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 id(&self) -> &'static str {
29 "missing_coverage"
30 }
31
32 fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
33 let test_sources = load_test_sources(&ctx.rust_files);
34 if test_sources.is_empty() {
35 return Vec::new();
38 }
39
40 let mut out = Vec::new();
41 let mut already_flagged: HashSet<(std::path::PathBuf, u32, String)> = HashSet::new();
42
43 for md in &ctx.markdown_files {
44 let Ok(claims) = MarkdownParser::parse(md) else {
45 continue;
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 = (claim.location.file.clone(), claim.location.line, leaf.clone());
66 if !already_flagged.insert(key) {
67 continue;
68 }
69
70 out.push(Divergence {
71 rule: RuleId::MissingCoverage,
72 severity: Severity::Notice,
73 location: Location::new(
74 claim.location.file.clone(),
75 claim.location.line,
76 ),
77 stated: format!("`{leaf}` is a capability the project exposes"),
78 reality: format!("no test references `{leaf}` by name"),
79 risk: "Capability claimed in the docs has no guard-rail in tests.".to_string(),
80 attribution: None,
81
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(
96 r"^(?:[A-Za-z_][A-Za-z0-9_]*::)*([A-Za-z_][A-Za-z0-9_]*)\([^)]*\)$",
97 )
98 .unwrap()
99 })
100}
101
102fn extract_callable_leaf(span: &str) -> Option<String> {
103 let caps = callable_re().captures(span.trim())?;
104 Some(caps.get(1)?.as_str().to_string())
105}
106
107fn load_test_sources(rust_files: &[std::path::PathBuf]) -> Vec<String> {
108 rust_files
109 .iter()
110 .filter(|p| is_test_file(p))
111 .filter_map(|p| std::fs::read_to_string(p).ok())
112 .collect()
113}
114
115fn is_test_file(path: &Path) -> bool {
116 if path.components().any(|c| c.as_os_str() == "tests") {
118 return true;
119 }
120 match std::fs::read_to_string(path) {
122 Ok(src) => src.contains("#[test]") || src.contains("#[cfg(test)]"),
123 Err(_) => false,
124 }
125}
126
127fn tests_reference(sources: &[String], symbol: &str) -> bool {
128 let pattern = format!(r"\b{}\b", regex::escape(symbol));
130 let Ok(re) = Regex::new(&pattern) else {
131 return false;
132 };
133 sources.iter().any(|s| re.is_match(s))
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::domain::CodeFact;
140
141 fn fact_fn(name: &str) -> CodeFact {
142 CodeFact {
143 location: Location::new("src/lib.rs", 1),
144 kind: FactKind::Function,
145 name: name.to_string(),
146 }
147 }
148
149 #[test]
150 fn flags_documented_fn_absent_from_tests() {
151 let tmp = tempfile::tempdir().unwrap();
152 let md = tmp.path().join("README.md");
153 std::fs::write(&md, "Call `place_order()` to submit.\n").unwrap();
154
155 let test_file = tmp.path().join("tests").join("api.rs");
156 std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
157 std::fs::write(&test_file, "#[test] fn t() { other_fn(); }\n").unwrap();
158
159 let mut ctx = ProjectContext::new(tmp.path());
160 ctx.markdown_files.push(md);
161 ctx.rust_files.push(test_file);
162 ctx.code_facts.push(fact_fn("place_order"));
163
164 let divs = MissingCoverageAnalyzer.analyze(&ctx);
165 assert_eq!(divs.len(), 1);
166 assert_eq!(divs[0].rule, RuleId::MissingCoverage);
167 assert_eq!(divs[0].severity, Severity::Notice);
168 }
169
170 #[test]
171 fn passes_when_test_mentions_symbol() {
172 let tmp = tempfile::tempdir().unwrap();
173 let md = tmp.path().join("README.md");
174 std::fs::write(&md, "Call `place_order()` to submit.\n").unwrap();
175
176 let test_file = tmp.path().join("tests").join("api.rs");
177 std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
178 std::fs::write(
179 &test_file,
180 "#[test] fn t() { let _ = place_order(); }\n",
181 )
182 .unwrap();
183
184 let mut ctx = ProjectContext::new(tmp.path());
185 ctx.markdown_files.push(md);
186 ctx.rust_files.push(test_file);
187 ctx.code_facts.push(fact_fn("place_order"));
188
189 assert!(MissingCoverageAnalyzer.analyze(&ctx).is_empty());
190 }
191
192 #[test]
193 fn does_not_flag_missing_symbols() {
194 let tmp = tempfile::tempdir().unwrap();
196 let md = tmp.path().join("README.md");
197 std::fs::write(&md, "Call `ghost_fn()` to vanish.\n").unwrap();
198
199 let test_file = tmp.path().join("tests").join("api.rs");
200 std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
201 std::fs::write(&test_file, "#[test] fn t() {}\n").unwrap();
202
203 let mut ctx = ProjectContext::new(tmp.path());
204 ctx.markdown_files.push(md);
205 ctx.rust_files.push(test_file);
206 assert!(MissingCoverageAnalyzer.analyze(&ctx).is_empty());
209 }
210
211 #[test]
212 fn word_boundary_prevents_substring_false_positive() {
213 let tmp = tempfile::tempdir().unwrap();
215 let md = tmp.path().join("README.md");
216 std::fs::write(&md, "Call `new()`.\n").unwrap();
217
218 let test_file = tmp.path().join("tests").join("api.rs");
219 std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
220 std::fs::write(&test_file, "#[test] fn t() { renew(); }\n").unwrap();
221
222 let mut ctx = ProjectContext::new(tmp.path());
223 ctx.markdown_files.push(md);
224 ctx.rust_files.push(test_file);
225 ctx.code_facts.push(fact_fn("new"));
226
227 let divs = MissingCoverageAnalyzer.analyze(&ctx);
228 assert_eq!(divs.len(), 1);
229 }
230
231 #[test]
232 fn silent_when_no_tests_exist() {
233 let tmp = tempfile::tempdir().unwrap();
234 let md = tmp.path().join("README.md");
235 std::fs::write(&md, "Call `f()`.\n").unwrap();
236
237 let mut ctx = ProjectContext::new(tmp.path());
238 ctx.markdown_files.push(md);
239 ctx.code_facts.push(fact_fn("f"));
240 assert!(MissingCoverageAnalyzer.analyze(&ctx).is_empty());
243 }
244}