Skip to main content

rec/replay/
executor.rs

1//! Command execution for replay.
2//!
3//! Executes recorded commands through the shell via `sh -c` with
4//! inherited stdio for real-time output.
5
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8
9/// Result of command execution, including whether it was a cd command.
10#[derive(Debug)]
11pub struct ExecutionResult {
12    /// Exit status of the command
13    pub status: std::process::ExitStatus,
14    /// If this was a cd command, the new directory to change to
15    pub new_cwd: Option<PathBuf>,
16}
17
18/// Check if a command is a `cd` command and extract the target directory.
19///
20/// Returns `Some(path)` if the command is a simple `cd <path>` command,
21/// `None` otherwise (e.g., `cd && ls`, `cd; echo`, complex commands).
22fn parse_cd_command(command_text: &str) -> Option<&str> {
23    let trimmed = command_text.trim();
24
25    // Must start with "cd "
26    if !trimmed.starts_with("cd ") {
27        return None;
28    }
29
30    // Check for command chaining that would make this not a simple cd
31    // e.g., "cd foo && ls", "cd foo; echo", "cd foo | cat"
32    if trimmed.contains("&&")
33        || trimmed.contains("||")
34        || trimmed.contains(';')
35        || trimmed.contains('|')
36    {
37        return None;
38    }
39
40    // Extract the path after "cd "
41    let path = trimmed.strip_prefix("cd ")?.trim();
42
43    // Handle quoted paths
44    let path = path
45        .strip_prefix('"')
46        .and_then(|p| p.strip_suffix('"'))
47        .or_else(|| path.strip_prefix('\'').and_then(|p| p.strip_suffix('\'')))
48        .unwrap_or(path);
49
50    if path.is_empty() {
51        return None;
52    }
53
54    Some(path)
55}
56
57/// Resolve a cd target path to an absolute path.
58///
59/// Handles:
60/// - `~` → home directory
61/// - `-` → previous directory (not supported, returns None)
62/// - Relative paths → resolved against current directory
63/// - Absolute paths → used as-is
64fn resolve_cd_path(target: &str, current_dir: &Path) -> Option<PathBuf> {
65    if target == "-" {
66        // OLDPWD not tracked, let shell handle it
67        return None;
68    }
69
70    let expanded = if target.starts_with('~') {
71        if let Some(home) = std::env::var_os("HOME") {
72            let home_path = PathBuf::from(home);
73            if target == "~" {
74                home_path
75            } else if let Some(rest) = target.strip_prefix("~/") {
76                home_path.join(rest)
77            } else {
78                // ~user syntax - let shell handle it
79                return None;
80            }
81        } else {
82            return None;
83        }
84    } else if Path::new(target).is_absolute() {
85        PathBuf::from(target)
86    } else {
87        current_dir.join(target)
88    };
89
90    // Canonicalize to resolve .. and symlinks
91    expanded.canonicalize().ok()
92}
93
94/// Execute a command string through the shell.
95///
96/// Uses the `SHELL` environment variable (fallback `/bin/sh`) to run
97/// the command via `sh -c`. Stdin, stdout, and stderr are inherited so
98/// the user sees output in real-time and can interact with prompts.
99///
100/// If `cwd` is provided and the directory exists, the command runs in
101/// that directory. If the directory doesn't exist, a warning is printed
102/// to stderr and the current directory is used instead.
103///
104/// For simple `cd <path>` commands, returns the resolved new directory
105/// in `ExecutionResult::new_cwd` so the caller can update its working
106/// directory for subsequent commands.
107///
108/// # Errors
109///
110/// Returns an error if the shell process cannot be spawned or waited on.
111pub fn execute_command(command_text: &str, cwd: Option<&Path>) -> std::io::Result<ExecutionResult> {
112    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
113
114    let mut cmd = Command::new(&shell);
115    cmd.arg("-c").arg(command_text);
116
117    let effective_cwd = if let Some(dir) = cwd {
118        if dir.exists() {
119            cmd.current_dir(dir);
120            dir.to_path_buf()
121        } else {
122            eprintln!(
123                "\x1b[33m  Warning: directory '{}' does not exist, using current directory\x1b[0m",
124                dir.display()
125            );
126            std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
127        }
128    } else {
129        std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
130    };
131
132    cmd.stdin(Stdio::inherit())
133        .stdout(Stdio::inherit())
134        .stderr(Stdio::inherit());
135
136    let status = cmd.status()?;
137
138    // If command succeeded and it's a simple cd, resolve the new directory
139    let new_cwd = if status.success() {
140        if let Some(target) = parse_cd_command(command_text) {
141            resolve_cd_path(target, &effective_cwd)
142        } else {
143            None
144        }
145    } else {
146        None
147    };
148
149    Ok(ExecutionResult { status, new_cwd })
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_parse_cd_simple() {
158        assert_eq!(parse_cd_command("cd foo"), Some("foo"));
159        assert_eq!(parse_cd_command("cd /tmp"), Some("/tmp"));
160        assert_eq!(parse_cd_command("cd ~/Code"), Some("~/Code"));
161        assert_eq!(parse_cd_command("cd .."), Some(".."));
162    }
163
164    #[test]
165    fn test_parse_cd_quoted() {
166        assert_eq!(parse_cd_command("cd \"my folder\""), Some("my folder"));
167        assert_eq!(parse_cd_command("cd 'my folder'"), Some("my folder"));
168    }
169
170    #[test]
171    fn test_parse_cd_chained_returns_none() {
172        assert_eq!(parse_cd_command("cd foo && ls"), None);
173        assert_eq!(parse_cd_command("cd foo; echo"), None);
174        assert_eq!(parse_cd_command("cd foo || true"), None);
175        assert_eq!(parse_cd_command("cd foo | cat"), None);
176    }
177
178    #[test]
179    fn test_parse_cd_not_cd() {
180        assert_eq!(parse_cd_command("echo cd foo"), None);
181        assert_eq!(parse_cd_command("ls"), None);
182        assert_eq!(parse_cd_command("cdfoo"), None);
183    }
184
185    #[test]
186    fn test_resolve_cd_path_absolute() {
187        let current = PathBuf::from("/home/user");
188        // /tmp should exist on most systems
189        let resolved = resolve_cd_path("/tmp", &current);
190        assert!(resolved.is_some());
191        assert!(resolved.unwrap().is_absolute());
192    }
193
194    #[test]
195    fn test_resolve_cd_path_home() {
196        let current = PathBuf::from("/tmp");
197        if std::env::var("HOME").is_ok() {
198            let resolved = resolve_cd_path("~", &current);
199            assert!(resolved.is_some());
200        }
201    }
202
203    #[test]
204    fn test_resolve_cd_path_dash_returns_none() {
205        let current = PathBuf::from("/tmp");
206        assert_eq!(resolve_cd_path("-", &current), None);
207    }
208
209    #[test]
210    fn test_execute_echo_command() {
211        let result = execute_command("echo hello", None).unwrap();
212        assert!(result.status.success());
213        assert!(result.new_cwd.is_none());
214    }
215
216    #[test]
217    fn test_execute_cd_returns_new_cwd() {
218        let result = execute_command("cd /tmp", None).unwrap();
219        assert!(result.status.success());
220        assert!(result.new_cwd.is_some());
221        assert_eq!(result.new_cwd.unwrap(), PathBuf::from("/tmp"));
222    }
223
224    #[test]
225    fn test_execute_cd_nonexistent_no_new_cwd() {
226        let result = execute_command("cd /nonexistent_dir_12345", None).unwrap();
227        assert!(!result.status.success());
228        assert!(result.new_cwd.is_none());
229    }
230}