|
1 | 1 | use crate::common::wt_command; |
2 | 2 | use std::collections::HashSet; |
3 | | -use worktrunk::styling::SUCCESS_SYMBOL; |
4 | 3 |
|
5 | | -/// Issue found during validation |
6 | | -#[derive(Debug)] |
7 | | -struct Issue { |
8 | | - shell: String, |
9 | | - severity: Severity, |
10 | | - category: Category, |
11 | | - message: String, |
12 | | -} |
13 | | - |
14 | | -#[derive(Debug, PartialEq)] |
15 | | -enum Severity { |
16 | | - Error, |
17 | | - Warning, |
18 | | -} |
19 | | - |
20 | | -#[derive(Debug)] |
21 | | -enum Category { |
22 | | - HiddenFlag, |
23 | | - Consistency, |
24 | | -} |
25 | | - |
26 | | -impl Issue { |
27 | | - fn error(shell: impl Into<String>, category: Category, message: impl Into<String>) -> Self { |
28 | | - Self { |
29 | | - shell: shell.into(), |
30 | | - severity: Severity::Error, |
31 | | - category, |
32 | | - message: message.into(), |
33 | | - } |
34 | | - } |
35 | | - |
36 | | - fn warning(shell: impl Into<String>, category: Category, message: impl Into<String>) -> Self { |
37 | | - Self { |
38 | | - shell: shell.into(), |
39 | | - severity: Severity::Warning, |
40 | | - category, |
41 | | - message: message.into(), |
42 | | - } |
43 | | - } |
44 | | -} |
45 | | - |
46 | | -/// Validate fish shell completions |
47 | | -fn validate_fish(_content: &str) -> Vec<Issue> { |
48 | | - // No hidden flags to check after removing --internal |
49 | | - Vec::new() |
50 | | -} |
51 | | - |
52 | | -/// Validate bash shell completions |
53 | | -fn validate_bash(_content: &str) -> Vec<Issue> { |
54 | | - // No hidden flags to check after removing --internal |
55 | | - Vec::new() |
56 | | -} |
57 | | - |
58 | | -/// Validate zsh shell completions |
59 | | -fn validate_zsh(_content: &str) -> Vec<Issue> { |
60 | | - // No hidden flags to check after removing --internal |
61 | | - Vec::new() |
62 | | -} |
63 | | - |
64 | | -/// Extract flags from shell completion content |
65 | | -fn extract_flags(content: &str, shell: &str) -> HashSet<String> { |
66 | | - let mut flags = HashSet::new(); |
67 | | - |
68 | | - match shell { |
69 | | - "fish" => { |
70 | | - // Only from 'complete -c wt' lines, not local variables |
71 | | - for line in content.lines() { |
72 | | - if line.contains("complete -c wt") |
73 | | - && let Some(captures) = line.split("-l ").nth(1) |
74 | | - && let Some(flag) = captures.split_whitespace().next() |
75 | | - { |
76 | | - flags.insert(flag.to_string()); |
77 | | - } |
78 | | - } |
79 | | - } |
80 | | - "bash" => { |
81 | | - // From opts= lines |
82 | | - for line in content.lines() { |
83 | | - if let Some(opts_start) = line.find("opts=\"") { |
84 | | - // Find the closing quote |
85 | | - let search_from = opts_start + 6; |
86 | | - if let Some(rel_end) = line[search_from..].find('"') { |
87 | | - let opts_end = search_from + rel_end; |
88 | | - let opts_str = &line[search_from..opts_end]; |
89 | | - for word in opts_str.split_whitespace() { |
90 | | - if let Some(flag) = word.strip_prefix("--") { |
91 | | - flags.insert(flag.to_string()); |
92 | | - } |
93 | | - } |
94 | | - } |
95 | | - } |
96 | | - } |
97 | | - // Also from case statements |
98 | | - for line in content.lines() { |
99 | | - if let Some(stripped) = line |
100 | | - .trim() |
101 | | - .strip_prefix("--") |
102 | | - .and_then(|s| s.strip_suffix(')')) |
103 | | - { |
104 | | - flags.insert(stripped.to_string()); |
105 | | - } |
106 | | - } |
107 | | - } |
108 | | - "zsh" => { |
109 | | - // From _arguments lines |
110 | | - for line in content.lines() { |
111 | | - if let Some(start) = line.find("'--") |
112 | | - && let Some(rest) = line[start + 3..].split(&['[', '='][..]).next() |
113 | | - { |
114 | | - flags.insert(rest.to_string()); |
115 | | - } |
116 | | - } |
117 | | - } |
118 | | - _ => {} |
119 | | - } |
120 | | - |
121 | | - flags |
122 | | -} |
123 | | - |
124 | | -/// Validate cross-shell consistency |
125 | | -fn validate_cross_shell(fish_content: &str, bash_content: &str, zsh_content: &str) -> Vec<Issue> { |
126 | | - let mut issues = Vec::new(); |
127 | | - |
128 | | - let fish_flags = extract_flags(fish_content, "fish"); |
129 | | - let bash_flags = extract_flags(bash_content, "bash"); |
130 | | - let zsh_flags = extract_flags(zsh_content, "zsh"); |
131 | | - |
132 | | - // Flags that should be hidden |
133 | | - let hidden_flags: HashSet<String> = ["internal"].iter().map(|s| s.to_string()).collect(); |
134 | | - |
135 | | - // Check if hidden flags appear anywhere |
136 | | - for flag in &hidden_flags { |
137 | | - let mut appears_in = Vec::new(); |
138 | | - if fish_flags.contains(flag) { |
139 | | - appears_in.push("fish"); |
140 | | - } |
141 | | - if bash_flags.contains(flag) { |
142 | | - appears_in.push("bash"); |
143 | | - } |
144 | | - if zsh_flags.contains(flag) { |
145 | | - appears_in.push("zsh"); |
146 | | - } |
147 | | - |
148 | | - if !appears_in.is_empty() { |
149 | | - issues.push(Issue::error( |
150 | | - "cross-shell", |
151 | | - Category::HiddenFlag, |
152 | | - format!( |
153 | | - "Hidden flag --{} appears in: {}", |
154 | | - flag, |
155 | | - appears_in.join(", ") |
156 | | - ), |
157 | | - )); |
158 | | - } |
159 | | - } |
160 | | - |
161 | | - // Check for flags missing from some shells |
162 | | - // (only report if missing from multiple shells - single shell might be intentional) |
163 | | - let all_flags: HashSet<_> = fish_flags |
164 | | - .union(&bash_flags) |
165 | | - .chain(zsh_flags.iter()) |
166 | | - .filter(|f| !hidden_flags.contains(*f)) |
167 | | - .collect(); |
168 | | - |
169 | | - for flag in all_flags { |
170 | | - let in_fish = fish_flags.contains(flag); |
171 | | - let in_bash = bash_flags.contains(flag); |
172 | | - let in_zsh = zsh_flags.contains(flag); |
173 | | - |
174 | | - let present_count = [in_fish, in_bash, in_zsh].iter().filter(|&&x| x).count(); |
175 | | - |
176 | | - // Only warn if flag is in exactly one shell (likely a bug) |
177 | | - // If it's in 2 shells, might be intentional |
178 | | - if present_count == 1 { |
179 | | - let mut missing = Vec::new(); |
180 | | - if !in_fish { |
181 | | - missing.push("fish"); |
182 | | - } |
183 | | - if !in_bash { |
184 | | - missing.push("bash"); |
185 | | - } |
186 | | - if !in_zsh { |
187 | | - missing.push("zsh"); |
188 | | - } |
189 | | - |
190 | | - issues.push(Issue::warning( |
191 | | - "cross-shell", |
192 | | - Category::Consistency, |
193 | | - format!("Flag --{} missing from: {}", flag, missing.join(", ")), |
194 | | - )); |
195 | | - } |
196 | | - } |
197 | | - |
198 | | - issues |
| 4 | +/// Drive the dynamic completion engine for a command-line context and return |
| 5 | +/// the set of candidate values it offers. |
| 6 | +/// |
| 7 | +/// A shell requests completions by invoking `COMPLETE=<shell> wt -- <words…>`, |
| 8 | +/// where the final (possibly empty) word is the token under the cursor. Worktrunk |
| 9 | +/// answers on stdout: one candidate per line, `value<TAB>help` for shells that |
| 10 | +/// render descriptions (fish/zsh). We key on `fish` and keep the value before the |
| 11 | +/// first tab. |
| 12 | +fn completion_candidates(words: &[&str]) -> HashSet<String> { |
| 13 | + let mut cmd = wt_command(); |
| 14 | + cmd.env("COMPLETE", "fish"); |
| 15 | + cmd.arg("--"); |
| 16 | + cmd.args(words); |
| 17 | + |
| 18 | + let output = cmd.output().unwrap(); |
| 19 | + assert!( |
| 20 | + output.status.success(), |
| 21 | + "completion invocation for {words:?} failed: {}", |
| 22 | + String::from_utf8_lossy(&output.stderr) |
| 23 | + ); |
| 24 | + |
| 25 | + String::from_utf8_lossy(&output.stdout) |
| 26 | + .lines() |
| 27 | + .filter_map(|line| line.split('\t').next()) |
| 28 | + .filter(|value| !value.is_empty()) |
| 29 | + .map(str::to_string) |
| 30 | + .collect() |
199 | 31 | } |
200 | 32 |
|
| 33 | +/// `hide = true` subcommands must never appear in completions. |
| 34 | +/// |
| 35 | +/// Worktrunk generates completions dynamically (clap_complete's completion |
| 36 | +/// engine, driven from `COMPLETE=<shell> wt`), assembled in |
| 37 | +/// `src/completion.rs`. Deprecated and internal subcommands are marked |
| 38 | +/// `#[command(hide = true)]` so they stay out of `--help` *and* out of |
| 39 | +/// completions; a regression that resurfaced one (e.g. an injection layer |
| 40 | +/// re-adding it, or a new internal subcommand missing `hide = true`) would leak |
| 41 | +/// it to every user's shell. |
| 42 | +/// |
| 43 | +/// Each context also asserts a visible sibling is present. That guard is |
| 44 | +/// load-bearing: without it, a broken invocation returning *no* candidates would |
| 45 | +/// satisfy every "hidden subcommand absent" check vacuously — the exact failure mode |
| 46 | +/// that let this test rot after completions moved from static generation |
| 47 | +/// (`wt config shell init`, which no longer emits any flag lines) to the dynamic |
| 48 | +/// engine. |
| 49 | +/// |
| 50 | +/// Hidden *flags* are deliberately not checked here: unlike subcommands, the |
| 51 | +/// completion assembly (`hide_non_positional_options_for_completion`) surfaces |
| 52 | +/// every long flag — hidden ones included — when the user explicitly types `--`, |
| 53 | +/// so "hidden flags never appear" is not an invariant the current design holds. |
201 | 54 | #[test] |
202 | | -fn test_completion_validation() { |
203 | | - // Generate completions |
204 | | - let fish_output = wt_command() |
205 | | - .arg("config") |
206 | | - .arg("shell") |
207 | | - .arg("init") |
208 | | - .arg("fish") |
209 | | - .output() |
210 | | - .unwrap(); |
211 | | - |
212 | | - let bash_output = wt_command() |
213 | | - .arg("config") |
214 | | - .arg("shell") |
215 | | - .arg("init") |
216 | | - .arg("bash") |
217 | | - .output() |
218 | | - .unwrap(); |
219 | | - |
220 | | - let zsh_output = wt_command() |
221 | | - .arg("config") |
222 | | - .arg("shell") |
223 | | - .arg("init") |
224 | | - .arg("zsh") |
225 | | - .output() |
226 | | - .unwrap(); |
227 | | - |
228 | | - assert!(fish_output.status.success()); |
229 | | - assert!(bash_output.status.success()); |
230 | | - assert!(zsh_output.status.success()); |
231 | | - |
232 | | - let fish_content = String::from_utf8_lossy(&fish_output.stdout); |
233 | | - let bash_content = String::from_utf8_lossy(&bash_output.stdout); |
234 | | - let zsh_content = String::from_utf8_lossy(&zsh_output.stdout); |
235 | | - |
236 | | - // Run all validators |
237 | | - let mut all_issues = Vec::new(); |
238 | | - all_issues.extend(validate_fish(&fish_content)); |
239 | | - all_issues.extend(validate_bash(&bash_content)); |
240 | | - all_issues.extend(validate_zsh(&zsh_content)); |
241 | | - all_issues.extend(validate_cross_shell( |
242 | | - &fish_content, |
243 | | - &bash_content, |
244 | | - &zsh_content, |
245 | | - )); |
246 | | - |
247 | | - // Separate errors and warnings |
248 | | - let errors: Vec<_> = all_issues |
249 | | - .iter() |
250 | | - .filter(|i| i.severity == Severity::Error) |
251 | | - .collect(); |
252 | | - let warnings: Vec<_> = all_issues |
253 | | - .iter() |
254 | | - .filter(|i| i.severity == Severity::Warning) |
255 | | - .collect(); |
256 | | - |
257 | | - // Report issues |
258 | | - if !errors.is_empty() { |
259 | | - eprintln!("\n{}", "=".repeat(80)); |
260 | | - eprintln!("COMPLETION VALIDATION ERRORS ({})", errors.len()); |
261 | | - eprintln!("{}", "=".repeat(80)); |
262 | | - for issue in &errors { |
263 | | - eprintln!( |
264 | | - "❌ [{}] {:?}: {}", |
265 | | - issue.shell, issue.category, issue.message |
266 | | - ); |
267 | | - } |
268 | | - } |
269 | | - |
270 | | - if !warnings.is_empty() { |
271 | | - eprintln!("\n{}", "=".repeat(80)); |
272 | | - eprintln!("COMPLETION VALIDATION WARNINGS ({})", warnings.len()); |
273 | | - eprintln!("{}", "=".repeat(80)); |
274 | | - for issue in &warnings { |
275 | | - eprintln!( |
276 | | - "⚠️ [{}] {:?}: {}", |
277 | | - issue.shell, issue.category, issue.message |
278 | | - ); |
279 | | - } |
280 | | - } |
281 | | - |
282 | | - // Fail on errors only |
283 | | - if !errors.is_empty() { |
284 | | - panic!( |
285 | | - "\n{} completion validation error(s) found - see output above", |
286 | | - errors.len() |
| 55 | +fn test_hidden_subcommands_excluded_from_completions() { |
| 56 | + // `wt <Tab>` — the legacy `select` subcommand is hidden (superseded by the |
| 57 | + // picker integrated into `wt switch`). |
| 58 | + let top_level = completion_candidates(&["wt", ""]); |
| 59 | + assert!( |
| 60 | + top_level.contains("switch"), |
| 61 | + "expected visible `switch` in top-level completions: {top_level:?}" |
| 62 | + ); |
| 63 | + assert!( |
| 64 | + !top_level.contains("select"), |
| 65 | + "hidden `select` leaked into `wt` completions: {top_level:?}" |
| 66 | + ); |
| 67 | + |
| 68 | + // `wt hook <Tab>` — the internal `run-pipeline` runner and the deprecated |
| 69 | + // `approvals` alias are hidden; the hook types are offered. |
| 70 | + let hook = completion_candidates(&["wt", "hook", ""]); |
| 71 | + assert!( |
| 72 | + hook.contains("pre-merge"), |
| 73 | + "expected visible `pre-merge` in hook completions: {hook:?}" |
| 74 | + ); |
| 75 | + for hidden in ["run-pipeline", "approvals"] { |
| 76 | + assert!( |
| 77 | + !hook.contains(hidden), |
| 78 | + "hidden `hook {hidden}` leaked into completions: {hook:?}" |
287 | 79 | ); |
288 | 80 | } |
289 | 81 |
|
290 | | - if errors.is_empty() && warnings.is_empty() { |
291 | | - println!("{SUCCESS_SYMBOL} All shell completions validated successfully!"); |
| 82 | + // `wt config shell <Tab>` — the internal `completions` generator (emits the |
| 83 | + // package-manager completion registration) is hidden; the interactive setup |
| 84 | + // subcommands are offered. `completions` lives here, not directly under |
| 85 | + // `wt config`, so it must be probed at this level to actually exercise the |
| 86 | + // hide filtering rather than the tree structure. |
| 87 | + let config_shell = completion_candidates(&["wt", "config", "shell", ""]); |
| 88 | + assert!( |
| 89 | + config_shell.contains("install"), |
| 90 | + "expected visible `install` in config shell completions: {config_shell:?}" |
| 91 | + ); |
| 92 | + assert!( |
| 93 | + !config_shell.contains("completions"), |
| 94 | + "hidden `config shell completions` leaked into completions: {config_shell:?}" |
| 95 | + ); |
| 96 | + |
| 97 | + // `wt config state <Tab>` — the deprecated per-category subcommands (now |
| 98 | + // folded into `wt config state cache`) are hidden; `cache` is offered. Like |
| 99 | + // `completions` above, these live under `state`, not directly under |
| 100 | + // `wt config`, so they must be probed at this level. |
| 101 | + let config_state = completion_candidates(&["wt", "config", "state", ""]); |
| 102 | + assert!( |
| 103 | + config_state.contains("cache"), |
| 104 | + "expected visible `cache` in config state completions: {config_state:?}" |
| 105 | + ); |
| 106 | + for hidden in ["previous-branch", "hints", "ci-status"] { |
| 107 | + assert!( |
| 108 | + !config_state.contains(hidden), |
| 109 | + "hidden `config state {hidden}` leaked into completions: {config_state:?}" |
| 110 | + ); |
292 | 111 | } |
293 | 112 | } |
0 commit comments