1use std::ffi::OsStr;
4use std::path::Path;
5use std::process::Stdio;
6use std::time::{Duration, Instant};
7use tokio::process::Command;
8use tracing::{debug, instrument};
9
10use crate::error::{Error, Fix};
11
12#[derive(Debug, Clone)]
14pub struct CommandOutput {
15 pub exit_code: i32,
17 pub stdout: String,
19 pub stderr: String,
21 pub duration: Duration,
23}
24
25impl CommandOutput {
26 pub fn success(&self) -> bool {
28 self.exit_code == 0
29 }
30}
31
32#[derive(Debug, Clone, Default)]
34pub struct CommandRunner {
35 pub working_dir: Option<std::path::PathBuf>,
37 pub env: Vec<(String, String)>,
39 pub inherit_env: bool,
41}
42
43impl CommandRunner {
44 pub fn new() -> Self {
46 Self {
47 working_dir: None,
48 env: Vec::new(),
49 inherit_env: true,
50 }
51 }
52
53 pub fn with_working_dir(mut self, dir: impl AsRef<Path>) -> Self {
55 self.working_dir = Some(dir.as_ref().to_path_buf());
56 self
57 }
58
59 pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
61 self.env.push((key.into(), value.into()));
62 self
63 }
64
65 pub fn with_ghc_bin(mut self, bin_dir: impl AsRef<Path>) -> Self {
71 let current_path = self
73 .env
74 .iter()
75 .rev()
76 .find(|(k, _)| k == "PATH")
77 .map(|(_, v)| v.clone())
78 .unwrap_or_else(|| std::env::var("PATH").unwrap_or_default());
79
80 let bin_str = bin_dir.as_ref().to_string_lossy();
81
82 #[cfg(windows)]
83 let separator = ";";
84 #[cfg(not(windows))]
85 let separator = ":";
86
87 let new_path = format!("{}{}{}", bin_str, separator, current_path);
88 self.env.push(("PATH".into(), new_path));
89 self
90 }
91
92 #[instrument(skip(self, args), fields(program = %program.as_ref().to_string_lossy()))]
94 pub async fn run<S, I>(&self, program: S, args: I) -> Result<CommandOutput, Error>
95 where
96 S: AsRef<OsStr>,
97 I: IntoIterator<Item = S>,
98 {
99 let program_ref = program.as_ref();
100 let args_vec: Vec<_> = args
101 .into_iter()
102 .map(|a| a.as_ref().to_os_string())
103 .collect();
104
105 debug!(
106 "Running command: {} {:?}",
107 program_ref.to_string_lossy(),
108 args_vec
109 );
110
111 let mut cmd = Command::new(program_ref);
112 cmd.args(&args_vec)
113 .stdin(Stdio::null())
114 .stdout(Stdio::piped())
115 .stderr(Stdio::piped());
116
117 if let Some(ref dir) = self.working_dir {
118 cmd.current_dir(dir);
119 }
120
121 if !self.inherit_env {
122 cmd.env_clear();
123 }
124
125 for (key, value) in &self.env {
126 cmd.env(key, value);
127 }
128
129 let start = Instant::now();
130
131 let output = cmd.output().await.map_err(|e| {
132 let program_str = program_ref.to_string_lossy().to_string();
133 if e.kind() == std::io::ErrorKind::NotFound {
134 Error::ToolchainMissing {
135 tool: program_str.clone(),
136 source: Some(Box::new(e)),
137 fixes: vec![Fix::with_command(
138 format!("Install {}", program_str),
139 "hx toolchain install".to_string(),
140 )],
141 }
142 } else {
143 Error::Io {
144 message: format!("failed to execute {}", program_str),
145 path: None,
146 source: e,
147 }
148 }
149 })?;
150
151 let duration = start.elapsed();
152
153 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
154 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
155 let exit_code = output.status.code().unwrap_or(-1);
156
157 debug!(
158 exit_code = exit_code,
159 duration_ms = duration.as_millis(),
160 "Command completed"
161 );
162
163 Ok(CommandOutput {
164 exit_code,
165 stdout,
166 stderr,
167 duration,
168 })
169 }
170
171 pub async fn run_checked<S, I>(&self, program: S, args: I) -> Result<CommandOutput, Error>
173 where
174 S: AsRef<OsStr>,
175 I: IntoIterator<Item = S>,
176 {
177 let program_str = program.as_ref().to_string_lossy().to_string();
178 let output = self.run(program, args).await?;
179
180 if !output.success() {
181 return Err(Error::CommandFailed {
182 command: program_str,
183 exit_code: Some(output.exit_code),
184 stdout: output.stdout,
185 stderr: output.stderr,
186 fixes: vec![],
187 });
188 }
189
190 Ok(output)
191 }
192}