diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-08 12:02:40 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-08 12:02:40 +0300 |
| commit | 75cf6abd55bfb60324fc47cf91eac08dbb8b87b4 (patch) | |
| tree | 6ef90d8014fe4d9a757d3f7e95bf736b70e4c685 /internal/hexaiaction | |
| parent | 0dcf347c3fbc6e4ffb7e46294f5dd92dbbcd98ef (diff) | |
docs: move tmux documentation to its own file
Diffstat (limited to 'internal/hexaiaction')
| -rw-r--r-- | internal/hexaiaction/cmdentry.go | 252 | ||||
| -rw-r--r-- | internal/hexaiaction/cmdentry_runcommand_test.go | 87 | ||||
| -rw-r--r-- | internal/hexaiaction/cmdentry_test.go | 250 | ||||
| -rw-r--r-- | internal/hexaiaction/custom_action_test.go | 59 | ||||
| -rw-r--r-- | internal/hexaiaction/parse.go | 8 | ||||
| -rw-r--r-- | internal/hexaiaction/parse_test.go | 2 | ||||
| -rw-r--r-- | internal/hexaiaction/prompts_more_test.go | 23 | ||||
| -rw-r--r-- | internal/hexaiaction/run_more_test.go | 35 | ||||
| -rw-r--r-- | internal/hexaiaction/run_seam_test.go | 56 | ||||
| -rw-r--r-- | internal/hexaiaction/run_test.go | 70 | ||||
| -rw-r--r-- | internal/hexaiaction/tui.go | 114 | ||||
| -rw-r--r-- | internal/hexaiaction/tui_delegate.go | 36 | ||||
| -rw-r--r-- | internal/hexaiaction/tui_delegate_test.go | 42 | ||||
| -rw-r--r-- | internal/hexaiaction/tui_test.go | 74 | ||||
| -rw-r--r-- | internal/hexaiaction/types.go | 14 |
15 files changed, 620 insertions, 502 deletions
diff --git a/internal/hexaiaction/cmdentry.go b/internal/hexaiaction/cmdentry.go index cf72495..ca33443 100644 --- a/internal/hexaiaction/cmdentry.go +++ b/internal/hexaiaction/cmdentry.go @@ -1,149 +1,183 @@ package hexaiaction import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "time" + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" - "codeberg.org/snonux/hexai/internal/tmux" - "golang.org/x/term" + "codeberg.org/snonux/hexai/internal/tmux" + "golang.org/x/term" ) // Options configures the command-line orchestration for hexai-tmux-action. type Options struct { - Infile string - Outfile string - UIChild bool - TmuxTarget string - TmuxSplit string // "v" or "h" - TmuxPercent int // 1-100 + Infile string + Outfile string + UIChild bool + TmuxTarget string + TmuxSplit string // "v" or "h" + TmuxPercent int // 1-100 } // RunCommand is the CLI orchestrator used by cmd/hexai-tmux-action. It runs in tmux // split-pane mode by default, or child mode when -ui-child is set. 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) - } - // Always use tmux path - return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent) + if opts.UIChild { + return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr) + } + // Always use tmux path + return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent) } // seams for unit tests -var isTTYFn = func(fd uintptr) bool { return term.IsTerminal(int(fd)) } -var splitRunFn = tmux.SplitRun -var osExecutableFn = os.Executable -var runFn = Run +var ( + isTTYFn = func(fd uintptr) bool { return term.IsTerminal(int(fd)) } + splitRunFn = tmux.SplitRun + osExecutableFn = os.Executable + runFn = Run +) // 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-tmux-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-tmux-action: cannot open outfile: %w", err) } - out = f - closeOut = func() { _ = f.Close() } - } - return in, out, closeIn, closeOut, nil + 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-tmux-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-tmux-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-tmux-action child: %v; echo failed: %v", err, copyErr) - } - } else { - closeOut() - } - return os.Rename(tmp, outfile) + 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-tmux-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-tmux-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) + dir, err := os.MkdirTemp("", "hexai-tmux-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() + 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-tmux-action: timeout waiting for reply file") } - time.Sleep(200 * time.Millisecond) - } + deadline := time.Now().Add(timeout) + for { + if _, err := os.Stat(path); err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("hexai-tmux-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 + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + _, err = io.Copy(w, f) + return err } // echoThrough no longer used in tmux-only flow, but kept for potential reuse. 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 + 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_runcommand_test.go b/internal/hexaiaction/cmdentry_runcommand_test.go index 092e43b..b139bb3 100644 --- a/internal/hexaiaction/cmdentry_runcommand_test.go +++ b/internal/hexaiaction/cmdentry_runcommand_test.go @@ -1,53 +1,60 @@ package hexaiaction import ( - "bytes" - "context" - "io" - "os" - "path/filepath" - "testing" + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" - "codeberg.org/snonux/hexai/internal/tmux" + "codeberg.org/snonux/hexai/internal/tmux" ) func TestRunCommand_UIChild(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 := runFn - runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { _, _ = io.WriteString(w, "OK"); return nil } - t.Cleanup(func(){ runFn = old }) - opts := Options{Infile: in, Outfile: out, UIChild: true} - if err := RunCommand(context.Background(), opts, bytes.NewBuffer(nil), io.Discard, io.Discard); err != nil { - t.Fatalf("RunCommand UIChild: %v", err) - } - b, _ := os.ReadFile(out) - if string(b) != "OK" { t.Fatalf("outfile: %q", string(b)) } + dir := t.TempDir() + in := filepath.Join(dir, "in.txt") + out := filepath.Join(dir, "out.txt") + _ = os.WriteFile(in, []byte("sel"), 0o600) + old := runFn + runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { + _, _ = io.WriteString(w, "OK") + return nil + } + t.Cleanup(func() { runFn = old }) + opts := Options{Infile: in, Outfile: out, UIChild: true} + if err := RunCommand(context.Background(), opts, bytes.NewBuffer(nil), io.Discard, io.Discard); err != nil { + t.Fatalf("RunCommand UIChild: %v", err) + } + b, _ := os.ReadFile(out) + if string(b) != "OK" { + t.Fatalf("outfile: %q", string(b)) + } } func TestRunCommand_Tmux(t *testing.T) { - oldTTY := isTTYFn - oldExec := osExecutableFn - oldSplit := splitRunFn - isTTYFn = func(_ uintptr) bool { return false } - osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil } - splitRunFn = func(_ 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"), 0o600) - break - } - } - return nil - } - defer func(){ isTTYFn = oldTTY; osExecutableFn = oldExec; splitRunFn = oldSplit }() - var out bytes.Buffer - if err := RunCommand(context.Background(), Options{}, bytes.NewBufferString("X"), &out, io.Discard); err != nil { - t.Fatalf("RunCommand tmux: %v", err) - } - if out.String() != "OUT" { t.Fatalf("stdout: %q", out.String()) } + oldTTY := isTTYFn + oldExec := osExecutableFn + oldSplit := splitRunFn + isTTYFn = func(_ uintptr) bool { return false } + osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil } + splitRunFn = func(_ 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"), 0o600) + break + } + } + return nil + } + defer func() { isTTYFn = oldTTY; osExecutableFn = oldExec; splitRunFn = oldSplit }() + var out bytes.Buffer + if err := RunCommand(context.Background(), Options{}, bytes.NewBufferString("X"), &out, io.Discard); err != nil { + t.Fatalf("RunCommand tmux: %v", err) + } + if out.String() != "OUT" { + t.Fatalf("stdout: %q", out.String()) + } } // Inline TTY path is exercised implicitly via other helpers; testing it directly diff --git a/internal/hexaiaction/cmdentry_test.go b/internal/hexaiaction/cmdentry_test.go index de8b5dd..9c896f6 100644 --- a/internal/hexaiaction/cmdentry_test.go +++ b/internal/hexaiaction/cmdentry_test.go @@ -1,135 +1,183 @@ package hexaiaction import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "testing" - "time" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" - "codeberg.org/snonux/hexai/internal/tmux" + "codeberg.org/snonux/hexai/internal/tmux" ) // tmux-only flow: decision helpers removed. 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)) } + 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)) } + 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)) } + // 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-tmux-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 + 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-tmux-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") } + 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-tmux-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") } + oldExec := osExecutableFn + osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-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") } + // 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") } + 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)) } + 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/internal/hexaiaction/custom_action_test.go b/internal/hexaiaction/custom_action_test.go index 451a313..72cfbc4 100644 --- a/internal/hexaiaction/custom_action_test.go +++ b/internal/hexaiaction/custom_action_test.go @@ -1,39 +1,46 @@ package hexaiaction import ( - "bytes" - "context" - "testing" + "bytes" + "context" + "os" + "testing" - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/editor" - "codeberg.org/snonux/hexai/internal/llm" - "os" + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" + "codeberg.org/snonux/hexai/internal/llm" ) type llmFake2 struct{} -func (llmFake2) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return "DONE", nil } -func (llmFake2) Name() string { return "fake" } + +func (llmFake2) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { + return "DONE", nil +} +func (llmFake2) Name() string { return "fake" } func (llmFake2) DefaultModel() string { return "m" } func TestActionCustom_UsesEditorPrompt(t *testing.T) { - // Seam: choose custom, fake client, and fake editor - oldChoose := chooseActionFn - oldNew := newClientFromApp - chooseActionFn = func() (ActionKind, error) { return ActionCustom, nil } - newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake2{}, nil } - t.Cleanup(func(){ chooseActionFn = oldChoose; newClientFromApp = oldNew }) + // Seam: choose custom, fake client, and fake editor + oldChoose := chooseActionFn + oldNew := newClientFromApp + chooseActionFn = func() (ActionKind, error) { return ActionCustom, nil } + newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake2{}, nil } + t.Cleanup(func() { chooseActionFn = oldChoose; newClientFromApp = oldNew }) - oldRunEd := editor.RunEditor - editor.RunEditor = func(_ string, path string) error { - return os.WriteFile(path, []byte("make it done"), 0o600) - } - t.Cleanup(func(){ editor.RunEditor = oldRunEd }) - t.Setenv("HEXAI_EDITOR", "dummy") + oldRunEd := editor.RunEditor + editor.RunEditor = func(_ string, path string) error { + return os.WriteFile(path, []byte("make it done"), 0o600) + } + t.Cleanup(func() { editor.RunEditor = oldRunEd }) + t.Setenv("HEXAI_EDITOR", "dummy") - in := bytes.NewBufferString("some code") - var out bytes.Buffer - var errb bytes.Buffer - if err := Run(context.Background(), in, &out, &errb); err != nil { t.Fatalf("Run: %v", err) } - if out.String() == "" { t.Fatalf("expected output") } + in := bytes.NewBufferString("some code") + var out bytes.Buffer + var errb bytes.Buffer + if err := Run(context.Background(), in, &out, &errb); err != nil { + t.Fatalf("Run: %v", err) + } + if out.String() == "" { + t.Fatalf("expected output") + } } diff --git a/internal/hexaiaction/parse.go b/internal/hexaiaction/parse.go index 99e2b24..33fc4af 100644 --- a/internal/hexaiaction/parse.go +++ b/internal/hexaiaction/parse.go @@ -1,11 +1,11 @@ package hexaiaction import ( - "bufio" - "io" - "strings" + "bufio" + "io" + "strings" - "codeberg.org/snonux/hexai/internal/textutil" + "codeberg.org/snonux/hexai/internal/textutil" ) // ParseInput splits raw stdin into optional diagnostics and selection/code. diff --git a/internal/hexaiaction/parse_test.go b/internal/hexaiaction/parse_test.go index f81ab54..ba5cd96 100644 --- a/internal/hexaiaction/parse_test.go +++ b/internal/hexaiaction/parse_test.go @@ -77,6 +77,8 @@ func (f *fakeClient) Chat(_ context.Context, msgs []llm.Message, _ ...llm.Reques return f.out, f.err } +func (f *fakeClient) DefaultModel() string { return "m" } + func TestRuners_Prompts(t *testing.T) { cfg := appconfig.App{ PromptCodeActionRewriteSystem: "SYS-R", diff --git a/internal/hexaiaction/prompts_more_test.go b/internal/hexaiaction/prompts_more_test.go index 62abc97..9f5d6cb 100644 --- a/internal/hexaiaction/prompts_more_test.go +++ b/internal/hexaiaction/prompts_more_test.go @@ -1,19 +1,26 @@ package hexaiaction import ( - "context" - "strings" - "testing" + "context" + "strings" + "testing" - "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/llm" ) type simpleDoer struct{ s string } -func (d simpleDoer) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return d.s, nil } +func (d simpleDoer) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { + return d.s, nil +} +func (d simpleDoer) DefaultModel() string { return "m" } func TestRunOnce_StripsFences(t *testing.T) { - got, err := runOnce(context.Background(), simpleDoer{"```\nok\n```"}, "SYS", "USER") - if err != nil { t.Fatalf("runOnce: %v", err) } - if strings.TrimSpace(got) != "ok" { t.Fatalf("got %q", got) } + got, err := runOnce(context.Background(), simpleDoer{"```\nok\n```"}, "SYS", "USER") + if err != nil { + t.Fatalf("runOnce: %v", err) + } + if strings.TrimSpace(got) != "ok" { + t.Fatalf("got %q", got) + } } diff --git a/internal/hexaiaction/run_more_test.go b/internal/hexaiaction/run_more_test.go index d7ab025..1c0eb51 100644 --- a/internal/hexaiaction/run_more_test.go +++ b/internal/hexaiaction/run_more_test.go @@ -1,26 +1,25 @@ package hexaiaction import ( - "bytes" - "context" - "os" - "testing" + "bytes" + "context" + "os" + "testing" ) // Covers the early error path in Run when no API key is available for the default provider. func TestRun_MissingAPIKey(t *testing.T) { - // Ensure no provider API keys in env - for _, k := range []string{"HEXAI_OPENAI_API_KEY", "OPENAI_API_KEY", "HEXAI_COPILOT_API_KEY", "COPILOT_API_KEY"} { - t.Setenv(k, "") - } - // Provide minimal stdin to get past empty input check (if reached) - in := bytes.NewBufferString("some selection text") - var out bytes.Buffer - var errBuf bytes.Buffer - // Expect an error due to missing OPENAI_API_KEY (default provider is openai) - if err := Run(context.Background(), in, &out, &errBuf); err == nil { - t.Fatal("expected error when API key is missing") - } - _ = os.Stderr + // Ensure no provider API keys in env + for _, k := range []string{"HEXAI_OPENAI_API_KEY", "OPENAI_API_KEY", "HEXAI_COPILOT_API_KEY", "COPILOT_API_KEY"} { + t.Setenv(k, "") + } + // Provide minimal stdin to get past empty input check (if reached) + in := bytes.NewBufferString("some selection text") + var out bytes.Buffer + var errBuf bytes.Buffer + // Expect an error due to missing OPENAI_API_KEY (default provider is openai) + if err := Run(context.Background(), in, &out, &errBuf); err == nil { + t.Fatal("expected error when API key is missing") + } + _ = os.Stderr } - diff --git a/internal/hexaiaction/run_seam_test.go b/internal/hexaiaction/run_seam_test.go index 0b8761f..bbec858 100644 --- a/internal/hexaiaction/run_seam_test.go +++ b/internal/hexaiaction/run_seam_test.go @@ -1,36 +1,46 @@ package hexaiaction import ( - "bytes" - "context" - "testing" + "bytes" + "context" + "testing" - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" ) type llmFake struct{} -func (llmFake) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return "OK", nil } +func (llmFake) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { + return "OK", nil +} func (llmFake) Name() string { return "fake" } func (llmFake) DefaultModel() string { return "model" } func TestRun_WithSeams_SkipAndRewrite(t *testing.T) { - // Seam: choose action to Skip first, then Rewrite - oldChoose := choo |
