use std::{env, path::PathBuf}; use ariadne::Fmt; use nix::{libc::STDOUT_FILENO, unistd::write}; use yansi::Color; use crate::{ libsh::error::{ShErr, ShErrKind, ShResult, next_color}, parse::{NdRule, Node, execute::prepare_argv, lex::Span}, procio::borrow_fd, state::{self, read_meta, write_meta}, }; pub fn truncate_home_path(path: String) -> String { if let Ok(home) = env::var("HOME") && path.starts_with(&home) { let new = path.strip_prefix(&home).unwrap(); return format!("~{new}"); } path.to_string() } enum StackIdx { FromTop(usize), FromBottom(usize), } fn print_dirs() -> ShResult<()> { let current_dir = env::current_dir()?; let dirs_iter = read_meta(|m| m.dirs().clone().into_iter()); let all_dirs = [current_dir] .into_iter() .chain(dirs_iter) .map(|d| d.to_string_lossy().to_string()) .map(truncate_home_path) .collect::>() .join(" "); let stdout = borrow_fd(STDOUT_FILENO); write(stdout, all_dirs.as_bytes())?; write(stdout, b"\n")?; Ok(()) } fn change_directory(target: &PathBuf, blame: Span) -> ShResult<()> { if !target.is_dir() { return Err(ShErr::at( ShErrKind::ExecFail, blame, format!("not a directory: '{}'", target.display().fg(next_color())), )); } if let Err(e) = state::change_dir(target) { return Err(ShErr::at( ShErrKind::ExecFail, blame, format!("Failed to change directory: '{}'", e.fg(Color::Red)), )); } let new_dir = env::current_dir().map_err(|e| { ShErr::at( ShErrKind::ExecFail, blame, format!("Failed to get current directory: '{}'", e.fg(Color::Red)), ) })?; unsafe { env::set_var("PWD", new_dir) }; Ok(()) } fn parse_stack_idx(arg: &str, blame: Span, cmd: &str) -> ShResult { let (from_top, digits) = if let Some(rest) = arg.strip_prefix('+') { (true, rest) } else if let Some(rest) = arg.strip_prefix('-') { (false, rest) } else { unreachable!() }; if digits.is_empty() { return Err(ShErr::at( ShErrKind::ExecFail, blame, format!( "{cmd}: missing index after '{}'", if from_top { "+" } else { "-" } ), )); } for ch in digits.chars() { if !ch.is_ascii_digit() { return Err(ShErr::at( ShErrKind::ExecFail, blame, format!("{cmd}: invalid argument: '{}'", arg.fg(next_color())), )); } } let n = digits.parse::().map_err(|e| { ShErr::at( ShErrKind::ExecFail, blame, format!("{cmd}: invalid index: '{}'", e.fg(next_color())), ) })?; if from_top { Ok(StackIdx::FromTop(n)) } else { Ok(StackIdx::FromBottom(n)) } } pub fn pushd(node: Node) -> ShResult<()> { let blame = node.get_span().clone(); let NdRule::Command { assignments: _, argv, } = node.class else { unreachable!() }; let mut argv = prepare_argv(argv)?; if !argv.is_empty() { argv.remove(0); } let mut dir = None; let mut rotate_idx = None; let mut no_cd = false; for (arg, _) in argv { if arg.starts_with('+') || (arg.starts_with('-') && arg.len() > 1 && arg.as_bytes()[1].is_ascii_digit()) { rotate_idx = Some(parse_stack_idx(&arg, blame.clone(), "pushd")?); } else if arg == "-n" { no_cd = true; } else if arg.starts_with('-') { return Err(ShErr::at( ShErrKind::ExecFail, blame, format!("pushd: invalid option: '{}'", arg.fg(next_color())), )); } else { if dir.is_some() { return Err(ShErr::at( ShErrKind::ExecFail, blame, "pushd: too many arguments", )); } let target = PathBuf::from(&arg); if !target.is_dir() { return Err(ShErr::at( ShErrKind::ExecFail, blame, format!( "pushd: not a directory: '{}'", target.display().fg(next_color()) ), )); } dir = Some(target); } } if let Some(idx) = rotate_idx { let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); let new_cwd = write_meta(|m| { let dirs = m.dirs_mut(); dirs.push_front(cwd); match idx { StackIdx::FromTop(n) => dirs.rotate_left(n), StackIdx::FromBottom(n) => dirs.rotate_right(n + 1), } dirs.pop_front() }); if let Some(dir) = new_cwd && !no_cd { change_directory(&dir, blame)?; print_dirs()?; } } else if let Some(dir) = dir { let old_dir = env::current_dir()?; if old_dir != dir { write_meta(|m| m.push_dir(old_dir)); } if no_cd { state::set_status(0); return Ok(()); } change_directory(&dir, blame)?; print_dirs()?; } state::set_status(0); Ok(()) } pub fn popd(node: Node) -> ShResult<()> { let blame = node.get_span().clone(); let NdRule::Command { assignments: _, argv, } = node.class else { unreachable!() }; let mut argv = prepare_argv(argv)?; if !argv.is_empty() { argv.remove(0); } let mut remove_idx = None; let mut no_cd = false; for (arg, _) in argv { if arg.starts_with('+') || (arg.starts_with('-') && arg.len() > 1 && arg.as_bytes()[1].is_ascii_digit()) { remove_idx = Some(parse_stack_idx(&arg, blame.clone(), "popd")?); } else if arg == "-n" { no_cd = true; } else if arg.starts_with('-') { return Err(ShErr::at( ShErrKind::ExecFail, blame, format!("popd: invalid option: '{}'", arg.fg(next_color())), )); } } if let Some(idx) = remove_idx { match idx { StackIdx::FromTop(0) => { // +0 is same as plain popd: pop top, cd to it let dir = write_meta(|m| m.pop_dir()); if !no_cd { if let Some(dir) = dir { change_directory(&dir, blame.clone())?; } else { return Err(ShErr::at( ShErrKind::ExecFail, blame, "popd: directory stack empty", )); } } } StackIdx::FromTop(n) => { // +N (N>0): remove (N-1)th stored entry, no cd write_meta(|m| { let dirs = m.dirs_mut(); let idx = n - 1; if idx >= dirs.len() { return Err(ShErr::at( ShErrKind::ExecFail, blame.clone(), format!("popd: directory index out of range: +{n}"), )); } dirs.remove(idx); Ok(()) })?; } StackIdx::FromBottom(n) => { write_meta(|m| -> ShResult<()> { let dirs = m.dirs_mut(); let actual = dirs.len().checked_sub(n + 1).ok_or_else(|| { ShErr::at( ShErrKind::ExecFail, blame.clone(), format!("popd: directory index out of range: -{n}"), ) })?; dirs.remove(actual); Ok(()) })?; } } print_dirs()?; } else { let dir = write_meta(|m| m.pop_dir()); if no_cd { state::set_status(0); return Ok(()); } if let Some(dir) = dir { change_directory(&dir, blame.clone())?; print_dirs()?; } else { return Err(ShErr::at( ShErrKind::ExecFail, blame, "popd: directory stack empty", )); } } Ok(()) } pub fn dirs(node: Node) -> ShResult<()> { let blame = node.get_span().clone(); let NdRule::Command { assignments: _, argv, } = node.class else { unreachable!() }; let mut argv = prepare_argv(argv)?; if !argv.is_empty() { argv.remove(0); } let mut abbreviate_home = true; let mut one_per_line = false; let mut one_per_line_indexed = false; let mut clear_stack = false; let mut target_idx: Option = None; for (arg, _) in argv { match arg.as_str() { "-p" => one_per_line = true, "-v" => one_per_line_indexed = true, "-c" => clear_stack = true, "-l" => abbreviate_home = false, _ if (arg.starts_with('+') || arg.starts_with('-')) && arg.len() > 1 && arg.as_bytes()[1].is_ascii_digit() => { target_idx = Some(parse_stack_idx(&arg, blame.clone(), "dirs")?); } _ if arg.starts_with('-') => { return Err(ShErr::at( ShErrKind::ExecFail, blame, format!("dirs: invalid option: '{}'", arg.fg(next_color())), )); } _ => { return Err(ShErr::at( ShErrKind::ExecFail, blame, format!("dirs: unexpected argument: '{}'", arg.fg(next_color())), )); } } } if clear_stack { write_meta(|m| m.dirs_mut().clear()); return Ok(()); } let mut dirs: Vec = read_meta(|m| { let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); let stack = [current_dir] .into_iter() .chain(m.dirs().clone()) .map(|d| d.to_string_lossy().to_string()); if abbreviate_home { stack.map(truncate_home_path) .collect() } else { stack.collect() } }); if let Some(idx) = target_idx { let target = match idx { StackIdx::FromTop(n) => dirs.get(n), StackIdx::FromBottom(n) => dirs.get(dirs.len().saturating_sub(n + 1)), }; if let Some(dir) = target { dirs = vec![dir.clone()]; } else { return Err(ShErr::at( ShErrKind::ExecFail, blame, format!( "dirs: directory index out of range: {}", match idx { StackIdx::FromTop(n) => format!("+{n}"), StackIdx::FromBottom(n) => format!("-{n}"), } ), )); } } let mut output = String::new(); if one_per_line { output = dirs.join("\n"); } else if one_per_line_indexed { for (i, dir) in dirs.iter_mut().enumerate() { *dir = format!("{i}\t{dir}"); } output = dirs.join("\n"); output.push('\n'); } else { print_dirs()?; } let stdout = borrow_fd(STDOUT_FILENO); write(stdout, output.as_bytes())?; Ok(()) } #[cfg(test)] pub mod tests { use std::{env, path::PathBuf}; use crate::{state::{self, read_meta}, testutil::{TestGuard, test_input}}; use pretty_assertions::{assert_ne,assert_eq}; use tempfile::TempDir; #[test] fn test_pushd_interactive() { let g = TestGuard::new(); let current_dir = env::current_dir().unwrap(); test_input("pushd /tmp").unwrap(); let new_dir = env::current_dir().unwrap(); assert_ne!(new_dir, current_dir); assert_eq!(new_dir, PathBuf::from("/tmp")); let dir_stack = read_meta(|m| m.dirs().clone()); assert_eq!(dir_stack.len(), 1); assert_eq!(dir_stack[0], current_dir); let out = g.read_output(); let path = super::truncate_home_path(current_dir.to_string_lossy().to_string()); assert_eq!(out, format!("/tmp {path}\n")); } #[test] fn test_popd_interactive() { let g = TestGuard::new(); let current_dir = env::current_dir().unwrap(); let tempdir = TempDir::new().unwrap(); let tempdir_raw = tempdir.path().to_path_buf().to_string_lossy().to_string(); test_input(format!("pushd {tempdir_raw}")).unwrap(); let dir_stack = read_meta(|m| m.dirs().clone()); assert_eq!(dir_stack.len(), 1); assert_eq!(dir_stack[0], current_dir); assert_eq!(env::current_dir().unwrap(), tempdir.path()); g.read_output(); // consume output of pushd test_input("popd").unwrap(); assert_eq!(env::current_dir().unwrap(), current_dir); let out = g.read_output(); let path = super::truncate_home_path(current_dir.to_string_lossy().to_string()); assert_eq!(out, format!("{path}\n")); } #[test] fn test_popd_empty_stack() { let _g = TestGuard::new(); test_input("popd").unwrap_err(); assert_ne!(state::get_status(), 0); } #[test] fn test_pushd_multiple_then_popd() { let g = TestGuard::new(); let original = env::current_dir().unwrap(); let tmp1 = TempDir::new().unwrap(); let tmp2 = TempDir::new().unwrap(); let path1 = tmp1.path().to_path_buf(); let path2 = tmp2.path().to_path_buf(); test_input(format!("pushd {}", path1.display())).unwrap(); test_input(format!("pushd {}", path2.display())).unwrap(); g.read_output(); assert_eq!(env::current_dir().unwrap(), path2); let stack = read_meta(|m| m.dirs().clone()); assert_eq!(stack.len(), 2); assert_eq!(stack[0], path1); assert_eq!(stack[1], original); test_input("popd").unwrap(); assert_eq!(env::current_dir().unwrap(), path1); test_input("popd").unwrap(); assert_eq!(env::current_dir().unwrap(), original); let stack = read_meta(|m| m.dirs().clone()); assert_eq!(stack.len(), 0); } #[test] fn test_pushd_rotate_plus() { let g = TestGuard::new(); let original = env::current_dir().unwrap(); let tmp1 = TempDir::new().unwrap(); let tmp2 = TempDir::new().unwrap(); let path1 = tmp1.path().to_path_buf(); let path2 = tmp2.path().to_path_buf(); // Build stack: cwd=original, then pushd path1, pushd path2 // Stack after: cwd=path2, [path1, original] test_input(format!("pushd {}", path1.display())).unwrap(); test_input(format!("pushd {}", path2.display())).unwrap(); g.read_output(); // pushd +1 rotates: [path2, path1, original] -> rotate_left(1) -> [path1, original, path2] // pop front -> cwd=path1, stack=[original, path2] test_input("pushd +1").unwrap(); assert_eq!(env::current_dir().unwrap(), path1); let stack = read_meta(|m| m.dirs().clone()); assert_eq!(stack.len(), 2); assert_eq!(stack[0], original); assert_eq!(stack[1], path2); } #[test] fn test_pushd_no_cd_flag() { let _g = TestGuard::new(); let original = env::current_dir().unwrap(); let tmp = TempDir::new().unwrap(); let path = tmp.path().to_path_buf(); test_input(format!("pushd -n {}", path.display())).unwrap(); // -n means don't cd, but the dir should still be on the stack assert_eq!(env::current_dir().unwrap(), original); } #[test] fn test_dirs_clear() { let _g = TestGuard::new(); let tmp = TempDir::new().unwrap(); test_input(format!("pushd {}", tmp.path().display())).unwrap(); assert_eq!(read_meta(|m| m.dirs().len()), 1); test_input("dirs -c").unwrap(); assert_eq!(read_meta(|m| m.dirs().len()), 0); } #[test] fn test_dirs_one_per_line() { let g = TestGuard::new(); let original = env::current_dir().unwrap(); let tmp = TempDir::new().unwrap(); let path = tmp.path().to_path_buf(); test_input(format!("pushd {}", path.display())).unwrap(); g.read_output(); test_input("dirs -p").unwrap(); let out = g.read_output(); let lines: Vec<&str> = out.split('\n').filter(|l| !l.is_empty()).collect(); assert_eq!(lines.len(), 2); assert_eq!(lines[0], super::truncate_home_path(path.to_string_lossy().to_string())); assert_eq!(lines[1], super::truncate_home_path(original.to_string_lossy().to_string())); } #[test] fn test_popd_indexed_from_top() { let _g = TestGuard::new(); let original = env::current_dir().unwrap(); let tmp1 = TempDir::new().unwrap(); let tmp2 = TempDir::new().unwrap(); let path1 = tmp1.path().to_path_buf(); let path2 = tmp2.path().to_path_buf(); // Stack: cwd=path2, [path1, original] test_input(format!("pushd {}", path1.display())).unwrap(); test_input(format!("pushd {}", path2.display())).unwrap(); // popd +1 removes index (1-1)=0 from stored dirs, i.e. path1 test_input("popd +1").unwrap(); assert_eq!(env::current_dir().unwrap(), path2); // no cd let stack = read_meta(|m| m.dirs().clone()); assert_eq!(stack.len(), 1); assert_eq!(stack[0], original); } #[test] fn test_pushd_nonexistent_dir() { let _g = TestGuard::new(); let result = test_input("pushd /nonexistent_dir_12345"); assert!(result.is_err()); } }