use apexe::config::ApexeConfig;
use apexe::scanner::ScanOrchestrator;
use tempfile::TempDir;
fn test_config() -> (TempDir, ApexeConfig) {
let tmp = TempDir::new().unwrap();
let config = ApexeConfig {
modules_dir: tmp.path().join("modules"),
cache_dir: tmp.path().join("cache"),
config_dir: tmp.path().to_path_buf(),
audit_log: tmp.path().join("audit.jsonl"),
log_level: "warn".into(),
default_timeout: 30,
scan_depth: 2,
json_output_preference: true,
..ApexeConfig::default()
};
(tmp, config)
}
#[test]
#[ignore]
fn test_scan_git() {
let (_tmp, config) = test_config();
let orchestrator = ScanOrchestrator::new(config);
let outcome = orchestrator.scan(&["git".into()], true, 2);
assert!(outcome.failures.is_empty(), "{:?}", outcome.failures);
let results = outcome.tools;
assert_eq!(results.len(), 1);
let git = &results[0];
assert_eq!(git.name, "git");
assert!(git.version.is_some(), "git should report a version");
assert!(!git.binary_path.is_empty());
let subcmd_names: Vec<&str> = git.subcommands.iter().map(|s| s.name.as_str()).collect();
assert!(
subcmd_names.contains(&"commit"),
"Expected 'commit' in subcommands, found: {subcmd_names:?}"
);
assert!(
subcmd_names.contains(&"push"),
"Expected 'push' in subcommands"
);
assert!(
subcmd_names.contains(&"pull"),
"Expected 'pull' in subcommands"
);
assert!(
subcmd_names.contains(&"clone"),
"Expected 'clone' in subcommands"
);
assert!(
git.subcommands.len() > 20,
"Expected >20 subcommands, got {}",
git.subcommands.len()
);
if let Some(commit) = git.subcommands.iter().find(|s| s.name == "commit") {
let has_message = commit
.flags
.iter()
.any(|f| f.long_name.as_deref() == Some("--message"));
assert!(
has_message,
"Expected --message flag on git commit, flags: {:?}",
commit
.flags
.iter()
.map(|f| f.long_name.as_deref().unwrap_or("?"))
.collect::<Vec<_>>()
);
}
assert!(git.scan_tier >= 1);
}
#[test]
#[ignore]
fn test_scan_docker() {
let (_tmp, config) = test_config();
let orchestrator = ScanOrchestrator::new(config);
let outcome = orchestrator.scan(&["docker".into()], true, 2);
assert!(outcome.failures.is_empty(), "{:?}", outcome.failures);
let results = outcome.tools;
assert_eq!(results.len(), 1);
let docker = &results[0];
assert_eq!(docker.name, "docker");
assert!(docker.version.is_some());
let container = docker.subcommands.iter().find(|s| s.name == "container");
assert!(
container.is_some(),
"Expected 'container' subcommand in docker"
);
let container = container.unwrap();
let nested_names: Vec<&str> = container
.subcommands
.iter()
.map(|s| s.name.as_str())
.collect();
assert!(
nested_names.contains(&"ls") || nested_names.contains(&"list"),
"Expected 'ls' or 'list' in docker container subcommands, found: {nested_names:?}"
);
}
#[tokio::test]
async fn test_find_renders_options_before_the_path_and_predicates_after() {
use apcore::Module;
use apexe::adapter::CliToolConverter;
use apexe::module::CliModule;
let (tmp, config) = test_config();
let sandbox = tmp.path().join("sandbox");
std::fs::create_dir_all(&sandbox).unwrap();
std::fs::write(sandbox.join("wanted.txt"), b"x").unwrap();
std::fs::write(sandbox.join("ignored.log"), b"x").unwrap();
let sandbox = sandbox.to_str().unwrap();
let orchestrator = ScanOrchestrator::new(config);
let outcome = orchestrator.scan(&["find".into()], true, 1);
if outcome.tools.is_empty() {
eprintln!("skipping: no `find` on PATH");
return;
}
let tools = outcome.tools;
let Some(overlay) = tools[0].overlay.clone() else {
eprintln!(
"skipping: this host's `find` matches no shipped overlay, and operand \
placement is overlay-supplied only"
);
return;
};
let modules = CliToolConverter::new().convert(&tools[0]);
let module = CliModule::from_scanned(&modules[0], 10_000).expect("binding should parse");
let by_predicate = module
.execute(
serde_json::json!({ "path": [sandbox], "name": "*.txt" }),
&apcore::Context::anonymous(),
)
.await
.expect("find should execute");
assert_eq!(
by_predicate["exit_code"], 0,
"{overlay}: find rejected the predicate ordering: {by_predicate}"
);
let stdout = by_predicate["stdout"].as_str().unwrap_or_default();
assert!(
stdout.contains("wanted.txt") && !stdout.contains("ignored.log"),
"{overlay}: the predicate did not take effect: {by_predicate}"
);
let with_option = module
.execute(
serde_json::json!({ "path": [sandbox], "L": true, "name": "*.txt" }),
&apcore::Context::anonymous(),
)
.await
.expect("find should execute");
assert_eq!(
with_option["exit_code"], 0,
"{overlay}: find rejected `-L` — an option rendered after the path is \
'unknown primary or operator' (BSD) / 'unknown predicate' (GNU): {with_option}"
);
assert!(
with_option["stdout"]
.as_str()
.unwrap_or_default()
.contains("wanted.txt"),
"{overlay}: the predicate stopped working once an option was added: {with_option}"
);
}
#[test]
fn test_shipped_find_overlays_declare_operand_and_flag_placement() {
for variant in ["bsd", "gnu"] {
let path = format!("overlays/find@{variant}.json");
let raw = std::fs::read_to_string(&path).expect("overlay should be readable");
let doc: serde_json::Value = serde_json::from_str(&raw).expect("overlay should parse");
let path_operand = doc["positional_args"]
.as_array()
.expect("positional_args")
.iter()
.find(|arg| arg["name"] == "path")
.unwrap_or_else(|| panic!("{path}: no `path` operand"));
assert_eq!(
path_operand["before_flags"], true,
"{path}: `path` must render before the predicates"
);
let pre_operand: Vec<&str> = doc["flags"]
.as_array()
.expect("flags")
.iter()
.filter(|f| f["before_operands"] == true)
.filter_map(|f| f["short"].as_str().or_else(|| f["long"].as_str()))
.collect();
assert!(
pre_operand.contains(&"-L"),
"{path}: find's true options must render before the path, got {pre_operand:?}"
);
}
}
#[test]
fn test_shipped_overlays_mark_every_optional_value_flag() {
const OPTIONAL: &[(&str, &[&str])] = &[
(
"cp@gnu",
&[
"--backup",
"--preserve",
"--reflink",
"--update",
"--context",
],
),
("df@gnu", &["--output"]),
("diff@bsd", &["--context", "--unified", "--color"]),
("diff@gnu", &["--context", "--unified", "--color"]),
("grep@bsd", &["--context", "--color", "--colour"]),
("grep@gnu", &["--color", "--colour"]),
("ln@gnu", &["--backup"]),
("ls@bsd", &["--color"]),
("ls@gnu", &["--color", "--classify", "--hyperlink"]),
("mkdir@gnu", &["--context"]),
("mv@gnu", &["--backup", "--update"]),
("rm@gnu", &["--interactive", "--preserve-root"]),
("sort@apple", &["--check"]),
("tail@gnu", &["--follow"]),
("uniq@bsd", &["--all-repeated"]),
("uniq@gnu", &["--all-repeated", "--group"]),
("xargs@gnu", &["--eof", "--replace"]),
];
let mut marked = 0;
for (overlay, flags) in OPTIONAL {
for long in *flags {
let flag = overlay_flag(overlay, long);
assert_eq!(
flag["value_optional"], true,
"{overlay}: {long} takes an optional value, so it must render as \
`{long}=<value>`; without the marker the value becomes an operand"
);
marked += 1;
}
}
assert_eq!(
marked, 34,
"the verified set is 34 flags across 17 overlays"
);
}
#[test]
fn test_shipped_overlays_leave_required_value_flags_unmarked() {
const REQUIRED: &[(&str, &[&str])] = &[
("du@gnu", &["--time-style"]),
("grep@bsd", &["--after-context", "--before-context"]),
("grep@gnu", &["--context"]),
(
"ls@gnu",
&[
"--sort",
"--format",
"--time-style",
"--quoting-style",
"--indicator-style",
"--context",
],
),
("mv@gnu", &["--context"]),
("sort@apple", &["--sort"]),
("sort@gnu", &["--sort"]),
("uniq@bsd", &["--skip-fields", "--skip-chars"]),
];
for (overlay, flags) in REQUIRED {
for long in *flags {
let flag = overlay_flag(overlay, long);
assert!(
flag.get("value_optional").is_none(),
"{overlay}: {long} takes a required value (or none at all), so it must \
render as `{long} <value>`; marking it would break a spelling that works"
);
}
}
}
fn overlay_flag(overlay: &str, long: &str) -> serde_json::Value {
let path = format!("overlays/{overlay}.json");
let raw = std::fs::read_to_string(&path).expect("overlay should be readable");
let doc: serde_json::Value = serde_json::from_str(&raw).expect("overlay should parse");
doc["flags"]
.as_array()
.expect("flags")
.iter()
.find(|flag| flag["long"] == long)
.unwrap_or_else(|| panic!("{path}: no flag named {long}"))
.clone()
}
#[test]
fn test_graceful_degradation() {
let (_tmp, config) = test_config();
let orchestrator = ScanOrchestrator::new(config);
let outcome = orchestrator.scan(&["true".into()], true, 1);
assert!(outcome.failures.is_empty(), "{:?}", outcome.failures);
let tools = outcome.tools;
assert_eq!(tools.len(), 1);
let tool = &tools[0];
assert_eq!(tool.name, "true");
assert!(!tool.binary_path.is_empty());
}