summaryrefslogtreecommitdiff
path: root/internal/hexaicli
diff options
context:
space:
mode:
Diffstat (limited to 'internal/hexaicli')
-rw-r--r--internal/hexaicli/cache.go57
-rw-r--r--internal/hexaicli/cache_test.go25
-rw-r--r--internal/hexaicli/editor_integration_test.go33
-rw-r--r--internal/hexaicli/run.go8
-rw-r--r--internal/hexaicli/run_editor_behavior_test.go17
-rw-r--r--internal/hexaicli/runner.go23
-rw-r--r--internal/hexaicli/runner_test.go15
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)