use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn init_creates_minimal_default_config() {
let temp = test_dir("init-minimal");
fs::create_dir_all(&temp).unwrap();
let output = Command::new(bin())
.current_dir(&temp)
.args(["config", "init"])
.output()
.unwrap();
assert_success(&output);
let text = fs::read_to_string(temp.join("bot-forge.toml")).unwrap();
assert_eq!(text, "catalog = \"rust-dev\"\n");
assert!(!text.contains("[policy]"));
assert!(!text.contains("[profiles."));
assert!(!text.contains("[[tools]]"));
assert!(!text.contains("internal-host"));
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn primary_config_requires_explicit_catalog() {
let temp = test_dir("config-selection");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
fs::write(&config, "catalog = \"rust-dev\"\n").unwrap();
let valid = Command::new(bin())
.args(["config", "validate", "--config", text(&config)])
.output()
.unwrap();
assert_success(&valid);
fs::write(&config, "").unwrap();
let missing = Command::new(bin())
.args(["config", "validate", "--config", text(&config)])
.output()
.unwrap();
assert_eq!(missing.status.code(), Some(2));
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn overlay_local_skill_source_is_relative_to_the_declaring_file() {
let temp = test_dir("overlay-local-skill");
let primary = temp.join("primary");
let site = temp.join("site");
fs::create_dir_all(site.join("skills")).unwrap();
fs::create_dir_all(&primary).unwrap();
let config = primary.join("bot-forge.toml");
let overlay = site.join("overlay.toml");
fs::write(&config, "catalog = \"rust-dev\"\n").unwrap();
fs::write(
&overlay,
"[profiles.minimal]\nadd = [\"local-skill\"]\n\n[[components]]\nid = \"local-skill\"\nkind = \"skill\"\nsource = \"./skills\"\nagents = [\"claude\"]\n",
)
.unwrap();
let output = Command::new(bin())
.args([
"plan",
"minimal",
"--format",
"json",
"--config",
text(&config),
"--overlay",
text(&overlay),
])
.output()
.unwrap();
assert_success(&output);
let plan: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let source = plan["components"]
.as_array()
.unwrap()
.iter()
.find(|component| component["id"] == "local-skill")
.and_then(|component| component["source"].as_str())
.unwrap();
assert_eq!(
Path::new(source).canonicalize().unwrap(),
site.join("skills").canonicalize().unwrap()
);
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn included_local_skill_source_is_relative_to_the_include_file() {
let temp = test_dir("include-local-skill");
let fragments = temp.join("fragments");
fs::create_dir_all(fragments.join("skills")).unwrap();
let config = temp.join("bot-forge.toml");
let include = fragments.join("skills.toml");
fs::write(
&config,
"catalog = \"rust-dev\"\ninclude = [\"fragments/skills.toml\"]\n",
)
.unwrap();
fs::write(
&include,
"[profiles.minimal]\nadd = [\"included-skill\"]\n\n[[components]]\nid = \"included-skill\"\nkind = \"skill\"\nsource = \"./skills\"\nagents = [\"claude\"]\n",
)
.unwrap();
let output = Command::new(bin())
.args([
"plan",
"minimal",
"--format",
"json",
"--config",
text(&config),
])
.output()
.unwrap();
assert_success(&output);
let plan: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let source = plan["components"]
.as_array()
.unwrap()
.iter()
.find(|component| component["id"] == "included-skill")
.and_then(|component| component["source"].as_str())
.unwrap();
assert_eq!(
Path::new(source).canonicalize().unwrap(),
fragments.join("skills").canonicalize().unwrap()
);
fs::remove_dir_all(temp).unwrap();
}
#[cfg(target_os = "macos")]
#[test]
fn only_rejects_a_profile_component_unsupported_on_the_host() {
let output = Command::new(bin())
.args([
"plan",
"advanced",
"--only",
"cargo-valgrind",
"--format",
"json",
])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
assert!(output.stderr.is_empty());
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(
value["error"]["message"]
.as_str()
.unwrap()
.contains("does not support the current platform")
);
}
#[test]
fn no_arguments_prints_help_when_noninteractive() {
let output = Command::new(bin()).output().unwrap();
assert_success(&output);
assert!(
String::from_utf8_lossy(&output.stdout).contains("Usage bot-forge [OPTIONS] <COMMAND>")
);
}
#[test]
fn root_help_lists_command_names_without_usage_syntax() {
let output = Command::new(bin()).arg("--help").output().unwrap();
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
for command in [
"config",
"plan",
"install",
"resume",
"remove",
"status",
"cache",
"doctor",
"apt-mirror",
"generate",
] {
assert!(
stdout
.lines()
.any(|line| line.trim_start().starts_with(command)),
"command={command} stdout={stdout}"
);
}
for syntax in ["config <COMMAND>", "plan [OPTIONS]", "remove [OPTIONS]"] {
assert!(!stdout.contains(syntax), "syntax={syntax} stdout={stdout}");
}
}
#[test]
fn root_help_orders_installation_commands_before_configuration() {
let output = Command::new(bin()).arg("--help").output().unwrap();
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.find(" install").unwrap() < stdout.find(" config").unwrap());
assert!(stdout.find(" plan").unwrap() < stdout.find(" config").unwrap());
assert!(stdout.find(" status").unwrap() < stdout.find(" config").unwrap());
}
#[test]
fn effective_config_uses_the_shared_verbose_short_alias() {
let output = Command::new(bin())
.args(["config", "effective", "--help"])
.output()
.unwrap();
assert_success(&output);
assert!(String::from_utf8_lossy(&output.stdout).contains("-v, --verbose"));
}
#[test]
fn help_command_opens_nested_command_help() {
let output = Command::new(bin())
.args(["help", "config", "effective"])
.output()
.unwrap();
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("BotForge config effective"));
assert!(stdout.contains("Usage bot-forge config effective [OPTIONS]"));
}
#[test]
fn conventional_nested_help_uses_the_same_renderer() {
let output = Command::new(bin())
.args(["config", "init", "help"])
.output()
.unwrap();
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("BotForge config init"));
assert!(stdout.contains("Usage bot-forge config init [OPTIONS]"));
}
#[test]
fn conventional_help_ignores_leaf_positionals() {
let output = Command::new(bin())
.args(["plan", "standard", "help"])
.output()
.unwrap();
assert_success(&output);
assert!(String::from_utf8_lossy(&output.stdout).contains("BotForge plan"));
}
#[test]
fn every_help_path_groups_business_options() {
let commands: &[&[&str]] = &[
&["config"],
&["config", "init"],
&["config", "validate"],
&["config", "effective"],
&["config", "explain"],
&["plan"],
&["install"],
&["resume"],
&["remove"],
&["status"],
&["cache"],
&["cache", "status"],
&["cache", "gc"],
&["doctor"],
&["apt-mirror"],
&["apt-mirror", "show"],
&["apt-mirror", "check"],
&["apt-mirror", "apply"],
&["apt-mirror", "restore"],
&["generate"],
&["generate", "completion"],
&["generate", "man"],
&["generate", "schema"],
&["generate", "json"],
&["generate", "jsonl"],
];
for path in commands {
let output = Command::new(bin())
.args(path.iter().copied().chain(std::iter::once("--help")))
.output()
.unwrap();
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&format!("Usage bot-forge {}", path.join(" "))),
"path={path:?} stdout={stdout}"
);
assert!(!stdout.contains("\nOptions\n"), "path={path:?}");
assert!(!stdout.contains("-h, --help"), "path={path:?}");
assert!(stdout.lines().all(|line| line == line.trim_end()));
}
}
#[test]
fn nested_help_paths_are_consistent() {
for path in [&["config"][..], &["cache"][..], &["apt-mirror"][..]] {
let direct = Command::new(bin())
.args(path.iter().copied().chain(std::iter::once("help")))
.output()
.unwrap();
assert_success(&direct);
let indirect = Command::new(bin())
.args(std::iter::once("help").chain(path.iter().copied()))
.output()
.unwrap();
assert_success(&indirect);
assert_eq!(direct.stdout, indirect.stdout, "path={path:?}");
assert!(String::from_utf8_lossy(&direct.stdout).contains("help"));
}
}
#[test]
fn invalid_help_paths_are_rejected() {
for args in [
&["config", "unknown", "--help"][..],
&["generate", "unknown", "--help"][..],
&["help", "generate", "unknown"][..],
] {
let output = Command::new(bin()).args(args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "args={args:?}");
}
}
#[test]
fn help_does_not_accept_unknown_or_extra_arguments() {
for args in [
["plan", "--bogus", "--help"].as_slice(),
["install", "--bogus", "--help"].as_slice(),
["config", "validate", "--bogus", "--help"].as_slice(),
["cache", "gc", "--bogus", "--help"].as_slice(),
["remove", "one", "two", "--help"].as_slice(),
["generate", "json", "extra", "--help"].as_slice(),
] {
let output = Command::new(env!("CARGO_BIN_EXE_bot-forge"))
.args(args)
.output()
.expect("invalid help invocation should execute");
assert_eq!(output.status.code(), Some(2), "accepted {args:?}");
}
}
#[test]
fn shared_configuration_and_preview_flags_use_canonical_names() {
let init = Command::new(bin())
.args(["config", "init", "--help"])
.output()
.unwrap();
assert_success(&init);
let init = String::from_utf8_lossy(&init.stdout);
assert!(init.contains("--output <FILE>"));
assert!(init.contains("\nOutput\n"));
assert!(init.contains("\nExecution\n"));
let plan = Command::new(bin())
.args(["plan", "--help"])
.output()
.unwrap();
assert_success(&plan);
let plan = String::from_utf8_lossy(&plan.stdout);
assert!(plan.contains("--config <FILE>"));
assert!(plan.contains("--overlay <FILE>"));
assert!(plan.contains("--format <FORMAT>"));
assert!(!plan.contains("--json"));
for path in [&["remove"][..], &["cache", "gc"][..]] {
let output = Command::new(bin())
.args(path.iter().copied().chain(std::iter::once("--help")))
.output()
.unwrap();
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("--dry-run"), "path={path:?}");
assert!(!stdout.contains("--plan"), "path={path:?}");
}
}
#[test]
fn help_describes_repeatable_selection_and_resume_profile() {
let plan = Command::new(bin())
.args(["plan", "--help"])
.output()
.unwrap();
assert_success(&plan);
let plan = String::from_utf8_lossy(&plan.stdout);
assert!(plan.contains("--only <NAME>"));
assert!(plan.contains("Include only a component; repeat as needed"));
assert!(plan.contains("--exclude <NAME>"));
assert!(plan.contains("Exclude a component; repeat as needed"));
let resume = Command::new(bin())
.args(["resume", "--help"])
.output()
.unwrap();
assert_success(&resume);
let resume = String::from_utf8_lossy(&resume.stdout);
assert!(resume.contains("Usage bot-forge resume [OPTIONS] [PROFILE]"));
assert!(resume.contains("Original profile"));
assert!(resume.contains("uses the journal value when omitted"));
assert!(resume.contains("legacy journals"));
}
#[test]
fn plan_allows_explicit_human_output_with_explanations() {
let output = Command::new(bin())
.args(["plan", "standard", "--format", "human", "--why"])
.output()
.unwrap();
assert_success(&output);
assert!(String::from_utf8_lossy(&output.stdout).contains("bot-forge plan explain"));
}
#[test]
fn enum_options_use_semantic_value_names() {
let output = Command::new(bin())
.args(["remove", "--help"])
.output()
.unwrap();
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("--kind <KIND>"));
assert!(stdout.contains("tool or skill"));
assert!(!stdout.contains("<tool|skill>"));
}
#[test]
fn config_init_writes_to_the_shared_output_option() {
let root = test_dir("config-init-output");
fs::create_dir_all(&root).unwrap();
let output_path = root.join("custom.toml");
let output = Command::new(bin())
.args(["config", "init", "--output", text(&output_path)])
.output()
.unwrap();
assert_success(&output);
assert!(output_path.is_file());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn shared_value_options_accept_equals_syntax() {
let temp = test_dir("equals-options");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("bot-forge.toml");
fs::write(&config, fixture("demo")).unwrap();
let output = Command::new(bin())
.args([
"plan",
"smoke",
&format!("--config={}", text(&config)),
"--format=json",
])
.output()
.unwrap();
assert_success(&output);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap();
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn generated_raw_formats_are_undecorated() {
for format in ["json", "jsonl", "schema", "completion", "man"] {
let output = Command::new(bin())
.args(["generate", format])
.output()
.unwrap();
assert_success(&output);
assert!(!output.stdout.is_empty());
assert!(!output.stdout.contains(&0x1b));
assert!(output.stderr.is_empty());
}
for removed in ["toml", "markdown"] {
let output = Command::new(bin())
.args(["generate", removed])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
}
let extra = Command::new(bin())
.args(["generate", "json", "extra"])
.output()
.unwrap();
assert_eq!(extra.status.code(), Some(2));
}
#[test]
fn commands_reject_missing_actions_and_ignored_arguments() {
for command in ["config", "cache"] {
let output = Command::new(bin()).arg(command).output().unwrap();
assert_eq!(
output.status.code(),
Some(2),
"{command} accepted no action"
);
}
for command in ["help", "--help", "--version"] {
let output = Command::new(bin())
.args([command, "extra"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2), "{command} ignored arguments");
}
for args in [
&["resume", "--unknown"][..],
&["resume", "NOT_VALID"][..],
&["resume", "--format", "json"][..],
] {
let output = Command::new(bin()).args(args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "resume accepted {args:?}");
}
}
#[test]
fn read_only_commands_reject_execution_only_or_conflicting_flags() {
for args in [
&["plan", "smoke", "--yes"][..],
&["install", "smoke", "--force"][..],
&["plan", "smoke", "--quiet"][..],
&["plan", "smoke", "--format", "json", "--why"][..],
&["install", "smoke", "--dry-run"][..],
&["install", "smoke", "--verbose"][..],
&["status", "smoke", "standard"][..],
&["config", "validate", "--verbose"][..],
&["config", "explain", "--show-sensitive"][..],
&["resume", "--run", "one", "--run", "two"][..],
&["resume", "--run", "one", "--abandon", "two"][..],
&["resume", "standard", "--abandon", "one"][..],
&["resume", "--config", "custom.toml", "--abandon", "one"][..],
&["remove", "demo", "--dry-run", "--yes"][..],
] {
let output = Command::new(bin()).args(args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "accepted {args:?}");
}
}
#[test]
fn non_repeatable_options_are_rejected_before_execution() {
for args in [
&["remove", "missing", "--dry-run", "--dry-run"][..],
&["config", "init", "-f", "--force"][..],
&["install", "smoke", "-q", "--quiet"][..],
&["doctor", "--format", "human", "--format", "json"][..],
&["install", "--help", "--help"][..],
] {
let output = Command::new(bin()).args(args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "accepted {args:?}");
if args.contains(&"json") {
assert!(output.stderr.is_empty(), "args={args:?}");
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(
value["error"]["message"]
.as_str()
.unwrap()
.contains("cannot be used multiple times")
);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cannot be used multiple times"),
"args={args:?}: {stderr}"
);
}
}
}
#[test]
fn sensitive_output_help_states_its_prerequisite() {
let output = Command::new(bin())
.args(["config", "effective", "--help"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("--show-sensitive"));
assert!(stdout.contains("requires --verbose"));
}
#[test]
fn config_commands_report_overlay_origins() {
let temp = test_dir("config-overlay");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
let overlay = temp.join("site.toml");
fs::write(
&config,
"catalog = \"rust-dev\"\n[policy]\nmax_parallel = 3\n",
)
.unwrap();
fs::write(&overlay, "[policy]\nmax_downloads = 2\n").unwrap();
let validate = Command::new(bin())
.args([
"config",
"validate",
"--config",
text(&config),
"--overlay",
text(&overlay),
])
.output()
.unwrap();
assert_success(&validate);
let validate_stdout = String::from_utf8_lossy(&validate.stdout);
assert!(validate_stdout.contains("bot-forge config validate"));
assert!(validate_stdout.contains("Catalog"));
assert!(validate_stdout.contains("rust-dev"));
let effective = Command::new(bin())
.args([
"config",
"effective",
"--config",
text(&config),
"--overlay",
text(&overlay),
])
.output()
.unwrap();
assert_success(&effective);
let stdout = String::from_utf8_lossy(&effective.stdout);
assert!(stdout.contains("max_parallel = 3"));
assert!(stdout.contains("max_downloads = 2"));
assert!(stdout.contains("rust-analyzer"));
assert!(stdout.contains("miri"));
assert!(!stdout.contains("tools ="));
let explain = Command::new(bin())
.args([
"config",
"explain",
"--config",
text(&config),
"--overlay",
text(&overlay),
])
.output()
.unwrap();
assert_success(&explain);
let explain_stdout = String::from_utf8_lossy(&explain.stdout);
let compact = explain_stdout.split_whitespace().collect::<String>();
assert!(compact.contains(&overlay.display().to_string()));
assert!(explain_stdout.contains("builtin:rust-dev"));
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn profile_modifiers_change_catalog_profiles_without_copying_them() {
let temp = test_dir("profile-modifiers");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
let overlay = temp.join("site.toml");
fs::write(&config, "catalog = \"rust-dev\"\n").unwrap();
fs::write(&overlay, "[profiles.advanced]\nremove = [\"miri\"]\n").unwrap();
let effective = Command::new(bin())
.args([
"config",
"effective",
"--config",
text(&config),
"--overlay",
text(&overlay),
])
.output()
.unwrap();
assert_success(&effective);
let stdout = String::from_utf8_lossy(&effective.stdout);
let document: toml::Value = toml::from_str(&stdout).unwrap();
let advanced = document["profiles"]["advanced"]["components"]
.as_array()
.unwrap()
.iter()
.map(|value| value.as_str().unwrap())
.collect::<Vec<_>>();
assert!(!advanced.contains(&"miri"));
assert!(advanced.contains(&"rust-analyzer"));
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn overlay_named_source_changes_canonical_coordinates_and_origin() {
let temp = test_dir("named-source-overlay");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
let overlay = temp.join("site.toml");
fs::write(
&config,
r#"catalog = "rust-dev"
[sources.company]
kind = "cargo-registry"
url = "https://packages.example/original/index"
[[components]]
id = "cargo-audit"
[components.install]
backend = "cargo"
crate = "cargo-audit"
version = "=0.21.2"
source = "company"
bins = ["cargo-audit"]
"#,
)
.unwrap();
fs::write(
&overlay,
"[sources.company]\nurl = \"https://packages.example/site/index\"\n",
)
.unwrap();
let effective = Command::new(bin())
.args([
"config",
"effective",
"--config",
text(&config),
"--overlay",
text(&overlay),
])
.output()
.unwrap();
assert_success(&effective);
let stdout = String::from_utf8_lossy(&effective.stdout);
assert!(stdout.contains("index+https://packages.example/site/index"));
assert!(!stdout.contains("index+https://packages.example/original/index"));
let explain = Command::new(bin())
.args([
"config",
"explain",
"--config",
text(&config),
"--overlay",
text(&overlay),
])
.output()
.unwrap();
assert_success(&explain);
let stdout = String::from_utf8_lossy(&explain.stdout);
assert!(stdout.contains("components.cargo-audit.install.source"));
assert!(stdout.contains("expanded from sources.company"));
let compact = stdout.split_whitespace().collect::<String>();
assert!(compact.contains(&overlay.display().to_string()));
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn overlays_cannot_redeclare_the_catalog_trust_root() {
let temp = test_dir("overlay-trust-root");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
let overlay = temp.join("site.toml");
fs::write(&config, "catalog = \"rust-dev\"\n").unwrap();
for source in ["catalog = \"rust-dev\"\n", "catalog-digest = \"forged\"\n"] {
fs::write(&overlay, source).unwrap();
let output = Command::new(bin())
.args([
"config",
"validate",
"--config",
text(&config),
"--overlay",
text(&overlay),
])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
}
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn config_rejects_removed_version_and_unknown_fields() {
let temp = test_dir("reject-version-field");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
fs::write(&config, "schema_version = 1\nunknown = true\n").unwrap();
let output = Command::new(bin())
.args(["config", "validate", "--config", text(&config)])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn malicious_supply_chain_coordinates_fail_before_execution() {
let cases = [
r#"catalog = "rust-dev"
[profiles.smoke]
components = ["bad"]
[[components]]
id = "bad"
platforms = ["*"]
[components.install]
backend = "archive"
url = "http://example.invalid/tool.zip"
sha256 = "bad"
format = "zip"
target = "/tmp/escape"
"#,
r#"catalog = "rust-dev"
[profiles.smoke]
components = ["bad"]
[[components]]
id = "bad"
platforms = ["*"]
[components.install]
backend = "git"
url = "https://example.invalid/tool.git"
revision = "main"
"#,
r#"catalog = "rust-dev"
[profiles.smoke]
components = ["bad"]
[[components]]
id = "bad"
platforms = ["*"]
[components.install]
backend = "shell"
command = "touch should-not-exist"
resources = ["test"]
"#,
];
for (index, source) in cases.iter().enumerate() {
let temp = test_dir(&format!("malicious-{index}"));
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
fs::write(&config, source).unwrap();
let output = Command::new(bin())
.current_dir(&temp)
.args(["plan", "smoke", "--config", text(&config)])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
assert!(!temp.join("should-not-exist").exists());
fs::remove_dir_all(temp).unwrap();
}
}
#[test]
fn effective_config_redacts_secrets_unless_both_flags_are_set() {
let temp = test_dir("redaction");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
fs::write(
&config,
r#"catalog = "rust-dev"
[[environment.mutations]]
id = "api-token"
kind = "variable"
name = "API_TOKEN"
value = "super-secret"
"#,
)
.unwrap();
let hidden = Command::new(bin())
.args(["config", "effective", "--config", text(&config)])
.output()
.unwrap();
assert_success(&hidden);
assert!(!String::from_utf8_lossy(&hidden.stdout).contains("super-secret"));
let shown = Command::new(bin())
.args([
"config",
"effective",
"--verbose",
"--show-sensitive",
"--config",
text(&config),
])
.output()
.unwrap();
assert_success(&shown);
assert!(String::from_utf8_lossy(&shown.stdout).contains("super-secret"));
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn plan_and_status_json_are_machine_readable() {
let temp = test_dir("plan-json");
let home = temp.join("home");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
fs::write(&config, fixture("demo")).unwrap();
for command in ["plan", "status"] {
let output = isolated_bin(&home)
.args([
command,
"smoke",
"--format",
"json",
"--config",
text(&config),
])
.output()
.unwrap();
assert_success(&output);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["profile"], "smoke");
assert!(value.get("schema_version").is_none());
if command == "plan" {
assert!(
value["plan_hash"]
.as_str()
.is_some_and(|hash| hash.len() == 64)
);
}
}
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn status_previews_the_same_dependency_closure_as_plan() {
let temp = test_dir("status-plan-parity");
let home = temp.join("home");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
fs::write(
&config,
r#"catalog = "rust-dev"
[profiles.smoke]
components = ["consumer"]
[[components]]
id = "consumer"
requires = ["capability:demo-runtime"]
platforms = ["*"]
[components.install]
backend = "apt"
packages = ["consumer"]
[[components]]
id = "provider"
provides = ["demo-runtime"]
platforms = ["*"]
[components.install]
backend = "apt"
packages = ["provider"]
"#,
)
.unwrap();
let status = isolated_bin(&home)
.args([
"status",
"smoke",
"--format",
"json",
"--config",
text(&config),
])
.output()
.unwrap();
assert_success(&status);
let value: serde_json::Value = serde_json::from_slice(&status.stdout).unwrap();
let names = value["tools"]
.as_array()
.unwrap()
.iter()
.map(|tool| tool["name"].as_str().unwrap())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(names, ["consumer", "provider"].into_iter().collect());
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn machine_output_modes_do_not_grant_install_authorization() {
let temp = test_dir("machine-output-authorization");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
fs::write(&config, fixture("demo")).unwrap();
for mode in [&["--format", "json"][..], &["--quiet"][..]] {
let output = Command::new(bin())
.args(["install", "smoke"])
.args(mode)
.args(["--config", text(&config)])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3));
if mode.contains(&"json") {
assert!(output.stderr.is_empty());
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(value["error"]["hint"].as_str().unwrap().contains("--yes"));
} else {
assert!(String::from_utf8_lossy(&output.stderr).contains("--yes"));
}
}
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn machine_output_errors_remain_machine_readable() {
for args in [
&["plan", "--format", "json", "--unknown"][..],
&["install", "--format=jsonl", "--unknown"][..],
] {
let output = Command::new(bin()).args(args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "args={args:?}");
assert!(output.stderr.is_empty(), "args={args:?}");
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["ok"], false);
assert_eq!(value["error"]["code"], "BF1002");
}
}
#[test]
fn plan_why_explains_selection_resources_and_origin() {
let temp = test_dir("plan-why");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
fs::write(&config, fixture("demo")).unwrap();
let output = Command::new(bin())
.args(["plan", "smoke", "--why", "--config", text(&config)])
.output()
.unwrap();
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
for expected in [
"Requested by",
"profile:smoke",
"Provider",
"Resources",
"Origin",
] {
assert!(stdout.contains(expected), "missing {expected}:\n{stdout}");
}
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn resume_abandons_a_selected_pending_run() {
let temp = test_dir("resume-abandon");
let home = temp.join("home");
let run = home.join("journals").join("run-1");
fs::create_dir_all(&run).unwrap();
fs::write(
run.join("demo.json"),
r#"{
"run_id": "run-1",
"component": "demo",
"plan_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"config_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"phase": "prepared",
"artifact_id": null,
"previous_artifact_id": null,
"activation_paths": [],
"updated_at": 1
}"#,
)
.unwrap();
let output = isolated_bin(&home)
.args(["resume", "--abandon", "run-1"])
.output()
.unwrap();
assert_success(&output);
let checkpoint = fs::read_to_string(run.join("demo.json")).unwrap();
assert!(checkpoint.contains(r#""phase": "abandoned""#));
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn cache_status_and_gc_preview_are_safe_and_machine_readable() {
let temp = test_dir("cache");
let home = temp.join("home");
fs::create_dir_all(home.join("artifacts").join("unused")).unwrap();
fs::create_dir_all(home.join("cache/downloads")).unwrap();
fs::create_dir_all(home.join("cache/quarantine")).unwrap();
fs::create_dir_all(home.join("cache/cargo/sources/source-digest")).unwrap();
fs::create_dir_all(home.join("work/cargo/demo/staging/fingerprint")).unwrap();
fs::create_dir_all(home.join("work/cargo/demo/targets/fingerprint")).unwrap();
fs::create_dir_all(home.join("logs")).unwrap();
fs::write(home.join("cache/downloads/payload"), "download").unwrap();
fs::write(home.join("cache/quarantine/corrupt"), "corrupt").unwrap();
fs::write(home.join("logs/install.json"), "managed-log").unwrap();
let status = isolated_bin(&home)
.args(["cache", "status", "--format", "json"])
.output()
.unwrap();
assert_success(&status);
let value: serde_json::Value = serde_json::from_slice(&status.stdout).unwrap();
assert_eq!(value["artifact_count"], 1);
assert_eq!(value["download_count"], 1);
assert_eq!(value["quarantine_count"], 1);
assert_eq!(value["cargo_source_caches"], 1);
assert_eq!(value["cargo_work_shards"], 2);
assert!(value["bytes"].as_u64().unwrap() >= 26);
assert!(value["oldest_modified"].as_u64().is_some());
assert!(value["newest_modified"].as_u64().is_some());
let classified = value["classes"]
.as_object()
.unwrap()
.values()
.map(|class| class["logical_bytes"].as_u64().unwrap())
.sum::<u64>();
assert!(value["bytes"].as_u64().unwrap() > classified);
let preview = isolated_bin(&home)
.args(["cache", "gc", "--dry-run", "--max-age-days", "0"])
.output()
.unwrap();
assert_success(&preview);
assert!(home.join("artifacts").join("unused").is_dir());
assert!(home.join("cache/downloads/payload").is_file());
assert!(home.join("work/cargo/demo/targets/fingerprint").is_dir());
let json_preview = isolated_bin(&home)
.args([
"cache",
"gc",
"--dry-run",
"--format",
"json",
"--max-age-days",
"0",
])
.output()
.unwrap();
assert_success(&json_preview);
serde_json::from_slice::<serde_json::Value>(&json_preview.stdout).unwrap();
let collect = isolated_bin(&home)
.args(["cache", "gc", "--format", "json", "--max-age-days", "0"])
.output()
.unwrap();
assert_success(&collect);
let value: serde_json::Value = serde_json::from_slice(&collect.stdout).unwrap();
assert!(
value["removed"]
.as_array()
.is_some_and(|items| items.len() >= 5)
);
assert!(!home.join("artifacts/unused").exists());
assert!(!home.join("cache/downloads/payload").exists());
assert!(!home.join("cache/quarantine/corrupt").exists());
assert!(!home.join("cache/cargo/sources/source-digest").exists());
assert!(!home.join("work/cargo/demo/targets/fingerprint").exists());
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn install_requires_confirmation_for_typed_backend() {
let temp = test_dir("install-confirm");
let home = temp.join("home");
fs::create_dir_all(&temp).unwrap();
let config = temp.join("config.toml");
fs::write(&config, fixture("does-not-exist")).unwrap();
let refused = isolated_bin(&home)
.args(["install", "smoke", "--config", text(&config)])
.output()
.unwrap();
assert_eq!(refused.status.code(), Some(3));
fs::remove_dir_all(temp).unwrap();
}
#[cfg(unix)]
#[test]
fn status_detects_managed_cargo_subcommands_without_parent_path_activation() {
use std::os::unix::fs::PermissionsExt;
let temp = test_dir("managed-cargo-detect");
let home = temp.join("home");
let managed_bin = home.join("bin");
fs::create_dir_all(&managed_bin).unwrap();
let command = managed_bin.join("cargo-bloat");
fs::write(&command, "#!/bin/sh\nprintf 'cargo-bloat 0.12.1\\n'\n").unwrap();
fs::set_permissions(&command, fs::Permissions::from_mode(0o755)).unwrap();
let config = temp.join("config.toml");
fs::write(
&config,
r#"catalog = "rust-dev"
[profiles.smoke]
components = ["cargo-bloat"]
[profiles.standard]
inherits = ["smoke"]
[cargo-tools]
cargo-bloat = "0.12.1"
"#,
)
.unwrap();
let output = isolated_bin(&home)
.env("BOT_FORGE_BIN_DIR", &managed_bin)
.args([
"status",
"smoke",
"--format",
"json",
"--config",
text(&config),
])
.output()
.unwrap();
assert_success(&output);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let tool = value["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == "cargo-bloat")
.unwrap();
assert_eq!(tool["installed"], true);
assert_eq!(tool["version"], "cargo-bloat 0.12.1");
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn removed_pre_1_0_aliases_are_rejected() {
for command in [
"init",
"list",
"state",
"uninstall",
"install-crate",
"install-skill",
"repair",
"update",
"version",
] {
let output = Command::new(bin()).arg(command).output().unwrap();
assert_eq!(
output.status.code(),
Some(2),
"{command} unexpectedly accepted"
);
}
}
#[test]
fn remove_rejects_redundant_force_alias() {
let output = Command::new(bin())
.args(["remove", "demo", "--force"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
assert!(String::from_utf8_lossy(&output.stderr).contains("unknown remove option"));
}
#[cfg(unix)]
#[test]
fn managed_cargo_install_uses_scheduler_artifact_journal_and_activation() {
use std::os::unix::fs::PermissionsExt;
let temp = test_dir("managed-cargo-e2e");
let home = temp.join("home");
let fake_bin = temp.join("fake-bin");
fs::create_dir_all(&fake_bin).unwrap();
let cargo = fake_bin.join("cargo");
fs::write(
&cargo,
r#"#!/bin/sh
root=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--root" ]; then root="$2"; shift 2; continue; fi
shift
done
mkdir -p "$root/bin"
printf '#!/bin/sh\necho demo 1.2.3\n' > "$root/bin/demo"
chmod +x "$root/bin/demo"
"#,
)
.unwrap();
fs::set_permissions(&cargo, fs::Permissions::from_mode(0o755)).unwrap();
let config = temp.join("config.toml");
fs::write(
&config,
r#"catalog = "rust-dev"
[policy]
max_parallel = 4
max_downloads = 2
allow_unlocked_cargo = true
[profiles.smoke]
components = ["demo"]
[[components]]
id = "demo"
platforms = ["*"]
provides = ["cargo"]
[components.detect]
kind = "command"
program = "demo"
args = ["--version"]
[components.install]
backend = "cargo"
crate = "demo"
version = "=1.2.3"
locked = false
bins = ["demo"]
"#,
)
.unwrap();
let path = format!("{}:{}", fake_bin.display(), env::var("PATH").unwrap());
let output = isolated_bin(&home)
.env("BOT_FORGE_BIN_DIR", home.join("bin"))
.env("PATH", path)
.args(["install", "smoke", "--yes", "--config", text(&config)])
.output()
.unwrap();
assert_success(&output);
let activation = home.join("bin/demo");
assert!(
fs::symlink_metadata(&activation)
.unwrap()
.file_type()
.is_symlink()
);
let registry: serde_json::Value =
serde_json::from_slice(&fs::read(home.join("registry.json")).unwrap()).unwrap();
assert!(registry.get("schema_version").is_none());
let entry = ®istry["entries"][0];
assert_eq!(entry["backend"], "cargo");
assert!(entry["artifact_id"].as_str().is_some());
assert!(
entry["plan_hash"]
.as_str()
.is_some_and(|hash| hash.len() == 64)
);
let journal = fs::read_dir(home.join("journals"))
.unwrap()
.next()
.unwrap()
.unwrap()
.path()
.join("demo.json");
let checkpoint: serde_json::Value =
serde_json::from_slice(&fs::read(journal).unwrap()).unwrap();
assert!(checkpoint.get("schema_version").is_none());
assert_eq!(checkpoint["phase"], "recorded");
let artifact_root = home
.join("artifacts")
.join(entry["artifact_id"].as_str().unwrap());
let removed = isolated_bin(&home)
.env("BOT_FORGE_BIN_DIR", home.join("bin"))
.args(["remove", "demo", "--yes"])
.output()
.unwrap();
assert_success(&removed);
assert!(!activation.exists());
assert!(
artifact_root.is_dir(),
"remove must not follow activation links"
);
fs::remove_dir_all(temp).unwrap();
}
#[cfg(unix)]
#[test]
fn managed_cargo_install_streams_clean_lifecycle_jsonl() {
use std::os::unix::fs::PermissionsExt;
let temp = test_dir("managed-cargo-jsonl");
let home = temp.join("home");
let fake_bin = temp.join("fake-bin");
fs::create_dir_all(&fake_bin).unwrap();
let cargo = fake_bin.join("cargo");
fs::write(
&cargo,
r#"#!/bin/sh
root=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--root" ]; then root="$2"; shift 2; continue; fi
shift
done
mkdir -p "$root/bin"
printf '#!/bin/sh\necho demo 1.2.3\n' > "$root/bin/demo"
chmod +x "$root/bin/demo"
"#,
)
.unwrap();
fs::set_permissions(&cargo, fs::Permissions::from_mode(0o755)).unwrap();
let config = temp.join("config.toml");
fs::write(
&config,
r#"catalog = "rust-dev"
[policy]
allow_unlocked_cargo = true
[profiles.smoke]
components = ["demo"]
[[components]]
id = "demo"
provides = ["cargo"]
[components.detect]
kind = "command"
program = "demo"
[components.install]
backend = "cargo"
crate = "demo"
version = "=1.2.3"
locked = false
bins = ["demo"]
"#,
)
.unwrap();
let path = format!("{}:{}", fake_bin.display(), env::var("PATH").unwrap());
let output = isolated_bin(&home)
.env("BOT_FORGE_BIN_DIR", home.join("bin"))
.env("PATH", path)
.args([
"install",
"smoke",
"--yes",
"--format",
"jsonl",
"--config",
text(&config),
])
.output()
.unwrap();
assert_success(&output);
let lines = String::from_utf8(output.stdout).unwrap();
let events = lines
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.collect::<Vec<_>>();
assert!(!events.is_empty());
assert!(events.iter().any(|event| event["event"] == "planned"));
assert!(events.iter().any(|event| event["event"] == "resource-wait"));
assert!(events.iter().any(|event| {
event["event"] == "resource-acquired"
&& event["resource"].as_str().is_some()
&& event["wait_ms"].as_u64().is_some()
}));
assert!(events.iter().any(|event| event["event"] == "run-completed"));
assert!(
events
.windows(2)
.all(|pair| pair[0]["sequence"].as_u64() < pair[1]["sequence"].as_u64())
);
assert!(
events
.iter()
.all(|event| event["run_id"] == events[0]["run_id"])
);
fs::remove_dir_all(temp).unwrap();
}
#[cfg(unix)]
#[test]
fn managed_git_install_activates_records_and_removes_declared_binary() {
use std::os::unix::fs::PermissionsExt;
let temp = test_dir("managed-git-e2e");
let home = temp.join("home");
let fake_bin = temp.join("fake-bin");
fs::create_dir_all(&fake_bin).unwrap();
let git = fake_bin.join("git");
fs::write(
&git,
r#"#!/bin/sh
if [ "$1" = "clone" ]; then
for argument in "$@"; do destination="$argument"; done
mkdir -p "$destination/package/bin"
printf '#!/bin/sh\necho demo-git 0123456\n' > "$destination/package/bin/demo"
chmod +x "$destination/package/bin/demo"
fi
exit 0
"#,
)
.unwrap();
fs::set_permissions(&git, fs::Permissions::from_mode(0o755)).unwrap();
let config = temp.join("config.toml");
fs::write(
&config,
r#"catalog = "rust-dev"
[profiles.smoke]
components = ["demo-git"]
[[components]]
id = "demo-git"
platforms = ["*"]
[components.detect]
kind = "command"
program = "demo-git"
args = ["--version"]
[components.install]
backend = "git"
url = "https://example.invalid/demo.git"
revision = "0123456789abcdef0123456789abcdef01234567"
subdirectory = "package"
bins = { demo-git = "bin/demo" }
"#,
)
.unwrap();
let path = format!(
"{}:{}:{}",
home.join("bin").display(),
fake_bin.display(),
env::var("PATH").unwrap()
);
let output = isolated_bin(&home)
.env("BOT_FORGE_BIN_DIR", home.join("bin"))
.env("PATH", path)
.args(["install", "smoke", "--yes", "--config", text(&config)])
.output()
.unwrap();
assert_success(&output);
let activation = home.join("bin/demo-git");
assert!(
fs::symlink_metadata(&activation)
.unwrap()
.file_type()
.is_symlink()
);
let registry: serde_json::Value =
serde_json::from_slice(&fs::read(home.join("registry.json")).unwrap()).unwrap();
let entry = ®istry["entries"][0];
assert_eq!(entry["backend"], "git");
assert_eq!(entry["source"], "backend:git");
assert_eq!(
entry["source_revision"],
"0123456789abcdef0123456789abcdef01234567"
);
assert!(
home.join("artifacts")
.join(entry["artifact_id"].as_str().unwrap())
.is_dir()
);
let journals = fs::read_dir(home.join("journals"))
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let checkpoint: serde_json::Value =
serde_json::from_slice(&fs::read(journals.join("demo-git.json")).unwrap()).unwrap();
assert_eq!(checkpoint["phase"], "recorded");
let removed = isolated_bin(&home)
.env("BOT_FORGE_BIN_DIR", home.join("bin"))
.args(["remove", "demo-git", "--yes"])
.output()
.unwrap();
assert_success(&removed);
assert!(!activation.exists());
fs::remove_dir_all(temp).unwrap();
}
fn fixture(package: &str) -> String {
format!(
r#"catalog = "rust-dev"
[profiles.smoke]
components = ["demo"]
[profiles.standard]
inherits = ["smoke"]
[[components]]
id = "demo"
platforms = ["*"]
[components.detect]
kind = "command"
program = "false"
[components.install]
backend = "apt"
packages = ["{package}"]
"#
)
}
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_bot-forge")
}
fn isolated_bin(home: &Path) -> Command {
let user_home = home.join("user-home");
let mut command = Command::new(bin());
command
.env("BOT_FORGE_HOME", home)
.env("HOME", &user_home)
.env("USERPROFILE", user_home);
command
}
fn text(path: &Path) -> &str {
path.to_str().unwrap()
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
"stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn test_dir(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
env::temp_dir().join(format!(
"bot-forge-cli-{name}-{nanos}-{}",
std::process::id()
))
}