From 88c0945e47bfa256d3286fc5272bc48f22eebfcb Mon Sep 17 00:00:00 2001 From: pagedmov Date: Fri, 20 Feb 2026 12:17:48 -0500 Subject: [PATCH] Implemented the exec builtin Fixed readline and terminal interactions using stdin instead of /dev/tty --- .gitignore | 1 + src/builtin/dirstack.rs | 2 +- src/builtin/exec.rs | 50 +++++++ src/builtin/mod.rs | 5 +- src/expand.rs | 6 +- src/jobs.rs | 5 + src/libsh/sys.rs | 9 +- src/main.rs | 20 ++- src/parse/execute.rs | 14 +- src/procio.rs | 18 +++ src/prompt/readline/complete.rs | 3 + src/prompt/readline/linebuf.rs | 2 +- src/prompt/readline/mod.rs | 7 +- src/prompt/readline/term.rs | 26 ++-- src/tests/highlight.rs | 18 +-- src/tests/state.rs | 239 +++++++++++++++++++++++++++++++- 16 files changed, 384 insertions(+), 41 deletions(-) create mode 100644 src/builtin/exec.rs diff --git a/.gitignore b/.gitignore index d6bfce5..33916db 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ shell.nix *~ TODO.md AUDIT.md +KNOWN_ISSUES.md rust-toolchain.toml /ref diff --git a/src/builtin/dirstack.rs b/src/builtin/dirstack.rs index e9f3e77..a1c23bc 100644 --- a/src/builtin/dirstack.rs +++ b/src/builtin/dirstack.rs @@ -158,7 +158,7 @@ pub fn pushd(node: Node, io_stack: &mut IoStack, job: &mut JobBldr) -> ShResult< let dirs = m.dirs_mut(); dirs.push_front(cwd); match idx { - StackIdx::FromTop(n) => dirs.rotate_left(n + 1), + StackIdx::FromTop(n) => dirs.rotate_left(n), StackIdx::FromBottom(n) => dirs.rotate_right(n + 1), } dirs.pop_front() diff --git a/src/builtin/exec.rs b/src/builtin/exec.rs new file mode 100644 index 0000000..c96d77e --- /dev/null +++ b/src/builtin/exec.rs @@ -0,0 +1,50 @@ +use nix::{errno::Errno, unistd::execvpe}; + +use crate::{ + builtin::setup_builtin, + jobs::JobBldr, + libsh::error::{ShErr, ShErrKind, ShResult}, + parse::{NdRule, Node, execute::ExecArgs}, + procio::IoStack, + state, +}; + +pub fn exec_builtin(node: Node, io_stack: &mut IoStack, job: &mut JobBldr) -> ShResult<()> { + let NdRule::Command { + assignments: _, + argv, + } = node.class + else { + unreachable!() + }; + + let (expanded_argv, guard) = setup_builtin(argv, job, Some((io_stack, node.redirs)))?; + if let Some(g) = guard { + // Persist redirections so they affect the entire shell, + // not just this command call + g.persist() + } + + if expanded_argv.is_empty() { + state::set_status(0); + return Ok(()); + } + + let args = ExecArgs::from_expanded(expanded_argv)?; + + let cmd = &args.cmd.0; + let span = args.cmd.1; + + let Err(e) = execvpe(cmd, &args.argv, &args.envp); + + // execvpe only returns on error + let cmd_str = cmd.to_str().unwrap().to_string(); + match e { + Errno::ENOENT => { + Err(ShErr::full(ShErrKind::CmdNotFound(cmd_str), "", span)) + } + _ => { + Err(ShErr::full(ShErrKind::Errno(e), format!("{e}"), span)) + } + } +} diff --git a/src/builtin/mod.rs b/src/builtin/mod.rs index 5b8a6fa..e05c959 100644 --- a/src/builtin/mod.rs +++ b/src/builtin/mod.rs @@ -26,11 +26,12 @@ pub mod test; // [[ ]] thing pub mod trap; pub mod zoltraak; pub mod dirstack; +pub mod exec; -pub const BUILTINS: [&str; 24] = [ +pub const BUILTINS: [&str; 25] = [ "echo", "cd", "read", "export", "pwd", "source", "shift", "jobs", "fg", "bg", "alias", "unalias", "return", "break", "continue", "exit", "zoltraak", "shopt", "builtin", "command", "trap", - "pushd", "popd", "dirs" + "pushd", "popd", "dirs", "exec", ]; /// Sets up a builtin command diff --git a/src/expand.rs b/src/expand.rs index 296b5a9..1eb14a8 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -829,6 +829,11 @@ pub fn expand_proc_sub(raw: &str, is_input: bool) -> ShResult { match unsafe { fork()? } { ForkResult::Child => { + // Close the parent's pipe end so the grandchild doesn't inherit it. + // Without this, >(cmd) hangs because the command holds its own + // pipe's write end open and never sees EOF. + drop(register_fd); + let redir = Redir::new(proc_fd, redir_type); let io_frame = IoFrame::from_redir(redir); let mut io_stack = IoStack::new(); @@ -842,7 +847,6 @@ pub fn expand_proc_sub(raw: &str, is_input: bool) -> ShResult { } ForkResult::Parent { child } => { write_jobs(|j| j.register_fd(child, register_fd)); - let registered = read_jobs(|j| j.registered_fds().to_vec()); // Do not wait; process may run in background Ok(path) } diff --git a/src/jobs.rs b/src/jobs.rs index 061be3c..653e295 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -198,6 +198,11 @@ impl JobTab { let registered_fd = RegisteredFd { fd, owner_pid }; self.fd_registry.push(registered_fd) } + /// Close all registered proc sub fds. Called after fork in exec_cmd + /// so the parent doesn't hold pipe ends that prevent EOF. + pub fn drain_registered_fds(&mut self) { + self.fd_registry.clear(); + } fn prune_jobs(&mut self) { while let Some(job) = self.jobs.last() { if job.is_none() { diff --git a/src/libsh/sys.rs b/src/libsh/sys.rs index 8900159..803c5a3 100644 --- a/src/libsh/sys.rs +++ b/src/libsh/sys.rs @@ -1,3 +1,5 @@ +use std::sync::LazyLock; + use termios::{LocalFlags, Termios}; use crate::prelude::*; @@ -31,6 +33,11 @@ use crate::prelude::*; /// lifecycle could lead to undefined behavior. pub(crate) static mut SAVED_TERMIOS: Option> = None; +pub static TTY_FILENO: LazyLock = LazyLock::new(|| { + open("/dev/tty", OFlag::O_RDWR, Mode::empty()) + .expect("Failed to open /dev/tty") +}); + #[derive(Debug)] pub struct TermiosGuard { saved_termios: Option, @@ -42,7 +49,7 @@ impl TermiosGuard { saved_termios: None, }; - if isatty(std::io::stdin().as_raw_fd()).unwrap() { + if isatty(*TTY_FILENO).unwrap() { let current_termios = termios::tcgetattr(std::io::stdin()).unwrap(); new.saved_termios = Some(current_termios); diff --git a/src/main.rs b/src/main.rs index 495bed0..3ecd031 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ use nix::unistd::read; use crate::builtin::trap::TrapTarget; use crate::libsh::error::{ShErr, ShErrKind, ShResult}; +use crate::libsh::sys::TTY_FILENO; use crate::parse::execute::exec_input; use crate::prelude::*; use crate::prompt::get_prompt; @@ -51,6 +52,12 @@ struct FernArgs { #[arg(long)] version: bool, + + #[arg(short)] + interactive: bool, + + #[arg(long,short)] + login_shell: bool, } /// Force evaluation of lazily-initialized values early in shell startup. @@ -70,7 +77,12 @@ fn kickstart_lazy_evals() { fn main() -> ExitCode { env_logger::init(); kickstart_lazy_evals(); - let args = FernArgs::parse(); + let mut args = FernArgs::parse(); + if env::args().next().is_some_and(|a| a.starts_with('-')) { + // first arg is '-fern' + // meaning we are in a login shell + args.login_shell = true; + } if args.version { println!("fern {}", env!("CARGO_PKG_VERSION")); return ExitCode::SUCCESS; @@ -134,7 +146,7 @@ fn fern_interactive() -> ShResult<()> { } // Create readline instance with initial prompt - let mut readline = match FernVi::new(get_prompt().ok()) { + let mut readline = match FernVi::new(get_prompt().ok(), *TTY_FILENO) { Ok(rl) => rl, Err(e) => { eprintln!("Failed to initialize readline: {e}"); @@ -169,7 +181,7 @@ fn fern_interactive() -> ShResult<()> { // Poll for stdin input let mut fds = [PollFd::new( - unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) }, + unsafe { BorrowedFd::borrow_raw(*TTY_FILENO) }, PollFlags::POLLIN, )]; @@ -191,7 +203,7 @@ fn fern_interactive() -> ShResult<()> { .is_some_and(|r| r.contains(PollFlags::POLLIN)) { let mut buffer = [0u8; 1024]; - match read(STDIN_FILENO, &mut buffer) { + match read(*TTY_FILENO, &mut buffer) { Ok(0) => { // EOF break; diff --git a/src/parse/execute.rs b/src/parse/execute.rs index 4ac28da..a04b119 100644 --- a/src/parse/execute.rs +++ b/src/parse/execute.rs @@ -2,14 +2,14 @@ use std::collections::{HashSet, VecDeque}; use crate::{ builtin::{ - alias::{alias, unalias}, cd::cd, dirstack::{dirs, popd, pushd}, echo::echo, export::export, flowctl::flowctl, jobctl::{JobBehavior, continue_job, jobs}, pwd::pwd, read::read_builtin, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, zoltraak::zoltraak + alias::{alias, unalias}, cd::cd, dirstack::{dirs, popd, pushd}, echo::echo, exec, export::export, flowctl::flowctl, jobctl::{JobBehavior, continue_job, jobs}, pwd::pwd, read::read_builtin, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, zoltraak::zoltraak }, expand::expand_aliases, jobs::{ChildProc, JobStack, dispatch_job}, libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt}, prelude::*, procio::{IoMode, IoStack}, - state::{self, ShFunc, VarFlags, read_logic, write_logic, write_vars}, + state::{self, ShFunc, VarFlags, read_logic, write_jobs, write_logic, write_vars}, }; use super::{ @@ -80,6 +80,10 @@ impl ExecArgs { pub fn new(argv: Vec) -> ShResult { assert!(!argv.is_empty()); let argv = prepare_argv(argv)?; + Self::from_expanded(argv) + } + pub fn from_expanded(argv: Vec<(String, Span)>) -> ShResult { + assert!(!argv.is_empty()); let cmd = Self::get_cmd(&argv); let argv = Self::get_argv(argv); let envp = Self::get_envp(); @@ -603,6 +607,7 @@ impl Dispatcher { "pushd" => pushd(cmd, io_stack_mut, curr_job_mut), "popd" => popd(cmd, io_stack_mut, curr_job_mut), "dirs" => dirs(cmd, io_stack_mut, curr_job_mut), + "exec" => exec::exec_builtin(cmd, io_stack_mut, curr_job_mut), _ => unimplemented!( "Have not yet added support for builtin '{}'", cmd_raw.span.as_str() @@ -663,6 +668,11 @@ impl Dispatcher { exit(e as i32) } ForkResult::Parent { child } => { + // Close proc sub pipe fds - the child has inherited them + // and will access them via /proc/self/fd/N. Keeping them + // open here would prevent EOF on the pipe. + write_jobs(|j| j.drain_registered_fds()); + let cmd_name = exec_args.cmd.0.to_str().unwrap(); let child_pgid = if let Some(pgid) = job.pgid() { diff --git a/src/procio.rs b/src/procio.rs index 700793b..ef502ee 100644 --- a/src/procio.rs +++ b/src/procio.rs @@ -39,6 +39,9 @@ pub enum IoMode { buf: String, pipe: Arc, }, + Close { + tgt_fd: RawFd, + } } impl IoMode { @@ -152,6 +155,21 @@ impl IoBuf { } pub struct RedirGuard(IoFrame); + +impl RedirGuard { + pub fn persist(mut self) { + if let Some(saved) = self.0.saved_io.take() { + close(saved.0).ok(); + close(saved.1).ok(); + close(saved.2).ok(); + } + + // the guard is dropped here + // but since we took the saved fds + // the drop does not restore them + } +} + impl Drop for RedirGuard { fn drop(&mut self) { self.0.restore().ok(); diff --git a/src/prompt/readline/complete.rs b/src/prompt/readline/complete.rs index 6ee4c54..bcd2439 100644 --- a/src/prompt/readline/complete.rs +++ b/src/prompt/readline/complete.rs @@ -263,6 +263,9 @@ impl Completer { if let Some(eq_pos) = token_str.rfind('=') { // Adjust span to only replace the part after '=' self.token_span.0 = cur_token.span.start + eq_pos + 1; + cur_token + .span + .set_range(self.token_span.0..self.token_span.1); } if ctx.last().is_some_and(|m| *m == markers::VAR_SUB) { diff --git a/src/prompt/readline/linebuf.rs b/src/prompt/readline/linebuf.rs index 17d4b3c..7c5abf4 100644 --- a/src/prompt/readline/linebuf.rs +++ b/src/prompt/readline/linebuf.rs @@ -824,7 +824,7 @@ impl LineBuf { dir: Direction, ) -> Box> { self.update_graphemes_lazy(); - let skip = if pos == 0 { 0 } else { pos + 1 }; + let skip = pos + 1; match dir { Direction::Forward => Box::new(self.grapheme_indices().to_vec().into_iter().skip(skip)) as Box>, diff --git a/src/prompt/readline/mod.rs b/src/prompt/readline/mod.rs index 0a211e0..5362fda 100644 --- a/src/prompt/readline/mod.rs +++ b/src/prompt/readline/mod.rs @@ -6,6 +6,7 @@ use term::{KeyReader, Layout, LineWriter, PollReader, TermWriter, get_win_size}; use vicmd::{CmdFlags, Motion, MotionCmd, RegisterName, Verb, VerbCmd, ViCmd}; use vimode::{CmdReplay, ModeReport, ViInsert, ViMode, ViNormal, ViReplace, ViVisual}; +use crate::libsh::sys::TTY_FILENO; use crate::prelude::*; use crate::{ libsh::{ @@ -112,10 +113,10 @@ pub struct FernVi { } impl FernVi { - pub fn new(prompt: Option) -> ShResult { + pub fn new(prompt: Option, tty: RawFd) -> ShResult { let mut new = Self { reader: PollReader::new(), - writer: Box::new(TermWriter::new(STDOUT_FILENO)), + writer: Box::new(TermWriter::new(tty)), prompt: prompt.unwrap_or("$ ".styled(Style::Green)), completer: Completer::new(), highlighter: Highlighter::new(), @@ -294,7 +295,7 @@ impl FernVi { pub fn get_layout(&mut self, line: &str) -> Layout { let to_cursor = self.editor.slice_to_cursor().unwrap_or_default(); - let (cols, _) = get_win_size(STDIN_FILENO); + let (cols, _) = get_win_size(*TTY_FILENO); let tab_stop = crate::state::read_shopts(|s| s.prompt.tab_stop) as u16; Layout::from_parts(tab_stop, cols, &self.prompt, to_cursor, line) } diff --git a/src/prompt/readline/term.rs b/src/prompt/readline/term.rs index 4e32097..d455a35 100644 --- a/src/prompt/readline/term.rs +++ b/src/prompt/readline/term.rs @@ -18,7 +18,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use vte::{Parser, Perform}; use crate::{ - libsh::error::{ShErr, ShErrKind, ShResult}, + libsh::{error::{ShErr, ShErrKind, ShResult}, sys::TTY_FILENO}, prompt::readline::keys::{KeyCode, ModKeys}, }; use crate::{ @@ -30,7 +30,7 @@ use crate::{ use super::{keys::KeyEvent, linebuf::LineBuf}; pub fn raw_mode() -> RawModeGuard { - let orig = termios::tcgetattr(unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) }) + let orig = termios::tcgetattr(unsafe { BorrowedFd::borrow_raw(*TTY_FILENO) }) .expect("Failed to get terminal attributes"); let mut raw = orig.clone(); termios::cfmakeraw(&mut raw); @@ -39,17 +39,17 @@ pub fn raw_mode() -> RawModeGuard { // Keep OPOST enabled so \n is translated to \r\n on output raw.output_flags |= termios::OutputFlags::OPOST; termios::tcsetattr( - unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) }, + unsafe { BorrowedFd::borrow_raw(*TTY_FILENO) }, termios::SetArg::TCSANOW, &raw, ) .expect("Failed to set terminal to raw mode"); - let (cols, rows) = get_win_size(STDIN_FILENO); + let (cols, rows) = get_win_size(*TTY_FILENO); RawModeGuard { orig, - fd: STDIN_FILENO, + fd: *TTY_FILENO, } } @@ -286,14 +286,14 @@ impl RawModeGuard { where F: FnOnce() -> R, { - let raw = tcgetattr(borrow_fd(STDIN_FILENO)).expect("Failed to get terminal attributes"); + let raw = tcgetattr(borrow_fd(*TTY_FILENO)).expect("Failed to get terminal attributes"); let mut cooked = raw.clone(); cooked.local_flags |= termios::LocalFlags::ICANON | termios::LocalFlags::ECHO; cooked.input_flags |= termios::InputFlags::ICRNL; - tcsetattr(borrow_fd(STDIN_FILENO), termios::SetArg::TCSANOW, &cooked) + tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &cooked) .expect("Failed to set cooked mode"); let res = f(); - tcsetattr(borrow_fd(STDIN_FILENO), termios::SetArg::TCSANOW, &raw) + tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &raw) .expect("Failed to restore raw mode"); res } @@ -542,16 +542,10 @@ pub struct TermReader { buffer: BufReader, } -impl Default for TermReader { - fn default() -> Self { - Self::new() - } -} - impl TermReader { - pub fn new() -> Self { + pub fn new(tty: RawFd) -> Self { Self { - buffer: BufReader::new(TermBuffer::new(1)), + buffer: BufReader::new(TermBuffer::new(tty)), } } diff --git a/src/tests/highlight.rs b/src/tests/highlight.rs index 2ea850c..a809cf0 100644 --- a/src/tests/highlight.rs +++ b/src/tests/highlight.rs @@ -366,7 +366,7 @@ fn marker_priority_reset_placement() { #[test] fn highlighter_produces_ansi_codes() { let mut highlighter = Highlighter::new(); - highlighter.load_input("echo hello"); + highlighter.load_input("echo hello", 0); highlighter.highlight(); let output = highlighter.take(); @@ -384,7 +384,7 @@ fn highlighter_produces_ansi_codes() { #[test] fn highlighter_handles_empty_input() { let mut highlighter = Highlighter::new(); - highlighter.load_input(""); + highlighter.load_input("", 0); highlighter.highlight(); let output = highlighter.take(); @@ -397,12 +397,12 @@ fn highlighter_command_validation() { let mut highlighter = Highlighter::new(); // Valid command (echo exists) - highlighter.load_input("echo test"); + highlighter.load_input("echo test", 0); highlighter.highlight(); let valid_output = highlighter.take(); // Invalid command (definitely doesn't exist) - highlighter.load_input("xyznotacommand123 test"); + highlighter.load_input("xyznotacommand123 test", 0); highlighter.highlight(); let invalid_output = highlighter.take(); @@ -419,7 +419,7 @@ fn highlighter_command_validation() { fn highlighter_preserves_text_content() { let input = "echo hello world"; let mut highlighter = Highlighter::new(); - highlighter.load_input(input); + highlighter.load_input(input, 0); highlighter.highlight(); let output = highlighter.take(); @@ -438,7 +438,7 @@ fn highlighter_preserves_text_content() { #[test] fn highlighter_multiple_tokens() { let mut highlighter = Highlighter::new(); - highlighter.load_input("ls -la | grep foo"); + highlighter.load_input("ls -la | grep foo", 0); highlighter.highlight(); let output = highlighter.take(); @@ -456,7 +456,7 @@ fn highlighter_multiple_tokens() { #[test] fn highlighter_string_with_variable() { let mut highlighter = Highlighter::new(); - highlighter.load_input(r#"echo "hello $USER""#); + highlighter.load_input(r#"echo "hello $USER""#, 0); highlighter.highlight(); let output = highlighter.take(); @@ -474,12 +474,12 @@ fn highlighter_reusable() { let mut highlighter = Highlighter::new(); // First input - highlighter.load_input("echo first"); + highlighter.load_input("echo first", 0); highlighter.highlight(); let output1 = highlighter.take(); // Second input (reusing same highlighter) - highlighter.load_input("echo second"); + highlighter.load_input("echo second", 0); highlighter.highlight(); let output2 = highlighter.take(); diff --git a/src/tests/state.rs b/src/tests/state.rs index c82bf69..a56c83b 100644 --- a/src/tests/state.rs +++ b/src/tests/state.rs @@ -1,4 +1,5 @@ -use crate::state::{LogTab, ScopeStack, ShellParam, VarFlags, VarTab}; +use std::path::PathBuf; +use crate::state::{LogTab, MetaTab, ScopeStack, ShellParam, VarFlags, VarTab}; // ============================================================================ // ScopeStack Tests - Variable Scoping @@ -593,3 +594,239 @@ fn shellparam_display() { assert_eq!(ShellParam::Pos(1).to_string(), "1"); assert_eq!(ShellParam::Pos(99).to_string(), "99"); } + +// ============================================================================ +// MetaTab Directory Stack Tests +// ============================================================================ + +#[test] +fn dirstack_push_pop() { + let mut meta = MetaTab::new(); + + meta.push_dir(PathBuf::from("/tmp")); + meta.push_dir(PathBuf::from("/var")); + + // push_front means /var is on top, /tmp is below + assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/var"))); + + let popped = meta.pop_dir(); + assert_eq!(popped, Some(PathBuf::from("/var"))); + assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/tmp"))); + + let popped = meta.pop_dir(); + assert_eq!(popped, Some(PathBuf::from("/tmp"))); + assert_eq!(meta.pop_dir(), None); +} + +#[test] +fn dirstack_empty() { + let mut meta = MetaTab::new(); + + assert_eq!(meta.dir_stack_top(), None); + assert_eq!(meta.pop_dir(), None); + assert!(meta.dirs().is_empty()); +} + +#[test] +fn dirstack_rotate_fwd() { + let mut meta = MetaTab::new(); + + // Build stack: front=[A, B, C, D]=back + meta.dirs_mut().push_back(PathBuf::from("/a")); + meta.dirs_mut().push_back(PathBuf::from("/b")); + meta.dirs_mut().push_back(PathBuf::from("/c")); + meta.dirs_mut().push_back(PathBuf::from("/d")); + + // rotate_left(1): [B, C, D, A] + meta.rotate_dirs_fwd(1); + assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/b"))); + assert_eq!(meta.dirs().back(), Some(&PathBuf::from("/a"))); +} + +#[test] +fn dirstack_rotate_bkwd() { + let mut meta = MetaTab::new(); + + // Build stack: front=[A, B, C, D]=back + meta.dirs_mut().push_back(PathBuf::from("/a")); + meta.dirs_mut().push_back(PathBuf::from("/b")); + meta.dirs_mut().push_back(PathBuf::from("/c")); + meta.dirs_mut().push_back(PathBuf::from("/d")); + + // rotate_right(1): [D, A, B, C] + meta.rotate_dirs_bkwd(1); + assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/d"))); + assert_eq!(meta.dirs().back(), Some(&PathBuf::from("/c"))); +} + +#[test] +fn dirstack_rotate_zero_is_noop() { + let mut meta = MetaTab::new(); + + meta.dirs_mut().push_back(PathBuf::from("/a")); + meta.dirs_mut().push_back(PathBuf::from("/b")); + meta.dirs_mut().push_back(PathBuf::from("/c")); + + meta.rotate_dirs_fwd(0); + assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/a"))); + + meta.rotate_dirs_bkwd(0); + assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/a"))); +} + +#[test] +fn dirstack_pushd_rotation_with_cwd() { + // Simulates what pushd +N does: insert cwd, rotate, pop new top + let mut meta = MetaTab::new(); + + // Stored stack: [/tmp, /var, /etc] + meta.push_dir(PathBuf::from("/etc")); + meta.push_dir(PathBuf::from("/var")); + meta.push_dir(PathBuf::from("/tmp")); + + // pushd +2 with cwd=/home: + // push_front(cwd): [/home, /tmp, /var, /etc] + // rotate_left(2): [/var, /etc, /home, /tmp] + // pop_front(): /var = new cwd + let cwd = PathBuf::from("/home"); + let dirs = meta.dirs_mut(); + dirs.push_front(cwd); + dirs.rotate_left(2); + let new_cwd = dirs.pop_front(); + + assert_eq!(new_cwd, Some(PathBuf::from("/var"))); + let remaining: Vec<_> = meta.dirs().iter().collect(); + assert_eq!(remaining, vec![ + &PathBuf::from("/etc"), + &PathBuf::from("/home"), + &PathBuf::from("/tmp"), + ]); +} + +#[test] +fn dirstack_pushd_minus_zero_with_cwd() { + // pushd -0: bring bottom to top + let mut meta = MetaTab::new(); + + // Stored stack: [/tmp, /var, /etc] + meta.push_dir(PathBuf::from("/etc")); + meta.push_dir(PathBuf::from("/var")); + meta.push_dir(PathBuf::from("/tmp")); + + // pushd -0 with cwd=/home: + // push_front(cwd): [/home, /tmp, /var, /etc] + // rotate_right(0+1=1): [/etc, /home, /tmp, /var] + // pop_front(): /etc = new cwd + let cwd = PathBuf::from("/home"); + let dirs = meta.dirs_mut(); + dirs.push_front(cwd); + dirs.rotate_right(1); + let new_cwd = dirs.pop_front(); + + assert_eq!(new_cwd, Some(PathBuf::from("/etc"))); +} + +#[test] +fn dirstack_pushd_plus_zero_noop() { + // pushd +0: should be a no-op (cwd stays the same) + let mut meta = MetaTab::new(); + + meta.push_dir(PathBuf::from("/etc")); + meta.push_dir(PathBuf::from("/var")); + meta.push_dir(PathBuf::from("/tmp")); + + // pushd +0 with cwd=/home: + // push_front(cwd): [/home, /tmp, /var, /etc] + // rotate_left(0): no-op + // pop_front(): /home = cwd unchanged + let cwd = PathBuf::from("/home"); + let dirs = meta.dirs_mut(); + dirs.push_front(cwd.clone()); + dirs.rotate_left(0); + let new_cwd = dirs.pop_front(); + + assert_eq!(new_cwd, Some(PathBuf::from("/home"))); +} + +#[test] +fn dirstack_popd_removes_from_top() { + let mut meta = MetaTab::new(); + + meta.push_dir(PathBuf::from("/etc")); + meta.push_dir(PathBuf::from("/var")); + meta.push_dir(PathBuf::from("/tmp")); + + // popd (no args) or popd +0: pop from front + let popped = meta.pop_dir(); + assert_eq!(popped, Some(PathBuf::from("/tmp"))); + assert_eq!(meta.dirs().len(), 2); +} + +#[test] +fn dirstack_popd_plus_n_offset() { + let mut meta = MetaTab::new(); + + // Stored: [/tmp, /var, /etc] (front to back) + meta.push_dir(PathBuf::from("/etc")); + meta.push_dir(PathBuf::from("/var")); + meta.push_dir(PathBuf::from("/tmp")); + + // popd +2: full stack is [cwd, /tmp, /var, /etc] + // +2 = /var, which is stored index 1 (n-1 = 2-1 = 1) + let removed = meta.dirs_mut().remove(1); // n-1 for +N + assert_eq!(removed, Some(PathBuf::from("/var"))); + + let remaining: Vec<_> = meta.dirs().iter().collect(); + assert_eq!(remaining, vec![ + &PathBuf::from("/tmp"), + &PathBuf::from("/etc"), + ]); +} + +#[test] +fn dirstack_popd_minus_zero() { + let mut meta = MetaTab::new(); + + // Stored: [/tmp, /var, /etc] + meta.push_dir(PathBuf::from("/etc")); + meta.push_dir(PathBuf::from("/var")); + meta.push_dir(PathBuf::from("/tmp")); + + // popd -0: remove bottom (back) + // actual = len - 1 - 0 = 2, via checked_sub(0+1) = checked_sub(1) = 2 + let len = meta.dirs().len(); + let actual = len.checked_sub(1).unwrap(); + let removed = meta.dirs_mut().remove(actual); + assert_eq!(removed, Some(PathBuf::from("/etc"))); +} + +#[test] +fn dirstack_popd_minus_n() { + let mut meta = MetaTab::new(); + + // Stored: [/tmp, /var, /etc, /usr] + meta.push_dir(PathBuf::from("/usr")); + meta.push_dir(PathBuf::from("/etc")); + meta.push_dir(PathBuf::from("/var")); + meta.push_dir(PathBuf::from("/tmp")); + + // popd -1: second from bottom = /etc + // actual = len - (1+1) = 4 - 2 = 2 + let len = meta.dirs().len(); + let actual = len.checked_sub(2).unwrap(); // n+1 = 2 + let removed = meta.dirs_mut().remove(actual); + assert_eq!(removed, Some(PathBuf::from("/etc"))); +} + +#[test] +fn dirstack_clear() { + let mut meta = MetaTab::new(); + + meta.push_dir(PathBuf::from("/tmp")); + meta.push_dir(PathBuf::from("/var")); + meta.push_dir(PathBuf::from("/etc")); + + meta.dirs_mut().clear(); + assert!(meta.dirs().is_empty()); + assert_eq!(meta.dir_stack_top(), None); +}