spec_drift/analyzers/
coverage.rs1use super::DriftAnalyzer;
2use crate::context::ProjectContext;
3use crate::domain::{Divergence, FactKind, Location, RuleId, Severity};
4use crate::parsers::MarkdownParser;
5use regex::Regex;
6use std::collections::HashSet;
7use std::path::Path;
8use std::sync::OnceLock;
9
10pub struct MissingCoverageAnalyzer;
20
21impl Default for MissingCoverageAnalyzer {
22 fn default() -> Self {
23 Self
24 }
25}
26
27impl DriftAnalyzer for MissingCoverageAnalyzer {
28 fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
29 let test_sources = load_test_sources(&ctx.rust_files);
30 if test_sources.is_empty() {
31 return Vec::new();
34 }
35
36 let mut out = Vec::new();
37 let mut already_flagged: HashSet<(std::path::PathBuf, u32, String)> = HashSet::new();
38
39 for md in &ctx.markdown_files {
40 let claims = match MarkdownParser::parse(md) {
41 Ok(c) => c,
42 Err(e) => {
43 eprintln!("spec-drift: skipping {} (coverage): {e}", md.display());
44 continue;
45 }
46 };
47 for claim in claims {
48 let Some(leaf) = extract_callable_leaf(&claim.text) else {
49 continue;
50 };
51
52 let exists_as_fn = ctx
55 .facts_named(&leaf)
56 .any(|f| matches!(f.kind, FactKind::Function));
57 if !exists_as_fn {
58 continue;
59 }
60
61 if tests_reference(&test_sources, &leaf) {
62 continue;
63 }
64
65 let key = (
66 claim.location.file.clone(),
67 claim.location.line,
68 leaf.clone(),
69 );
70 if !already_flagged.insert(key) {
71 continue;
72 }
73
74 out.push(Divergence {
75 rule: RuleId::MissingCoverage,
76 severity: Severity::Notice,
77 location: Location::new(claim.location.file.clone(), claim.location.line),
78 stated: format!("`{leaf}` is a capability the project exposes"),
79 reality: format!("no test references `{leaf}` by name"),
80 risk: "Capability claimed in the docs has no guard-rail in tests.".to_string(),
81 attribution: None,
82 });
83 }
84 }
85
86 out
87 }
88}
89
90fn callable_re() -> &'static Regex {
91 static RE: OnceLock<Regex> = OnceLock::new();
92 RE.get_or_init(|| {
93 Regex::new(r"^(?:[A-Za-z_][A-Za-z0-9_]*::)*([A-Za-z_][A-Za-z0-9_]*)\([^)]*\)$").unwrap()
96 })
97}
98
99fn extract_callable_leaf(span: &str) -> Option<String> {
100 let caps = callable_re().captures(span.trim())?;
101 Some(caps.get(1)?.as_str().to_string())
102}
103
104fn load_test_sources(rust_files: &[std::path::PathBuf]) -> Vec<String> {
105 rust_files
106 .iter()
107 .filter(|p| is_test_file(p))
108 .filter_map(|p| std::fs::read_to_string(p).ok())
109 .collect()
110}
111
112fn is_test_file(path: &Path) -> bool {
113 if path.components().any(|c| c.as_os_str() == "tests") {
115 return true;
116 }
117 match std::fs::read_to_string(path) {
119 Ok(src) => src.contains("#[test]") || src.contains("#[cfg(test)]"),
120 Err(_) => false,
121 }
122}
123
124fn tests_reference(sources: &[String], symbol: &str) -> bool {
125 let pattern = format!(r"\b{}\b", regex::escape(symbol));
127 let Ok(re) = Regex::new(&pattern) else {
128 return false;
129 };
130 sources.iter().any(|s| re.is_match(s))
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::domain::CodeFact;
137
138 fn fact_fn(name: &str) -> CodeFact {
139 CodeFact {
140 location: Location::new("src/lib.rs", 1),
141 kind: FactKind::Function,
142 name: name.to_string(),
143 }
144 }
145
146 #[test]
147 fn flags_documented_fn_absent_from_tests() {
148 let tmp = tempfile::tempdir().unwrap();
149 let md = tmp.path().join("README.md");
150 std::fs::write(&md, "Call `place_order()` to submit.\n").unwrap();
151
152 let test_file = tmp.path().join("tests").join("api.rs");
153 std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
154 std::fs::write(&test_file, "#[test] fn t() { other_fn(); }\n").unwrap();
155
156 let mut ctx = ProjectContext::new(tmp.path());
157 ctx.markdown_files.push(md);
158 ctx.rust_files.push(test_file);
159 ctx.code_facts.push(fact_fn("place_order"));
160
161 let divs = MissingCoverageAnalyzer.analyze(&ctx);
162 assert_eq!(divs.len(), 1);
163 assert_eq!(divs[0].rule, RuleId::MissingCoverage);
164 assert_eq!(divs[0].severity, Severity::Notice);
165 }
166
167 #[test]
168 fn passes_when_test_mentions_symbol() {
169 let tmp = tempfile::tempdir().unwrap();
170 let md = tmp.path().join("README.md");
171 std::fs::write(&md, "Call `place_order()` to submit.\n").unwrap();
172
173 let test_file = tmp.path().join("tests").join("api.rs");
174 std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
175 std::fs::write(&test_file, "#[test] fn t() { let _ = place_order(); }\n").unwrap();
176
177 let mut ctx = ProjectContext::new(tmp.path());
178 ctx.markdown_files.push(md);
179 ctx.rust_files.push(test_file);
180 ctx.code_facts.push(fact_fn("place_order"));
181
182 assert!(MissingCoverageAnalyzer.analyze(&ctx).is_empty());
183 }
184
185 #[test]
186 fn does_not_flag_missing_symbols() {
187 let tmp = tempfile::tempdir().unwrap();
189 let md = tmp.path().join("README.md");
190 std::fs::write(&md, "Call `ghost_fn()` to vanish.\n").unwrap();
191
192 let test_file = tmp.path().join("tests").join("api.rs");
193 std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
194 std::fs::write(&test_file, "#[test] fn t() {}\n").unwrap();
195
196 let mut ctx = ProjectContext::new(tmp.path());
197 ctx.markdown_files.push(md);
198 ctx.rust_files.push(test_file);
199 assert!(MissingCoverageAnalyzer.analyze(&ctx).is_empty());
202 }
203
204 #[test]
205 fn word_boundary_prevents_substring_false_positive() {
206 let tmp = tempfile::tempdir().unwrap();
208 let md = tmp.path().join("README.md");
209 std::fs::write(&md, "Call `new()`.\n").unwrap();
210
211 let test_file = tmp.path().join("tests").join("api.rs");
212 std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
213 std::fs::write(&test_file, "#[test] fn t() { renew(); }\n").unwrap();
214
215 let mut ctx = ProjectContext::new(tmp.path());
216 ctx.markdown_files.push(md);
217 ctx.rust_files.push(test_file);
218 ctx.code_facts.push(fact_fn("new"));
219
220 let divs = MissingCoverageAnalyzer.analyze(&ctx);
221 assert_eq!(divs.len(), 1);
222 }
223
224 #[test]
225 fn silent_when_no_tests_exist() {
226 let tmp = tempfile::tempdir().unwrap();
227 let md = tmp.path().join("README.md");
228 std::fs::write(&md, "Call `f()`.\n").unwrap();
229
230 let mut ctx = ProjectContext::new(tmp.path());
231 ctx.markdown_files.push(md);
232 ctx.code_facts.push(fact_fn("f"));
233 assert!(MissingCoverageAnalyzer.analyze(&ctx).is_empty());
236 }
237}