Vectrum
Sign In
256 lines · 9.9 KB · Go
package main

import (
	"encoding/json"
	"flag"
	"io"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"testing"

	"github.com/johannSo/codey/internal/approval"
)

// testFlags is a flag set shaped like main's: the Codey flags the pre-pass has
// to know the arity of, and the Claude Code ones.
func testFlags() (*flag.FlagSet, *claudeFlags, *string, *string, *string, *bool) {
	fs := flag.NewFlagSet("codey", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	prompt := fs.String("p", "", "")
	model := fs.String("model", "", "")
	resume := fs.String("resume", "", "")
	v := fs.Bool("v", false, "")
	fs.Int("max-turns", 0, "")
	fs.String("C", ".", "")
	fs.Bool("yolo", false, "")
	return fs, registerClaudeFlags(fs), prompt, model, resume, v
}

func TestPrepareArgsReadsBothSpellingsOfP(t *testing.T) {
	cases := []struct {
		name       string
		args       []string
		wantPrompt string // what -p ends up holding
		wantPrint  bool   // -p given as a switch
		wantArgs   []string
		wantModel  string
	}{
		{name: "codey style", args: []string{"-p", "fix it"}, wantPrompt: "fix it"},
		{name: "claude style, prompt last", args: []string{"-p", "--output-format", "json", "fix it"}, wantPrint: true, wantArgs: []string{"fix it"}},
		{name: "--print", args: []string{"--print", "--model", "m", "fix", "it"}, wantPrint: true, wantArgs: []string{"fix", "it"}, wantModel: "m"},
		{name: "prompt first, flags after", args: []string{"fix it", "-p", "--model", "m"}, wantPrint: true, wantArgs: []string{"fix it"}, wantModel: "m"},
		{name: "-p with its prompt, then flags", args: []string{"-p", "fix it", "--model=m"}, wantPrompt: "fix it", wantModel: "m"},
		{name: "-p alone", args: []string{"-p"}, wantPrint: true},
		{name: "-p= keeps a leading dash", args: []string{"-p=-x is broken"}, wantPrompt: "-x is broken"},
		{name: "double-dash value flags", args: []string{"--model", "m", "--max-turns", "3", "-p", "go"}, wantPrompt: "go", wantModel: "m"},
		{name: "bool flags take no value", args: []string{"-p", "-v", "go"}, wantPrint: true, wantArgs: []string{"go"}},
		{name: "-- ends the flags", args: []string{"-p", "--", "--model", "x"}, wantPrint: true, wantArgs: []string{"--model", "x"}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			fs, cc, prompt, model, _, _ := testFlags()
			if err := fs.Parse(prepareArgs(fs, c.args, cc)); err != nil {
				t.Fatalf("parse: %v", err)
			}
			if *prompt != c.wantPrompt || cc.print != c.wantPrint || *model != c.wantModel {
				t.Errorf("-p=%q print=%v model=%q, want %q %v %q", *prompt, cc.print, *model, c.wantPrompt, c.wantPrint, c.wantModel)
			}
			if got := fs.Args(); !slices.Equal(got, c.wantArgs) && !(len(got) == 0 && len(c.wantArgs) == 0) {
				t.Errorf("positional = %q, want %q", got, c.wantArgs)
			}
		})
	}
}

func TestPrepareArgsTakesSeveralRulesInARow(t *testing.T) {
	fs, cc, _, _, _, _ := testFlags()
	args := []string{"-p", "--allowedTools", "Read", "Bash(git log:*)", "Edit,Write", "summarise the log", "--disallowedTools", "WebFetch"}
	if err := fs.Parse(prepareArgs(fs, args, cc)); err != nil {
		t.Fatal(err)
	}
	if want := []string{"Read", "Bash(git log:*)", "Edit", "Write"}; !slices.Equal(cc.allowed, want) {
		t.Errorf("allowed = %q, want %q", cc.allowed, want)
	}
	if !slices.Equal(cc.disallowed, []string{"WebFetch"}) {
		t.Errorf("disallowed = %q", cc.disallowed)
	}
	if got := fs.Args(); !slices.Equal(got, []string{"summarise the log"}) {
		t.Errorf("the prompt should stop the rule list: positional = %q", got)
	}

	// A one-word prompt is not a tool name, so it is not eaten as a rule.
	fs, cc, _, _, _, _ = testFlags()
	fs.Parse(prepareArgs(fs, []string{"-p", "--allowed-tools", "Read", "hello"}, cc))
	if !slices.Equal(cc.allowed, []string{"Read"}) || !slices.Equal(fs.Args(), []string{"hello"}) {
		t.Errorf("allowed=%q positional=%q", cc.allowed, fs.Args())
	}
}

