Skip to main content

nd_300/actions/fix/
cmd.rs

1use std::process::Output;
2use std::time::{Duration, Instant};
3use tokio::process::Command;
4
5pub const TIMEOUT_QUICK: Duration = Duration::from_secs(15);
6pub const TIMEOUT_MEDIUM: Duration = Duration::from_secs(30);
7pub const TIMEOUT_SLOW: Duration = Duration::from_secs(60);
8
9pub async fn run_cmd(mut cmd: Command, timeout: Duration) -> Result<Output, String> {
10    let label = format!("{:?}", cmd.as_std().get_program());
11    match tokio::time::timeout(timeout, cmd.output()).await {
12        Ok(Ok(output)) => Ok(output),
13        Ok(Err(e)) => Err(format!("{} failed: {}", label, e)),
14        Err(_) => Err(format!("{} timed out after {}s", label, timeout.as_secs())),
15    }
16}
17
18/// Structured record of a subprocess invocation. Carries enough information for
19/// the fix-flow report to render full forensic detail (command, args, exit
20/// code, captured streams, wall-time, and the failure mode if any).
21#[derive(Debug, Clone)]
22pub struct CmdOutcome {
23    /// Program name (e.g. `"netsh"`, `"ipconfig"`).
24    pub command: String,
25    /// Arguments passed to the program, in order.
26    pub args: Vec<String>,
27    /// Process exit code if the program ran to completion. `None` indicates a
28    /// spawn error or a timeout — distinguished by `error`.
29    pub exit_code: Option<i32>,
30    /// Captured stdout (lossy UTF-8 — best-effort, suitable for human display
31    /// and Markdown reports). Trailing whitespace is preserved as-is.
32    pub stdout: String,
33    /// Captured stderr (same encoding caveat as `stdout`).
34    pub stderr: String,
35    /// Wall-clock duration from spawn through completion / timeout / spawn
36    /// failure.
37    pub duration: Duration,
38    /// `true` when the program ran to completion AND returned exit code 0.
39    /// `false` for any non-zero exit, spawn failure, or timeout.
40    pub ok: bool,
41    /// Populated when the spawn or run failed for a reason other than a
42    /// non-zero exit code (e.g. binary not found, OS-level failure, timeout).
43    pub error: Option<String>,
44}
45
46impl CmdOutcome {
47    /// Returns a single human-readable line summarizing the outcome.
48    /// Suitable for terminal status lines.
49    pub fn summary(&self) -> String {
50        if self.ok {
51            format!("ok ({:.1}s)", self.duration.as_secs_f64())
52        } else if let Some(err) = &self.error {
53            format!("failed: {}", err)
54        } else if let Some(code) = self.exit_code {
55            format!("exit {} ({:.1}s)", code, self.duration.as_secs_f64())
56        } else {
57            "failed".to_string()
58        }
59    }
60
61    /// Reconstruct an approximate command line for display in reports.
62    pub fn cmdline(&self) -> String {
63        let mut s = self.command.clone();
64        for a in &self.args {
65            s.push(' ');
66            if a.contains(' ') {
67                s.push('"');
68                s.push_str(a);
69                s.push('"');
70            } else {
71                s.push_str(a);
72            }
73        }
74        s
75    }
76}
77
78/// Capturing variant of [`run_cmd`]. Consumes the supplied [`Command`],
79/// recording the program path and argv first so the resulting [`CmdOutcome`]
80/// can be rendered in fix-flow reports without losing the invocation context.
81///
82/// Returns a [`CmdOutcome`] for every code path (success, non-zero exit, spawn
83/// error, timeout) — callers don't need to handle a `Result`. Inspect
84/// [`CmdOutcome::ok`] / [`CmdOutcome::error`] / [`CmdOutcome::exit_code`] to
85/// branch on what happened.
86pub async fn run_cmd_capture(mut cmd: Command, timeout: Duration) -> CmdOutcome {
87    let std_cmd = cmd.as_std();
88    let command = std_cmd.get_program().to_string_lossy().into_owned();
89    let args: Vec<String> = std_cmd
90        .get_args()
91        .map(|s| s.to_string_lossy().into_owned())
92        .collect();
93
94    let started = Instant::now();
95    let raced = tokio::time::timeout(timeout, cmd.output()).await;
96    let duration = started.elapsed();
97
98    match raced {
99        Ok(Ok(output)) => {
100            let exit_code = output.status.code();
101            let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
102            let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
103            let ok = output.status.success();
104            CmdOutcome {
105                command,
106                args,
107                exit_code,
108                stdout,
109                stderr,
110                duration,
111                ok,
112                error: None,
113            }
114        }
115        Ok(Err(e)) => CmdOutcome {
116            command,
117            args,
118            exit_code: None,
119            stdout: String::new(),
120            stderr: String::new(),
121            duration,
122            ok: false,
123            error: Some(format!("spawn failed: {}", e)),
124        },
125        Err(_) => CmdOutcome {
126            command,
127            args,
128            exit_code: None,
129            stdout: String::new(),
130            stderr: String::new(),
131            duration,
132            ok: false,
133            error: Some(format!("timed out after {}s", timeout.as_secs())),
134        },
135    }
136}