1use std::path::{Path, PathBuf};
10use std::process::Stdio;
11use std::time::Duration;
12
13use tokio::process::Command;
14use tokio::time::timeout;
15
16pub const MAX_BYTES_PER_STREAM: usize = 8 * 1024;
18pub const MAX_LINES_PER_STREAM: usize = 200;
20pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
23
24#[derive(Debug, Clone)]
26pub struct ExecOptions {
27 pub cwd: PathBuf,
28 pub timeout: Duration,
29}
30
31impl ExecOptions {
32 pub fn new(cwd: impl Into<PathBuf>) -> Self {
33 Self {
34 cwd: cwd.into(),
35 timeout: DEFAULT_TIMEOUT,
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct ExecOutcome {
43 pub stdout: String,
44 pub stderr: String,
45 pub exit_code: Option<i32>,
47 pub truncated: bool,
49 pub timed_out: bool,
51}
52
53pub async fn execute(tokens: &[String], opts: &ExecOptions) -> Result<ExecOutcome, ExecError> {
57 let (program, args) = tokens.split_first().ok_or(ExecError::EmptyCommand)?;
58
59 let mut cmd = Command::new(program);
60 cmd.args(args)
61 .current_dir(&opts.cwd)
62 .stdin(Stdio::null())
63 .stdout(Stdio::piped())
64 .stderr(Stdio::piped())
65 .kill_on_drop(true);
66
67 let output_future = cmd.output();
68 match timeout(opts.timeout, output_future).await {
69 Ok(Ok(output)) => {
70 let (stdout, stdout_truncated) = clip(&output.stdout);
71 let (stderr, stderr_truncated) = clip(&output.stderr);
72 Ok(ExecOutcome {
73 stdout,
74 stderr,
75 exit_code: output.status.code(),
76 truncated: stdout_truncated || stderr_truncated,
77 timed_out: false,
78 })
79 }
80 Ok(Err(e)) => Err(ExecError::Spawn {
81 program: program.clone(),
82 source: e,
83 }),
84 Err(_) => Ok(ExecOutcome {
85 stdout: String::new(),
86 stderr: format!(
87 "command timed out after {} seconds and was killed",
88 opts.timeout.as_secs()
89 ),
90 exit_code: None,
91 truncated: true,
92 timed_out: true,
93 }),
94 }
95}
96
97fn clip(raw: &[u8]) -> (String, bool) {
100 let text = String::from_utf8_lossy(raw).replace("\r\n", "\n");
101 let mut byte_truncated = false;
102 let mut line_truncated = false;
103
104 let bounded_bytes = if text.len() > MAX_BYTES_PER_STREAM {
105 byte_truncated = true;
106 let mut cut = MAX_BYTES_PER_STREAM;
109 while cut > 0 && !text.is_char_boundary(cut) {
110 cut -= 1;
111 }
112 &text[..cut]
113 } else {
114 text.as_str()
115 };
116
117 let mut out = String::with_capacity(bounded_bytes.len());
118 for (i, line) in bounded_bytes.split_inclusive('\n').enumerate() {
119 if i >= MAX_LINES_PER_STREAM {
120 line_truncated = true;
121 break;
122 }
123 out.push_str(line);
124 }
125 (out, byte_truncated || line_truncated)
126}
127
128#[derive(Debug, thiserror::Error)]
129pub enum ExecError {
130 #[error("cannot execute empty command")]
131 EmptyCommand,
132
133 #[error("could not spawn `{program}`: {source}")]
134 Spawn {
135 program: String,
136 #[source]
137 source: std::io::Error,
138 },
139}
140
141pub fn cwd_label(cwd: &Path) -> String {
144 cwd.display().to_string()
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn clip_normalises_crlf() {
153 let raw = b"a\r\nb\r\nc\n";
154 let (text, truncated) = clip(raw);
155 assert_eq!(text, "a\nb\nc\n");
156 assert!(!truncated);
157 }
158
159 #[test]
160 fn clip_enforces_line_cap() {
161 let raw: Vec<u8> = (0..300)
162 .map(|i| format!("line-{i}\n"))
163 .collect::<String>()
164 .into_bytes();
165 let (text, truncated) = clip(&raw);
166 assert!(truncated);
167 let line_count = text.lines().count();
168 assert_eq!(line_count, MAX_LINES_PER_STREAM);
169 }
170
171 #[test]
172 fn clip_enforces_byte_cap() {
173 let raw = vec![b'x'; MAX_BYTES_PER_STREAM + 1024];
174 let (text, truncated) = clip(&raw);
175 assert!(truncated);
176 assert!(text.len() <= MAX_BYTES_PER_STREAM);
177 }
178
179 #[tokio::test]
180 async fn echo_runs_and_returns_zero() {
181 let tmp = tempfile::tempdir().unwrap();
182 let opts = ExecOptions::new(tmp.path());
183 let program = if cfg!(windows) { "cmd" } else { "echo" };
184 let tokens: Vec<String> = if cfg!(windows) {
185 ["cmd", "/C", "echo", "hi"]
186 .iter()
187 .map(|s| s.to_string())
188 .collect()
189 } else {
190 ["echo", "hi"].iter().map(|s| s.to_string()).collect()
191 };
192 let _ = program; let out = execute(&tokens, &opts).await.unwrap();
194 assert_eq!(out.exit_code, Some(0));
195 assert!(out.stdout.contains("hi"));
196 assert!(!out.truncated);
197 }
198}