1use std::collections::HashMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use serde::Serialize;
10
11use crate::catalog::Catalog;
12use crate::config::HarnessConfig;
13use crate::error::MifRhError;
14use crate::finding::Finding;
15use crate::ontology_pack::OntologyPack;
16use crate::resolve::{Basis, MapRecord, ResolveContext, resolve_finding};
17
18pub struct ReviewOptions<'a> {
20 pub topics: Option<&'a [String]>,
22 pub reports_dir: &'a Path,
25 pub ontology_packs: &'a HashMap<String, OntologyPack>,
32 pub catalog: &'a Catalog,
34 pub config: &'a HarnessConfig,
36 pub check_relationship_targets_script: Option<&'a Path>,
41}
42
43#[derive(Debug, Clone)]
46pub struct TopicSummary {
47 pub topic: String,
49 pub bound: Vec<String>,
51 pub total: usize,
53 pub stamped: usize,
55 pub discovery: usize,
57 pub untyped: usize,
59 pub bad: usize,
62}
63
64#[derive(Debug, Clone)]
66pub struct ReviewReport {
67 pub topics: Vec<TopicSummary>,
69 pub any_bad: bool,
72}
73
74impl ReviewReport {
75 #[must_use]
77 pub fn total_findings(&self) -> usize {
78 self.topics.iter().map(|t| t.total).sum()
79 }
80
81 #[must_use]
83 pub fn total_stamped(&self) -> usize {
84 self.topics.iter().map(|t| t.stamped).sum()
85 }
86
87 #[must_use]
89 pub fn total_discovery(&self) -> usize {
90 self.topics.iter().map(|t| t.discovery).sum()
91 }
92
93 #[must_use]
95 pub fn total_untyped(&self) -> usize {
96 self.topics.iter().map(|t| t.untyped).sum()
97 }
98
99 #[must_use]
101 pub fn total_bad(&self) -> usize {
102 self.topics.iter().map(|t| t.bad).sum()
103 }
104
105 #[must_use]
108 pub fn summary_line(&self) -> String {
109 format!(
110 "{} topic(s); {} findings — {} stamped, {} discovery-only, {} untyped, {} invalid/unresolved",
111 self.topics.len(),
112 self.total_findings(),
113 self.total_stamped(),
114 self.total_discovery(),
115 self.total_untyped(),
116 self.total_bad(),
117 )
118 }
119
120 #[must_use]
123 pub const fn strict_should_fail(&self) -> bool {
124 self.any_bad
125 }
126}
127
128#[derive(Debug, Clone, Serialize)]
131pub struct FollowupEntry {
132 pub finding_id: String,
135 pub file: Option<String>,
137 pub basis: String,
142 pub entity_type: Option<String>,
144 pub resolved_ontology: Option<String>,
146 pub valid: bool,
152}
153
154#[derive(Debug, Clone, Serialize)]
157pub struct FollowupBacklog {
158 pub topics: HashMap<String, Vec<FollowupEntry>>,
160 pub total_needs_followup: usize,
162}
163
164fn topic_findings_dir(reports_dir: &Path, topic: &str) -> PathBuf {
165 reports_dir.join(topic).join("findings")
166}
167
168pub(crate) fn list_finding_files(dir: &Path) -> Result<Vec<PathBuf>, MifRhError> {
169 let entries = fs::read_dir(dir).map_err(|source| MifRhError::Io {
170 path: dir.display().to_string(),
171 source,
172 })?;
173 let mut files: Vec<PathBuf> = entries
174 .filter_map(Result::ok)
175 .map(|entry| entry.path())
176 .filter(|path| {
177 let is_json = path.extension().and_then(|ext| ext.to_str()) == Some("json");
178 let is_dotfile = path
179 .file_name()
180 .and_then(|name| name.to_str())
181 .is_some_and(|name| name.starts_with('.'));
182 is_json && !is_dotfile
183 })
184 .collect();
185 files.sort();
186 Ok(files)
187}
188
189fn write_map(path: &Path, records: &[MapRecord]) -> Result<(), MifRhError> {
193 let mut sorted = records.to_vec();
194 sorted.sort_by(|a, b| a.finding_id.cmp(&b.finding_id));
195 crate::write_json_atomic(path, &sorted)
196}
197
198fn review_one_topic(
199 topic: &str,
200 opts: &ReviewOptions<'_>,
201) -> Result<Option<(TopicSummary, Vec<FollowupEntry>)>, MifRhError> {
202 let findings_dir = topic_findings_dir(opts.reports_dir, topic);
203 if !findings_dir.is_dir() {
204 return Ok(None);
207 }
208
209 let files = list_finding_files(&findings_dir)?;
210 let ctx = ResolveContext {
211 topic,
212 catalog: opts.catalog,
213 config: opts.config,
214 ontology_packs: opts.ontology_packs,
215 };
216
217 let mut items: Vec<(&PathBuf, Option<MapRecord>)> = Vec::with_capacity(files.len());
220 for file in &files {
221 let record = Finding::load(file).and_then(|finding| resolve_finding(&finding, &ctx));
222 items.push((file, record.ok()));
223 }
224
225 let records: Vec<&MapRecord> = items.iter().filter_map(|(_, r)| r.as_ref()).collect();
226 let gap = items.iter().filter(|(_, r)| r.is_none()).count();
227
228 let stamped = records
229 .iter()
230 .filter(|r| matches!(r.basis, Basis::Declared | Basis::Resolved) && r.valid)
231 .count();
232 let discovery = records
233 .iter()
234 .filter(|r| r.basis == Basis::Discovery && r.valid)
235 .count();
236 let untyped = records.iter().filter(|r| r.basis == Basis::Untyped).count();
237 let bad = records.iter().filter(|r| !r.valid).count() + gap;
238
239 write_map(
240 &opts.reports_dir.join(topic).join("ontology-map.json"),
241 &records.iter().map(|r| (*r).clone()).collect::<Vec<_>>(),
242 )?;
243
244 let mut followup: Vec<FollowupEntry> = Vec::new();
245 for (file, record) in &items {
246 match record {
247 Some(r) if matches!(r.basis, Basis::Discovery | Basis::Untyped) || !r.valid => {
248 followup.push(FollowupEntry {
249 finding_id: r.finding_id.clone(),
250 file: Some(file.display().to_string()),
251 basis: r.basis.label().to_string(),
252 entity_type: r.entity_type.clone(),
253 resolved_ontology: r.resolved_ontology.clone(),
254 valid: r.valid,
255 });
256 },
257 None => {
258 let finding_id = file
259 .file_stem()
260 .and_then(|s| s.to_str())
261 .unwrap_or_default()
262 .to_string();
263 followup.push(FollowupEntry {
264 finding_id,
265 file: Some(file.display().to_string()),
266 basis: "gap".to_string(),
267 entity_type: None,
268 resolved_ontology: None,
269 valid: false,
270 });
271 },
272 Some(_) => {},
273 }
274 }
275 followup.sort_by(|a, b| a.finding_id.cmp(&b.finding_id));
276
277 let bound = opts
278 .config
279 .topic_bindings(topic)
280 .into_iter()
281 .map(|b| b.id)
282 .collect();
283
284 Ok(Some((
285 TopicSummary {
286 topic: topic.to_string(),
287 bound,
288 total: files.len(),
289 stamped,
290 discovery,
291 untyped,
292 bad,
293 },
294 followup,
295 )))
296}
297
298fn relationship_targets_clean(script: &Path, reports_dir: &Path) -> Result<bool, MifRhError> {
320 let status = Command::new(script)
321 .arg("--reports-dir")
322 .arg(reports_dir)
323 .status()
324 .map_err(|source| MifRhError::Io {
325 path: script.display().to_string(),
326 source,
327 })?;
328 Ok(status.success())
329}
330
331pub fn review(opts: &ReviewOptions<'_>) -> Result<(ReviewReport, FollowupBacklog), MifRhError> {
340 let topic_ids: Vec<String> = opts.topics.map_or_else(
341 || opts.config.topics.iter().map(|t| t.id.clone()).collect(),
342 <[String]>::to_vec,
343 );
344
345 let mut summaries = Vec::new();
346 let mut followup_topics: HashMap<String, Vec<FollowupEntry>> = HashMap::new();
347 let mut any_bad = false;
348
349 for topic in &topic_ids {
350 if let Some((summary, followup)) = review_one_topic(topic, opts)? {
351 if summary.bad > 0 {
352 any_bad = true;
353 }
354 if !followup.is_empty() {
355 followup_topics.insert(topic.clone(), followup);
356 }
357 summaries.push(summary);
358 }
359 }
360
361 if let Some(script) = opts.check_relationship_targets_script
362 && !relationship_targets_clean(script, opts.reports_dir)?
363 {
364 any_bad = true;
365 }
366
367 let total_needs_followup = followup_topics.values().map(Vec::len).sum();
368
369 Ok((
370 ReviewReport {
371 topics: summaries,
372 any_bad,
373 },
374 FollowupBacklog {
375 topics: followup_topics,
376 total_needs_followup,
377 },
378 ))
379}
380
381pub fn write_followup(path: &Path, backlog: &FollowupBacklog) -> Result<(), MifRhError> {
387 crate::write_json_atomic(path, backlog)
388}
389
390#[cfg(test)]
391mod tests {
392 use std::fs;
393
394 use super::{ReviewOptions, review};
395 use crate::catalog::{Catalog, CatalogEntry};
396 use crate::config::{HarnessConfig, TopicConfig};
397 use crate::ontology_pack;
398
399 fn write_finding(dir: &std::path::Path, name: &str, contents: &str) {
400 fs::create_dir_all(dir).unwrap();
401 fs::write(dir.join(name), contents).unwrap();
402 }
403
404 #[test]
405 fn review_rebuilds_the_map_and_summarizes_coverage() {
406 let root = tempfile::tempdir().unwrap();
407 let reports_dir = root.path().join("reports");
408 let ontologies_dir = root.path().join("ontologies");
409 fs::create_dir_all(&ontologies_dir).unwrap();
410 fs::write(
411 ontologies_dir.join("edu-fixture.yaml"),
412 "
413ontology:
414 id: edu-fixture
415 version: \"0.1.0\"
416entity_types:
417 - name: title
418 schema:
419 required: [name]
420 properties: {name: {type: string}}
421discovery:
422 enabled: true
423 patterns:
424 - content_pattern: \"ISBN\"
425 suggest_entity: title
426",
427 )
428 .unwrap();
429
430 let findings_dir = reports_dir.join("edu").join("findings");
431 write_finding(
432 &findings_dir,
433 "good.json",
434 r#"{"@id":"f-good","entity":{"name":"Algebra I","entity_type":"title"}}"#,
435 );
436 write_finding(
437 &findings_dir,
438 "disc.json",
439 r#"{"@id":"f-disc","content":"has an ISBN"}"#,
440 );
441 write_finding(
442 &findings_dir,
443 "untyped.json",
444 r#"{"@id":"f-untyped","content":"nothing special"}"#,
445 );
446 write_finding(
447 &findings_dir,
448 "invalid.json",
449 r#"{"@id":"f-invalid","entity":{"entity_type":"title"}}"#,
450 );
451
452 let catalog = Catalog {
453 ontologies: vec![CatalogEntry {
454 id: "edu-fixture".to_string(),
455 version: "0.1.0".to_string(),
456 source: None,
457 core: false,
458 }],
459 };
460 let config = HarnessConfig {
461 topics: vec![TopicConfig {
462 id: "edu".to_string(),
463 ontologies: vec!["edu-fixture".to_string()],
464 }],
465 };
466
467 let ontology_packs = ontology_pack::load_packs_from_dir(&ontologies_dir).unwrap();
468 let opts = ReviewOptions {
469 topics: None,
470 reports_dir: &reports_dir,
471 ontology_packs: &ontology_packs,
472 catalog: &catalog,
473 config: &config,
474 check_relationship_targets_script: None,
475 };
476
477 let (report, backlog) = review(&opts).unwrap();
478 assert_eq!(report.topics.len(), 1);
479 assert_eq!(
480 report.summary_line(),
481 "1 topic(s); 4 findings — 1 stamped, 1 discovery-only, 1 untyped, 1 invalid/unresolved"
482 );
483 assert!(report.strict_should_fail());
484 assert_eq!(backlog.total_needs_followup, 3);
485
486 let map_path = reports_dir.join("edu").join("ontology-map.json");
487 assert!(map_path.exists());
488 let map: Vec<crate::resolve::MapRecord> =
489 serde_json::from_str(&fs::read_to_string(map_path).unwrap()).unwrap();
490 assert_eq!(map.len(), 4);
491 }
492
493 #[test]
494 fn a_topic_with_no_findings_directory_is_skipped_entirely() {
495 let root = tempfile::tempdir().unwrap();
496 let reports_dir = root.path().join("reports");
497
498 let catalog = Catalog { ontologies: vec![] };
499 let config = HarnessConfig {
500 topics: vec![TopicConfig {
501 id: "ghost".to_string(),
502 ontologies: vec![],
503 }],
504 };
505 let ontology_packs = std::collections::HashMap::new();
506 let opts = ReviewOptions {
507 topics: None,
508 reports_dir: &reports_dir,
509 ontology_packs: &ontology_packs,
510 catalog: &catalog,
511 config: &config,
512 check_relationship_targets_script: None,
513 };
514
515 let (report, _backlog) = review(&opts).unwrap();
516 assert_eq!(report.topics.len(), 0);
517 assert_eq!(
518 report.summary_line(),
519 "0 topic(s); 0 findings — 0 stamped, 0 discovery-only, 0 untyped, 0 invalid/unresolved"
520 );
521 }
522}