mxsh 0.1.0

Embeddable POSIX-style shell parser and runtime
Documentation
#![cfg(all(feature = "embed", feature = "test-support", feature = "unix-runtime"))]

use mxsh::ShellBuilder;
use mxsh::embed::StdioConfig;
use mxsh::policy::{CommandOverride, VariableAttributes};
use mxsh::runtime::testing::{InMemoryRuntime, StringStdioOut};

#[test]
fn special_host_builtin_keeps_prefix_assignment() {
    let mut shell = ShellBuilder::new()
        .register_special_builtin("observe", |context, _args| {
            let value = context.env_get("X").unwrap_or("").to_string();
            let _ = context.env_set("SEEN", value, VariableAttributes::empty());
            0
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    let result = shell.run(&mut runtime, "X=1 observe");

    assert_eq!(result.status, 0);
    assert_eq!(shell.env_get("X"), Some("1"));
    assert_eq!(shell.env_get("SEEN"), Some("1"));
}

#[test]
fn regular_host_builtin_restores_prefix_assignment() {
    let mut shell = ShellBuilder::new()
        .register_builtin("observe", |context, _args| {
            let value = context.env_get("X").unwrap_or("").to_string();
            let _ = context.env_set("SEEN", value, VariableAttributes::empty());
            0
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    let result = shell.run(&mut runtime, "X=1 observe");

    assert_eq!(result.status, 0);
    assert_eq!(shell.env_get("X"), None);
    assert_eq!(shell.env_get("SEEN"), Some("1"));
}

#[test]
fn command_override_matcher_can_fall_through_to_normal_resolution() {
    let stdout = StringStdioOut::new();
    let mut shell = ShellBuilder::new()
        .register_command_override(
            "echo",
            CommandOverride::new(|context, _args| {
                let _ = context.write_stdout_line("override");
                0
            })
            .with_matcher(|argv| argv.len() == 1),
        )
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    let result = shell.run(&mut runtime, "echo; echo hello");

    assert_eq!(result.status, 0);
    assert_eq!(stdout.collect(), "override\nhello\n");
}

#[test]
fn command_override_can_update_shell_state() {
    let stdout = StringStdioOut::new();
    let mut shell = ShellBuilder::new()
        .register_command_override(
            "echo",
            CommandOverride::new(|context, args| {
                let rendered = args.join(" ");
                let _ = context.write_stdout_line(&rendered);
                let _ = context.env_set("OVERRIDE_OUTPUT", rendered, VariableAttributes::empty());
                0
            }),
        )
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    let result = shell.run(&mut runtime, "echo hello world");

    assert_eq!(result.status, 0);
    assert_eq!(stdout.collect(), "hello world\n");
    assert_eq!(shell.env_get("OVERRIDE_OUTPUT"), Some("hello world"));
}

#[test]
fn command_policy_can_mark_unspecified_utilities() {
    let stderr = StringStdioOut::new();
    let mut shell = ShellBuilder::new()
        .clear_unspecified_utilities()
        .add_unspecified_utility("mystery")
        .stdio(StdioConfig {
            stderr: stderr.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    let result = shell.run(&mut runtime, "mystery");

    assert_eq!(result.status, 1);
    assert_eq!(result.exit_code, Some(1));
    assert!(
        stderr
            .collect()
            .contains("mystery: The behavior of this command is undefined.")
    );
}

#[test]
fn host_builtin_can_write_using_shell_environment() {
    let stdout = StringStdioOut::new();
    let mut shell = ShellBuilder::new()
        .env("MSG", "from-shell", VariableAttributes::empty())
        .register_builtin("emit-msg", |context, _args| {
            let message = context.env_get("MSG").unwrap_or("");
            let _ = context.write_stdout_line(message);
            0
        })
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    let result = shell.run(&mut runtime, "emit-msg");

    assert_eq!(result.status, 0);
    assert_eq!(stdout.collect(), "from-shell\n");
}

#[test]
fn host_builtin_can_read_shell_identity_and_working_directory() {
    let stdout = StringStdioOut::new();
    let cwd = std::env::temp_dir().join("mxsh-host-builtin-runtime");
    let expected_line = format!("toysh: hello @ {}", cwd.display());
    let mut shell = ShellBuilder::new()
        .shell_name("toysh")
        .current_dir(&cwd)
        .env("GREETING", "hello", VariableAttributes::EXPORT)
        .register_builtin("invoke", |context, _args| {
            let greeting = context.env_get("GREETING").unwrap_or("");
            let _ = context.write_stdout_line(&format!(
                "{}: {} @ {}",
                context.shell_name(),
                greeting,
                context.current_dir().display()
            ));
            7
        })
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    let result = shell.run(&mut runtime, "invoke");

    assert_eq!(result.status, 7);
    assert_eq!(stdout.collect(), format!("{expected_line}\n"));
}

#[test]
fn host_builtin_can_update_working_directory_and_environment() {
    let original_dir = std::env::temp_dir().join("mxsh-host-builtin-original");
    let updated_dir = std::env::temp_dir().join("mxsh-host-builtin-updated");
    let expected_original = original_dir.display().to_string();
    let updated_dir_for_builtin = updated_dir.clone();
    let mut shell = ShellBuilder::new()
        .current_dir(&original_dir)
        .register_builtin("rehome", move |context, _args| {
            let previous = context.current_dir().display().to_string();
            context.set_current_dir(updated_dir_for_builtin.clone());
            let _ = context.env_set("PREVIOUS_CWD", previous, VariableAttributes::empty());
            0
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    let result = shell.run(&mut runtime, "rehome");

    assert_eq!(result.status, 0);
    assert_eq!(
        shell.env_get("PREVIOUS_CWD"),
        Some(expected_original.as_str())
    );
    assert_eq!(shell.current_dir(), updated_dir.as_path());
}

#[test]
fn configs_can_install_distinct_builtin_registries() {
    let mut empty = ShellBuilder::new()
        .clear_builtins()
        .new_session()
        .expect("session should build");
    let custom_stdout = StringStdioOut::new();
    let mut custom = ShellBuilder::new()
        .clear_builtins()
        .register_builtin("hello", |context, args| {
            let _ = context.write_stdout_line(&args.join(" "));
            0
        })
        .stdio(StdioConfig {
            stdout: custom_stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let standard_stdout = StringStdioOut::new();
    let mut standard = ShellBuilder::new()
        .stdio(StdioConfig {
            stdout: standard_stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    assert_eq!(empty.run(&mut runtime, "echo missing").status, 127);
    assert_eq!(custom.run(&mut runtime, "hello custom builtin").status, 0);
    assert_eq!(
        standard.run(&mut runtime, "echo standard builtin").status,
        0
    );
    assert_eq!(custom_stdout.collect(), "custom builtin\n");
    assert_eq!(standard_stdout.collect(), "standard builtin\n");
}

#[test]
fn configs_can_reclassify_builtin_specialness() {
    let mut regular = ShellBuilder::new()
        .clear_builtins()
        .register_builtin("echo", |context, _args| {
            let value = context.env_get("X").unwrap_or("").to_string();
            let _ = context.env_set("SEEN", value, VariableAttributes::empty());
            0
        })
        .new_session()
        .expect("session should build");
    let mut special = ShellBuilder::new()
        .clear_builtins()
        .register_special_builtin("echo", |context, _args| {
            let value = context.env_get("X").unwrap_or("").to_string();
            let _ = context.env_set("SEEN", value, VariableAttributes::empty());
            0
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    assert_eq!(regular.run(&mut runtime, "X=1 echo").status, 0);
    assert_eq!(special.run(&mut runtime, "X=1 echo").status, 0);
    assert_eq!(regular.env_get("X"), None);
    assert_eq!(special.env_get("X"), Some("1"));
    assert_eq!(regular.env_get("SEEN"), Some("1"));
    assert_eq!(special.env_get("SEEN"), Some("1"));
}