umshctl/
repl.rs

1//! The interactive shell: one attach, many commands.
2//!
3//! Every line is re-parsed with the same `clap` tree the one-shot
4//! grammar uses, so `help`, `help phy`, and `phy --help` all work and
5//! the completer can walk the tree instead of maintaining a word list.
6
7use 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
22/// What marks a `connect` selector as a TCP endpoint rather than a
23/// radio name.
24const TCP_SCHEME: &str = "tcp://";
25
26/// The REPL's grammar: everything the one-shot tree has, plus the few
27/// verbs that only mean something inside a session.
28#[derive(Debug, clap::Subcommand)]
29pub enum ReplCommand {
30    #[command(flatten)]
31    Shared(Command),
32
33    /// Attach to a radio, replacing the current attachment. With no
34    /// argument, rediscovers the way a bare launch does.
35    Connect {
36        /// A scan-listing number, a BLE name or id, a serial port
37        /// path, or `tcp://HOST:PORT`.
38        #[arg(value_name = "SELECTOR")]
39        selector: Option<String>,
40
41        /// Choose from a numbered listing, ignoring the saved default.
42        #[arg(long, conflicts_with = "selector")]
43        pick: bool,
44    },
45
46    /// Open a session to a device across the mesh, using the attached
47    /// radio to reach it.
48    ///
49    /// The radio is borrowed for as long as the session lasts, and every
50    /// command that reads or writes the device works the same as it does
51    /// over a wire — slower, and without the host domain, which is not an
52    /// administrator's to see. `disconnect` gives the radio back.
53    Remote {
54        /// The device to manage, as its node public key.
55        #[arg(value_name = "KEY")]
56        target: KeyArg,
57    },
58
59    /// Detach from the current radio without leaving the shell.
60    ///
61    /// On a mesh session this ends the session and returns to the radio
62    /// it borrowed; a second `disconnect` lets go of that too.
63    Disconnect,
64
65    /// Leave the shell.
66    #[command(alias = "quit")]
67    Exit,
68}
69
70/// Wrapper used to parse REPL lines and to build the completion tree.
71#[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/// Rustyline helper providing tab completion driven by the clap tree.
79#[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        // Tokenize up to the cursor; give up quietly on an unclosed
94        // quote rather than completing something surprising.
95        let Some(mut tokens) = shlex::split(line_to_cursor) else {
96            return Ok((pos, Vec::new()));
97        };
98
99        // Separate the partial word being typed from the tokens behind it.
100        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        // Walk the tree with the finished tokens, tracking whether the
110        // next one will be swallowed as a flag's value.
111        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            // Subcommand names and their aliases, then the possible
141            // values of whichever positional comes next.
142            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
172/// Whether a clap argument consumes the next token as its value.
173fn 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        // A missing history file is the normal first run.
186        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                // `help` renders through clap's error channel, and a
213                // help request is not a failure.
214                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
239/// Parse and run one line. `Ok(false)` means the user asked to leave.
240async 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
285/// `connect`: rediscover, or attach to the radio the user named.
286async fn connect(app: &mut App, selector: Option<String>, pick: bool) -> Result<()> {
287    let target = match selector {
288        // A bare number refers to the last `ble-scan` listing, which is
289        // the whole reason the REPL keeps it.
290        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    // Detaching first reverts session-scoped device state (promiscuous
313    // mode) on the radio being left behind, and ends a mesh session so
314    // its borrowed radio is not left in a task nobody holds.
315    app.detach().await;
316    app.attach(target).await
317}
318
319/// Classify a `connect` selector that is not a scan-listing number.
320///
321/// The `tcp://` test comes first: the scheme's own slashes would
322/// satisfy the path rule below it. A bare `host:port` stays a BLE
323/// selector — the scheme is what separates an endpoint from a name.
324fn 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        // `tcp://` contains the slashes the path rule looks for, so the
359        // order of the two tests is what makes this work at all.
360        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        // A bare endpoint is still a name: BLE radios are named freely,
375        // and the scheme is the only reliable signal.
376        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        // A malformed endpoint is reported, not silently taken for a
384        // radio name.
385        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        // Naming a radio and asking to be shown the listing are two
411        // different requests.
412        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        // The same key as the tool prints it.
437        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        // A node is named by its key, never discovered.
444        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}