Skip to main content

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                    if line.ends_with('\r') {
126                        line.pop();
127                    }
128                    _ = stdout_lines.send((true, std::mem::take(&mut line)));
129                }
130            });
131
132            let stderr_lines = tx;
133            let stderr = output.stderr.take().unwrap();
134            let stderr = s.spawn(move || {
135                let mut reader = BufReader::new(stderr);
136                let mut line = String::new();
137                while reader.read_line(&mut line).unwrap() > 0 {
138                    if line.is_empty() {
139                        continue;
140                    }
141                    if line.ends_with('\n') {
142                        line.pop();
143                    }
144                    if line.ends_with('\r') {
145                        line.pop();
146                    }
147                    _ = stderr_lines.send((false, std::mem::take(&mut line)));
148                }
149            });
150
151            let runner = s.spawn(move || kill_receiver.run_cmd(output, warn_time));
152
153            let mut line_number = 1;
154            let mut output_lines = vec![];
155
156            while let Ok((is_stdout, line)) = rx.recv_timeout(timeout) {
157                if show_line_numbers {
158                    cwrite!(
159                        writer,
160                        fg = Color::White,
161                        dimmed = true,
162                        "{line_number:>3} "
163                    );
164                }
165
166                // Careful that we don't print ANSI escape sequences
167                let line_out = fast_strip_ansi::strip_ansi_string(&line);
168                if is_stdout {
169                    cwriteln!(writer, fg = Color::White, "{line_out}");
170                } else {
171                    cwriteln!(writer, fg = Color::Yellow, "{line_out}");
172                }
173
174                output_lines.push(line);
175                line_number += 1;
176            }
177
178            let mut handles = vec![stdout, stderr];
179            while !handles.is_empty() {
180                if start.elapsed() > timeout {
181                    cwriteln!(writer, fg = Color::Yellow, "Process took too long!");
182                    kill_sender.kill();
183
184                    return Ok((Lines::new(output_lines), CommandResult::TimedOut));
185                }
186
187                let mut new_handles = vec![];
188                for handle in handles.drain(..) {
189                    if handle.is_finished() {
190                        handle
191                            .join()
192                            .map_err(|_| std::io::Error::other("thread panicked"))?;
193                    } else {
194                        new_handles.push(handle);
195                    }
196                }
197                handles = new_handles;
198                std::thread::sleep(std::time::Duration::from_millis(10));
199            }
200
201            Ok((
202                Lines::new(output_lines),
203                CommandResult::Exit(runner.join().unwrap()?),
204            ))
205        })
206    }
207}