Skip to main content

edict/
subprocess.rs

1use std::path::Path;
2use std::process::{Command, Output, Stdio};
3use std::time::Duration;
4
5use anyhow::Context;
6
7use crate::error::ExitError;
8
9// On Unix, CommandExt lets us call .process_group(0) to detach the child
10// into its own process group so SIGTERM to the parent's group doesn't kill it.
11#[cfg(unix)]
12use std::os::unix::process::CommandExt as _;
13
14/// Result of running a subprocess.
15#[derive(Debug)]
16pub struct RunOutput {
17    pub stdout: String,
18    pub stderr: String,
19    pub exit_code: i32,
20}
21
22impl RunOutput {
23    /// Returns true if the process exited successfully.
24    pub fn success(&self) -> bool {
25        self.exit_code == 0
26    }
27
28    /// Parse stdout as JSON.
29    pub fn parse_json<T: serde::de::DeserializeOwned>(&self) -> anyhow::Result<T> {
30        serde_json::from_str(&self.stdout)
31            .with_context(|| "parsing JSON output from subprocess".to_string())
32    }
33}
34
35/// Builder for running companion tools.
36pub struct Tool {
37    program: String,
38    args: Vec<String>,
39    timeout: Option<Duration>,
40    maw_workspace: Option<String>,
41    /// When true, spawn the subprocess in a new process group (process_group(0)) so
42    /// it survives a SIGTERM directed at the parent's process group.  Use this for
43    /// cleanup subprocesses that must outlive the signal that triggered them.
44    new_process_group: bool,
45}
46
47impl Tool {
48    /// Create a new tool invocation.
49    pub fn new(program: &str) -> Self {
50        Self {
51            program: program.to_string(),
52            args: Vec::new(),
53            timeout: None,
54            maw_workspace: None,
55            new_process_group: false,
56        }
57    }
58
59    /// Spawn the subprocess in a new process group so it survives a SIGTERM
60    /// sent to the parent's process group.  Use this for cleanup subprocesses
61    /// (e.g. `bus claims release`) that are spawned from a signal handler.
62    ///
63    /// On non-Unix platforms this is a no-op (the flag is ignored).
64    pub fn new_process_group(mut self) -> Self {
65        self.new_process_group = true;
66        self
67    }
68
69    /// Add a single argument.
70    pub fn arg(mut self, arg: &str) -> Self {
71        self.args.push(arg.to_string());
72        self
73    }
74
75    /// Add multiple arguments.
76    pub fn args(mut self, args: &[&str]) -> Self {
77        self.args.extend(args.iter().map(|s| s.to_string()));
78        self
79    }
80
81    /// Set a timeout for the subprocess.
82    #[allow(dead_code)]
83    pub fn timeout(mut self, duration: Duration) -> Self {
84        self.timeout = Some(duration);
85        self
86    }
87
88    /// Wrap this command with `maw exec <workspace> --`.
89    ///
90    /// Validates that the workspace name matches `[a-z0-9][a-z0-9-]*` to prevent
91    /// argument confusion with the maw CLI.
92    pub fn in_workspace(mut self, workspace: &str) -> anyhow::Result<Self> {
93        if workspace.is_empty()
94            || !workspace
95                .bytes()
96                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
97            || workspace.starts_with('-')
98            || workspace.contains("..")
99            || workspace.contains('/')
100            || workspace.len() > 64
101        {
102            anyhow::bail!(
103                "invalid workspace name {workspace:?}: must match [a-z0-9][a-z0-9-]*, max 64 chars, no path components"
104            );
105        }
106        self.maw_workspace = Some(workspace.to_string());
107        Ok(self)
108    }
109
110    /// Run the tool, capturing stdout and stderr.
111    #[tracing::instrument(skip(self), fields(tool = %self.program, workspace = ?self.maw_workspace))]
112    pub fn run(&self) -> anyhow::Result<RunOutput> {
113        let (program, args) = self.build_command();
114
115        let mut cmd = Command::new(&program);
116        cmd.args(&args)
117            .stdout(Stdio::piped())
118            .stderr(Stdio::piped());
119
120        // Detach cleanup subprocesses into their own process group so they
121        // survive a SIGTERM that kills the parent's process group (e.g. from
122        // `botty kill`).  On non-Unix targets the flag is simply ignored.
123        #[cfg(unix)]
124        if self.new_process_group {
125            cmd.process_group(0);
126        }
127
128        let start = crate::telemetry::metrics::time_start();
129
130        let output: Output = if let Some(timeout) = self.timeout {
131            run_with_timeout(&mut cmd, timeout, &self.program)?
132        } else {
133            cmd.output().map_err(|e| self.not_found_or_other(e))?
134        };
135
136        let success = output.status.success();
137        let tool_name = &self.program;
138        let success_str = if success { "true" } else { "false" };
139        crate::telemetry::metrics::time_record(
140            "edict.subprocess.duration_seconds",
141            start,
142            &[("tool", tool_name), ("success", success_str)],
143        );
144        crate::telemetry::metrics::counter(
145            "edict.subprocess.calls_total",
146            1,
147            &[("tool", tool_name), ("success", success_str)],
148        );
149
150        Ok(RunOutput {
151            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
152            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
153            exit_code: output.status.code().unwrap_or(-1),
154        })
155    }
156
157    /// Run the tool and return an error if it fails.
158    pub fn run_ok(&self) -> anyhow::Result<RunOutput> {
159        let output = self.run()?;
160        if output.success() {
161            Ok(output)
162        } else {
163            Err(ExitError::ToolFailed {
164                tool: self.program.clone(),
165                code: output.exit_code,
166                message: output.stderr.trim().to_string(),
167            }
168            .into())
169        }
170    }
171
172    fn build_command(&self) -> (String, Vec<String>) {
173        if let Some(ref ws) = self.maw_workspace {
174            let mut args = vec![
175                "exec".to_string(),
176                ws.clone(),
177                "--".to_string(),
178                self.program.clone(),
179            ];
180            args.extend(self.args.clone());
181            ("maw".to_string(), args)
182        } else {
183            (self.program.clone(), self.args.clone())
184        }
185    }
186
187    fn not_found_or_other(&self, e: std::io::Error) -> anyhow::Error {
188        if e.kind() == std::io::ErrorKind::NotFound {
189            let tool = if self.maw_workspace.is_some() {
190                "maw"
191            } else {
192                &self.program
193            };
194            ExitError::ToolNotFound {
195                tool: tool.to_string(),
196            }
197            .into()
198        } else {
199            anyhow::Error::new(e).context(format!("running {}", self.program))
200        }
201    }
202}
203
204fn run_with_timeout(
205    cmd: &mut Command,
206    timeout: Duration,
207    tool_name: &str,
208) -> anyhow::Result<Output> {
209    let mut child = cmd.spawn().map_err(|e| {
210        if e.kind() == std::io::ErrorKind::NotFound {
211            anyhow::Error::from(ExitError::ToolNotFound {
212                tool: tool_name.to_string(),
213            })
214        } else {
215            anyhow::Error::new(e).context(format!("spawning {tool_name}"))
216        }
217    })?;
218
219    let start = std::time::Instant::now();
220    loop {
221        match child.try_wait() {
222            Ok(Some(status)) => {
223                // Process exited — collect output
224                let stdout = child.stdout.take().map_or_else(Vec::new, |mut r| {
225                    let mut buf = Vec::new();
226                    std::io::Read::read_to_end(&mut r, &mut buf).unwrap_or(0);
227                    buf
228                });
229                let stderr = child.stderr.take().map_or_else(Vec::new, |mut r| {
230                    let mut buf = Vec::new();
231                    std::io::Read::read_to_end(&mut r, &mut buf).unwrap_or(0);
232                    buf
233                });
234                return Ok(Output {
235                    status,
236                    stdout,
237                    stderr,
238                });
239            }
240            Ok(None) => {
241                // Still running
242                if start.elapsed() >= timeout {
243                    let _ = child.kill();
244                    let _ = child.wait();
245                    return Err(ExitError::Timeout {
246                        tool: tool_name.to_string(),
247                        timeout_secs: timeout.as_secs(),
248                    }
249                    .into());
250                }
251                std::thread::sleep(Duration::from_millis(50));
252            }
253            Err(e) => return Err(anyhow::Error::new(e).context(format!("waiting for {tool_name}"))),
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn run_echo() {
264        let output = Tool::new("echo").arg("hello").run().unwrap();
265        assert!(output.success());
266        assert_eq!(output.stdout.trim(), "hello");
267    }
268
269    #[test]
270    fn run_false_fails() {
271        let output = Tool::new("false").run().unwrap();
272        assert!(!output.success());
273    }
274
275    #[test]
276    fn run_ok_returns_error_on_failure() {
277        let result = Tool::new("false").run_ok();
278        assert!(result.is_err());
279        let err = result.unwrap_err();
280        assert!(err.downcast_ref::<ExitError>().is_some());
281    }
282
283    #[test]
284    fn run_not_found() {
285        let result = Tool::new("nonexistent-tool-xyz").run();
286        assert!(result.is_err());
287        let err = result.unwrap_err();
288        let exit_err = err.downcast_ref::<ExitError>().unwrap();
289        assert!(matches!(exit_err, ExitError::ToolNotFound { .. }));
290    }
291
292    #[test]
293    fn run_with_timeout_succeeds() {
294        let output = Tool::new("echo")
295            .arg("fast")
296            .timeout(Duration::from_secs(5))
297            .run()
298            .unwrap();
299        assert!(output.success());
300        assert_eq!(output.stdout.trim(), "fast");
301    }
302
303    #[test]
304    fn maw_exec_wrapper() {
305        // Verify command construction (won't actually run since maw may not be available)
306        let tool = Tool::new("bn").arg("next").in_workspace("default").unwrap();
307        let (program, args) = tool.build_command();
308        assert_eq!(program, "maw");
309        assert_eq!(args, vec!["exec", "default", "--", "bn", "next"]);
310    }
311
312    #[test]
313    fn invalid_workspace_names() {
314        assert!(Tool::new("bn").in_workspace("").is_err());
315        assert!(Tool::new("bn").in_workspace("--flag").is_err());
316        assert!(Tool::new("bn").in_workspace("-starts-dash").is_err());
317        assert!(Tool::new("bn").in_workspace("Has Uppercase").is_err());
318        assert!(Tool::new("bn").in_workspace("has space").is_err());
319        // Valid names
320        assert!(Tool::new("bn").in_workspace("default").is_ok());
321        assert!(Tool::new("bn").in_workspace("northern-cedar").is_ok());
322        assert!(Tool::new("bn").in_workspace("ws123").is_ok());
323    }
324
325    #[test]
326    fn parse_json_output() {
327        let output = RunOutput {
328            stdout: r#"{"key": "value"}"#.to_string(),
329            stderr: String::new(),
330            exit_code: 0,
331        };
332        let parsed: serde_json::Value = output.parse_json().unwrap();
333        assert_eq!(parsed["key"], "value");
334    }
335}
336
337/// Ensure exactly one bus hook exists with the given description.
338///
339/// Performs idempotent upsert: finds any existing hook(s) matching the
340/// description, removes them, then adds a new hook with current parameters.
341/// The `add_args` slice should contain all args for `bus hooks add` *except*
342/// `--description` (which is added automatically).
343///
344/// Returns `Ok(("created"|"updated"|"unchanged", hook_id))`.
345pub fn ensure_bus_hook(description: &str, add_args: &[&str]) -> anyhow::Result<(String, String)> {
346    // List existing hooks
347    let existing = Tool::new("bus")
348        .args(&["hooks", "list", "--format", "json"])
349        .run();
350
351    let mut removed = false;
352    if let Ok(output) = existing {
353        if output.success() {
354            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&output.stdout) {
355                if let Some(hooks) = parsed.get("hooks").and_then(|h| h.as_array()) {
356                    for hook in hooks {
357                        let desc = hook.get("description").and_then(|d| d.as_str());
358                        if desc == Some(description) {
359                            if let Some(id) = hook.get("id").and_then(|i| i.as_str()) {
360                                let _ = Tool::new("bus").args(&["hooks", "remove", id]).run();
361                                removed = true;
362                            }
363                        }
364                    }
365                }
366            }
367        }
368    }
369
370    // Add with --description
371    let mut args = vec!["hooks", "add", "--description", description];
372    args.extend_from_slice(add_args);
373
374    let result = Tool::new("bus").args(&args).run()?;
375
376    if !result.success() {
377        anyhow::bail!("bus hooks add failed: {}", result.stderr.trim());
378    }
379
380    // Extract hook ID from output (format: "Added: Hook hk-xxx created")
381    let hook_id = result
382        .stdout
383        .split_whitespace()
384        .find(|s| s.starts_with("hk-"))
385        .unwrap_or("unknown")
386        .to_string();
387
388    let action = if removed { "updated" } else { "created" };
389    Ok((action.to_string(), hook_id))
390}
391
392/// Simple helper to run a command with args, optionally in a specific directory.
393/// Returns stdout on success, or an error.
394pub fn run_command(program: &str, args: &[&str], cwd: Option<&Path>) -> anyhow::Result<String> {
395    let mut cmd = Command::new(program);
396    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
397
398    if let Some(dir) = cwd {
399        cmd.current_dir(dir);
400    }
401
402    let output = cmd.output().with_context(|| format!("running {program}"))?;
403
404    if output.status.success() {
405        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
406    } else {
407        anyhow::bail!(
408            "{program} failed: {}",
409            String::from_utf8_lossy(&output.stderr).trim()
410        )
411    }
412}