Skip to main content

jt_consoleutils/shell/
exec.rs

1use std::{
2   io::{self, BufRead},
3   process::{Child, ChildStderr, ChildStdout, Command, ExitStatus, Stdio},
4   sync::mpsc,
5   thread,
6   time::{Duration, Instant}
7};
8
9use super::{CommandResult, ShellError, overlay};
10use crate::output::{Output, OutputMode};
11
12const FRAME_INTERVAL: Duration = Duration::from_millis(80);
13
14pub(super) enum Line {
15   Stdout(String),
16   /// A stdout chunk terminated by `\r` rather than `\n` — overwrites the
17   /// current viewport line in place instead of appending a new one.
18   StdoutCr(String),
19   Stderr(String)
20}
21
22impl Line {
23   fn text(&self) -> &str {
24      match self {
25         Line::Stdout(s) | Line::StdoutCr(s) | Line::Stderr(s) => s
26      }
27   }
28}
29
30pub(super) struct RenderedOverlay {
31   pub viewport: Vec<String>,
32   pub stderr_lines: Vec<String>,
33   pub elapsed: Duration
34}
35
36struct SpawnedCommand {
37   child: Child,
38   lines: mpsc::Receiver<Line>,
39   readers: Vec<thread::JoinHandle<()>>
40}
41
42/// Run a shell command with mode-appropriate output:
43/// - **quiet**: collect output silently
44/// - **verbose**: stream with `| label...` and `> ` prefixed lines
45/// - **default**: animated spinner overlay with scrolling viewport
46pub fn run_command(
47   label: &str,
48   program: &str,
49   args: &[&str],
50   output: &mut dyn Output,
51   mode: OutputMode,
52   viewport_size: usize
53) -> Result<CommandResult, ShellError> {
54   if mode.is_quiet() {
55      return run_quiet(program, args);
56   }
57   if mode.is_verbose() {
58      return run_verbose(label, program, args, output, mode);
59   }
60   run_overlay(label, program, args, output, viewport_size)
61}
62
63/// Run a command with stdout and stderr inherited from the parent process.
64///
65/// Use this for read-only inspection commands (e.g. `outdated`) where the
66/// output should be displayed directly to the user without any spinner or
67/// prefix decoration.
68pub fn run_passthrough(
69   program: &str,
70   args: &[&str],
71   output: &mut dyn Output,
72   mode: OutputMode
73) -> Result<CommandResult, ShellError> {
74   if mode.is_dry_run() {
75      output.dry_run_shell(&super::format_command(program, args));
76      return Ok(CommandResult { success: true, stderr: String::new() });
77   }
78
79   let status = Command::new(program)
80      .args(args)
81      .stdout(Stdio::inherit())
82      .stderr(Stdio::inherit())
83      .spawn()
84      .map_err(|e| ShellError::Spawn(program.to_string(), e))?
85      .wait()
86      .map_err(|e| ShellError::Wait(program.to_string(), e))?;
87
88   Ok(CommandResult { success: status.success(), stderr: String::new() })
89}
90
91/// Quiet mode: collect output silently, no terminal rendering.
92pub(super) fn run_quiet(program: &str, args: &[&str]) -> Result<CommandResult, ShellError> {
93   let SpawnedCommand { child, lines, readers } = spawn_command_with_lines(program, args)?;
94   let stderr_lines = collect_stderr_lines(lines, |_| {});
95   let status = wait_and_join(program, child, readers)?;
96
97   Ok(CommandResult { success: status.success(), stderr: stderr_lines.join("\n") })
98}
99
100/// Verbose mode: stream output with `| label...` header and `> ` prefixed lines.
101fn run_verbose(
102   label: &str,
103   program: &str,
104   args: &[&str],
105   output: &mut dyn Output,
106   mode: OutputMode
107) -> Result<CommandResult, ShellError> {
108   output.log(mode, &format!("{label}..."));
109   output.shell_command(&super::format_command(program, args));
110
111   let SpawnedCommand { child, lines, readers } = spawn_command_with_lines(program, args)?;
112   let stderr_lines = collect_stderr_lines(lines, |line| output.shell_line(line));
113   let status = wait_and_join(program, child, readers)?;
114
115   Ok(CommandResult { success: status.success(), stderr: stderr_lines.join("\n") })
116}
117
118/// Default mode: animated spinner with scrolling viewport overlay.
119fn run_overlay(
120   label: &str,
121   program: &str,
122   args: &[&str],
123   output: &mut dyn Output,
124   viewport_size: usize
125) -> Result<CommandResult, ShellError> {
126   let SpawnedCommand { child, lines, readers } = spawn_command_with_lines(program, args)?;
127   let rendered = render_overlay_lines(label, lines, viewport_size);
128   let status = wait_and_join(program, child, readers)?;
129   output.step_result(label, status.success(), rendered.elapsed.as_millis(), &rendered.viewport);
130   Ok(CommandResult { success: status.success(), stderr: rendered.stderr_lines.join("\n") })
131}
132
133/// Drive the animated spinner overlay from a pre-built line receiver.
134/// Returns viewport, collected stderr lines, and elapsed time.
135/// Callers are responsible for calling `output.step_result` afterward.
136pub(super) fn render_overlay_lines(label: &str, lines: mpsc::Receiver<Line>, viewport_size: usize) -> RenderedOverlay {
137   let mut stderr_lines: Vec<String> = Vec::new();
138   let mut viewport: Vec<String> = Vec::new();
139   let start = Instant::now();
140
141   {
142      let stdout_handle = io::stdout();
143      let mut out = stdout_handle.lock();
144      let mut frame = 0usize;
145      let mut last_rows = overlay::render_frame(&mut out, label, &[], 0, 0, viewport_size);
146
147      loop {
148         match lines.recv_timeout(FRAME_INTERVAL) {
149            Ok(line) => {
150               let text = line.text().to_string();
151               match line {
152                  Line::StdoutCr(_) => {
153                     // Overwrite the last viewport entry in place (progress-bar style).
154                     if let Some(last) = viewport.last_mut() {
155                        *last = text;
156                     } else {
157                        viewport.push(text);
158                     }
159                  }
160                  Line::Stderr(s) => {
161                     stderr_lines.push(s.clone());
162                     viewport.push(s);
163                  }
164                  Line::Stdout(_) => {
165                     viewport.push(text);
166                  }
167               }
168               frame += 1;
169               last_rows = overlay::render_frame(&mut out, label, &viewport, frame, last_rows, viewport_size);
170            }
171            Err(mpsc::RecvTimeoutError::Timeout) => {
172               frame += 1;
173               last_rows = overlay::render_frame(&mut out, label, &viewport, frame, last_rows, viewport_size);
174            }
175            Err(mpsc::RecvTimeoutError::Disconnected) => break
176         }
177      }
178
179      overlay::clear_lines(&mut out, last_rows);
180   }
181
182   RenderedOverlay { viewport, stderr_lines, elapsed: start.elapsed() }
183}
184
185fn spawn_command_with_lines(program: &str, args: &[&str]) -> Result<SpawnedCommand, ShellError> {
186   let mut child = Command::new(program)
187      .args(args)
188      .stdout(Stdio::piped())
189      .stderr(Stdio::piped())
190      .spawn()
191      .map_err(|e| ShellError::Spawn(program.to_string(), e))?;
192
193   let child_stdout = child
194      .stdout
195      .take()
196      .ok_or_else(|| ShellError::Spawn(program.to_string(), io::Error::other("stdout is not piped")))?;
197   let child_stderr = child
198      .stderr
199      .take()
200      .ok_or_else(|| ShellError::Spawn(program.to_string(), io::Error::other("stderr is not piped")))?;
201
202   let (tx, rx) = mpsc::channel::<Line>();
203   let readers = spawn_line_readers(child_stdout, child_stderr, tx);
204
205   Ok(SpawnedCommand { child, lines: rx, readers })
206}
207
208fn spawn_line_readers(stdout: ChildStdout, stderr: ChildStderr, tx: mpsc::Sender<Line>) -> Vec<thread::JoinHandle<()>> {
209   let tx_stderr = tx.clone();
210
211   let stdout_reader = thread::spawn(move || {
212      use std::io::Read;
213      let mut buf = String::new();
214      let mut raw = String::new();
215      let mut reader = io::BufReader::new(stdout);
216      let mut byte = [0u8; 1];
217      while reader.read(&mut byte).unwrap_or(0) > 0 {
218         let ch = byte[0] as char;
219         if ch == '\n' {
220            let line = std::mem::take(&mut buf);
221            // Drain any \r-terminated segment that was pending in raw
222            raw.clear();
223            let _ = tx.send(Line::Stdout(line));
224         } else if ch == '\r' {
225            let segment = std::mem::take(&mut buf);
226            raw.clear();
227            let _ = tx.send(Line::StdoutCr(segment));
228         } else {
229            buf.push(ch);
230            raw.push(ch);
231         }
232      }
233      // Flush any remaining text without a terminator.
234      if !buf.is_empty() {
235         let _ = tx.send(Line::Stdout(buf));
236      }
237   });
238
239   let stderr_reader = thread::spawn(move || {
240      for line in io::BufReader::new(stderr).lines().map_while(Result::ok) {
241         let _ = tx_stderr.send(Line::Stderr(line));
242      }
243   });
244
245   vec![stdout_reader, stderr_reader]
246}
247
248fn collect_stderr_lines(lines: mpsc::Receiver<Line>, mut on_line: impl FnMut(&str)) -> Vec<String> {
249   let mut stderr_lines = Vec::new();
250
251   for line in lines {
252      let text = line.text().to_string();
253      on_line(&text);
254      if let Line::Stderr(stderr) = line {
255         stderr_lines.push(stderr);
256      }
257   }
258
259   stderr_lines
260}
261
262fn wait_and_join(
263   program: &str,
264   mut child: Child,
265   readers: Vec<thread::JoinHandle<()>>
266) -> Result<ExitStatus, ShellError> {
267   let status = child.wait().map_err(|e| ShellError::Wait(program.to_string(), e))?;
268
269   for reader in readers {
270      reader.join().unwrap();
271   }
272
273   Ok(status)
274}