use crate::common::{run_rsconstruct, run_rsconstruct_with_env, setup_test_project};
use std::fs;
use tempfile::TempDir;
#[test]
fn tera_to_file_translation() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let config_content = r#"
project_name = "TestProject"
version = "1.2.3"
author = "Test Author"
debug_mode = True
features = ["logging", "caching", "metrics"]
max_connections = 100
"#;
fs::write(project_path.join("config/test_config.py"), config_content)
.expect("Failed to write config file");
let tera_content = r#"{% set cfg = load_python(path="config/test_config.py") %}
# Generated configuration for {{ cfg.project_name }}
# Version: {{ cfg.version }}
# Author: {{ cfg.author }}
[settings]
project = "{{ cfg.project_name }}"
version = "{{ cfg.version }}"
debug = {{ cfg.debug_mode }}
max_connections = {{ cfg.max_connections }}
[features]
{% for feature in cfg.features -%}
{{ feature }} = enabled
{% endfor %}
# Build information
{% if cfg.debug_mode -%}
build_type = "debug"
optimization = 0
{% else -%}
build_type = "release"
optimization = 3
{% endif -%}
"#;
fs::write(
project_path.join("tera.templates/app.config.tera"),
tera_content,
)
.expect("Failed to write tera file");
let output = run_rsconstruct_with_env(project_path, &["build"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"rsconstruct build failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let output_file = project_path.join("app.config");
assert!(output_file.exists(), "Output file was not created");
let generated_content =
fs::read_to_string(&output_file).expect("Failed to read generated file");
assert!(generated_content.contains("Generated configuration for TestProject"));
assert!(generated_content.contains("Version: 1.2.3"));
assert!(generated_content.contains("Author: Test Author"));
assert!(generated_content.contains("debug = true"));
assert!(generated_content.contains("max_connections = 100"));
assert!(generated_content.contains("logging = enabled"));
assert!(generated_content.contains("caching = enabled"));
assert!(generated_content.contains("metrics = enabled"));
assert!(generated_content.contains("build_type = \"debug\""));
assert!(generated_content.contains("optimization = 0"));
println!("Generated file content:\n{}", generated_content);
}
#[test]
fn incremental_build() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("config/simple.py"),
"name = 'SimpleTest'\ncount = 42",
)
.expect("Failed to write config");
fs::write(
project_path.join("tera.templates/simple.txt.tera"),
"{% set c = load_python(path='config/simple.py') %}Name: {{ c.name }}, Count: {{ c.count }}"
).expect("Failed to write tera");
let output1 = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(output1.status.success());
let stdout1 = String::from_utf8_lossy(&output1.stdout);
assert!(stdout1.contains("Processing:"));
let output2 =
run_rsconstruct_with_env(project_path, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(output2.status.success());
let stdout2 = String::from_utf8_lossy(&output2.stdout);
assert!(stdout2.contains("[tera] Skipping (unchanged):"));
assert!(project_path.join(".rsconstruct/db.redb").exists());
}
#[test]
fn multiple_templates() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let config = "shared_name = 'MultiTest'\nshared_value = 123";
fs::write(project_path.join("config/shared.py"), config).unwrap();
fs::write(
project_path.join("tera.templates/first.txt.tera"),
"{% set c = load_python(path='config/shared.py') %}First: {{ c.shared_name }}",
)
.unwrap();
fs::write(
project_path.join("tera.templates/second.conf.tera"),
"{% set c = load_python(path='config/shared.py') %}[config]\nname={{ c.shared_name }}\nvalue={{ c.shared_value }}"
).unwrap();
fs::write(
project_path.join("tera.templates/third.json.tera"),
r#"{% set c = load_python(path='config/shared.py') %}{"name": "{{ c.shared_name }}", "value": {{ c.shared_value }}}"#
).unwrap();
let output = run_rsconstruct(project_path, &["build"]);
assert!(output.status.success());
assert!(project_path.join("first.txt").exists());
assert!(project_path.join("second.conf").exists());
assert!(project_path.join("third.json").exists());
let first = fs::read_to_string(project_path.join("first.txt")).unwrap();
assert_eq!(first.trim(), "First: MultiTest");
let third = fs::read_to_string(project_path.join("third.json")).unwrap();
assert!(third.contains(r#""name": "MultiTest""#));
assert!(third.contains(r#""value": 123"#));
}
#[test]
fn dep_inputs_triggers_rebuild() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("config/settings.py"), "name = 'Original'").unwrap();
fs::write(
project_path.join("tera.templates/output.txt.tera"),
"{% set c = load_python(path='config/settings.py') %}Name: {{ c.name }}",
)
.unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.tera]\nsrc_dirs = [\"tera.templates\"]\ndep_inputs = [\"config/settings.py\"]\n"
).unwrap();
let output1 = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(
output1.status.success(),
"First build failed: stdout={}, stderr={}",
String::from_utf8_lossy(&output1.stdout),
String::from_utf8_lossy(&output1.stderr)
);
let stdout1 = String::from_utf8_lossy(&output1.stdout);
assert!(
stdout1.contains("Processing:"),
"First build should process: {}",
stdout1
);
let output2 =
run_rsconstruct_with_env(project_path, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(output2.status.success());
let stdout2 = String::from_utf8_lossy(&output2.stdout);
assert!(
stdout2.contains("[tera] Skipping (unchanged):"),
"Second build should skip: {}",
stdout2
);
std::thread::sleep(std::time::Duration::from_millis(100));
fs::write(project_path.join("config/settings.py"), "name = 'Modified'").unwrap();
let output3 = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(
output3.status.success(),
"Third build failed: stdout={}, stderr={}",
String::from_utf8_lossy(&output3.stdout),
String::from_utf8_lossy(&output3.stderr)
);
let stdout3 = String::from_utf8_lossy(&output3.stdout);
assert!(
stdout3.contains("Processing:"),
"Build after extra_input change should reprocess, not skip: {}",
stdout3
);
let content = fs::read_to_string(project_path.join("output.txt")).unwrap();
assert!(
content.contains("Modified"),
"Output should reflect the modified config: {}",
content
);
}
#[test]
fn dep_inputs_nonexistent_file_fails() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("config/simple.py"), "val = 'test'").unwrap();
fs::write(
project_path.join("tera.templates/simple.txt.tera"),
"{% set c = load_python(path='config/simple.py') %}{{ c.val }}",
)
.unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.tera]\nsrc_dirs = [\"tera.templates\"]\ndep_inputs = [\"nonexistent_file.txt\"]\n"
).unwrap();
let output = run_rsconstruct_with_env(project_path, &["build"], &[("NO_COLOR", "1")]);
assert!(
!output.status.success(),
"Build should fail with nonexistent extra_input: stdout={}, stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("dep_inputs file not found") || stderr.contains("nonexistent_file.txt"),
"Error should mention missing dep_inputs file: {}",
stderr
);
}
#[test]
fn subdirectory_output() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::create_dir_all(project_path.join("tera.templates/sub")).unwrap();
fs::write(
project_path.join("tera.templates/sub/output.txt.tera"),
"Hello from subdirectory",
)
.unwrap();
let output = run_rsconstruct_with_env(project_path, &["build"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"rsconstruct build failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let output_file = project_path.join("sub/output.txt");
assert!(
output_file.exists(),
"Output file sub/output.txt was not created"
);
let content = fs::read_to_string(&output_file).unwrap();
assert_eq!(content, "Hello from subdirectory");
}
fn setup_glob_project(template_body: &str) -> TempDir {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let project_path = temp_dir.path();
fs::create_dir_all(project_path.join("tera.templates")).unwrap();
fs::write(
project_path.join("rsconstruct.toml"),
"[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n[analyzer.tera]\n",
)
.unwrap();
fs::write(
project_path.join("tera.templates/report.txt.tera"),
template_body,
)
.unwrap();
temp_dir
}
#[test]
fn glob_counts_matching_files() {
let project = setup_glob_project("Total: {{ glob(pattern=\"data/**/*.md\") | length }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
fs::write(p.join("data/b.md"), "b").unwrap();
fs::write(p.join("data/c.md"), "c").unwrap();
fs::write(p.join("data/ignore.txt"), "x").unwrap();
let output = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"build failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let report = fs::read_to_string(p.join("report.txt")).unwrap();
assert_eq!(report.trim(), "Total: 3", "Got: {}", report);
}
#[test]
fn glob_invalidates_when_file_added() {
let project = setup_glob_project("Total: {{ glob(pattern=\"data/**/*.md\") | length }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
fs::write(p.join("data/b.md"), "b").unwrap();
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
out1.status.success(),
"first build failed: {}",
String::from_utf8_lossy(&out1.stderr)
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Total: 2"
);
fs::write(p.join("data/c.md"), "c").unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(
out2.status.success(),
"second build failed: {}",
String::from_utf8_lossy(&out2.stderr)
);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
!stdout2.contains("[tera] Skipping (unchanged):"),
"Second build should NOT have skipped after adding a glob-matched file. stdout={}",
stdout2,
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Total: 3"
);
}
#[test]
fn glob_invalidates_when_file_removed() {
let project = setup_glob_project("Total: {{ glob(pattern=\"data/**/*.md\") | length }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
fs::write(p.join("data/b.md"), "b").unwrap();
fs::write(p.join("data/c.md"), "c").unwrap();
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(out1.status.success());
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Total: 3"
);
fs::remove_file(p.join("data/c.md")).unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(out2.status.success());
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
!stdout2.contains("[tera] Skipping (unchanged):"),
"Second build should NOT have skipped after removing a glob-matched file. stdout={}",
stdout2,
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Total: 2"
);
}
#[test]
fn glob_invalidates_when_file_renamed() {
let project =
setup_glob_project("Sorted: {{ glob(pattern=\"data/**/*.md\") | join(sep=\",\") }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/old_name.md"), "same-content").unwrap();
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(out1.status.success());
let report1 = fs::read_to_string(p.join("report.txt")).unwrap();
assert!(report1.contains("old_name.md"), "Got: {}", report1);
fs::rename(p.join("data/old_name.md"), p.join("data/new_name.md")).unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(out2.status.success());
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
!stdout2.contains("[tera] Skipping (unchanged):"),
"Second build should NOT have skipped after rename. stdout={}",
stdout2,
);
let report2 = fs::read_to_string(p.join("report.txt")).unwrap();
assert!(report2.contains("new_name.md"), "Got: {}", report2);
assert!(!report2.contains("old_name.md"), "Got: {}", report2);
}
#[test]
fn shell_output_without_depends_on_is_rejected() {
let project = setup_glob_project("Count: {{ shell_output(command=\"ls data | wc -l\") }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
let output = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
!output.status.success(),
"Build should have failed for shell_output without depends_on. 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("depends_on"),
"Error message should mention depends_on. Got: {}",
combined,
);
}
#[test]
fn shell_output_with_depends_on_succeeds() {
let project = setup_glob_project(
"Count: {{ shell_output(command=\"ls data | wc -l\", depends_on=[\"data/**/*.md\"]) }}\n",
);
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
fs::write(p.join("data/b.md"), "b").unwrap();
let output = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"Build with depends_on should have succeeded. stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let report = fs::read_to_string(p.join("report.txt")).unwrap();
assert_eq!(report.trim(), "Count: 2", "Got: {}", report);
}
#[test]
fn shell_output_invalidates_on_matching_file_change() {
let project = setup_glob_project(
"Lines: {{ shell_output(command=\"cat data/*.md | wc -l\", depends_on=[\"data/**/*.md\"]) }}\n",
);
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "one\ntwo\n").unwrap();
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
out1.status.success(),
"first build failed: {}",
String::from_utf8_lossy(&out1.stderr)
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Lines: 2"
);
fs::write(p.join("data/a.md"), "one\ntwo\nthree\n").unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(
out2.status.success(),
"second build failed: {}",
String::from_utf8_lossy(&out2.stderr)
);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
!stdout2.contains("[tera] Skipping (unchanged):"),
"Second build should NOT have skipped after editing a depends_on-matched file. stdout={}",
stdout2,
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Lines: 3"
);
}
#[test]
fn shell_output_invalidates_when_command_edited() {
let project =
setup_glob_project("Out: {{ shell_output(command=\"echo first\", depends_on=[]) }}\n");
let p = project.path();
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
out1.status.success(),
"first build failed: {}",
String::from_utf8_lossy(&out1.stderr)
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Out: first"
);
fs::write(
p.join("tera.templates/report.txt.tera"),
"Out: {{ shell_output(command=\"echo second\", depends_on=[]) }}\n",
)
.unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(
out2.status.success(),
"second build failed: {}",
String::from_utf8_lossy(&out2.stderr)
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Out: second"
);
}
fn git_init_and_commit(project_path: &std::path::Path) {
use std::process::Command;
let run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(project_path)
.args(args)
.output()
.expect("git invocation failed");
assert!(
status.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&status.stderr)
);
};
run(&["init", "-q", "-b", "main"]);
run(&["config", "user.email", "test@example.com"]);
run(&["config", "user.name", "Test"]);
run(&["add", "-A"]);
run(&["commit", "-q", "-m", "init"]);
}
#[test]
fn git_count_files_counts_only_tracked() {
let project = setup_glob_project("Total: {{ git_count_files(pattern=\"data/*.md\") }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
fs::write(p.join("data/b.md"), "b").unwrap();
git_init_and_commit(p);
fs::write(p.join("data/untracked.md"), "x").unwrap();
let output = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"build failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let report = fs::read_to_string(p.join("report.txt")).unwrap();
assert_eq!(report.trim(), "Total: 2", "Got: {}", report);
}
#[test]
fn git_count_files_invalidates_when_file_committed() {
let project = setup_glob_project("Total: {{ git_count_files(pattern=\"data/*.md\") }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
fs::write(p.join("data/b.md"), "b").unwrap();
git_init_and_commit(p);
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
out1.status.success(),
"first build failed: {}",
String::from_utf8_lossy(&out1.stderr)
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Total: 2"
);
fs::write(p.join("data/c.md"), "c").unwrap();
use std::process::Command;
Command::new("git")
.current_dir(p)
.args(["add", "data/c.md"])
.output()
.unwrap();
Command::new("git")
.current_dir(p)
.args(["commit", "-q", "-m", "add c"])
.output()
.unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(
out2.status.success(),
"second build failed: {}",
String::from_utf8_lossy(&out2.stderr)
);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
!stdout2.contains("[tera] Skipping (unchanged):"),
"Second build should NOT have skipped after committing a new tracked file. stdout={}",
stdout2,
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Total: 3"
);
}
#[test]
fn git_count_files_skips_when_only_untracked_added() {
let project = setup_glob_project("Total: {{ git_count_files(pattern=\"data/*.md\") }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
fs::write(p.join("data/b.md"), "b").unwrap();
git_init_and_commit(p);
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(out1.status.success());
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Total: 2"
);
fs::write(p.join("data/untracked.md"), "x").unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(out2.status.success());
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
stdout2.contains("[tera] Skipping (unchanged):"),
"Second build SHOULD have skipped — untracked files don't affect git_count_files. stdout={}",
stdout2,
);
}
#[test]
fn glob_skips_when_matched_file_content_changes() {
let project = setup_glob_project("Total: {{ glob(pattern=\"data/**/*.md\") | length }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "original").unwrap();
fs::write(p.join("data/b.md"), "original").unwrap();
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(out1.status.success());
fs::write(p.join("data/a.md"), "edited").unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(out2.status.success());
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
stdout2.contains("[tera] Skipping (unchanged):"),
"Second build SHOULD skip — glob tracks names, not content. stdout={}",
stdout2,
);
}
#[test]
fn git_count_files_skips_when_tracked_file_content_changes() {
let project = setup_glob_project("Total: {{ git_count_files(pattern=\"data/*.md\") }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "original").unwrap();
fs::write(p.join("data/b.md"), "original").unwrap();
git_init_and_commit(p);
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(out1.status.success());
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Total: 2"
);
fs::write(p.join("data/a.md"), "edited").unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(out2.status.success());
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
stdout2.contains("[tera] Skipping (unchanged):"),
"Second build SHOULD skip — git_count_files tracks count, not content. stdout={}",
stdout2,
);
}
#[test]
fn grep_count_counts_matching_lines() {
let project =
setup_glob_project("TODOs: {{ grep_count(pattern=\"^TODO\", glob=\"src/**/*.txt\") }}\n");
let p = project.path();
fs::create_dir_all(p.join("src")).unwrap();
fs::write(p.join("src/a.txt"), "TODO: x\nfoo\nTODO: y\n").unwrap();
fs::write(p.join("src/b.txt"), "no todos here\nTODO: z\n").unwrap();
let output = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"build failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let report = fs::read_to_string(p.join("report.txt")).unwrap();
assert_eq!(report.trim(), "TODOs: 3", "Got: {}", report);
}
#[test]
fn grep_count_invalidates_when_matched_file_content_changes() {
let project =
setup_glob_project("TODOs: {{ grep_count(pattern=\"^TODO\", glob=\"src/**/*.txt\") }}\n");
let p = project.path();
fs::create_dir_all(p.join("src")).unwrap();
fs::write(p.join("src/a.txt"), "TODO: x\n").unwrap();
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
out1.status.success(),
"first build failed: {}",
String::from_utf8_lossy(&out1.stderr)
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"TODOs: 1"
);
fs::write(p.join("src/a.txt"), "TODO: x\nTODO: y\n").unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(
out2.status.success(),
"second build failed: {}",
String::from_utf8_lossy(&out2.stderr)
);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
!stdout2.contains("[tera] Skipping (unchanged):"),
"Second build should NOT skip — grep_count tracks content. stdout={}",
stdout2,
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"TODOs: 2"
);
}
#[test]
fn grep_count_invalidates_when_regex_changes() {
let project =
setup_glob_project("Hits: {{ grep_count(pattern=\"^TODO\", glob=\"src/**/*.txt\") }}\n");
let p = project.path();
fs::create_dir_all(p.join("src")).unwrap();
fs::write(p.join("src/a.txt"), "TODO: x\nFIXME: y\n").unwrap();
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(out1.status.success());
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Hits: 1"
);
fs::write(
p.join("tera.templates/report.txt.tera"),
"Hits: {{ grep_count(pattern=\"^FIXME\", glob=\"src/**/*.txt\") }}\n",
)
.unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(out2.status.success());
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Hits: 1"
);
}
#[test]
fn glob_in_included_snippet_is_tracked() {
let temp_dir = 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("tera.snippets")).unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(
p.join("rsconstruct.toml"),
"[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n[analyzer.tera]\n",
)
.unwrap();
fs::write(
p.join("tera.templates/report.txt.tera"),
"Header\n{% include \"tera.snippets/main.md.tera\" %}\nFooter\n",
)
.unwrap();
fs::write(
p.join("tera.snippets/main.md.tera"),
"Total: {{ glob(pattern=\"data/**/*.md\") | length }}\n",
)
.unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
fs::write(p.join("data/b.md"), "b").unwrap();
let out1 = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
out1.status.success(),
"first build failed: {}",
String::from_utf8_lossy(&out1.stderr)
);
let report = fs::read_to_string(p.join("report.txt")).unwrap();
assert!(
report.contains("Total: 2"),
"report should reflect 2 files: {}",
report
);
fs::write(p.join("data/c.md"), "c").unwrap();
let out2 = run_rsconstruct_with_env(p, &["build", "--verbose"], &[("NO_COLOR", "1")]);
assert!(
out2.status.success(),
"second build failed: {}",
String::from_utf8_lossy(&out2.stderr)
);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
!stdout2.contains("[tera] Skipping (unchanged):"),
"Second build should NOT skip — glob in included snippet matched a new file. stdout={}",
stdout2,
);
let report2 = fs::read_to_string(p.join("report.txt")).unwrap();
assert!(
report2.contains("Total: 3"),
"report should reflect 3 files after add: {}",
report2
);
}
#[test]
fn glob_no_matches_returns_empty_list() {
let project =
setup_glob_project("Total: {{ glob(pattern=\"nonexistent/**/*.md\") | length }}\n");
let p = project.path();
let output = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"build with empty glob should succeed. stderr={}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read_to_string(p.join("report.txt")).unwrap().trim(),
"Total: 0"
);
}
#[test]
fn show_files_hash_pieces_surfaces_glob_state() {
let project = setup_glob_project("Total: {{ glob(pattern=\"data/*.md\") | length }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
fs::write(p.join("data/b.md"), "b").unwrap();
let build = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
build.status.success(),
"build failed: {}",
String::from_utf8_lossy(&build.stderr)
);
let out = run_rsconstruct_with_env(
p,
&[
"analyzers",
"show",
"files",
"tera.templates/report.txt.tera",
"--hash-pieces",
],
&[("NO_COLOR", "1")],
);
assert!(
out.status.success(),
"show files --hash-pieces failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("hash pieces:"),
"expected 'hash pieces:' header in output: {}",
stdout
);
assert!(
stdout.contains("glob") && stdout.contains("data/*.md"),
"expected glob pattern in hash pieces: {}",
stdout
);
assert!(
stdout.contains("data/a.md") && stdout.contains("data/b.md"),
"expected resolved file list in hash pieces: {}",
stdout
);
}
#[test]
fn show_files_hash_pieces_json_shape() {
let project = setup_glob_project("Total: {{ glob(pattern=\"data/*.md\") | length }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
let build = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(build.status.success());
let out = run_rsconstruct_with_env(
p,
&[
"--json",
"analyzers",
"show",
"files",
"tera.templates/report.txt.tera",
"--hash-pieces",
],
&[("NO_COLOR", "1")],
);
assert!(
out.status.success(),
"json show files --hash-pieces failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("invalid JSON: {}\n---\n{}", e, stdout));
let arr = parsed.as_array().expect("top-level JSON must be an array");
assert_eq!(arr.len(), 1, "expected one entry, got: {}", stdout);
let entry = &arr[0];
let pieces = entry
.get("hash_pieces")
.and_then(|v| v.as_array())
.unwrap_or_else(|| panic!("hash_pieces field missing or not array: {}", stdout));
let joined = pieces
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
joined.contains("glob:data/*.md"),
"expected 'glob:data/*.md' piece, got: {}",
joined
);
assert!(
joined.contains("data/a.md"),
"expected resolved file list to mention data/a.md, got: {}",
joined
);
}
#[test]
fn show_files_without_hash_pieces_omits_field() {
let project = setup_glob_project("Total: {{ glob(pattern=\"data/*.md\") | length }}\n");
let p = project.path();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/a.md"), "a").unwrap();
let build = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(build.status.success());
let out = run_rsconstruct_with_env(
p,
&[
"--json",
"analyzers",
"show",
"files",
"tera.templates/report.txt.tera",
],
&[("NO_COLOR", "1")],
);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("invalid JSON: {}\n---\n{}", e, stdout));
let entry = &parsed.as_array().expect("array")[0];
assert!(
entry.get("hash_pieces").is_none(),
"hash_pieces field must be absent when flag is omitted: {}",
stdout
);
}
#[test]
fn copyright_years_falls_back_when_no_commits() {
let project = setup_glob_project("© {{ copyright_years() }}\n");
let p = project.path();
let output = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"build failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let report = fs::read_to_string(p.join("report.txt")).unwrap();
let current_year = chrono::Local::now().format("%Y").to_string();
assert_eq!(
report.trim(),
format!("© {}", current_year),
"Got: {}",
report
);
}
#[test]
fn copyright_years_uses_first_commit_year() {
let project = setup_glob_project("© {{ copyright_years() }}\n");
let p = project.path();
git_init_and_commit(p);
let output = run_rsconstruct_with_env(p, &["build"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"build failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let report = fs::read_to_string(p.join("report.txt")).unwrap();
let current_year: i32 = chrono::Local::now()
.format("%Y")
.to_string()
.parse()
.unwrap();
assert!(
report.trim().ends_with(¤t_year.to_string()),
"Got: {}",
report
);
}
#[test]
fn analyzer_tracks_load_python_without_dep_inputs() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
"[analyzer.tera]\n\n[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n",
)
.unwrap();
fs::write(project_path.join("config/tracked.py"), "name = 'Original'").unwrap();
fs::write(
project_path.join("tera.templates/tracked.txt.tera"),
"{% set c = load_python(path='config/tracked.py') %}Name: {{ c.name }}",
)
.unwrap();
let output1 = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(
output1.status.success(),
"First build failed: stdout={}, stderr={}",
String::from_utf8_lossy(&output1.stdout),
String::from_utf8_lossy(&output1.stderr)
);
std::thread::sleep(std::time::Duration::from_millis(100));
fs::write(project_path.join("config/tracked.py"), "name = 'Modified'").unwrap();
let output2 = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(output2.status.success());
let content = fs::read_to_string(project_path.join("tracked.txt")).unwrap();
assert!(
content.contains("Modified"),
"Editing a load_python config must rebuild the product: {}",
content
);
}
#[test]
fn analyzer_tracks_version_str_default_path() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
"[analyzer.tera]\n\n[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n",
)
.unwrap();
fs::write(project_path.join("config/version.py"), "tup = (1, 2, 3)").unwrap();
fs::write(
project_path.join("tera.templates/version.txt.tera"),
"version: {{ version_str() }}",
)
.unwrap();
let output1 = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(
output1.status.success(),
"First build failed: stdout={}, stderr={}",
String::from_utf8_lossy(&output1.stdout),
String::from_utf8_lossy(&output1.stderr)
);
let content = fs::read_to_string(project_path.join("version.txt")).unwrap();
assert!(content.contains("1.2.3"), "Got: {}", content);
std::thread::sleep(std::time::Duration::from_millis(100));
fs::write(project_path.join("config/version.py"), "tup = (1, 2, 4)").unwrap();
let output2 = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(output2.status.success());
let content = fs::read_to_string(project_path.join("version.txt")).unwrap();
assert!(
content.contains("1.2.4"),
"Bumping config/version.py must rebuild a version_str() product: {}",
content
);
}
#[test]
fn analyzer_tracks_version_str_explicit_lua_path() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
"[analyzer.tera]\n\n[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n",
)
.unwrap();
fs::write(project_path.join("config/version.lua"), "tup = { 2, 0, 0 }").unwrap();
fs::write(
project_path.join("tera.templates/vlua.txt.tera"),
r#"version: {{ version_str(path="config/version.lua") }}"#,
)
.unwrap();
let output1 = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(
output1.status.success(),
"First build failed: stdout={}, stderr={}",
String::from_utf8_lossy(&output1.stdout),
String::from_utf8_lossy(&output1.stderr)
);
let content = fs::read_to_string(project_path.join("vlua.txt")).unwrap();
assert!(content.contains("2.0.0"), "Got: {}", content);
std::thread::sleep(std::time::Duration::from_millis(100));
fs::write(project_path.join("config/version.lua"), "tup = { 2, 0, 1 }").unwrap();
let output2 = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(output2.status.success());
let content = fs::read_to_string(project_path.join("vlua.txt")).unwrap();
assert!(
content.contains("2.0.1"),
"Bumping the version_str(path=...) file must rebuild the product: {}",
content
);
}
#[test]
fn load_toml_and_toml_get_read_values() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("data.toml"),
"[project]\nname = \"demo\"\nversion = \"0.0.24\"\n\n[tool.ruff]\nline-length = 130\n",
)
.unwrap();
fs::write(
project_path.join("tera.templates/toml_out.txt.tera"),
concat!(
"get: {{ toml_get(path=\"data.toml\", key=\"project.version\") }}\n",
"nested: {{ toml_get(path=\"data.toml\", key=\"tool.ruff.line-length\") }}\n",
"load: {% set cfg = load_toml(path=\"data.toml\") %}{{ cfg.project.name }}\n",
),
)
.unwrap();
let output = run_rsconstruct_with_env(project_path, &["build"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"build failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let content = fs::read_to_string(project_path.join("toml_out.txt")).unwrap();
assert!(content.contains("get: 0.0.24"), "Got: {}", content);
assert!(content.contains("nested: 130"), "Got: {}", content);
assert!(content.contains("load: demo"), "Got: {}", content);
}
#[test]
fn analyzer_tracks_toml_get_path() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("rsconstruct.toml"),
"[analyzer.tera]\n\n[processor.tera]\nsrc_dirs = [\"tera.templates\"]\n",
)
.unwrap();
fs::write(
project_path.join("pyproject.toml"),
"[project]\nversion = \"2.0.0\"\n",
)
.unwrap();
fs::write(
project_path.join("tera.templates/vtoml.txt.tera"),
"version: {{ toml_get(path=\"pyproject.toml\", key=\"project.version\") }}\n",
)
.unwrap();
let output1 = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(
output1.status.success(),
"First build failed: stdout={}, stderr={}",
String::from_utf8_lossy(&output1.stdout),
String::from_utf8_lossy(&output1.stderr)
);
let content = fs::read_to_string(project_path.join("vtoml.txt")).unwrap();
assert!(content.contains("2.0.0"), "Got: {}", content);
std::thread::sleep(std::time::Duration::from_millis(100));
fs::write(
project_path.join("pyproject.toml"),
"[project]\nversion = \"2.0.1\"\n",
)
.unwrap();
let output2 = run_rsconstruct_with_env(project_path, &["build", "-v"], &[("NO_COLOR", "1")]);
assert!(output2.status.success());
let content = fs::read_to_string(project_path.join("vtoml.txt")).unwrap();
assert!(
content.contains("2.0.1"),
"Bumping the toml_get(path=...) file must rebuild the product: {}",
content
);
}
#[test]
fn toml_get_missing_key_is_an_error() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("data.toml"),
"[project]\nversion = \"1.0\"\n",
)
.unwrap();
fs::write(
project_path.join("tera.templates/bad.txt.tera"),
"{{ toml_get(path=\"data.toml\", key=\"project.nope\") }}\n",
)
.unwrap();
let output = run_rsconstruct_with_env(project_path, &["build"], &[("NO_COLOR", "1")]);
assert!(
!output.status.success(),
"A missing key must fail the build, not render empty"
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("no key 'project.nope'"),
"Got: {}",
combined
);
}
#[test]
fn toml_get_on_a_table_is_an_error() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(
project_path.join("data.toml"),
"[project]\nversion = \"1.0\"\n",
)
.unwrap();
fs::write(
project_path.join("tera.templates/bad2.txt.tera"),
"{{ toml_get(path=\"data.toml\", key=\"project\") }}\n",
)
.unwrap();
let output = run_rsconstruct_with_env(project_path, &["build"], &[("NO_COLOR", "1")]);
assert!(
!output.status.success(),
"Interpolating a whole table must fail rather than emit JSON"
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(combined.contains("not a scalar"), "Got: {}", combined);
}