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    pub fn run(
58        &self,
59        writer: &mut dyn termcolor::WriteColor,
60        show_line_numbers: bool,
61        runner: Option<String>,
62        timeout: Duration,
63        envs: &HashMap<String, String>,
64        kill_receiver: &ScriptKillReceiver,
65        kill_sender: &ScriptKillSender,
66    ) -> Result<(Lines, CommandResult), std::io::Error> {
67        let start = Instant::now();
68        let warn_time = timeout.saturating_mul(90) / 100;
69        let timeout = timeout.saturating_mul(110) / 100;
70
71        // This fails to exit if the command hangs....
72        thread::scope(|s| {
73            let mut command = if let Some(runner) = runner {
74                let bits = shellish_parse::parse(&runner, ParseOptions::default())
75                    .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
76                let mut cmd = Command::new(&bits[0]);
77                cmd.args(&bits[1..]);
78                cmd
79            } else {
80                let mut cmd = Command::new("sh");
81                cmd.arg("-c");
82                cmd
83            };
84            command.arg(&self.command);
85            command.envs(envs);
86            if let Some(pwd) = envs.get("PWD") {
87                command.current_dir(pwd);
88            }
89            #[cfg(unix)]
90            {
91                use std::os::unix::process::CommandExt;
92                command.process_group(0);
93            }
94            #[cfg(windows)]
95            {
96                use std::os::windows::process::CommandExt;
97                const CREATE_SUSPENDED: u32 = 0x00000004;
98                command.creation_flags(CREATE_SUSPENDED);
99            }
100            command.stdout(Stdio::piped());
101            command.stderr(Stdio::piped());
102            command.stdin(Stdio::null());
103            let mut output = command.spawn().map_err(|e| {
104                std::io::Error::new(
105                    e.kind(),
106                    format!("failed to spawn command {command:?}: {e}"),
107                )
108            })?;
109            let (tx, rx) = std::sync::mpsc::channel();
110
111            // Spawn a thread for stdout and stderr and collect each line we read into a buffer
112            let stdout_lines = tx.clone();
113            let stdout = output.stdout.take().unwrap();
114            let stdout = s.spawn(move || {
115                let mut reader = BufReader::new(stdout);
116                let mut line = String::new();
117                while reader.read_line(&mut line).unwrap() > 0 {
118                    if line.is_empty() {
119                        continue;
120                    }
121                    if line.ends_with('\n') {
122                        line.pop();
123                    }
124                    _ = stdout_lines.send((true, std::mem::take(&mut line)));
125                }
126            });
127
128            let stderr_lines = tx;
129            let stderr = output.stderr.take().unwrap();
130            let stderr = s.spawn(move || {
131                let mut reader = BufReader::new(stderr);
132                let mut line = String::new();
133                while reader.read_line(&mut line).unwrap() > 0 {
134                    if line.is_empty() {
135                        continue;
136                    }
137                    if line.ends_with('\n') {
138                        line.pop();
139                    }
140                    _ = stderr_lines.send((false, std::mem::take(&mut line)));
141                }
142            });
143
144            let runner = s.spawn(move || kill_receiver.run_cmd(output, warn_time));
145
146            let mut line_number = 1;
147            let mut output_lines = vec![];
148
149            while let Ok((is_stdout, line)) = rx.recv_timeout(timeout) {
150                if show_line_numbers {
151                    cwrite!(
152                        writer,
153                        fg = Color::White,
154                        dimmed = true,
155                        "{line_number:>3} "
156                    );
157                }
158
159                // Careful that we don't print ANSI escape sequences
160                let line_out = fast_strip_ansi::strip_ansi_string(&line);
161                if is_stdout {
162                    cwriteln!(writer, fg = Color::White, "{line_out}");
163                } else {
164                    cwriteln!(writer, fg = Color::Yellow, "{line_out}");
165                }
166
167                output_lines.push(line);
168                line_number += 1;
169            }
170
171            let mut handles = vec![stdout, stderr];
172            while !handles.is_empty() {
173                if start.elapsed() > timeout {
174                    cwriteln!(writer, fg = Color::Yellow, "Process took too long!");
175                    kill_sender.kill();
176
177                    return Ok((Lines::new(output_lines), CommandResult::TimedOut));
178                }
179
180                let mut new_handles = vec![];
181                for handle in handles.drain(..) {
182                    if handle.is_finished() {
183                        handle
184                            .join()
185                            .map_err(|_| std::io::Error::other("thread panicked"))?;
186                    } else {
187                        new_handles.push(handle);
188                    }
189                }
190                handles = new_handles;
191                std::thread::sleep(std::time::Duration::from_millis(10));
192            }
193
194            Ok((
195                Lines::new(output_lines),
196                CommandResult::Exit(runner.join().unwrap()?),
197            ))
198        })
199    }
200}