use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use serde_json::{Value, json};
use crate::error::MifRhError;
struct Membership {
goal_versions: BTreeSet<String>,
stale_in: BTreeSet<String>,
}
pub fn build_index(findings_dir: &Path) -> Result<Value, MifRhError> {
let findings = load_finding_files(findings_dir)?;
let membership = fold_membership(findings_dir)?;
let entries: Vec<Value> = findings
.iter()
.map(|finding| project_finding(finding, &membership))
.collect();
Ok(json!({
"@type": "ResearchIndex",
"count": entries.len(),
"findings": entries,
}))
}
fn load_finding_files(findings_dir: &Path) -> Result<Vec<Value>, MifRhError> {
let mut paths: Vec<_> = std::fs::read_dir(findings_dir)
.map_err(|source| MifRhError::Io {
path: findings_dir.display().to_string(),
source,
})?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
.collect();
paths.sort();
if paths.is_empty() {
return Err(MifRhError::NoFindingsFound {
path: findings_dir.display().to_string(),
});
}
let mut findings = Vec::with_capacity(paths.len());
for path in &paths {
let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::FindingIo {
path: path.display().to_string(),
source,
})?;
let value: Value =
serde_json::from_str(&contents).map_err(|source| MifRhError::FindingJson {
path: path.display().to_string(),
source,
})?;
findings.push(value);
}
Ok(findings)
}
fn fold_membership(findings_dir: &Path) -> Result<HashMap<String, Membership>, MifRhError> {
let goals_dir = findings_dir.join("../goals");
let mut membership: HashMap<String, Membership> = HashMap::new();
let Ok(entries) = std::fs::read_dir(&goals_dir) else {
return Ok(membership);
};
let mut paths: Vec<_> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".members.json"))
})
.collect();
paths.sort();
for path in &paths {
let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
path: path.display().to_string(),
source,
})?;
let doc: Value = serde_json::from_str(&contents).map_err(|source| MifRhError::Json {
path: path.display().to_string(),
source,
})?;
let Some(version) = doc.get("version").and_then(Value::as_str) else {
continue;
};
for member_id in doc
.get("members")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(id) = member_id.as_str() {
membership
.entry(id.to_string())
.or_insert_with(|| Membership {
goal_versions: BTreeSet::new(),
stale_in: BTreeSet::new(),
})
.goal_versions
.insert(version.to_string());
}
}
for stale_id in doc
.get("stale")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(id) = stale_id.as_str() {
membership
.entry(id.to_string())
.or_insert_with(|| Membership {
goal_versions: BTreeSet::new(),
stale_in: BTreeSet::new(),
})
.stale_in
.insert(version.to_string());
}
}
}
Ok(membership)
}
fn project_finding(finding: &Value, membership: &HashMap<String, Membership>) -> Value {
let id = finding
.get("@id")
.and_then(Value::as_str)
.unwrap_or_default();
let citations = finding
.get("citations")
.and_then(Value::as_array)
.map_or(0, Vec::len);
let entry = membership.get(id);
json!({
"id": id,
"title": finding.get("title"),
"namespace": finding.get("namespace"),
"dimension": finding.pointer("/extensions/harness/dimension"),
"tags": finding.get("tags").cloned().unwrap_or_else(|| json!([])),
"verdict": finding.pointer("/extensions/harness/verification/verdict"),
"citations": citations,
"goal_versions": entry.map_or_else(Vec::new, |m| m.goal_versions.iter().cloned().collect::<Vec<_>>()),
"stale_in": entry.map_or_else(Vec::new, |m| m.stale_in.iter().cloned().collect::<Vec<_>>()),
})
}
#[cfg(test)]
mod tests {
use super::build_index;
use std::fs;
#[test]
fn projects_basic_finding_fields() {
let dir = tempfile::tempdir().unwrap();
let findings = dir.path().join("findings");
fs::create_dir_all(&findings).unwrap();
fs::write(
findings.join("f1.json"),
r#"{"@id": "urn:mif:f1", "title": "T1", "namespace": "ns", "tags": ["a"],
"citations": [{"url": "https://x"}, {"url": "https://y"}]}"#,
)
.unwrap();
let index = build_index(&findings).unwrap();
assert_eq!(index["count"], 1);
let entry = &index["findings"][0];
assert_eq!(entry["id"], "urn:mif:f1");
assert_eq!(entry["title"], "T1");
assert_eq!(entry["citations"], 2);
assert_eq!(entry["goal_versions"], serde_json::json!([]));
}
#[test]
fn folds_membership_from_goals_members_files() {
let dir = tempfile::tempdir().unwrap();
let findings = dir.path().join("findings");
let goals = dir.path().join("goals");
fs::create_dir_all(&findings).unwrap();
fs::create_dir_all(&goals).unwrap();
fs::write(findings.join("f1.json"), r#"{"@id": "urn:mif:f1"}"#).unwrap();
fs::write(
goals.join("goal-gv-1.members.json"),
r#"{"version": "gv-1", "members": ["urn:mif:f1"], "stale": []}"#,
)
.unwrap();
fs::write(
goals.join("goal-gv-2.members.json"),
r#"{"version": "gv-2", "members": ["urn:mif:f1"], "stale": ["urn:mif:f1"]}"#,
)
.unwrap();
let index = build_index(&findings).unwrap();
let entry = &index["findings"][0];
assert_eq!(entry["goal_versions"], serde_json::json!(["gv-1", "gv-2"]));
assert_eq!(entry["stale_in"], serde_json::json!(["gv-2"]));
}
#[test]
fn skips_a_members_file_with_a_non_string_version() {
let dir = tempfile::tempdir().unwrap();
let findings = dir.path().join("findings");
let goals = dir.path().join("goals");
fs::create_dir_all(&findings).unwrap();
fs::create_dir_all(&goals).unwrap();
fs::write(findings.join("f1.json"), r#"{"@id": "urn:mif:f1"}"#).unwrap();
fs::write(
goals.join("goal-legacy.members.json"),
r#"{"version": null, "members": ["urn:mif:f1"]}"#,
)
.unwrap();
let index = build_index(&findings).unwrap();
let entry = &index["findings"][0];
assert_eq!(entry["goal_versions"], serde_json::json!([]));
}
#[test]
fn rejects_an_empty_findings_directory() {
let dir = tempfile::tempdir().unwrap();
let findings = dir.path().join("findings");
fs::create_dir_all(&findings).unwrap();
let error = build_index(&findings).unwrap_err();
assert!(matches!(error, super::MifRhError::NoFindingsFound { .. }));
}
}