1use anyhow::Result;
8use clap::{CommandFactory, Parser};
9use rustyline::completion::{Completer, Pair};
10use rustyline::error::ReadlineError;
11use rustyline::{Context, Editor};
12
13use umsh::core::PublicKey;
14
15use crate::App;
16use crate::command::Command;
17use crate::command::values::KeyArg;
18use crate::connection::{self, Target};
19use crate::mesh;
20use crate::output::warn;
21
22const TCP_SCHEME: &str = "tcp://";
25
26#[derive(Debug, clap::Subcommand)]
29pub enum ReplCommand {
30 #[command(flatten)]
31 Shared(Command),
32
33 Connect {
36 #[arg(value_name = "SELECTOR")]
39 selector: Option<String>,
40
41 #[arg(long, conflicts_with = "selector")]
43 pick: bool,
44 },
45
46 Remote {
54 #[arg(value_name = "KEY")]
56 target: KeyArg,
57 },
58
59 Disconnect,
64
65 #[command(alias = "quit")]
67 Exit,
68}
69
70#[derive(Debug, Parser)]
72#[command(name = "", no_binary_name = true, disable_help_flag = false)]
73pub struct ReplCommandLine {
74 #[command(subcommand)]
75 pub command: ReplCommand,
76}
77
78#[derive(rustyline::Helper, rustyline::Hinter, rustyline::Highlighter, rustyline::Validator)]
80struct ReplHelper;
81
82impl Completer for ReplHelper {
83 type Candidate = Pair;
84
85 fn complete(
86 &self,
87 line: &str,
88 pos: usize,
89 _ctx: &Context<'_>,
90 ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
91 let line_to_cursor = &line[..pos];
92
93 let Some(mut tokens) = shlex::split(line_to_cursor) else {
96 return Ok((pos, Vec::new()));
97 };
98
99 let (partial, complete_start) =
101 if !line_to_cursor.ends_with(char::is_whitespace) && !tokens.is_empty() {
102 let partial = tokens.pop().unwrap_or_default();
103 let start = pos.saturating_sub(partial.len());
104 (partial, start)
105 } else {
106 (String::new(), pos)
107 };
108
109 let mut cmd = ReplCommandLine::command();
112 let mut expect_value_for: Option<String> = None;
113 for token in &tokens {
114 if expect_value_for.take().is_some() {
115 continue;
116 }
117 if let Some(flag) = token.strip_prefix("--").or_else(|| token.strip_prefix('-')) {
118 if let Some(arg) = cmd.get_arguments().find(|arg| {
119 arg.get_long() == Some(flag)
120 || arg.get_short().is_some_and(|c| c.to_string() == flag)
121 }) && takes_value(arg)
122 {
123 expect_value_for = Some(flag.to_string());
124 }
125 } else if let Some(sub) = cmd.find_subcommand(token) {
126 cmd = sub.clone();
127 }
128 }
129
130 let candidates: Vec<String> = if let Some(flag) = expect_value_for {
131 cmd.get_arguments()
132 .find(|arg| arg.get_long() == Some(flag.as_str()))
133 .map(possible_values)
134 .unwrap_or_default()
135 } else if partial.starts_with('-') {
136 cmd.get_arguments()
137 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
138 .collect()
139 } else {
140 let mut candidates: Vec<String> = cmd
143 .get_subcommands()
144 .flat_map(|sub| {
145 std::iter::once(sub.get_name().to_string())
146 .chain(sub.get_all_aliases().map(str::to_string))
147 })
148 .collect();
149 candidates.extend(cmd.get_positionals().flat_map(possible_values));
150 candidates
151 };
152
153 let pairs = candidates
154 .into_iter()
155 .filter(|candidate| candidate.starts_with(&partial))
156 .map(|candidate| Pair {
157 display: candidate.clone(),
158 replacement: format!("{candidate} "),
159 })
160 .collect();
161 Ok((complete_start, pairs))
162 }
163}
164
165fn possible_values(arg: &clap::Arg) -> Vec<String> {
166 arg.get_possible_values()
167 .iter()
168 .map(|value| value.get_name().to_string())
169 .collect()
170}
171
172fn takes_value(arg: &clap::Arg) -> bool {
174 matches!(
175 arg.get_action(),
176 clap::ArgAction::Set | clap::ArgAction::Append
177 )
178}
179
180pub async fn run(app: &mut App) -> Result<()> {
181 let mut editor = Editor::<ReplHelper, rustyline::history::DefaultHistory>::new()?;
182 editor.set_helper(Some(ReplHelper));
183 let history = connection::history_path();
184 if let Some(path) = &history {
185 let _ = editor.load_history(path);
187 }
188
189 banner(app);
190 let mut failed = false;
191 loop {
192 let mut prompt = app.prompt();
193 if failed {
194 prompt.insert_str(0, "❌ ");
195 }
196 let line = match editor.readline(&prompt) {
197 Ok(line) => line,
198 Err(ReadlineError::Interrupted | ReadlineError::Eof) => break,
199 Err(error) => {
200 eprintln!("error: {error}");
201 break;
202 }
203 };
204 if line.trim().is_empty() {
205 continue;
206 }
207 editor.add_history_entry(line.as_str())?;
208 match process_line(app, &line).await {
209 Ok(true) => failed = false,
210 Ok(false) => break,
211 Err(error) => {
212 failed =
215 !line.trim_start().starts_with("help") && !line.trim_end().ends_with("help");
216 eprintln!("{error}");
217 }
218 }
219 }
220
221 if let Some(path) = &history {
222 if let Some(parent) = path.parent() {
223 let _ = std::fs::create_dir_all(parent);
224 }
225 if let Err(error) = editor.save_history(path) {
226 warn(format!("could not save history: {error}"));
227 }
228 }
229 Ok(())
230}
231
232fn banner(app: &App) {
233 println!("umshctl — `help` lists commands, `exit` leaves.");
234 if app.session.is_none() {
235 println!("not attached: `ble-scan` to look for radios, `connect` to attach.");
236 }
237}
238
239async fn process_line(app: &mut App, line: &str) -> Result<bool> {
241 let Some(args) = shlex::split(line) else {
242 anyhow::bail!("unbalanced quotes");
243 };
244 let command = ReplCommandLine::try_parse_from(args)?.command;
245 match command {
246 ReplCommand::Exit => return Ok(false),
247 ReplCommand::Disconnect => {
248 let was_mesh = app.mesh.is_some();
249 match app.detach().await {
250 Some(label) if was_mesh => match &app.session {
251 Some(session) => println!(
252 "left {label}; back on {} ({})",
253 session.label,
254 session.target.transport()
255 ),
256 None => println!("left {label}"),
257 },
258 Some(label) => println!("detached from {label}"),
259 None => println!("not attached"),
260 }
261 return Ok(true);
262 }
263 ReplCommand::Remote { target } => {
264 mesh::open_remote(app, PublicKey(target.0), mesh::Greeting::Named).await?;
265 if let Some(session) = &app.session {
266 println!("on {} over the mesh", session.label);
267 }
268 return Ok(true);
269 }
270 ReplCommand::Connect { selector, pick } => {
271 connect(app, selector, pick).await?;
272 return Ok(true);
273 }
274 ReplCommand::Shared(command) => {
275 command.validate()?;
276 if command.needs_device() && app.session.is_none() {
277 anyhow::bail!("not attached — try `ble-scan` or `connect`");
278 }
279 command.run(app).await?;
280 }
281 }
282 Ok(true)
283}
284
285async fn connect(app: &mut App, selector: Option<String>, pick: bool) -> Result<()> {
287 let target = match selector {
288 Some(selector) => match selector.parse::<usize>() {
291 Ok(index) if (1..=app.last_scan.len()).contains(&index) => {
292 Target::from(&app.last_scan[index - 1])
293 }
294 Ok(index) => anyhow::bail!(
295 "no radio {index} in the last scan ({} listed); run `ble-scan` again",
296 app.last_scan.len()
297 ),
298 Err(_) => named_target(selector, app.baud)?,
299 },
300 None => {
301 let how = if pick {
302 connection::Discovery::Ask
303 } else {
304 app.discovery
305 };
306 let Some(target) = connection::discover(&app.prefs, app.interactive, how).await? else {
307 anyhow::bail!("no ULCP radios found");
308 };
309 target
310 }
311 };
312 app.detach().await;
316 app.attach(target).await
317}
318
319fn named_target(selector: String, baud: u32) -> Result<Target> {
325 if let Some(endpoint) = selector.strip_prefix(TCP_SCHEME) {
326 let (host, port) = connection::parse_endpoint(endpoint)?;
327 return Ok(Target::Tcp { host, port });
328 }
329 if selector.contains('/') {
330 return Ok(Target::Serial {
331 port: selector,
332 baud,
333 });
334 }
335 Ok(Target::Ble {
336 selector,
337 name: None,
338 })
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn the_command_tree_is_internally_consistent() {
347 ReplCommandLine::command().debug_assert();
348 crate::ToolArgs::command().debug_assert();
349 }
350
351 fn parse(line: &str) -> Result<ReplCommand> {
352 let args = shlex::split(line).expect("balanced quotes");
353 Ok(ReplCommandLine::try_parse_from(args)?.command)
354 }
355
356 #[test]
357 fn a_tcp_selector_beats_the_serial_path_rule() {
358 assert_eq!(
361 named_target("tcp://127.0.0.1:9000".into(), 115_200).unwrap(),
362 Target::Tcp {
363 host: "127.0.0.1".into(),
364 port: 9000,
365 }
366 );
367 assert_eq!(
368 named_target("/dev/cu.usbmodem101".into(), 115_200).unwrap(),
369 Target::Serial {
370 port: "/dev/cu.usbmodem101".into(),
371 baud: 115_200,
372 }
373 );
374 assert_eq!(
377 named_target("127.0.0.1:9000".into(), 115_200).unwrap(),
378 Target::Ble {
379 selector: "127.0.0.1:9000".into(),
380 name: None,
381 }
382 );
383 assert!(named_target("tcp://127.0.0.1".into(), 115_200).is_err());
386 }
387
388 #[test]
389 fn repl_only_verbs_parse_and_shared_ones_still_do() {
390 assert!(matches!(parse("exit").unwrap(), ReplCommand::Exit));
391 assert!(matches!(parse("quit").unwrap(), ReplCommand::Exit));
392 assert!(matches!(
393 parse("disconnect").unwrap(),
394 ReplCommand::Disconnect
395 ));
396 assert!(matches!(
397 parse("connect 2").unwrap(),
398 ReplCommand::Connect {
399 selector: Some(ref s),
400 pick: false,
401 } if s == "2"
402 ));
403 assert!(matches!(
404 parse("connect --pick").unwrap(),
405 ReplCommand::Connect {
406 selector: None,
407 pick: true,
408 }
409 ));
410 assert!(parse("connect --pick 2").is_err());
413 assert!(matches!(
414 parse("info").unwrap(),
415 ReplCommand::Shared(Command::Info(_))
416 ));
417 }
418
419 #[test]
420 fn quoted_names_survive_the_split() {
421 let ReplCommand::Shared(Command::Name { name }) = parse("name \"Rogue Valley\"").unwrap()
422 else {
423 panic!("expected name");
424 };
425 assert_eq!(name.as_deref(), Some("Rogue Valley"));
426 }
427
428 #[test]
429 fn remote_takes_a_key_in_either_written_form() {
430 let hex = "c4".repeat(32);
431 let ReplCommand::Remote { target } = parse(&format!("remote {hex}")).unwrap() else {
432 panic!("expected remote");
433 };
434 assert_eq!(target.0, [0xC4; 32]);
435
436 let base58 = PublicKey(target.0).to_string();
438 let ReplCommand::Remote { target } = parse(&format!("remote {base58}")).unwrap() else {
439 panic!("expected remote");
440 };
441 assert_eq!(target.0, [0xC4; 32]);
442
443 assert!(parse("remote").is_err());
445 assert!(parse("remote nonsense").is_err());
446 }
447
448 #[test]
449 fn unknown_verbs_are_rejected_rather_than_guessed_at() {
450 assert!(parse("teleport").is_err());
451 }
452
453 #[test]
454 fn completion_offers_subcommands_then_narrows_by_prefix() {
455 let helper = ReplHelper;
456 let ctx_history = rustyline::history::DefaultHistory::new();
457 let ctx = Context::new(&ctx_history);
458
459 let (_, all) = helper.complete("", 0, &ctx).unwrap();
460 let names: Vec<&str> = all.iter().map(|pair| pair.display.as_str()).collect();
461 assert!(names.contains(&"info"), "{names:?}");
462 assert!(names.contains(&"capture"), "{names:?}");
463 assert!(names.contains(&"exit"), "{names:?}");
464
465 let (start, narrowed) = helper.complete("rep", 3, &ctx).unwrap();
466 assert_eq!(start, 0);
467 assert_eq!(
468 narrowed
469 .iter()
470 .map(|pair| pair.display.as_str())
471 .collect::<Vec<_>>(),
472 ["repeater"],
473 );
474 }
475
476 #[test]
477 fn completion_descends_into_a_subcommand() {
478 let helper = ReplHelper;
479 let ctx_history = rustyline::history::DefaultHistory::new();
480 let ctx = Context::new(&ctx_history);
481
482 let (_, pairs) = helper.complete("repeater ", 9, &ctx).unwrap();
483 let names: Vec<&str> = pairs.iter().map(|pair| pair.display.as_str()).collect();
484 assert!(names.contains(&"min-rssi"), "{names:?}");
485 assert!(names.contains(&"default-region"), "{names:?}");
486 assert!(!names.contains(&"info"), "{names:?}");
487 }
488
489 #[test]
490 fn completion_offers_flags_when_one_is_being_typed() {
491 let helper = ReplHelper;
492 let ctx_history = rustyline::history::DefaultHistory::new();
493 let ctx = Context::new(&ctx_history);
494
495 let (_, pairs) = helper.complete("capture --", 10, &ctx).unwrap();
496 let names: Vec<&str> = pairs.iter().map(|pair| pair.display.as_str()).collect();
497 assert!(names.contains(&"--pcap"), "{names:?}");
498 assert!(names.contains(&"--layers"), "{names:?}");
499 }
500
501 #[test]
502 fn completion_offers_a_flags_possible_values() {
503 let helper = ReplHelper;
504 let ctx_history = rustyline::history::DefaultHistory::new();
505 let ctx = Context::new(&ctx_history);
506
507 let (_, pairs) = helper.complete("capture --layers ", 17, &ctx).unwrap();
508 let names: Vec<&str> = pairs.iter().map(|pair| pair.display.as_str()).collect();
509 assert!(names.contains(&"radio"), "{names:?}");
510 assert!(names.contains(&"both"), "{names:?}");
511 }
512}