use std::fs;
use std::path::{Path, PathBuf};
mod common;
#[path = "common/isolated.rs"]
mod isolated;
const CACHEDIR_TAG_SIGNATURE: &str = "Signature: 8a477f597d28d172789f06886806bc55";
fn write_fixture_file(path: impl AsRef<Path>, bytes: &[u8]) {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, bytes).unwrap();
}
fn write_cachedir_tag(dir: impl AsRef<Path>) {
write_fixture_file(dir.as_ref().join("CACHEDIR.TAG"), &cachedir_tag_bytes());
}
fn write_config(temp: &tempfile::TempDir, contents: impl AsRef<str>) {
let config_dir = temp.path().join("rebecca-config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("config.toml"), contents.as_ref()).unwrap();
}
fn cachedir_tag_bytes() -> Vec<u8> {
format!("{CACHEDIR_TAG_SIGNATURE}\n# cache directory\n").into_bytes()
}
#[test]
fn purge_help_shows_project_artifact_options() {
let output = common::command::rebecca()
.args(["purge", "--help"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("--root"));
assert!(stdout.contains("--max-depth"));
assert!(stdout.contains("--min-age-days"));
assert!(stdout.contains("--artifact"));
assert!(stdout.contains("--list-artifacts"));
assert!(stdout.contains("--exclude"));
}
#[test]
fn purge_list_artifacts_human_reports_supported_selectors_without_loading_config() {
let temp = tempfile::tempdir().unwrap();
write_config(&temp, "[purge\n");
let output = isolated::isolated_rebecca(&temp)
.args(["purge", "--list-artifacts"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Supported project artifacts:"));
assert!(stdout.contains("node_modules"));
assert!(stdout.contains("node-modules"));
assert!(stdout.contains("windows.project-artifact-node-modules"));
assert!(stdout.contains("CACHEDIR.TAG"));
assert!(stdout.contains("dotnet-bin"));
assert!(stdout.contains("composer-vendor"));
}
#[test]
fn purge_list_artifacts_json_reports_machine_readable_catalog() {
let output = common::command::rebecca()
.args(["purge", "--list-artifacts", "--format", "json"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let value: serde_json::Value = common::support::api_data(&output.stdout);
let artifacts = value.as_array().unwrap();
assert!(artifacts.iter().any(|artifact| {
artifact["artifact"] == "node_modules"
&& artifact["rule_id"] == "windows.project-artifact-node-modules"
&& artifact["rule_suffix"] == "node-modules"
}));
assert!(artifacts.iter().any(|artifact| {
artifact["artifact"] == "CACHEDIR.TAG"
&& artifact["rule_id"] == "windows.project-artifact-cachedir-tag"
}));
}
#[test]
fn purge_json_builds_project_artifact_plan_without_deleting() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
let node_modules_file = workspace.join("app").join("node_modules").join("pkg.bin");
let target_file = workspace
.join("app")
.join("target")
.join("debug")
.join("app.bin");
write_fixture_file(&node_modules_file, b"abc");
write_fixture_file(&target_file, b"rust");
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--format",
"json",
"--no-progress",
"--root",
workspace.to_str().unwrap(),
"--min-age-days",
"0",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
assert!(
node_modules_file.exists(),
"purge should preview by default"
);
assert!(target_file.exists(), "purge should preview by default");
let value: serde_json::Value = common::support::api_data(&output.stdout);
assert_eq!(value["request"]["workflow"], "project-artifacts");
assert_eq!(value["request"]["mode"], "dry-run");
assert_eq!(value["request"]["project_artifact_min_age_days"], 0);
assert_eq!(
PathBuf::from(
value["request"]["project_artifact_roots"][0]
.as_str()
.unwrap()
),
workspace
);
assert_eq!(value["summary"]["allowed_targets"], 2);
assert_eq!(value["summary"]["estimated_bytes"], 7);
let targets = value["targets"].as_array().unwrap();
assert!(targets.iter().any(|target| {
target["rule_id"] == "windows.project-artifact-node-modules"
&& PathBuf::from(target["path"].as_str().unwrap())
.ends_with(Path::new("app").join("node_modules"))
}));
assert!(targets.iter().any(|target| {
target["rule_id"] == "windows.project-artifact-target"
&& PathBuf::from(target["path"].as_str().unwrap())
.ends_with(Path::new("app").join("target"))
}));
}
#[test]
fn purge_human_output_groups_project_artifacts_by_project_path() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
write_fixture_file(
workspace.join("app").join("node_modules").join("pkg.bin"),
b"abc",
);
write_fixture_file(
workspace
.join("app")
.join("target")
.join("debug")
.join("app.bin"),
b"rust",
);
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--no-progress",
"--root",
workspace.to_str().unwrap(),
"--min-age-days",
"0",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Workflow: Project artifacts"));
assert!(stdout.contains("Project artifact details:"));
assert!(stdout.contains(&workspace.join("app").display().to_string()));
assert!(stdout.contains("- node_modules [allowed]"));
assert!(stdout.contains("- target [allowed]"));
}
#[test]
fn purge_human_output_highlights_recently_modified_artifacts() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
write_fixture_file(
workspace.join("app").join("node_modules").join("pkg.bin"),
b"abc",
);
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--no-progress",
"--root",
workspace.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Recently modified artifacts:"));
assert!(stdout.contains("- node_modules [skipped]"));
assert!(stdout.contains("modified within the last 7 days"));
}
#[test]
fn purge_json_filters_selected_artifacts() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
write_fixture_file(
workspace.join("app").join("node_modules").join("pkg.bin"),
b"abc",
);
write_fixture_file(
workspace
.join("app")
.join("target")
.join("debug")
.join("app.bin"),
b"rust",
);
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--format",
"json",
"--no-progress",
"--root",
workspace.to_str().unwrap(),
"--artifact",
"target",
"--min-age-days",
"0",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let value: serde_json::Value = common::support::api_data(&output.stdout);
assert_eq!(value["request"]["project_artifact_selectors"][0], "target");
assert_eq!(value["summary"]["allowed_targets"], 1);
assert_eq!(value["summary"]["estimated_bytes"], 4);
let targets = value["targets"].as_array().unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0]["rule_id"], "windows.project-artifact-target");
}
#[test]
fn purge_json_filters_context_sensitive_vendor_artifacts() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
let composer_vendor = workspace.join("php-app").join("vendor");
write_fixture_file(composer_vendor.join("pkg").join("autoload.php"), b"php");
write_fixture_file(workspace.join("php-app").join("composer.json"), b"{}");
write_fixture_file(
workspace
.join("go-app")
.join("vendor")
.join("pkg")
.join("dep.go"),
b"go",
);
write_fixture_file(workspace.join("go-app").join("go.mod"), b"module example");
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--format",
"json",
"--no-progress",
"--root",
workspace.to_str().unwrap(),
"--artifact",
"vendor",
"--min-age-days",
"0",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let value: serde_json::Value = common::support::api_data(&output.stdout);
assert_eq!(value["summary"]["allowed_targets"], 1);
let targets = value["targets"].as_array().unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(
targets[0]["rule_id"],
"windows.project-artifact-composer-vendor"
);
assert_eq!(
PathBuf::from(targets[0]["path"].as_str().unwrap()),
composer_vendor
);
}
#[test]
fn purge_uses_configured_roots_when_root_flag_is_absent() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
write_fixture_file(
workspace.join("app").join("node_modules").join("pkg.bin"),
b"abc",
);
write_config(
&temp,
format!(
r#"
[purge]
roots = ['{}']
max_depth = 2
min_age_days = 0
"#,
workspace.display()
),
);
let output = isolated::isolated_rebecca(&temp)
.args(["purge", "--format", "json", "--no-progress"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let value: serde_json::Value = common::support::api_data(&output.stdout);
assert_eq!(
PathBuf::from(
value["request"]["project_artifact_roots"][0]
.as_str()
.unwrap()
),
workspace
);
assert_eq!(value["request"]["project_artifact_max_depth"], 2);
assert_eq!(value["request"]["project_artifact_min_age_days"], 0);
assert_eq!(value["summary"]["allowed_targets"], 1);
}
#[test]
fn purge_root_flag_overrides_configured_roots() {
let temp = tempfile::tempdir().unwrap();
let configured_workspace = temp.path().join("configured-workspace");
let cli_workspace = temp.path().join("cli-workspace");
write_fixture_file(
configured_workspace
.join("app")
.join("node_modules")
.join("pkg.bin"),
b"configured",
);
write_fixture_file(
cli_workspace
.join("app")
.join("target")
.join("debug")
.join("app.bin"),
b"cli",
);
write_config(
&temp,
format!(
r#"
[purge]
roots = ['{}']
max_depth = 2
min_age_days = 0
"#,
configured_workspace.display()
),
);
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--format",
"json",
"--no-progress",
"--root",
cli_workspace.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let value: serde_json::Value = common::support::api_data(&output.stdout);
assert_eq!(
PathBuf::from(
value["request"]["project_artifact_roots"][0]
.as_str()
.unwrap()
),
cli_workspace
);
let targets = value["targets"].as_array().unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0]["rule_id"], "windows.project-artifact-target");
assert!(!targets.iter().any(|target| {
PathBuf::from(target["path"].as_str().unwrap()).starts_with(&configured_workspace)
}));
}
#[test]
fn purge_depth_and_min_age_flags_override_config() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
write_fixture_file(
workspace
.join("level1")
.join("level2")
.join("node_modules")
.join("pkg.bin"),
b"abc",
);
write_config(
&temp,
format!(
r#"
[purge]
roots = ['{}']
max_depth = 1
min_age_days = 30
"#,
workspace.display()
),
);
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--format",
"json",
"--no-progress",
"--max-depth",
"3",
"--min-age-days",
"0",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let value: serde_json::Value = common::support::api_data(&output.stdout);
assert_eq!(value["request"]["project_artifact_max_depth"], 3);
assert_eq!(value["request"]["project_artifact_min_age_days"], 0);
assert_eq!(value["summary"]["allowed_targets"], 1);
}
#[test]
fn purge_json_skips_recent_artifacts_by_default() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
write_fixture_file(
workspace.join("app").join("node_modules").join("pkg.bin"),
b"abc",
);
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--format",
"json",
"--no-progress",
"--root",
workspace.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let value: serde_json::Value = common::support::api_data(&output.stdout);
assert_eq!(value["summary"]["allowed_targets"], 0);
assert_eq!(value["summary"]["skipped_targets"], 1);
assert_eq!(value["summary"]["estimated_bytes"], 0);
let target = &value["targets"].as_array().unwrap()[0];
assert_eq!(target["status"], "skipped");
assert_eq!(target["reason_code"], "project-artifact-recently-modified");
assert!(
target["reason"]
.as_str()
.unwrap()
.contains("modified within the last 7 days")
);
}
#[test]
fn purge_json_reports_cachedir_tag_artifacts() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
let cache = workspace.join("app").join("tool-cache");
write_fixture_file(cache.join("entry.bin"), b"abc");
write_cachedir_tag(&cache);
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--format",
"json",
"--no-progress",
"--root",
workspace.to_str().unwrap(),
"--min-age-days",
"0",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let value: serde_json::Value = common::support::api_data(&output.stdout);
assert_eq!(value["summary"]["allowed_targets"], 1);
assert_eq!(
value["summary"]["estimated_bytes"],
3 + cachedir_tag_bytes().len() as u64
);
let target = &value["targets"].as_array().unwrap()[0];
assert_eq!(target["rule_id"], "windows.project-artifact-cachedir-tag");
assert_eq!(target["status"], "allowed");
assert!(
PathBuf::from(target["path"].as_str().unwrap())
.ends_with(Path::new("app").join("tool-cache"))
);
}
#[test]
fn purge_human_output_shows_modified_time_for_artifacts() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
write_fixture_file(
workspace.join("app").join("node_modules").join("pkg.bin"),
b"abc",
);
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--no-progress",
"--root",
workspace.to_str().unwrap(),
"--min-age-days",
"0",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("modified at"));
}
#[test]
fn purge_json_honors_exclude_flag() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
let node_modules = workspace.join("app").join("node_modules");
write_fixture_file(node_modules.join("pkg.bin"), b"abc");
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--format",
"json",
"--no-progress",
"--root",
workspace.to_str().unwrap(),
"--exclude",
node_modules.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
common::support::stderr(&output)
);
let value: serde_json::Value = common::support::api_data(&output.stdout);
assert_eq!(value["summary"]["allowed_targets"], 0);
assert_eq!(value["summary"]["blocked_targets"], 1);
let blocked = &value["targets"].as_array().unwrap()[0];
assert_eq!(blocked["rule_id"], "windows.project-artifact-node-modules");
assert_eq!(blocked["status"], "blocked");
assert_eq!(blocked["reason_code"], "safety-policy-blocked");
assert!(
blocked["reason"]
.as_str()
.unwrap()
.contains("user-protected path")
);
}
#[test]
fn purge_rejects_missing_root() {
let temp = tempfile::tempdir().unwrap();
let missing = temp.path().join("missing");
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--format",
"json",
"--root",
missing.to_str().unwrap(),
])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = common::support::stderr(&output);
assert!(stderr.contains("purge root"));
assert!(stderr.contains("not accessible"));
}
#[test]
fn purge_rejects_unknown_artifact_selector() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let output = isolated::isolated_rebecca(&temp)
.args([
"purge",
"--format",
"json",
"--root",
workspace.to_str().unwrap(),
"--artifact",
"missing-artifact",
])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = common::support::stderr(&output);
assert!(stderr.contains("invalid project artifact selector"));
assert!(stderr.contains("missing-artifact"));
}