diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-07 11:36:38 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-07 11:36:38 +0300 |
| commit | fb06bfa9dc7140f987bdbad59467a84610221fbb (patch) | |
| tree | b8ebf92c121a32eed0dc5e193c5877c5d0b94e6b | |
| parent | 0d424adfc64da1c61296c66a99162ec68cc4f8d0 (diff) | |
refactor: move hexai-action to cmd/hexai-action; extract orchestration into internal/hexaiaction; move tests; update Magefile and docs
| -rw-r--r-- | Magefile.go | 12 | ||||
| -rw-r--r-- | TODO.md | 8 | ||||
| -rw-r--r-- | cmd/hexai-action/main.go | 33 | ||||
| -rw-r--r-- | cmd/internal/hexai-action/main.go | 238 | ||||
| -rw-r--r-- | cmd/internal/hexai-action/main_test.go | 355 | ||||
| -rw-r--r-- | docs/testing.md | 2 | ||||
| -rw-r--r-- | internal/hexaiaction/cmdentry.go | 168 | ||||
| -rw-r--r-- | internal/hexaiaction/cmdentry_test.go | 152 | ||||
| -rwxr-xr-x | scripts/coverage.sh | 3 |
9 files changed, 365 insertions, 606 deletions
diff --git a/Magefile.go b/Magefile.go index bd55ef6..16ff7c0 100644 --- a/Magefile.go +++ b/Magefile.go @@ -43,8 +43,8 @@ func BuildHexaiCLI() error { // BuildHexaiAction builds the hexai-action TUI binary. func BuildHexaiAction() error { - printCoverage() - return sh.RunV("go", "build", "-o", "hexai-action", "cmd/internal/hexai-action/main.go") + printCoverage() + return sh.RunV("go", "build", "-o", "hexai-action", "cmd/hexai-action/main.go") } // Dev runs tests, vet, lint, then builds with race for both binaries. @@ -57,7 +57,7 @@ func Dev() error { if err := sh.RunV("go", "build", "-race", "-o", "hexai", "cmd/hexai/main.go"); err != nil { return err } - return sh.RunV("go", "build", "-race", "-o", "hexai-action", "cmd/internal/hexai-action/main.go") + return sh.RunV("go", "build", "-race", "-o", "hexai-action", "cmd/hexai-action/main.go") } // Run launches the LSP server via go run (useful during development). @@ -102,9 +102,9 @@ func Install() error { // RunAction runs the hexai-action TUI via go run (reads stdin). func RunAction() error { - printCoverage() - mg.Deps(Dev) - return sh.RunV("go", "run", "cmd/internal/hexai-action/main.go") + printCoverage() + mg.Deps(Dev) + return sh.RunV("go", "run", "cmd/hexai-action/main.go") } // printCoverage prints a warning if an existing coverage profile shows total < coverateThreshold. @@ -69,7 +69,7 @@ Helix configuration after change - Optional: users can force tmux pane with `:pipe hexai-action -tmux` or disable with `:pipe hexai-action -no-tmux` if auto-detection does not fit their setup. Migration plan -1) Implement tmux package and integrate auto-mode in `cmd/internal/hexai-action/main.go`. +1) Implement tmux package and integrate auto-mode in `cmd/hexai-action/main.go`. 2) Keep legacy flags (`-infile`, `-outfile`) for compatibility and tests. 3) Update README and docs to show new Helix keybinding and describe flags. 4) Mark shell scripts (`llminputs/ai`, `llminputs/hx.hexai-action-prompt`) as deprecated in repo notes; retain them temporarily. @@ -78,7 +78,7 @@ Migration plan Testing plan - Unit tests: - `internal/tmux`: mock `exec.Command` via a small command-runner interface; verify command assembly and availability checks. - - `cmd/internal/hexai-action`: tests for parent decision logic (TTY vs pipe; tmux available vs not) using injectable detectors. + - `internal/hexaiaction`: tests for parent decision logic (TTY vs pipe; tmux available vs not) using injectable detectors. - hexaiaction: existing tests continue to pass; add tests for non-interactive fallback behavior when no TTY. - Integration tests (manual or scripted): - Run under tmux: verify a pane opens, TUI choice is applied, stdout contains result. @@ -93,7 +93,7 @@ Edge cases and mitigations Implementation steps (incremental) 1) Add `internal/tmux` with `Available`, `SplitRun`, and helpers. -2) Add TTY detection helper to `cmd/internal/hexai-action` and wire flags (`-tmux`, `-no-tmux`, `-ui-child`). +2) Add TTY detection helper to `internal/hexaiaction` and wire flags (`-tmux`, `-no-tmux`, `-ui-child`). 3) Parent flow: detect mode, manage temp files, spawn child via tmux when selected, wait/print result. 4) Child flow: reuse existing `hexaiaction.Run` to keep logic centralized; ensure outfile atomic write. 5) Docs: update README with new Helix config and flags. @@ -101,7 +101,7 @@ Implementation steps (incremental) Notes on code organization - Place all tmux-related code under `internal/tmux` and keep functions well under 50 lines. -- Keep command entrypoint (`cmd/internal/hexai-action/main.go`) small and focused on wiring/mode selection. +- Keep command entrypoint (`cmd/hexai-action/main.go`) small and focused on wiring/mode selection. - Avoid duplication across `hexaiaction` and `tmux`; IO/file and action logic remain in `hexaiaction`. Outcome diff --git a/cmd/hexai-action/main.go b/cmd/hexai-action/main.go new file mode 100644 index 0000000..b796cbd --- /dev/null +++ b/cmd/hexai-action/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "codeberg.org/snonux/hexai/internal/hexaiaction" +) + +func main() { + infile := flag.String("infile", "", "Read input from this file instead of stdin") + outfile := flag.String("outfile", "", "Write output to this file instead of stdout") + forceTmux := flag.Bool("tmux", false, "Force running the UI in a tmux split-pane (auto if not set)") + noTmux := flag.Bool("no-tmux", false, "Disable tmux mode even if available") + uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically") + tmuxTarget := flag.String("tmux-target", "", "tmux split target (advanced)") + tmuxSplit := flag.String("tmux-split", "v", "tmux split orientation: v or h") + tmuxPercent := flag.Int("tmux-percent", 33, "tmux split size percentage (1-100)") + flag.Parse() + + opts := hexaiaction.Options{ + Infile: *infile, Outfile: *outfile, + ForceTmux: *forceTmux, NoTmux: *noTmux, UIChild: *uiChild, + TmuxTarget: *tmuxTarget, TmuxSplit: *tmuxSplit, TmuxPercent: *tmuxPercent, + } + if err := hexaiaction.RunCommand(context.Background(), opts, os.Stdin, os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + diff --git a/cmd/internal/hexai-action/main.go b/cmd/internal/hexai-action/main.go deleted file mode 100644 index b8ba524..0000000 --- a/cmd/internal/hexai-action/main.go +++ /dev/null @@ -1,238 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "io" - "os" - "path/filepath" - "time" - - "codeberg.org/snonux/hexai/internal/hexaiaction" - "codeberg.org/snonux/hexai/internal/tmux" - "golang.org/x/term" -) - -func main() { - infile := flag.String("infile", "", "Read input from this file instead of stdin") - outfile := flag.String("outfile", "", "Write output to this file instead of stdout") - // Tmux/UI flags - forceTmux := flag.Bool("tmux", false, "Force running the UI in a tmux split-pane (auto if not set)") - noTmux := flag.Bool("no-tmux", false, "Disable tmux mode even if available") - uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically") - tmuxTarget := flag.String("tmux-target", "", "tmux split target (advanced)") - tmuxSplit := flag.String("tmux-split", "v", "tmux split orientation: v or h") - tmuxPercent := flag.Int("tmux-percent", 33, "tmux split size percentage (1-100)") - flag.Parse() - - // Child mode: run TUI and write atomically to -outfile - if *uiChild { - if err := runChild(*infile, *outfile); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - return - } - - // Parent mode: decide inline vs tmux - if shouldRunInTmux(*forceTmux, *noTmux) { - if err := runInTmuxParent(*tmuxTarget, *tmuxSplit, *tmuxPercent); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - return - } - - // Inline path: only if we have a TTY for UI; otherwise echo input - if isTTY(os.Stdout.Fd()) && isTTY(os.Stdin.Fd()) { - in, out, closeIn, closeOut, err := openIO(*infile, *outfile) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - defer closeIn() - defer closeOut() - if err := hexaiactionRun(context.Background(), in, out, os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - return - } - - // Fallback: no TTY and tmux not available; echo input to output - if err := echoThrough(*infile, *outfile); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -// openIO returns readers/writers for infile/outfile flags with deferred closers. -func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) { - in := io.Reader(os.Stdin) - out := io.Writer(os.Stdout) - closeIn := func() {} - closeOut := func() {} - - if path := infile; path != "" { - f, err := os.Open(path) - if err != nil { - return nil, nil, func() {}, func() {}, fmt.Errorf("hexai-action: cannot open infile: %w", err) - } - in = f - closeIn = func() { _ = f.Close() } - } - if path := outfile; path != "" { - f, err := os.Create(path) - if err != nil { - return nil, nil, func() {}, func() {}, fmt.Errorf("hexai-action: cannot open outfile: %w", err) - } - out = f - closeOut = func() { _ = f.Close() } - } - return in, out, closeIn, closeOut, nil -} - -// runChild runs the interactive flow and writes the final output atomically to outfile. -var hexaiactionRun = hexaiaction.Run - -func runChild(infile, outfile string) error { - if outfile == "" { - // No atomic handoff needed; just run normally to stdout - in, out, closeIn, closeOut, err := openIO(infile, "") - if err != nil { - return err - } - defer closeIn() - defer closeOut() - return hexaiactionRun(context.Background(), in, out, os.Stderr) - } - tmp := outfile + ".tmp" - in, out, closeIn, closeOut, err := openIO(infile, tmp) - if err != nil { - return err - } - defer closeIn() - if err := hexaiactionRun(context.Background(), in, out, os.Stderr); err != nil { - // On error, try to echo input to tmp to avoid blocking - closeOut() - if copyErr := echoThrough(infile, tmp); copyErr != nil { - return fmt.Errorf("hexai-action child: %v; echo failed: %v", err, copyErr) - } - } else { - closeOut() - } - return os.Rename(tmp, outfile) -} - -var isTTYFn = isTTY -var tmuxAvailableFn = tmux.Available -var splitRunFn = tmux.SplitRun -var osExecutableFn = os.Executable - -func shouldRunInTmux(forceTmux, noTmux bool) bool { - if noTmux { - return false - } - if forceTmux { - return true - } - // Auto: prefer tmux when stdio are not TTYs (Helix :pipe scenario) - if !(isTTYFn(os.Stdin.Fd()) && isTTYFn(os.Stdout.Fd())) && tmuxAvailableFn() { - return true - } - return false -} - -func isTTY(fd uintptr) bool { return term.IsTerminal(int(fd)) } - -func runInTmuxParent(target, split string, percent int) error { - // Prepare temp files - dir, err := os.MkdirTemp("", "hexai-action-") - if err != nil { - return err - } - defer func() { _ = os.RemoveAll(dir) }() - inPath := filepath.Join(dir, "input.txt") - outPath := filepath.Join(dir, "reply.txt") - // Read stdin and persist to inPath - if err := persistStdin(inPath); err != nil { - return err - } - // Build child argv - exe, err := osExecutableFn() - if err != nil { - return err - } - argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath} - // Spawn tmux split - opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent} - if err := splitRunFn(opts, argv); err != nil { - return err - } - // Wait for outfile to appear - if err := waitForFile(outPath, 60*time.Second); err != nil { - return err - } - // Print to stdout - return catFileToStdout(outPath) -} - -func persistStdin(path string) error { - f, err := os.Create(path) - if err != nil { - return err - } - defer func() { _ = f.Close() }() - if _, err := io.Copy(f, os.Stdin); err != nil { - return err - } - return f.Sync() -} - -func waitForFile(path string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for { - if _, err := os.Stat(path); err == nil { - return nil - } - if time.Now().After(deadline) { - return fmt.Errorf("hexai-action: timeout waiting for reply file") - } - time.Sleep(200 * time.Millisecond) - } -} - -func catFileToStdout(path string) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer func() { _ = f.Close() }() - _, err = io.Copy(os.Stdout, f) - return err -} - -func echoThrough(infile, outfile string) error { - // Read from infile or stdin and write to outfile or stdout - var in io.Reader = os.Stdin - var out io.Writer = os.Stdout - if infile != "" { - f, err := os.Open(infile) - if err != nil { - return err - } - defer func() { _ = f.Close() }() - in = f - } - if outfile != "" { - f, err := os.Create(outfile) - if err != nil { - return err - } - defer func() { _ = f.Close() }() - out = f - } - _, err := io.Copy(out, in) - return err -} diff --git a/cmd/internal/hexai-action/main_test.go b/cmd/internal/hexai-action/main_test.go deleted file mode 100644 index 16bb2ed..0000000 --- a/cmd/internal/hexai-action/main_test.go +++ /dev/null @@ -1,355 +0,0 @@ -package main - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "testing" - - "codeberg.org/snonux/hexai/internal/tmux" -) - -func TestShouldRunInTmux_Preferences(t *testing.T) { - // no-tmux overrides - if shouldRunInTmux(false, true) { - t.Fatal("expected false when no-tmux is set") - } - // force tmux overrides - if !shouldRunInTmux(true, false) { - t.Fatal("expected true when -tmux is set") - } -} - -func TestShouldRunInTmux_Auto(t *testing.T) { - oldIsTTY := isTTYFn - oldAvail := tmuxAvailableFn - t.Cleanup(func() { isTTYFn = oldIsTTY; tmuxAvailableFn = oldAvail }) - // Simulate Helix :pipe (no TTY) and tmux available - isTTYFn = func(_ uintptr) bool { return false } - tmuxAvailableFn = func() bool { return true } - if !shouldRunInTmux(false, false) { - t.Fatal("expected true when not TTY and tmux available") - } - // Simulate TTY present: prefer inline - isTTYFn = func(_ uintptr) bool { return true } - if shouldRunInTmux(false, false) { - t.Fatal("expected false when TTY present") - } - // Simulate tmux not available - isTTYFn = func(_ uintptr) bool { return false } - tmuxAvailableFn = func() bool { return false } - if shouldRunInTmux(false, false) { - t.Fatal("expected false when tmux unavailable") - } -} - -func TestPersistStdin_WritesFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "in.txt") - // Point os.Stdin to a temp file with content - src := filepath.Join(dir, "src.txt") - if err := os.WriteFile(src, []byte("hello world"), 0o600); err != nil { - t.Fatalf("write src: %v", err) - } - f, err := os.Open(src) - if err != nil { t.Fatalf("open src: %v", err) } - old := os.Stdin - os.Stdin = f - t.Cleanup(func() { os.Stdin = old; _ = f.Close() }) - if err := persistStdin(path); err != nil { - t.Fatalf("persistStdin error: %v", err) - } - b, err := os.ReadFile(path) - if err != nil { t.Fatalf("read out: %v", err) } - if string(b) != "hello world" { - t.Fatalf("unexpected content %q", string(b)) - } -} - -func TestEchoThrough(t *testing.T) { - dir := t.TempDir() - in := filepath.Join(dir, "in.txt") - out := filepath.Join(dir, "out.txt") - if err := os.WriteFile(in, []byte("hello"), 0o600); err != nil { - t.Fatalf("write in: %v", err) - } - if err := echoThrough(in, out); err != nil { - t.Fatalf("echoThrough: %v", err) - } - b, _ := os.ReadFile(out) - if string(b) != "hello" { - t.Fatalf("unexpected: %q", string(b)) - } -} - -func TestEchoThrough_StdinStdout(t *testing.T) { - // set stdin - rIn, wIn, _ := os.Pipe() - _, _ = wIn.Write([]byte("PIPE")) - _ = wIn.Close() - oldIn := os.Stdin - os.Stdin = rIn - defer func() { os.Stdin = oldIn; _ = rIn.Close() }() - // capture stdout - r, w, _ := os.Pipe() - oldOut := os.Stdout - os.Stdout = w - defer func() { os.Stdout = oldOut; _ = r.Close(); _ = w.Close() }() - if err := echoThrough("", ""); err != nil { t.Fatalf("echoThrough: %v", err) } - _ = w.Close() - data, _ := io.ReadAll(r) - if string(data) != "PIPE" { - t.Fatalf("stdout: %q", string(data)) - } -} - -func TestWaitForFile(t *testing.T) { - dir := t.TempDir() - p := filepath.Join(dir, "x") - go func() { - // create shortly after - f, _ := os.Create(p) - defer f.Close() - f.WriteString("ok") - }() - if err := waitForFile(p, 2_000_000_000); err != nil { // 2s - t.Fatalf("waitForFile: %v", err) - } -} - -func TestCatFileToStdout(t *testing.T) { - dir := t.TempDir() - p := filepath.Join(dir, "f") - if err := os.WriteFile(p, []byte("abc"), 0o600); err != nil { - t.Fatalf("write: %v", err) - } - // capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - defer func() { os.Stdout = old; _ = r.Close(); _ = w.Close() }() - if err := catFileToStdout(p); err != nil { - t.Fatalf("catFileToStdout: %v", err) - } - _ = w.Close() - buf, _ := io.ReadAll(r) - if string(buf) != "abc" { - t.Fatalf("stdout = %q", string(buf)) - } -} - -func TestRunInTmuxParent_Stubbed(t *testing.T) { - dir := t.TempDir() - // set stdin content - src := filepath.Join(dir, "stdin.txt") - _ = os.WriteFile(src, []byte("input"), 0o600) - f, _ := os.Open(src) - oldStdin := os.Stdin - os.Stdin = f - defer func() { os.Stdin = oldStdin; _ = f.Close() }() - - // capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - defer func() { os.Stdout = oldStdout; _ = r.Close(); _ = w.Close() }() - - // stub seams - oldExec := osExecutableFn - oldSplit := splitRunFn - oldRun := hexaiactionRun - osExecutableFn = func() (string, error) { return "/bin/hexai-action", nil } - splitRunFn = func(opts tmux.SplitOpts, argv []string) error { - // find -outfile path and write content to simulate child - for i := 0; i < len(argv)-1; i++ { - if argv[i] == "-outfile" && i+1 < len(argv) { - _ = os.WriteFile(argv[i+1], []byte("OUT:"+strings.Join(argv, ",")), 0o600) - break - } - } - return nil - } - // Ensure child mode won't try to run the real TUI if invoked in tests. - hexaiactionRun = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { - _, _ = io.WriteString(w, "child-stub") - return nil - } - defer func() { osExecutableFn = oldExec; splitRunFn = oldSplit; hexaiactionRun = oldRun }() - - if err := runInTmuxParent("", "v", 33); err != nil { - t.Fatalf("runInTmuxParent: %v", err) - } - _ = w.Close() - got, _ := io.ReadAll(r) - if !strings.HasPrefix(string(got), "OUT:") { - t.Fatalf("unexpected stdout: %q", string(got)) - } -} - -func TestRunChild_StubbedOutfile(t *testing.T) { - dir := t.TempDir() - in := filepath.Join(dir, "in.txt") - out := filepath.Join(dir, "out.txt") - _ = os.WriteFile(in, []byte("sel"), 0o600) - old := hexaiactionRun - hexaiactionRun = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { - _, _ = io.WriteString(w, "RESULT") - return nil - } - defer func() { hexaiactionRun = old }() - if err := runChild(in, out); err != nil { - t.Fatalf("runChild: %v", err) - } - b, _ := os.ReadFile(out) - if string(b) != "RESULT" { - t.Fatalf("unexpected outfile: %q", string(b)) - } -} - -func TestRunChild_StubbedStdout(t *testing.T) { - dir := t.TempDir() - in := filepath.Join(dir, "in.txt") - _ = os.WriteFile(in, []byte("sel"), 0o600) - // capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - defer func() { os.Stdout = oldStdout; _ = r.Close(); _ = w.Close() }() - old := hexaiactionRun - hexaiactionRun = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { - _, _ = io.WriteString(w, "STDOUT-RESULT") - return nil - } - defer func() { hexaiactionRun = old }() - if err := runChild(in, ""); err != nil { - t.Fatalf("runChild: %v", err) - } - _ = w.Close() - data, _ := io.ReadAll(r) - if string(data) != "STDOUT-RESULT" { - t.Fatalf("stdout: %q", string(data)) - } -} - -func TestRunChild_ErrorFallback(t *testing.T) { - dir := t.TempDir() - in := filepath.Join(dir, "in.txt") - out := filepath.Join(dir, "out.txt") - _ = os.WriteFile(in, []byte("INPUT"), 0o600) - old := hexaiactionRun - hexaiactionRun = func(_ context.Context, _ io.Reader, _ io.Writer, _ io.Writer) error { - return fmt.Errorf("boom") - } - defer func() { hexaiactionRun = old }() - if err := runChild(in, out); err != nil { - t.Fatalf("runChild: %v", err) - } - b, _ := os.ReadFile(out) - if string(b) != "INPUT" { - t.Fatalf("expected fallback echo, got %q", string(b)) - } -} - -func TestWaitForFile_Timeout(t *testing.T) { - dir := t.TempDir() - p := filepath.Join(dir, "nope") - if err := waitForFile(p, 10_000_000); err == nil { // 10ms - t.Fatal("expected timeout error") - } -} - -func TestOpenIO_InfileOutfile(t *testing.T) { - dir := t.TempDir() - in := filepath.Join(dir, "i") - out := filepath.Join(dir, "o") - _ = os.WriteFile(in, []byte("X"), 0o600) - r, w, ci, co, err := openIO(in, out) - if err != nil { t.Fatalf("openIO: %v", err) } - defer ci(); defer co() - if _, err := io.Copy(w, r); err != nil { t.Fatalf("copy: %v", err) } - b, _ := os.ReadFile(out) - if string(b) != "X" { t.Fatalf("got %q", string(b)) } -} - -func TestRunInTmuxParent_ExecutableError(t *testing.T) { - old := osExecutableFn - osExecutableFn = func() (string, error) { return "", fmt.Errorf("no exe") } - defer func() { osExecutableFn = old }() - // set stdin content - r, w, _ := os.Pipe() - _, _ = w.Write([]byte("x")) - _ = w.Close() - oldIn := os.Stdin - os.Stdin = r - defer func() { os.Stdin = oldIn; _ = r.Close() }() - if err := runInTmuxParent("", "v", 33); err == nil { - t.Fatal("expected error from missing executable") - } -} - -func TestRunInTmuxParent_SplitError(t *testing.T) { - oldExec := osExecutableFn - osExecutableFn = func() (string, error) { return "/bin/hexai-action", nil } - oldSplit := splitRunFn - splitRunFn = func(_ tmux.SplitOpts, _ []string) error { return fmt.Errorf("split failed") } - defer func() { osExecutableFn = oldExec; splitRunFn = oldSplit }() - // set stdin - r, w, _ := os.Pipe() - _, _ = w.Write([]byte("x")) - _ = w.Close() - oldIn := os.Stdin - os.Stdin = r - defer func() { os.Stdin = oldIn; _ = r.Close() }() - if err := runInTmuxParent("", "v", 33); err == nil { - t.Fatal("expected split error") - } -} - -func TestEchoThrough_OutfileError(t *testing.T) { - dir := t.TempDir() - in := filepath.Join(dir, "i.txt") - _ = os.WriteFile(in, []byte("x"), 0o600) - // Outfile inside non-existent subdir -> Create should fail - out := filepath.Join(dir, "nope", "out.txt") - if err := echoThrough(in, out); err == nil { - t.Fatal("expected echoThrough outfile error") - } -} - -func TestPersistStdin_Error(t *testing.T) { - // Parent directory missing -> Create should fail - dir := t.TempDir() - p := filepath.Join(dir, "missing", "x.txt") - // set stdin to something - r, w, _ := os.Pipe() - _, _ = w.Write([]byte("x")) - _ = w.Close() - old := os.Stdin - os.Stdin = r - defer func() { os.Stdin = old; _ = r.Close() }() - if err := persistStdin(p); err == nil { - t.Fatal("expected persistStdin error") - } -} - -func TestCatFileToStdout_Error(t *testing.T) { - if err := catFileToStdout("/nonexistent/path/file.txt"); err == nil { - t.Fatal("expected error for missing file") - } -} - -func TestOpenIO_Errors(t *testing.T) { - // Non-existent infile - if _, _, _, _, err := openIO("/definitely/missing/file.txt", ""); err == nil { - t.Fatal("expected infile error") - } - // Outfile in missing dir - dir := t.TempDir() - out := filepath.Join(dir, "nope", "x.txt") - if _, _, _, _, err := openIO("", out); err == nil { - t.Fatal("expected outfile error") - } -} diff --git a/docs/testing.md b/docs/testing.md index 17dd4b3..86d88b1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -21,7 +21,7 @@ Suggested additions: - The `HEXAI_TEST_SKIP_NET=1` env var disables any tests that require network access, keeping runs deterministic in CI/sandboxes. - Package-specific runs: - - `HEXAI_TEST_SKIP_NET=1 go test ./cmd/internal/hexai-action -cover` + - `HEXAI_TEST_SKIP_NET=1 go test ./internal/hexaiaction -cover` - `HEXAI_TEST_SKIP_NET=1 go test ./internal/hexaiaction -cover` Notes diff --git a/internal/hexaiaction/cmdentry.go b/internal/hexaiaction/cmdentry.go new file mode 100644 index 0000000..1947390 --- /dev/null +++ b/internal/hexaiaction/cmdentry.go @@ -0,0 +1,168 @@ +package hexaiaction + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "codeberg.org/snonux/hexai/internal/tmux" + "golang.org/x/term" +) + +// Options configures the command-line orchestration for hexai-action. +type Options struct { + Infile string + Outfile string + ForceTmux bool + NoTmux bool + UIChild bool + TmuxTarget string + TmuxSplit string // "v" or "h" + TmuxPercent int // 1-100 +} + +// RunCommand is the CLI orchestrator used by cmd/hexai-action. It decides whether +// to run inline, in a tmux split pane, or in child mode; then delegates to Run. +func RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error { + if opts.UIChild { + return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr) + } + if shouldRunInTmux(opts.ForceTmux, opts.NoTmux) { + return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent) + } + // Inline path: only if we have a TTY for UI; otherwise echo input + if isTTYFn(os.Stdout.Fd()) && isTTYFn(os.Stdin.Fd()) { + in, out, closeIn, closeOut, err := openIO(opts.Infile, opts.Outfile) + if err != nil { return err } + defer closeIn(); defer closeOut() + return Run(ctx, in, out, stderr) + } + // Fallback: echo + return echoThrough(opts.Infile, opts.Outfile, stdin, stdout) +} + +// seams for unit tests +var isTTYFn = func(fd uintptr) bool { return term.IsTerminal(int(fd)) } +var tmuxAvailableFn = tmux.Available +var splitRunFn = tmux.SplitRun +var osExecutableFn = os.Executable +var runFn = Run + +func shouldRunInTmux(forceTmux, noTmux bool) bool { + if noTmux { return false } + if forceTmux { return true } + if !(isTTYFn(os.Stdin.Fd()) && isTTYFn(os.Stdout.Fd())) && tmuxAvailableFn() { return true } + return false +} + +// openIO returns readers/writers for infile/outfile flags with deferred closers. +func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) { + in := io.Reader(os.Stdin) + out := io.Writer(os.Stdout) + closeIn := func() {} + closeOut := func() {} + if path := infile; path != "" { + f, err := os.Open(path) + if err != nil { return nil, nil, func(){}, func(){}, fmt.Errorf("hexai-action: cannot open infile: %w", err) } + in = f + closeIn = func() { _ = f.Close() } + } + if path := outfile; path != "" { + f, err := os.Create(path) + if err != nil { return nil, nil, func(){}, func(){}, fmt.Errorf("hexai-action: cannot open outfile: %w", err) } + out = f + closeOut = func() { _ = f.Close() } + } + return in, out, closeIn, closeOut, nil +} + +// runChild runs the interactive flow and writes the final output atomically when outfile is set. +func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error { + if outfile == "" { + // No atomic handoff needed; just run normally to provided stdout + var in io.Reader = os.Stdin + if infile != "" { + f, err := os.Open(infile) + if err != nil { return err } + defer func(){ _ = f.Close() }() + in = f + } + return runFn(ctx, in, stdout, stderr) + } + tmp := outfile + ".tmp" + in, out, closeIn, closeOut, err := openIO(infile, tmp) + if err != nil { return err } + defer closeIn() + if err := runFn(ctx, in, out, stderr); err != nil { + closeOut() + if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil { + return fmt.Errorf("hexai-action child: %v; echo failed: %v", err, copyErr) + } + } else { + closeOut() + } + return os.Rename(tmp, outfile) +} + +func runInTmuxParent(stdin io.Reader, stdout io.Writer, target, split string, percent int) error { + dir, err := os.MkdirTemp("", "hexai-action-") + if err != nil { return err } + defer func() { _ = os.RemoveAll(dir) }() + inPath := filepath.Join(dir, "input.txt") + outPath := filepath.Join(dir, "reply.txt") + if err := persistStdin(inPath, stdin); err != nil { return err } + exe, err := osExecutableFn() + if err != nil { return err } + argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath} + opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent} + if err := splitRunFn(opts, argv); err != nil { return err } + if err := waitForFile(outPath, 60*time.Second); err != nil { return err } + return catFileTo(stdout, outPath) +} + +func persistStdin(path string, stdin io.Reader) error { + f, err := os.Create(path) + if err != nil { return err } + defer func() { _ = f.Close() }() + if _, err := io.Copy(f, stdin); err != nil { return err } + return f.Sync() +} + +func waitForFile(path string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + if _, err := os.Stat(path); err == nil { return nil } + if time.Now().After(deadline) { return fmt.Errorf("hexai-action: timeout waiting for reply file") } + time.Sleep(200 * time.Millisecond) + } +} + +func catFileTo(w io.Writer, path string) error { + f, err := os.Open(path) + if err != nil { return err } + defer func() { _ = f.Close() }() + _, err = io.Copy(w, f) + return err +} + +func echoThrough(infile, outfile string, stdin io.Reader, stdout io.Writer) error { + var in io.Reader = stdin + var out io.Writer = stdout + if infile != "" { + f, err := os.Open(infile) + if err != nil { return err } + defer func() { _ = f.Close() }() + in = f + } + if outfile != "" { + f, err := os.Create(outfile) + if err != nil { return err } + defer func() { _ = f.Close() }() + out = f + } + _, err := io.Copy(out, in) + return err +} diff --git a/internal/hexaiaction/cmdentry_test.go b/internal/hexaiaction/cmdentry_test.go new file mode 100644 index 0000000..8525f7d --- /dev/null +++ b/internal/hexaiaction/cmdentry_test.go @@ -0,0 +1,152 @@ +package hexaiaction + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "codeberg.org/snonux/hexai/internal/tmux" +) + +func TestShouldRunInTmux_Preferences(t *testing.T) { + if shouldRunInTmux(false, true) { t.Fatal("expected false when no-tmux is set") } + if !shouldRunInTmux(true, false) { t.Fatal("expected true when -tmux is set") } +} + +func TestShouldRunInTmux_Auto(t *testing.T) { + oldIsTTY := isTTYFn + oldAvail := tmuxAvailableFn + t.Cleanup(func() { isTTYFn = oldIsTTY; tmuxAvailableFn = oldAvail }) + isTTYFn = func(_ uintptr) bool { return false } + tmuxAvailableFn = func() bool { return true } + if !shouldRunInTmux(false, false) { t.Fatal("expected true when not TTY and tmux available") } + isTTYFn = func(_ uintptr) bool { return true } + if shouldRunInTmux(false, false) { t.Fatal("expected false when TTY present") } + isTTYFn = func(_ uintptr) bool { return false } + tmuxAvailableFn = func() bool { return false } + if shouldRunInTmux(false, false) { t.Fatal("expected false when tmux unavailable") } +} + +func TestPersistStdin_WritesFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "in.txt") + // Point stdin to content + src := filepath.Join(dir, "src.txt") + if err := os.WriteFile(src, []byte("hello world"), 0o600); err != nil { t.Fatalf("write src: %v", err) } + f, _ := os.Open(src) + defer f.Close() + if err := persistStdin(path, f); err != nil { t.Fatalf("persistStdin: %v", err) } + b, _ := os.ReadFile(path) + if string(b) != "hello world" { t.Fatalf("unexpected content %q", string(b)) } +} + +func TestEchoThrough(t *testing.T) { + dir := t.TempDir() + in := filepath.Join(dir, "in.txt") + out := filepath.Join(dir, "out.txt") + _ = os.WriteFile(in, []byte("hello"), 0o600) + if err := echoThrough(in, out, os.Stdin, os.Stdout); err != nil { t.Fatalf("echoThrough: %v", err) } + b, _ := os.ReadFile(out) + if string(b) != "hello" { t.Fatalf("unexpected: %q", string(b)) } +} + +func TestEchoThrough_StdinStdout(t *testing.T) { + // set stdin + rIn, wIn, _ := os.Pipe() + _, _ = wIn.Write([]byte("PIPE")) + _ = wIn.Close() + // capture stdout + r, w, _ := os.Pipe() + if err := echoThrough("", "", rIn, w); err != nil { t.Fatalf("echoThrough: %v", err) } + _ = w.Close() + data, _ := io.ReadAll(r) + if string(data) != "PIPE" { t.Fatalf("stdout: %q", string(data)) } +} + +func TestRunInTmuxParent_Stubbed(t *testing.T) { + dir := t.TempDir() + // set stdin content + r, w, _ := os.Pipe() + _, _ = w.Write([]byte("input")) + _ = w.Close() + // capture stdout + rout, wout, _ := os.Pipe() + oldExec := osExecutableFn + oldSplit := splitRunFn + osExecutableFn = func() (string, error) { return "/bin/hexai-action", nil } + splitRunFn = func(opts tmux.SplitOpts, argv []string) error { + for i := 0; i < len(argv)-1; i++ { + if argv[i] == "-outfile" && i+1 < len(argv) { + _ = os.WriteFile(argv[i+1], []byte("OUT:"+strings.Join(argv, ",")), 0o600) + break + } + } + return nil + } + t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit }) + if err := runInTmuxParent(r, wout, "", "v", 33); err != nil { t.Fatalf("runInTmuxParent: %v", err) } + _ = wout.Close() + got, _ := io.ReadAll(rout) + if !strings.HasPrefix(string(got), "OUT:") { t.Fatalf("unexpected stdout: %q", string(got)) } + _ = dir +} + +func TestRunInTmuxParent_ExecutableError(t *testing.T) { + old := osExecutableFn + osExecutableFn = func() (string, error) { return "", fmt.Errorf("no exe") } + t.Cleanup(func() { osExecutableFn = old }) + r, w, _ := os.Pipe(); _, _ = w.Write([]byte("x")); _ = w.Close() + if err := runInTmuxParent(r, io.Discard, "", "v", 33); err == nil { t.Fatal("expected error from missing executable") } +} + +func TestRunInTmuxParent_SplitError(t *testing.T) { + oldExec := osExecutableFn + osExecutableFn = func() (string, error) { return "/bin/hexai-action", nil } + oldSplit := splitRunFn + splitRunFn = func(_ tmux.SplitOpts, _ []string) error { return fmt.Errorf("split failed") } + t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit }) + r, w, _ := os.Pipe(); _, _ = w.Write([]byte("x")); _ = w.Close() + if err := runInTmuxParent(r, io.Discard, "", "v", 33); err == nil { t.Fatal("expected split error") } +} + +func TestRunChild_StdoutAndOutfile(t *testing.T) { + // Outfile mode + dir := t.TempDir() + in := filepath.Join(dir, "in.txt") + out := filepath.Join(dir, "out.txt") + _ = os.WriteFile(in, []byte("sel"), 0o600) + oldRun := runFn + runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { _, _ = io.WriteString(w, "RESULT"); return nil } + t.Cleanup(func(){ runFn = oldRun }) + if err := runChild(context.Background(), in, out, io.Discard, io.Discard); err != nil { t.Fatalf("runChild: %v", err) } + b, _ := os.ReadFile(out) + if len(b) == 0 { t.Fatalf("expected some output") } + // Stdout mode + r, w, _ := os.Pipe() + if err := runChild(context.Background(), in, "", w, io.Discard); err != nil { t.Fatalf("runChild: %v", err) } + _ = w.Close(); buf, _ := io.ReadAll(r) + if len(buf) == 0 { t.Fatalf("expected stdout output") } +} + +func TestWaitForFile_Timeout(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "nope") + if err := waitForFile(p, 10*time.Millisecond); err == nil { t.Fatal("expected timeout error") } +} + +func TestOpenIO_InfileOutfile(t *testing.T) { + dir := t.TempDir() + in := filepath.Join(dir, "i"); out := filepath.Join(dir, "o") + _ = os.WriteFile(in, []byte("X"), 0o600) + r, w, ci, co, err := openIO(in, out) + if err != nil { t.Fatalf("openIO: %v", err) } + defer ci(); defer co() + if _, err := io.Copy(w, r); err != nil { t.Fatalf("copy: %v", err) } + b, _ := os.ReadFile(out) + if string(b) != "X" { t.Fatalf("got %q", string(b)) } +} diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 1492f0f..c57c838 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -8,7 +8,7 @@ pkgs=("$@") if [ ${#pkgs[@]} -eq 0 ]; then pkgs=( "codeberg.org/snonux/hexai/internal/tmux" - "codeberg.org/snonux/hexai/cmd/internal/hexai-action" + "codeberg.org/snonux/hexai/internal/hexaiaction" ) fi @@ -28,4 +28,3 @@ done echo echo "Hint: combine coverage across all packages with:" echo " go test ./... -coverprofile=cover.out && go tool cover -func=cover.out" - |
