Skip to main content

pimalaya_cli/
repl.rs

1//! A read-eval-print loop over stdin, running a binary's own commands
2//! against what a session keeps open.
3//!
4//! A session keeps one process alive for one account, so what the
5//! account needs (a client connection, a token read off a keyring) is
6//! built once rather than once per command. [`Repl::run`] reads stdin one
7//! line at a time, splits it as a shell does, parses it with the binary's
8//! command grammar plus `--json`, and hands the command to the binary
9//! with a printer of its own.
10//!
11//! Every answer goes to stdout, failures included, so a piped session
12//! answers each line once and an application can keep one open. stderr holds
13//! the prompt, clap's rendering of a mistyped line on a terminal, and the
14//! help in a JSON session, where stdout keeps one JSON value per line.
15//! The session claims stdin ([`claim_stdin`]), so a command reading its
16//! input there is refused. A line starting another session (`repl`,
17//! `mcp`) or setting a flag the session fixes is refused before the binary
18//! sees it, coded `usage-invalid`.
19//!
20//! [`claim_stdin`]: crate::session::claim_stdin
21
22use std::{
23    io::{self, BufRead, IsTerminal, Write},
24    str,
25};
26
27use anyhow::{Context, Error, Result};
28use clap::{Command, CommandFactory, FromArgMatches, Parser, Subcommand};
29
30use crate::{
31    clap::args::JsonFlag,
32    error::{ErrorCode, ErrorReport, partial_outcome_of, usage_error, usage_invalid},
33    printer::{Printer, StdoutPrinter},
34    session::claim_stdin,
35};
36
37/// The commands starting a session, which a line inside one may not run.
38const SESSIONS: &[&str] = &["repl", "mcp"];
39
40/// The flags every session fixes when it starts: the shared account,
41/// configuration and log flags.
42const FIXED: &[&str] = &[
43    "-a",
44    "--account",
45    "-c",
46    "--config",
47    "--log-level",
48    "--log",
49    "--log-file",
50];
51
52/// The text hint of a failure while debug logs are off: `--log-level` is
53/// fixed when the session starts.
54const LOG_LEVEL_HINT: &str = "Restart the session with --log-level to enable more verbose logs";
55
56/// A read-eval-print loop over stdin for one binary.
57#[derive(Clone, Debug)]
58pub struct Repl {
59    /// The binary name, which the prompt and the help carry.
60    pub bin: &'static str,
61    /// Whether the session prints JSON, as its `--json` says.
62    pub json: bool,
63    /// The flags the session fixes when it starts, spelled as a line
64    /// would (`-a`, `--account`): a line setting one is refused naming
65    /// it. A binary adds its own, as in `--backend`.
66    pub fixed: Vec<&'static str>,
67}
68
69impl Repl {
70    /// Builds the loop of the binary `bin`, printing JSON when `json` is
71    /// set, as the session's printer does, and fixing the shared account,
72    /// configuration and log flags.
73    pub fn new(bin: &'static str, json: bool) -> Self {
74        Self {
75            bin,
76            json,
77            fixed: FIXED.to_vec(),
78        }
79    }
80
81    /// Runs the loop over stdin until EOF, `quit` or `exit`.
82    ///
83    /// `T` is the binary's command tree, parsed from each line without
84    /// the binary name, and `C` its failure codes. `execute` runs one
85    /// parsed command with the printer of its line, JSON when the session
86    /// or the line asks for it; a `repl` or `mcp` line never reaches it.
87    /// A version `T` carries (`#[command(version)]` on the enum) prints
88    /// after any command. The loop claims stdin first, and fails only when
89    /// stdin or stdout does.
90    pub fn run<T: Subcommand, C: ErrorCode>(
91        &self,
92        mut execute: impl FnMut(&mut StdoutPrinter, T) -> Result<()>,
93    ) -> Result<()> {
94        claim_stdin();
95
96        let stdin = io::stdin();
97
98        let mut streams = Streams {
99            interactive: stdin.is_terminal(),
100            input: stdin.lock(),
101            stdout: io::stdout(),
102            stderr: io::stderr(),
103        };
104
105        let mut printer = |json| StdoutPrinter::new(&JsonFlag { enabled: json });
106        self.run_on::<T, C, _>(&mut streams, &mut printer, &mut execute)
107    }
108
109    /// Runs the loop over `streams`, each line printing through the
110    /// printer `printer` builds for its JSON flag.
111    fn run_on<T: Subcommand, C: ErrorCode, P: Printer>(
112        &self,
113        streams: &mut Streams<impl BufRead, impl Write, impl Write>,
114        printer: &mut impl FnMut(bool) -> P,
115        execute: &mut impl FnMut(&mut P, T) -> Result<()>,
116    ) -> Result<()> {
117        // NOTE: the flattened `--json` would lend its doc as the about.
118        let grammar = Line::<T>::command()
119            .name(self.bin)
120            .bin_name(self.bin)
121            .about(None)
122            .long_about(None);
123
124        let versioned = grammar.get_version().is_some() || grammar.get_long_version().is_some();
125        let mut grammar = grammar.propagate_version(versioned);
126
127        let mut line = Vec::new();
128
129        loop {
130            if streams.interactive {
131                write!(streams.stderr, "{}> ", self.bin)?;
132                streams.stderr.flush()?;
133            }
134
135            line.clear();
136
137            let read = streams.input.read_until(b'\n', &mut line);
138            if read.context("Cannot read a line from stdin")? == 0 {
139                return Ok(());
140            }
141
142            match self.step::<T>(&mut grammar, &line) {
143                Step::Quit => return Ok(()),
144                Step::Skip => continue,
145                Step::Run { cmd, json } => {
146                    let mut printer = printer(json);
147                    let result = execute(&mut printer, cmd);
148                    answer::<C>(&mut printer, result)?;
149                }
150                Step::Fail { err, json } => answer::<C>(&mut printer(json), Err(err))?,
151                // Help and version are no failures, on stderr in a JSON
152                // session so stdout stays JSON.
153                Step::Clap { err, json: true } if !err.use_stderr() => {
154                    write!(streams.stderr, "{}", err.render())?
155                }
156                Step::Clap { err, json: false } if !err.use_stderr() => {
157                    write!(streams.stdout, "{}", err.render())?
158                }
159                Step::Clap { err, .. } if streams.interactive => {
160                    write!(streams.stderr, "{}", err.render())?
161                }
162                Step::Clap { err, json } => {
163                    answer::<C>(&mut printer(json), Err(usage_error(&err)))?
164                }
165            }
166
167            streams.stdout.flush()?;
168        }
169    }
170
171    /// Sorts one line read off stdin.
172    fn step<T: Subcommand>(&self, grammar: &mut Command, line: &[u8]) -> Step<T> {
173        let Ok(line) = str::from_utf8(line) else {
174            let err = usage_invalid("Cannot read the line: it is not valid UTF-8");
175            return Step::Fail {
176                err,
177                json: self.json,
178            };
179        };
180
181        let Some(words) = shlex::split(line) else {
182            let err = usage_invalid("Cannot split the line: a quote is not closed");
183            return Step::Fail {
184                err,
185                json: self.json,
186            };
187        };
188
189        match words.as_slice() {
190            [] => return Step::Skip,
191            [word] if word == "quit" || word == "exit" => return Step::Quit,
192            _ => (),
193        }
194
195        // The words before any `--` are flags: clap stops at help or at an
196        // error before telling whether `--json` is set.
197        let flags = words.iter().take_while(|word| *word != "--");
198        let json = self.json || flags.clone().any(|word| word == "--json");
199
200        let parsed = grammar
201            .try_get_matches_from_mut(&words)
202            .and_then(|matches| {
203                // The name, whatever alias or prefix the line used.
204                let name = matches.subcommand_name();
205                if name.is_some_and(|name| SESSIONS.contains(&name)) {
206                    return Ok(None);
207                }
208
209                let line = Line::<T>::from_arg_matches(&matches);
210                line.map(Some).map_err(|err| err.format(grammar))
211            });
212
213        let err = match parsed {
214            Ok(Some(line)) => {
215                let json = self.json || line.json.enabled;
216                return Step::Run {
217                    cmd: line.cmd,
218                    json,
219                };
220            }
221            Ok(None) => {
222                let err = usage_invalid("Cannot start a session inside a session");
223                return Step::Fail { err, json };
224            }
225            Err(err) => err,
226        };
227
228        if !err.use_stderr() {
229            return Step::Clap { err, json };
230        }
231
232        let fixed = flags.into_iter().find_map(|word| {
233            self.fixed.iter().copied().find(|flag| {
234                if flag.starts_with("--") {
235                    let value = word.strip_prefix(flag);
236                    value.is_some_and(|value| value.is_empty() || value.starts_with('='))
237                } else {
238                    // A short flag, its value attached or not.
239                    !word.starts_with("--") && word.starts_with(flag)
240                }
241            })
242        });
243
244        match fixed {
245            Some(flag) => {
246                let err = usage_invalid(format!(
247                    "Cannot set `{flag}` inside a session, start a new session with it instead"
248                ));
249                Step::Fail { err, json }
250            }
251            None => Step::Clap { err, json },
252        }
253    }
254}
255
256/// The words of one line: the binary's command tree and the line's own
257/// `--json`.
258#[derive(Debug, Parser)]
259#[command(no_binary_name = true, infer_subcommands = true)]
260struct Line<T: Subcommand> {
261    #[command(subcommand)]
262    cmd: T,
263    #[command(flatten)]
264    json: JsonFlag,
265}
266
267/// Where the loop reads its lines and writes what no printer does.
268struct Streams<I, O, E> {
269    input: I,
270    stdout: O,
271    stderr: E,
272    /// Whether a person types the lines: the prompt renders, and a
273    /// mistyped line gets clap's rendering on stderr.
274    interactive: bool,
275}
276
277/// What one line asks of the loop.
278enum Step<T> {
279    /// End the session.
280    Quit,
281    /// Read the next line: a blank or comment line.
282    Skip,
283    /// Run the command, printing JSON or not.
284    Run { cmd: T, json: bool },
285    /// Print the failure object, as JSON or not.
286    Fail { err: Error, json: bool },
287    /// Answer clap's verdict, help or version or a parse error, in a JSON
288    /// session or not.
289    Clap { err: clap::Error, json: bool },
290}
291
292/// Prints how a command ended: nothing more after its result or a
293/// partial outcome, the failure object after a failure.
294fn answer<C: ErrorCode>(printer: &mut impl Printer, result: Result<()>) -> Result<()> {
295    match result {
296        Err(err) if partial_outcome_of(&err).is_none() => {
297            let mut report = ErrorReport::<C>::from(err);
298            report.log_level_hint = LOG_LEVEL_HINT;
299            printer.out(report)
300        }
301        _ => Ok(()),
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use std::{
308        cell::RefCell,
309        fmt,
310        io::{self, Write},
311        rc::Rc,
312    };
313
314    use anyhow::{Result, bail};
315    use clap::Subcommand;
316    use serde::Serialize;
317    use serde_json::{Value, json};
318
319    use crate::{
320        error::{NoErrorCode, PartialOutcome},
321        output::Bytes,
322        printer::Printer,
323        repl::{Repl, Streams},
324    };
325
326    /// A stream shared between the loop and the printers.
327    #[derive(Clone, Default)]
328    struct Sink(Rc<RefCell<Vec<u8>>>);
329
330    impl Sink {
331        fn text(&self) -> String {
332            String::from_utf8(self.0.borrow().clone()).unwrap()
333        }
334    }
335
336    impl Write for Sink {
337        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
338            self.0.borrow_mut().extend_from_slice(buf);
339            Ok(buf.len())
340        }
341
342        fn flush(&mut self) -> io::Result<()> {
343            Ok(())
344        }
345    }
346
347    /// A printer writing to a [`Sink`] the way the stdout one writes to
348    /// a pipe.
349    struct SinkPrinter {
350        sink: Sink,
351        json: bool,
352    }
353
354    impl Printer for SinkPrinter {
355        fn out<T: fmt::Display + Serialize>(&mut self, data: T) -> Result<()> {
356            if self.json {
357                serde_json::to_writer(&mut self.sink, &data)?;
358                writeln!(self.sink)?;
359            } else {
360                write!(self.sink, "{data}")?;
361            }
362
363            Ok(())
364        }
365
366        fn event<T: fmt::Display + Serialize>(&mut self, event: T) -> Result<()> {
367            self.out(event)
368        }
369
370        fn out_bytes(&mut self, bytes: Bytes) -> Result<()> {
371            self.out(bytes)
372        }
373
374        fn is_json(&self) -> bool {
375            self.json
376        }
377    }
378
379    #[derive(Debug, Subcommand)]
380    enum TestCommand {
381        /// Print a word.
382        Echo { word: String },
383        /// Fail.
384        Fail,
385        /// Print a word, then end with a partial outcome.
386        Partial,
387        /// Start a session.
388        #[command(alias = "shell")]
389        Repl,
390        /// Serve tools.
391        Mcp,
392    }
393
394    #[derive(Serialize)]
395    struct Word {
396        word: String,
397    }
398
399    impl fmt::Display for Word {
400        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401            writeln!(f, "{}", self.word)
402        }
403    }
404
405    fn word(printer: &mut SinkPrinter, word: &str) -> Result<()> {
406        printer.out(Word {
407            word: word.to_owned(),
408        })
409    }
410
411    /// Runs a session over `input`, answering its stdout and stderr.
412    fn session(json: bool, interactive: bool, input: &[u8]) -> (String, String) {
413        let mut repl = Repl::new("app", json);
414        repl.fixed.extend(["-b", "--backend"]);
415        let stdout = Sink::default();
416        let stderr = Sink::default();
417
418        let mut streams = Streams {
419            input,
420            stdout: stdout.clone(),
421            stderr: stderr.clone(),
422            interactive,
423        };
424
425        let mut printer = |json| SinkPrinter {
426            sink: stdout.clone(),
427            json,
428        };
429
430        let mut execute = |printer: &mut SinkPrinter, cmd| match cmd {
431            TestCommand::Echo { word: w } => word(printer, &w),
432            TestCommand::Fail => bail!("Boom"),
433            TestCommand::Partial => {
434                word(printer, "half")?;
435                Err(PartialOutcome::default().into())
436            }
437            TestCommand::Repl | TestCommand::Mcp => word(printer, "nested"),
438        };
439
440        repl.run_on::<TestCommand, NoErrorCode, _>(&mut streams, &mut printer, &mut execute)
441            .unwrap();
442
443        (stdout.text(), stderr.text())
444    }
445
446    /// The JSON values of a piped JSON session's stdout.
447    fn values(stdout: &str) -> Vec<Value> {
448        stdout
449            .lines()
450            .map(|line| serde_json::from_str(line).unwrap_or_else(|err| panic!("{err}: {line}")))
451            .collect()
452    }
453
454    fn error(value: &Value) -> &str {
455        value["error"].as_str().unwrap_or_default()
456    }
457
458    #[test]
459    fn lines_are_split_as_a_shell_splits_them() {
460        let input = b"echo \"a b\"\necho 'c\nec d\\ e\n";
461        let (stdout, stderr) = session(true, false, input);
462        let values = values(&stdout);
463
464        assert_eq!(values.len(), 3, "{stdout}");
465        assert_eq!(values[0], json!({ "word": "a b" }));
466        assert_eq!(
467            error(&values[1]),
468            "Cannot split the line: a quote is not closed"
469        );
470        assert_eq!(values[1]["code"], "usage-invalid", "{stdout}");
471        assert_eq!(values[2], json!({ "word": "d e" }));
472        assert_eq!(stderr, "");
473    }
474
475    #[test]
476    fn blank_and_comment_lines_are_skipped() {
477        let input = b"\n   \n# echo a\necho b # c\n";
478        let (stdout, _) = session(true, false, input);
479
480        assert_eq!(values(&stdout), [json!({ "word": "b" })]);
481    }
482
483    #[test]
484    fn quit_exit_and_eof_end_the_session() {
485        for input in [&b"echo a\nquit\necho b\n"[..], b"echo a\n exit \necho b\n"] {
486            let (stdout, _) = session(false, false, input);
487            assert_eq!(stdout, "a\n");
488        }
489
490        let (stdout, _) = session(false, false, b"echo a\necho b");
491        assert_eq!(stdout, "a\nb\n");
492    }
493
494    #[test]
495    fn a_line_that_is_not_utf8_fails_alone() {
496        let (stdout, _) = session(true, false, b"echo \xff\necho a\n");
497        let values = values(&stdout);
498
499        assert_eq!(values.len(), 2, "{stdout}");
500        assert_eq!(
501            error(&values[0]),
502            "Cannot read the line: it is not valid UTF-8"
503        );
504        assert_eq!(values[0]["code"], "usage-invalid", "{stdout}");
505        assert_eq!(values[1], json!({ "word": "a" }));
506    }
507
508    #[test]
509    fn a_session_flag_is_refused_by_name() {
510        let input =
511            b"echo a -a work\necho a --config=x.toml\necho -bimap a\necho -- -a\necho a --nope\n";
512        let (stdout, _) = session(true, false, input);
513        let values = values(&stdout);
514
515        assert_eq!(values.len(), 5, "{stdout}");
516
517        for (value, flag) in values.iter().zip(["-a", "--config", "-b"]) {
518            let message = format!(
519                "Cannot set `{flag}` inside a session, start a new session with it instead"
520            );
521            assert_eq!(error(value), message);
522            assert_eq!(value["code"], "usage-invalid", "{value}");
523        }
524
525        assert_eq!(values[3], json!({ "word": "-a" }));
526        assert!(!error(&values[4]).contains("Cannot set"), "{stdout}");
527    }
528
529    #[test]
530    fn a_nested_session_is_refused_before_the_binary_runs_it() {
531        // By its name, an alias and an inferred prefix, the line's own
532        // `--json` kept.
533        let input = b"repl\nmcp\nshell\nmc\nrepl --json\necho a\n";
534        let (stdout, _) = session(true, false, input);
535        let values = values(&stdout);
536
537        assert_eq!(values.len(), 6, "{stdout}");
538
539        for value in &values[..5] {
540            assert_eq!(error(value), "Cannot start a session inside a session");
541            assert_eq!(value["code"], "usage-invalid", "{value}");
542        }
543
544        assert_eq!(values[5], json!({ "word": "a" }));
545
546        let (stdout, _) = session(false, false, b"mcp --json\nmcp -a work\n");
547        let first: Value = serde_json::from_str(stdout.lines().next().unwrap()).unwrap();
548        assert_eq!(error(&first), "Cannot start a session inside a session");
549        assert!(
550            stdout.contains("Cannot set `-a` inside a session"),
551            "{stdout}"
552        );
553
554        let (stdout, _) = session(false, false, b"mcp --help\n");
555        assert!(stdout.contains("Usage: app mcp"), "{stdout}");
556    }
557
558    /// A command tree carrying its version, as a binary's enum does with
559    /// `#[command(version, long_version = long_version!())]`.
560    #[derive(Debug, Subcommand)]
561    #[command(version = "1.2.3", long_version = "v1.2.3 +feature")]
562    enum VersionedCommand {
563        /// Print a word.
564        Echo { word: String },
565    }
566
567    /// Runs a session of [`VersionedCommand`] over `input`, answering its
568    /// stdout and stderr.
569    fn versioned_session(json: bool, input: &[u8]) -> (String, String) {
570        let stdout = Sink::default();
571        let stderr = Sink::default();
572
573        let mut streams = Streams {
574            input,
575            stdout: stdout.clone(),
576            stderr: stderr.clone(),
577            interactive: false,
578        };
579
580        let mut printer = |json| SinkPrinter {
581            sink: stdout.clone(),
582            json,
583        };
584
585        let mut execute = |printer: &mut SinkPrinter, cmd| match cmd {
586            VersionedCommand::Echo { word: w } => word(printer, &w),
587        };
588
589        Repl::new("app", json)
590            .run_on::<VersionedCommand, NoErrorCode, _>(&mut streams, &mut printer, &mut execute)
591            .unwrap();
592
593        (stdout.text(), stderr.text())
594    }
595
596    #[test]
597    fn a_version_line_prints_the_version_the_tree_carries() {
598        let input = b"-V\n--version\necho --version\necho a\n";
599        let (stdout, stderr) = versioned_session(false, input);
600        let lines: Vec<&str> = stdout.lines().collect();
601
602        assert_eq!(lines.len(), 4, "{stdout}");
603        assert_eq!(lines[0], "app 1.2.3");
604        assert_eq!(lines[1], "app v1.2.3 +feature");
605        assert!(lines[2].ends_with(" v1.2.3 +feature"), "{stdout}");
606        assert_eq!(lines[3], "a");
607        assert_eq!(stderr, "");
608
609        let (stdout, stderr) = versioned_session(true, b"--version\n");
610        assert_eq!(stdout, "");
611        assert_eq!(stderr, "app v1.2.3 +feature\n");
612
613        // A tree carrying no version has no version flag.
614        let (stdout, _) = session(true, false, b"--version\n-V\n");
615        for value in values(&stdout) {
616            assert_eq!(value["code"], "usage-invalid", "{stdout}");
617        }
618    }
619
620    #[test]
621    fn help_goes_to_stderr_in_a_json_session() {
622        let (stdout, stderr) = session(true, false, b"help\necho --json --help\n");
623        assert_eq!(stdout, "");
624        assert!(stderr.starts_with("Usage:"), "{stderr}");
625        assert!(stderr.contains("  echo     Print a word\n"), "{stderr}");
626        assert!(stderr.contains("\nPrint a word\n"), "{stderr}");
627
628        let (stdout, stderr) = session(false, false, b"help\n");
629        assert!(stdout.contains("  echo     Print a word\n"), "{stdout}");
630        assert_eq!(stderr, "");
631    }
632
633    #[test]
634    fn a_line_json_flag_applies_to_that_line_and_nothing_is_added() {
635        let (stdout, _) = session(false, false, b"echo a --json\necho b\nfail --json\n");
636        let mut lines = stdout.lines();
637
638        assert_eq!(lines.next(), Some(r#"{"word":"a"}"#));
639        assert_eq!(lines.next(), Some("b"));
640        assert_eq!(
641            error(&serde_json::from_str(lines.next().unwrap()).unwrap()),
642            "Boom"
643        );
644        assert_eq!(lines.next(), None);
645        assert!(stdout.starts_with("{\"word\":\"a\"}\nb\n{"), "{stdout}");
646    }
647
648    #[test]
649    fn a_partial_outcome_prints_its_result_alone() {
650        let (stdout, _) = session(true, false, b"partial\necho a\n");
651        assert_eq!(
652            values(&stdout),
653            [json!({ "word": "half" }), json!({ "word": "a" })]
654        );
655    }
656
657    #[test]
658    fn a_failure_prints_on_stdout_even_on_a_terminal() {
659        let (stdout, stderr) = session(false, true, b"fail\n");
660
661        assert!(stdout.starts_with("Error: Boom\n"), "{stdout}");
662        assert_eq!(stderr, "app> app> ");
663    }
664
665    #[test]
666    fn a_failure_hints_at_restarting_the_session_for_logs() {
667        let (stdout, _) = session(false, false, b"fail\n");
668        let hint = "\n - Restart the session with --log-level to enable more verbose logs\n";
669        assert!(stdout.ends_with(hint), "{stdout}");
670    }
671
672    #[test]
673    fn a_command_help_names_the_binary_in_its_usage() {
674        let (stdout, _) = session(false, false, b"help echo\necho --help\n");
675        let usage: Vec<&str> = stdout
676            .lines()
677            .filter(|line| line.starts_with("Usage:"))
678            .collect();
679
680        assert_eq!(usage, ["Usage: app echo [OPTIONS] <WORD>"; 2], "{stdout}");
681    }
682
683    #[test]
684    fn a_parse_error_goes_to_clap_on_a_terminal_and_to_stdout_when_piped() {
685        let (stdout, stderr) = session(true, true, b"frobnicate\n");
686        assert_eq!(stdout, "");
687        assert!(
688            stderr.contains("error: unrecognized subcommand"),
689            "{stderr}"
690        );
691
692        let (stdout, stderr) = session(true, false, b"frobnicate\n");
693        let values = values(&stdout);
694        assert_eq!(values.len(), 1, "{stdout}");
695        assert!(
696            error(&values[0]).contains("unrecognized subcommand"),
697            "{stdout}"
698        );
699        assert_eq!(values[0]["code"], "usage-invalid", "{stdout}");
700        assert_eq!(stderr, "");
701
702        let (stdout, _) = session(false, false, b"frobnicate\n");
703        assert!(
704            stdout.starts_with("Error: unrecognized subcommand"),
705            "{stdout}"
706        );
707    }
708}