agent_profile/cli/
completions.rs1use clap::{Arg, ArgAction, Command, ValueHint};
17use clap_complete::Shell;
18
19use super::{AgentScope, CommandWord, Positional};
20use crate::adapter;
21
22pub(super) const SHELL_NAMES: [&str; 5] = ["bash", "elvish", "fish", "powershell", "zsh"];
23
24pub(super) fn parse_shell(word: &str) -> Option<Shell> {
26 match word {
27 "bash" => Some(Shell::Bash),
28 "elvish" => Some(Shell::Elvish),
29 "fish" => Some(Shell::Fish),
30 "powershell" => Some(Shell::PowerShell),
31 "zsh" => Some(Shell::Zsh),
32 _ => None,
33 }
34}
35
36pub(super) fn model() -> Command {
38 let mut root = Command::new("agent-profile")
39 .version(env!("CARGO_PKG_VERSION"))
40 .disable_help_subcommand(true);
41 for word in CommandWord::ALL {
42 if word.scope() != AgentScope::AgentRequired {
43 root = root.subcommand(command_model(word, false));
44 }
45 }
46 for id in adapter::known_agents() {
47 let mut agent = Command::new(id)
48 .disable_help_subcommand(true)
49 .arg(flag("dry-run"))
50 .arg(flag("verbose"));
51 for word in CommandWord::ALL {
52 if word.scope() != AgentScope::TopLevelOnly {
53 agent = agent.subcommand(command_model(word, true));
54 }
55 }
56 root = root.subcommand(agent);
57 }
58 root
59}
60
61fn command_model(word: CommandWord, with_agent: bool) -> Command {
62 let mut command = Command::new(word.as_str()).disable_help_subcommand(true);
63 if word.accepts_repo() {
64 command = command
65 .arg(Arg::new("repo").long("repo").value_name("path").value_hint(ValueHint::DirPath));
66 }
67 if word.accepts_json() {
68 command = command.arg(flag("json"));
69 }
70 if word.accepts_yes() {
71 command = command.arg(flag("yes"));
72 }
73 match word.positional(with_agent) {
74 Positional::RequiredProfile => {
75 command = command.arg(Arg::new("profile").value_name("profile").required(true));
76 }
77 Positional::OptionalProfile => {
78 command = command.arg(Arg::new("profile").value_name("profile"));
79 }
80 Positional::Shell => {
81 command = command.arg(
82 Arg::new("shell").value_name("shell").required(true).value_parser(SHELL_NAMES),
83 );
84 }
85 Positional::None => {}
86 }
87 command
88}
89
90fn flag(name: &'static str) -> Arg {
91 Arg::new(name).long(name).action(ArgAction::SetTrue)
92}
93
94pub(super) fn script(shell: Shell) -> String {
96 let mut model = model();
97 let mut buffer = Vec::new();
98 clap_complete::generate(shell, &mut model, "agent-profile", &mut buffer);
99 let text = String::from_utf8_lossy(&buffer).into_owned();
100 if shell == Shell::Bash {
101 text.replace("agent__subcmd__profile", "agent__profile")
102 } else {
103 text
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use std::collections::BTreeSet;
110 #[cfg(target_os = "linux")]
111 use std::path::Path;
112
113 use super::*;
114
115 fn set<'a>(items: impl IntoIterator<Item = &'a str>) -> BTreeSet<String> {
116 items.into_iter().map(str::to_owned).collect()
117 }
118
119 fn built() -> Command {
120 let mut root = model();
121 root.build();
122 root
123 }
124
125 fn arg_ids(command: &Command) -> BTreeSet<String> {
126 command.get_arguments().map(|a| a.get_id().to_string()).collect()
127 }
128
129 fn sub_names(command: &Command) -> BTreeSet<String> {
130 command.get_subcommands().map(|c| c.get_name().to_owned()).collect()
131 }
132
133 #[test]
134 fn root_subcommands_are_the_top_level_words_and_the_agents() {
135 let root = built();
136 let mut want = set(CommandWord::ALL
137 .iter()
138 .filter(|w| w.scope() != AgentScope::AgentRequired)
139 .map(|w| w.as_str()));
140 want.extend(set(adapter::known_agents()));
141 assert_eq!(sub_names(&root), want);
142 }
143
144 #[test]
145 fn root_arguments_are_help_and_version() {
146 assert_eq!(arg_ids(&built()), set(["help", "version"]));
147 }
148
149 #[test]
150 fn each_agent_offers_the_agent_words_and_the_launch_flags() {
151 let root = built();
152 let want_words = set(CommandWord::ALL
153 .iter()
154 .filter(|w| w.scope() != AgentScope::TopLevelOnly)
155 .map(|w| w.as_str()));
156 for id in adapter::known_agents() {
157 let agent = root.find_subcommand(id).expect("agent subcommand");
158 assert_eq!(sub_names(agent), want_words, "{id}");
159 assert_eq!(arg_ids(agent), set(["dry-run", "verbose", "help"]), "{id}");
160 }
161 }
162
163 #[test]
164 fn each_command_mirrors_the_grammar_table() {
165 let root = built();
166 let mut checked = 0;
167 let mut check = |command: &Command, word: CommandWord, with_agent: bool| {
168 let mut want = set(["help"]);
169 if word.accepts_repo() {
170 want.insert("repo".to_owned());
171 let repo = command.get_arguments().find(|a| a.get_id() == "repo").unwrap();
172 assert_eq!(repo.get_value_hint(), ValueHint::DirPath);
173 }
174 if word.accepts_json() {
175 want.insert("json".to_owned());
176 }
177 if word.accepts_yes() {
178 want.insert("yes".to_owned());
179 }
180 match word.positional(with_agent) {
181 Positional::RequiredProfile | Positional::OptionalProfile => {
182 want.insert("profile".to_owned());
183 }
184 Positional::Shell => {
185 want.insert("shell".to_owned());
186 }
187 Positional::None => {}
188 }
189 assert_eq!(arg_ids(command), want, "{}", word.as_str());
190 assert!(sub_names(command).is_empty(), "{}", word.as_str());
191 checked += 1;
192 };
193 for word in CommandWord::ALL {
194 if let Some(command) = root.find_subcommand(word.as_str()) {
195 check(command, word, false);
196 }
197 }
198 for id in adapter::known_agents() {
199 let agent = root.find_subcommand(id).unwrap();
200 for word in CommandWord::ALL {
201 if let Some(command) = agent.find_subcommand(word.as_str()) {
202 check(command, word, true);
203 }
204 }
205 }
206 assert!(checked > 0);
207 }
208
209 fn walk(command: &Command, found_help: &mut bool) {
210 for sub in command.get_subcommands() {
211 if sub.get_name() == "help" {
212 *found_help = true;
213 }
214 walk(sub, found_help);
215 }
216 }
217
218 #[test]
219 fn no_help_subcommand_anywhere() {
220 let mut found = false;
221 walk(&built(), &mut found);
222 assert!(!found);
223 }
224
225 #[test]
226 fn script_for_each_shell_names_every_agent_and_word() {
227 for shell in [Shell::Bash, Shell::Elvish, Shell::Fish, Shell::PowerShell, Shell::Zsh] {
228 let text = script(shell);
229 assert!(!text.is_empty(), "{shell}");
230 for id in adapter::known_agents() {
231 assert!(text.contains(id), "{shell}: {id}");
232 }
233 for word in CommandWord::ALL {
234 assert!(text.contains(word.as_str()), "{shell}: {}", word.as_str());
235 }
236 }
237 }
238
239 #[test]
240 fn completions_shell_argument_accepts_exactly_the_shell_names() {
241 let mut root = model();
242 root.build();
243 let completions = root.find_subcommand("completions").expect("completions subcommand");
244 let shell = completions
245 .get_arguments()
246 .find(|arg| arg.get_id() == "shell")
247 .expect("shell argument");
248 let values: Vec<String> =
249 shell.get_possible_values().iter().map(|v| v.get_name().to_owned()).collect();
250 assert_eq!(values, SHELL_NAMES);
251 }
252
253 #[test]
254 fn parse_shell_is_exact_lower_case() {
255 let want = [Shell::Bash, Shell::Elvish, Shell::Fish, Shell::PowerShell, Shell::Zsh];
256 for (name, shell) in SHELL_NAMES.iter().zip(want) {
257 assert_eq!(parse_shell(name), Some(shell));
258 }
259 for bad in ["Bash", "BASH", "", "nope"] {
260 assert_eq!(parse_shell(bad), None, "{bad:?}");
261 }
262 }
263
264 #[test]
265 fn bash_script_has_no_mismatched_state_label() {
266 let text = script(Shell::Bash);
267 assert!(!text.contains("agent__subcmd__profile"));
268 assert!(text.contains("agent__profile__subcmd__claude)"));
269 }
270
271 #[cfg(target_os = "linux")]
272 fn complete(script: &Path, words: &[&str]) -> BTreeSet<String> {
273 let out = std::process::Command::new("bash")
274 .args(["--norc", "--noprofile", "-c"])
275 .arg(
276 r#"source "$1"; shift; COMP_WORDS=("$@"); COMP_CWORD=$((${#COMP_WORDS[@]} - 1)); COMP_LINE="$*"; COMP_POINT=${#COMP_LINE}; _agent__profile agent-profile "${COMP_WORDS[COMP_CWORD]}" "${COMP_WORDS[COMP_CWORD - 1]}"; printf "%s\n" "${COMPREPLY[@]}""#,
277 )
278 .arg("bash")
279 .arg(script)
280 .args(words)
281 .output()
282 .unwrap();
283 String::from_utf8_lossy(&out.stdout).lines().map(str::to_owned).collect()
284 }
285
286 #[cfg(target_os = "linux")]
287 #[test]
288 fn bash_script_completes_the_second_and_third_word() {
289 use std::io::Write;
290 let mut file = tempfile::NamedTempFile::new().unwrap();
291 file.write_all(script(Shell::Bash).as_bytes()).unwrap();
292 file.flush().unwrap();
293 let path = file.path();
294
295 let first = complete(path, &["agent-profile", ""]);
296 assert!(first.contains("claude") && first.contains("agents"), "{first:?}");
297
298 let second = complete(path, &["agent-profile", "claude", ""]);
299 assert!(second.contains("create") && second.contains("--dry-run"), "{second:?}");
300 assert!(!second.contains("agents"), "{second:?}");
301
302 let third = complete(path, &["agent-profile", "claude", "create", ""]);
303 assert!(third.contains("--help"), "{third:?}");
304 assert!(!third.contains("--json") && !third.contains("--repo"), "{third:?}");
305 }
306}