1use 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
17pub struct InspectionResult {
19 pub artifact_type: String,
21 pub confidence: f64,
23 pub present_sections: Vec<String>,
24 pub missing_sections: Vec<String>,
25 pub status: Option<String>,
27 pub category: Option<String>,
28 pub supersedes: Option<String>,
30 pub relationships: Vec<(String, Vec<String>)>,
32}
33
34pub struct FileInspection {
36 pub path: String,
37 pub artifact_type: String,
38 pub confidence: f64,
39}
40
41pub 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 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.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
77fn 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
88fn 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 result.supersedes = Some(first_line(supersedes).to_string());
111 }
112 }
113}
114
115pub 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 if let Some(spec) = spec_for(&c.artifact_type) {
135 result.relationships = extract_relationships(artifact, spec);
136 }
137 result
138}
139
140pub 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}