diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 0f3524b..71001d7 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -22,131 +22,143 @@ var skipDirs = map[string]bool{ ".vscode": true, } -type Entry struct { - Version int `json:"version"` - CreatedAt time.Time `json:"created_at"` - SourcePath string `json:"source_path"` - SessionID string `json:"session_id,omitempty"` - Data format.Output `json:"data"` -} - -// SameSession reports whether this entry was written by the same session as -// the current process (same terminal, editor, or CI job). -func (e *Entry) SameSession(c *Config) bool { - return e.SessionID != "" && e.SessionID == c.SessionID -} - // Key returns a cache key derived from the absolute path (first 16 hex chars of SHA256). func Key(absPath string) string { h := sha256.Sum256([]byte(absPath)) return hex.EncodeToString(h[:])[:16] } -// Write writes the output data to the cache for the given absolute path. +// Write writes the output data to the cache for the given absolute path and +// updates the manifest accordingly. func (c *Config) Write(absPath string, data *format.Output) error { if err := os.MkdirAll(c.Cache, 0700); err != nil { return fmt.Errorf("failed to create cache directory: %w", err) } - entry := Entry{ - Version: 1, - CreatedAt: time.Now(), - SourcePath: absPath, - SessionID: c.SessionID, - Data: *data, - } + key := Key(absPath) - b, err := json.Marshal(entry) + b, err := json.Marshal(data) if err != nil { - return fmt.Errorf("failed to marshal cache entry: %w", err) + return fmt.Errorf("failed to marshal cache data: %w", err) } - path := filepath.Join(c.Cache, Key(absPath)+".json") + path := filepath.Join(c.Cache, key+".json") if err := os.WriteFile(path, b, 0600); err != nil { return fmt.Errorf("failed to write cache file: %w", err) } + m, err := c.LoadManifest() + if err != nil { + return fmt.Errorf("failed to load manifest: %w", err) + } + + m.Entries[key] = ManifestEntry{ + Version: 1, + CreatedAt: time.Now(), + SourcePath: absPath, + } + + if err := c.SaveManifest(m); err != nil { + return fmt.Errorf("failed to save manifest: %w", err) + } + return nil } -// Read reads a cached entry for the given absolute path, returning an error if missing or expired. +// ForPath returns cached results for the given absolute path. // -// allowChanged can be set to true to return the entry even if the files have changed (so the cache is stale). This is -// useful for tracking changes in the results between runs, but should be set to false when inspecting results to make -// sure results are up-to-date. -func (c *Config) Read(absPath string, allowChanged bool) (*Entry, error) { - path := filepath.Join(c.Cache, Key(absPath)+".json") - return readEntryFile(path, c.TTL, allowChanged) +// The entry must be within TTL and the source files must not have been modified +// since the entry was created. +func (c *Config) ForPath(absPath string) (*format.Output, error) { + m, err := c.LoadManifest() + if err != nil { + return nil, fmt.Errorf("failed to load manifest: %w", err) + } + + key := Key(absPath) + + e, ok := m.Entries[key] + if !ok || c.TTL <= 0 || time.Since(e.CreatedAt) > c.TTL { + return nil, fmt.Errorf("no cached results found") + } + + if e.SourcePath != "" { + if changed := newerFile(e.SourcePath, e.CreatedAt); changed != "" { + return nil, fmt.Errorf("cached results stale (source file changed: %s)", changed) + } + } + + return readDataFile(c.Cache, key) } -// ReadLatest reads the most recently modified cache entry, returning an error if missing or expired. -func (c *Config) ReadLatest() (*Entry, error) { - entries, err := os.ReadDir(c.Cache) +// Latest returns the most recent cached result within TTL. +// +// When allowStale is false, the entry is also rejected if any source file has +// been modified since the entry was created. Set allowStale to true when +// reading for comparison (e.g. diffing against a prior run). +func (c *Config) Latest(allowStale bool) (*format.Output, error) { + m, err := c.LoadManifest() if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("no cached results found") - } - return nil, fmt.Errorf("failed to read cache directory: %w", err) + return nil, fmt.Errorf("failed to load manifest: %w", err) } - var newest os.DirEntry + var newestKey string var newestTime time.Time - for _, e := range entries { - if e.IsDir() || filepath.Ext(e.Name()) != ".json" { + for key, e := range m.Entries { + if c.TTL > 0 && time.Since(e.CreatedAt) > c.TTL { continue } - info, err := e.Info() - if err != nil { - continue - } - if newest == nil || info.ModTime().After(newestTime) { - newest = e - newestTime = info.ModTime() + if newestKey == "" || e.CreatedAt.After(newestTime) { + newestKey = key + newestTime = e.CreatedAt } } - if newest == nil { + if newestKey == "" { return nil, fmt.Errorf("no cached results found") } - return readEntryFile(filepath.Join(c.Cache, newest.Name()), c.TTL, false) + if !allowStale { + best := m.Entries[newestKey] + if best.SourcePath != "" { + if changed := newerFile(best.SourcePath, best.CreatedAt); changed != "" { + return nil, fmt.Errorf("cached results stale (source file changed: %s)", changed) + } + } + } + + return readDataFile(c.Cache, newestKey) } -func readEntryFile(path string, ttl time.Duration, allowChanged bool) (*Entry, error) { +func readDataFile(cacheDir, key string) (*format.Output, error) { + path := filepath.Join(cacheDir, key+".json") + // nolint:gosec // G304: Cache path is derived internally. - b, err := os.ReadFile(path) + f, err := os.Open(path) if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("no cached results found") - } return nil, fmt.Errorf("failed to read cache file: %w", err) } + defer func() { + _ = f.Close() + }() - var entry Entry - if err := json.Unmarshal(b, &entry); err != nil { - return nil, fmt.Errorf("failed to decode cache entry: %w", err) - } - - if ttl > 0 && time.Since(entry.CreatedAt) > ttl { - return nil, fmt.Errorf("cached results expired") - } - - if entry.SourcePath != "" && !allowChanged { - if changed := newerFile(entry.SourcePath, entry.CreatedAt); changed != "" { - return nil, fmt.Errorf("cached results stale (source file changed: %s)", changed) - } + var output format.Output + if err := json.NewDecoder(f).Decode(&output); err != nil { + return nil, fmt.Errorf("failed to decode cache data: %w", err) } - return &entry, nil + return &output, nil } -// hasNewerFile walks the source directory and returns true as soon as it finds -// any file modified after the given time. Skips known heavy directories. +// newerFile walks root looking for any file modified after since. It returns +// the path of the first such file, or "" if none are found. If root does not +// exist (e.g. a temporary directory that has been cleaned up), the walk +// silently returns "" so the cached entry is still considered fresh. func newerFile(root string, since time.Time) string { var changed string _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { - return nil // best-effort, skip errors + return nil } if d.IsDir() { if skipDirs[d.Name()] { @@ -181,4 +193,4 @@ func ReadFile(path string) (*format.Output, error) { } return &output, nil -} +} \ No newline at end of file diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 7045034..24d6cec 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -55,7 +55,9 @@ func testConfig(t *testing.T) *Config { return c } -func TestWriteThenRead(t *testing.T) { +// ForPath tests + +func TestForPath(t *testing.T) { c := testConfig(t) data := testOutput() absPath := t.TempDir() @@ -63,17 +65,15 @@ func TestWriteThenRead(t *testing.T) { err := c.Write(absPath, &data) require.NoError(t, err) - entry, err := c.Read(absPath, false) + output, err := c.ForPath(absPath) require.NoError(t, err) - assert.Equal(t, 1, entry.Version) - assert.Equal(t, absPath, entry.SourcePath) - assert.Equal(t, "USD", entry.Data.Currency) - assert.Len(t, entry.Data.Projects, 1) - assert.Equal(t, "test-project", entry.Data.Projects[0].ProjectName) + assert.Equal(t, "USD", output.Currency) + assert.Len(t, output.Projects, 1) + assert.Equal(t, "test-project", output.Projects[0].ProjectName) } -func TestReadExpired(t *testing.T) { +func TestForPathExpired(t *testing.T) { c := testConfig(t) c.TTL = time.Nanosecond data := testOutput() @@ -82,39 +82,21 @@ func TestReadExpired(t *testing.T) { err := c.Write(absPath, &data) require.NoError(t, err) - // Give it a moment to expire time.Sleep(2 * time.Millisecond) - _, err = c.Read(absPath, false) + _, err = c.ForPath(absPath) assert.Error(t, err) - assert.Contains(t, err.Error(), "expired") + assert.Contains(t, err.Error(), "no cached results found") } -func TestReadMissing(t *testing.T) { +func TestForPathMissing(t *testing.T) { c := testConfig(t) - _, err := c.Read("/nonexistent/path", false) + _, err := c.ForPath("/nonexistent/path") assert.Error(t, err) assert.Contains(t, err.Error(), "no cached results found") } -func TestReadLatest(t *testing.T) { - c := testConfig(t) - data := testOutput() - - err := c.Write("/first/project", &data) - require.NoError(t, err) - time.Sleep(10 * time.Millisecond) - - data.Projects[0].ProjectName = "second-project" - err = c.Write("/second/project", &data) - require.NoError(t, err) - - entry, err := c.ReadLatest() - require.NoError(t, err) - assert.Equal(t, "second-project", entry.Data.Projects[0].ProjectName) -} - -func TestReadStaleSourceFiles(t *testing.T) { +func TestForPathStaleSourceFiles(t *testing.T) { c := testConfig(t) sourceDir := t.TempDir() data := testOutput() @@ -122,21 +104,20 @@ func TestReadStaleSourceFiles(t *testing.T) { err := c.Write(sourceDir, &data) require.NoError(t, err) - // Verify cache is valid before modification - _, err = c.Read(sourceDir, false) + _, err = c.ForPath(sourceDir) require.NoError(t, err) - // Create a file in the source dir newer than the cache + // Create a file in the source dir newer than the cache. time.Sleep(10 * time.Millisecond) err = os.WriteFile(filepath.Join(sourceDir, "main.tf"), []byte("resource {}"), 0600) require.NoError(t, err) - _, err = c.Read(sourceDir, false) + _, err = c.ForPath(sourceDir) assert.Error(t, err) assert.Contains(t, err.Error(), "stale") } -func TestReadSkipsHeavyDirs(t *testing.T) { +func TestForPathSkipsHeavyDirs(t *testing.T) { c := testConfig(t) sourceDir := t.TempDir() data := testOutput() @@ -144,19 +125,19 @@ func TestReadSkipsHeavyDirs(t *testing.T) { err := c.Write(sourceDir, &data) require.NoError(t, err) - // Create a newer file inside .terraform — should be skipped + // Create a newer file inside .terraform — should be skipped. time.Sleep(10 * time.Millisecond) tfDir := filepath.Join(sourceDir, ".terraform") require.NoError(t, os.MkdirAll(tfDir, 0700)) err = os.WriteFile(filepath.Join(tfDir, "plugin.bin"), []byte("binary"), 0600) require.NoError(t, err) - entry, err := c.Read(sourceDir, false) + output, err := c.ForPath(sourceDir) require.NoError(t, err) - assert.Equal(t, "USD", entry.Data.Currency) + assert.Equal(t, "USD", output.Currency) } -func TestSameSessionFromSameProcess(t *testing.T) { +func TestForPathWrongPath(t *testing.T) { c := testConfig(t) data := testOutput() absPath := t.TempDir() @@ -164,46 +145,96 @@ func TestSameSessionFromSameProcess(t *testing.T) { err := c.Write(absPath, &data) require.NoError(t, err) - entry, err := c.Read(absPath, false) + _, err = c.ForPath("/completely/different/path") + assert.Error(t, err) +} + +// Latest tests + +func TestLatest(t *testing.T) { + c := testConfig(t) + data := testOutput() + + err := c.Write("/first/project", &data) + require.NoError(t, err) + time.Sleep(10 * time.Millisecond) + + data.Projects[0].ProjectName = "second-project" + err = c.Write("/second/project", &data) require.NoError(t, err) - assert.NotEmpty(t, entry.SessionID) - assert.True(t, entry.SameSession(c), "entry written by same config should be same session") + output, err := c.Latest(false) + require.NoError(t, err) + assert.Equal(t, "second-project", output.Projects[0].ProjectName) } -func TestSameSessionWithExplicitID(t *testing.T) { +func TestLatestExpired(t *testing.T) { c := testConfig(t) - c.SessionID = "test-session-123" + c.TTL = time.Nanosecond data := testOutput() - absPath := t.TempDir() - err := c.Write(absPath, &data) + err := c.Write("/test/project", &data) require.NoError(t, err) - entry, err := c.Read(absPath, false) - require.NoError(t, err) + time.Sleep(2 * time.Millisecond) - assert.Equal(t, "test-session-123", entry.SessionID) - assert.True(t, entry.SameSession(c)) + _, err = c.Latest(false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no cached results found") +} + +func TestLatestEmpty(t *testing.T) { + c := testConfig(t) + + _, err := c.Latest(false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no cached results found") } -func TestDifferentSession(t *testing.T) { +func TestLatestStaleSourceFiles(t *testing.T) { c := testConfig(t) - c.SessionID = "session-A" + sourceDir := t.TempDir() data := testOutput() - absPath := t.TempDir() - err := c.Write(absPath, &data) + err := c.Write(sourceDir, &data) require.NoError(t, err) - c.SessionID = "session-B" - entry, err := c.Read(absPath, false) + // Create a file in the source dir newer than the cache. + time.Sleep(10 * time.Millisecond) + err = os.WriteFile(filepath.Join(sourceDir, "main.tf"), []byte("resource {}"), 0600) require.NoError(t, err) - assert.Equal(t, "session-A", entry.SessionID) - assert.False(t, entry.SameSession(c), "different session IDs should not match") + // Without allowStale, should be rejected. + _, err = c.Latest(false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "stale") + + // With allowStale, should still return the data. + output, err := c.Latest(true) + require.NoError(t, err) + assert.Equal(t, "USD", output.Currency) } +func TestLatestDeletedSourceDir(t *testing.T) { + c := testConfig(t) + sourceDir := t.TempDir() + data := testOutput() + + err := c.Write(sourceDir, &data) + require.NoError(t, err) + + // Remove the source directory (simulates price command's temp dir cleanup). + require.NoError(t, os.RemoveAll(sourceDir)) + + // Should still return the cached data since a missing directory is not + // considered stale. + output, err := c.Latest(false) + require.NoError(t, err) + assert.Equal(t, "USD", output.Currency) +} + +// ReadFile tests + func TestReadFile(t *testing.T) { data := testOutput() b, err := json.Marshal(data) @@ -217,4 +248,4 @@ func TestReadFile(t *testing.T) { require.NoError(t, err) assert.Equal(t, "USD", output.Currency) assert.Len(t, output.Projects, 1) -} +} \ No newline at end of file diff --git a/internal/cache/config.go b/internal/cache/config.go index bc4ea86..82cc00f 100644 --- a/internal/cache/config.go +++ b/internal/cache/config.go @@ -1,20 +1,11 @@ package cache import ( - "fmt" - "math" "os" "path/filepath" "time" - "github.com/infracost/cli/internal/api/events" - config "github.com/infracost/cli/internal/config/process" "github.com/infracost/cli/internal/logging" - "github.com/shirou/gopsutil/process" -) - -var ( - _ config.Processor = (*Config)(nil) ) type Config struct { @@ -24,9 +15,8 @@ type Config struct { // TTL is how long cached results remain valid. TTL time.Duration `env:"INFRACOST_CLI_CACHE_TTL" default:"1h"` - // SessionID identifies the current session (terminal, editor, CI job). - // Falls back to the parent process ID if not set. - SessionID string `env:"INFRACOST_SESSION_ID"` + // manifest is the in-memory manifest, lazily loaded on first access. + manifest *Manifest } func (c *Config) Process() { @@ -36,10 +26,6 @@ func (c *Config) Process() { if c.TTL == 0 { c.TTL = time.Hour } - if c.SessionID == "" { - c.SessionID = getSessionID() - } - events.RegisterMetadata("session", c.SessionID) } func defaultCachePath() string { @@ -57,22 +43,3 @@ func defaultCachePath() string { logging.WithError(err).Msg("failed to load user home dir, falling back to current directory") return filepath.Join(".infracost", "cache") } - -func getSessionID() string { - ppid := os.Getppid() - if ppid > math.MaxInt32 { - return fmt.Sprintf("%d", ppid) - } - - process, err := process.NewProcess(int32(ppid)) //nolint:gosec // guarded by MaxInt32 check above - if err != nil { - return fmt.Sprintf("%d", ppid) - } - - createTime, err := process.CreateTime() - if err != nil { - return fmt.Sprintf("%d", ppid) - } - - return fmt.Sprintf("%d-%d", ppid, createTime) -} diff --git a/internal/cache/manifest.go b/internal/cache/manifest.go new file mode 100644 index 0000000..5b6f81b --- /dev/null +++ b/internal/cache/manifest.go @@ -0,0 +1,71 @@ +package cache + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" +) + +// Manifest holds the index of all cache entries. +type Manifest struct { + // Entries is keyed by the cache key (hash of the absolute source path). + Entries map[string]ManifestEntry `json:"entries"` +} + +// ManifestEntry holds metadata for a single cached result. +type ManifestEntry struct { + // Version is the schema version of the cache entry. + Version int + // CreatedAt is when this entry was written. + CreatedAt time.Time + // SourcePath is the absolute path of the directory that was scanned. + SourcePath string +} + +func (c *Config) LoadManifest() (*Manifest, error) { + if c.manifest != nil { + // just load the manifest once + return c.manifest, nil + } + + path := filepath.Join(c.Cache, "manifest.json") + + c.manifest = &Manifest{ + Entries: make(map[string]ManifestEntry), + } + + // nolint:gosec // G304: Cache path is derived internally. + f, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return c.manifest, nil + } + return nil, err + } + defer func() { + _ = f.Close() + }() + + if err := json.NewDecoder(f).Decode(c.manifest); err != nil { + return nil, err + } + + return c.manifest, nil +} + +func (c *Config) SaveManifest(m *Manifest) error { + path := filepath.Join(c.Cache, "manifest.json") + + // nolint:gosec // G304: Cache path is derived internally. + f, err := os.Create(path) + if err != nil { + return err + } + defer func() { + _ = f.Close() + }() + + return json.NewEncoder(f).Encode(m) +} \ No newline at end of file diff --git a/internal/cache/manifest_test.go b/internal/cache/manifest_test.go new file mode 100644 index 0000000..a1b0614 --- /dev/null +++ b/internal/cache/manifest_test.go @@ -0,0 +1,72 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadManifestMissing(t *testing.T) { + c := testConfig(t) + + m, err := c.LoadManifest() + require.NoError(t, err) + assert.Empty(t, m.Entries) +} + +func TestLoadManifestCached(t *testing.T) { + c := testConfig(t) + + m1, err := c.LoadManifest() + require.NoError(t, err) + + m2, err := c.LoadManifest() + require.NoError(t, err) + + assert.Same(t, m1, m2, "LoadManifest should return the same pointer on subsequent calls") +} + +func TestSaveAndLoadManifest(t *testing.T) { + c := testConfig(t) + require.NoError(t, os.MkdirAll(c.Cache, 0700)) + + m := &Manifest{ + Entries: map[string]ManifestEntry{ + "abc123": { + Version: 1, + CreatedAt: time.Now(), + SourcePath: "/test/path", + }, + }, + } + + err := c.SaveManifest(m) + require.NoError(t, err) + + // Load into a fresh config to verify persistence. + c2 := &Config{Cache: c.Cache} + loaded, err := c2.LoadManifest() + require.NoError(t, err) + + assert.Len(t, loaded.Entries, 1) + assert.Equal(t, "/test/path", loaded.Entries["abc123"].SourcePath) +} + +func TestSaveManifestWritesToDisk(t *testing.T) { + c := testConfig(t) + require.NoError(t, os.MkdirAll(c.Cache, 0700)) + + m := &Manifest{ + Entries: map[string]ManifestEntry{}, + } + + err := c.SaveManifest(m) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(c.Cache, "manifest.json")) + require.NoError(t, err, "manifest.json should exist on disk after save") +} \ No newline at end of file diff --git a/internal/cmds/inspect.go b/internal/cmds/inspect.go index 1e2bd6e..ca750df 100644 --- a/internal/cmds/inspect.go +++ b/internal/cmds/inspect.go @@ -7,6 +7,7 @@ import ( "github.com/infracost/cli/internal/cache" "github.com/infracost/cli/internal/config" + "github.com/infracost/cli/internal/format" "github.com/infracost/cli/internal/inspect" "github.com/spf13/cobra" ) @@ -19,7 +20,7 @@ func Inspect(cfg *config.Config) *cobra.Command { Use: "inspect [path]", Short: "Inspect cached analysis results with filtering and grouping", RunE: func(_ *cobra.Command, args []string) error { - var data *cache.Entry + var data *format.Output var err error if file != "" { @@ -35,18 +36,18 @@ func Inspect(cfg *config.Config) *cobra.Command { if err != nil { return fmt.Errorf("failed to resolve path: %w", err) } - data, err = cfg.Cache.Read(absPath, false) + data, err = cfg.Cache.ForPath(absPath) if err != nil { return fmt.Errorf("no cached results found, run 'infracost scan %s' first", args[0]) } } else { - data, err = cfg.Cache.ReadLatest() + data, err = cfg.Cache.Latest(false) if err != nil { return fmt.Errorf("no cached results found, run 'infracost scan ' first") } } - return inspect.Run(os.Stdout, &data.Data, opts) + return inspect.Run(os.Stdout, data, opts) }, } diff --git a/internal/cmds/price.go b/internal/cmds/price.go index 406aa06..a93e20f 100644 --- a/internal/cmds/price.go +++ b/internal/cmds/price.go @@ -75,10 +75,12 @@ func Price(cfg *config.Config) *cobra.Command { eventsClient := cfg.Events.Client(api.Client(cmd.Context(), source, cfg.OrgID)) - // Diff against the previous cached result from the same session to detect - // fixed policy violations. - if prev, err := cfg.Cache.Read(dir, true); err == nil && prev.SameSession(&cfg.Cache) { - output.TrackDiff(cmd.Context(), eventsClient, &prev.Data) + // Diff against the previous cached result to detect fixed policy violations. + if prev, err := cfg.Cache.Latest(true); err != nil { + logging.Infof("could not load previous run data: %v", err) + } else { + logging.Infof("found previous run data in cache") + output.TrackDiff(cmd.Context(), eventsClient, prev) } if err := cfg.Cache.Write(dir, &output); err != nil { diff --git a/internal/cmds/scan.go b/internal/cmds/scan.go index ee93077..a1fc17d 100644 --- a/internal/cmds/scan.go +++ b/internal/cmds/scan.go @@ -76,10 +76,12 @@ func Scan(cfg *config.Config) *cobra.Command { eventsClient := cfg.Events.Client(api.Client(cmd.Context(), source, cfg.OrgID)) - // Diff against the previous cached result from the same session to detect - // fixed policy violations. - if prev, err := cfg.Cache.Read(absoluteDirectory, true); err == nil && prev.SameSession(&cfg.Cache) { - output.TrackDiff(cmd.Context(), eventsClient, &prev.Data) + // Diff against the previous cached result to detect fixed policy violations. + if prev, err := cfg.Cache.Latest(true); err != nil { + logging.Infof("could not load previous run data: %v", err) + } else { + logging.Infof("found previous run data in cache") + output.TrackDiff(cmd.Context(), eventsClient, prev) } if err := cfg.Cache.Write(absoluteDirectory, &output); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 7ba20ed..86400b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,10 @@ type Config struct { // ClaudePath is the path to the Claude CLI binary. Defaults to "claude" (looked up on PATH). ClaudePath string `env:"INFRACOST_CLI_CLAUDE_PATH" flag:"claude-path;hidden" usage:"Path to the Claude CLI binary"` + // Logging contains the configuration for logging. + // keep logging above other structs, so it gets processed first and others can log in their process functions. + Logging logging.Config + // Dashboard contains the configuration for the dashboard API. Dashboard dashboard.Config @@ -44,9 +48,6 @@ type Config struct { // Auth contains the configuration for authenticating with Infracost. Auth auth.Config - // Logging contains the configuration for logging. - Logging logging.Config - // Plugins contains the configuration for plugins. Plugins plugins.Config