use std::path::Path;
use serde_json::Value;
use crate::error::MifRhError;
use crate::harness_project::read_json;
use crate::harness_reconcile::list_finding_paths;
#[derive(Debug, Clone)]
pub struct ShippableTypingReport {
pub blockers: Vec<String>,
}
impl ShippableTypingReport {
#[must_use]
pub const fn ok(&self) -> bool {
self.blockers.is_empty()
}
}
fn load_ontology_map(reports_dir: &Path, topic: &str) -> Result<Vec<Value>, MifRhError> {
let map_path = reports_dir.join("ontology-map.json");
if !map_path.is_file() {
return Err(MifRhError::OntologyMapUnusable {
path: map_path.display().to_string(),
topic: topic.to_string(),
reason: "is missing".to_string(),
});
}
read_json(&map_path)
.ok()
.and_then(|value| value.as_array().cloned())
.ok_or_else(|| MifRhError::OntologyMapUnusable {
path: map_path.display().to_string(),
topic: topic.to_string(),
reason: "is unparseable or not a record array".to_string(),
})
}
fn blocking_reason(map: &[Value], raw_id: &str) -> Option<String> {
let record = map
.iter()
.find(|entry| entry.get("finding_id").and_then(Value::as_str) == Some(raw_id));
record.map_or_else(
|| Some("missing".to_string()),
|record| {
let valid = record
.get("valid")
.and_then(Value::as_bool)
.unwrap_or(false);
let basis = record.get("basis").and_then(Value::as_str).unwrap_or("");
if !valid {
Some("invalid".to_string())
} else if matches!(basis, "untyped" | "unresolved" | "discovery") {
Some(basis.to_string())
} else {
None
}
},
)
}
fn check_finding(path: &Path, map: &[Value]) -> Option<String> {
let Ok(finding) = read_json(path) else {
return Some(format!(" {} (unreadable JSON)", path.display()));
};
let verdict = finding
.pointer("/extensions/harness/verification/verdict")
.and_then(Value::as_str)
.unwrap_or("");
if verdict != "survived" && verdict != "weakened" {
return None;
}
let raw_id = finding.get("@id").and_then(Value::as_str).unwrap_or("");
let label = if raw_id.is_empty() {
path.display().to_string()
} else {
raw_id.to_string()
};
blocking_reason(map, raw_id).map(|reason| format!(" {label} ({reason})"))
}
pub fn check_shippable_typing(reports_dir: &Path) -> Result<ShippableTypingReport, MifRhError> {
let topic = reports_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
let map = load_ontology_map(reports_dir, topic)?;
let blockers = list_finding_paths(reports_dir)
.iter()
.filter_map(|path| check_finding(path, &map))
.collect();
Ok(ShippableTypingReport { blockers })
}
#[cfg(test)]
mod tests {
use super::check_shippable_typing;
use std::fs;
fn setup(dir: &std::path::Path) -> std::path::PathBuf {
let reports_dir = dir.join("reports/edu");
fs::create_dir_all(reports_dir.join("findings")).unwrap();
reports_dir
}
#[test]
fn passes_a_fully_typed_shippable_finding() {
let dir = tempfile::tempdir().unwrap();
let reports_dir = setup(dir.path());
fs::write(
reports_dir.join("findings/f1.json"),
r#"{"@id":"urn:mif:f1","extensions":{"harness":{"verification":{"verdict":"survived"}}}}"#,
)
.unwrap();
fs::write(
reports_dir.join("ontology-map.json"),
r#"[{"finding_id":"urn:mif:f1","valid":true,"basis":"declared"}]"#,
)
.unwrap();
let report = check_shippable_typing(&reports_dir).unwrap();
assert!(report.ok(), "{:?}", report.blockers);
}
#[test]
fn blocks_an_untyped_shippable_finding() {
let dir = tempfile::tempdir().unwrap();
let reports_dir = setup(dir.path());
fs::write(
reports_dir.join("findings/f1.json"),
r#"{"@id":"urn:mif:f1","extensions":{"harness":{"verification":{"verdict":"survived"}}}}"#,
)
.unwrap();
fs::write(
reports_dir.join("ontology-map.json"),
r#"[{"finding_id":"urn:mif:f1","valid":true,"basis":"untyped"}]"#,
)
.unwrap();
let report = check_shippable_typing(&reports_dir).unwrap();
assert_eq!(report.blockers, vec![" urn:mif:f1 (untyped)"]);
}
#[test]
fn blocks_a_discovery_only_shippable_finding_not_just_untyped_or_unresolved() {
let dir = tempfile::tempdir().unwrap();
let reports_dir = setup(dir.path());
fs::write(
reports_dir.join("findings/f1.json"),
r#"{"@id":"urn:mif:f1","extensions":{"harness":{"verification":{"verdict":"survived"}}}}"#,
)
.unwrap();
fs::write(
reports_dir.join("ontology-map.json"),
r#"[{"finding_id":"urn:mif:f1","valid":true,"basis":"discovery"}]"#,
)
.unwrap();
let report = check_shippable_typing(&reports_dir).unwrap();
assert_eq!(report.blockers, vec![" urn:mif:f1 (discovery)"]);
}
#[test]
fn a_falsified_untyped_finding_does_not_block() {
let dir = tempfile::tempdir().unwrap();
let reports_dir = setup(dir.path());
fs::write(
reports_dir.join("findings/f1.json"),
r#"{"@id":"urn:mif:f1","extensions":{"harness":{"verification":{"verdict":"falsified"}}}}"#,
)
.unwrap();
fs::write(
reports_dir.join("ontology-map.json"),
r#"[{"finding_id":"urn:mif:f1","valid":true,"basis":"untyped"}]"#,
)
.unwrap();
let report = check_shippable_typing(&reports_dir).unwrap();
assert!(report.ok());
}
#[test]
fn an_unparseable_finding_blocks_closed() {
let dir = tempfile::tempdir().unwrap();
let reports_dir = setup(dir.path());
fs::write(
reports_dir.join("findings/corrupt.json"),
"{ not valid json ",
)
.unwrap();
fs::write(reports_dir.join("ontology-map.json"), "[]").unwrap();
let report = check_shippable_typing(&reports_dir).unwrap();
assert_eq!(report.blockers.len(), 1);
assert!(report.blockers[0].contains("unreadable JSON"));
}
#[test]
fn a_missing_ontology_map_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let reports_dir = setup(dir.path());
let error = check_shippable_typing(&reports_dir).unwrap_err();
assert!(matches!(
error,
super::MifRhError::OntologyMapUnusable { reason, .. } if reason == "is missing"
));
}
#[test]
fn a_wrong_shape_ontology_map_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let reports_dir = setup(dir.path());
fs::write(
reports_dir.join("ontology-map.json"),
r#"{"not":"an array"}"#,
)
.unwrap();
let error = check_shippable_typing(&reports_dir).unwrap_err();
assert!(matches!(
error,
super::MifRhError::OntologyMapUnusable { reason, .. }
if reason == "is unparseable or not a record array"
));
}
#[test]
fn a_no_id_shippable_finding_blocks_and_names_the_file() {
let dir = tempfile::tempdir().unwrap();
let reports_dir = setup(dir.path());
fs::write(
reports_dir.join("findings/noid.json"),
r#"{"extensions":{"harness":{"verification":{"verdict":"survived"}}}}"#,
)
.unwrap();
fs::write(reports_dir.join("ontology-map.json"), "[]").unwrap();
let report = check_shippable_typing(&reports_dir).unwrap();
assert_eq!(report.blockers.len(), 1);
assert!(report.blockers[0].contains("noid.json"));
assert!(report.blockers[0].contains("(missing)"));
}
#[test]
fn a_flat_finding_layout_is_gated_too() {
let dir = tempfile::tempdir().unwrap();
let reports_dir = dir.path().join("reports/edu");
fs::create_dir_all(&reports_dir).unwrap();
fs::write(
reports_dir.join("finding-flat.json"),
r#"{"@id":"urn:mif:flat","extensions":{"harness":{"verification":{"verdict":"survived"}}}}"#,
)
.unwrap();
fs::write(
reports_dir.join("ontology-map.json"),
r#"[{"finding_id":"urn:mif:flat","valid":true,"basis":"untyped"}]"#,
)
.unwrap();
let report = check_shippable_typing(&reports_dir).unwrap();
assert_eq!(report.blockers, vec![" urn:mif:flat (untyped)"]);
}
}