| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "path/filepath" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/command" |
| 9 | "reasonix/internal/hook" |
| 10 | "reasonix/internal/memory" |
| 11 | "reasonix/internal/outputstyle" |
| 12 | "reasonix/internal/plugin" |
| 13 | "reasonix/internal/skill" |
| 14 | ) |
| 15 | |
| 16 | func TestRenderSkillListUsesSharedVisualLanguage(t *testing.T) { |
| 17 | width := 72 |
| 18 | got := renderSkillList(width, []skill.Skill{ |
| 19 | {Name: "explore", Description: strings.Repeat("long ", 30), Scope: skill.ScopeProject}, |
| 20 | {Name: "deep", Description: "run in isolation", Scope: skill.ScopeGlobal, RunAs: skill.RunSubagent}, |
| 21 | }, map[string]bool{"explore": true}) |
| 22 | for _, want := range []string{"skills (2)", "/explore", "(project)", "…", "/deep", "subagent", "disabled", "invoke:"} { |
| 23 | if !strings.Contains(got, want) { |
| 24 | t.Fatalf("skill list missing %q:\n%s", want, got) |
| 25 | } |
| 26 | } |
| 27 | assertLinesWithin(t, got, width) |
| 28 | } |
| 29 | |
| 30 | func TestRenderSkillShowCapsLongBody(t *testing.T) { |
| 31 | width := 80 |
| 32 | body := strings.Repeat("line\n", skillShowMaxLines+3) |
| 33 | got := renderSkillShow(width, skill.Skill{ |
| 34 | Name: "review", |
| 35 | Description: "review code", |
| 36 | Scope: skill.ScopeBuiltin, |
| 37 | Path: "/very/long/path/to/SKILL.md", |
| 38 | Body: body, |
| 39 | }, true) |
| 40 | for _, want := range []string{"skill:", "review", "builtin", "disabled", "review code", "SKILL.md", "+3 more lines"} { |
| 41 | if !strings.Contains(got, want) { |
| 42 | t.Fatalf("skill show missing %q:\n%s", want, got) |
| 43 | } |
| 44 | } |
| 45 | assertLinesWithin(t, got, width) |
| 46 | } |
| 47 | |
| 48 | func TestViewProtectLinesCompactsLongBodyLines(t *testing.T) { |
| 49 | got := viewProtectLines(strings.Repeat("x", 80)+"\nshort", 20) |
| 50 | lines := strings.Split(got, "\n") |
| 51 | if len(lines) != 2 || !strings.HasSuffix(lines[0], "…") || visibleWidth(lines[0]) > 20 || lines[1] != "short" { |
| 52 | t.Fatalf("protected lines = %q", got) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestRenderMemoryGroupsDocsAndStore(t *testing.T) { |
| 57 | width := 72 |
| 58 | store := memory.Store{Dir: filepath.Join(t.TempDir(), "memory")} |
| 59 | if _, err := store.Save(memory.Memory{Name: "saved-fact", Title: "Saved Fact", Description: "remembered fact"}); err != nil { |
| 60 | t.Fatalf("save memory: %v", err) |
| 61 | } |
| 62 | got := renderMemory(width, &memory.Set{ |
| 63 | Docs: []memory.Source{{Path: "/Users/me/project/REASONIX.md", Scope: memory.ScopeProject}}, |
| 64 | Store: store, |
| 65 | Index: store.Index(), |
| 66 | }) |
| 67 | for _, want := range []string{ |
| 68 | "memory", "instructions", "precedence=1", "scope=project", "REASONIX.md", |
| 69 | "saved memories", "saved-fact", "Saved Fact", "revision=1", "freshness=fresh", |
| 70 | "doc edits apply next session", |
| 71 | } { |
| 72 | if !strings.Contains(got, want) { |
| 73 | t.Fatalf("memory view missing %q:\n%s", want, got) |
| 74 | } |
| 75 | } |
| 76 | assertLinesWithin(t, got, width) |
| 77 | } |
| 78 | |
| 79 | func TestRenderOutputStylesUsesActiveStatus(t *testing.T) { |
| 80 | width := 72 |
| 81 | got := renderOutputStyles(width, []outputstyle.OutputStyle{ |
| 82 | {Name: "concise", Description: "short answers", Builtin: true}, |
| 83 | {Name: "team", Description: strings.Repeat("custom ", 20), Builtin: false}, |
| 84 | }, "team") |
| 85 | for _, want := range []string{"output styles", "concise", "(builtin)", "team", "(custom)", "active", "…"} { |
| 86 | if !strings.Contains(got, want) { |
| 87 | t.Fatalf("output style view missing %q:\n%s", want, got) |
| 88 | } |
| 89 | } |
| 90 | if strings.Contains(got, "*") { |
| 91 | t.Fatalf("output style view should use active status instead of star:\n%s", got) |
| 92 | } |
| 93 | assertLinesWithin(t, got, width) |
| 94 | } |
| 95 | |
| 96 | func TestRenderModelsUsesActiveStatus(t *testing.T) { |
| 97 | width := 72 |
| 98 | got := renderModels(width, []string{"deepseek/v4", "openai/really-long-model-name-" + strings.Repeat("x", 80)}, "deepseek/v4") |
| 99 | for _, want := range []string{"models", "deepseek/v4", "active", "…", "switch with /model"} { |
| 100 | if !strings.Contains(got, want) { |
| 101 | t.Fatalf("model view missing %q:\n%s", want, got) |
| 102 | } |
| 103 | } |
| 104 | assertLinesWithin(t, got, width) |
| 105 | } |
| 106 | |
| 107 | func TestRenderHooksUsesSharedVisualLanguage(t *testing.T) { |
| 108 | width := 72 |
| 109 | got := renderHooks(width, []hook.ResolvedHook{{ |
| 110 | HookConfig: hook.HookConfig{Command: strings.Repeat("echo ", 30)}, |
| 111 | Event: hook.PreToolUse, |
| 112 | Scope: hook.ScopeProject, |
| 113 | }}) |
| 114 | for _, want := range []string{"hooks (1 active)", "PreToolUse", "project", "…", ".reasonix/settings.json"} { |
| 115 | if !strings.Contains(got, want) { |
| 116 | t.Fatalf("hooks view missing %q:\n%s", want, got) |
| 117 | } |
| 118 | } |
| 119 | assertLinesWithin(t, got, width) |
| 120 | } |
| 121 | |
| 122 | func TestRenderHooksShowsPermissionRequestMatch(t *testing.T) { |
| 123 | width := 72 |
| 124 | got := renderHooks(width, []hook.ResolvedHook{{ |
| 125 | HookConfig: hook.HookConfig{Command: "notify", Match: "bash"}, |
| 126 | Event: hook.PermissionRequest, |
| 127 | Scope: hook.ScopeGlobal, |
| 128 | }}) |
| 129 | for _, want := range []string{"PermissionRequest", "global", "bash", "notify"} { |
| 130 | if !strings.Contains(got, want) { |
| 131 | t.Fatalf("hooks view missing %q:\n%s", want, got) |
| 132 | } |
| 133 | } |
| 134 | assertLinesWithin(t, got, width) |
| 135 | } |
| 136 | |
| 137 | func TestRenderHelpGroupsCommands(t *testing.T) { |
| 138 | width := 72 |
| 139 | got := renderHelp(width, |
| 140 | []command.Command{{Name: "review", Description: "review code"}}, |
| 141 | []skill.Skill{{Name: "explore", Description: "inspect repo"}}, |
| 142 | []plugin.Prompt{{Name: "mcp__docs__summarize", Description: "summarize docs"}}, |
| 143 | ) |
| 144 | for _, want := range []string{"commands", "built-in", "/tree", "custom", "/review", "skills", "/explore", "MCP prompts", "/mcp__docs__summarize"} { |
| 145 | if !strings.Contains(got, want) { |
| 146 | t.Fatalf("help view missing %q:\n%s", want, got) |
| 147 | } |
| 148 | } |
| 149 | if strings.Contains(got, " · ") { |
| 150 | t.Fatalf("help view should not use one-line command chains:\n%s", got) |
| 151 | } |
| 152 | assertLinesWithin(t, got, width) |
| 153 | } |
| 154 | |
| 155 | func TestRenderHelpDocsShowsOnlyRuntimeWinner(t *testing.T) { |
| 156 | got := renderHelp(72, |
| 157 | []command.Command{{Name: "docs", Description: "custom docs"}}, |
| 158 | []skill.Skill{{Name: "docs", Description: "docs skill"}}, |
| 159 | nil, |
| 160 | ) |
| 161 | if count := strings.Count(got, "/docs"); count != 1 { |
| 162 | t.Fatalf("help contains %d /docs entries, want one:\n%s", count, got) |
| 163 | } |
| 164 | if !strings.Contains(got, "custom docs") || strings.Contains(got, "docs skill") { |
| 165 | t.Fatalf("help did not preserve the runtime-winning custom command:\n%s", got) |
| 166 | } |
| 167 | if !strings.Contains(got, "/reasonix:docs") { |
| 168 | t.Fatalf("help did not preserve the qualified built-in docs fallback:\n%s", got) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestRenderHelpDocsUsesQualifiedFallbackForHiddenAlias(t *testing.T) { |
| 173 | got := renderHelp(72, |
| 174 | []command.Command{ |
| 175 | {Name: "docs", Plugin: "manuals", Hidden: true}, |
| 176 | {Name: "manuals:docs", Plugin: "manuals", Description: "plugin docs"}, |
| 177 | }, |
| 178 | nil, |
| 179 | nil, |
| 180 | ) |
| 181 | if strings.Contains(got, "\n /docs ") { |
| 182 | t.Fatalf("help exposed a misleading hidden /docs alias:\n%s", got) |
| 183 | } |
| 184 | for _, want := range []string{"/reasonix:docs", "/manuals:docs"} { |
| 185 | if !strings.Contains(got, want) { |
| 186 | t.Fatalf("help missing %q:\n%s", want, got) |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func TestRenderSkillPathsStaysWithinWidth(t *testing.T) { |
| 192 | width := 72 |
| 193 | got := renderSkillPaths(width, []skill.Root{{ |
| 194 | Dir: "/Users/me/projects/really/deep/path/to/.reasonix/skills", |
| 195 | Scope: skill.ScopeProject, |
| 196 | Priority: 0, |
| 197 | Status: skill.StatusMissing, |
| 198 | }}) |
| 199 | assertLinesWithin(t, got, width) |
| 200 | } |
| 201 | |
| 202 | func TestRenderMCPStatusStaysWithinWidth(t *testing.T) { |
| 203 | width := 72 |
| 204 | got := renderMCPStatus(width, |
| 205 | []plugin.ServerStatus{{Name: "a-very-long-server-name-that-should-not-wrap", Transport: "stdio", Tools: 12}}, |
| 206 | []plugin.Prompt{{Server: "a-very-long-server-name-that-should-not-wrap", Name: "mcp__server__prompt_with_a_really_long_name", Description: strings.Repeat("describe ", 20)}}, |
| 207 | []plugin.Resource{{Server: "a-very-long-server-name-that-should-not-wrap", URI: "file:///Users/me/project/docs/really/deep/resource.md", Name: strings.Repeat("resource ", 20)}}, |
| 208 | []plugin.Failure{{Name: "another-very-long-server-name-that-should-not-wrap", Transport: "stdio", Error: strings.Repeat("failure ", 20)}}, |
| 209 | ) |
| 210 | assertLinesWithin(t, got, width) |
| 211 | } |
| 212 | |
| 213 | func assertLinesWithin(t *testing.T, s string, width int) { |
| 214 | t.Helper() |
| 215 | for i, line := range strings.Split(s, "\n") { |
| 216 | if got := visibleWidth(line); got > width { |
| 217 | t.Fatalf("line %d exceeds width %d with %d cols:\n%s\n\nfull output:\n%s", i+1, width, got, line, s) |
| 218 | } |
| 219 | if strings.Contains(line, "\n") { |
| 220 | t.Fatalf("line %d unexpectedly contains newline: %q", i+1, line) |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 |