1use crate::code_cache::{glob_source_files, CodeCache};
8use crate::error::AqlError;
9use crate::resolver::ResolverRegistry;
10use crate::store::AnnotationStore;
11use crate::types::{Binding, ProjectRoot, RelativePath, Scope, TagName};
12use rustc_hash::{FxHashMap, FxHashSet};
13use serde::Serialize;
14
15#[non_exhaustive]
17#[derive(Debug, Clone, Serialize)]
18pub struct FileCoverage {
19 pub file: RelativePath,
21 pub has_resolver: bool,
23 pub annotation_count: usize,
25 pub covered: bool,
27}
28
29#[non_exhaustive]
31#[derive(Debug, Clone, Serialize)]
32pub struct SpotCheckResult {
33 pub checked: usize,
35 pub passed: usize,
37 pub failed: usize,
39 pub failures: Vec<SpotCheckFailure>,
41}
42
43#[non_exhaustive]
45#[derive(Debug, Clone, Serialize)]
46pub struct SpotCheckFailure {
47 pub file: RelativePath,
49 pub binding: Binding,
51 pub reason: String,
53}
54
55#[non_exhaustive]
57#[derive(Debug, Clone, Serialize)]
58pub struct FileDiffCoverage {
59 pub file: RelativePath,
61 pub annotation_count: usize,
63 pub tags: FxHashMap<TagName, usize>,
65 pub covered: bool,
67 pub source_bytes: usize,
69}
70
71#[non_exhaustive]
73#[derive(Debug, Clone, Serialize)]
74pub struct FileSummary {
75 pub file: RelativePath,
77 pub tags: FxHashMap<TagName, Vec<Binding>>,
79 pub annotation_count: usize,
81 pub source_bytes: usize,
83 pub annotation_bytes: usize,
85 pub compression: Option<f64>,
87 pub has_resolver: bool,
89 pub stale: bool,
91}
92
93pub fn file_coverage(
98 root: &ProjectRoot,
99 store: &AnnotationStore,
100 resolvers: &ResolverRegistry,
101 scope: &Scope,
102) -> Vec<FileCoverage> {
103 let source_files = glob_source_files(root, scope, resolvers);
104 let annotated: FxHashSet<RelativePath> = store.annotated_files().into_iter().collect();
105
106 let mut results: Vec<FileCoverage> = source_files
107 .into_iter()
108 .map(|abs_path| {
109 let rel = crate::paths::relative(root, &abs_path);
110 let has_resolver = resolvers.has_resolver_for(&abs_path);
111 let anns = store.get_file_annotations(&rel);
112 let annotation_count = anns.len();
113 let covered = annotated.contains(&*rel);
114 FileCoverage {
115 file: rel,
116 has_resolver,
117 annotation_count,
118 covered,
119 }
120 })
121 .collect();
122
123 results.sort_by(|a, b| a.file.cmp(&b.file));
124 results
125}
126
127pub fn spot_check_bindings(
133 store: &AnnotationStore,
134 code_cache: &CodeCache,
135 scope: &Scope,
136 n: usize,
137) -> SpotCheckResult {
138 let annotations = store.annotations_in_scope(scope);
139
140 let mut pairs: Vec<(RelativePath, Binding)> = Vec::new();
142 for (file, anns) in &annotations {
143 for ann in anns {
144 if !ann.binding.is_empty() {
145 pairs.push(((*file).clone(), ann.binding.clone()));
146 }
147 }
148 }
149 pairs.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
150 pairs.dedup();
151 pairs.truncate(n);
152
153 let mut passed = 0usize;
154 let mut failures = Vec::new();
155
156 for (file, binding) in &pairs {
157 let code = code_cache.get(file);
158 let found = match code {
159 Some(root) => has_code_name(root, binding),
160 None => {
161 failures.push(SpotCheckFailure {
162 file: file.clone(),
163 binding: binding.clone(),
164 reason: "file not in code cache (not parsed)".to_string(),
165 });
166 continue;
167 }
168 };
169 if found {
170 passed += 1;
171 } else {
172 failures.push(SpotCheckFailure {
173 file: file.clone(),
174 binding: binding.clone(),
175 reason: "no code element with matching name".to_string(),
176 });
177 }
178 }
179
180 let checked = pairs.len();
181 SpotCheckResult {
182 checked,
183 passed,
184 failed: failures.len(),
185 failures,
186 }
187}
188
189fn has_code_name(element: &crate::resolver::CodeElement, binding: &str) -> bool {
191 if element.name.as_ref() == binding {
192 return true;
193 }
194 element.children.iter().any(|c| has_code_name(c, binding))
195}
196
197pub fn diff_file_coverage(
202 store: &AnnotationStore,
203 root: &ProjectRoot,
204 files: &[RelativePath],
205) -> Vec<FileDiffCoverage> {
206 files
207 .iter()
208 .map(|file| {
209 let anns = store.get_file_annotations(file);
210 let annotation_count = anns.len();
211
212 let mut tags: FxHashMap<TagName, usize> = FxHashMap::default();
213 for ann in &anns {
214 *tags.entry(ann.tag.clone()).or_insert(0) += 1;
215 }
216
217 let abs_path = root.join(AsRef::<std::path::Path>::as_ref(file));
218 let source_bytes = std::fs::metadata(&abs_path)
219 .map(|m| m.len() as usize)
220 .unwrap_or(0);
221
222 FileDiffCoverage {
223 file: file.clone(),
224 annotation_count,
225 tags,
226 covered: annotation_count > 0,
227 source_bytes,
228 }
229 })
230 .collect()
231}
232
233pub fn file_summary(
238 store: &AnnotationStore,
239 resolvers: &ResolverRegistry,
240 root: &ProjectRoot,
241 file: &RelativePath,
242) -> Result<FileSummary, AqlError> {
243 let anns = store.get_file_annotations(file);
244
245 let mut tags: FxHashMap<TagName, Vec<Binding>> = FxHashMap::default();
246 for ann in &anns {
247 tags.entry(ann.tag.clone())
248 .or_default()
249 .push(ann.binding.clone());
250 }
251
252 let annotation_count = anns.len();
253
254 let abs_path = root.join(AsRef::<std::path::Path>::as_ref(file));
255 let source_bytes = std::fs::metadata(&abs_path)
256 .map(|m| m.len() as usize)
257 .unwrap_or(0);
258
259 let annotation_bytes = serde_json::to_string(&anns).map(|s| s.len()).unwrap_or(0);
260
261 let compression = if source_bytes > 0 {
262 Some(annotation_bytes as f64 / source_bytes as f64)
263 } else {
264 None
265 };
266
267 let has_resolver = resolvers.has_resolver_for(&abs_path);
268 let stale = store.is_stale(file);
269
270 Ok(FileSummary {
271 file: file.clone(),
272 tags,
273 annotation_count,
274 source_bytes,
275 annotation_bytes,
276 compression,
277 has_resolver,
278 stale,
279 })
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::store::Annotation;
286 use std::path::Path;
287
288 fn test_store() -> AnnotationStore {
289 let mut store = AnnotationStore::new(Path::new("/tmp/test-project"));
290 store.inject_test_data(
291 &RelativePath::from("src/api.ts"),
292 vec![Annotation {
293 tag: TagName::from("route"),
294 attrs: FxHashMap::default(),
295 binding: Binding::from("getUser"),
296 file: RelativePath::from("src/api.ts"),
297 children: vec![],
298 }],
299 );
300 store
301 }
302
303 #[test]
304 fn spot_check_reports_missing_code() {
305 let store = test_store();
307 let code_cache = CodeCache::new(Path::new("/tmp/test-project"));
308
309 let result = spot_check_bindings(&store, &code_cache, &Scope::from(""), 100);
311
312 assert_eq!(result.checked, 1, "should check one binding");
314 assert_eq!(result.failed, 1, "should fail — file not parsed");
315 assert_eq!(
316 result.failures[0].binding, "getUser",
317 "failure should name the binding"
318 );
319 }
320
321 #[test]
322 fn diff_coverage_counts_tags() {
323 let mut store = AnnotationStore::new(Path::new("/tmp/test-project"));
325 store.inject_test_data(
326 &RelativePath::from("src/api.ts"),
327 vec![
328 Annotation {
329 tag: TagName::from("route"),
330 attrs: FxHashMap::default(),
331 binding: Binding::from("a"),
332 file: RelativePath::from("src/api.ts"),
333 children: vec![],
334 },
335 Annotation {
336 tag: TagName::from("route"),
337 attrs: FxHashMap::default(),
338 binding: Binding::from("b"),
339 file: RelativePath::from("src/api.ts"),
340 children: vec![],
341 },
342 Annotation {
343 tag: TagName::from("middleware"),
344 attrs: FxHashMap::default(),
345 binding: Binding::from("c"),
346 file: RelativePath::from("src/api.ts"),
347 children: vec![],
348 },
349 ],
350 );
351 let root = ProjectRoot::from(Path::new("/tmp/test-project"));
352
353 let results = diff_file_coverage(&store, &root, &[RelativePath::from("src/api.ts")]);
355
356 assert_eq!(results.len(), 1, "should have one file result");
358 assert_eq!(
359 results[0].annotation_count, 3,
360 "should count all annotations"
361 );
362 assert_eq!(
363 results[0].tags.get(&TagName::from("route")),
364 Some(&2),
365 "route tag should have count 2"
366 );
367 assert_eq!(
368 results[0].tags.get(&TagName::from("middleware")),
369 Some(&1),
370 "middleware tag should have count 1"
371 );
372 assert!(results[0].covered, "file should be covered");
373 }
374}