From fb06bfa9dc7140f987bdbad59467a84610221fbb Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 7 Sep 2025 11:36:38 +0300 Subject: refactor: move hexai-action to cmd/hexai-action; extract orchestration into internal/hexaiaction; move tests; update Magefile and docs --- internal/hexaiaction/cmdentry.go | 168 ++++++++++++++++++++++++++++++++++ internal/hexaiaction/cmdentry_test.go | 152 ++++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 internal/hexaiaction/cmdentry.go create mode 100644 internal/hexaiaction/cmdentry_test.go (limited to 'internal') 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)) } +} -- cgit v1.2.3