Skip to main content

hx_core/
command.rs

1//! Command execution utilities.
2
3use 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/// Output from a command execution.
13#[derive(Debug, Clone)]
14pub struct CommandOutput {
15    /// Exit code (0 = success)
16    pub exit_code: i32,
17    /// Standard output
18    pub stdout: String,
19    /// Standard error
20    pub stderr: String,
21    /// How long the command took
22    pub duration: Duration,
23}
24
25impl CommandOutput {
26    /// Check if the command succeeded.
27    pub fn success(&self) -> bool {
28        self.exit_code == 0
29    }
30}
31
32/// A command runner that captures output and provides structured results.
33#[derive(Debug, Clone, Default)]
34pub struct CommandRunner {
35    /// Working directory for commands
36    pub working_dir: Option<std::path::PathBuf>,
37    /// Environment variables to set
38    pub env: Vec<(String, String)>,
39    /// Whether to inherit the parent environment
40    pub inherit_env: bool,
41}
42
43impl CommandRunner {
44    /// Create a new command runner.
45    pub fn new() -> Self {
46        Self {
47            working_dir: None,
48            env: Vec::new(),
49            inherit_env: true,
50        }
51    }
52
53    /// Set the working directory.
54    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    /// Add an environment variable.
60    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    /// Configure PATH to use a specific toolchain bin directory.
66    ///
67    /// Prepends the given bin directory to PATH, ensuring tools from that
68    /// directory are found first. Can be called multiple times to add
69    /// multiple bin directories.
70    pub fn with_ghc_bin(mut self, bin_dir: impl AsRef<Path>) -> Self {
71        // Check if we already have a PATH in our env list
72        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    /// Run a command and capture output.
93    #[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    /// Run a command and return an error if it fails.
172    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}