Skip to main content

mars_agents/platform/
process.rs

1//! External process invocation.
2//!
3//! Centralizes git and other external tool execution.
4
5use std::path::Path;
6use std::process::Command;
7
8use crate::error::MarsError;
9
10/// Run a git command and return stdout on success.
11///
12/// Arguments are passed as an explicit argv array, never through a shell.
13/// Errors include context, arguments, exit code, and stderr.
14pub fn run_git(args: &[&str], cwd: &Path, context: &str) -> Result<String, MarsError> {
15    let command_display = display_command(args);
16    let output = Command::new("git")
17        .current_dir(cwd)
18        .args(args)
19        .output()
20        .map_err(|e| MarsError::GitCli {
21            command: command_display.clone(),
22            message: format!(
23                "{context} (cwd: {}): failed to execute git: {e}",
24                cwd.display()
25            ),
26        })?;
27
28    if output.status.success() {
29        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
30    } else {
31        let stderr = String::from_utf8_lossy(&output.stderr);
32        let stdout = String::from_utf8_lossy(&output.stdout);
33        let error_output = if stderr.trim().is_empty() {
34            stdout.trim()
35        } else {
36            stderr.trim()
37        };
38
39        Err(MarsError::GitCli {
40            command: command_display,
41            message: format!(
42                "{context}: exit {}: {}",
43                output.status.code().unwrap_or(-1),
44                error_output
45            ),
46        })
47    }
48}
49
50/// Run a git command with a specific ref argument that may contain special characters.
51///
52/// Wraps `run_git` but takes the ref as a separate argument to ensure it's passed correctly.
53pub fn run_git_with_ref(
54    base_args: &[&str],
55    ref_arg: &str,
56    cwd: &Path,
57    context: &str,
58) -> Result<String, MarsError> {
59    let mut args: Vec<&str> = base_args.to_vec();
60    args.push(ref_arg);
61    run_git(&args, cwd, context)
62}
63
64/// Display a command for error messages (not for execution).
65pub fn display_command(args: &[&str]) -> String {
66    if args.is_empty() {
67        "git".to_string()
68    } else {
69        format!("git {}", args.join(" "))
70    }
71}
72
73/// Run a git command and return the raw `Output`, allowing caller to handle exit codes.
74///
75/// Unlike `run_git`, this does not treat non-zero exit codes as errors.
76/// Use this for commands like `git merge-file` where positive exit codes have meaning.
77pub fn run_git_raw(
78    args: &[&str],
79    cwd: &Path,
80    context: &str,
81) -> Result<std::process::Output, MarsError> {
82    let command_display = display_command(args);
83    Command::new("git")
84        .current_dir(cwd)
85        .args(args)
86        .output()
87        .map_err(|e| MarsError::GitCli {
88            command: command_display,
89            message: format!(
90                "{context} (cwd: {}): failed to execute git: {e}",
91                cwd.display()
92            ),
93        })
94}
95
96#[cfg(test)]
97mod tests {
98    use std::process::Command;
99
100    use tempfile::TempDir;
101
102    use super::*;
103
104    #[test]
105    fn run_git_version_succeeds() {
106        // git --version should work in any environment with git
107        let tmp = TempDir::new().unwrap();
108        let result = run_git(&["--version"], tmp.path(), "test");
109        assert!(result.is_ok(), "git --version should succeed: {:?}", result);
110        assert!(result.unwrap().contains("git version"));
111    }
112
113    #[test]
114    fn run_git_invalid_command_fails() {
115        let tmp = TempDir::new().unwrap();
116        let result = run_git(&["not-a-real-command"], tmp.path(), "test");
117        assert!(result.is_err());
118
119        let err = result.unwrap_err();
120        let err_str = err.to_string();
121        assert!(err_str.contains("test"), "error should include context");
122        assert!(
123            err_str.contains("not-a-real-command"),
124            "error should include command"
125        );
126    }
127
128    #[test]
129    fn run_git_execute_failure_includes_cwd_and_command() {
130        let missing = std::env::temp_dir().join("mars-run-git-missing-cwd");
131        let result = run_git(&["status", "--short"], &missing, "test");
132        let err = result.expect_err("missing cwd should fail before git runs");
133        let message = err.to_string();
134
135        assert!(message.contains("git status --short"));
136        assert!(message.contains("cwd:"));
137        assert!(message.contains(&missing.display().to_string()));
138    }
139
140    #[test]
141    fn display_command_formats_args() {
142        assert_eq!(display_command(&["status", "-s"]), "git status -s");
143        assert_eq!(
144            display_command(&["log", "--oneline", "-5"]),
145            "git log --oneline -5"
146        );
147    }
148
149    #[test]
150    fn run_git_with_ref_passes_ref_without_shell_interpretation() {
151        let tmp = TempDir::new().unwrap();
152        Command::new("git")
153            .current_dir(tmp.path())
154            .args(["init", "."])
155            .output()
156            .expect("git init");
157        Command::new("git")
158            .current_dir(tmp.path())
159            .args(["config", "user.name", "Mars Test"])
160            .output()
161            .expect("git config user.name");
162        Command::new("git")
163            .current_dir(tmp.path())
164            .args(["config", "user.email", "mars@example.com"])
165            .output()
166            .expect("git config user.email");
167        std::fs::write(tmp.path().join("README.md"), "hello").unwrap();
168        Command::new("git")
169            .current_dir(tmp.path())
170            .args(["add", "README.md"])
171            .output()
172            .expect("git add");
173        Command::new("git")
174            .current_dir(tmp.path())
175            .args(["commit", "-m", "init"])
176            .output()
177            .expect("git commit");
178
179        let result = run_git_with_ref(
180            &["rev-parse", "--verify"],
181            "HEAD;echo shell-injected",
182            tmp.path(),
183            "verify ref",
184        );
185
186        let err = result.expect_err("metacharacter ref should be passed as one invalid git ref");
187        let message = err.to_string();
188        assert!(message.contains("HEAD;echo shell-injected"));
189        assert!(!message.contains("shell-injected\n"));
190    }
191
192    #[test]
193    fn run_git_raw_execute_failure_returns_structured_error() {
194        let missing = std::env::temp_dir().join("mars-run-git-raw-missing-cwd");
195        let result = run_git_raw(&["status"], &missing, "raw test");
196
197        let err = result.expect_err("missing cwd should fail before git runs");
198        match err {
199            MarsError::GitCli { command, message } => {
200                assert_eq!(command, "git status");
201                assert!(message.contains("raw test"));
202                assert!(message.contains("cwd:"));
203                assert!(message.contains(&missing.display().to_string()));
204            }
205            other => panic!("expected GitCli error, got {other:?}"),
206        }
207    }
208}