diff options
Diffstat (limited to 'internal/hexaicli')
| -rw-r--r-- | internal/hexaicli/cache.go | 57 | ||||
| -rw-r--r-- | internal/hexaicli/cache_test.go | 25 | ||||
| -rw-r--r-- | internal/hexaicli/editor_integration_test.go | 33 | ||||
| -rw-r--r-- | internal/hexaicli/run.go | 8 | ||||
| -rw-r--r-- | internal/hexaicli/run_editor_behavior_test.go | 17 | ||||
| -rw-r--r-- | internal/hexaicli/runner.go | 23 | ||||
| -rw-r--r-- | internal/hexaicli/runner_test.go | 15 |
7 files changed, 103 insertions, 75 deletions
diff --git a/internal/hexaicli/cache.go b/internal/hexaicli/cache.go index 544eab0..742ffce 100644 --- a/internal/hexaicli/cache.go +++ b/internal/hexaicli/cache.go @@ -1,6 +1,7 @@ package hexaicli import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -14,7 +15,38 @@ import ( const cliResponseCacheTTL = 24 * time.Hour -var nowCLIResponseCache = time.Now +// responseCache carries the injectable dependencies for the on-disk CLI +// response cache. The only dependency is the clock used to stamp entries and +// decide expiry. Production code uses defaultResponseCache (backed by +// time.Now); tests construct a responseCache with a fake clock to exercise TTL +// expiry without sleeping. +type responseCache struct { + now func() time.Time +} + +// defaultResponseCache is the production cache used by the package-level +// lookup/store wrappers. It reads the real wall clock. +var defaultResponseCache = responseCache{now: time.Now} + +// cacheNowContextKey carries an injected clock through the request context so +// the cache TTL logic can be driven deterministically (e.g. in tests) without +// mutating package state. +type cacheNowContextKey struct{} + +// withCLIResponseCacheNow returns a context carrying now as the clock the CLI +// response cache should use for stamping and expiring entries. +func withCLIResponseCacheNow(ctx context.Context, now func() time.Time) context.Context { + return context.WithValue(ctx, cacheNowContextKey{}, now) +} + +// responseCacheFromContext builds a responseCache using the clock injected via +// withCLIResponseCacheNow, falling back to the real wall clock. +func responseCacheFromContext(ctx context.Context) responseCache { + if now, ok := ctx.Value(cacheNowContextKey{}).(func() time.Time); ok && now != nil { + return responseCache{now: now} + } + return defaultResponseCache +} type cliResponseCacheKey struct { Provider string `json:"provider"` @@ -39,7 +71,21 @@ func newCLIResponseCacheKey(provider, model string, req requestArgs, msgs []llm. } } -func lookupCLIResponseCache(key cliResponseCacheKey) (string, time.Duration, bool) { +// lookupCLIResponseCache reads a cached response using the clock injected into +// ctx (defaulting to the real wall clock). +func lookupCLIResponseCache(ctx context.Context, key cliResponseCacheKey) (string, time.Duration, bool) { + return responseCacheFromContext(ctx).lookup(key) +} + +// storeCLIResponseCache writes a cached response using the clock injected into +// ctx (defaulting to the real wall clock). +func storeCLIResponseCache(ctx context.Context, key cliResponseCacheKey, output string) { + responseCacheFromContext(ctx).store(key, output) +} + +// lookup returns the cached output for key, its age, and whether it is a valid +// (non-expired) hit. Expired entries are removed. +func (c responseCache) lookup(key cliResponseCacheKey) (string, time.Duration, bool) { path, ok := cliResponseCachePath(key) if !ok { return "", 0, false @@ -48,7 +94,7 @@ func lookupCLIResponseCache(key cliResponseCacheKey) (string, time.Duration, boo if !ok { return "", 0, false } - age := nowCLIResponseCache().Sub(entry.CreatedAt) + age := c.now().Sub(entry.CreatedAt) if age > cliResponseCacheTTL { _ = os.Remove(path) return "", 0, false @@ -56,7 +102,8 @@ func lookupCLIResponseCache(key cliResponseCacheKey) (string, time.Duration, boo return entry.Output, age, true } -func storeCLIResponseCache(key cliResponseCacheKey, output string) { +// store persists output for key, stamping it with the injected clock. +func (c responseCache) store(key cliResponseCacheKey, output string) { path, ok := cliResponseCachePath(key) if !ok { return @@ -64,7 +111,7 @@ func storeCLIResponseCache(key cliResponseCacheKey, output string) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return } - entry := cliResponseCacheEntry{CreatedAt: nowCLIResponseCache().UTC(), Output: output} + entry := cliResponseCacheEntry{CreatedAt: c.now().UTC(), Output: output} data, err := json.Marshal(entry) if err != nil { return diff --git a/internal/hexaicli/cache_test.go b/internal/hexaicli/cache_test.go index c9b83c6..98dfb2d 100644 --- a/internal/hexaicli/cache_test.go +++ b/internal/hexaicli/cache_test.go @@ -47,12 +47,13 @@ func TestCLIResponseCacheFingerprintChanges(t *testing.T) { func TestLookupCLIResponseCacheExpiresEntries(t *testing.T) { t.Setenv("XDG_CACHE_HOME", t.TempDir()) - oldNow := nowCLIResponseCache - nowCLIResponseCache = func() time.Time { return time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) } - defer func() { nowCLIResponseCache = oldNow }() + // Inject a fake clock via a responseCache value, demonstrating dependency + // injection rather than mutating package state. + now := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + cache := responseCache{now: func() time.Time { return now }} key := newCLIResponseCacheKey("openai", "gpt-4.1", requestArgs{maxTokens: 10}, []llm.Message{{Role: "user", Content: "hello"}}) - storeCLIResponseCache(key, "cached") + cache.store(key, "cached") path, ok := cliResponseCachePath(key) if !ok { @@ -62,8 +63,9 @@ func TestLookupCLIResponseCacheExpiresEntries(t *testing.T) { t.Fatalf("expected cache file: %v", err) } - nowCLIResponseCache = func() time.Time { return time.Date(2026, 3, 16, 11, 0, 0, 0, time.UTC) } - if _, _, hit := lookupCLIResponseCache(key); hit { + // Advance the injected clock past the TTL so the entry expires. + now = time.Date(2026, 3, 16, 11, 0, 0, 0, time.UTC) + if _, _, hit := cache.lookup(key); hit { t.Fatal("expected expired cache miss") } if _, err := os.Stat(path); !os.IsNotExist(err) { @@ -175,9 +177,8 @@ func TestRun_ExpiredCacheFallsBackToProvider(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("XDG_CACHE_HOME", t.TempDir()) - oldNow := nowCLIResponseCache - nowCLIResponseCache = func() time.Time { return time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) } - defer func() { nowCLIResponseCache = oldNow }() + now := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + ctx := withCLIResponseCacheNow(context.Background(), func() time.Time { return now }) oldNew := newClientFromApp defer func() { newClientFromApp = oldNew }() @@ -192,13 +193,13 @@ func TestRun_ExpiredCacheFallsBackToProvider(t *testing.T) { return &fakeClient{name: cfg.Provider, model: "gpt-4.1", resp: resp}, nil } - if err := Run(context.Background(), []string{"hello"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + if err := Run(ctx, []string{"hello"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}); err != nil { t.Fatalf("first Run: %v", err) } - nowCLIResponseCache = func() time.Time { return time.Date(2026, 3, 16, 11, 0, 0, 0, time.UTC) } + now = time.Date(2026, 3, 16, 11, 0, 0, 0, time.UTC) var out, errb bytes.Buffer - if err := Run(context.Background(), []string{"hello"}, strings.NewReader(""), &out, &errb); err != nil { + if err := Run(ctx, []string{"hello"}, strings.NewReader(""), &out, &errb); err != nil { t.Fatalf("second Run: %v", err) } if calls != 2 { diff --git a/internal/hexaicli/editor_integration_test.go b/internal/hexaicli/editor_integration_test.go index e5580be..784b83c 100644 --- a/internal/hexaicli/editor_integration_test.go +++ b/internal/hexaicli/editor_integration_test.go @@ -3,11 +3,9 @@ package hexaicli import ( "bytes" "context" - "os" "testing" "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/editor" "codeberg.org/snonux/hexai/internal/llm" ) @@ -23,20 +21,13 @@ func (cliFake) CodeCompletion(context.Context, string, string, int, string, floa } func TestRun_NoArgs_OpensEditor(t *testing.T) { - // Seam: fake client and editor - oldNew := newClientFromApp - newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return cliFake{}, nil } - t.Cleanup(func() { newClientFromApp = oldNew }) - oldRun := editor.RunEditor - editor.RunEditor = func(_ context.Context, _ string, path string) error { - return os.WriteFile(path, []byte("PROMPT"), 0o600) - } - t.Cleanup(func() { editor.RunEditor = oldRun }) - t.Setenv("HEXAI_EDITOR", "dummy") + runner := NewRunner() + runner.newClient = func(_ appconfig.App) (llm.Client, error) { return cliFake{}, nil } + runner.openEditor = func(context.Context, []byte) (string, error) { return "PROMPT", nil } // Provide stdin selection var stdout, stderr bytes.Buffer - if err := Run(context.Background(), nil, bytes.NewBufferString("SELECTION"), &stdout, &stderr); err != nil { + if err := runner.Run(context.Background(), nil, bytes.NewBufferString("SELECTION"), &stdout, &stderr); err != nil { t.Fatalf("Run: %v", err) } if stdout.String() == "" { @@ -45,17 +36,15 @@ func TestRun_NoArgs_OpensEditor(t *testing.T) { } func TestRun_WithArgs_DoesNotOpenEditor(t *testing.T) { - // Provide args; still use fake client - oldNew := newClientFromApp - newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return cliFake{}, nil } - t.Cleanup(func() { newClientFromApp = oldNew }) - // Stub editor and detect if called (should not be) + runner := NewRunner() + runner.newClient = func(_ appconfig.App) (llm.Client, error) { return cliFake{}, nil } called := false - oldRun := editor.RunEditor - editor.RunEditor = func(_ context.Context, _ string, _ string) error { called = true; return nil } - t.Cleanup(func() { editor.RunEditor = oldRun }) + runner.openEditor = func(context.Context, []byte) (string, error) { + called = true + return "", nil + } var stdout, stderr bytes.Buffer - if err := Run(context.Background(), []string{"ARG"}, bytes.NewBufferString("SEL"), &stdout, &stderr); err != nil { + if err := runner.Run(context.Background(), []string{"ARG"}, bytes.NewBufferString("SEL"), &stdout, &stderr); err != nil { t.Fatalf("Run: %v", err) } if called { diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go index 6614bc5..8152618 100644 --- a/internal/hexaicli/run.go +++ b/internal/hexaicli/run.go @@ -157,7 +157,7 @@ func setupCLIPrinter(stdout io.Writer, jobs []cliJob) *termprint.ColumnPrinter { } func runSingleCLIJob(ctx context.Context, job cliJob, msgs []llm.Message, input string, stdout io.Writer, printer *termprint.ColumnPrinter, streamOutput bool, clientFactory cliClientFactory, statusSink cliStatusSink) *cliJobResult { - if res := cachedCLIJobResult(job, msgs, stdout, printer, streamOutput); res != nil { + if res := cachedCLIJobResult(ctx, job, msgs, stdout, printer, streamOutput); res != nil { return res } @@ -181,7 +181,7 @@ func runSingleCLIJob(ctx context.Context, job cliJob, msgs []llm.Message, input printer.Flush(job.index) } if err == nil { - storeCLIResponseCache(newCLIResponseCacheKey(job.provider, model, job.req, jobMsgs), outBuf.String()) + storeCLIResponseCache(ctx, newCLIResponseCacheKey(job.provider, model, job.req, jobMsgs), outBuf.String()) } return &cliJobResult{ provider: job.provider, @@ -192,8 +192,8 @@ func runSingleCLIJob(ctx context.Context, job cliJob, msgs []llm.Message, input } } -func cachedCLIJobResult(job cliJob, msgs []llm.Message, stdout io.Writer, printer *termprint.ColumnPrinter, streamOutput bool) *cliJobResult { - output, age, ok := lookupCLIResponseCache(newCLIResponseCacheKey(job.provider, job.req.model, job.req, msgs)) +func cachedCLIJobResult(ctx context.Context, job cliJob, msgs []llm.Message, stdout io.Writer, printer *termprint.ColumnPrinter, streamOutput bool) *cliJobResult { + output, age, ok := lookupCLIResponseCache(ctx, newCLIResponseCacheKey(job.provider, job.req.model, job.req, msgs)) if !ok { return nil } diff --git a/internal/hexaicli/run_editor_behavior_test.go b/internal/hexaicli/run_editor_behavior_test.go index 99a2f2d..b9ebd75 100644 --- a/internal/hexaicli/run_editor_behavior_test.go +++ b/internal/hexaicli/run_editor_behavior_test.go @@ -7,7 +7,6 @@ import ( "testing" "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/editor" "codeberg.org/snonux/hexai/internal/llm" ) @@ -22,23 +21,17 @@ func (okClient) DefaultModel() string { return "m" } // Ensure that when stdin has content and args are empty, Run does not open the editor. func TestRun_DoesNotOpenEditorWhenStdinPresent(t *testing.T) { - // Guard: make editor invocation fatal if called - oldRunEd := editor.RunEditor - defer func() { editor.RunEditor = oldRunEd }() - editor.RunEditor = func(_ context.Context, _ string, _ string) error { + runner := NewRunner() + runner.openEditor = func(context.Context, []byte) (string, error) { t.Fatalf("editor should not be invoked when stdin has content") - return nil + return "", nil } - - // Stub client constructor to avoid hitting real providers - oldNew := newClientFromApp - defer func() { newClientFromApp = oldNew }() - newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return okClient{}, nil } + runner.newClient = func(_ appconfig.App) (llm.Client, error) { return okClient{}, nil } var out, errb bytes.Buffer restore, f := setStdin(t, "from-stdin") defer restore() - if err := Run(context.Background(), nil, f, &out, &errb); err != nil { + if err := runner.Run(context.Background(), nil, f, &out, &errb); err != nil { t.Fatalf("Run: %v", err) } if !strings.Contains(out.String(), "OK") { diff --git a/internal/hexaicli/runner.go b/internal/hexaicli/runner.go index 3929001..340733c 100644 --- a/internal/hexaicli/runner.go +++ b/internal/hexaicli/runner.go @@ -29,10 +29,11 @@ type cliStatusSink interface { // Runner executes the CLI with injectable configuration, editor, client, and status dependencies. type Runner struct { - loadConfig cliConfigLoader - openEditor cliEditorOpener - newClient cliClientFactory - statusSink cliStatusSink + loadConfig cliConfigLoader + openEditor cliEditorOpener + openConfigEditor func(context.Context, string) error + newClient cliClientFactory + statusSink cliStatusSink } type tmuxCLIStatusSink struct{} @@ -58,10 +59,11 @@ func (tmuxCLIStatusSink) SetGlobal(snapshot stats.Snapshot, provider, model stri // NewRunner builds a CLI runner with production dependencies. func NewRunner() *Runner { return &Runner{ - loadConfig: loadConfigFromContext, - openEditor: editor.OpenTempAndEdit, - newClient: newClientFromApp, - statusSink: tmuxCLIStatusSink{}, + loadConfig: loadConfigFromContext, + openEditor: editor.OpenTempAndEdit, + openConfigEditor: editor.OpenFile, + newClient: newClientFromApp, + statusSink: tmuxCLIStatusSink{}, } } @@ -85,7 +87,7 @@ func (r *Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout } cfgPath = p } - if err := editor.OpenFile(ctx, cfgPath); err != nil { + if err := runner.openConfigEditor(ctx, cfgPath); err != nil { _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai %s: %v"+logging.AnsiReset+"\n", sub, err) return err } @@ -172,6 +174,9 @@ func normalizeRunner(r *Runner) Runner { if runner.openEditor == nil { runner.openEditor = editor.OpenTempAndEdit } + if runner.openConfigEditor == nil { + runner.openConfigEditor = editor.OpenFile + } if runner.newClient == nil { runner.newClient = newClientFromApp } diff --git a/internal/hexaicli/runner_test.go b/internal/hexaicli/runner_test.go index 8b52b89..1296bfc 100644 --- a/internal/hexaicli/runner_test.go +++ b/internal/hexaicli/runner_test.go @@ -9,7 +9,6 @@ import ( "testing" "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/editor" "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/stats" ) @@ -63,17 +62,14 @@ func TestRunner_UsesInjectedDependencies(t *testing.T) { } func TestRunner_ConfigSubcommand_OpensConfigFromContext(t *testing.T) { - old := editor.RunEditor - t.Cleanup(func() { editor.RunEditor = old }) - t.Setenv("EDITOR", "true") var gotPath string - editor.RunEditor = func(_ context.Context, _, path string) error { + runner := NewRunner() + runner.openConfigEditor = func(_ context.Context, path string) error { gotPath = path return nil } cfgFile := filepath.Join(t.TempDir(), "hexai", "config.toml") ctx := WithCLIConfigPath(context.Background(), cfgFile) - runner := NewRunner() if err := runner.Run(ctx, []string{"config"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}); err != nil { t.Fatalf("Run: %v", err) } @@ -83,17 +79,14 @@ func TestRunner_ConfigSubcommand_OpensConfigFromContext(t *testing.T) { } func TestRunner_ConfigSubcommand_UsesXDGWhenNoOverride(t *testing.T) { - old := editor.RunEditor - t.Cleanup(func() { editor.RunEditor = old }) - t.Setenv("HEXAI_EDITOR", "true") xdg := t.TempDir() t.Setenv("XDG_CONFIG_HOME", xdg) var gotPath string - editor.RunEditor = func(_ context.Context, _, path string) error { + runner := NewRunner() + runner.openConfigEditor = func(_ context.Context, path string) error { gotPath = path return nil } - runner := NewRunner() want := filepath.Join(xdg, "hexai", "config.toml") if err := runner.Run(context.Background(), []string{"config"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}); err != nil { t.Fatalf("Run: %v", err) |