func TestPrepareArgsLeavesTheUnknownToTheFlagPackage(t *testing.T) {
	fs, cc, _, _, _, _ := testFlags()
	if err := fs.Parse(prepareArgs(fs, []string{"--mcp-config", "x.json", "-p", "go"}, cc)); err == nil {
		t.Error("an unknown flag should still be an error")
	}
}

// The agent-run pre-parse in agentcmd.go still sees the line first, and an
// ordinary line still comes through it untouched for prepareArgs.
func TestScanArgsStillHandsOrdinaryLinesOn(t *testing.T) {
	args := []string{"-p", "--permission-mode", "acceptEdits", "--output-format", "json", "go"}
	inv := scanArgs(args)
	if inv.Kind != invNormal || !slices.Equal(inv.Rest, args) {
		t.Fatalf("scanArgs = %+v", inv)
	}
	fs, cc, _, _, _, _ := testFlags()
	if err := fs.Parse(prepareArgs(fs, inv.Rest, cc)); err != nil {
		t.Fatal(err)
	}
	if cc.permissionMode != "acceptEdits" || cc.outputFormat != "json" || !cc.print {
		t.Errorf("flags = %+v", cc)
	}
}

// ttyless is a stdin that is a file, not a terminal: what a CI job has.
func ttyless(t *testing.T, content string) *os.File {
	t.Helper()
	p := filepath.Join(t.TempDir(), "stdin")
	os.WriteFile(p, []byte(content), 0o644)
	f, err := os.Open(p)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { f.Close() })
	return f
}

func parseResolve(t *testing.T, stdin *os.File, args ...string) (cliRun, error) {
	t.Helper()
	fs, cc, prompt, _, resume, _ := testFlags()
	if err := fs.Parse(prepareArgs(fs, args, cc)); err != nil {
		t.Fatalf("parse %q: %v", args, err)
	}
	return cc.resolve(*prompt, fs.Args(), *resume, "/work", stdin)
}

func TestResolveJoinsThePrompt(t *testing.T) {
	r, err := parseResolve(t, ttyless(t, "ignored"), "-p", "fix", "the", "bug")
	if err != nil || r.Prompt != "fix the bug" || r.FromStdin {
		t.Errorf("prompt=%q fromStdin=%v err=%v", r.Prompt, r.FromStdin, err)
	}
}

func TestResolveReadsThePromptFromStdin(t *testing.T) {
	r, err := parseResolve(t, ttyless(t, "  explain main.go\n"), "-p", "--output-format", "json")
	if err != nil {
		t.Fatal(err)
	}
	if r.Prompt != "explain main.go" || !r.FromStdin || !r.NoPrompt || r.Format != formatJSON {
		t.Errorf("got %+v", r)
	}
	if _, err := parseResolve(t, ttyless(t, ""), "-p"); err == nil || !strings.Contains(err.Error(), "needs a prompt") {
		t.Errorf("no prompt anywhere should be an error, got %v", err)
	}
}

func TestResolveRefusesWhatItCannotDo(t *testing.T) {
	stdin := ttyless(t, "")
	for _, args := range [][]string{
		{"-p", "x", "--output-format", "yaml"},
		{"--output-format", "json"},
		{"-p", "x", "--input-format", "stream-json"},
		{"-p", "x", "--permission-mode", "plan"},
		{"-p", "x", "--permission-mode", "whatever"},
		{"-c", "-resume", "20260101T000000Z", "-p", "x"},
	} {
		if _, err := parseResolve(t, stdin, args...); err == nil {
			t.Errorf("%q should be refused", args)
		}
	}
	_, err := parseResolve(t, stdin, "-p", "x", "--permission-mode", "plan")
	if err == nil || !strings.Contains(err.Error(), "plan mode is not supported") {
		t.Errorf("plan mode should say so plainly, got %v", err)
	}
}

