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#[cfg(unix)]
12use std::os::unix::process::CommandExt as _;
13
14#[derive(Debug)]
16pub struct RunOutput {
17 pub stdout: String,
18 pub stderr: String,
19 pub exit_code: i32,
20}
21
22impl RunOutput {
23 pub fn success(&self) -> bool {
25 self.exit_code == 0
26 }
27
28 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
35pub struct Tool {
37 program: String,
38 args: Vec<String>,
39 timeout: Option<Duration>,
40 maw_workspace: Option<String>,
41 new_process_group: bool,
45}
46
47impl Tool {
48 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 pub fn new_process_group(mut self) -> Self {
65 self.new_process_group = true;
66 self
67 }
68
69 pub fn arg(mut self, arg: &str) -> Self {
71 self.args.push(arg.to_string());
72 self
73 }
74
75 pub fn args(mut self, args: &[&str]) -> Self {
77 self.args.extend(args.iter().map(|s| s.to_string()));
78 self
79 }
80
81 #[allow(dead_code)]
83 pub fn timeout(mut self, duration: Duration) -> Self {
84 self.timeout = Some(duration);
85 self
86 }
87
88 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 #[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 #[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 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 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 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 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 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
337pub fn ensure_bus_hook(description: &str, add_args: &[&str]) -> anyhow::Result<(String, String)> {
346 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 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 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
392pub 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}