Skip to main content

amql_engine/
stats.rs

1//! Project statistics for token burn measurement.
2//!
3//! Counts source files, lines, bytes, annotations, and estimates token
4//! savings from using AQL annotations instead of raw source reading.
5
6use crate::store::AnnotationStore;
7use crate::types::ProjectRoot;
8use serde::Serialize;
9
10#[cfg(feature = "fs")]
11use crate::code_cache::glob_source_files;
12#[cfg(feature = "fs")]
13use crate::resolver::ResolverRegistry;
14
15/// Aggregate project statistics for token burn reporting.
16#[derive(Debug, Clone, Serialize)]
17pub struct ProjectStats {
18    /// Number of source files with resolver support.
19    pub source_files: usize,
20    /// Total lines across all source files.
21    pub source_lines: usize,
22    /// Total bytes across all source files.
23    pub source_bytes: usize,
24    /// Estimated source tokens (bytes / 4).
25    pub source_tokens: usize,
26    /// Number of annotation sidecar files loaded.
27    pub annotation_files: usize,
28    /// Total annotation count across all files.
29    pub annotation_count: usize,
30    /// Estimated annotation tokens (serialized JSON bytes / 4).
31    pub annotation_tokens: usize,
32    /// Compression ratio: source_tokens / annotation_tokens.
33    /// `None` when annotation_tokens is zero.
34    pub compression_ratio: Option<f64>,
35}
36
37/// Compute project statistics from loaded stores.
38///
39/// Scans all resolver-supported source files under `project_root`, counts
40/// lines and bytes, then compares against annotation token cost.
41#[cfg(feature = "fs")]
42pub fn project_stats(
43    project_root: &ProjectRoot,
44    store: &AnnotationStore,
45    resolvers: &ResolverRegistry,
46) -> ProjectStats {
47    let source_files = glob_source_files(project_root, &crate::types::Scope::from(""), resolvers);
48    let file_count = source_files.len();
49
50    let mut total_lines: usize = 0;
51    let mut total_bytes: usize = 0;
52
53    for path in &source_files {
54        if let Ok(content) = std::fs::read_to_string(path) {
55            total_bytes += content.len();
56            total_lines += content.lines().count();
57        }
58    }
59
60    let source_tokens = total_bytes / 4;
61
62    let annotation_files = store.file_count();
63    let all_annotations = store.get_all_annotations();
64    let annotation_count = all_annotations.len();
65
66    // Estimate annotation tokens from serialized JSON size
67    let annotation_bytes = serde_json::to_string(&all_annotations)
68        .map(|s| s.len())
69        .unwrap_or(0);
70    let annotation_tokens = annotation_bytes / 4;
71
72    let compression_ratio = if annotation_tokens > 0 {
73        Some(source_tokens as f64 / annotation_tokens as f64)
74    } else {
75        None
76    };
77
78    ProjectStats {
79        source_files: file_count,
80        source_lines: total_lines,
81        source_bytes: total_bytes,
82        source_tokens,
83        annotation_files,
84        annotation_count,
85        annotation_tokens,
86        compression_ratio,
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use std::path::Path;
94
95    #[test]
96    fn stats_on_empty_store() {
97        // Arrange
98        let store = AnnotationStore::new(Path::new("/nonexistent"));
99        let resolvers = ResolverRegistry::with_defaults();
100        let root = ProjectRoot::from(Path::new("/nonexistent"));
101
102        // Act
103        let stats = project_stats(&root, &store, &resolvers);
104
105        // Assert
106        assert_eq!(stats.source_files, 0, "no source files in nonexistent dir");
107        assert_eq!(stats.annotation_count, 0, "no annotations");
108        assert!(
109            stats.compression_ratio.is_none(),
110            "no ratio with zero annotations"
111        );
112    }
113}