| 1 | package command |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | |
| 8 | fileencoding "reasonix/internal/fileutil/encoding" |
| 9 | ) |
| 10 | |
| 11 | func TestRender(t *testing.T) { |
| 12 | c := Command{Body: "Review $1 focusing on $ARGUMENTS. Cost: $$5. Missing: [$3]"} |
| 13 | got := c.Render([]string{"main.go", "bugs"}) |
| 14 | want := "Review main.go focusing on main.go bugs. Cost: $5. Missing: []" |
| 15 | if got != want { |
| 16 | t.Errorf("Render = %q, want %q", got, want) |
| 17 | } |
| 18 | |
| 19 | // No args: $ARGUMENTS and $N collapse to empty. |
| 20 | if got := (Command{Body: "x=$ARGUMENTS y=$1"}).Render(nil); got != "x= y=" { |
| 21 | t.Errorf("empty-args Render = %q", got) |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | func write(t *testing.T, dir, rel, content string) { |
| 26 | t.Helper() |
| 27 | p := filepath.Join(dir, rel) |
| 28 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | if err := os.WriteFile(p, []byte(content), 0o644); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | func TestLoad(t *testing.T) { |
| 37 | dir := t.TempDir() |
| 38 | write(t, dir, "review.md", "---\ndescription: Review the diff\nargument-hint: [area]\n---\nReview, focus on $ARGUMENTS.") |
| 39 | write(t, dir, "plain.md", "No frontmatter, just $1.") |
| 40 | write(t, dir, "git/commit.md", "---\ndescription: Commit\n---\nWrite a commit message.") |
| 41 | write(t, dir, "notes.txt", "ignored — not markdown") |
| 42 | |
| 43 | cmds, err := Load(dir) |
| 44 | if err != nil { |
| 45 | t.Fatalf("Load: %v", err) |
| 46 | } |
| 47 | if len(cmds) != 3 { |
| 48 | t.Fatalf("loaded %d commands, want 3 (%v)", len(cmds), names(cmds)) |
| 49 | } |
| 50 | |
| 51 | byName := map[string]Command{} |
| 52 | for _, c := range cmds { |
| 53 | byName[c.Name] = c |
| 54 | } |
| 55 | |
| 56 | r, ok := byName["review"] |
| 57 | if !ok || r.Description != "Review the diff" || r.ArgHint != "[area]" { |
| 58 | t.Errorf("review parsed wrong: %+v", r) |
| 59 | } |
| 60 | if r.Body != "Review, focus on $ARGUMENTS." { |
| 61 | t.Errorf("review body = %q", r.Body) |
| 62 | } |
| 63 | if p := byName["plain"]; p.Body != "No frontmatter, just $1." || p.Description != "" { |
| 64 | t.Errorf("plain parsed wrong: %+v", p) |
| 65 | } |
| 66 | if _, ok := byName["git:commit"]; !ok { |
| 67 | t.Errorf("subdir namespacing failed: %v", names(cmds)) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestLoadDecodesGB18030CommandFile(t *testing.T) { |
| 72 | dir := t.TempDir() |
| 73 | body := "---\ndescription: 中文命令\nargument-hint: [主题]\n---\n请总结 $ARGUMENTS。" |
| 74 | path := filepath.Join(dir, "summary.md") |
| 75 | if err := os.WriteFile(path, fileencoding.Encode(body, fileencoding.GB18030), 0o644); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | |
| 79 | cmds, err := Load(dir) |
| 80 | if err != nil { |
| 81 | t.Fatalf("Load: %v", err) |
| 82 | } |
| 83 | if len(cmds) != 1 || cmds[0].Description != "中文命令" || cmds[0].ArgHint != "[主题]" || cmds[0].Body != "请总结 $ARGUMENTS。" { |
| 84 | t.Fatalf("decoded command = %+v", cmds) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestLoadOverrideAndMissingDir(t *testing.T) { |
| 89 | user := t.TempDir() |
| 90 | project := t.TempDir() |
| 91 | write(t, user, "review.md", "USER version") |
| 92 | write(t, project, "review.md", "PROJECT version") |
| 93 | |
| 94 | // Later dir (project) wins on a name clash; a non-existent dir is skipped. |
| 95 | cmds, err := Load("/no/such/dir", user, project) |
| 96 | if err != nil { |
| 97 | t.Fatalf("Load: %v", err) |
| 98 | } |
| 99 | if len(cmds) != 1 || cmds[0].Body != "PROJECT version" { |
| 100 | t.Errorf("override failed: %+v", cmds) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestLoadRootsUsesCanonicalPluginNamesAndHiddenCompatibleShortName(t *testing.T) { |
| 105 | pluginDir := t.TempDir() |
| 106 | projectDir := t.TempDir() |
| 107 | write(t, pluginDir, "plan.md", "---\ndescription: Plugin plan\n---\nPLUGIN $ARGUMENTS") |
| 108 | write(t, pluginDir, "status.md", "PLUGIN STATUS") |
| 109 | write(t, projectDir, "plan.md", "---\ndescription: Project plan\n---\nPROJECT $ARGUMENTS") |
| 110 | |
| 111 | cmds, err := LoadRoots( |
| 112 | Root{Path: pluginDir, Plugin: "planning-with-files"}, |
| 113 | Root{Path: projectDir}, |
| 114 | ) |
| 115 | if err != nil { |
| 116 | t.Fatalf("LoadRoots: %v", err) |
| 117 | } |
| 118 | byName := map[string]Command{} |
| 119 | for _, cmd := range cmds { |
| 120 | byName[cmd.Name] = cmd |
| 121 | } |
| 122 | if got := byName["plan"]; got.Body != "PROJECT $ARGUMENTS" || got.Plugin != "" || got.ShortName != "" || got.Hidden { |
| 123 | t.Fatalf("short-name winner = %+v, want project command", got) |
| 124 | } |
| 125 | canonical, ok := byName["planning-with-files:plan"] |
| 126 | if !ok || canonical.Body != "PLUGIN $ARGUMENTS" || canonical.Plugin != "planning-with-files" || canonical.ShortName != "plan" || canonical.Hidden { |
| 127 | t.Fatalf("canonical plugin command = %+v, %v", canonical, ok) |
| 128 | } |
| 129 | if got := byName["status"]; got.Plugin != "planning-with-files" || got.ShortName != "status" || !got.Hidden { |
| 130 | t.Fatalf("short compatibility command = %+v, want hidden plugin alias", got) |
| 131 | } |
| 132 | if got := byName["planning-with-files:status"]; got.Plugin != "planning-with-files" || got.ShortName != "status" || got.Hidden { |
| 133 | t.Fatalf("canonical status command = %+v", got) |
| 134 | } |
| 135 | if got := canonical.Render([]string{"feature"}); got != "PLUGIN feature" { |
| 136 | t.Fatalf("canonical command render = %q", got) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | func TestLoadRootsDoesNotReplaceAnExplicitQualifiedCommand(t *testing.T) { |
| 141 | pluginDir := t.TempDir() |
| 142 | projectDir := t.TempDir() |
| 143 | write(t, pluginDir, "plan.md", "PLUGIN") |
| 144 | write(t, projectDir, "plan.md", "PROJECT") |
| 145 | write(t, projectDir, "planning-with-files/plan.md", "EXPLICIT QUALIFIED") |
| 146 | |
| 147 | cmds, err := LoadRoots( |
| 148 | Root{Path: pluginDir, Plugin: "planning-with-files"}, |
| 149 | Root{Path: projectDir}, |
| 150 | ) |
| 151 | if err != nil { |
| 152 | t.Fatalf("LoadRoots: %v", err) |
| 153 | } |
| 154 | for _, cmd := range cmds { |
| 155 | if cmd.Name == "planning-with-files:plan" { |
| 156 | if cmd.Body != "EXPLICIT QUALIFIED" || cmd.Plugin != "" || cmd.ShortName != "" || cmd.Hidden { |
| 157 | t.Fatalf("explicit qualified command was replaced: %+v", cmd) |
| 158 | } |
| 159 | return |
| 160 | } |
| 161 | } |
| 162 | t.Fatal("explicit qualified command missing") |
| 163 | } |
| 164 | |
| 165 | func TestLoadRootsOmitsAmbiguousShortPluginName(t *testing.T) { |
| 166 | alpha := t.TempDir() |
| 167 | beta := t.TempDir() |
| 168 | write(t, alpha, "plan.md", "ALPHA") |
| 169 | write(t, beta, "plan.md", "BETA") |
| 170 | cmds, err := LoadRoots(Root{Path: alpha, Plugin: "alpha"}, Root{Path: beta, Plugin: "beta"}) |
| 171 | if err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | byName := map[string]Command{} |
| 175 | for _, cmd := range cmds { |
| 176 | byName[cmd.Name] = cmd |
| 177 | } |
| 178 | if _, ok := byName["plan"]; ok { |
| 179 | t.Fatal("ambiguous plugin short name must not remain invocable") |
| 180 | } |
| 181 | if byName["alpha:plan"].Body != "ALPHA" || byName["beta:plan"].Body != "BETA" { |
| 182 | t.Fatalf("canonical plugin commands = %+v", cmds) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func names(cmds []Command) []string { |
| 187 | out := make([]string, len(cmds)) |
| 188 | for i, c := range cmds { |
| 189 | out[i] = c.Name |
| 190 | } |
| 191 | return out |
| 192 | } |
| 193 |