a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
598 lines
18 KiB
Go
598 lines
18 KiB
Go
// Package savings persists cumulative token-savings metrics across server
|
|
// restarts. Every source-reading tool call feeds this store through the MCP
|
|
// server's tokenStats, so over time the numbers become a credible narrative:
|
|
// "Gortex saved N tokens / $X at model rate this month".
|
|
//
|
|
// Storage: the machine-global SQLite sidecar (~/.gortex/sidecar.sqlite —
|
|
// the same database that holds notes, memories, scopes, and notebooks).
|
|
// Every observation is one transaction (event row + totals upsert), so the
|
|
// ledger is durable at the call: a SIGKILLed server loses nothing, and
|
|
// multiple gortex processes write the same database safely through SQLite's
|
|
// WAL + busy-timeout. The flat-file era (savings.json cumulative totals +
|
|
// savings.jsonl event log under the cache dir) is imported once on open and
|
|
// the legacy files renamed to *.bak.
|
|
package savings
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/persistence"
|
|
"github.com/zzet/gortex/internal/platform"
|
|
)
|
|
|
|
// schemaVersion is the snapshot-shape version, kept for the JSON surface
|
|
// (graph_stats cumulative_savings, `gortex savings --json`) and for
|
|
// reading flat-file-era ledgers during legacy import.
|
|
const schemaVersion = 1
|
|
|
|
// Totals is the cumulative record for a single scope (top-level or per-repo).
|
|
type Totals struct {
|
|
TokensSaved int64 `json:"tokens_saved"`
|
|
TokensReturned int64 `json:"tokens_returned"`
|
|
CallsCounted int64 `json:"calls_counted"`
|
|
}
|
|
|
|
// File is the snapshot shape callers consume (graph_stats, the CLI) and
|
|
// the on-disk schema of the flat-file era — still parsed by the one-shot
|
|
// legacy import.
|
|
type File struct {
|
|
Version int `json:"version"`
|
|
FirstSeen time.Time `json:"first_seen"`
|
|
LastUpdated time.Time `json:"last_updated"`
|
|
Totals Totals `json:"totals"`
|
|
PerRepo map[string]*Totals `json:"per_repo,omitempty"`
|
|
PerLanguage map[string]*Totals `json:"per_language,omitempty"`
|
|
// DroppedObservations counts ledger writes this process discarded
|
|
// (accounting must never fail a tool call, but drops must not be
|
|
// invisible — a persistently failing ledger looks exactly like
|
|
// "nothing recorded" otherwise).
|
|
DroppedObservations int64 `json:"dropped_observations,omitempty"`
|
|
}
|
|
|
|
// Observation is one source-reading tool call to book.
|
|
type Observation struct {
|
|
Repo string
|
|
Language string
|
|
Tool string
|
|
SessionID string
|
|
// Model is the LLM model that drove the call when known (resolved by
|
|
// the recorder from the host's model hint); Client is the MCP client
|
|
// app from the initialize handshake. Both may be empty.
|
|
Model string
|
|
Client string
|
|
Returned int64
|
|
Saved int64
|
|
}
|
|
|
|
// Store is the token-savings ledger. All operations are safe for
|
|
// concurrent use. When opened with an empty path the store tracks
|
|
// in-memory only — the behaviour test fixtures and the eval servers
|
|
// rely on — and never touches disk.
|
|
//
|
|
// Write errors against the sidecar are intentionally not propagated to
|
|
// record() callers (accounting must never fail a tool call), but unlike
|
|
// the flat-file era there is no batching: an observation either commits
|
|
// durably or is dropped whole.
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
sc *persistence.SidecarStore
|
|
mem File // in-memory accumulation when sc == nil
|
|
memEvents []Event // in-memory event log when sc == nil
|
|
|
|
// dropped counts observations the sidecar refused (disk full,
|
|
// permissions, lock timeout). warnOnce emits a single stderr line
|
|
// the first time it happens so the failure is diagnosable without
|
|
// failing any tool call.
|
|
dropped atomic.Int64
|
|
warnOnce sync.Once
|
|
}
|
|
|
|
// DefaultDBPath returns the canonical savings ledger location: the
|
|
// machine-global sidecar database under the Gortex data dir.
|
|
func DefaultDBPath() string {
|
|
return persistence.DefaultSidecarPath(platform.DataDir())
|
|
}
|
|
|
|
// DefaultPath returns the flat-file era's savings.json location under the
|
|
// Gortex cache dir. The live ledger no longer writes it — the path is the
|
|
// default source for the one-shot legacy import (see Store.ImportLegacy).
|
|
//
|
|
// An absolute $XDG_CACHE_HOME is honoured; otherwise the location stays
|
|
// under os.UserCacheDir() — the historical default for this store, kept
|
|
// so an existing savings file is not orphaned. Returns an empty string
|
|
// when no cache dir can be resolved.
|
|
func DefaultPath() string {
|
|
if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) {
|
|
if base, err := os.UserCacheDir(); err != nil || base == "" {
|
|
return ""
|
|
}
|
|
}
|
|
return filepath.Join(platform.OSCacheDir(), "savings.json")
|
|
}
|
|
|
|
// DefaultEventsPath returns the flat-file era's savings.jsonl event-log
|
|
// path next to DefaultPath. Empty when the cache dir is unavailable.
|
|
func DefaultEventsPath() string {
|
|
p := DefaultPath()
|
|
if p == "" {
|
|
return ""
|
|
}
|
|
return EventsPathFor(p)
|
|
}
|
|
|
|
// EventsPathFor returns the JSONL event-log path that corresponds to a
|
|
// flat-file cumulative savings JSON path — `<dir>/savings.jsonl` alongside
|
|
// the JSON file. Empty when storePath is empty.
|
|
func EventsPathFor(storePath string) string {
|
|
if storePath == "" {
|
|
return ""
|
|
}
|
|
dir := filepath.Dir(storePath)
|
|
base := filepath.Base(storePath)
|
|
ext := filepath.Ext(base)
|
|
stem := base
|
|
if ext != "" {
|
|
stem = base[:len(base)-len(ext)]
|
|
}
|
|
return filepath.Join(dir, stem+".jsonl")
|
|
}
|
|
|
|
// Open opens the savings ledger inside the sidecar database at dbPath
|
|
// (creating tables as needed). An empty dbPath yields an in-memory-only
|
|
// store. The sidecar handle is process-shared: opening the same path the
|
|
// notes/memories managers use reuses their connection.
|
|
func Open(dbPath string) (*Store, error) {
|
|
s := &Store{}
|
|
s.mem = emptyFile()
|
|
if dbPath == "" {
|
|
return s, nil
|
|
}
|
|
sc, err := persistence.OpenSidecar(dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open savings ledger: %w", err)
|
|
}
|
|
s.sc = sc
|
|
return s, nil
|
|
}
|
|
|
|
func emptyFile() File {
|
|
return File{
|
|
Version: schemaVersion,
|
|
PerRepo: make(map[string]*Totals),
|
|
PerLanguage: make(map[string]*Totals),
|
|
}
|
|
}
|
|
|
|
// AddObservation books one source-reading tool call. Durable immediately
|
|
// when the store is sidecar-backed; in-memory otherwise.
|
|
func (s *Store) AddObservation(o Observation) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
if o.Saved < 0 {
|
|
o.Saved = 0
|
|
}
|
|
now := time.Now().UTC()
|
|
|
|
if s.sc != nil {
|
|
err := s.sc.AddSavingsObservation(persistence.SavingsEvent{
|
|
TS: now,
|
|
SessionID: o.SessionID,
|
|
Tool: o.Tool,
|
|
Repo: o.Repo,
|
|
Language: o.Language,
|
|
Model: o.Model,
|
|
Client: o.Client,
|
|
Returned: o.Returned,
|
|
Saved: o.Saved,
|
|
})
|
|
if err != nil {
|
|
s.dropped.Add(1)
|
|
s.warnOnce.Do(func() {
|
|
fmt.Fprintf(os.Stderr, "gortex: savings ledger write failed, observations will be dropped: %v\n", err)
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.mem.FirstSeen.IsZero() {
|
|
s.mem.FirstSeen = now
|
|
}
|
|
s.mem.LastUpdated = now
|
|
s.mem.Totals.TokensSaved += o.Saved
|
|
s.mem.Totals.TokensReturned += o.Returned
|
|
s.mem.Totals.CallsCounted++
|
|
addBucket := func(bucket map[string]*Totals, key string) {
|
|
if key == "" {
|
|
return
|
|
}
|
|
t := bucket[key]
|
|
if t == nil {
|
|
t = &Totals{}
|
|
bucket[key] = t
|
|
}
|
|
t.TokensSaved += o.Saved
|
|
t.TokensReturned += o.Returned
|
|
t.CallsCounted++
|
|
}
|
|
addBucket(s.mem.PerRepo, o.Repo)
|
|
addBucket(s.mem.PerLanguage, o.Language)
|
|
s.memEvents = append(s.memEvents, Event{
|
|
TS: now,
|
|
SessionID: o.SessionID,
|
|
Repo: o.Repo,
|
|
Language: o.Language,
|
|
Tool: o.Tool,
|
|
Model: o.Model,
|
|
Client: o.Client,
|
|
Returned: o.Returned,
|
|
Saved: o.Saved,
|
|
})
|
|
}
|
|
|
|
// Snapshot returns the current cumulative totals. Sidecar-backed stores
|
|
// read the live aggregates, so the snapshot reflects every writer process,
|
|
// not just this one. FirstSeen stays the zero time until something has
|
|
// actually been recorded — callers must not present it as "tracking since"
|
|
// when it is zero.
|
|
//
|
|
// On a read error the returned File is empty and the error is non-nil —
|
|
// callers that render the empty state must distinguish "nothing recorded"
|
|
// from "ledger unreadable".
|
|
func (s *Store) Snapshot() (File, error) {
|
|
if s == nil {
|
|
return emptyFile(), nil
|
|
}
|
|
if s.sc == nil {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cp := s.mem
|
|
cp.PerRepo = copyTotalsMap(s.mem.PerRepo)
|
|
cp.PerLanguage = copyTotalsMap(s.mem.PerLanguage)
|
|
cp.DroppedObservations = s.dropped.Load()
|
|
return cp, nil
|
|
}
|
|
|
|
buckets, firstSeen, lastUpdated, err := s.sc.SavingsTotals()
|
|
if err != nil {
|
|
return emptyFile(), fmt.Errorf("savings totals read: %w", err)
|
|
}
|
|
out := emptyFile()
|
|
out.FirstSeen = firstSeen
|
|
out.LastUpdated = lastUpdated
|
|
out.DroppedObservations = s.dropped.Load()
|
|
for bucket, r := range buckets {
|
|
t := &Totals{TokensSaved: r.Saved, TokensReturned: r.Returned, CallsCounted: r.Calls}
|
|
switch {
|
|
case bucket == "":
|
|
out.Totals = *t
|
|
case len(bucket) > 5 && bucket[:5] == "repo:":
|
|
out.PerRepo[bucket[5:]] = t
|
|
case len(bucket) > 5 && bucket[:5] == "lang:":
|
|
out.PerLanguage[bucket[5:]] = t
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ToolTotals returns the per-tool aggregate over events with TS >= since
|
|
// (zero = all time), sorted by tokens saved descending. Sidecar-backed
|
|
// stores aggregate in SQL so the dashboard never materializes the full
|
|
// event history just to render a breakdown.
|
|
func (s *Store) ToolTotals(since time.Time) ([]ToolTotal, error) {
|
|
if s == nil {
|
|
return nil, nil
|
|
}
|
|
if s.sc == nil {
|
|
s.mu.Lock()
|
|
events := FilterSince(s.memEvents, since)
|
|
s.mu.Unlock()
|
|
_, per := AggregateByTool(events)
|
|
return per, nil
|
|
}
|
|
rows, err := s.sc.SavingsToolTotals(since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]ToolTotal, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, ToolTotal{Tool: r.Tool, Totals: Totals{
|
|
TokensSaved: r.Saved,
|
|
TokensReturned: r.Returned,
|
|
CallsCounted: r.Calls,
|
|
}})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Close releases the underlying sidecar handle (dropping it from the
|
|
// process-wide cache). Safe on nil and in-memory stores. The daemon
|
|
// keeps its store open for the process lifetime; tests and one-shot
|
|
// CLI paths should Close when done.
|
|
func (s *Store) Close() error {
|
|
if s == nil || s.sc == nil {
|
|
return nil
|
|
}
|
|
return s.sc.Close()
|
|
}
|
|
|
|
// EventsSince returns the per-call events with TS >= since, oldest first.
|
|
// since=zero returns the full event history.
|
|
func (s *Store) EventsSince(since time.Time) ([]Event, error) {
|
|
if s == nil {
|
|
return nil, nil
|
|
}
|
|
if s.sc == nil {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return FilterSince(s.memEvents, since), nil
|
|
}
|
|
rows, err := s.sc.SavingsEventsSince(since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]Event, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, Event{
|
|
TS: r.TS,
|
|
SessionID: r.SessionID,
|
|
Repo: r.Repo,
|
|
Language: r.Language,
|
|
Tool: r.Tool,
|
|
Model: r.Model,
|
|
Client: r.Client,
|
|
Returned: r.Returned,
|
|
Saved: r.Saved,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ModelTotals returns the per-model aggregate over events with TS >= since
|
|
// (zero = all time), tokens-saved descending. Only calls with a resolved
|
|
// model are counted — clients that don't surface their model are absent.
|
|
func (s *Store) ModelTotals(since time.Time) ([]DimTotal, error) {
|
|
return s.dimTotals(since, AggregateByModel, func(t time.Time) ([]persistence.SavingsDimRow, error) {
|
|
return s.sc.SavingsModelTotals(t)
|
|
})
|
|
}
|
|
|
|
// ClientTotals returns the per-MCP-client aggregate over events with
|
|
// TS >= since (zero = all time), tokens-saved descending.
|
|
func (s *Store) ClientTotals(since time.Time) ([]DimTotal, error) {
|
|
return s.dimTotals(since, AggregateByClient, func(t time.Time) ([]persistence.SavingsDimRow, error) {
|
|
return s.sc.SavingsClientTotals(t)
|
|
})
|
|
}
|
|
|
|
// dimTotals is the shared model/client breakdown: aggregate in SQL when
|
|
// sidecar-backed, fold the in-memory event log otherwise.
|
|
func (s *Store) dimTotals(since time.Time, memAgg func([]Event) []DimTotal, scQuery func(time.Time) ([]persistence.SavingsDimRow, error)) ([]DimTotal, error) {
|
|
if s == nil {
|
|
return nil, nil
|
|
}
|
|
if s.sc == nil {
|
|
s.mu.Lock()
|
|
events := FilterSince(s.memEvents, since)
|
|
s.mu.Unlock()
|
|
return memAgg(events), nil
|
|
}
|
|
rows, err := scQuery(since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]DimTotal, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, DimTotal{Name: r.Name, Totals: Totals{
|
|
TokensSaved: r.Saved,
|
|
TokensReturned: r.Returned,
|
|
CallsCounted: r.Calls,
|
|
}})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Flush is a no-op kept for call-site compatibility: every observation
|
|
// commits durably as it is recorded, so there is nothing to flush.
|
|
func (s *Store) Flush() error { return nil }
|
|
|
|
// Reset wipes all cumulative data and events. Used by
|
|
// `gortex savings --reset`. The legacy-import mark survives, so flat
|
|
// files already imported (and renamed *.bak) are not re-imported.
|
|
func (s *Store) Reset() error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
s.mem = emptyFile()
|
|
s.memEvents = nil
|
|
s.mu.Unlock()
|
|
if s.sc == nil {
|
|
return nil
|
|
}
|
|
return s.sc.ResetSavings()
|
|
}
|
|
|
|
// ImportLegacy imports the flat-file era's ledger — the cumulative
|
|
// savings.json at jsonPath and its sibling savings.jsonl — into the
|
|
// sidecar, then renames both files to *.bak. Idempotent: a migration
|
|
// mark guarantees the import runs once per sidecar, including when
|
|
// there was nothing to import. In-memory stores skip the import.
|
|
//
|
|
// The cumulative file was flush-batched while the event log appended
|
|
// eagerly, so a SIGKILL-era ledger can carry fewer totals than its own
|
|
// events; totals are floored per bucket and per field at what the
|
|
// events reconstruct, or "Last 7 days" could exceed "All time".
|
|
func (s *Store) ImportLegacy(jsonPath string) error {
|
|
if s == nil || s.sc == nil || jsonPath == "" {
|
|
return nil
|
|
}
|
|
eventsPath := EventsPathFor(jsonPath)
|
|
if s.sc.SavingsLegacyImportDone() {
|
|
// Self-heal lingering files: a crash between the import commit
|
|
// and the renames — or an old-version daemon recreating the
|
|
// files after migration — leaves live-looking flat files no
|
|
// future open would ever touch. Their content is intentionally
|
|
// not imported (the mark owns that decision); sweep them aside.
|
|
_ = renameLegacySavings(jsonPath)
|
|
_ = renameLegacySavings(eventsPath)
|
|
return nil
|
|
}
|
|
|
|
legacy, err := readLegacyFile(jsonPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// A hard read error must abort without marking or renaming so the
|
|
// import retries next open — importing a truncated event log and
|
|
// renaming the original away would make the loss permanent.
|
|
events, err := LoadEvents(eventsPath, time.Time{})
|
|
if err != nil {
|
|
return fmt.Errorf("read legacy savings events: %w", err)
|
|
}
|
|
|
|
// Rebuild totals from the events unconditionally...
|
|
rebuilt := make(map[string]persistence.SavingsTotalsRow)
|
|
for _, ev := range events {
|
|
bump := func(bucket string) {
|
|
r := rebuilt[bucket]
|
|
r.Saved += ev.Saved
|
|
r.Returned += ev.Returned
|
|
r.Calls++
|
|
rebuilt[bucket] = r
|
|
}
|
|
bump("")
|
|
if ev.Repo != "" {
|
|
bump("repo:" + ev.Repo)
|
|
}
|
|
if ev.Language != "" {
|
|
bump("lang:" + ev.Language)
|
|
}
|
|
}
|
|
|
|
// ...then overlay the cumulative file's (possibly larger, possibly
|
|
// flush-lagged) numbers, taking the per-field max of the two views.
|
|
buckets := rebuilt
|
|
var firstSeen, lastUpdated time.Time
|
|
if legacy != nil {
|
|
overlay := func(bucket string, t Totals) {
|
|
r := buckets[bucket]
|
|
r.Saved = max(r.Saved, t.TokensSaved)
|
|
r.Returned = max(r.Returned, t.TokensReturned)
|
|
r.Calls = max(r.Calls, t.CallsCounted)
|
|
buckets[bucket] = r
|
|
}
|
|
overlay("", legacy.Totals)
|
|
for k, v := range legacy.PerRepo {
|
|
overlay("repo:"+k, *v)
|
|
}
|
|
for k, v := range legacy.PerLanguage {
|
|
overlay("lang:"+k, *v)
|
|
}
|
|
firstSeen, lastUpdated = legacy.FirstSeen, legacy.LastUpdated
|
|
}
|
|
if len(events) > 0 {
|
|
if firstSeen.IsZero() || events[0].TS.Before(firstSeen) {
|
|
firstSeen = events[0].TS
|
|
}
|
|
if last := events[len(events)-1].TS; last.After(lastUpdated) {
|
|
lastUpdated = last
|
|
}
|
|
}
|
|
|
|
pevents := make([]persistence.SavingsEvent, 0, len(events))
|
|
for _, ev := range events {
|
|
pevents = append(pevents, persistence.SavingsEvent{
|
|
TS: ev.TS,
|
|
SessionID: ev.SessionID,
|
|
Tool: ev.Tool,
|
|
Repo: ev.Repo,
|
|
Language: ev.Language,
|
|
Returned: ev.Returned,
|
|
Saved: ev.Saved,
|
|
})
|
|
}
|
|
if err := s.sc.ImportLegacySavings(buckets, firstSeen, lastUpdated, pevents); err != nil {
|
|
return err
|
|
}
|
|
return errors.Join(
|
|
renameLegacySavings(jsonPath),
|
|
renameLegacySavings(eventsPath),
|
|
)
|
|
}
|
|
|
|
// renameLegacySavings moves an already-imported flat file aside to
|
|
// <file>.bak. Never deletes; a missing file is fine. A rename failure
|
|
// is reported so the caller can surface it — silently leaving the file
|
|
// in place makes it look live when it no longer is.
|
|
func renameLegacySavings(path string) error {
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
return nil
|
|
}
|
|
if err := os.Rename(path, path+".bak"); err != nil {
|
|
return fmt.Errorf("rename legacy savings file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readLegacyFile loads a flat-file era savings.json. Returns (nil, nil)
|
|
// when the file doesn't exist; corrupt or version-mismatched files are
|
|
// skipped the same way (the import has nothing trustworthy to carry over).
|
|
func readLegacyFile(path string) (*File, error) {
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read legacy savings: %w", err)
|
|
}
|
|
var loaded File
|
|
if jerr := json.Unmarshal(data, &loaded); jerr != nil || loaded.Version != schemaVersion {
|
|
return nil, nil
|
|
}
|
|
if loaded.PerRepo == nil {
|
|
loaded.PerRepo = make(map[string]*Totals)
|
|
}
|
|
if loaded.PerLanguage == nil {
|
|
loaded.PerLanguage = make(map[string]*Totals)
|
|
}
|
|
// JSON null map values unmarshal to nil pointers — drop them here,
|
|
// the single choke point, so no consumer dereferences one.
|
|
for k, v := range loaded.PerRepo {
|
|
if v == nil {
|
|
delete(loaded.PerRepo, k)
|
|
}
|
|
}
|
|
for k, v := range loaded.PerLanguage {
|
|
if v == nil {
|
|
delete(loaded.PerLanguage, k)
|
|
}
|
|
}
|
|
return &loaded, nil
|
|
}
|
|
|
|
// copyTotalsMap returns a deep copy of a name → Totals map.
|
|
func copyTotalsMap(src map[string]*Totals) map[string]*Totals {
|
|
if src == nil {
|
|
return make(map[string]*Totals)
|
|
}
|
|
dst := make(map[string]*Totals, len(src))
|
|
for k, v := range src {
|
|
cp := *v
|
|
dst[k] = &cp
|
|
}
|
|
return dst
|
|
}
|