Skip to main content

jt_consoleutils/shell/
scripted.rs

1//! Scripted shell for driving the spinner overlay in tests without spawning real OS processes.
2//!
3//! [`ScriptedShell`] and [`Script`] are intended for **testing use**. They are not gated behind
4//! `#[cfg(test)]` so that downstream crates can use them in their own test suites, but they carry
5//! no meaningful runtime cost in production builds (LTO eliminates unused code).
6//!
7//! Use [`ScriptedShell::with_config`] to customise overlay behaviour (e.g. viewport height).
8#![allow(dead_code)]
9
10use std::{cell::RefCell, collections::VecDeque, sync::mpsc, thread, time::Duration};
11
12use super::{
13   CommandResult, Shell, ShellConfig, ShellError,
14   exec::{Line, render_overlay_lines}
15};
16use crate::output::{Output, OutputMode};
17
18// ---------------------------------------------------------------------------
19// ScriptEvent
20// ---------------------------------------------------------------------------
21
22enum ScriptEvent {
23   Out(String),
24   Err(String),
25   Delay(u64)
26}
27
28// ---------------------------------------------------------------------------
29// Script builder
30// ---------------------------------------------------------------------------
31
32/// A sequence of stdout/stderr events and delays that [`ScriptedShell`] replays
33/// through the spinner overlay renderer.
34///
35/// Build one with the fluent builder methods ([`Script::out`], [`Script::err`],
36/// [`Script::delay_ms`], etc.) and enqueue it on a [`ScriptedShell`] via
37/// [`ScriptedShell::push`].
38///
39/// Intended for **testing use**.
40pub struct Script {
41   events: Vec<ScriptEvent>,
42   success: bool
43}
44
45impl Default for Script {
46   fn default() -> Self {
47      Self::new()
48   }
49}
50
51impl Script {
52   /// Create a new empty `Script` that exits with success by default.
53   pub fn new() -> Self {
54      Self { events: Vec::new(), success: true }
55   }
56
57   /// Write raw text to stdout (no implicit newline).
58   pub fn out(mut self, text: &str) -> Self {
59      self.events.push(ScriptEvent::Out(text.to_string()));
60      self
61   }
62
63   /// Write raw text to stdout then sleep for `ms` milliseconds.
64   pub fn out_ms(self, text: &str, ms: u64) -> Self {
65      self.out(text).delay_ms(ms)
66   }
67
68   /// Write text followed by a newline to stdout.
69   pub fn out_line(self, text: &str) -> Self {
70      self.out(text).out("\n")
71   }
72
73   /// Write text followed by a cr to stdout.
74   pub fn out_cr(mut self, text: &str) -> Self {
75      self.events.push(ScriptEvent::Out(format!("{text}\r")));
76      self
77   }
78
79   /// Write text followed by a newline to stdout then sleep for `ms` milliseconds.
80   pub fn out_line_ms(self, text: &str, ms: u64) -> Self {
81      self.out_line(text).delay_ms(ms)
82   }
83
84   /// Write text followed by a cr to stdout then sleep for `ms` milliseconds.
85   pub fn out_cr_ms(self, text: &str, ms: u64) -> Self {
86      self.out_cr(text).delay_ms(ms)
87   }
88
89   /// Write raw text to stderr (no implicit newline).
90   pub fn err(mut self, text: &str) -> Self {
91      self.events.push(ScriptEvent::Err(text.to_string()));
92      self
93   }
94
95   /// Write raw text to stderr then sleep for `ms` milliseconds.
96   pub fn err_ms(self, text: &str, ms: u64) -> Self {
97      self.err(text).delay_ms(ms)
98   }
99
100   /// Write text followed by a newline to stderr.
101   pub fn err_line(self, text: &str) -> Self {
102      self.err(text).err("\n")
103   }
104
105   /// Write text followed by a newline to stderr then sleep for `ms` milliseconds.
106   pub fn err_line_ms(self, text: &str, ms: u64) -> Self {
107      self.err_line(text).delay_ms(ms)
108   }
109
110   /// Sleep for `ms` milliseconds before processing the next event.
111   pub fn delay_ms(mut self, ms: u64) -> Self {
112      self.events.push(ScriptEvent::Delay(ms));
113      self
114   }
115
116   /// Mark this script as exiting with a failure code. Default is success.
117   pub fn exit_failure(mut self) -> Self {
118      self.success = false;
119      self
120   }
121}
122
123// ---------------------------------------------------------------------------
124// ScriptedShell
125// ---------------------------------------------------------------------------
126
127/// A [`Shell`] implementation that drives the real spinner overlay using
128/// pre-configured output scripts. No OS processes are spawned.
129///
130/// Intended for **testing use**. Enqueue one [`Script`] per expected
131/// [`Shell::run_command`] call via [`ScriptedShell::push`]; each call pops the
132/// front script and replays its events through the live overlay renderer,
133/// letting you write overlay integration tests without real subprocesses.
134///
135/// Use [`ScriptedShell::with_config`] to supply a custom [`ShellConfig`]
136/// (e.g. to change the overlay viewport height).
137pub struct ScriptedShell {
138   scripts: RefCell<VecDeque<Script>>,
139   config: ShellConfig
140}
141
142impl Default for ScriptedShell {
143   fn default() -> Self {
144      Self::new()
145   }
146}
147
148impl ScriptedShell {
149   /// Create a new `ScriptedShell` with an empty script queue and default config.
150   pub fn new() -> Self {
151      Self { scripts: RefCell::new(VecDeque::new()), config: ShellConfig::default() }
152   }
153
154   /// Override the shell configuration (e.g. `viewport_size`).
155   pub fn with_config(mut self, config: ShellConfig) -> Self {
156      self.config = config;
157      self
158   }
159
160   /// Enqueue a script to be consumed by the next `run_command` call.
161   pub fn push(self, script: Script) -> Self {
162      self.scripts.borrow_mut().push_back(script);
163      self
164   }
165}
166
167impl Shell for ScriptedShell {
168   fn run_command(
169      &self,
170      label: &str,
171      _program: &str,
172      _args: &[&str],
173      output: &mut dyn Output,
174      _mode: OutputMode
175   ) -> Result<CommandResult, ShellError> {
176      let script =
177         self.scripts.borrow_mut().pop_front().expect("ScriptedShell: run_command called but script queue is empty");
178
179      let (tx, rx) = mpsc::channel::<Line>();
180      let success = script.success;
181
182      thread::spawn(move || {
183         let mut stdout_buf = String::new();
184         let mut stderr_buf = String::new();
185
186         for event in script.events {
187            match event {
188               ScriptEvent::Out(s) => feed(&s, &mut stdout_buf, false, &tx),
189               ScriptEvent::Err(s) => feed(&s, &mut stderr_buf, true, &tx),
190               ScriptEvent::Delay(ms) => thread::sleep(Duration::from_millis(ms))
191            }
192         }
193
194         // Flush any remaining buffered text (no trailing newline).
195         if !stdout_buf.is_empty() {
196            let _ = tx.send(Line::Stdout(std::mem::take(&mut stdout_buf)));
197         }
198         if !stderr_buf.is_empty() {
199            let _ = tx.send(Line::Stderr(std::mem::take(&mut stderr_buf)));
200         }
201         // tx dropped here → receiver disconnects → overlay loop exits
202      });
203
204      let rendered = render_overlay_lines(label, rx, self.config.viewport_size);
205      output.step_result(label, success, rendered.elapsed.as_millis(), &rendered.viewport);
206
207      Ok(CommandResult { success, stderr: rendered.stderr_lines.join("\n") })
208   }
209
210   fn shell_exec(
211      &self,
212      _script: &str,
213      _output: &mut dyn Output,
214      _mode: OutputMode
215   ) -> Result<CommandResult, ShellError> {
216      Ok(CommandResult { success: true, stderr: String::new() })
217   }
218
219   fn command_exists(&self, _program: &str) -> bool {
220      true
221   }
222
223   fn command_output(&self, _program: &str, _args: &[&str]) -> Result<String, ShellError> {
224      Ok(String::new())
225   }
226
227   fn exec_capture(
228      &self,
229      _cmd: &str,
230      _output: &mut dyn Output,
231      _mode: OutputMode
232   ) -> Result<CommandResult, ShellError> {
233      Ok(CommandResult { success: true, stderr: String::new() })
234   }
235
236   fn exec_interactive(&self, _cmd: &str, _output: &mut dyn Output, _mode: OutputMode) -> Result<(), ShellError> {
237      Ok(())
238   }
239}
240
241// ---------------------------------------------------------------------------
242// Helpers
243// ---------------------------------------------------------------------------
244
245// ---------------------------------------------------------------------------
246// Tests
247// ---------------------------------------------------------------------------
248
249#[cfg(test)]
250mod tests {
251   use std::sync::mpsc;
252
253   use super::{Line, Script, ScriptedShell, feed};
254   use crate::{
255      output::{OutputMode, StringOutput},
256      shell::{Shell, ShellConfig}
257   };
258
259   // -----------------------------------------------------------------------
260   // Helpers
261   // -----------------------------------------------------------------------
262
263   fn default_mode() -> OutputMode {
264      OutputMode::default()
265   }
266
267   /// Drain a channel into a Vec, classifying each Line.
268   fn collect_lines(rx: mpsc::Receiver<Line>) -> Vec<Line> {
269      rx.into_iter().collect()
270   }
271
272   /// Convenience: run feed() on `input` starting with an empty buffer,
273   /// collect every Line sent on the channel, and return (lines, remaining_buf).
274   fn feed_all(input: &str, is_stderr: bool) -> (Vec<Line>, String) {
275      let (tx, rx) = mpsc::channel::<Line>();
276      let mut buf = String::new();
277      feed(input, &mut buf, is_stderr, &tx);
278      drop(tx);
279      let lines = collect_lines(rx);
280      (lines, buf)
281   }
282
283   // -----------------------------------------------------------------------
284   // feed() — basic newline splitting
285   // -----------------------------------------------------------------------
286
287   #[test]
288   fn feed_single_complete_stdout_line() {
289      let (lines, buf) = feed_all("hello\n", false);
290      assert_eq!(lines.len(), 1);
291      assert!(matches!(&lines[0], Line::Stdout(s) if s == "hello"));
292      assert!(buf.is_empty());
293   }
294
295   #[test]
296   fn feed_multiple_newline_lines() {
297      let (lines, buf) = feed_all("a\nb\nc\n", false);
298      assert_eq!(lines.len(), 3);
299      assert!(matches!(&lines[0], Line::Stdout(s) if s == "a"));
300      assert!(matches!(&lines[1], Line::Stdout(s) if s == "b"));
301      assert!(matches!(&lines[2], Line::Stdout(s) if s == "c"));
302      assert!(buf.is_empty());
303   }
304
305   #[test]
306   fn feed_partial_line_stays_in_buffer() {
307      let (lines, buf) = feed_all("partial", false);
308      assert!(lines.is_empty());
309      assert_eq!(buf, "partial");
310   }
311
312   #[test]
313   fn feed_partial_line_flushed_by_subsequent_newline() {
314      let (tx, rx) = mpsc::channel::<Line>();
315      let mut buf = String::new();
316      feed("partial", &mut buf, false, &tx);
317      feed(" line\n", &mut buf, false, &tx);
318      drop(tx);
319      let lines = collect_lines(rx);
320      assert_eq!(lines.len(), 1);
321      assert!(matches!(&lines[0], Line::Stdout(s) if s == "partial line"));
322      assert!(buf.is_empty());
323   }
324
325   // -----------------------------------------------------------------------
326   // feed() — carriage-return (StdoutCr) handling
327   // -----------------------------------------------------------------------
328
329   #[test]
330   fn feed_cr_produces_stdout_cr() {
331      let (lines, buf) = feed_all("progress\r", false);
332      assert_eq!(lines.len(), 1);
333      assert!(matches!(&lines[0], Line::StdoutCr(s) if s == "progress"));
334      assert!(buf.is_empty());
335   }
336
337   #[test]
338   fn feed_cr_overwrites_accumulate_correctly() {
339      // Simulate a progress bar that rewrites the same line three times.
340      let (lines, buf) = feed_all("10%\r50%\r100%\r", false);
341      assert_eq!(lines.len(), 3);
342      assert!(matches!(&lines[0], Line::StdoutCr(s) if s == "10%"));
343      assert!(matches!(&lines[1], Line::StdoutCr(s) if s == "50%"));
344      assert!(matches!(&lines[2], Line::StdoutCr(s) if s == "100%"));
345      assert!(buf.is_empty());
346   }
347
348   #[test]
349   fn feed_cr_then_newline_sends_cr_then_stdout() {
350      // "10%\r\n" → StdoutCr("10%") then Stdout("")
351      let (lines, buf) = feed_all("10%\r\n", false);
352      assert_eq!(lines.len(), 2);
353      assert!(matches!(&lines[0], Line::StdoutCr(s) if s == "10%"));
354      assert!(matches!(&lines[1], Line::Stdout(s) if s.is_empty()));
355      assert!(buf.is_empty());
356   }
357
358   #[test]
359   fn feed_newline_in_cr_payload_is_preserved() {
360      // Text between \r terminators may contain \n as payload (multi-line
361      // progress-bar unit). Those \n chars must survive as literal payload
362      // inside the StdoutCr line, not be treated as line terminators.
363      let (lines, buf) = feed_all("line1\nline2\r", false);
364      assert_eq!(lines.len(), 1);
365      assert!(matches!(&lines[0], Line::StdoutCr(s) if s == "line1\nline2"));
366      assert!(buf.is_empty());
367   }
368
369   // -----------------------------------------------------------------------
370   // feed() — stderr
371   // -----------------------------------------------------------------------
372
373   #[test]
374   fn feed_stderr_produces_stderr_lines() {
375      let (lines, buf) = feed_all("error msg\n", true);
376      assert_eq!(lines.len(), 1);
377      assert!(matches!(&lines[0], Line::Stderr(s) if s == "error msg"));
378      assert!(buf.is_empty());
379   }
380
381   #[test]
382   fn feed_stderr_cr_treated_as_newline() {
383      // For stderr, \r is treated the same as \n (produces Line::Stderr).
384      let (lines, _buf) = feed_all("err\r", true);
385      assert_eq!(lines.len(), 1);
386      assert!(matches!(&lines[0], Line::Stderr(_)));
387   }
388
389   #[test]
390   fn feed_stderr_partial_stays_in_buffer() {
391      let (lines, buf) = feed_all("partial err", true);
392      assert!(lines.is_empty());
393      assert_eq!(buf, "partial err");
394   }
395
396   // -----------------------------------------------------------------------
397   // feed() — empty and edge cases
398   // -----------------------------------------------------------------------
399
400   #[test]
401   fn feed_empty_input_produces_nothing() {
402      let (lines, buf) = feed_all("", false);
403      assert!(lines.is_empty());
404      assert!(buf.is_empty());
405   }
406
407   #[test]
408   fn feed_only_newline_produces_empty_stdout_line() {
409      let (lines, buf) = feed_all("\n", false);
410      assert_eq!(lines.len(), 1);
411      assert!(matches!(&lines[0], Line::Stdout(s) if s.is_empty()));
412      assert!(buf.is_empty());
413   }
414
415   #[test]
416   fn feed_only_cr_produces_empty_stdout_cr_line() {
417      let (lines, buf) = feed_all("\r", false);
418      assert_eq!(lines.len(), 1);
419      assert!(matches!(&lines[0], Line::StdoutCr(s) if s.is_empty()));
420      assert!(buf.is_empty());
421   }
422
423   // -----------------------------------------------------------------------
424   // ScriptedShell — run_command result
425   // -----------------------------------------------------------------------
426
427   #[test]
428   fn scripted_shell_success_result() {
429      let shell = ScriptedShell::new().push(Script::new().out_line("step done"));
430      let mut out = StringOutput::new();
431      let result = shell.run_command("build", "unused", &[], &mut out, default_mode()).unwrap();
432      assert!(result.success);
433   }
434
435   #[test]
436   fn scripted_shell_failure_result() {
437      let shell = ScriptedShell::new().push(Script::new().err_line("something broke").exit_failure());
438      let mut out = StringOutput::new();
439      let result = shell.run_command("deploy", "unused", &[], &mut out, default_mode()).unwrap();
440      assert!(!result.success);
441   }
442
443   #[test]
444   fn scripted_shell_stderr_captured_in_result() {
445      let shell = ScriptedShell::new().push(Script::new().err_line("warn: low disk").exit_failure());
446      let mut out = StringOutput::new();
447      let result = shell.run_command("check", "unused", &[], &mut out, default_mode()).unwrap();
448      assert_eq!(result.stderr, "warn: low disk");
449   }
450
451   #[test]
452   fn scripted_shell_multiple_stderr_lines_joined() {
453      let shell =
454         ScriptedShell::new().push(Script::new().err_line("error: line 1").err_line("error: line 2").exit_failure());
455      let mut out = StringOutput::new();
456      let result = shell.run_command("test", "unused", &[], &mut out, default_mode()).unwrap();
457      assert_eq!(result.stderr, "error: line 1\nerror: line 2");
458   }
459
460   #[test]
461   fn scripted_shell_step_result_written_to_output() {
462      let shell = ScriptedShell::new().push(Script::new().out_line("ok"));
463      let mut out = StringOutput::new();
464      shell.run_command("mytask", "unused", &[], &mut out, default_mode()).unwrap();
465      // StringOutput::step_result writes "✓ label (elapsed)"
466      assert!(out.log().contains("mytask"));
467      assert!(out.log().starts_with('✓'));
468   }
469
470   #[test]
471   fn scripted_shell_failure_step_result_uses_cross() {
472      let shell = ScriptedShell::new().push(Script::new().err_line("bad").exit_failure());
473      let mut out = StringOutput::new();
474      shell.run_command("mytask", "unused", &[], &mut out, default_mode()).unwrap();
475      assert!(out.log().starts_with('✗'));
476   }
477
478   #[test]
479   fn scripted_shell_multiple_scripts_consumed_in_order() {
480      let shell = ScriptedShell::new()
481         .push(Script::new().out_line("first"))
482         .push(Script::new().out_line("second").exit_failure());
483      let mut out = StringOutput::new();
484
485      let r1 = shell.run_command("step1", "unused", &[], &mut out, default_mode()).unwrap();
486      let r2 = shell.run_command("step2", "unused", &[], &mut out, default_mode()).unwrap();
487
488      assert!(r1.success);
489      assert!(!r2.success);
490   }
491
492   #[test]
493   fn scripted_shell_empty_queue_panics() {
494      let shell = ScriptedShell::new();
495      let mut out = StringOutput::new();
496      let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
497         shell.run_command("oops", "unused", &[], &mut out, default_mode()).unwrap();
498      }));
499      assert!(result.is_err());
500   }
501
502   // -----------------------------------------------------------------------
503   // ScriptedShell — other Shell trait methods
504   // -----------------------------------------------------------------------
505
506   #[test]
507   fn scripted_shell_shell_exec_returns_success() {
508      let shell = ScriptedShell::new();
509      let mut out = StringOutput::new();
510      let result = shell.shell_exec("echo hi", &mut out, default_mode()).unwrap();
511      assert!(result.success);
512   }
513
514   #[test]
515   fn scripted_shell_command_exists_returns_true() {
516      let shell = ScriptedShell::new();
517      assert!(shell.command_exists("anything"));
518   }
519
520   #[test]
521   fn scripted_shell_command_output_returns_empty_string() {
522      let shell = ScriptedShell::new();
523      let result = shell.command_output("anything", &["--version"]).unwrap();
524      assert_eq!(result, "");
525   }
526
527   #[test]
528   fn scripted_shell_exec_capture_returns_success() {
529      let shell = ScriptedShell::new();
530      let mut out = StringOutput::new();
531      let result = shell.exec_capture("echo hi", &mut out, default_mode()).unwrap();
532      assert!(result.success);
533   }
534
535   #[test]
536   fn scripted_shell_exec_interactive_returns_ok() {
537      let shell = ScriptedShell::new();
538      let mut out = StringOutput::new();
539      assert!(shell.exec_interactive("echo hi", &mut out, default_mode()).is_ok());
540   }
541
542   // -----------------------------------------------------------------------
543   // ScriptedShell — custom viewport_size via with_config
544   // -----------------------------------------------------------------------
545
546   #[test]
547   fn scripted_shell_with_config_accepts_custom_viewport() {
548      let config = ShellConfig { viewport_size: 2 };
549      let shell = ScriptedShell::new()
550         .with_config(config)
551         .push(Script::new().out_line("line 1").out_line("line 2").out_line("line 3"));
552      let mut out = StringOutput::new();
553      // Should not panic; simply verify the call completes successfully.
554      let result = shell.run_command("task", "unused", &[], &mut out, default_mode()).unwrap();
555      assert!(result.success);
556   }
557}
558
559/// Append `s` to `buf`, sending one `Line` per `\r` or `\n` terminator encountered.
560///
561/// The terminator character determines the `Line` variant:
562/// - `\r` → `Line::StdoutCr`: overwrites the last viewport slot in place.
563/// - `\n` → `Line::Stdout` / `Line::Stderr`: appends a new viewport slot.
564///
565/// Crucially, `\r` takes precedence over any `\n` characters embedded within
566/// the same chunk. The input is therefore split on `\r` first; within each
567/// `\r`-terminated segment the `\n` characters are **payload** (they represent
568/// sub-rows of a multi-line progress-bar unit) and are preserved verbatim in
569/// the emitted string. Only within segments that are ultimately `\n`-terminated
570/// (i.e. no `\r` follows) are embedded `\n` characters treated as line breaks.
571///
572/// Any text after the final terminator remains buffered for the next call.
573fn feed(s: &str, buf: &mut String, is_stderr: bool, tx: &mpsc::Sender<Line>) {
574   // Split on \r first to identify CR-terminated chunks.
575   let mut segments = s.split('\r').peekable();
576
577   while let Some(seg) = segments.next() {
578      let is_last = segments.peek().is_none();
579
580      if is_last {
581         // Tail after the last \r (or the whole string if no \r present).
582         // Within this tail, \n characters are genuine line terminators.
583         if is_stderr {
584            for ch in seg.chars() {
585               if ch == '\n' {
586                  let line = std::mem::take(buf);
587                  let _ = tx.send(Line::Stderr(line));
588               } else {
589                  buf.push(ch);
590               }
591            }
592         } else {
593            for ch in seg.chars() {
594               if ch == '\n' {
595                  let line = std::mem::take(buf);
596                  let _ = tx.send(Line::Stdout(line));
597               } else {
598                  buf.push(ch);
599               }
600            }
601         }
602      } else {
603         // This segment is followed by a \r, so the whole accumulated
604         // content (buf + seg, with any \n kept as payload) becomes a
605         // StdoutCr line.  Stderr never uses \r.
606         buf.push_str(seg);
607         let line = std::mem::take(buf);
608         if is_stderr {
609            // Treat \r as \n for stderr (shouldn't normally occur).
610            let _ = tx.send(Line::Stderr(line));
611         } else {
612            let _ = tx.send(Line::StdoutCr(line));
613         }
614      }
615   }
616}