clitest_lib/
command.rs

1use std::{
2    collections::HashMap,
3    io::{BufRead, BufReader},
4    process::{Command, ExitStatus, Stdio},
5    thread,
6    time::{Duration, Instant},
7};
8
9use serde::Serialize;
10use shellish_parse::ParseOptions;
11use termcolor::Color;
12
13use crate::{
14    cwrite, cwriteln,
15    output::Lines,
16    script::{ScriptKillReceiver, ScriptKillSender, ScriptLocation},
17};
18
19#[derive(Copy, Clone, derive_more::Debug, derive_more::Display, PartialEq, Eq)]
20pub enum CommandResult {
21    #[debug("{_0:?}")]
22    #[display("{_0}")]
23    Exit(ExitStatus),
24    #[debug("timed out")]
25    #[display("timed out")]
26    TimedOut,
27}
28
29impl CommandResult {
30    pub fn success(&self) -> bool {
31        match self {
32            CommandResult::Exit(status) => status.success(),
33            CommandResult::TimedOut => false,
34        }
35    }
36}
37
38#[derive(Clone, Debug, Serialize)]
39#[serde(transparent)]
40pub struct CommandLine {
41    pub command: String,
42    #[serde(skip)]
43    pub location: ScriptLocation,
44    #[serde(skip)]
45    pub line_count: usize,
46}
47
48impl CommandLine {
49    pub fn new(command: String, location: ScriptLocation, line_count: usize) -> Self {
50        Self {
51            command,
52            location,
53            line_count,
54        }
55    }
56
57    #[allow(clippy::too_many_arguments)]
58    pub fn run(
59        &self,
60        writer: &mut dyn termcolor::WriteColor,
61        show_line_numbers: bool,
62        runner: Option<String>,
63        timeout: Duration,
64        envs: &HashMap<String, String>,
65        kill_receiver: &ScriptKillReceiver,
66        kill_sender: &ScriptKillSender,
67    ) -> Result<(Lines, CommandResult), std::io::Error> {
68        let start = Instant::now();
69        let warn_time = timeout.saturating_mul(90) / 100;
70        let timeout = timeout.saturating_mul(110) / 100;
71
72        // This fails to exit if the command hangs....
73        thread::scope(|s| {
74            let mut command = if let Some(runner) = runner {
75                let bits = shellish_parse::parse(&runner, ParseOptions::default())
76                    .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
77                let mut cmd = Command::new(&bits[0]);
78                cmd.args(&bits[1..]);
79                cmd
80            } else {
81                let mut cmd = Command::new("sh");
82                cmd.arg("-c");
83                cmd
84            };
85            command.arg(&self.command);
86            command.envs(envs);
87            if let Some(pwd) = envs.get("PWD") {
88                command.current_dir(pwd);
89            }
90            #[cfg(unix)]
91            {
92                use std::os::unix::process::CommandExt;
93                command.process_group(0);
94            }
95            #[cfg(windows)]
96            {
97                use std::os::windows::process::CommandExt;
98                const CREATE_SUSPENDED: u32 = 0x00000004;
99                command.creation_flags(CREATE_SUSPENDED);
100            }
101            command.stdout(Stdio::piped());
102            command.stderr(Stdio::piped());
103            command.stdin(Stdio::null());
104            let mut output = command.spawn().map_err(|e| {
105                std::io::Error::new(
106                    e.kind(),
107                    format!("failed to spawn command {command:?}: {e}"),
108                )
109            })?;
110            let (tx, rx) = std::sync::mpsc::channel();
111
112            // Spawn a thread for stdout and stderr and collect each line we read into a buffer
113            let stdout_lines = tx.clone();
114            let stdout = output.stdout.take().unwrap();
115            let stdout = s.spawn(move || {
116                let mut reader = BufReader::new(stdout);
117                let mut line = String::new();
118                while reader.read_line(&mut line).unwrap() > 0 {
119                    if line.is_empty() {
120                        continue;
121                    }
122                    if line.ends_with('\n') {
123                        line.pop();
124                    }
125                    _ = stdout_lines.send((true, std::mem::take(&mut line)));
126                }
127            });
128
129            let stderr_lines = tx;
130            let stderr = output.stderr.take().unwrap();
131            let stderr = s.spawn(move || {
132                let mut reader = BufReader::new(stderr);
133                let mut line = String::new();
134                while reader.read_line(&mut line).unwrap() > 0 {
135                    if line.is_empty() {
136                        continue;
137                    }
138                    if line.ends_with('\n') {
139                        line.pop();
140                    }
141                    _ = stderr_lines.send((false, std::mem::take(&mut line)));
142                }
143            });
144
145            let runner = s.spawn(move || kill_receiver.run_cmd(output, warn_time));
146
147            let mut line_number = 1;
148            let mut output_lines = vec![];
149
150            while let Ok((is_stdout, line)) = rx.recv_timeout(timeout) {
151                if show_line_numbers {
152                    cwrite!(
153                        writer,
154                        fg = Color::White,
155                        dimmed = true,
156                        "{line_number:>3} "
157                    );
158                }
159
160                // Careful that we don't print ANSI escape sequences
161                let line_out = fast_strip_ansi::strip_ansi_string(&line);
162                if is_stdout {
163                    cwriteln!(writer, fg = Color::White, "{line_out}");
164                } else {
165                    cwriteln!(writer, fg = Color::Yellow, "{line_out}");
166                }
167
168                output_lines.push(line);
169                line_number += 1;
170            }
171
172            let mut handles = vec![stdout, stderr];
173            while !handles.is_empty() {
174                if start.elapsed() > timeout {
175                    cwriteln!(writer, fg = Color::Yellow, "Process took too long!");
176                    kill_sender.kill();
177
178                    return Ok((Lines::new(output_lines), CommandResult::TimedOut));
179                }
180
181                let mut new_handles = vec![];
182                for handle in handles.drain(..) {
183                    if handle.is_finished() {
184                        handle
185                            .join()
186                            .map_err(|_| std::io::Error::other("thread panicked"))?;
187                    } else {
188                        new_handles.push(handle);
189                    }
190                }
191                handles = new_handles;
192                std::thread::sleep(std::time::Duration::from_millis(10));
193            }
194
195            Ok((
196                Lines::new(output_lines),
197                CommandResult::Exit(runner.join().unwrap()?),
198            ))
199        })
200    }
201}