1use 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
37const SESSIONS: &[&str] = &["repl", "mcp"];
39
40const FIXED: &[&str] = &[
43 "-a",
44 "--account",
45 "-c",
46 "--config",
47 "--log-level",
48 "--log",
49 "--log-file",
50];
51
52const LOG_LEVEL_HINT: &str = "Restart the session with --log-level to enable more verbose logs";
55
56#[derive(Clone, Debug)]
58pub struct Repl {
59 pub bin: &'static str,
61 pub json: bool,
63 pub fixed: Vec<&'static str>,
67}
68
69impl Repl {
70 pub fn new(bin: &'static str, json: bool) -> Self {
74 Self {
75 bin,
76 json,
77 fixed: FIXED.to_vec(),
78 }
79 }
80
81 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 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 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 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 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 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 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 !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#[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
267struct Streams<I, O, E> {
269 input: I,
270 stdout: O,
271 stderr: E,
272 interactive: bool,
275}
276
277enum Step<T> {
279 Quit,
281 Skip,
283 Run { cmd: T, json: bool },
285 Fail { err: Error, json: bool },
287 Clap { err: clap::Error, json: bool },
290}
291
292fn 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 #[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 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 Echo { word: String },
383 Fail,
385 Partial,
387 #[command(alias = "shell")]
389 Repl,
390 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 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 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 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 #[derive(Debug, Subcommand)]
561 #[command(version = "1.2.3", long_version = "v1.2.3 +feature")]
562 enum VersionedCommand {
563 Echo { word: String },
565 }
566
567 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 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}