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 crate::App;
14use crate::command::Command;
15use crate::connection::{self, Target};
16use crate::output::warn;
17
18/// The REPL's grammar: everything the one-shot tree has, plus the few
19/// verbs that only mean something inside a session.
20#[derive(Debug, clap::Subcommand)]
21pub enum ReplCommand {
22    #[command(flatten)]
23    Shared(Command),
24
25    /// Attach to a radio, replacing the current attachment. With no
26    /// argument, rediscovers the way a bare launch does.
27    Connect {
28        /// A scan-listing number, a BLE name or id, or a serial port
29        /// path.
30        #[arg(value_name = "SELECTOR")]
31        selector: Option<String>,
32
33        /// Choose from a numbered listing, ignoring the saved default.
34        #[arg(long, conflicts_with = "selector")]
35        pick: bool,
36    },
37
38    /// Detach from the current radio without leaving the shell.
39    Disconnect,
40
41    /// Leave the shell.
42    #[command(alias = "quit")]
43    Exit,
44}
45
46/// Wrapper used to parse REPL lines and to build the completion tree.
47#[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/// Rustyline helper providing tab completion driven by the clap tree.
55#[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        // Tokenize up to the cursor; give up quietly on an unclosed
70        // quote rather than completing something surprising.
71        let Some(mut tokens) = shlex::split(line_to_cursor) else {
72            return Ok((pos, Vec::new()));
73        };
74
75        // Separate the partial word being typed from the tokens behind it.
76        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        // Walk the tree with the finished tokens, tracking whether the
86        // next one will be swallowed as a flag's value.
87        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            // Subcommand names and their aliases, then the possible
117            // values of whichever positional comes next.
118            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
148/// Whether a clap argument consumes the next token as its value.
149fn 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        // A missing history file is the normal first run.
162        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                // `help` renders through clap's error channel, and a
189                // help request is not a failure.
190                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
215/// Parse and run one line. `Ok(false)` means the user asked to leave.
216async 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
245/// `connect`: rediscover, or attach to the radio the user named.
246async fn connect(app: &mut App, selector: Option<String>, pick: bool) -> Result<()> {
247    let target = match selector {
248        // A bare number refers to the last `scan` listing, which is the
249        // whole reason the REPL keeps it.
250        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            // A path is a serial port; anything else is a BLE selector.
259            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    // Detaching first reverts session-scoped device state (promiscuous
281    // mode) on the radio being left behind.
282    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        // Naming a radio and asking to be shown the listing are two
324        // different requests.
325        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}