Skip to main content

command_stream/commands/
cd.rs

1//! Virtual `cd` command implementation
2
3use crate::commands::CommandContext;
4use crate::utils::{trace, CommandResult};
5use std::env;
6use std::path::PathBuf;
7
8/// Execute the cd command
9///
10/// Mirrors POSIX `sh`/bash semantics so that shell scripts translate directly:
11///   - `cd`            -> change to $HOME (or $USERPROFILE on Windows)
12///   - `cd ~`/`cd ~/x` -> tilde expands to $HOME
13///   - `cd -`          -> change to $OLDPWD and print the new directory (like sh)
14///   - `cd <dir>`      -> change to <dir> (relative paths resolve against the
15///     current working directory, or the `cwd` option)
16///
17/// Like a real shell, a successful `cd` updates the `PWD` and `OLDPWD`
18/// environment variables and changes the process directory so that subsequent
19/// commands (virtual or real) observe the new location.
20pub async fn cd(ctx: CommandContext) -> CommandResult {
21    let home = env::var("HOME")
22        .or_else(|_| env::var("USERPROFILE"))
23        .unwrap_or_else(|_| "/".to_string());
24
25    let previous_dir = env::current_dir().ok();
26    let base = ctx.get_cwd();
27
28    let mut print_dir = false;
29    let target: String = match ctx.args.first().map(|s| s.as_str()) {
30        // `cd` with no argument goes to $HOME, just like sh.
31        None | Some("") => home.clone(),
32        // `cd -` switches to the previous directory and prints it (sh behavior).
33        Some("-") => match env::var("OLDPWD") {
34            Ok(oldpwd) if !oldpwd.is_empty() => {
35                print_dir = true;
36                oldpwd
37            }
38            _ => {
39                trace("VirtualCommand", "cd: OLDPWD not set");
40                return CommandResult::error("cd: OLDPWD not set\n");
41            }
42        },
43        Some("~") => home.clone(),
44        Some(t) if t.starts_with("~/") => PathBuf::from(&home).join(&t[2..]).display().to_string(),
45        Some(t) => t.to_string(),
46    };
47
48    // Resolve relative targets against the effective base directory so that the
49    // `cwd` option and chained `cd` commands behave consistently.
50    let target_path = PathBuf::from(&target);
51    let resolved = if target_path.is_absolute() {
52        target_path
53    } else {
54        base.join(&target_path)
55    };
56
57    trace(
58        "VirtualCommand",
59        &format!("cd: changing directory to {:?}", resolved),
60    );
61
62    match env::set_current_dir(&resolved) {
63        Ok(()) => {
64            let new_dir = env::current_dir()
65                .map(|p| p.display().to_string())
66                .unwrap_or_default();
67            // Keep PWD/OLDPWD in sync with the real shell so `$PWD`-style lookups
68            // and child processes observe the change.
69            if let Some(prev) = previous_dir {
70                env::set_var("OLDPWD", prev);
71            }
72            env::set_var("PWD", &new_dir);
73            trace(
74                "VirtualCommand",
75                &format!("cd: success, new dir: {}", new_dir),
76            );
77            // A successful `cd` is silent, except for `cd -` which echoes the dir.
78            if print_dir {
79                CommandResult::success(format!("{}\n", new_dir))
80            } else {
81                CommandResult::success_empty()
82            }
83        }
84        Err(e) => {
85            trace("VirtualCommand", &format!("cd: failed: {}", e));
86            CommandResult::error(format!("cd: {}\n", e))
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use std::path::Path;
95    use tempfile::tempdir;
96    use tokio::sync::Mutex;
97
98    // `cd` mutates process-global state (current dir + PWD/OLDPWD env vars).
99    // Rust runs tests in parallel by default, so serialize the cd tests against
100    // each other to avoid races on that shared state. An async-aware mutex lets
101    // the guard be held across the `cd(...).await` calls without tripping
102    // clippy's `await_holding_lock` lint.
103    static CD_TEST_LOCK: Mutex<()> = Mutex::const_new(());
104
105    // Normalize paths so comparisons survive symlinked temp dirs
106    // (e.g. macOS `/var` -> `/private/var`).
107    fn normalize(p: &Path) -> PathBuf {
108        std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
109    }
110
111    #[tokio::test]
112    async fn test_cd_to_temp() {
113        let _guard = CD_TEST_LOCK.lock().await;
114        let temp = tempdir().unwrap();
115        let temp_path = temp.path().to_string_lossy().to_string();
116        let original_dir = env::current_dir().unwrap();
117
118        let ctx = CommandContext::new(vec![temp_path.clone()]);
119        let result = cd(ctx).await;
120        assert!(result.is_success());
121        assert_eq!(result.stdout, "");
122        assert_eq!(
123            normalize(&env::current_dir().unwrap()),
124            normalize(temp.path())
125        );
126
127        // Restore original directory
128        env::set_current_dir(original_dir).unwrap();
129    }
130
131    #[tokio::test]
132    async fn test_cd_to_nonexistent() {
133        let _guard = CD_TEST_LOCK.lock().await;
134        let original_dir = env::current_dir().unwrap();
135        let ctx = CommandContext::new(vec!["/nonexistent/path/12345".to_string()]);
136        let result = cd(ctx).await;
137        assert!(!result.is_success());
138        assert_eq!(result.code, 1);
139        assert!(result.stderr.contains("cd:"));
140        // A failed cd must not move the process out of its directory.
141        assert_eq!(env::current_dir().unwrap(), original_dir);
142    }
143
144    #[tokio::test]
145    async fn test_cd_no_arg_goes_home() {
146        let _guard = CD_TEST_LOCK.lock().await;
147        let temp = tempdir().unwrap();
148        let original_dir = env::current_dir().unwrap();
149        env::set_var("HOME", temp.path());
150
151        let ctx = CommandContext::new(vec![]);
152        let result = cd(ctx).await;
153        assert!(result.is_success());
154        assert_eq!(
155            normalize(&env::current_dir().unwrap()),
156            normalize(temp.path())
157        );
158
159        env::set_current_dir(original_dir).unwrap();
160    }
161
162    #[tokio::test]
163    async fn test_cd_tilde_expands_home() {
164        let _guard = CD_TEST_LOCK.lock().await;
165        let temp = tempdir().unwrap();
166        let original_dir = env::current_dir().unwrap();
167        env::set_var("HOME", temp.path());
168
169        let ctx = CommandContext::new(vec!["~".to_string()]);
170        let result = cd(ctx).await;
171        assert!(result.is_success());
172        assert_eq!(
173            normalize(&env::current_dir().unwrap()),
174            normalize(temp.path())
175        );
176
177        env::set_current_dir(original_dir).unwrap();
178    }
179
180    #[tokio::test]
181    async fn test_cd_tilde_subpath_expands() {
182        let _guard = CD_TEST_LOCK.lock().await;
183        let temp = tempdir().unwrap();
184        std::fs::create_dir(temp.path().join("sub")).unwrap();
185        let original_dir = env::current_dir().unwrap();
186        env::set_var("HOME", temp.path());
187
188        let ctx = CommandContext::new(vec!["~/sub".to_string()]);
189        let result = cd(ctx).await;
190        assert!(result.is_success());
191        assert_eq!(
192            normalize(&env::current_dir().unwrap()),
193            normalize(&temp.path().join("sub"))
194        );
195
196        env::set_current_dir(original_dir).unwrap();
197    }
198
199    #[tokio::test]
200    async fn test_cd_dash_switches_and_prints() {
201        let _guard = CD_TEST_LOCK.lock().await;
202        let dir_a = tempdir().unwrap();
203        let dir_b = tempdir().unwrap();
204        let original_dir = env::current_dir().unwrap();
205
206        let _ = cd(CommandContext::new(vec![dir_a
207            .path()
208            .to_string_lossy()
209            .to_string()]))
210        .await;
211        let _ = cd(CommandContext::new(vec![dir_b
212            .path()
213            .to_string_lossy()
214            .to_string()]))
215        .await;
216
217        let result = cd(CommandContext::new(vec!["-".to_string()])).await;
218        assert!(result.is_success());
219        // sh prints the previous directory on `cd -`.
220        assert_eq!(
221            normalize(Path::new(result.stdout.trim())),
222            normalize(dir_a.path())
223        );
224        assert_eq!(
225            normalize(&env::current_dir().unwrap()),
226            normalize(dir_a.path())
227        );
228
229        env::set_current_dir(original_dir).unwrap();
230    }
231
232    #[tokio::test]
233    async fn test_cd_updates_pwd_and_oldpwd() {
234        let _guard = CD_TEST_LOCK.lock().await;
235        let dir_a = tempdir().unwrap();
236        let dir_b = tempdir().unwrap();
237        let original_dir = env::current_dir().unwrap();
238
239        let _ = cd(CommandContext::new(vec![dir_a
240            .path()
241            .to_string_lossy()
242            .to_string()]))
243        .await;
244        assert_eq!(
245            normalize(Path::new(&env::var("PWD").unwrap())),
246            normalize(dir_a.path())
247        );
248
249        let _ = cd(CommandContext::new(vec![dir_b
250            .path()
251            .to_string_lossy()
252            .to_string()]))
253        .await;
254        assert_eq!(
255            normalize(Path::new(&env::var("PWD").unwrap())),
256            normalize(dir_b.path())
257        );
258        assert_eq!(
259            normalize(Path::new(&env::var("OLDPWD").unwrap())),
260            normalize(dir_a.path())
261        );
262
263        env::set_current_dir(original_dir).unwrap();
264    }
265
266    #[tokio::test]
267    async fn test_cd_relative_resolves_against_cwd_option() {
268        let _guard = CD_TEST_LOCK.lock().await;
269        let temp = tempdir().unwrap();
270        std::fs::create_dir(temp.path().join("sub")).unwrap();
271        let original_dir = env::current_dir().unwrap();
272
273        let mut ctx = CommandContext::new(vec!["sub".to_string()]);
274        ctx.cwd = Some(temp.path().to_path_buf());
275        let result = cd(ctx).await;
276        assert!(result.is_success());
277        assert_eq!(
278            normalize(&env::current_dir().unwrap()),
279            normalize(&temp.path().join("sub"))
280        );
281
282        env::set_current_dir(original_dir).unwrap();
283    }
284}