Skip to main content

rac_engine/
inspect.rs

1//! Artifact inspection (`decided.services.inspect`): classify one document and
2//! report its structure, or aggregate types across a directory.
3//!
4//! Section names are stored normalized (e.g. `"success metrics"`); the
5//! renderers format them (`.title()` for humans, snake_case for JSON).
6//! Decision metadata (`status`, `category`, `supersedes`) is attached only
7//! for decisions; relationships are spec-driven and exclude `supersedes`
8//! (the documented v0.4.2/ADR-007 top-level-scalar exception).
9
10use crate::classify::classify;
11use crate::parse::{parse_file, Artifact};
12use crate::pycompat::py_strip;
13use crate::relationships::extract_relationships;
14use crate::spec::{canonical_value, spec_for, specs};
15use crate::walk::find_markdown_files;
16
17/// Typed single-file inspection result (`InspectionResult`).
18pub struct InspectionResult {
19    /// Artifact name, or `"unknown"`.
20    pub artifact_type: String,
21    /// 0.0 – 1.0, already rounded to 2dp by `classify`.
22    pub confidence: f64,
23    pub present_sections: Vec<String>,
24    pub missing_sections: Vec<String>,
25    /// Decision metadata — populated only for decisions that declare it.
26    pub status: Option<String>,
27    pub category: Option<String>,
28    /// Top-level scalar (v0.4.2 / ADR-007 exception), never in `relationships`.
29    pub supersedes: Option<String>,
30    /// `{snake_section -> [refs]}` in spec.optional order; `related_*` only.
31    pub relationships: Vec<(String, Vec<String>)>,
32}
33
34/// One file's result inside a directory inspection.
35pub struct FileInspection {
36    pub path: String,
37    pub artifact_type: String,
38    pub confidence: f64,
39}
40
41/// Aggregated inspection across a directory of Markdown files.
42pub struct DirectoryInspection {
43    pub directory: String,
44    pub recursive: bool,
45    pub files: Vec<FileInspection>,
46}
47
48impl DirectoryInspection {
49    pub fn total_files(&self) -> usize {
50        self.files.len()
51    }
52
53    /// Known types first (in ARTIFACT_SPECS order), then `unknown`.
54    pub fn counts(&self) -> Vec<(&str, usize)> {
55        let mut counts: Vec<(&str, usize)> = specs().iter().map(|s| (s.name.as_str(), 0)).collect();
56        counts.push(("unknown", 0));
57        for f in &self.files {
58            if let Some(slot) = counts.iter_mut().find(|(name, _)| *name == f.artifact_type) {
59                slot.1 += 1;
60            } else {
61                // `counts.get(f.type, 0) + 1` — an unregistered type appends.
62                counts.push((f.artifact_type.as_str(), 1));
63            }
64        }
65        counts
66    }
67
68    pub fn unknown_count(&self) -> usize {
69        self.counts()
70            .iter()
71            .find(|(name, _)| *name == "unknown")
72            .map(|(_, n)| *n)
73            .unwrap_or(0)
74    }
75}
76
77/// `_first_line(body)` — the first non-empty line of a section body.
78fn first_line(body: &str) -> &str {
79    for line in crate::pycompat::py_splitlines(body) {
80        let stripped = py_strip(line);
81        if !stripped.is_empty() {
82            return stripped;
83        }
84    }
85    ""
86}
87
88/// `_attach_decision_metadata(result, product)`.
89fn attach_decision_metadata(result: &mut InspectionResult, artifact: &Artifact) {
90    let Some(spec) = spec_for("decision") else {
91        return;
92    };
93    for (field_name, allowed) in &spec.metadata {
94        let Some(body) = artifact.section(field_name) else {
95            continue;
96        };
97        if body.is_empty() {
98            continue;
99        }
100        let value = canonical_value(first_line(body), allowed);
101        match field_name.as_str() {
102            "status" => result.status = Some(value),
103            "category" => result.category = Some(value),
104            _ => {}
105        }
106    }
107    if let Some(supersedes) = artifact.section("supersedes") {
108        if !supersedes.is_empty() {
109            // Metadata only (REQ-003): no validation, just normalize the value.
110            result.supersedes = Some(first_line(supersedes).to_string());
111        }
112    }
113}
114
115/// `build_inspection(product)` — classify, then attach decision metadata and
116/// relationships.
117pub fn build_inspection(artifact: &Artifact) -> InspectionResult {
118    let c = classify(artifact);
119    let mut result = InspectionResult {
120        artifact_type: c.artifact_type.clone(),
121        confidence: c.confidence,
122        present_sections: c.present_sections,
123        missing_sections: c.missing_sections,
124        status: None,
125        category: None,
126        supersedes: None,
127        relationships: Vec::new(),
128    };
129    if c.artifact_type == "decision" {
130        attach_decision_metadata(&mut result, artifact);
131    }
132    // Relationship metadata is spec-driven, so it applies to any recognized
133    // type (Unknown has no spec and therefore no relationships).
134    if let Some(spec) = spec_for(&c.artifact_type) {
135        result.relationships = extract_relationships(artifact, spec);
136    }
137    result
138}
139
140/// `inspect_directory(directory, recursive)` — walk, classify, aggregate.
141pub fn inspect_directory(directory: &str, recursive: bool) -> DirectoryInspection {
142    use rayon::prelude::*;
143    let files: Vec<FileInspection> = find_markdown_files(directory, recursive)
144        .into_par_iter()
145        .map(|entry| {
146            let artifact = parse_file(&entry.display);
147            let c = classify(&artifact);
148            FileInspection {
149                path: entry.display,
150                artifact_type: c.artifact_type,
151                confidence: c.confidence,
152            }
153        })
154        .collect();
155    DirectoryInspection {
156        directory: directory.to_string(),
157        recursive,
158        files,
159    }
160}