Skip to main content

amql_engine/
coverage.rs

1//! Per-file annotation coverage analysis and sanity checks.
2//!
3//! Functions here cross-reference annotation data with the source file
4//! system and code element caches to produce coverage reports, binding
5//! spot-checks, and per-file summaries.
6
7use 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/// Per-file annotation coverage entry.
16#[non_exhaustive]
17#[derive(Debug, Clone, Serialize)]
18pub struct FileCoverage {
19    /// Relative source file path.
20    pub file: RelativePath,
21    /// Whether a code resolver exists for this file's extension.
22    pub has_resolver: bool,
23    /// Number of annotations targeting this file.
24    pub annotation_count: usize,
25    /// True if at least one annotation exists for this file.
26    pub covered: bool,
27}
28
29/// Result of spot-checking annotation bindings against code elements.
30#[non_exhaustive]
31#[derive(Debug, Clone, Serialize)]
32pub struct SpotCheckResult {
33    /// Number of bindings checked.
34    pub checked: usize,
35    /// Number that matched a code element.
36    pub passed: usize,
37    /// Number that did not match any code element.
38    pub failed: usize,
39    /// Details of each failure.
40    pub failures: Vec<SpotCheckFailure>,
41}
42
43/// A single binding that failed the spot check.
44#[non_exhaustive]
45#[derive(Debug, Clone, Serialize)]
46pub struct SpotCheckFailure {
47    /// Source file the annotation targets.
48    pub file: RelativePath,
49    /// The binding value that did not match.
50    pub binding: Binding,
51    /// Human-readable reason for the failure.
52    pub reason: String,
53}
54
55/// Per-file coverage for a specific set of files (diff-aware).
56#[non_exhaustive]
57#[derive(Debug, Clone, Serialize)]
58pub struct FileDiffCoverage {
59    /// Relative source file path.
60    pub file: RelativePath,
61    /// Total annotation count for this file.
62    pub annotation_count: usize,
63    /// Annotation count per tag.
64    pub tags: FxHashMap<TagName, usize>,
65    /// True if at least one annotation exists.
66    pub covered: bool,
67    /// Source file size in bytes (0 if file not found).
68    pub source_bytes: usize,
69}
70
71/// Detailed single-file annotation summary.
72#[non_exhaustive]
73#[derive(Debug, Clone, Serialize)]
74pub struct FileSummary {
75    /// Relative source file path.
76    pub file: RelativePath,
77    /// Bindings grouped by tag.
78    pub tags: FxHashMap<TagName, Vec<Binding>>,
79    /// Total annotation count.
80    pub annotation_count: usize,
81    /// Source file size in bytes.
82    pub source_bytes: usize,
83    /// Serialized annotation size in bytes.
84    pub annotation_bytes: usize,
85    /// Compression ratio: annotation_bytes / source_bytes (None if source_bytes == 0).
86    pub compression: Option<f64>,
87    /// Whether a code resolver exists for this file.
88    pub has_resolver: bool,
89    /// Whether the sidecar is stale on disk.
90    pub stale: bool,
91}
92
93/// Per-file annotation coverage report.
94///
95/// Iterates all source files within `scope`, cross-references with the
96/// annotation store, and reports coverage per file.
97pub 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
127/// Spot-check annotation bindings against parsed code elements.
128///
129/// Collects all annotations in `scope`, sorts deterministically by
130/// (file, binding), takes the first `n`, and verifies each binding
131/// matches a code element name in the same file.
132pub 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    // Flatten to (file, binding) pairs, sorted deterministically
141    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
189/// Check if any code element in the tree has a name matching `binding`.
190fn 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
197/// Annotation coverage for a specific set of files (diff-aware).
198///
199/// For each requested file, counts annotations by tag and checks
200/// resolver availability.
201pub 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
233/// Detailed single-file annotation summary.
234///
235/// Groups annotations by tag with their bindings, computes sizes,
236/// and checks staleness.
237pub 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        // Arrange
306        let store = test_store();
307        let code_cache = CodeCache::new(Path::new("/tmp/test-project"));
308
309        // Act
310        let result = spot_check_bindings(&store, &code_cache, &Scope::from(""), 100);
311
312        // Assert
313        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        // Arrange
324        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        // Act
354        let results = diff_file_coverage(&store, &root, &[RelativePath::from("src/api.ts")]);
355
356        // Assert
357        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}