1use anyhow::Result;
8use clap::{CommandFactory, Parser};
9use rustyline::completion::{Completer, Pair};
10use rustyline::error::ReadlineError;
11use rustyline::{Context, Editor};
12
13use crate::App;
14use crate::command::Command;
15use crate::connection::{self, Target};
16use crate::output::warn;
17
18#[derive(Debug, clap::Subcommand)]
21pub enum ReplCommand {
22 #[command(flatten)]
23 Shared(Command),
24
25 Connect {
28 #[arg(value_name = "SELECTOR")]
31 selector: Option<String>,
32
33 #[arg(long, conflicts_with = "selector")]
35 pick: bool,
36 },
37
38 Disconnect,
40
41 #[command(alias = "quit")]
43 Exit,
44}
45
46#[derive(Debug, Parser)]
48#[command(name = "", no_binary_name = true, disable_help_flag = false)]
49pub struct ReplCommandLine {
50 #[command(subcommand)]
51 pub command: ReplCommand,
52}
53
54#[derive(rustyline::Helper, rustyline::Hinter, rustyline::Highlighter, rustyline::Validator)]
56struct ReplHelper;
57
58impl Completer for ReplHelper {
59 type Candidate = Pair;
60
61 fn complete(
62 &self,
63 line: &str,
64 pos: usize,
65 _ctx: &Context<'_>,
66 ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
67 let line_to_cursor = &line[..pos];
68
69 let Some(mut tokens) = shlex::split(line_to_cursor) else {
72 return Ok((pos, Vec::new()));
73 };
74
75 let (partial, complete_start) =
77 if !line_to_cursor.ends_with(char::is_whitespace) && !tokens.is_empty() {
78 let partial = tokens.pop().unwrap_or_default();
79 let start = pos.saturating_sub(partial.len());
80 (partial, start)
81 } else {
82 (String::new(), pos)
83 };
84
85 let mut cmd = ReplCommandLine::command();
88 let mut expect_value_for: Option<String> = None;
89 for token in &tokens {
90 if expect_value_for.take().is_some() {
91 continue;
92 }
93 if let Some(flag) = token.strip_prefix("--").or_else(|| token.strip_prefix('-')) {
94 if let Some(arg) = cmd.get_arguments().find(|arg| {
95 arg.get_long() == Some(flag)
96 || arg.get_short().is_some_and(|c| c.to_string() == flag)
97 }) && takes_value(arg)
98 {
99 expect_value_for = Some(flag.to_string());
100 }
101 } else if let Some(sub) = cmd.find_subcommand(token) {
102 cmd = sub.clone();
103 }
104 }
105
106 let candidates: Vec<String> = if let Some(flag) = expect_value_for {
107 cmd.get_arguments()
108 .find(|arg| arg.get_long() == Some(flag.as_str()))
109 .map(possible_values)
110 .unwrap_or_default()
111 } else if partial.starts_with('-') {
112 cmd.get_arguments()
113 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
114 .collect()
115 } else {
116 let mut candidates: Vec<String> = cmd
119 .get_subcommands()
120 .flat_map(|sub| {
121 std::iter::once(sub.get_name().to_string())
122 .chain(sub.get_all_aliases().map(str::to_string))
123 })
124 .collect();
125 candidates.extend(cmd.get_positionals().flat_map(possible_values));
126 candidates
127 };
128
129 let pairs = candidates
130 .into_iter()
131 .filter(|candidate| candidate.starts_with(&partial))
132 .map(|candidate| Pair {
133 display: candidate.clone(),
134 replacement: format!("{candidate} "),
135 })
136 .collect();
137 Ok((complete_start, pairs))
138 }
139}
140
141fn possible_values(arg: &clap::Arg) -> Vec<String> {
142 arg.get_possible_values()
143 .iter()
144 .map(|value| value.get_name().to_string())
145 .collect()
146}
147
148fn takes_value(arg: &clap::Arg) -> bool {
150 matches!(
151 arg.get_action(),
152 clap::ArgAction::Set | clap::ArgAction::Append
153 )
154}
155
156pub async fn run(app: &mut App) -> Result<()> {
157 let mut editor = Editor::<ReplHelper, rustyline::history::DefaultHistory>::new()?;
158 editor.set_helper(Some(ReplHelper));
159 let history = connection::history_path();
160 if let Some(path) = &history {
161 let _ = editor.load_history(path);
163 }
164
165 banner(app);
166 let mut failed = false;
167 loop {
168 let mut prompt = app.prompt();
169 if failed {
170 prompt.insert_str(0, "❌ ");
171 }
172 let line = match editor.readline(&prompt) {
173 Ok(line) => line,
174 Err(ReadlineError::Interrupted | ReadlineError::Eof) => break,
175 Err(error) => {
176 eprintln!("error: {error}");
177 break;
178 }
179 };
180 if line.trim().is_empty() {
181 continue;
182 }
183 editor.add_history_entry(line.as_str())?;
184 match process_line(app, &line).await {
185 Ok(true) => failed = false,
186 Ok(false) => break,
187 Err(error) => {
188 failed =
191 !line.trim_start().starts_with("help") && !line.trim_end().ends_with("help");
192 eprintln!("{error}");
193 }
194 }
195 }
196
197 if let Some(path) = &history {
198 if let Some(parent) = path.parent() {
199 let _ = std::fs::create_dir_all(parent);
200 }
201 if let Err(error) = editor.save_history(path) {
202 warn(format!("could not save history: {error}"));
203 }
204 }
205 Ok(())
206}
207
208fn banner(app: &App) {
209 println!("umshctl — `help` lists commands, `exit` leaves.");
210 if app.session.is_none() {
211 println!("not attached: `scan` to look for radios, `connect` to attach.");
212 }
213}
214
215async fn process_line(app: &mut App, line: &str) -> Result<bool> {
217 let Some(args) = shlex::split(line) else {
218 anyhow::bail!("unbalanced quotes");
219 };
220 let command = ReplCommandLine::try_parse_from(args)?.command;
221 match command {
222 ReplCommand::Exit => return Ok(false),
223 ReplCommand::Disconnect => {
224 match app.detach() {
225 Some(label) => println!("detached from {label}"),
226 None => println!("not attached"),
227 }
228 return Ok(true);
229 }
230 ReplCommand::Connect { selector, pick } => {
231 connect(app, selector, pick).await?;
232 return Ok(true);
233 }
234 ReplCommand::Shared(command) => {
235 command.validate()?;
236 if command.needs_device() && app.session.is_none() {
237 anyhow::bail!("not attached — try `scan` or `connect`");
238 }
239 command.run(app).await?;
240 }
241 }
242 Ok(true)
243}
244
245async fn connect(app: &mut App, selector: Option<String>, pick: bool) -> Result<()> {
247 let target = match selector {
248 Some(selector) => match selector.parse::<usize>() {
251 Ok(index) if (1..=app.last_scan.len()).contains(&index) => {
252 Target::from(&app.last_scan[index - 1])
253 }
254 Ok(index) => anyhow::bail!(
255 "no radio {index} in the last scan ({} listed); run `scan` again",
256 app.last_scan.len()
257 ),
258 Err(_) if selector.contains('/') => Target::Serial {
260 port: selector,
261 baud: app.baud,
262 },
263 Err(_) => Target::Ble {
264 selector,
265 name: None,
266 },
267 },
268 None => {
269 let how = if pick {
270 connection::Discovery::Ask
271 } else {
272 app.discovery
273 };
274 let Some(target) = connection::discover(&app.prefs, app.interactive, how).await? else {
275 anyhow::bail!("no ULCP radios found");
276 };
277 target
278 }
279 };
280 app.detach();
283 app.attach(target).await
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn the_command_tree_is_internally_consistent() {
292 ReplCommandLine::command().debug_assert();
293 crate::ToolArgs::command().debug_assert();
294 }
295
296 fn parse(line: &str) -> Result<ReplCommand> {
297 let args = shlex::split(line).expect("balanced quotes");
298 Ok(ReplCommandLine::try_parse_from(args)?.command)
299 }
300
301 #[test]
302 fn repl_only_verbs_parse_and_shared_ones_still_do() {
303 assert!(matches!(parse("exit").unwrap(), ReplCommand::Exit));
304 assert!(matches!(parse("quit").unwrap(), ReplCommand::Exit));
305 assert!(matches!(
306 parse("disconnect").unwrap(),
307 ReplCommand::Disconnect
308 ));
309 assert!(matches!(
310 parse("connect 2").unwrap(),
311 ReplCommand::Connect {
312 selector: Some(ref s),
313 pick: false,
314 } if s == "2"
315 ));
316 assert!(matches!(
317 parse("connect --pick").unwrap(),
318 ReplCommand::Connect {
319 selector: None,
320 pick: true,
321 }
322 ));
323 assert!(parse("connect --pick 2").is_err());
326 assert!(matches!(
327 parse("info").unwrap(),
328 ReplCommand::Shared(Command::Info(_))
329 ));
330 }
331
332 #[test]
333 fn quoted_names_survive_the_split() {
334 let ReplCommand::Shared(Command::Name { name }) = parse("name \"Rogue Valley\"").unwrap()
335 else {
336 panic!("expected name");
337 };
338 assert_eq!(name.as_deref(), Some("Rogue Valley"));
339 }
340
341 #[test]
342 fn unknown_verbs_are_rejected_rather_than_guessed_at() {
343 assert!(parse("teleport").is_err());
344 }
345
346 #[test]
347 fn completion_offers_subcommands_then_narrows_by_prefix() {
348 let helper = ReplHelper;
349 let ctx_history = rustyline::history::DefaultHistory::new();
350 let ctx = Context::new(&ctx_history);
351
352 let (_, all) = helper.complete("", 0, &ctx).unwrap();
353 let names: Vec<&str> = all.iter().map(|pair| pair.display.as_str()).collect();
354 assert!(names.contains(&"info"), "{names:?}");
355 assert!(names.contains(&"capture"), "{names:?}");
356 assert!(names.contains(&"exit"), "{names:?}");
357
358 let (start, narrowed) = helper.complete("rep", 3, &ctx).unwrap();
359 assert_eq!(start, 0);
360 assert_eq!(
361 narrowed
362 .iter()
363 .map(|pair| pair.display.as_str())
364 .collect::<Vec<_>>(),
365 ["repeater"],
366 );
367 }
368
369 #[test]
370 fn completion_descends_into_a_subcommand() {
371 let helper = ReplHelper;
372 let ctx_history = rustyline::history::DefaultHistory::new();
373 let ctx = Context::new(&ctx_history);
374
375 let (_, pairs) = helper.complete("repeater ", 9, &ctx).unwrap();
376 let names: Vec<&str> = pairs.iter().map(|pair| pair.display.as_str()).collect();
377 assert!(names.contains(&"min-rssi"), "{names:?}");
378 assert!(names.contains(&"default-region"), "{names:?}");
379 assert!(!names.contains(&"info"), "{names:?}");
380 }
381
382 #[test]
383 fn completion_offers_flags_when_one_is_being_typed() {
384 let helper = ReplHelper;
385 let ctx_history = rustyline::history::DefaultHistory::new();
386 let ctx = Context::new(&ctx_history);
387
388 let (_, pairs) = helper.complete("provision --", 12, &ctx).unwrap();
389 let names: Vec<&str> = pairs.iter().map(|pair| pair.display.as_str()).collect();
390 assert!(names.contains(&"--host-key"), "{names:?}");
391 assert!(names.contains(&"--force"), "{names:?}");
392 }
393
394 #[test]
395 fn completion_offers_a_flags_possible_values() {
396 let helper = ReplHelper;
397 let ctx_history = rustyline::history::DefaultHistory::new();
398 let ctx = Context::new(&ctx_history);
399
400 let (_, pairs) = helper.complete("capture --layers ", 17, &ctx).unwrap();
401 let names: Vec<&str> = pairs.iter().map(|pair| pair.display.as_str()).collect();
402 assert!(names.contains(&"radio"), "{names:?}");
403 assert!(names.contains(&"both"), "{names:?}");
404 }
405}