use super::ambient::{declares_inputs, hash_declared_inputs};
use super::output_hash::hash_outputs_with;
use crate::core::types::Resource;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IoDigest {
pub input_hash: Option<String>,
pub output_hash: Option<String>,
pub outputs_missing: bool,
}
impl IoDigest {
pub fn is_empty(&self) -> bool {
self.input_hash.is_none() && self.output_hash.is_none() && !self.outputs_missing
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProbeMap {
by_machine: HashMap<String, HashMap<String, IoDigest>>,
}
impl ProbeMap {
pub fn insert(&mut self, machine: &str, resource_id: &str, digest: IoDigest) {
self.by_machine
.entry(machine.to_string())
.or_default()
.insert(resource_id.to_string(), digest);
}
pub fn get(&self, machine: &str, resource_id: &str) -> Option<&IoDigest> {
self.by_machine.get(machine)?.get(resource_id)
}
pub fn is_empty(&self) -> bool {
self.by_machine.values().all(HashMap::is_empty)
}
pub fn len(&self) -> usize {
self.by_machine.values().map(HashMap::len).sum()
}
}
pub fn probe_base_dir(resource: &Resource) -> PathBuf {
match resource.working_dir.as_deref() {
Some(d) if !d.is_empty() => PathBuf::from(d),
_ => PathBuf::from("."),
}
}
pub fn probe_resource(resource: &Resource) -> Option<IoDigest> {
if !declares_inputs(resource) && resource.output_artifacts.is_empty() {
return None;
}
let base = probe_base_dir(resource);
let input_hash = hash_declared_inputs(resource, &base);
let mut outputs_missing = false;
for artifact in &resource.output_artifacts {
if !resolve_under(&base, artifact).exists() {
outputs_missing = true;
break;
}
}
let file_artifacts: Vec<String> = resource
.output_artifacts
.iter()
.filter(|a| !resolve_under(&base, a).is_dir())
.cloned()
.collect();
let output_hash = if file_artifacts.is_empty() || outputs_missing {
None
} else {
hash_outputs_with(&file_artifacts, &base, &resource.output_equivalence)
.ok()
.flatten()
};
Some(IoDigest {
input_hash,
output_hash,
outputs_missing,
})
}
pub fn resolve_under(base: &Path, path: &str) -> PathBuf {
let p = Path::new(path);
if p.is_absolute() {
p.to_path_buf()
} else {
base.join(p)
}
}
pub fn probe_all<F>(resources: &indexmap::IndexMap<String, Resource>, is_local: F) -> ProbeMap
where
F: Fn(&str) -> bool,
{
let mut out = ProbeMap::default();
for (id, resource) in resources {
let local: Vec<&str> = resource.machine.iter().filter(|m| is_local(m)).collect();
if local.is_empty() {
continue;
}
let Some(digest) = probe_resource(resource) else {
continue;
};
if digest.is_empty() {
continue;
}
for machine in local {
out.insert(machine, id, digest.clone());
}
}
out
}
pub fn staleness_reason(
probe: &IoDigest,
stored_input_hash: Option<&str>,
stored_output_hash: Option<&str>,
) -> Option<String> {
if probe.outputs_missing {
return Some("output artifact missing".to_string());
}
if let Some(current) = probe.input_hash.as_deref() {
match stored_input_hash {
None => return Some("no recorded input hash".to_string()),
Some(stored) if stored != current => {
return Some("inputs changed".to_string());
}
_ => {}
}
}
if let Some(current) = probe.output_hash.as_deref() {
if let Some(stored) = stored_output_hash {
if stored != current {
return Some("output artifact modified".to_string());
}
}
}
None
}
pub fn record_io_hashes(
resource: &Resource,
machine: &crate::core::types::Machine,
details: &mut std::collections::HashMap<String, serde_yaml_ng::Value>,
) {
if !declares_inputs(resource) && resource.output_artifacts.is_empty() {
return;
}
if !probe_answers_for(machine) {
return;
}
let base = probe_base_dir(resource);
if let Some(hash) = hash_declared_inputs(resource, &base) {
details.insert("input_hash".to_string(), serde_yaml_ng::Value::String(hash));
}
if !resource.output_artifacts.is_empty() {
if let Ok(Some(hash)) = hash_outputs_with(
&resource.output_artifacts,
&base,
&resource.output_equivalence,
) {
details.insert(
"output_hash".to_string(),
serde_yaml_ng::Value::String(hash),
);
}
}
}
pub fn probe_config(config: &crate::core::types::ForjarConfig) -> ProbeMap {
let resolved = crate::core::resolver::resolve_all(
&config.resources,
&config.params,
&config.machines,
&config.secrets,
);
probe_all(&resolved, |m| probe_covers(config, m))
}
pub fn probe_covers(config: &crate::core::types::ForjarConfig, machine: &str) -> bool {
config.machines.get(machine).is_some_and(probe_answers_for)
}
pub fn probe_answers_for(machine: &crate::core::types::Machine) -> bool {
crate::transport::controller_answers_for(machine)
}