func TestResolvePermissionModes(t *testing.T) {
	stdin := ttyless(t, "")
	edit := json.RawMessage(`{"path":"a.go"}`)

	r, _ := parseResolve(t, stdin, "-p", "x", "--permission-mode", "acceptEdits")
	if v, _ := r.Rules.Check("write_file", edit); v != approval.Allowed || r.Mode != "acceptEdits" || r.YOLO {
		t.Errorf("acceptEdits should allow edits and only edits: %+v", r)
	}
	if v, _ := r.Rules.Check("bash", json.RawMessage(`{"command":"ls"}`)); v == approval.Allowed {
		t.Error("acceptEdits still asks before a command")
	}

	for _, args := range [][]string{
		{"-p", "x", "--permission-mode", "bypassPermissions"},
		{"-p", "x", "--dangerously-skip-permissions"},
	} {
		if r, _ := parseResolve(t, stdin, args...); !r.YOLO || r.Mode != "bypassPermissions" {
			t.Errorf("%q should be -yolo: %+v", args, r)
		}
	}
	if r, _ := parseResolve(t, stdin, "-p", "x", "--permission-mode", "dontAsk"); !r.NoPrompt {
		t.Error("dontAsk refuses rather than asking")
	}
	// Codey's own -p keeps asking on stdin, as `yes | codey -p` relies on.
	if r, _ := parseResolve(t, stdin, "-p", "x"); r.NoPrompt {
		t.Error("plain -p with a prompt argument should still read approvals from stdin")
	}
	// A Claude-shaped run with no terminal never asks.
	if r, _ := parseResolve(t, stdin, "-p", "x", "--output-format", "text"); !r.NoPrompt {
		t.Error("an output format with no terminal should not ask")
	}
}

func TestResolveContinue(t *testing.T) {
	stdin := ttyless(t, "")
	for _, args := range [][]string{{"-c", "-p", "x"}, {"--continue", "-p", "x"}, {"-c", "-resume", "latest", "-p", "x"}} {
		if r, err := parseResolve(t, stdin, args...); err != nil || r.Resume != "latest" {
			t.Errorf("%q: resume=%q err=%v", args, r.Resume, err)
		}
	}
	if r, _ := parseResolve(t, stdin, "--resume", "abc", "-p", "x"); r.Resume != "abc" {
		t.Errorf("--resume <id> is unchanged, got %q", r.Resume)
	}
}

func TestResolveRules(t *testing.T) {
	stdin := ttyless(t, "")
	r, err := parseResolve(t, stdin, "-p", "x",
		"--allowedTools", "Bash(go test:*),Read(./src/**)",
		"--disallowedTools", "WebSearch Bash(git push:*) mcp__db NotebookEdit")
	if err != nil {
		t.Fatal(err)
	}
	if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "NotebookEdit") {
		t.Errorf("an unknown tool is a warning, not an error: %q", r.Warnings)
	}
	for name, want := range map[string]bool{
		"web_search": true, "mcp__db__query": true, "bash": false, "read_file": false,
	} {
		if got := r.Drop(name); got != want {
			t.Errorf("Drop(%q) = %v, want %v", name, got, want)
		}
	}
	if !r.DropHostedSearch {
		t.Error("WebSearch disallowed should take hosted search off the request too")
	}
	if v, _ := r.Rules.Check("bash", json.RawMessage(`{"command":"go test ./... && git push"}`)); v != approval.Denied {
		t.Errorf("a scoped deny rule is enforced at approval time, got %v", v)
	}
	if v, _ := r.Rules.Check("read_file", json.RawMessage(`{"path":"/work/src/a.go"}`)); v != approval.Allowed {
		t.Errorf("relative path rules are read against the working root, got %v", v)
	}
}

func TestResolveWarnsAboutAPromptWithoutP(t *testing.T) {
	r, err := parseResolve(t, ttyless(t, ""), "fix it")
	if err != nil || r.Prompt != "" || len(r.Warnings) != 1 {
		t.Errorf("prompt=%q warnings=%q err=%v", r.Prompt, r.Warnings, err)
	}
}