use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::error::MifRhError;
use crate::harness_project::read_json;
#[derive(Debug, Clone)]
pub struct Orphan {
pub source_file: String,
pub target: String,
}
#[derive(Debug, Clone)]
pub struct RelationshipTargetsReport {
pub orphans: Vec<Orphan>,
pub checked: usize,
pub active_findings: usize,
}
impl RelationshipTargetsReport {
#[must_use]
pub const fn ok(&self) -> bool {
self.orphans.is_empty()
}
}
fn active_finding_paths(reports_dir: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
let Ok(topics) = std::fs::read_dir(reports_dir) else {
return paths;
};
for topic in topics.flatten() {
let findings_dir = topic.path().join("findings");
let Ok(entries) = std::fs::read_dir(&findings_dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let is_json_file = path.is_file()
&& path.extension().is_some_and(|ext| ext == "json")
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| !name.starts_with('.'));
if is_json_file {
paths.push(path);
}
}
}
paths.sort();
paths
}
fn relationship_targets(finding: &Value) -> Vec<String> {
finding
.get("relationships")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|relationship| relationship.get("target").and_then(Value::as_str))
.filter(|target| !target.is_empty())
.map(str::to_string)
.collect()
}
pub fn check_relationship_targets(
reports_dir: &Path,
) -> Result<RelationshipTargetsReport, MifRhError> {
let paths = active_finding_paths(reports_dir);
if paths.is_empty() {
return Ok(RelationshipTargetsReport {
orphans: Vec::new(),
checked: 0,
active_findings: 0,
});
}
let mut ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut all_targets: Vec<(String, String)> = Vec::new();
for path in &paths {
let finding =
read_json(path).map_err(|_| MifRhError::RelationshipTargetFindingUnparseable {
path: path.display().to_string(),
})?;
if let Some(id) = finding.get("@id").and_then(Value::as_str)
&& !id.is_empty()
{
ids.insert(id.to_string());
}
let file = path.display().to_string();
for target in relationship_targets(&finding) {
all_targets.push((file.clone(), target));
}
}
let mut orphans: Vec<Orphan> = all_targets
.iter()
.filter(|(_, target)| !ids.contains(target))
.map(|(source_file, target)| Orphan {
source_file: source_file.clone(),
target: target.clone(),
})
.collect();
orphans.sort_by(|a, b| (&a.source_file, &a.target).cmp(&(&b.source_file, &b.target)));
Ok(RelationshipTargetsReport {
orphans,
checked: all_targets.len(),
active_findings: ids.len(),
})
}
#[cfg(test)]
mod tests {
use super::check_relationship_targets;
use std::fs;
fn write(dir: &std::path::Path, rel: &str, contents: &str) {
let path = dir.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
#[test]
fn passes_when_every_target_resolves() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"t1/findings/a.json",
r#"{"@id":"urn:mif:t1:a","relationships":[]}"#,
);
write(
dir.path(),
"t1/findings/b.json",
r#"{"@id":"urn:mif:t1:b","relationships":[{"type":"relates-to","target":"urn:mif:t1:a"}]}"#,
);
let report = check_relationship_targets(dir.path()).unwrap();
assert!(report.ok(), "{:?}", report.orphans);
assert_eq!(report.checked, 1);
assert_eq!(report.active_findings, 2);
}
#[test]
fn flags_a_dangling_target_and_a_quarantine_only_target() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"t1/findings/a.json",
r#"{"@id":"urn:mif:t1:a","relationships":[]}"#,
);
write(
dir.path(),
"t1/findings/b.json",
r#"{"@id":"urn:mif:t1:b","relationships":[
{"type":"relates-to","target":"urn:mif:t1:does-not-exist"},
{"type":"relates-to","target":"urn:mif:t1:quarantined"}
]}"#,
);
write(
dir.path(),
"t1/quarantine/quarantined.json",
r#"{"@id":"urn:mif:t1:quarantined","relationships":[]}"#,
);
let report = check_relationship_targets(dir.path()).unwrap();
assert!(!report.ok());
let targets: Vec<&str> = report.orphans.iter().map(|o| o.target.as_str()).collect();
assert!(targets.contains(&"urn:mif:t1:does-not-exist"));
assert!(targets.contains(&"urn:mif:t1:quarantined"));
}
#[test]
fn resolves_targets_across_topics_since_id_is_a_global_urn() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"t1/findings/a.json",
r#"{"@id":"urn:mif:t1:a","relationships":[]}"#,
);
write(
dir.path(),
"t2/findings/b.json",
r#"{"@id":"urn:mif:t2:b","relationships":[{"type":"relates-to","target":"urn:mif:t1:a"}]}"#,
);
let report = check_relationship_targets(dir.path()).unwrap();
assert!(report.ok(), "{:?}", report.orphans);
}
#[test]
fn a_malformed_finding_hard_fails_instead_of_silently_narrowing_the_universe() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "t1/findings/bad.json", "{invalid json");
write(
dir.path(),
"t1/findings/z.json",
r#"{"@id":"urn:mif:t1:z","relationships":[{"type":"relates-to","target":"urn:mif:t1:does-not-exist"}]}"#,
);
let error = check_relationship_targets(dir.path()).unwrap_err();
assert!(matches!(
error,
super::MifRhError::RelationshipTargetFindingUnparseable { .. }
));
}
#[test]
fn no_active_findings_passes_with_zero_checked() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("t1")).unwrap();
let report = check_relationship_targets(dir.path()).unwrap();
assert!(report.ok());
assert_eq!(report.checked, 0);
assert_eq!(report.active_findings, 0);
}
}