Implemented the exec builtin

Fixed readline and terminal interactions using stdin instead of /dev/tty
This commit is contained in:
2026-02-20 12:17:48 -05:00
parent 2184b9b361
commit 129390c2da
16 changed files with 384 additions and 41 deletions

View File

@@ -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()

50
src/builtin/exec.rs Normal file
View File

@@ -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))
}
}
}

View File

@@ -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

View File

@@ -829,6 +829,11 @@ pub fn expand_proc_sub(raw: &str, is_input: bool) -> ShResult<String> {
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<String> {
}
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)
}

View File

@@ -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() {

View File

@@ -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<Option<Termios>> = None;
pub static TTY_FILENO: LazyLock<RawFd> = LazyLock::new(|| {
open("/dev/tty", OFlag::O_RDWR, Mode::empty())
.expect("Failed to open /dev/tty")
});
#[derive(Debug)]
pub struct TermiosGuard {
saved_termios: Option<Termios>,
@@ -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);

View File

@@ -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;

View File

@@ -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<Tk>) -> ShResult<Self> {
assert!(!argv.is_empty());
let argv = prepare_argv(argv)?;
Self::from_expanded(argv)
}
pub fn from_expanded(argv: Vec<(String, Span)>) -> ShResult<Self> {
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() {

View File

@@ -39,6 +39,9 @@ pub enum IoMode {
buf: String,
pipe: Arc<OwnedFd>,
},
Close {
tgt_fd: RawFd,
}
}
impl IoMode {
@@ -152,6 +155,21 @@ impl<R: Read> IoBuf<R> {
}
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();

View File

@@ -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) {

View File

@@ -824,7 +824,7 @@ impl LineBuf {
dir: Direction,
) -> Box<dyn Iterator<Item = usize>> {
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<dyn Iterator<Item = usize>>,

View File

@@ -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<String>) -> ShResult<Self> {
pub fn new(prompt: Option<String>, tty: RawFd) -> ShResult<Self> {
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)
}

View File

@@ -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<TermBuffer>,
}
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)),
}
}

View File

@@ -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();

View File

@@ -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);
}