#![cfg(all(feature = "embed", feature = "test-support", feature = "unix-runtime"))]
use std::io;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use mxsh::ShellBuilder;
use mxsh::advanced::{PlannedSimpleCommandKind, Planner};
use mxsh::ast::{AndOrList, Command, Pipeline, Program, SimpleCommand};
use mxsh::embed::{NamedFileDescriptor, StdioConfig};
use mxsh::policy::{StartupSources, VariableAttributes};
use mxsh::runtime::Runtime;
use mxsh::runtime::fd::FileDescriptor;
use mxsh::runtime::process::{
ExternalCommand, ProcessEvent, ProcessHandle, RuntimeSignal, SpawnMode, SpawnStdio,
SpawnedProcess, WaitMode,
};
use mxsh::runtime::testing::{DeterministicRuntime, InMemoryRuntime, StringStdioOut};
fn first_command(script: &str) -> Command {
let program = Program::parse(script).expect("script should parse");
let first = program.body().first().expect("script should not be empty");
let AndOrList::Pipeline(pipeline) = first.and_or_list() else {
panic!("expected a simple pipeline");
};
pipeline
.commands()
.first()
.expect("pipeline should contain a command")
.clone()
}
fn first_simple_command(script: &str) -> SimpleCommand {
match first_command(script) {
Command::Simple(command) => command,
other => panic!("expected simple command, got {other:?}"),
}
}
fn first_pipeline(script: &str) -> Pipeline {
let program = Program::parse(script).expect("script should parse");
let first = program.body().first().expect("script should not be empty");
let AndOrList::Pipeline(pipeline) = first.and_or_list() else {
panic!("expected a pipeline");
};
pipeline.clone()
}
#[derive(Clone, Default)]
struct ForkCountingRuntime {
forks: Arc<AtomicUsize>,
}
impl ForkCountingRuntime {
fn fork_count(&self) -> usize {
self.forks.load(Ordering::SeqCst)
}
}
impl Runtime for ForkCountingRuntime {
type ForegroundGuard = ();
fn fork(&self) -> Result<Self, io::Error> {
self.forks.fetch_add(1, Ordering::SeqCst);
Ok(self.clone())
}
fn spawn_external_command(
&mut self,
_command: &ExternalCommand,
_stdio: SpawnStdio,
_close_fds: &[FileDescriptor],
_mode: SpawnMode,
) -> Result<SpawnedProcess, io::Error> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"external spawning is not used in this test runtime",
))
}
fn wait_process(
&mut self,
_process: ProcessHandle,
_mode: WaitMode,
) -> Result<ProcessEvent, io::Error> {
Ok(ProcessEvent::Exited(0))
}
fn signal_process_group(
&mut self,
_process: ProcessHandle,
_signal: RuntimeSignal,
) -> Result<(), io::Error> {
Ok(())
}
fn claim_foreground(
&mut self,
_process: ProcessHandle,
_tty: FileDescriptor,
) -> Result<Self::ForegroundGuard, io::Error> {
Ok(())
}
fn release_foreground(&mut self, _guard: Self::ForegroundGuard) -> Result<(), io::Error> {
Ok(())
}
fn exec_replace(
&self,
_program: &str,
_argv: &[String],
_env: &[(String, String)],
_cwd: &Path,
) -> Result<(), io::Error> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"exec replacement is not used in this test runtime",
))
}
}
#[test]
fn one_config_creates_independent_sessions() {
let blueprint = ShellBuilder::new()
.register_builtin("remember", |ctx, args| {
let seen = args.first().cloned().unwrap_or_default();
ctx.env_set("SEEN", seen, VariableAttributes::empty());
0
})
.env("BASE", "present", VariableAttributes::empty())
.blueprint()
.expect("blueprint should build");
let mut left = blueprint.new_session();
let mut right = blueprint.new_session();
let mut runtime = InMemoryRuntime::new();
assert_eq!(left.run(&mut runtime, "remember left; LEFT=1").status, 0);
assert_eq!(right.run(&mut runtime, "remember right").status, 0);
assert_eq!(left.env_get("BASE"), Some("present"));
assert_eq!(right.env_get("BASE"), Some("present"));
assert_eq!(left.env_get("LEFT"), Some("1"));
assert_eq!(right.env_get("LEFT"), None);
assert_eq!(left.env_get("SEEN"), Some("left"));
assert_eq!(right.env_get("SEEN"), Some("right"));
}
#[test]
fn config_seeds_each_new_session() {
let blueprint = ShellBuilder::new()
.positional_parameters(["one", "two"])
.shell_name("toysh")
.alias("ll", "ls -l")
.function("wave", first_command("{ echo wave; }"))
.inherited_fd(7, FileDescriptor::STDERR)
.named_inherited_fd(NamedFileDescriptor::new("trace", 9, FileDescriptor::STDOUT))
.blueprint()
.expect("blueprint should build");
let mut left = blueprint.new_session();
let right = blueprint.new_session();
let mut runtime = InMemoryRuntime::new();
assert_eq!(
left.frame(),
["toysh".to_string(), "one".to_string(), "two".to_string()]
);
assert_eq!(left.alias("ll"), Some("ls -l"));
assert!(matches!(
left.function("wave"),
Some(Command::BraceGroup(_))
));
let left_fds = left.inherited_file_descriptors();
assert!(left_fds.contains(&(7, FileDescriptor::STDERR)));
assert!(left_fds.contains(&(9, FileDescriptor::STDOUT)));
assert_eq!(left.run(&mut runtime, "shift").status, 0);
assert_eq!(
right.frame(),
["toysh".to_string(), "one".to_string(), "two".to_string()]
);
assert_eq!(right.alias("ll"), Some("ls -l"));
assert!(matches!(
right.function("wave"),
Some(Command::BraceGroup(_))
));
let right_fds = right.inherited_file_descriptors();
assert!(right_fds.contains(&(7, FileDescriptor::STDERR)));
assert!(right_fds.contains(&(9, FileDescriptor::STDOUT)));
}
#[test]
fn config_seeds_startup_sources_for_each_new_session() {
let blueprint = ShellBuilder::new()
.startup_sources(StartupSources::new().with_env_file_var("TOYSH_ENV"))
.blueprint()
.expect("blueprint should build");
let left = blueprint.new_session();
let right = blueprint.new_session();
assert_eq!(left.startup_sources().env_file_var(), Some("TOYSH_ENV"));
assert_eq!(right.startup_sources().env_file_var(), Some("TOYSH_ENV"));
}
#[test]
fn detached_session_checkpoint_uses_explicit_ownership_boundaries() {
let stdout = StringStdioOut::new();
let mut session = ShellBuilder::new()
.stdio(StdioConfig {
stdout: stdout.fd(),
..StdioConfig::default()
})
.new_session()
.expect("session should build");
let mut runtime = DeterministicRuntime::new();
runtime.push_spawn(Some(1234), [], [ProcessEvent::Exited(0)]);
let setup = session.run(
&mut runtime,
"X=parent; alias hi='echo alias'; f(){ echo func; }; trap 'echo cleanup' EXIT; job &",
);
assert_eq!(setup.status, 0);
assert!(session.last_background_pid().is_some());
let checkpoint = session.detached_checkpoint();
let mut fork = checkpoint.new_session();
let forked = fork.run(
&mut runtime,
"echo fork-x=$X; hi; f; wait \"$!\" 2>/dev/null; echo fork-wait=$?; exit 0",
);
assert_eq!(forked.status, 0);
assert_eq!(forked.exit_code, Some(0));
let parent = session.run(&mut runtime, "wait \"$!\"; echo parent-wait=$?; exit 0");
assert_eq!(parent.status, 0);
assert_eq!(parent.exit_code, Some(0));
assert_eq!(
stdout.collect(),
"fork-x=parent\nalias\nfunc\nfork-wait=127\nparent-wait=0\ncleanup\n"
);
}
#[test]
fn config_language_policy_applies_to_each_fresh_session() {
let blueprint = ShellBuilder::new()
.alias("ll", "echo aliased")
.alias_expansion_enabled(false)
.blueprint()
.expect("blueprint should build");
let mut left = blueprint.new_session();
let mut right = blueprint.new_session();
let mut runtime = InMemoryRuntime::new();
assert_eq!(left.run(&mut runtime, "ll").status, 127);
assert_eq!(right.run(&mut runtime, "ll").status, 127);
let fresh = blueprint.new_session();
assert!(!fresh.language().alias_expansion_enabled());
assert!(!left.language().alias_expansion_enabled());
assert!(!right.language().alias_expansion_enabled());
}
#[test]
fn public_parse_resolve_plan_and_execute_pipeline_works() {
let stdout = StringStdioOut::new();
let mut session = ShellBuilder::default()
.stdio(StdioConfig {
stdout: stdout.fd(),
..StdioConfig::default()
})
.new_session()
.expect("session should build");
let mut runtime = InMemoryRuntime::new();
let command = first_simple_command("echo hello");
let plan = Planner::new(&mut session, &mut runtime)
.prepare_simple_command(&command)
.expect("command should resolve");
assert_eq!(plan.argv(), ["echo".to_string(), "hello".to_string()]);
assert!(matches!(
plan.kind(),
PlannedSimpleCommandKind::Builtin { .. }
));
let program = Program::parse("echo hello").expect("program should parse");
let plan = Planner::new(&mut session, &mut runtime).prepare(&program);
let result = Planner::new(&mut session, &mut runtime).execute_plan(&plan);
assert_eq!(result.status, 0);
assert_eq!(session.last_status(), 0);
assert_eq!(stdout.collect(), "hello\n");
}
#[test]
fn public_pipeline_plan_and_execute_works() {
let stdout = StringStdioOut::new();
let mut session = ShellBuilder::default()
.stdio(StdioConfig {
stdout: stdout.fd(),
..StdioConfig::default()
})
.new_session()
.expect("session should build");
let mut runtime = InMemoryRuntime::new();
runtime.register_command("emit", |argv, _env, _cwd, stdio| {
let _ = stdio.stdout_fd.write_line(&argv[1..].join(" "));
0
});
runtime.register_command("cat", |_argv, _env, _cwd, stdio| {
let input = stdio
.stdin_fd
.dup()
.expect("dup cat stdin")
.read_to_string()
.unwrap_or_default();
let _ = stdio.stdout_fd.write_str(&input);
0
});
let pipeline = first_pipeline("emit hello | cat");
let plan = Planner::new(&mut session, &mut runtime).prepare_pipeline(&pipeline);
let result = Planner::new(&mut session, &mut runtime).execute_pipeline(&plan);
assert_eq!(result.status, 0);
assert_eq!(session.last_status(), 0);
assert_eq!(stdout.collect(), "hello\n");
}
#[test]
fn pipeline_stage_expansion_side_effects_do_not_leak_to_parent_state() {
let stdout = StringStdioOut::new();
let mut session = ShellBuilder::default()
.stdio(StdioConfig {
stdout: stdout.fd(),
..StdioConfig::default()
})
.new_session()
.expect("session should build");
let mut runtime = InMemoryRuntime::new();
runtime.register_command("cat", |_argv, _env, _cwd, stdio| {
let input = stdio
.stdin_fd
.dup()
.expect("dup cat stdin")
.read_to_string()
.unwrap_or_default();
let _ = stdio.stdout_fd.write_str(&input);
0
});
let result = session.run(
&mut runtime,
"unset X; echo ${X:=foo} | cat; printf '<%s>\\n' \"$X\"",
);
assert_eq!(result.status, 0);
assert_eq!(session.env_get("X"), None);
assert_eq!(stdout.collect(), "foo\n<>\n");
}
#[test]
fn command_substitution_and_subshell_use_runtime_forking() {
let mut session = ShellBuilder::default()
.new_session()
.expect("session should build");
let mut runtime = ForkCountingRuntime::default();
let result = session.run(&mut runtime, "VALUE=$(echo hi); (:)");
assert_eq!(result.status, 0);
assert_eq!(session.env_get("VALUE"), Some("hi"));
assert_eq!(runtime.fork_count(), 2);
}