Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/ctx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,43 @@ use std::path::PathBuf;

use crate::env;

pub struct ShellConfig {
pub prompt: String,
pub history_path: Option<PathBuf>,
pub max_history: usize,
}

impl Default for ShellConfig {
fn default() -> Self {
Self {
prompt: "\u{1F980}> ".to_string(), // 🦀>
history_path: None,
max_history: 1000,
}
}
}

pub struct ShellCtx {
pub home_dir: Option<PathBuf>,
pub cwd: PathBuf,
pub config: ShellConfig,
}

impl ShellCtx {
pub fn new(home_dir: Option<PathBuf>, cwd: PathBuf) -> Self {
Self { home_dir, cwd }
Self { home_dir, cwd, config: ShellConfig::default() }
}

pub fn with_config(home_dir: Option<PathBuf>, cwd: PathBuf, config: ShellConfig) -> Self {
Self { home_dir, cwd, config }
}

/// Initialize from the current process environment.
pub fn from_env() -> Self {
Self {
home_dir: env::home_dir(),
cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
config: ShellConfig::default(),
}
}
}
6 changes: 6 additions & 0 deletions src/exit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ impl From<ExitCode> for i32 {
}
}

impl From<ExitCode> for std::process::ExitCode {
fn from(val: ExitCode) -> Self {
std::process::ExitCode::from(val.0)
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
6 changes: 4 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod shell;

pub use arg::Arg;
pub use command::Command;
pub use ctx::ShellConfig;
pub use shell::Shell;

/// Run the ferrish shell with standard I/O
Expand All @@ -31,8 +32,9 @@ pub use shell::Shell;
///
/// let io = MockIo::from_lines(&["echo test", "exit"]);
/// let mut shell = Shell::builder().with_io(io);
/// let result = shell.run();
/// let exit_code = shell.run()?;
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn run() -> anyhow::Result<()> {
pub fn run() -> anyhow::Result<exit::ExitCode> {
Shell::<crate::io::StandardIo>::builder().with_std_io().run()
}
5 changes: 3 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
fn main() -> anyhow::Result<()> {
ferrish::run()
fn main() -> anyhow::Result<std::process::ExitCode> {
let code = ferrish::run()?;
Ok(code.into())
}
47 changes: 24 additions & 23 deletions src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::path::PathBuf;
use crate::{
Command,
arg::Args,
ctx::ShellCtx,
ctx::{ShellConfig, ShellCtx},
error::ShellResult,
executor,
exit::ExitCode,
Expand All @@ -19,13 +19,9 @@ pub struct Shell<IO: ShellIo> {

pub type StandardShell = Shell<StandardIo>;

/// Non-generic entry points: `prefix()` and `builder()` don't depend on the IO type,
/// so they live on the concrete `Shell<StandardIo>` to avoid type-inference ambiguity.
/// Non-generic entry points: `builder()` doesn't depend on the IO type,
/// so it lives on the concrete `Shell<StandardIo>` to avoid type-inference ambiguity.
impl Shell<StandardIo> {
pub const fn prefix() -> &'static str {
"\u{1F980}> " // 🦀>
}

/// Create a shell builder
///
/// # Example
Expand All @@ -49,19 +45,19 @@ impl<IO: ShellIo> Shell<IO> {
self.io.borrow_mut()
}

pub fn run(&mut self) -> anyhow::Result<()> {
pub fn run(&mut self) -> anyhow::Result<ExitCode> {
loop {
{
let mut io = self.io.borrow_mut();
let w = io.out_writer();
w.write_all(Shell::<StandardIo>::prefix().as_bytes())?;
w.write_all(self.ctx.config.prompt.as_bytes())?;
w.flush()?;
}

let mut buffer = Vec::<u8>::new();
let bytes = self.io.borrow_mut().read_line(&mut buffer)?;
if bytes == 0 {
continue;
return Ok(ExitCode::SUCCESS);
}

let buffer = buffer.trim_ascii();
Expand All @@ -71,10 +67,7 @@ impl<IO: ShellIo> Shell<IO> {

let (command, args) = parser::parse(buffer);
match self.execute_command(command.clone(), args) {
Ok(Some(_exit_code)) => {
// TODO: set exit code in caller env
break;
}
Ok(Some(exit_code)) => return Ok(exit_code),
Ok(None) => {}
Err(e) => {
let fatal = e.is_fatal();
Expand All @@ -87,8 +80,6 @@ impl<IO: ShellIo> Shell<IO> {
}
}
}

Ok(())
}

pub fn run_script(&mut self, script: &[&str]) -> anyhow::Result<ExitCode> {
Expand All @@ -103,7 +94,6 @@ impl<IO: ShellIo> Shell<IO> {

let (command, args) = parser::parse(buffer);
if let Some(exit_code) = self.execute_command(command, args)? {
// TODO: set exit code in caller env instead of returning
return Ok(exit_code);
}
}
Expand All @@ -124,6 +114,7 @@ impl<IO: ShellIo> Shell<IO> {
pub struct ShellBuilder {
home_dir: Option<PathBuf>,
cwd: Option<PathBuf>,
config: Option<ShellConfig>,
}

impl ShellBuilder {
Expand All @@ -139,11 +130,26 @@ impl ShellBuilder {
self
}

/// Override the shell configuration
pub fn with_config(mut self, config: ShellConfig) -> Self {
self.config = Some(config);
self
}

/// Override only the prompt string
pub fn with_prompt(mut self, prompt: String) -> Self {
let mut config = self.config.unwrap_or_default();
config.prompt = prompt;
self.config = Some(config);
self
}

fn build_ctx(self) -> ShellCtx {
let base = ShellCtx::from_env();
ShellCtx::new(
ShellCtx::with_config(
self.home_dir.or(base.home_dir),
self.cwd.unwrap_or(base.cwd),
self.config.unwrap_or_default(),
)
}

Expand Down Expand Up @@ -188,11 +194,6 @@ mod tests {
use crate::io::MockIo;
use crate::Arg;

#[test]
fn test_shell_prefix() {
assert_eq!(Shell::prefix(), "\u{1F980}> ");
}

#[test]
fn test_shell_execute_command_echo() {
let io = MockIo::empty();
Expand Down
17 changes: 13 additions & 4 deletions tests/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,13 @@ impl ShellTest {
let output = String::from_utf8_lossy(shell.io().output()).to_string();
let error = String::from_utf8_lossy(shell.io().error()).to_string();

run_result.unwrap_or_else(|err| {
panic!("shell.run() failed: {err}\nstdout:\n{output}\nstderr:\n{error}");
});
let exit_code = run_result
.unwrap_or_else(|err| {
panic!("shell.run() failed: {err}\nstdout:\n{output}\nstderr:\n{error}");
})
.0;

TestResult { output, error, home_dir: self.home_dir }
TestResult { output, error, home_dir: self.home_dir, exit_code }
// self drops here — temp_dir is cleaned up
}
}
Expand All @@ -116,6 +118,8 @@ pub struct TestResult {
error: String,
#[allow(dead_code)]
home_dir: Option<PathBuf>,
#[allow(dead_code)]
exit_code: u8,
}

impl TestResult {
Expand All @@ -128,6 +132,11 @@ impl TestResult {
&self.error
}

#[allow(dead_code)]
pub fn exit_code(&self) -> u8 {
self.exit_code
}

#[allow(dead_code)]
pub fn output_contains(&self, s: &str) -> bool {
self.output.contains(s)
Expand Down
22 changes: 22 additions & 0 deletions tests/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,28 @@ fn test_repl_sequential_commands() {
result.assert_output_contains("hello");
}

// ============================================================================
// Exit Code Propagation Tests
// ============================================================================

#[test]
fn test_exit_zero_propagates() {
let result = ShellTest::new().script("exit 0").run();
assert_eq!(result.exit_code(), 0);
}

#[test]
fn test_exit_nonzero_propagates() {
let result = ShellTest::new().script("exit 1").run();
assert_eq!(result.exit_code(), 1);
}

#[test]
fn test_exit_arbitrary_code_propagates() {
let result = ShellTest::new().script("exit 42").run();
assert_eq!(result.exit_code(), 42);
}

// ============================================================================
// Error Handling and Recovery Tests
// ============================================================================
Expand Down
Loading