use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
fn workspace_root() -> PathBuf {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let crate_dir = manifest.parent().expect("manifest dir has a parent");
let workspace = crate_dir.parent().expect("crate dir has a parent");
workspace.to_path_buf()
}
const TREE_BASE_ARGS: &[&str] = &[
"tree",
"-p",
"camel-cli",
"-e",
"features,no-dev",
"--prefix",
"none",
"--locked",
];
fn tree_command(extra_args: &[&str]) -> Command {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let mut command = Command::new(&cargo);
command
.args(TREE_BASE_ARGS)
.args(extra_args)
.env("CARGO_TERM_COLOR", "never")
.current_dir(workspace_root());
command
}
fn tree_lines(extra_args: &[&str]) -> Vec<String> {
let output = tree_command(extra_args)
.output()
.unwrap_or_else(|error| panic!("failed to spawn `cargo tree`: {error}"));
if !output.status.success() {
panic!(
"`cargo tree` failed with {}:\n{}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut lines: Vec<String> = stdout.lines().map(normalize_tree_line).collect();
lines.sort();
lines.dedup();
lines
}
fn tree_fails_with(extra_args: &[&str], fragment: &str) {
let output = tree_command(extra_args)
.output()
.unwrap_or_else(|error| panic!("failed to spawn `cargo tree`: {error}"));
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
panic!(
"`cargo tree` unexpectedly succeeded (expected failure naming \
`{fragment}`); status {}, stderr:\n{stderr}",
output.status
);
}
assert!(
stderr.contains(fragment),
"`cargo tree` failed with status {} but its stderr does not name \
`{fragment}`; stderr:\n{stderr}",
output.status
);
}
fn normalize_tree_line(line: &str) -> String {
strip_ansi_escapes(&strip_paren_paths(line))
.replace(" (*)", "")
.replace(" [*]", "")
}
fn strip_ansi_escapes(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(ch) = chars.next() {
if ch != '\x1b' {
out.push(ch);
continue;
}
match chars.next() {
Some('[') => {
for final_byte in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&final_byte) {
break;
}
}
}
Some(_) => {}
None => break,
}
}
out
}
fn strip_paren_paths(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(start) = rest.find(" (/") {
match rest[start..].find(')') {
Some(close) => {
out.push_str(&rest[..start]);
rest = &rest[start + close + 1..];
}
None => break,
}
}
out.push_str(rest);
out
}
fn golden_fixture_lines() -> Vec<String> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("default-deptree.txt");
let raw = fs::read_to_string(&path).unwrap_or_else(|error| {
panic!("failed to read golden fixture {}: {error}", path.display())
});
let mut lines: Vec<String> = raw.lines().map(str::to_string).collect();
lines.sort();
lines.dedup();
lines
}
fn assert_absent(lines: &[String], prefixes: &[&str], context: &str) {
let offenders: Vec<&String> = lines
.iter()
.filter(|line| prefixes.iter().any(|prefix| line.starts_with(prefix)))
.collect();
assert!(
offenders.is_empty(),
"{context}:\n{}",
offenders
.iter()
.map(|line| format!(" {line}"))
.collect::<Vec<_>>()
.join("\n")
);
}
fn is_lang_feature_edge(line: &str) -> bool {
line.starts_with("camel-core feature \"lang-")
|| line.starts_with("camel-core feature \"camel-language-")
}
#[test]
fn default_closure_matches_golden() {
let actual = tree_lines(&[]);
let expected = golden_fixture_lines();
let actual_set: HashSet<&String> = actual
.iter()
.filter(|line| !is_lang_feature_edge(line))
.collect();
let expected_set: HashSet<&String> = expected
.iter()
.filter(|line| !is_lang_feature_edge(line))
.collect();
if actual_set == expected_set {
for prefix in [
"camel-language-js v",
"camel-language-rhai v",
"camel-language-jsonpath v",
"camel-language-xpath v",
"camel-language-minijinja v",
] {
assert!(
actual.iter().any(|line| line.starts_with(prefix)),
"default closure must still contain `{prefix}`"
);
}
return;
}
let mut missing: Vec<&String> = expected_set.difference(&actual_set).copied().collect();
let mut extra: Vec<&String> = actual_set.difference(&expected_set).copied().collect();
missing.sort();
extra.sort();
let missing_total = missing.len();
let extra_total = extra.len();
let missing_block = diff_block(&missing, '-');
let extra_block = diff_block(&extra, '+');
panic!(
"default feature closure drifted from the golden fixture \
(crates/camel-cli/tests/fixtures/default-deptree.txt)\n\
only in golden [{missing_total} total, first 20]:\n{missing_block}\n\
only in current tree [{extra_total} total, first 20]:\n{extra_block}"
);
}
fn diff_block(lines: &[&String], marker: char) -> String {
lines
.iter()
.take(20)
.map(|line| format!(" {marker} {line}"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn mimalloc_stack_absent_under_all_features() {
let lines = tree_lines(&["--all-features"]);
assert_absent(
&lines,
&["mimalloc v", "libmimalloc-sys v"],
"mimalloc stack must be absent under --all-features",
);
}
#[test]
fn default_build_has_no_allocator_crate() {
let lines = tree_lines(&[]);
assert_absent(
&lines,
&[
"mimalloc v",
"libmimalloc-sys v",
"tikv-jemallocator v",
"tikv-jemalloc-sys v",
],
"default build must not contain any allocator crate",
);
}
const GRPC_PREFIX: &str = "camel-component-grpc v";
const SLIM_FORBIDDEN_PREFIXES: &[&str] = &[
"camel-component-kafka v",
GRPC_PREFIX,
"camel-component-wasm v",
"camel-component-llm v",
"camel-component-mcp v",
"camel-component-mqtt v",
"camel-component-surrealdb v",
"camel-component-exec v",
"camel-lsp v",
"tower-lsp v",
"camel-language-js v",
"camel-language-rhai v",
"camel-language-jsonpath v",
"camel-language-xpath v",
];
#[test]
fn slim_closure_excludes_controllable_set() {
let lines = tree_lines(&["--no-default-features"]);
assert_absent(
&lines,
SLIM_FORBIDDEN_PREFIXES,
"slim closure (--no-default-features) must exclude the controllable set",
);
}
#[test]
fn slim_plus_grpc_resolves_grpc_only() {
let lines = tree_lines(&["--no-default-features", "--features", "slim-http,grpc"]);
for prefix in [GRPC_PREFIX, "tonic v"] {
assert!(
lines.iter().any(|line| line.starts_with(prefix)),
"slim-http,grpc closure must contain `{prefix}`"
);
}
let other_thirteen: Vec<&str> = SLIM_FORBIDDEN_PREFIXES
.iter()
.copied()
.filter(|prefix| *prefix != GRPC_PREFIX)
.collect();
assert_absent(
&lines,
&other_thirteen,
"slim-http,grpc closure must still exclude the other thirteen forbidden prefixes",
);
}
#[test]
fn kafka_feature_table_implies_capability() {
let manifest = fs::read_to_string(workspace_root().join("crates/camel-cli/Cargo.toml"))
.expect("failed to read crates/camel-cli/Cargo.toml");
const DYNAMIC_LINKING_LINE: &str =
r#"dynamic-linking = ["kafka", "camel-component-kafka/dynamic-linking"]"#;
let mut in_features = false;
let mut kafka_lines: Vec<&str> = Vec::new();
for line in manifest.lines() {
if line.starts_with('[') {
in_features = line == "[features]";
continue;
}
if in_features && (line.starts_with("kafka") || line.starts_with("dynamic-linking")) {
kafka_lines.push(line);
}
}
assert_eq!(
kafka_lines,
vec![
r#"kafka = ["dep:camel-component-kafka", "camel-bundles/kafka", "camel-component-kafka/cmake-build"]"#,
DYNAMIC_LINKING_LINE,
],
"the kafka feature surface must be exactly `kafka` and `dynamic-linking`"
);
}
#[test]
fn dynamic_linking_closure_resolves_kafka() {
let lines = tree_lines(&["--no-default-features", "--features", "dynamic-linking"]);
assert!(
lines
.iter()
.any(|line| line.contains("camel-component-kafka")),
"dynamic-linking closure must contain `camel-component-kafka`"
);
let remaining: Vec<&str> = SLIM_FORBIDDEN_PREFIXES
.iter()
.copied()
.filter(|prefix| *prefix != "camel-component-kafka v")
.collect();
assert_absent(
&lines,
&remaining,
"dynamic-linking closure must still exclude the remaining controllable set",
);
}
#[test]
fn removed_kafka_feature_names_rejected() {
tree_fails_with(&["--features", "cmake-build"], "cmake-build");
tree_fails_with(&["--features", "kafka-static"], "kafka-static");
}