use forjar::core::task::probe::record_io_hashes;
use forjar::core::task::{hash_declared_inputs, hash_inputs, probe_resource, staleness_reason};
use forjar::core::types::{MachineTarget, Resource, ResourceType};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
fn this_host() -> forjar::core::types::Machine {
serde_yaml_ng::from_str("hostname: box\naddr: 127.0.0.1").expect("machine")
}
fn fixture(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("fj244-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::create_dir_all(dir.join("ambient")).unwrap();
std::fs::create_dir_all(dir.join("out")).unwrap();
std::fs::write(dir.join("src/slide.svg"), "<svg>hello</svg>\n").unwrap();
std::fs::write(dir.join("ambient/fonts.txt"), "FONT_SET=v1\n").unwrap();
dir
}
fn rasterize(dir: &Path, ambient: &[&str]) -> Resource {
Resource {
resource_type: ResourceType::Task,
machine: MachineTarget::Single("local".to_string()),
command: Some("cat src/slide.svg ambient/fonts.txt > out/slide.png".to_string()),
working_dir: Some(dir.display().to_string()),
task_inputs: vec!["src/slide.svg".to_string()],
ambient_inputs: ambient.iter().map(|s| (*s).to_string()).collect(),
output_artifacts: vec!["out/slide.png".to_string()],
cache: true,
..Default::default()
}
}
fn apply_and_record(r: &Resource, dir: &Path) -> HashMap<String, serde_yaml_ng::Value> {
let status = std::process::Command::new("bash")
.arg("-c")
.arg(r.command.as_deref().unwrap())
.current_dir(dir)
.status()
.expect("run recipe");
assert!(status.success(), "fixture recipe failed");
let mut details = HashMap::new();
record_io_hashes(r, &this_host(), &mut details);
details
}
fn verdict(r: &Resource, details: &HashMap<String, serde_yaml_ng::Value>) -> Option<String> {
staleness_reason(
&probe_resource(r).expect("resource declares I/O"),
details.get("input_hash").and_then(|v| v.as_str()),
details.get("output_hash").and_then(|v| v.as_str()),
)
}
#[test]
fn an_ambient_change_makes_the_task_stale() {
let dir = fixture("stale");
let r = rasterize(&dir, &["cat ambient/fonts.txt"]);
let details = apply_and_record(&r, &dir);
assert_eq!(verdict(&r, &details), None, "settled right after apply");
std::fs::write(dir.join("ambient/fonts.txt"), "FONT_SET=v2-NEW-FONT\n").unwrap();
assert_eq!(
verdict(&r, &details),
Some("inputs changed".to_string()),
"an ambient change is invisible — plan/check/drift/apply all report \
clean over a stale artifact"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_unchanged_ambient_input_settles() {
let dir = fixture("settle");
let r = rasterize(&dir, &["cat ambient/fonts.txt"]);
let details = apply_and_record(&r, &dir);
for _ in 0..3 {
assert_eq!(
verdict(&r, &details),
None,
"an idempotency pump: the probe and the lock composite differently"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failing_ambient_command_is_not_silently_dropped() {
let dir = fixture("failing");
let broken = rasterize(&dir, &["exit 7"]);
let none = rasterize(&dir, &[]);
let with_failure = probe_resource(&broken).unwrap().input_hash;
assert!(
with_failure.is_some(),
"a broken fingerprint erased the hash"
);
assert_ne!(
with_failure,
probe_resource(&none).unwrap().input_hash,
"a broken fingerprint collapsed to the no-ambient hash"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn no_ambient_inputs_leaves_the_recorded_hash_byte_identical() {
let dir = fixture("compat");
let r = rasterize(&dir, &[]);
assert_eq!(
hash_declared_inputs(&r, &dir),
hash_inputs(&r.task_inputs, &dir).unwrap(),
"the composite shape changed for resources that declare no ambient input"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_ambient_only_resource_is_still_probed() {
let dir = fixture("ambient-only");
let mut r = rasterize(&dir, &["echo v1"]);
r.task_inputs.clear();
let probe = probe_resource(&r).expect("an ambient-only resource must be probed");
assert!(
probe.input_hash.is_some(),
"the fingerprint was computed and then never consulted"
);
let mut details = HashMap::new();
record_io_hashes(&r, &this_host(), &mut details);
assert!(
details.contains_key("input_hash"),
"the lock recorded nothing to compare the next probe against"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ambient_inputs_are_template_resolved() {
let mut params = HashMap::new();
params.insert(
"fp".to_string(),
serde_yaml_ng::Value::String("fc-list".to_string()),
);
let r = Resource {
resource_type: ResourceType::Task,
ambient_inputs: vec!["{{params.fp}} | sha256sum".to_string()],
..Default::default()
};
let resolved =
forjar::core::resolver::resolve_resource_templates(&r, ¶ms, &indexmap::IndexMap::new())
.expect("resolves");
assert_eq!(resolved.ambient_inputs, vec!["fc-list | sha256sum"]);
}