use bash_ast::{parse_to_ast, summary::summarise_bash_shape};
fn shape(src: &str) -> bash_ast::summary::BashShape {
let prog = parse_to_ast(src).unwrap_or_else(|e| panic!("parse failed for {:?}: {e:?}", src));
summarise_bash_shape(&prog)
}
#[test]
fn simple_command_primary_program() {
let s = shape("cargo check --workspace");
assert_eq!(s.primary_program.as_deref(), Some("cargo"));
assert_eq!(s.cwd_hint, None);
}
#[test]
fn cd_and_then_command_primary_program_and_cwd_hint() {
let s = shape("cd packages/yah/ui && bun run typecheck");
assert_eq!(s.primary_program.as_deref(), Some("bun"));
assert_eq!(s.cwd_hint.as_deref(), Some("packages/yah/ui"));
}
#[test]
fn double_cd_chain() {
let s = shape("cd /tmp && cd sub && cargo test");
assert_eq!(s.primary_program.as_deref(), Some("cargo"));
assert_eq!(s.cwd_hint.as_deref(), Some("sub"));
}
#[test]
fn leading_var_assignment_skipped() {
let s = shape("RUST_LOG=debug cargo run -p yah");
assert_eq!(s.primary_program.as_deref(), Some("cargo"));
}
#[test]
fn pipeline_primary_is_first_stage() {
let s = shape("cargo check 2>&1 | head -20");
assert_eq!(s.primary_program.as_deref(), Some("cargo"));
}
#[test]
fn all_programs_single() {
let s = shape("cargo build -p desktop");
assert_eq!(s.all_programs, vec!["cargo"]);
}
#[test]
fn all_programs_pipeline_deduped() {
let s = shape("cargo check | grep error | grep error");
assert_eq!(s.all_programs, vec!["cargo", "grep"]);
}
#[test]
fn all_programs_list() {
let s = shape("cargo check && bun run build");
assert_eq!(s.all_programs, vec!["cargo", "bun"]);
}
#[test]
fn list_ops_and() {
let s = shape("cargo check && echo done");
assert_eq!(s.list_ops, vec!["&&"]);
}
#[test]
fn list_ops_or() {
let s = shape("cargo check || echo failed");
assert_eq!(s.list_ops, vec!["||"]);
}
#[test]
fn list_ops_mixed_deduped() {
let s = shape("a && b && c || d");
assert!(s.list_ops.iter().any(|op| op == "&&"));
assert!(s.list_ops.iter().any(|op| op == "||"));
}
#[test]
fn pipeline_stages_single_command() {
let s = shape("cargo check");
assert_eq!(s.pipeline_stages, 1);
}
#[test]
fn pipeline_stages_two_stage() {
let s = shape("echo hi | grep hi");
assert_eq!(s.pipeline_stages, 2);
}
#[test]
fn pipeline_stages_three_stage() {
let s = shape("cat file.txt | grep foo | wc -l");
assert_eq!(s.pipeline_stages, 3);
}
#[test]
fn redirects_stderr_to_stdout() {
let s = shape("cargo build 2>&1");
assert!(s.redirects.contains(&">&".to_string()), "redirects={:?}", s.redirects);
}
#[test]
fn redirects_append() {
let s = shape("echo hi >> out.txt");
assert!(s.redirects.contains(&">>".to_string()), "redirects={:?}", s.redirects);
}
#[test]
fn no_redirects() {
let s = shape("cargo check");
assert!(s.redirects.is_empty());
}
#[test]
fn heredoc_detected() {
let s = shape("cat <<EOF\nhello\nEOF");
assert!(s.has_heredoc);
}
#[test]
fn no_heredoc() {
let s = shape("echo hello");
assert!(!s.has_heredoc);
}
#[test]
fn side_effect_git_push() {
let s = shape("git push origin main");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::GitWrite)));
}
#[test]
fn side_effect_git_commit() {
let s = shape("git commit -m 'fix'");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::GitWrite)));
}
#[test]
fn no_side_effect_git_log() {
let s = shape("git log --oneline -10");
assert!(s.side_effect.is_none(), "git log should not be classified as side-effect");
}
#[test]
fn no_side_effect_git_status() {
let s = shape("git status");
assert!(s.side_effect.is_none());
}
#[test]
fn side_effect_gh_pr_create() {
let s = shape("gh pr create --title 'My PR'");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::GitHub)));
}
#[test]
fn side_effect_gh_release_create() {
let s = shape("gh release create v1.0.0");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::GitHub)));
}
#[test]
fn no_side_effect_gh_pr_view() {
let s = shape("gh pr view 123");
assert!(s.side_effect.is_none(), "gh pr view should not trigger GitHub side-effect");
}
#[test]
fn side_effect_cargo_publish() {
let s = shape("cargo publish --dry-run");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::Publish)));
}
#[test]
fn no_side_effect_cargo_build() {
let s = shape("cargo build -p desktop");
assert!(s.side_effect.is_none());
}
#[test]
fn side_effect_rm() {
let s = shape("rm -rf /tmp/foo");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::Destructive)));
}
#[test]
fn side_effect_mkfs() {
let s = shape("mkfs.ext4 /dev/sdb");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::Destructive)));
}
#[test]
fn side_effect_curl_post() {
let s = shape("curl -X POST https://api.example.com/data");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::Network)));
}
#[test]
fn side_effect_curl_merged_delete() {
let s = shape("curl -XDELETE https://api.example.com/item/1");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::Network)));
}
#[test]
fn no_side_effect_curl_get() {
let s = shape("curl https://api.example.com/data");
assert!(s.side_effect.is_none(), "plain curl GET should not be a side-effect");
}
#[test]
fn side_effect_sudo() {
let s = shape("sudo systemctl restart myservice");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::SudoOrInstall)));
}
#[test]
fn side_effect_brew_install() {
let s = shape("brew install ripgrep");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::SudoOrInstall)));
}
#[test]
fn side_effect_apt_install() {
let s = shape("apt-get install -y curl");
assert!(matches!(s.side_effect, Some(bash_ast::summary::SideEffect::SudoOrInstall)));
}
#[test]
fn no_side_effect_brew_search() {
let s = shape("brew search ripgrep");
assert!(s.side_effect.is_none());
}
#[test]
fn kind_ls_is_search() {
use bash_ast::summary::ProgramKind;
let s = shape("ls -la src/");
assert_eq!(s.kind, Some(ProgramKind::Search));
}
#[test]
fn kind_grep_is_search() {
use bash_ast::summary::ProgramKind;
let s = shape("grep -r TODO .");
assert_eq!(s.kind, Some(ProgramKind::Search));
}
#[test]
fn kind_git_is_vcs() {
use bash_ast::summary::ProgramKind;
let s = shape("git status");
assert_eq!(s.kind, Some(ProgramKind::Vcs));
}
#[test]
fn kind_after_cd_strip() {
use bash_ast::summary::ProgramKind;
let s = shape("cd packages/yah/ui && bun run typecheck");
assert_eq!(s.kind, Some(ProgramKind::Build));
}
#[test]
fn kind_curl_is_network() {
use bash_ast::summary::ProgramKind;
let s = shape("curl https://api.example.com/data");
assert_eq!(s.kind, Some(ProgramKind::Network));
}
#[test]
fn kind_rm_is_destructive() {
use bash_ast::summary::ProgramKind;
let s = shape("rm -rf /tmp/foo");
assert_eq!(s.kind, Some(ProgramKind::Destructive));
}
#[test]
fn kind_unknown_program_is_none() {
let s = shape("flux capacitor --plutonium");
assert!(s.kind.is_none(), "unknown program should not be classified");
}
#[test]
fn kind_is_orthogonal_to_side_effect() {
use bash_ast::summary::{ProgramKind, SideEffect};
let s = shape("git push --force origin main");
assert_eq!(s.kind, Some(ProgramKind::Vcs));
assert_eq!(s.side_effect, Some(SideEffect::GitWrite));
}
#[test]
fn kind_peels_timeout_wrapper() {
use bash_ast::summary::ProgramKind;
let s = shape("timeout 30 git push origin main");
assert_eq!(s.kind, Some(ProgramKind::Vcs));
}
#[test]
fn kind_peels_env_wrapper() {
use bash_ast::summary::ProgramKind;
let s = shape("env RUST_LOG=debug cargo test");
assert_eq!(s.kind, Some(ProgramKind::Build));
}
#[test]
fn kind_substring_mcp_fallback() {
use bash_ast::summary::{ProgramKind, kind_for};
assert_eq!(kind_for("mcp-server-postgres"), Some(ProgramKind::Mcp));
assert_eq!(kind_for("claude-mcp"), Some(ProgramKind::Mcp));
assert_eq!(kind_for("FOO_MCP"), Some(ProgramKind::Mcp));
}
#[test]
fn kind_substring_yah_fallback() {
use bash_ast::summary::{ProgramKind, kind_for};
assert_eq!(kind_for("yah-yubaba"), Some(ProgramKind::Yah));
assert_eq!(kind_for("yah-camp"), Some(ProgramKind::Yah));
}
#[test]
fn kind_yah_wins_over_mcp() {
use bash_ast::summary::{ProgramKind, kind_for};
assert_eq!(kind_for("yah-image-mcp"), Some(ProgramKind::Yah));
}
#[test]
fn kind_exact_match_wins_over_substring() {
use bash_ast::summary::{ProgramKind, kind_for};
assert_eq!(kind_for("pwd"), Some(ProgramKind::Search));
}
#[test]
fn kind_mcp_substring_fallback() {
use bash_ast::summary::ProgramKind;
assert_eq!(
shape("mcp-server-postgres --port 7777").kind,
Some(ProgramKind::Mcp),
);
assert_eq!(
shape("foo-mcp run").kind,
Some(ProgramKind::Mcp),
);
assert_eq!(
shape("MCP-Inspector --target foo").kind,
Some(ProgramKind::Mcp),
);
}
#[test]
fn kind_mcp_does_not_match_unrelated_names() {
let s = shape("tmpcheck --quick");
assert!(s.kind.is_none());
let s = shape("compose up");
assert!(s.kind.is_none());
}
#[test]
fn kind_expanded_search_entries() {
use bash_ast::summary::ProgramKind;
for cmd in ["fd .", "bat README.md", "jq .name file.json", "ps aux", "htop"] {
assert_eq!(shape(cmd).kind, Some(ProgramKind::Search), "{cmd}");
}
}
#[test]
fn kind_expanded_build_entries() {
use bash_ast::summary::ProgramKind;
for cmd in ["go build ./...", "deno run main.ts"] {
let s = shape(cmd);
let expected = match cmd.split_whitespace().next().unwrap() {
"deno" => ProgramKind::ScriptingRuntime,
"go" => ProgramKind::Build,
_ => unreachable!(),
};
assert_eq!(s.kind, Some(expected), "{cmd}");
}
}
#[test]
fn kind_expanded_container_entries() {
use bash_ast::summary::ProgramKind;
for cmd in ["kubectl get pods", "helm install chart", "terraform apply"] {
assert_eq!(shape(cmd).kind, Some(ProgramKind::Container), "{cmd}");
}
}
#[test]
fn peel1_pure_assignment_stmts_skipped() {
let s = shape("pass=0\nfail=0\n./tests/run_replay_tests.sh 27");
assert_eq!(
s.primary_program.as_deref(),
Some("./tests/run_replay_tests.sh"),
"primary should be the test runner, not an assignment"
);
}
#[test]
fn peel2_for_loop_body_recursed() {
let s = shape("for i in 1 2 3; do\n ./tests/run_replay_tests.sh \"$i\"\ndone");
assert_eq!(
s.primary_program.as_deref(),
Some("./tests/run_replay_tests.sh"),
"primary should come from inside the for-loop body"
);
}
#[test]
fn peel2_while_loop_body_recursed() {
let s = shape("while true; do\n ./tests/run_replay_tests.sh 27\ndone");
assert_eq!(s.primary_program.as_deref(), Some("./tests/run_replay_tests.sh"));
}
#[test]
fn peel2_compound_body_recursed() {
let s = shape("{ ./tests/run_replay_tests.sh 27 2>&1; }");
assert_eq!(s.primary_program.as_deref(), Some("./tests/run_replay_tests.sh"));
}
#[test]
fn peel3_assign_from_cmdsub_unwrapped() {
let s = shape("out=$(VAR=1 ./tests/run_replay_tests.sh 27 2>&1)");
assert_eq!(
s.primary_program.as_deref(),
Some("./tests/run_replay_tests.sh"),
"primary should come from inside the command substitution"
);
}
#[test]
fn noisetable_repro_for_loop_with_assign_cmdsub() {
let s = shape(
"for i in $(seq 1 10); do\n out=$(VAR=1 ./tests/run_replay_tests.sh 27 2>&1)\ndone",
);
assert_eq!(
s.primary_program.as_deref(),
Some("./tests/run_replay_tests.sh"),
"primary should drill through for→assign→cmdsub to find the test runner"
);
}
#[test]
fn top_level_statements_simple() {
let src = "cargo check";
let s = shape(src);
assert_eq!(s.top_level_statements.len(), 1);
let slice = &s.top_level_statements[0];
assert_eq!(slice.kind, bash_ast::summary::StatementKind::Command);
assert_eq!(&src[slice.byte_start..slice.byte_end], "cargo check");
}
#[test]
fn top_level_statements_backslash_continuation_is_one() {
let src = "yah board open \\\n --kind feature \\\n --parent R196 \\\n --title 'foo'";
let s = shape(src);
assert_eq!(
s.top_level_statements.len(),
1,
"backslash-LF continuations must collapse: {:?}",
s.top_level_statements,
);
assert_eq!(s.top_level_statements[0].kind, bash_ast::summary::StatementKind::Command);
}
#[test]
fn top_level_statements_newline_joined_two_commands() {
let src = "cargo check\nbun run typecheck";
let s = shape(src);
assert_eq!(s.top_level_statements.len(), 2);
let [a, b] = [&s.top_level_statements[0], &s.top_level_statements[1]];
assert_eq!(&src[a.byte_start..a.byte_end], "cargo check");
assert_eq!(&src[b.byte_start..b.byte_end], "bun run typecheck");
}
#[test]
fn top_level_statements_pipeline_is_one() {
let src = "head -n 20 log | tail -3";
let s = shape(src);
assert_eq!(s.top_level_statements.len(), 1);
assert_eq!(s.top_level_statements[0].kind, bash_ast::summary::StatementKind::Pipeline);
}
#[test]
fn top_level_statements_list_is_one() {
let src = "cargo check && bun run build";
let s = shape(src);
assert_eq!(s.top_level_statements.len(), 1);
assert_eq!(s.top_level_statements[0].kind, bash_ast::summary::StatementKind::List);
}
#[test]
fn top_level_statements_quoted_newline_does_not_split() {
let src = "echo \"line1\nline2\"";
let s = shape(src);
assert_eq!(
s.top_level_statements.len(),
1,
"quoted newline must not split: {:?}",
s.top_level_statements,
);
}