use crate::common::{
run_rsconstruct, run_rsconstruct_json, run_rsconstruct_json_with_env, run_rsconstruct_with_env,
setup_test_project,
};
use std::fs;
#[allow(unused_imports)]
use std::path::Path;
use tempfile::TempDir;
#[test]
fn clean_command() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("config/clean_test.py"), "test = 'clean'")
.expect("Failed to write config");
fs::write(
project_path.join("tera.templates/cleanme.txt.tera"),
"{% set c = load_python(path='config/clean_test.py') %}{{ c.test }}",
)
.expect("Failed to write template");
let build_output = run_rsconstruct(project_path, &["build"]);
assert!(build_output.status.success());
assert!(project_path.join("cleanme.txt").exists());
assert!(project_path.join(".rsconstruct/db.redb").exists());
let clean_output = run_rsconstruct(project_path, &["clean", "outputs"]);
assert!(clean_output.status.success());
assert!(!project_path.join("cleanme.txt").exists());
assert!(project_path.join(".rsconstruct").exists());
}
#[test]
fn force_rebuild() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("config/force.py"), "mode = 'force'")
.expect("Failed to write config");
fs::write(
project_path.join("tera.templates/force.txt.tera"),
"{% set c = load_python(path='config/force.py') %}Mode: {{ c.mode }}",
)
.expect("Failed to write template");
let first_build = run_rsconstruct(project_path, &["build"]);
assert!(
first_build.status.success(),
"First build failed: {}",
String::from_utf8_lossy(&first_build.stderr)
);
let result = run_rsconstruct_json(project_path, &["build", "--force"]);
assert!(result.exit_success);
assert_eq!(result.success, 1, "Should have 1 successful build");
assert_eq!(result.skipped, 0, "Should not skip anything with --force");
}
#[test]
fn no_color_env() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/color_test.txt.tera"),
"hello",
)
.unwrap();
let output = run_rsconstruct_with_env(project_path, &["build"], &[("NO_COLOR", "1")]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("\x1b["),
"Output should not contain ANSI escape codes when NO_COLOR is set"
);
}
#[test]
fn timings_flag() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/timing_test.txt.tera"),
"hello",
)
.unwrap();
let output =
run_rsconstruct_with_env(project_path, &["build", "--timings"], &[("NO_COLOR", "1")]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Timing:"),
"Output should contain 'Timing:' header"
);
assert!(
stdout.contains("Total:"),
"Output should contain 'Total:' line"
);
}
#[test]
fn no_timings_by_default() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/no_timing.txt.tera"),
"hello",
)
.unwrap();
let output = run_rsconstruct(project_path, &["build"]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("Timing:"),
"Output should not contain timing info without --timings flag"
);
assert!(
!stdout.contains("Total:"),
"Output should not contain total timing without --timings flag"
);
}
#[test]
fn keep_going_continues_after_failure() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/bad.txt.tera"),
"{{ invalid",
)
.unwrap();
fs::write(project_path.join("tera.templates/good.txt.tera"), "hello").unwrap();
let output = run_rsconstruct_with_env(
project_path,
&["build", "-v", "--keep-going"],
&[("NO_COLOR", "1")],
);
assert!(
!output.status.success(),
"Build should fail with bad template"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Processing:"),
"Files should be processed with --keep-going"
);
}
#[test]
fn keep_going_short_flag() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/bad_k.txt.tera"),
"{{ invalid",
)
.unwrap();
let output = run_rsconstruct_with_env(project_path, &["build", "-k"], &[("NO_COLOR", "1")]);
assert!(
!output.status.success(),
"Build should fail with bad template"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{}{}", stdout, stderr);
assert!(
combined.contains("error") || combined.contains("Error"),
"Should report errors: stdout={}, stderr={}",
stdout,
stderr
);
}
#[test]
fn build_stops_on_first_error() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/aaa.txt.tera"),
"{{ invalid",
)
.unwrap();
fs::write(project_path.join("tera.templates/zzz.txt.tera"), "hello").unwrap();
let result = run_rsconstruct_json(project_path, &["build"]);
assert!(!result.exit_success, "Build should fail with bad template");
assert_eq!(result.failed, 1, "Should have exactly 1 failure");
assert!(
!result.has_product("zzz.txt", "success"),
"Second file should NOT be processed after first error"
);
}
#[test]
fn keep_going_continues_after_error() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/bad.txt.tera"),
"{{ undefined_var }}",
)
.unwrap();
fs::write(project_path.join("tera.templates/good.txt.tera"), "hello").unwrap();
let result1 = run_rsconstruct_json(project_path, &["build", "--keep-going"]);
assert!(!result1.exit_success, "Build should fail with bad template");
assert_eq!(result1.failed, 1, "Should have 1 failure");
assert_eq!(result1.success, 1, "Should have 1 success (good.txt)");
assert!(
result1.has_product("good.txt", "success"),
"Good template should be processed with --keep-going"
);
fs::write(project_path.join("tera.templates/bad.txt.tera"), "fixed").unwrap();
let result2 = run_rsconstruct_json(project_path, &["build"]);
assert!(result2.exit_success, "Second build should succeed");
assert_eq!(
result2.skipped, 1,
"Good template should be skipped (cached)"
);
assert_eq!(
result2.success, 1,
"Bad template (now fixed) should be processed"
);
}
#[test]
fn parallel_build_with_j_flag() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
for name in &["alpha", "beta", "gamma", "delta"] {
fs::write(
project_path.join(format!("tera.templates/{}.txt.tera", name)),
format!("content of {}", name),
)
.unwrap();
}
let result = run_rsconstruct_json(project_path, &["build", "-j2"]);
assert!(
result.exit_success,
"Parallel build with -j2 should succeed"
);
assert_eq!(result.success, 4, "Should process all 4 templates");
assert_eq!(result.total_products, 4);
}
#[test]
fn parallel_keep_going_continues_after_failure() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("tera.templates/aaa_bad.txt.tera"),
"{{ undefined_var }}",
)
.unwrap();
fs::write(project_path.join("tera.templates/good1.txt.tera"), "hello1").unwrap();
fs::write(project_path.join("tera.templates/good2.txt.tera"), "hello2").unwrap();
fs::write(project_path.join("tera.templates/good3.txt.tera"), "hello3").unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n\n[build]\nparallel = 2\n",
)
.unwrap();
let result = run_rsconstruct_json(project_path, &["build", "--keep-going"]);
assert!(
!result.exit_success,
"Build should fail with bad template even with --keep-going"
);
assert_eq!(result.failed, 1, "Should have 1 failure");
assert_eq!(
result.success, 3,
"All 3 good templates should be processed with --keep-going"
);
}
#[test]
fn parallel_builds_all_independent_products() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
for i in 0..8 {
fs::write(
project_path.join(format!("tera.templates/task_{:02}.txt.tera", i)),
format!("content of task {}", i),
)
.unwrap();
}
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n\n[build]\nparallel = 4\n",
)
.unwrap();
let result = run_rsconstruct_json(project_path, &["build"]);
assert!(
result.exit_success,
"Parallel build with 8 products and 4 jobs should succeed"
);
assert_eq!(result.success, 8, "Should process all 8 templates");
assert_eq!(result.total_products, 8);
let result2 = run_rsconstruct_json(project_path, &["build"]);
assert!(result2.exit_success);
assert_eq!(
result2.skipped, 8,
"All 8 products should be skipped on second build"
);
}
#[test]
fn parallel_timings_flag() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
for name in &["one", "two", "three"] {
fs::write(
project_path.join(format!("tera.templates/{}.txt.tera", name)),
format!("hello from {}", name),
)
.unwrap();
}
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n\n[build]\nparallel = 2\n",
)
.unwrap();
let output =
run_rsconstruct_with_env(project_path, &["build", "--timings"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"Parallel build with --timings should succeed: stdout={}, stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Timing:"),
"Should contain 'Timing:' header in parallel mode"
);
assert!(
stdout.contains("Total:"),
"Should contain 'Total:' line in parallel mode"
);
let timing_lines = stdout
.lines()
.filter(|l| l.contains("[tera]") && l.contains("(0."))
.count();
assert!(
timing_lines >= 1,
"Should have at least one timing entry: {}",
stdout
);
}
#[test]
fn deterministic_build_order() {
let outputs: Vec<Vec<String>> = (0..2)
.map(|_| {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
for name in &["zebra", "alpha", "mango", "banana", "cherry"] {
fs::write(
project_path.join(format!("tera.templates/{}.txt.tera", name)),
format!("content of {}", name),
)
.unwrap();
}
let output = run_rsconstruct_with_env(
project_path,
&["build", "-v", "-j", "1"],
&[("NO_COLOR", "1")],
);
assert!(
output.status.success(),
"Build failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let processing_names: Vec<String> = stdout
.lines()
.filter(|l| l.contains("Processing:"))
.filter_map(|l| l.split("Processing:").nth(1).map(|s| s.trim().to_string()))
.collect();
assert_eq!(
processing_names.len(),
5,
"Should process all 5 templates: {}",
stdout
);
processing_names
})
.collect();
assert_eq!(
outputs[0], outputs[1],
"Build order must be deterministic across runs.\nFirst: {:?}\nSecond: {:?}",
outputs[0], outputs[1]
);
}
#[test]
fn classify_propagates_through_dependencies() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("config/gen.py"), "val = 1").unwrap();
fs::write(
project_path.join("tera.templates/step1.txt.tera"),
"{% set c = load_python(path='config/gen.py') %}step1={{ c.val }}",
)
.unwrap();
let output1 = run_rsconstruct_with_env(project_path, &["build"], &[("NO_COLOR", "1")]);
assert!(
output1.status.success(),
"Phase 1 build failed: stdout={}, stderr={}",
String::from_utf8_lossy(&output1.stdout),
String::from_utf8_lossy(&output1.stderr)
);
assert!(
project_path.join("step1.txt").exists(),
"Tera should generate step1.txt"
);
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.tera]\nsrc_dirs = [\"tera.templates\"]\ndep_inputs = [\"step1.txt\"]\n",
)
.unwrap();
fs::write(project_path.join("tera.templates/step2.txt.tera"), "step2").unwrap();
let output2 = run_rsconstruct_with_env(project_path, &["build"], &[("NO_COLOR", "1")]);
assert!(
output2.status.success(),
"Phase 2 build failed: stdout={}, stderr={}",
String::from_utf8_lossy(&output2.stdout),
String::from_utf8_lossy(&output2.stderr)
);
let output3 = run_rsconstruct_with_env(
project_path,
&["build", "--stop-after", "classify"],
&[("NO_COLOR", "1")],
);
assert!(output3.status.success());
let stdout3 = String::from_utf8_lossy(&output3.stdout);
assert!(
stdout3.contains("0 to build, 0 to restore (2 up-to-date)"),
"Both products should be up-to-date: {}",
stdout3
);
std::thread::sleep(std::time::Duration::from_millis(100));
fs::write(
project_path.join("tera.templates/step1.txt.tera"),
"{% set c = load_python(path='config/gen.py') %}modified={{ c.val }}",
)
.unwrap();
let output4 = run_rsconstruct_with_env(
project_path,
&["build", "--stop-after", "classify"],
&[("NO_COLOR", "1")],
);
assert!(output4.status.success());
let stdout4 = String::from_utf8_lossy(&output4.stdout);
assert!(
stdout4.contains("0 up-to-date"),
"No products should be up-to-date when root dependency changed: {}",
stdout4
);
}
#[test]
fn checker_and_generator_both_rebuild_on_shared_input_change() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
concat!(
"[processor.tera]\n",
"src_dirs = [\"tera.templates\"]\n",
"\n",
"[processor.script]\n",
"src_dirs = [\"tera.templates\"]\n",
"src_extensions = [\".tera\"]\n",
"command = \"true\"\n",
),
)
.unwrap();
fs::write(
project_path.join("tera.templates/shared.txt.tera"),
"version1",
)
.unwrap();
let result1 = run_rsconstruct_json(project_path, &["build"]);
assert!(result1.exit_success, "First build should succeed");
assert!(
result1.has_product("shared.txt", "success"),
"Tera should process: {:?}",
result1.products
);
assert_eq!(result1.failed, 0, "No failures expected");
assert!(
project_path.join("shared.txt").exists(),
"Tera output should exist after first build"
);
let content1 = fs::read_to_string(project_path.join("shared.txt")).unwrap();
assert_eq!(content1, "version1");
let result2 = run_rsconstruct_json(project_path, &["build"]);
assert!(result2.exit_success);
assert_eq!(
result2.skipped, result2.total_products,
"All products should be skipped on second build (no changes)"
);
std::thread::sleep(std::time::Duration::from_millis(100));
fs::write(
project_path.join("tera.templates/shared.txt.tera"),
"version2",
)
.unwrap();
let result3 = run_rsconstruct_json(project_path, &["build"]);
assert!(
result3.exit_success,
"Third build should succeed: {:?}",
result3.errors
);
assert_eq!(
result3.skipped, 0,
"No products should be skipped after input change, got: {:?}",
result3.products
);
assert!(
result3.has_product("shared.txt", "success"),
"Tera generator MUST rebuild after input change: {:?}",
result3.products
);
let content3 = fs::read_to_string(project_path.join("shared.txt")).unwrap();
assert_eq!(
content3, "version2",
"Output should contain new content after rebuild"
);
}
#[test]
fn cross_processor_discovery() {
let temp_dir = tempfile::TempDir::new().unwrap();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
r#"
[processor.tera]
src_dirs = ["tera.templates"]
[processor.ascii]
src_dirs = ["."]
src_extensions = [".txt"]
"#,
)
.unwrap();
fs::create_dir_all(project_path.join("tera.templates")).unwrap();
fs::write(
project_path.join("tera.templates/generated.txt.tera"),
"hello world",
)
.unwrap();
assert!(!project_path.join("generated.txt").exists());
let output = run_rsconstruct_with_env(
project_path,
&["--json", "processors", "files"],
&[("NO_COLOR", "1")],
);
assert!(
output.status.success(),
"processors files failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: Vec<serde_json::Value> = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("JSON parse failed: {}\nOutput: {}", e, stdout));
let ascii_products: Vec<&serde_json::Value> = parsed
.iter()
.filter(|p| p["processor"].as_str() == Some("ascii"))
.collect();
assert!(
!ascii_products.is_empty(),
"ascii processor should have discovered products from tera's output.\n\
All products: {:?}",
parsed
);
let has_generated_input = ascii_products.iter().any(|p| {
p["inputs"]
.as_array()
.unwrap()
.iter()
.any(|i| i.as_str().unwrap().contains("generated.txt"))
});
assert!(
has_generated_input,
"ascii should have generated.txt as input.\nascii products: {:?}",
ascii_products
);
}
#[test]
fn explicit_processor_discovery() {
let temp_dir = tempfile::TempDir::new().unwrap();
let project_path = temp_dir.path();
fs::create_dir_all(project_path.join("data")).unwrap();
fs::write(project_path.join("config.txt"), "config").unwrap();
fs::write(project_path.join("data/a.csv"), "a").unwrap();
fs::write(project_path.join("data/b.csv"), "b").unwrap();
fs::write(project_path.join("data/skip.txt"), "not a csv").unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
r#"
[processor.explicit.report]
command = "scripts/build_report.py"
inputs = ["config.txt"]
input_globs = ["data/*.csv"]
output_files = ["out/report.html"]
src_dirs = ["."]
"#,
)
.unwrap();
let output = run_rsconstruct_with_env(
project_path,
&["--json", "processors", "files"],
&[("NO_COLOR", "1")],
);
assert!(
output.status.success(),
"processors files failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: Vec<serde_json::Value> = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("JSON parse failed: {}\nOutput: {}", e, stdout));
let explicit_products: Vec<&serde_json::Value> = parsed
.iter()
.filter(|p| p["processor"].as_str().unwrap().contains("explicit"))
.collect();
assert_eq!(
explicit_products.len(),
1,
"Expected 1 explicit product, got {}: {:?}",
explicit_products.len(),
explicit_products
);
let product = explicit_products[0];
let inputs: Vec<&str> = product["inputs"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(inputs.len(), 3, "Expected 3 inputs: {:?}", inputs);
assert_eq!(inputs[0], "config.txt");
assert!(
inputs[1].ends_with("a.csv"),
"Expected a.csv, got {}",
inputs[1]
);
assert!(
inputs[2].ends_with("b.csv"),
"Expected b.csv, got {}",
inputs[2]
);
assert!(
!inputs.iter().any(|i| i.contains("skip.txt")),
"skip.txt should not be an input"
);
let outputs: Vec<&str> = product["outputs"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(outputs, vec!["out/report.html"]);
}
#[test]
fn cross_processor_nonexistent_output_dir() {
let temp_dir = tempfile::TempDir::new().unwrap();
let project_path = temp_dir.path();
fs::create_dir_all(project_path.join("tera.templates/out/generated")).unwrap();
fs::write(
project_path.join("tera.templates/out/generated/hello.txt.tera"),
"hello world",
)
.unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
r#"
[processor.tera]
src_dirs = ["tera.templates"]
[processor.ascii]
src_dirs = ["out/generated"]
src_extensions = [".txt"]
"#,
)
.unwrap();
assert!(
!project_path.join("out/generated").exists(),
"out/generated/ should not exist before discovery"
);
let output = run_rsconstruct_with_env(
project_path,
&["--json", "processors", "files"],
&[("NO_COLOR", "1")],
);
assert!(
output.status.success(),
"processors files failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: Vec<serde_json::Value> = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("JSON parse failed: {}\nOutput: {}", e, stdout));
let tera_products: Vec<&serde_json::Value> = parsed
.iter()
.filter(|p| p["processor"].as_str() == Some("tera"))
.collect();
assert_eq!(
tera_products.len(),
1,
"Expected 1 tera product: {:?}",
tera_products
);
let ascii_products: Vec<&serde_json::Value> = parsed
.iter()
.filter(|p| p["processor"].as_str() == Some("ascii"))
.collect();
assert_eq!(
ascii_products.len(),
1,
"ascii should discover 1 product from tera's output in nonexistent dir.\n\
All products: {:?}",
parsed
);
let ascii_input = ascii_products[0]["inputs"].as_array().unwrap()[0]
.as_str()
.unwrap();
assert!(
ascii_input.contains("out/generated/hello.txt"),
"ascii input should be out/generated/hello.txt, got: {}",
ascii_input
);
}
#[test]
fn max_jobs_limits_per_processor_concurrency() {
let temp_dir = tempfile::TempDir::new().unwrap();
let project_path = temp_dir.path();
let script_content = r#"#!/bin/bash
# Track concurrency: atomically increment counter, record peak, sleep, decrement.
COUNTER_FILE="$PROJECT_ROOT/.concurrency_counter"
PEAK_FILE="$PROJECT_ROOT/.concurrency_peak"
LOCK_FILE="$PROJECT_ROOT/.concurrency_lock"
# Increment and record peak
(
flock 9
current=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
current=$((current + 1))
echo "$current" > "$COUNTER_FILE"
peak=$(cat "$PEAK_FILE" 2>/dev/null || echo 0)
if [ "$current" -gt "$peak" ]; then
echo "$current" > "$PEAK_FILE"
fi
) 9>"$LOCK_FILE"
# Hold the slot for a bit so concurrency can be observed
sleep 0.3
# Decrement
(
flock 9
current=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
current=$((current - 1))
echo "$current" > "$COUNTER_FILE"
) 9>"$LOCK_FILE"
"#;
let script_path = project_path.join("check_concurrency.sh");
fs::write(&script_path, script_content).unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
}
let config = format!(
r#"[processor.script]
command = "bash"
args = ["{script}"]
src_extensions = [".txt"]
src_dirs = ["inputs"]
max_jobs = 2
batch = false
"#,
script = script_path.display(),
);
fs::write(project_path.join("rsconstruct.toml"), &config).unwrap();
fs::create_dir_all(project_path.join("inputs")).unwrap();
for i in 0..8 {
fs::write(
project_path.join(format!("inputs/file_{:02}.txt", i)),
format!("content {}", i),
)
.unwrap();
}
fs::write(project_path.join(".concurrency_counter"), "0").unwrap();
fs::write(project_path.join(".concurrency_peak"), "0").unwrap();
let result = run_rsconstruct_json_with_env(
project_path,
&["build", "-j8"],
&[
("NO_COLOR", "1"),
("PROJECT_ROOT", project_path.to_str().unwrap()),
],
);
assert!(
result.exit_success,
"Build should succeed. Errors: {:?}",
result.errors
);
assert_eq!(result.success, 8, "All 8 files should be processed");
let peak: usize = fs::read_to_string(project_path.join(".concurrency_peak"))
.unwrap()
.trim()
.parse()
.unwrap();
assert!(
peak <= 2,
"Peak concurrency was {} but max_jobs=2 should limit it to 2",
peak
);
assert!(
peak >= 1,
"Peak concurrency should be at least 1, got {}",
peak
);
}
#[test]
fn max_jobs_unset_allows_full_parallelism() {
let temp_dir = tempfile::TempDir::new().unwrap();
let project_path = temp_dir.path();
let script_content = r#"#!/bin/bash
COUNTER_FILE="$PROJECT_ROOT/.concurrency_counter"
PEAK_FILE="$PROJECT_ROOT/.concurrency_peak"
LOCK_FILE="$PROJECT_ROOT/.concurrency_lock"
(
flock 9
current=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
current=$((current + 1))
echo "$current" > "$COUNTER_FILE"
peak=$(cat "$PEAK_FILE" 2>/dev/null || echo 0)
if [ "$current" -gt "$peak" ]; then
echo "$current" > "$PEAK_FILE"
fi
) 9>"$LOCK_FILE"
sleep 0.3
(
flock 9
current=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
current=$((current - 1))
echo "$current" > "$COUNTER_FILE"
) 9>"$LOCK_FILE"
"#;
let script_path = project_path.join("check_concurrency.sh");
fs::write(&script_path, script_content).unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
}
let config = format!(
r#"[processor.script]
command = "bash"
args = ["{script}"]
src_extensions = [".txt"]
src_dirs = ["inputs"]
batch = false
"#,
script = script_path.display(),
);
fs::write(project_path.join("rsconstruct.toml"), &config).unwrap();
fs::create_dir_all(project_path.join("inputs")).unwrap();
for i in 0..8 {
fs::write(
project_path.join(format!("inputs/file_{:02}.txt", i)),
format!("content {}", i),
)
.unwrap();
}
fs::write(project_path.join(".concurrency_counter"), "0").unwrap();
fs::write(project_path.join(".concurrency_peak"), "0").unwrap();
let result = run_rsconstruct_json_with_env(
project_path,
&["build", "-j8"],
&[
("NO_COLOR", "1"),
("PROJECT_ROOT", project_path.to_str().unwrap()),
],
);
assert!(
result.exit_success,
"Build should succeed. Errors: {:?}",
result.errors
);
assert_eq!(result.success, 8, "All 8 files should be processed");
let peak: usize = fs::read_to_string(project_path.join(".concurrency_peak"))
.unwrap()
.trim()
.parse()
.unwrap();
assert!(
peak > 2,
"Without max_jobs, peak concurrency should exceed 2 with -j8, got {}",
peak
);
}
#[test]
fn generator_non_batch_partial_failure_only_rebuilds_failed() {
let temp_dir = tempfile::TempDir::new().unwrap();
let project_path = temp_dir.path();
let script_path = project_path.join("transform.sh");
fs::write(
&script_path,
r#"#!/bin/bash
input="$1"; output="$2"
if grep -q "FAIL" "$input"; then
echo "Error: $input contains FAIL" >&2
exit 1
fi
cp "$input" "$output"
"#,
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
}
fs::create_dir_all(project_path.join("src")).unwrap();
fs::write(project_path.join("src/aaa_good1.txt"), "content1\n").unwrap();
fs::write(project_path.join("src/aaa_good2.txt"), "content2\n").unwrap();
fs::write(project_path.join("src/zzz_bad.txt"), "FAIL\n").unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
format!(
r#"[processor.generator]
command = "{script}"
src_extensions = [".txt"]
src_dirs = ["src"]
output_extension = "out"
batch = false
"#,
script = script_path.display(),
),
)
.unwrap();
let result1 = run_rsconstruct_json_with_env(
project_path,
&["build", "--keep-going"],
&[("NO_COLOR", "1")],
);
assert!(
!result1.exit_success,
"First build should fail (zzz_bad.txt)"
);
assert_eq!(result1.success, 2, "Two good files should succeed");
assert_eq!(result1.failed, 1, "One bad file should fail");
fs::write(project_path.join("src/zzz_bad.txt"), "fixed\n").unwrap();
let result2 = run_rsconstruct_json_with_env(project_path, &["build"], &[("NO_COLOR", "1")]);
assert!(result2.exit_success, "Second build should succeed");
assert_eq!(
result2.skipped, 2,
"Two good files should be skipped (cached)"
);
assert_eq!(result2.success, 1, "Only the fixed file should be rebuilt");
}
fn setup_two_processor_project() -> tempfile::TempDir {
let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
let p = temp_dir.path();
fs::create_dir_all(p.join("tera.templates")).unwrap();
fs::create_dir_all(p.join("src")).unwrap();
fs::write(p.join("rsconstruct.toml"), "[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n\n[processor.ruff]\nsrc_dirs = [\"src\"]\n").unwrap();
fs::write(p.join("src/hello.py"), "print('hi')\n").unwrap();
temp_dir
}
#[test]
fn exclude_processor_runs_everything_else() {
let temp_dir = setup_two_processor_project();
let project_path = temp_dir.path();
let output =
run_rsconstruct_with_env(project_path, &["build", "-x", "ruff"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"build with -x ruff must succeed: stdout={}, stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
assert!(
!combined.contains("[ruff]"),
"ruff must not run when -x ruff is passed: {}",
combined
);
}
#[test]
fn exclude_unknown_processor_is_config_error() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let output = run_rsconstruct_with_env(
project_path,
&["build", "-x", "nonexistent"],
&[("NO_COLOR", "1")],
);
assert!(!output.status.success());
let exit_code = output.status.code().unwrap();
assert_eq!(exit_code, 2, "Expected CONFIG_ERROR (2), got {}", exit_code);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Unknown processor"),
"Error should name the unknown processor: {}",
stderr
);
}
#[test]
fn include_and_exclude_same_processor_is_error() {
let temp_dir = setup_two_processor_project();
let project_path = temp_dir.path();
let output = run_rsconstruct_with_env(
project_path,
&["build", "-p", "tera", "-x", "tera"],
&[("NO_COLOR", "1")],
);
assert!(!output.status.success(), "-p tera -x tera must fail");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("both -p and -x"),
"Error should explain the conflict: {}",
stderr
);
}
#[test]
fn svg_change_rebuilds_marp_and_ipdfunite() {
crate::common::require_tool("marp");
let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
let project_path = temp_dir.path();
fs::create_dir_all(project_path.join("marp/courses/deck")).unwrap();
fs::create_dir_all(project_path.join("svg")).unwrap();
fs::write(
project_path.join("svg/img.svg"),
r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="red"/></svg>"#,
).unwrap();
fs::write(
project_path.join("marp/courses/deck/a.md"),
"# Slide\n\n\n",
)
.unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
concat!(
"[processor.marp]\n",
"src_dirs = [\"marp\"]\n",
"timeout_secs = 120\n",
"max_attempts = 3\n",
"[processor.ipdfunite]\n",
"src_dirs = [\"marp\"]\n",
"[analyzer.markdown]\n",
),
)
.unwrap();
let result1 = run_rsconstruct_json(project_path, &["build"]);
assert!(
result1.exit_success,
"Phase 1 build failed (errors={:?}, products={:?})",
result1.errors, result1.products
);
assert!(
project_path.join("out/marp/courses/deck/a.pdf").exists(),
"marp output PDF should exist on disk"
);
assert!(
project_path.join("out/ipdfunite/deck.pdf").exists(),
"ipdfunite merged PDF should exist on disk"
);
std::thread::sleep(std::time::Duration::from_millis(100));
fs::write(
project_path.join("svg/img.svg"),
r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="blue"/></svg>"#,
).unwrap();
let classify = run_rsconstruct_with_env(
project_path,
&["build", "--stop-after", "classify"],
&[("NO_COLOR", "1")],
);
assert!(
classify.status.success(),
"Classify should succeed: stderr={}",
String::from_utf8_lossy(&classify.stderr)
);
let classify_out = String::from_utf8_lossy(&classify.stdout);
assert!(
!classify_out.contains("(2 up-to-date)"),
"After SVG change, not every product should be up-to-date: {}",
classify_out
);
let result3 = run_rsconstruct_json(project_path, &["build"]);
assert!(
result3.exit_success,
"Phase 3 build failed (errors={:?})",
result3.errors
);
let marp_ran = result3
.products
.iter()
.any(|p| p.processor == "marp" && p.status == "success");
assert!(
marp_ran,
"marp must rebuild when its referenced SVG changes: {:?}",
result3.products
);
let ipdfunite_ran = result3
.products
.iter()
.any(|p| p.processor == "ipdfunite" && p.status == "success");
assert!(
ipdfunite_ran,
"ipdfunite must rebuild when its upstream marp PDF changes: {:?}",
result3.products
);
}
#[test]
fn json_mode_stdout_is_pure_json() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
for i in 1..=3 {
fs::write(
project_path.join(format!("tera.templates/f{i}.txt.tera")),
format!("Hello {i}\n"),
)
.unwrap();
}
let cases: [&[&str]; 4] = [
&["--json", "build"],
&["--json", "-v", "build", "--force"],
&["--json", "-v", "build"],
&["--json", "--color=always", "-v", "build", "--force"],
];
for args in cases {
let output = run_rsconstruct_with_env(project_path, args, &[]);
assert!(
output.status.success(),
"build failed for {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines().filter(|l| !l.trim().is_empty()) {
assert!(
!line.contains('\u{1b}'),
"ANSI escape on stdout in JSON mode ({args:?}): {line:?}"
);
assert!(
serde_json::from_str::<serde_json::Value>(line).is_ok(),
"non-JSON line on stdout in JSON mode ({args:?}): {line:?}"
);
}
}
}
#[test]
fn src_dirs_never_defaults_to_scanning_the_tree() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let project_path = temp_dir.path();
fs::create_dir_all(project_path.join("sub")).unwrap();
fs::write(project_path.join("a.py"), "x = 1\n").unwrap();
fs::write(project_path.join("sub/b.py"), "y = 2\n").unwrap();
let cases: [(&str, bool); 4] = [
("[processor.ruff]\n", false),
("[processor.ruff]\nsrc_dirs = []\n", false),
("[processor.ruff]\nsrc_dirs = [\"sub\"]\n", true),
("[processor.ruff]\nsrc_dirs = [\"\"]\n", true),
];
for (config, expect_products) in cases {
fs::write(project_path.join("rsconstruct.toml"), config).unwrap();
let output = run_rsconstruct(project_path, &["build"]);
assert!(
output.status.success(),
"build should succeed for config {config:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let built_nothing = stdout.contains("Nothing to build");
assert_eq!(
!built_nothing,
expect_products,
"config {config:?} should {} have matched files, got:\n{stdout}",
if expect_products { "" } else { "not" }
);
}
}
#[test]
fn state_dir_is_never_indexed() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.ijsonlint]\nsrc_dirs = [\"\"]\n",
)
.unwrap();
fs::write(project_path.join("good.json"), "{\"ok\": true}\n").unwrap();
let build1 = run_rsconstruct(project_path, &["build"]);
assert!(
build1.status.success(),
"first build failed: stdout={} stderr={}",
String::from_utf8_lossy(&build1.stdout),
String::from_utf8_lossy(&build1.stderr)
);
assert!(project_path.join(".rsconstruct").exists());
fs::write(project_path.join(".rsconstruct/bad.json"), "{not json").unwrap();
let build2 = run_rsconstruct(project_path, &["build"]);
let stdout = String::from_utf8_lossy(&build2.stdout);
let stderr = String::from_utf8_lossy(&build2.stderr);
assert!(
build2.status.success(),
"build must not lint the tool's own state dir: stdout={stdout} stderr={stderr}"
);
assert!(
!stdout.contains(".rsconstruct/bad.json") && !stderr.contains(".rsconstruct/bad.json"),
"state-dir file leaked into the build: stdout={stdout} stderr={stderr}"
);
}
#[test]
fn warn_symlinks_knob_controls_the_symlink_warning() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("good.json"), "{\"ok\": true}\n").unwrap();
std::os::unix::fs::symlink("good.json", project_path.join("linked.json")).unwrap();
let quiet_config = "[processor.ijsonlint]\nsrc_dirs = [\"\"]\n";
let loud_config = "[build]\nwarn_symlinks = true\n\n[processor.ijsonlint]\nsrc_dirs = [\"\"]\n";
for (config, expect_warning) in [(quiet_config, false), (loud_config, true)] {
fs::write(project_path.join("rsconstruct.toml"), config).unwrap();
let build = run_rsconstruct(project_path, &["build"]);
let stdout = String::from_utf8_lossy(&build.stdout);
let stderr = String::from_utf8_lossy(&build.stderr);
let warned = stdout.contains("linked.json") || stderr.contains("linked.json");
assert_eq!(
warned,
expect_warning,
"warn_symlinks={expect_warning} should {} warn about the symlink: stdout={stdout} stderr={stderr}",
if expect_warning { "" } else { "not" }
);
}
}
#[test]
fn output_roots_are_not_indexed() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
"[build]\noutput_dir = \"artifacts\"\n\n\
[processor.tera]\nsrc_dirs = [\"tera.templates\"]\noutput_dir = \"generated\"\n\n\
[processor.ijsonlint]\nsrc_dirs = [\"\"]\n",
)
.unwrap();
fs::write(project_path.join("good.json"), "{\"ok\": true}\n").unwrap();
fs::create_dir_all(project_path.join("artifacts")).unwrap();
fs::write(project_path.join("artifacts/bad.json"), "{not json").unwrap();
fs::create_dir_all(project_path.join("generated")).unwrap();
fs::write(project_path.join("generated/bad.json"), "{not json").unwrap();
let build = run_rsconstruct(project_path, &["build"]);
let stdout = String::from_utf8_lossy(&build.stdout);
let stderr = String::from_utf8_lossy(&build.stderr);
assert!(
build.status.success(),
"build must not lint files under output roots: stdout={stdout} stderr={stderr}"
);
assert!(
!stdout.contains("bad.json") && !stderr.contains("bad.json"),
"output-root file leaked into the build: stdout={stdout} stderr={stderr}"
);
}
#[test]
fn src_dirs_under_output_root_are_scanned() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.ijsonlint]\nsrc_dirs = [\"out/checkme\"]\n",
)
.unwrap();
fs::create_dir_all(project_path.join("out/checkme")).unwrap();
fs::write(project_path.join("out/checkme/bad.json"), "{not json").unwrap();
fs::create_dir_all(project_path.join("out/other")).unwrap();
fs::write(project_path.join("out/other/unscanned.json"), "{not json").unwrap();
let build = run_rsconstruct(project_path, &["build"]);
let stdout = String::from_utf8_lossy(&build.stdout);
let stderr = String::from_utf8_lossy(&build.stderr);
assert!(
!build.status.success(),
"explicitly opted-in dir under the output root must be scanned (and fail on the bad file): stdout={stdout} stderr={stderr}"
);
assert!(
stdout.contains("out/checkme/bad.json") || stderr.contains("out/checkme/bad.json"),
"the opted-in file should be the reported failure: stdout={stdout} stderr={stderr}"
);
assert!(
!stdout.contains("unscanned.json") && !stderr.contains("unscanned.json"),
"non-opted-in output dir must stay excluded: stdout={stdout} stderr={stderr}"
);
}
#[test]
fn zero_value_build_knobs_are_rejected() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
for (toml, needle) in [
(
"[build]\nmax_discovery_passes = 0\n",
"max_discovery_passes",
),
("[build]\nmax_arg_len = 0\n", "max_arg_len"),
] {
fs::write(project_path.join("rsconstruct.toml"), toml).unwrap();
let out = run_rsconstruct(project_path, &["build"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"{needle} = 0 must be a config error, not a silent no-op build"
);
assert!(
stderr.contains(needle),
"error must name the offending field: {stderr}"
);
}
}
#[test]
fn no_mtime_cache_chain_converges() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n\n\
[processor.imarkdown2html]\nsrc_dirs = [\"\"]\n",
)
.unwrap();
fs::write(project_path.join("tera.templates/doc.md.tera"), "# one\n").unwrap();
let b1 = run_rsconstruct(project_path, &["build", "--no-mtime-cache"]);
assert!(
b1.status.success(),
"first build failed: stderr={}",
String::from_utf8_lossy(&b1.stderr)
);
fs::write(project_path.join("tera.templates/doc.md.tera"), "# two\n").unwrap();
let b2 = run_rsconstruct(project_path, &["build", "--no-mtime-cache"]);
assert!(
b2.status.success(),
"second build failed: stderr={}",
String::from_utf8_lossy(&b2.stderr)
);
let b3 = run_rsconstruct_with_env(
project_path,
&["build", "--no-mtime-cache", "-v"],
&[("NO_COLOR", "1")],
);
assert!(
b3.status.success(),
"third build failed: stderr={}",
String::from_utf8_lossy(&b3.stderr)
);
let stdout3 = String::from_utf8_lossy(&b3.stdout);
assert!(
!stdout3.contains("Processing:"),
"an unchanged project must be fully cached under --no-mtime-cache: {stdout3}"
);
}
#[test]
fn user_listed_dep_auto_must_exist() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("config/present.py"), "x = 1\n").unwrap();
fs::write(project_path.join("tera.templates/t.txt.tera"), "hello\n").unwrap();
let strict = "[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n\
dep_auto = [\"config/present.py\", \"config/missing.py\"]\n";
fs::write(project_path.join("rsconstruct.toml"), strict).unwrap();
let out = run_rsconstruct(project_path, &["build"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"a listed dep_auto file that does not exist must fail the build: {stderr}"
);
for needle in [
"processor.tera",
"config/missing.py",
"rsconstruct.toml:3",
"allow_missing_dep_auto",
] {
assert!(
stderr.contains(needle),
"error must mention {needle}: {stderr}"
);
}
assert!(
!stderr.contains("config/present.py"),
"the entry that exists must not be reported: {stderr}"
);
let lenient = format!("[build]\nallow_missing_dep_auto = true\n\n{strict}");
fs::write(project_path.join("rsconstruct.toml"), lenient).unwrap();
let out = run_rsconstruct(project_path, &["build"]);
assert!(
out.status.success(),
"allow_missing_dep_auto must restore the skip: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
project_path.join("t.txt").exists(),
"the lenient build must still render the template"
);
}
#[test]
fn src_dirs_entry_must_exist() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("tera.templates/t.txt.tera"), "hello\n").unwrap();
let strict = "[processor.tera]\nsrc_dirs = [\"tera.templates\", \"templates_moved\"]\n";
fs::write(project_path.join("rsconstruct.toml"), strict).unwrap();
let out = run_rsconstruct(project_path, &["build"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"a src_dirs entry that does not exist must fail the build: {stderr}"
);
assert_eq!(
out.status.code(),
Some(2),
"a missing src_dirs entry is a config error (exit 2): {stderr}"
);
for needle in [
"processor.tera",
"templates_moved",
"allow_missing_src_dirs",
] {
assert!(
stderr.contains(needle),
"error must mention {needle}: {stderr}"
);
}
assert!(
!stderr.contains("'tera.templates'"),
"the entry that exists must not be reported: {stderr}"
);
let lenient = format!("[build]\nallow_missing_src_dirs = true\n\n{strict}");
fs::write(project_path.join("rsconstruct.toml"), lenient).unwrap();
let out = run_rsconstruct(project_path, &["build"]);
assert!(
out.status.success(),
"allow_missing_src_dirs must restore the skip: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
project_path.join("t.txt").exists(),
"the lenient build must still render the template"
);
}
#[test]
fn src_dirs_entry_backed_by_upstream_output_is_not_missing() {
let temp_dir = tempfile::TempDir::new().unwrap();
let project_path = temp_dir.path();
let script_path = project_path.join("to_md.sh");
fs::write(
&script_path,
"#!/bin/bash\nprintf '# %s\\n' \"$(cat \"$1\")\" > \"$2\"\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
}
fs::create_dir_all(project_path.join("src")).unwrap();
fs::write(project_path.join("src/a.txt"), "hello\n").unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
format!(
r#"[processor.generator]
command = "{script}"
src_extensions = [".txt"]
src_dirs = ["src"]
output_dir = "out/gen"
output_extension = "md"
batch = false
[processor.markdownlint]
src_dirs = ["out/gen"]
"#,
script = script_path.display(),
),
)
.unwrap();
assert!(!project_path.join("out/gen").exists());
let output =
run_rsconstruct_with_env(project_path, &["processors", "files"], &[("NO_COLOR", "1")]);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"a src_dirs entry fed by an upstream output must not be reported as missing: {stderr}"
);
assert!(
!stderr.contains("does not exist or is not a directory"),
"no missing-directory report expected: {stderr}"
);
}
#[test]
fn src_files_entry_must_exist() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("tera.templates/t.txt.tera"), "hello\n").unwrap();
let strict = "[processor.tera]\nsrc_files = [\"tera.templates/t.txt.tera\", \"tera.templates/gone.tera\"]\n";
fs::write(project_path.join("rsconstruct.toml"), strict).unwrap();
let out = run_rsconstruct(project_path, &["build"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(2),
"a missing src_files entry must fail as a config error: {stderr}"
);
assert!(
stderr.contains("processor.tera") && stderr.contains("tera.templates/gone.tera"),
"error must name the processor and the entry: {stderr}"
);
assert!(
!stderr.contains("'tera.templates/t.txt.tera'"),
"the entry that exists must not be reported: {stderr}"
);
let lenient = format!("[build]\nallow_missing_src_dirs = true\n\n{strict}");
fs::write(project_path.join("rsconstruct.toml"), lenient).unwrap();
let out = run_rsconstruct(project_path, &["build"]);
assert_eq!(
out.status.code(),
Some(2),
"allow_missing_src_dirs must not forgive a missing file: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn src_files_entry_backed_by_upstream_output_is_not_missing() {
let temp_dir = tempfile::TempDir::new().unwrap();
let project_path = temp_dir.path();
let script_path = project_path.join("to_md.sh");
fs::write(
&script_path,
"#!/bin/bash\nprintf '# %s\\n' \"$(cat \"$1\")\" > \"$2\"\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
}
fs::create_dir_all(project_path.join("src")).unwrap();
fs::write(project_path.join("src/a.txt"), "hello\n").unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
format!(
r#"[processor.generator]
command = "{script}"
src_extensions = [".txt"]
src_dirs = ["src"]
output_dir = "out/gen"
output_extension = "md"
batch = false
[processor.markdownlint]
src_files = ["out/gen/a.md"]
"#,
script = script_path.display(),
),
)
.unwrap();
assert!(!project_path.join("out/gen/a.md").exists());
let output =
run_rsconstruct_with_env(project_path, &["processors", "files"], &[("NO_COLOR", "1")]);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"a src_files entry fed by an upstream output must not be reported as missing: {stderr}"
);
}
#[test]
fn src_files_alone_matches_only_the_named_files() {
let temp_dir = tempfile::TempDir::new().unwrap();
let project_path = temp_dir.path();
fs::write(project_path.join("a.md"), "# a\n").unwrap();
fs::create_dir_all(project_path.join("sub")).unwrap();
fs::write(project_path.join("sub/b.md"), "# b\n").unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.markdownlint]\nsrc_files = [\"a.md\"]\n",
)
.unwrap();
let output = run_rsconstruct_with_env(
project_path,
&["--json", "processors", "files"],
&[("NO_COLOR", "1")],
);
assert!(
output.status.success(),
"processors files failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: Vec<serde_json::Value> = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("JSON parse failed: {e}\nOutput: {stdout}"));
let inputs: Vec<String> = parsed
.iter()
.filter(|p| p["processor"].as_str() == Some("markdownlint"))
.flat_map(|p| p["inputs"].as_array().unwrap().iter())
.map(|i| i.as_str().unwrap().to_string())
.collect();
assert_eq!(
inputs,
vec!["a.md".to_string()],
"only the named file may be matched, got {inputs:?}"
);
}
#[test]
fn reject_dot_src_dirs_is_opt_in() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("tera.templates/t.txt.tera"), "hello\n").unwrap();
let dot = "[processor.tera]\nsrc_dirs = [\".\"]\n";
fs::write(project_path.join("rsconstruct.toml"), dot).unwrap();
let out = run_rsconstruct(project_path, &["build"]);
assert!(
out.status.success(),
"\".\" must be accepted by default: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(project_path.join("t.txt").exists());
let strict = format!("[build]\nreject_dot_src_dirs = true\n\n{dot}");
fs::write(project_path.join("rsconstruct.toml"), strict).unwrap();
let out = run_rsconstruct(project_path, &["build"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(2),
"with the switch on, \".\" must be a config error: {stderr}"
);
for needle in [
"processor.tera",
"reject_dot_src_dirs",
"rsconstruct.toml:5",
] {
assert!(
stderr.contains(needle),
"error must mention {needle}: {stderr}"
);
}
let root = "[build]\nreject_dot_src_dirs = true\n\n[processor.tera]\nsrc_dirs = [\"\"]\n";
fs::write(project_path.join("rsconstruct.toml"), root).unwrap();
let out = run_rsconstruct(project_path, &["build"]);
assert!(
out.status.success(),
"\"\" is the documented whole-tree spelling and must stay accepted: {}",
String::from_utf8_lossy(&out.stderr)
);
let disabled = "[build]\nreject_dot_src_dirs = true\n\n[processor.tera]\nsrc_dirs = [\".\"]\nenabled = false\n";
fs::write(project_path.join("rsconstruct.toml"), disabled).unwrap();
let out = run_rsconstruct(project_path, &["build"]);
assert!(
out.status.success(),
"a disabled stanza is not checked: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn default_dep_auto_stays_optional() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("ok.yaml"), "key: value\n").unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.yamllint]\nsrc_dirs = [\"\"]\n",
)
.unwrap();
assert!(!project_path.join(".yamllint").exists());
let out = run_rsconstruct(project_path, &["build"]);
assert!(
out.status.success(),
"an absent default dep_auto file must not fail the build: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
}