command_stream/commands/
cd.rs1use 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
40pub 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 None | Some("") => home.clone(),
85 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 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 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 static CD_TEST_LOCK: Mutex<()> = Mutex::const_new(());
165
166 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 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 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 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}