use std::fs;
use tempfile::TempDir;
use crate::common::{setup_test_project, run_rsconstruct, run_rsconstruct_with_env};
#[test]
fn cache_operations() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("config/cache_test.py"),
"value = 'cached'"
).unwrap();
fs::write(
project_path.join("tera.templates/cached.txt.tera"),
"{% set c = load_python(path='config/cache_test.py') %}{{ c.value }}"
).unwrap();
let output = run_rsconstruct(project_path, &["build"]);
assert!(output.status.success());
assert!(project_path.join("cached.txt").exists());
assert!(project_path.join(".rsconstruct/db.redb").exists());
assert!(project_path.join(".rsconstruct/objects").exists());
let size_output = run_rsconstruct(project_path, &["cache", "size"]);
assert!(size_output.status.success());
let size_stdout = String::from_utf8_lossy(&size_output.stdout);
assert!(size_stdout.contains("Cache size:"));
assert!(size_stdout.contains("objects"));
assert!(!size_stdout.contains("0 objects"), "Cache should have objects after build");
fs::remove_file(project_path.join("cached.txt")).unwrap();
assert!(!project_path.join("cached.txt").exists());
let restore_output = run_rsconstruct_with_env(project_path, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(restore_output.status.success());
let restore_stdout = String::from_utf8_lossy(&restore_output.stdout);
assert!(restore_stdout.contains("Restored from cache:"));
assert!(project_path.join("cached.txt").exists());
let content = fs::read_to_string(project_path.join("cached.txt")).unwrap();
assert_eq!(content.trim(), "cached");
let trim_output = run_rsconstruct(project_path, &["cache", "trim"]);
assert!(trim_output.status.success());
let trim_stdout = String::from_utf8_lossy(&trim_output.stdout);
assert!(trim_stdout.contains("0 unreferenced objects"));
let clear_output = run_rsconstruct(project_path, &["cache", "clear"]);
assert!(clear_output.status.success());
assert!(!project_path.join(".rsconstruct").join("objects").exists());
let size_after = run_rsconstruct(project_path, &["cache", "size"]);
assert!(size_after.status.success());
let size_after_stdout = String::from_utf8_lossy(&size_after.stdout);
assert!(size_after_stdout.contains("0 B"));
assert!(size_after_stdout.contains("0 objects"));
}
#[test]
fn cache_list_shows_entries() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/list_test.txt.tera"),
"hello"
).unwrap();
let build = run_rsconstruct(project_path, &["build"]);
assert!(build.status.success());
let output = run_rsconstruct_with_env(project_path, &["cache", "list"], &[("NO_COLOR", "1")]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let entries: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("Cache list should be valid JSON: {}\nOutput: {}", e, stdout));
let arr = entries.as_array().expect("Cache list should be a JSON array");
assert!(!arr.is_empty(), "Cache list should have entries after build");
let first = &arr[0];
assert!(first["cache_key"].as_str().is_some(),
"Cache entry should have a cache_key: {}", first);
}
#[test]
fn cache_list_empty() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
"\n"
).unwrap();
let output = run_rsconstruct_with_env(project_path, &["cache", "list"], &[("NO_COLOR", "1")]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let entries: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("Cache list should be valid JSON: {}\nOutput: {}", e, stdout));
let arr = entries.as_array().expect("Cache list should be a JSON array");
assert!(arr.is_empty(), "Empty cache should produce an empty JSON array: {}", stdout);
}
#[test]
fn cache_stats_empty() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
"\n"
).unwrap();
let output = run_rsconstruct_with_env(project_path, &["cache", "stats"], &[("NO_COLOR", "1")]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Cache is empty"), "Expected 'Cache is empty' message, got: {}", stdout);
}
#[test]
fn cache_stats_after_build() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/stats_test.txt.tera"),
"hello"
).unwrap();
let build = run_rsconstruct(project_path, &["build"]);
assert!(build.status.success());
let output = run_rsconstruct_with_env(project_path, &["cache", "stats"], &[("NO_COLOR", "1")]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("entries"), "Expected 'entries' in stats, got: {}", stdout);
}
#[test]
fn cache_stats_json() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/json_test.txt.tera"),
"hello"
).unwrap();
let build = run_rsconstruct(project_path, &["build"]);
assert!(build.status.success());
let output = run_rsconstruct_with_env(project_path, &["--json", "cache", "stats"], &[("NO_COLOR", "1")]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("Cache stats JSON should be valid: {}\nOutput: {}", e, stdout));
assert!(parsed.is_object(), "Expected JSON object, got: {}", stdout);
assert!(parsed.get("all").is_some(), "Expected 'all' key in stats JSON, got: {}", stdout);
}
#[test]
fn cache_clear_removes_everything() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/clear_test.txt.tera"),
"hello"
).unwrap();
let build = run_rsconstruct(project_path, &["build"]);
assert!(build.status.success());
assert!(project_path.join(".rsconstruct").exists(), "Cache dir should exist after build");
assert!(project_path.join(".rsconstruct/objects").exists(), "Objects dir should exist after build");
assert!(project_path.join(".rsconstruct/descriptors").exists(), "Descriptors dir should exist after build");
let clear = run_rsconstruct(project_path, &["cache", "clear"]);
assert!(clear.status.success());
assert!(!project_path.join(".rsconstruct").exists(),
"Entire .rsconstruct dir should be removed after cache clear");
assert!(!project_path.join(".rsconstruct/objects").exists(),
"Objects dir should not exist after cache clear");
assert!(!project_path.join(".rsconstruct/descriptors").exists(),
"Descriptors dir should not exist after cache clear");
let rebuild = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(rebuild.status.success());
let stdout = String::from_utf8_lossy(&rebuild.stdout);
assert!(stdout.contains("Processing:"), "Should do a full rebuild after cache clear: {}", stdout);
assert!(!stdout.contains("Restored from cache:"), "Should not restore after cache clear: {}", stdout);
}
#[test]
fn remove_stale_keeps_current_entries() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/stale_test.txt.tera"),
"stale test content"
).unwrap();
let build = run_rsconstruct(project_path, &["build"]);
assert!(build.status.success());
assert!(project_path.join("stale_test.txt").exists());
let stale = run_rsconstruct_with_env(project_path, &["cache", "stale"], &[("NO_COLOR", "1")]);
assert!(stale.status.success());
let stale_stdout = String::from_utf8_lossy(&stale.stdout);
assert!(!stale_stdout.lines().any(|l| l.starts_with("stale ")),
"Fresh build must have no stale entries: {}", stale_stdout);
assert!(stale_stdout.lines().any(|l| l.starts_with("current ")),
"Fresh build's entries must be recognized as current: {}", stale_stdout);
let remove = run_rsconstruct_with_env(project_path, &["cache", "remove-stale"], &[("NO_COLOR", "1")]);
assert!(remove.status.success());
let remove_stdout = String::from_utf8_lossy(&remove.stdout);
assert!(remove_stdout.contains("Removed 0 stale index entries"),
"remove-stale after a fresh build must remove nothing: {}", remove_stdout);
fs::remove_file(project_path.join("stale_test.txt")).unwrap();
let rebuild = run_rsconstruct_with_env(project_path, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(rebuild.status.success());
let rebuild_stdout = String::from_utf8_lossy(&rebuild.stdout);
assert!(rebuild_stdout.contains("Restored from cache:"),
"Cache must survive remove-stale: {}", rebuild_stdout);
fs::write(
project_path.join("tera.templates/stale_test.txt.tera"),
"changed content"
).unwrap();
let remove2 = run_rsconstruct_with_env(project_path, &["cache", "remove-stale"], &[("NO_COLOR", "1")]);
assert!(remove2.status.success());
let remove2_stdout = String::from_utf8_lossy(&remove2.stdout);
assert!(!remove2_stdout.contains("Removed 0 stale index entries"),
"Changing input content must make the old entry stale: {}", remove2_stdout);
}
#[test]
fn cache_survives_input_rename() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/original.txt.tera"),
"rename test content"
).unwrap();
let build1 = run_rsconstruct(project_path, &["build"]);
assert!(build1.status.success());
assert!(project_path.join("original.txt").exists());
fs::remove_file(project_path.join("original.txt")).unwrap();
fs::rename(
project_path.join("tera.templates/original.txt.tera"),
project_path.join("tera.templates/renamed.txt.tera"),
).unwrap();
let build2 = run_rsconstruct_with_env(project_path, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(build2.status.success(),
"Build after rename should succeed: stderr={}",
String::from_utf8_lossy(&build2.stderr));
let stdout = String::from_utf8_lossy(&build2.stdout);
assert!(stdout.contains("Restored from cache:"),
"Renamed input with same content should restore from cache: {}", stdout);
assert!(project_path.join("renamed.txt").exists(),
"Output under new name should exist after restore");
let content = fs::read_to_string(project_path.join("renamed.txt")).unwrap();
assert_eq!(content.trim(), "rename test content");
}
#[test]
fn tool_upgrade_invalidates_cached_results() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let bin_dir = project_path.join("toolbin");
fs::create_dir_all(&bin_dir).unwrap();
let tool = bin_dir.join("faketool");
fs::write(&tool, "#!/bin/sh\nexit 0\n").unwrap();
crate::common::make_executable(&tool);
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.script]\ncommand = \"faketool\"\nsrc_dirs = [\"src\"]\nsrc_extensions = [\".txt\"]\n",
).unwrap();
fs::create_dir_all(project_path.join("src")).unwrap();
fs::write(project_path.join("src/a.txt"), "content\n").unwrap();
let path_env = format!("{}:{}", bin_dir.display(), std::env::var("PATH").unwrap_or_default());
let build1 = run_rsconstruct_with_env(
project_path, &["build", "-v"],
&[("NO_COLOR", "1"), ("PATH", &path_env)],
);
assert!(build1.status.success(),
"first build failed: {}", String::from_utf8_lossy(&build1.stderr));
let build2 = run_rsconstruct_with_env(
project_path, &["build", "-v"],
&[("NO_COLOR", "1"), ("PATH", &path_env)],
);
assert!(build2.status.success(),
"second build failed: {}", String::from_utf8_lossy(&build2.stderr));
let stdout2 = String::from_utf8_lossy(&build2.stdout);
assert!(!stdout2.contains("Processing:"),
"unchanged tool + unchanged inputs must not re-run anything: {}", stdout2);
fs::write(&tool, "#!/bin/sh\n# v2\nexit 0\n").unwrap();
crate::common::make_executable(&tool);
let build3 = run_rsconstruct_with_env(
project_path, &["build", "-v"],
&[("NO_COLOR", "1"), ("PATH", &path_env)],
);
assert!(build3.status.success(),
"build after tool upgrade failed: {}", String::from_utf8_lossy(&build3.stderr));
let stdout3 = String::from_utf8_lossy(&build3.stdout);
assert!(stdout3.contains("Processing:"),
"a tool upgrade must invalidate cached results: {}", stdout3);
}
#[test]
fn hash_tool_versions_false_ignores_tool_upgrade() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let bin_dir = project_path.join("toolbin");
fs::create_dir_all(&bin_dir).unwrap();
let tool = bin_dir.join("faketool2");
fs::write(&tool, "#!/bin/sh\nexit 0\n").unwrap();
crate::common::make_executable(&tool);
fs::write(
project_path.join("rsconstruct.toml"),
"[build]\nhash_tool_versions = false\n\n\
[processor.script]\ncommand = \"faketool2\"\nsrc_dirs = [\"src\"]\nsrc_extensions = [\".txt\"]\n",
).unwrap();
fs::create_dir_all(project_path.join("src")).unwrap();
fs::write(project_path.join("src/a.txt"), "content\n").unwrap();
let path_env = format!("{}:{}", bin_dir.display(), std::env::var("PATH").unwrap_or_default());
let build1 = run_rsconstruct_with_env(
project_path, &["build", "-v"],
&[("NO_COLOR", "1"), ("PATH", &path_env)],
);
assert!(build1.status.success(),
"first build failed: {}", String::from_utf8_lossy(&build1.stderr));
fs::write(&tool, "#!/bin/sh\n# v2\nexit 0\n").unwrap();
crate::common::make_executable(&tool);
let build2 = run_rsconstruct_with_env(
project_path, &["build", "-v"],
&[("NO_COLOR", "1"), ("PATH", &path_env)],
);
assert!(build2.status.success(),
"second build failed: {}", String::from_utf8_lossy(&build2.stderr));
let stdout2 = String::from_utf8_lossy(&build2.stdout);
assert!(!stdout2.contains("Processing:"),
"with hash_tool_versions=false a tool upgrade must not invalidate: {}", stdout2);
}
#[test]
fn distinct_processors_do_not_share_cache_entries() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let project_path = temp_dir.path();
let bin_dir = project_path.join("toolbin");
fs::create_dir_all(&bin_dir).unwrap();
let tool = bin_dir.join("stamp");
fs::write(&tool, "#!/bin/sh\nprintf 'built:%s' \"$2\" > \"$2\"\nexit 0\n").unwrap();
crate::common::make_executable(&tool);
fs::write(
project_path.join("rsconstruct.toml"),
concat!(
"[build]\nhash_tool_versions = false\n\n",
"[processor.generator.gen_a]\n",
"command = \"stamp\"\n",
"output_dir = \"out/a\"\n",
"output_extension = \"txt\"\n",
"batch = false\n",
"src_extensions = [\".src\"]\n",
"src_dirs = [\"src\"]\n",
"\n",
"[processor.generator.gen_b]\n",
"command = \"stamp\"\n",
"output_dir = \"out/b\"\n",
"output_extension = \"txt\"\n",
"batch = false\n",
"src_extensions = [\".src\"]\n",
"src_dirs = [\"src\"]\n",
),
).unwrap();
fs::create_dir_all(project_path.join("src")).unwrap();
fs::write(project_path.join("src/input.src"), "identical content\n").unwrap();
let path_env = format!("{}:{}", bin_dir.display(), std::env::var("PATH").unwrap_or_default());
let build = run_rsconstruct_with_env(
project_path, &["build", "-v"],
&[("NO_COLOR", "1"), ("PATH", &path_env)],
);
assert!(build.status.success(),
"build failed: {}", String::from_utf8_lossy(&build.stderr));
let out_a = project_path.join("out/a/input.txt");
let out_b = project_path.join("out/b/input.txt");
assert!(out_a.exists(), "gen_a produced no output");
assert!(out_b.exists(), "gen_b produced no output");
assert_eq!(fs::read_to_string(&out_a).unwrap(), "built:out/a/input.txt",
"gen_a's output came from another processor's cache entry");
assert_eq!(fs::read_to_string(&out_b).unwrap(), "built:out/b/input.txt",
"gen_b's output came from another processor's cache entry");
fs::remove_file(&out_a).unwrap();
fs::remove_file(&out_b).unwrap();
let build2 = run_rsconstruct_with_env(
project_path, &["build", "-v"],
&[("NO_COLOR", "1"), ("PATH", &path_env)],
);
assert!(build2.status.success(),
"rebuild failed: {}", String::from_utf8_lossy(&build2.stderr));
let stdout2 = String::from_utf8_lossy(&build2.stdout);
assert!(stdout2.contains("Restored from cache:"),
"second build should restore from cache, not rebuild: {}", stdout2);
assert_eq!(fs::read_to_string(&out_a).unwrap(), "built:out/a/input.txt",
"gen_a restored the wrong processor's cached blob");
assert_eq!(fs::read_to_string(&out_b).unwrap(), "built:out/b/input.txt",
"gen_b restored the wrong processor's cached blob");
}