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#[cfg(windows)]
9fn user_facing_path(path: PathBuf) -> PathBuf {
10    use std::ffi::OsString;
11    use std::os::windows::ffi::{OsStrExt, OsStringExt};
12
13    const VERBATIM_UNC: &[u16] = &[92, 92, 63, 92, 85, 78, 67, 92];
14    const VERBATIM: &[u16] = &[92, 92, 63, 92];
15    const UNC: &[u16] = &[92, 92];
16
17    let encoded: Vec<_> = path.as_os_str().encode_wide().collect();
18    if let Some(rest) = encoded.strip_prefix(VERBATIM_UNC) {
19        let mut normalized = UNC.to_vec();
20        normalized.extend_from_slice(rest);
21        return PathBuf::from(OsString::from_wide(&normalized));
22    }
23    if let Some(rest) = encoded.strip_prefix(VERBATIM) {
24        return PathBuf::from(OsString::from_wide(rest));
25    }
26    path
27}
28
29#[cfg(not(windows))]
30fn user_facing_path(path: PathBuf) -> PathBuf {
31    path
32}
33
34#[derive(Debug)]
35pub(crate) struct CdContext {
36    pub(crate) cwd: PathBuf,
37    pub(crate) oldpwd: PathBuf,
38}
39
40/// Execute the cd command
41///
42/// Mirrors POSIX `sh`/bash semantics so that shell scripts translate directly:
43///   - `cd`            -> change to $HOME (or $USERPROFILE on Windows)
44///   - `cd ~`/`cd ~/x` -> tilde expands to $HOME
45///   - `cd -`          -> change to $OLDPWD and print the new directory (like sh)
46///   - `cd <dir>`      -> change to `<dir>` (relative paths resolve against the
47///     current working directory, or the `cwd` option)
48///
49/// This low-level command API retains its original process-mutating behavior.
50/// `ProcessRunner` and `Pipeline` use `resolve_cd` instead so their cwd and
51/// environment remain invocation-local.
52pub async fn cd(ctx: CommandContext) -> CommandResult {
53    let (result, context) = resolve_cd(ctx).await;
54    let Some(context) = context else {
55        return result;
56    };
57
58    if let Err(error) = env::set_current_dir(&context.cwd) {
59        trace("VirtualCommand", &format!("cd: failed: {}", error));
60        return CommandResult::error(format!("cd: {}\n", error));
61    }
62    env::set_var("OLDPWD", &context.oldpwd);
63    env::set_var("PWD", &context.cwd);
64    result
65}
66
67pub(crate) async fn resolve_cd(ctx: CommandContext) -> (CommandResult, Option<CdContext>) {
68    let invocation_env = ctx.env.as_ref();
69    let env_value = |name: &str| {
70        invocation_env
71            .and_then(|values| values.get(name).cloned())
72            .or_else(|| env::var(name).ok())
73    };
74    let home = env_value("HOME")
75        .or_else(|| env_value("USERPROFILE"))
76        .unwrap_or_else(|| "/".to_string());
77
78    let base = ctx.get_cwd();
79    let previous_dir = user_facing_path(std::fs::canonicalize(&base).unwrap_or(base.clone()));
80
81    let mut print_dir = false;
82    let target: String = match ctx.args.first().map(|s| s.as_str()) {
83        // `cd` with no argument goes to $HOME, just like sh.
84        None | Some("") => home.clone(),
85        // `cd -` switches to the previous directory and prints it (sh behavior).
86        Some("-") => match env_value("OLDPWD") {
87            Some(oldpwd) if !oldpwd.is_empty() => {
88                print_dir = true;
89                oldpwd
90            }
91            _ => {
92                trace("VirtualCommand", "cd: OLDPWD not set");
93                return (CommandResult::error("cd: OLDPWD not set\n"), None);
94            }
95        },
96        Some("~") => home.clone(),
97        Some(t) if t.starts_with("~/") => PathBuf::from(&home).join(&t[2..]).display().to_string(),
98        Some(t) => t.to_string(),
99    };
100
101    // Resolve relative targets against the effective base directory so that the
102    // `cwd` option and chained `cd` commands behave consistently.
103    let target_path = PathBuf::from(&target);
104    let resolved = if target_path.is_absolute() {
105        target_path
106    } else {
107        base.join(&target_path)
108    };
109
110    trace(
111        "VirtualCommand",
112        &format!("cd: changing directory to {:?}", resolved),
113    );
114
115    match std::fs::canonicalize(&resolved).and_then(|new_dir| {
116        if new_dir.is_dir() {
117            Ok(new_dir)
118        } else {
119            Err(std::io::Error::new(
120                std::io::ErrorKind::NotADirectory,
121                "not a directory",
122            ))
123        }
124    }) {
125        Ok(new_dir) => {
126            let new_dir = user_facing_path(new_dir);
127            trace(
128                "VirtualCommand",
129                &format!("cd: success, new dir: {}", new_dir.display()),
130            );
131            // A successful `cd` is silent, except for `cd -` which echoes the dir.
132            let result = if print_dir {
133                CommandResult::success(format!("{}\n", new_dir.display()))
134            } else {
135                CommandResult::success_empty()
136            };
137            (
138                result,
139                Some(CdContext {
140                    cwd: new_dir,
141                    oldpwd: previous_dir,
142                }),
143            )
144        }
145        Err(e) => {
146            trace("VirtualCommand", &format!("cd: failed: {}", e));
147            (CommandResult::error(format!("cd: {}\n", e)), None)
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use std::path::Path;
156    use tempfile::tempdir;
157    use tokio::sync::Mutex;
158
159    // `cd` mutates process-global state (current dir + PWD/OLDPWD env vars).
160    // Rust runs tests in parallel by default, so serialize the cd tests against
161    // each other to avoid races on that shared state. An async-aware mutex lets
162    // the guard be held across the `cd(...).await` calls without tripping
163    // clippy's `await_holding_lock` lint.
164    static CD_TEST_LOCK: Mutex<()> = Mutex::const_new(());
165
166    // Normalize paths so comparisons survive symlinked temp dirs
167    // (e.g. macOS `/var` -> `/private/var`).
168    fn normalize(p: &Path) -> PathBuf {
169        std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
170    }
171
172    #[tokio::test]
173    async fn test_cd_to_temp() {
174        let _guard = CD_TEST_LOCK.lock().await;
175        let temp = tempdir().unwrap();
176        let temp_path = temp.path().to_string_lossy().to_string();
177        let original_dir = env::current_dir().unwrap();
178
179        let ctx = CommandContext::new(vec![temp_path.clone()]);
180        let result = cd(ctx).await;
181        assert!(result.is_success());
182        assert_eq!(result.stdout, "");
183        assert_eq!(
184            normalize(&env::current_dir().unwrap()),
185            normalize(temp.path())
186        );
187
188        // Restore original directory
189        env::set_current_dir(original_dir).unwrap();
190    }
191
192    #[tokio::test]
193    async fn test_cd_to_nonexistent() {
194        let _guard = CD_TEST_LOCK.lock().await;
195        let original_dir = env::current_dir().unwrap();
196        let ctx = CommandContext::new(vec!["/nonexistent/path/12345".to_string()]);
197        let result = cd(ctx).await;
198        assert!(!result.is_success());
199        assert_eq!(result.code, 1);
200        assert!(result.stderr.contains("cd:"));
201        // A failed cd must not move the process out of its directory.
202        assert_eq!(env::current_dir().unwrap(), original_dir);
203    }
204
205    #[tokio::test]
206    async fn test_cd_no_arg_goes_home() {
207        let _guard = CD_TEST_LOCK.lock().await;
208        let temp = tempdir().unwrap();
209        let original_dir = env::current_dir().unwrap();
210        env::set_var("HOME", temp.path());
211
212        let ctx = CommandContext::new(vec![]);
213        let result = cd(ctx).await;
214        assert!(result.is_success());
215        assert_eq!(
216            normalize(&env::current_dir().unwrap()),
217            normalize(temp.path())
218        );
219
220        env::set_current_dir(original_dir).unwrap();
221    }
222
223    #[tokio::test]
224    async fn test_cd_tilde_expands_home() {
225        let _guard = CD_TEST_LOCK.lock().await;
226        let temp = tempdir().unwrap();
227        let original_dir = env::current_dir().unwrap();
228        env::set_var("HOME", temp.path());
229
230        let ctx = CommandContext::new(vec!["~".to_string()]);
231        let result = cd(ctx).await;
232        assert!(result.is_success());
233        assert_eq!(
234            normalize(&env::current_dir().unwrap()),
235            normalize(temp.path())
236        );
237
238        env::set_current_dir(original_dir).unwrap();
239    }
240
241    #[tokio::test]
242    async fn test_cd_tilde_subpath_expands() {
243        let _guard = CD_TEST_LOCK.lock().await;
244        let temp = tempdir().unwrap();
245        std::fs::create_dir(temp.path().join("sub")).unwrap();
246        let original_dir = env::current_dir().unwrap();
247        env::set_var("HOME", temp.path());
248
249        let ctx = CommandContext::new(vec!["~/sub".to_string()]);
250        let result = cd(ctx).await;
251        assert!(result.is_success());
252        assert_eq!(
253            normalize(&env::current_dir().unwrap()),
254            normalize(&temp.path().join("sub"))
255        );
256
257        env::set_current_dir(original_dir).unwrap();
258    }
259
260    #[tokio::test]
261    async fn test_cd_dash_switches_and_prints() {
262        let _guard = CD_TEST_LOCK.lock().await;
263        let dir_a = tempdir().unwrap();
264        let dir_b = tempdir().unwrap();
265        let original_dir = env::current_dir().unwrap();
266
267        let _ = cd(CommandContext::new(vec![dir_a
268            .path()
269            .to_string_lossy()
270            .to_string()]))
271        .await;
272        let _ = cd(CommandContext::new(vec![dir_b
273            .path()
274            .to_string_lossy()
275            .to_string()]))
276        .await;
277
278        let result = cd(CommandContext::new(vec!["-".to_string()])).await;
279        assert!(result.is_success());
280        // sh prints the previous directory on `cd -`.
281        assert_eq!(
282            normalize(Path::new(result.stdout.trim())),
283            normalize(dir_a.path())
284        );
285        assert_eq!(
286            normalize(&env::current_dir().unwrap()),
287            normalize(dir_a.path())
288        );
289
290        env::set_current_dir(original_dir).unwrap();
291    }
292
293    #[tokio::test]
294    async fn test_cd_updates_pwd_and_oldpwd() {
295        let _guard = CD_TEST_LOCK.lock().await;
296        let dir_a = tempdir().unwrap();
297        let dir_b = tempdir().unwrap();
298        let original_dir = env::current_dir().unwrap();
299
300        let _ = cd(CommandContext::new(vec![dir_a
301            .path()
302            .to_string_lossy()
303            .to_string()]))
304        .await;
305        assert_eq!(
306            normalize(Path::new(&env::var("PWD").unwrap())),
307            normalize(dir_a.path())
308        );
309
310        let _ = cd(CommandContext::new(vec![dir_b
311            .path()
312            .to_string_lossy()
313            .to_string()]))
314        .await;
315        assert_eq!(
316            normalize(Path::new(&env::var("PWD").unwrap())),
317            normalize(dir_b.path())
318        );
319        assert_eq!(
320            normalize(Path::new(&env::var("OLDPWD").unwrap())),
321            normalize(dir_a.path())
322        );
323
324        env::set_current_dir(original_dir).unwrap();
325    }
326
327    #[tokio::test]
328    async fn test_cd_relative_resolves_against_cwd_option() {
329        let _guard = CD_TEST_LOCK.lock().await;
330        let temp = tempdir().unwrap();
331        std::fs::create_dir(temp.path().join("sub")).unwrap();
332        let original_dir = env::current_dir().unwrap();
333
334        let mut ctx = CommandContext::new(vec!["sub".to_string()]);
335        ctx.cwd = Some(temp.path().to_path_buf());
336        let result = cd(ctx).await;
337        assert!(result.is_success());
338        assert_eq!(
339            normalize(&env::current_dir().unwrap()),
340            normalize(&temp.path().join("sub"))
341        );
342
343        env::set_current_dir(original_dir).unwrap();
344    }
345}