Skip to main content

otto_cli/
runner.rs

1use crate::model::RunStatus;
2use std::collections::HashMap;
3use std::io::{Read, Write};
4use std::process::{Command, ExitStatus, Stdio};
5use std::thread;
6use std::time::{Duration, Instant};
7use time::OffsetDateTime;
8use wait_timeout::ChildExt;
9
10#[derive(Debug, Clone)]
11pub struct Request {
12    pub name: String,
13    pub command_preview: String,
14    pub use_shell: bool,
15    pub exec: Vec<String>,
16    pub shell: String,
17    pub dir: String,
18    pub env: HashMap<String, String>,
19    pub timeout: Duration,
20    pub retries: i32,
21    pub retry_backoff: Duration,
22    pub stream_output: bool,
23}
24
25#[derive(Debug, Clone)]
26pub struct RunResult {
27    pub started_at: OffsetDateTime,
28    pub duration: Duration,
29    pub exit_code: i32,
30    pub status: RunStatus,
31    pub stderr_tail: Option<String>,
32}
33
34#[derive(Debug, Clone)]
35pub struct RunFailure {
36    pub result: RunResult,
37    pub message: String,
38}
39
40pub fn execute(req: &Request) -> Result<RunResult, RunFailure> {
41    if req.retries < 0 {
42        return Err(RunFailure {
43            result: failed_result(127, Duration::ZERO, None),
44            message: "retries must be >= 0".to_string(),
45        });
46    }
47
48    if req.use_shell && req.shell.trim().is_empty() {
49        return Err(RunFailure {
50            result: failed_result(127, Duration::ZERO, None),
51            message: "shell command is required".to_string(),
52        });
53    }
54
55    if !req.use_shell && req.exec.is_empty() {
56        return Err(RunFailure {
57            result: failed_result(127, Duration::ZERO, None),
58            message: "exec command is required".to_string(),
59        });
60    }
61
62    let retry_backoff = if req.retry_backoff.is_zero() {
63        Duration::from_secs(1)
64    } else {
65        req.retry_backoff
66    };
67
68    let start = OffsetDateTime::now_utc();
69    let wall = Instant::now();
70    let attempts = req.retries + 1;
71
72    let mut last_exit = 0;
73    let mut last_stderr = None;
74    let mut last_error = String::new();
75
76    for attempt in 0..attempts {
77        match run_once(req) {
78            Ok((code, stderr_tail, None)) => {
79                return Ok(RunResult {
80                    started_at: start,
81                    duration: wall.elapsed(),
82                    exit_code: code,
83                    status: RunStatus::Success,
84                    stderr_tail,
85                });
86            }
87            Ok((code, stderr_tail, Some(err))) => {
88                last_exit = code;
89                last_stderr = stderr_tail;
90                last_error = err;
91            }
92            Err(err) => {
93                last_exit = 127;
94                last_stderr = None;
95                last_error = err;
96            }
97        }
98
99        if attempt < attempts - 1 {
100            let wait = retry_backoff
101                .checked_mul(1_u32 << attempt)
102                .unwrap_or(Duration::from_secs(60));
103            thread::sleep(wait);
104        }
105    }
106
107    Err(RunFailure {
108        result: RunResult {
109            started_at: start,
110            duration: wall.elapsed(),
111            exit_code: last_exit,
112            status: RunStatus::Failed,
113            stderr_tail: last_stderr,
114        },
115        message: last_error,
116    })
117}
118
119fn run_once(req: &Request) -> Result<(i32, Option<String>, Option<String>), String> {
120    let mut command = build_command(req)?;
121    if !req.dir.is_empty() {
122        command.current_dir(&req.dir);
123    }
124    if !req.env.is_empty() {
125        command.envs(&req.env);
126    }
127
128    if req.stream_output {
129        command.stdout(Stdio::inherit());
130    } else {
131        command.stdout(Stdio::null());
132    }
133    command.stderr(Stdio::piped());
134
135    let mut child = command.spawn().map_err(|e| format!("run command: {e}"))?;
136
137    let stderr = child
138        .stderr
139        .take()
140        .ok_or_else(|| "failed to capture stderr".to_string())?;
141
142    let stream_output = req.stream_output;
143    let stderr_handle = thread::spawn(move || {
144        let mut reader = std::io::BufReader::new(stderr);
145        let mut buf = [0_u8; 4096];
146        let mut all = Vec::new();
147        let mut sink = std::io::stderr().lock();
148
149        loop {
150            let read = match reader.read(&mut buf) {
151                Ok(0) => break,
152                Ok(n) => n,
153                Err(_) => break,
154            };
155
156            let chunk = &buf[..read];
157            if stream_output {
158                let _ = sink.write_all(chunk);
159                let _ = sink.flush();
160            }
161            all.extend_from_slice(chunk);
162        }
163
164        all
165    });
166
167    let (status, timeout_hit) = wait_child(&mut child, req.timeout)?;
168    let stderr_bytes = stderr_handle
169        .join()
170        .map_err(|_| "stderr reader thread panicked".to_string())?;
171
172    let stderr_text = String::from_utf8_lossy(&stderr_bytes).to_string();
173    let stderr_tail = tail(&stderr_text, 10, 1400);
174
175    if timeout_hit {
176        return Ok((
177            124,
178            stderr_tail,
179            Some(format!(
180                "command timed out after {}",
181                format_duration(req.timeout)
182            )),
183        ));
184    }
185
186    if status.success() {
187        return Ok((0, stderr_tail, None));
188    }
189
190    let code = status.code().unwrap_or(1);
191    Ok((
192        code,
193        stderr_tail,
194        Some(format!("command failed with exit code {code}")),
195    ))
196}
197
198fn wait_child(
199    child: &mut std::process::Child,
200    timeout: Duration,
201) -> Result<(ExitStatus, bool), String> {
202    if timeout.is_zero() {
203        let status = child.wait().map_err(|e| format!("wait command: {e}"))?;
204        return Ok((status, false));
205    }
206
207    match child
208        .wait_timeout(timeout)
209        .map_err(|e| format!("wait command: {e}"))?
210    {
211        Some(status) => Ok((status, false)),
212        None => {
213            let _ = child.kill();
214            let status = child.wait().map_err(|e| format!("wait command: {e}"))?;
215            Ok((status, true))
216        }
217    }
218}
219
220fn build_command(req: &Request) -> Result<Command, String> {
221    if req.use_shell {
222        if cfg!(target_os = "windows") {
223            let mut cmd = Command::new("cmd");
224            cmd.arg("/C").arg(&req.shell);
225            return Ok(cmd);
226        }
227
228        let mut cmd = Command::new("/bin/sh");
229        cmd.arg("-c").arg(&req.shell);
230        return Ok(cmd);
231    }
232
233    let Some(program) = req.exec.first() else {
234        return Err("exec command is required".to_string());
235    };
236
237    let mut cmd = Command::new(program);
238    if req.exec.len() > 1 {
239        cmd.args(&req.exec[1..]);
240    }
241    Ok(cmd)
242}
243
244fn failed_result(exit_code: i32, duration: Duration, stderr_tail: Option<String>) -> RunResult {
245    RunResult {
246        started_at: OffsetDateTime::now_utc(),
247        duration,
248        exit_code,
249        status: RunStatus::Failed,
250        stderr_tail,
251    }
252}
253
254pub fn tail(input: &str, line_limit: usize, char_limit: usize) -> Option<String> {
255    if input.is_empty() {
256        return None;
257    }
258
259    let trimmed = input.trim_end_matches('\n');
260    if trimmed.is_empty() {
261        return None;
262    }
263
264    let mut lines: Vec<&str> = trimmed.lines().collect();
265    if lines.len() > line_limit {
266        lines = lines.split_off(lines.len() - line_limit);
267    }
268
269    let mut out = lines.join("\n");
270
271    if out.chars().count() > char_limit {
272        let start = out.chars().count().saturating_sub(char_limit);
273        out = out.chars().skip(start).collect();
274    }
275
276    Some(out)
277}
278
279fn format_duration(duration: Duration) -> String {
280    let ms = duration.as_millis();
281
282    if ms < 1_000 {
283        return format!("{ms}ms");
284    }
285
286    if ms.is_multiple_of(1_000) {
287        return format!("{}s", ms / 1_000);
288    }
289
290    format!("{:.3}s", duration.as_secs_f64())
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::model::RunStatus;
297    use tempfile::tempdir;
298
299    fn base_request() -> Request {
300        Request {
301            name: "inline".to_string(),
302            command_preview: "echo ok".to_string(),
303            use_shell: false,
304            exec: vec![
305                "/bin/sh".to_string(),
306                "-c".to_string(),
307                "echo ok".to_string(),
308            ],
309            shell: String::new(),
310            dir: String::new(),
311            env: HashMap::new(),
312            timeout: Duration::ZERO,
313            retries: 0,
314            retry_backoff: Duration::from_millis(10),
315            stream_output: true,
316        }
317    }
318
319    #[test]
320    fn execute_success() {
321        let result = execute(&base_request()).expect("success");
322        assert_eq!(result.exit_code, 0);
323        assert_eq!(result.status, RunStatus::Success);
324    }
325
326    #[test]
327    fn execute_failure() {
328        let mut req = base_request();
329        req.exec = vec![
330            "/bin/sh".to_string(),
331            "-c".to_string(),
332            "echo err >&2; exit 7".to_string(),
333        ];
334
335        let err = execute(&req).expect_err("expected failure");
336        assert_eq!(err.result.exit_code, 7);
337        assert_eq!(err.result.status, RunStatus::Failed);
338    }
339
340    #[test]
341    fn execute_timeout() {
342        let mut req = base_request();
343        req.exec = vec![
344            "/bin/sh".to_string(),
345            "-c".to_string(),
346            "sleep 1".to_string(),
347        ];
348        req.timeout = Duration::from_millis(50);
349
350        let err = execute(&req).expect_err("expected timeout");
351        assert_eq!(err.result.exit_code, 124);
352    }
353
354    #[test]
355    fn execute_retry_then_success() {
356        let dir = tempdir().expect("tempdir");
357        let flag = dir.path().join("flag");
358        let script = format!(
359            r#"[ -f "{}" ] || {{ touch "{}"; exit 9; }}; exit 0"#,
360            flag.display(),
361            flag.display()
362        );
363
364        let mut req = base_request();
365        req.use_shell = true;
366        req.exec.clear();
367        req.shell = script;
368        req.retries = 1;
369
370        let result = execute(&req).expect("retry succeeds");
371        assert_eq!(result.exit_code, 0);
372    }
373
374    #[test]
375    fn validate_request_retries() {
376        let mut req = base_request();
377        req.retries = -1;
378        assert!(execute(&req).is_err());
379    }
380
381    #[test]
382    fn tail_limits_output() {
383        let input = "a\nb\nc\nd\ne\nf";
384        let out = tail(input, 3, 10).expect("tail");
385        assert_eq!(out, "d\ne\nf");
386    }
387}