use std::path::PathBuf;
use alba_shell::{ShellEnv, ShellStream, execute, parse};
use tokio_util::sync::CancellationToken;
const HELPER_FLAG: &str = "--alba-shell-conformance-helper";
const HELPER_PLACEHOLDER: &str = "{HELPER}";
const HELPER_ECHO_VAR: &str = "ALBA_MARK";
async fn run_in(src: &str, cwd: PathBuf) -> (i32, Vec<(ShellStream, String)>) {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let program = parse(src).expect("parse");
let env = ShellEnv {
env: std::env::vars().collect(),
cwd,
output: tx,
cancel: CancellationToken::new(),
};
let result = execute(&program, env).await;
let mut lines = Vec::new();
while let Ok(line) = rx.try_recv() {
lines.push((line.stream, line.text));
}
(result.exit_code, lines)
}
fn stdout(lines: &[(ShellStream, String)]) -> Vec<&str> {
lines
.iter()
.filter(|(s, _)| *s == ShellStream::Stdout)
.map(|(_, t)| t.as_str())
.collect()
}
struct Case {
name: &'static str,
script: &'static str,
want_exit: i32,
want_stdout: &'static [&'static str],
}
const CASES: &[Case] = &[
Case {
name: "echo_joins",
script: "echo one two",
want_exit: 0,
want_stdout: &["one two"],
},
Case {
name: "quoting_preserves",
script: "echo 'a b' \"c d\"",
want_exit: 0,
want_stdout: &["a b c d"],
},
Case {
name: "var_roundtrip",
script: "X=alba; echo $X${X}",
want_exit: 0,
want_stdout: &["albaalba"],
},
Case {
name: "unset_var_is_empty",
script: "echo start${NOPE}end",
want_exit: 0,
want_stdout: &["startend"],
},
Case {
name: "last_exit_status",
script: "false; echo $?; true; echo $?",
want_exit: 0,
want_stdout: &["1", "0"],
},
Case {
name: "subst",
script: "echo $(echo inner)",
want_exit: 0,
want_stdout: &["inner"],
},
Case {
name: "glob_sorted_forward_slashes",
script: "echo sub/*.txt *.txt",
want_exit: 0,
want_stdout: &["sub/c.txt a.txt b.txt"],
},
Case {
name: "glob_skips_hidden_entries",
script: "echo *",
want_exit: 0,
want_stdout: &["a.txt b.txt sub"],
},
Case {
name: "glob_with_a_written_dot_matches_hidden",
script: "echo .h*",
want_exit: 0,
want_stdout: &[".hidden"],
},
Case {
name: "glob_trailing_slash_matches_directories",
script: "echo */",
want_exit: 0,
want_stdout: &["sub"],
},
Case {
name: "unmatched_glob_literal",
script: "echo *.zzz",
want_exit: 0,
want_stdout: &["*.zzz"],
},
Case {
name: "quoted_glob_literal",
script: "echo '*.txt'",
want_exit: 0,
want_stdout: &["*.txt"],
},
Case {
name: "pipeline",
script: "cat a.txt b.txt | cat",
want_exit: 0,
want_stdout: &["alpha", "beta"],
},
Case {
name: "pipeline_exit_is_last",
script: "false | true",
want_exit: 0,
want_stdout: &[],
},
Case {
name: "and_or",
script: "test -f a.txt && echo yes || echo no",
want_exit: 0,
want_stdout: &["yes"],
},
Case {
name: "test_bang",
script: "test ! -f missing.txt && [ ! -d missing ] && echo both",
want_exit: 0,
want_stdout: &["both"],
},
Case {
name: "negation",
script: "! test -f missing.txt",
want_exit: 0,
want_stdout: &[],
},
Case {
name: "redirect_then_cat",
script: "echo saved > out.txt && cat out.txt",
want_exit: 0,
want_stdout: &["saved"],
},
Case {
name: "append",
script: "echo 1 > o.txt; echo 2 >> o.txt; cat o.txt",
want_exit: 0,
want_stdout: &["1", "2"],
},
Case {
name: "stdin_redirect",
script: "cat < a.txt",
want_exit: 0,
want_stdout: &["alpha"],
},
Case {
name: "cp_mv_rm_roundtrip",
script: "cp a.txt c.txt && mv c.txt d.txt && rm d.txt && test -f a.txt",
want_exit: 0,
want_stdout: &[],
},
Case {
name: "clean_rebuild_dir",
script: "rm -r -f dist && mkdir dist && test -d dist",
want_exit: 0,
want_stdout: &[],
},
Case {
name: "clean_rebuild_dir_grouped_flags",
script: "rm -rf dist && mkdir dist && test -d dist",
want_exit: 0,
want_stdout: &[],
},
Case {
name: "exit_code_propagates",
script: "exit 4",
want_exit: 4,
want_stdout: &[],
},
Case {
name: "not_found_is_127",
script: "definitely-not-a-command-alba",
want_exit: 127,
want_stdout: &[],
},
Case {
name: "assignment_scoped_to_line",
script: "X=1; echo $X",
want_exit: 0,
want_stdout: &["1"],
},
Case {
name: "newline_is_semicolon",
script: "echo a\necho b",
want_exit: 0,
want_stdout: &["a", "b"],
},
Case {
name: "subst_keeps_interior_newlines",
script: "echo \"$(cat a.txt b.txt)\"",
want_exit: 0,
want_stdout: &["alpha", "beta"],
},
Case {
name: "subst_interior_newlines_split_unquoted",
script: "echo $(cat a.txt b.txt)",
want_exit: 0,
want_stdout: &["alpha beta"],
},
Case {
name: "tilde_expansion",
script: "HOME=/alba/home; echo ~/x ~ ~foo",
want_exit: 0,
want_stdout: &["/alba/home/x /alba/home ~foo"],
},
Case {
name: "external_command",
script: "ALBA_MARK=beacon '{HELPER}' --alba-shell-conformance-helper",
want_exit: 0,
want_stdout: &["external command ok", "beacon"],
},
Case {
name: "external_command_into_a_pipeline",
script: "'{HELPER}' --alba-shell-conformance-helper | cat",
want_exit: 0,
want_stdout: &["external command ok"],
},
Case {
name: "external_command_exit_code",
script: "'{HELPER}' --alba-shell-conformance-helper --fail || echo recovered",
want_exit: 0,
want_stdout: &["external command ok", "recovered"],
},
];
fn run_as_helper() -> i32 {
println!("external command ok");
if let Ok(mark) = std::env::var(HELPER_ECHO_VAR) {
println!("{mark}");
}
if std::env::args().any(|arg| arg == "--fail") {
return 3;
}
0
}
fn main() {
if std::env::args().any(|arg| arg == HELPER_FLAG) {
std::process::exit(run_as_helper());
}
let helper = std::env::current_exe().expect("the running test binary has a path");
let helper = helper.display().to_string();
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime")
.block_on(conformance(&helper));
println!("conformance: {} cases passed", CASES.len());
}
async fn conformance(helper: &str) {
for case in CASES {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "alpha\n").unwrap();
std::fs::write(dir.path().join("b.txt"), "beta\n").unwrap();
std::fs::write(dir.path().join(".hidden"), "shh\n").unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("sub/c.txt"), "gamma\n").unwrap();
let script = case.script.replace(HELPER_PLACEHOLDER, helper);
let (code, lines) = run_in(&script, dir.path().to_path_buf()).await;
assert_eq!(
code, case.want_exit,
"{}: exit code (lines: {lines:?})",
case.name
);
assert_eq!(stdout(&lines), case.want_stdout.to_vec(), "{}", case.name);
}
}