typst_bake/
stats.rs

1//! Compression statistics for embedded files.
2//!
3//! All embedded resources (templates, fonts, packages) are compressed with zstd
4//! and decompressed lazily at runtime.
5
6/// Compression statistics for all embedded content.
7///
8/// Resources are compressed with zstd at compile time and decompressed lazily at runtime.
9#[derive(Debug, Clone)]
10pub struct EmbedStats {
11    /// Template files statistics
12    pub templates: CategoryStats,
13    /// Package files statistics
14    pub packages: PackageStats,
15    /// Font files statistics
16    pub fonts: CategoryStats,
17}
18
19/// Statistics for a category of files (templates, fonts)
20#[derive(Debug, Clone)]
21pub struct CategoryStats {
22    /// Original uncompressed size in bytes
23    pub original_size: usize,
24    /// Compressed size in bytes
25    pub compressed_size: usize,
26    /// Number of files
27    pub file_count: usize,
28}
29
30/// Statistics for all packages
31#[derive(Debug, Clone)]
32pub struct PackageStats {
33    /// Per-package statistics
34    pub packages: Vec<PackageInfo>,
35    /// Total original size of all packages
36    pub total_original: usize,
37    /// Total compressed size of all packages
38    pub total_compressed: usize,
39}
40
41/// Statistics for a single package
42#[derive(Debug, Clone)]
43pub struct PackageInfo {
44    /// Package name with version (e.g., "gentle-clues:1.2.0")
45    pub name: String,
46    /// Original uncompressed size in bytes
47    pub original_size: usize,
48    /// Compressed size in bytes
49    pub compressed_size: usize,
50    /// Number of files in this package
51    pub file_count: usize,
52}
53
54impl EmbedStats {
55    /// Calculate total original size across all categories
56    pub fn total_original(&self) -> usize {
57        self.templates.original_size + self.packages.total_original + self.fonts.original_size
58    }
59
60    /// Calculate total compressed size across all categories
61    pub fn total_compressed(&self) -> usize {
62        self.templates.compressed_size + self.packages.total_compressed + self.fonts.compressed_size
63    }
64
65    /// Calculate compression ratio (0.0 to 1.0, where 0.0 means no compression)
66    pub fn compression_ratio(&self) -> f64 {
67        let original = self.total_original();
68        if original == 0 {
69            return 0.0;
70        }
71        1.0 - (self.total_compressed() as f64 / original as f64)
72    }
73
74    /// Display compression statistics in a human-readable format
75    pub fn display(&self) {
76        println!("Compression Statistics:");
77        println!("========================");
78
79        // Templates
80        if self.templates.file_count > 0 {
81            println!(
82                "Templates:  {:>9} -> {:>9} ({:>5.1}% reduced, {} files)",
83                format_size(self.templates.original_size),
84                format_size(self.templates.compressed_size),
85                self.templates.compression_ratio() * 100.0,
86                self.templates.file_count
87            );
88        }
89
90        // Fonts
91        if self.fonts.file_count > 0 {
92            println!(
93                "Fonts:      {:>9} -> {:>9} ({:>5.1}% reduced, {} files)",
94                format_size(self.fonts.original_size),
95                format_size(self.fonts.compressed_size),
96                self.fonts.compression_ratio() * 100.0,
97                self.fonts.file_count
98            );
99        }
100
101        // Packages
102        if !self.packages.packages.is_empty() {
103            println!("Packages:");
104
105            // Calculate column widths for package alignment
106            let name_width = self
107                .packages
108                .packages
109                .iter()
110                .map(|p| p.name.len())
111                .max()
112                .unwrap_or(0);
113            let orig_width = self
114                .packages
115                .packages
116                .iter()
117                .map(|p| format_size(p.original_size).len())
118                .max()
119                .unwrap_or(0);
120            let comp_width = self
121                .packages
122                .packages
123                .iter()
124                .map(|p| format_size(p.compressed_size).len())
125                .max()
126                .unwrap_or(0);
127
128            for pkg in &self.packages.packages {
129                println!(
130                    "  {:<name_w$}  {:>orig_w$} -> {:>comp_w$}  ({:>5.1}%)",
131                    pkg.name,
132                    format_size(pkg.original_size),
133                    format_size(pkg.compressed_size),
134                    pkg.compression_ratio() * 100.0,
135                    name_w = name_width,
136                    orig_w = orig_width,
137                    comp_w = comp_width,
138                );
139            }
140        }
141
142        // Total
143        println!("------------------------");
144        println!(
145            "Total: {} -> {} ({:.1}% reduced)",
146            format_size(self.total_original()),
147            format_size(self.total_compressed()),
148            self.compression_ratio() * 100.0
149        );
150    }
151}
152
153impl CategoryStats {
154    /// Calculate compression ratio for this category
155    pub fn compression_ratio(&self) -> f64 {
156        if self.original_size == 0 {
157            return 0.0;
158        }
159        1.0 - (self.compressed_size as f64 / self.original_size as f64)
160    }
161}
162
163impl PackageInfo {
164    /// Calculate compression ratio for this package
165    pub fn compression_ratio(&self) -> f64 {
166        if self.original_size == 0 {
167            return 0.0;
168        }
169        1.0 - (self.compressed_size as f64 / self.original_size as f64)
170    }
171}
172
173impl PackageStats {
174    /// Calculate compression ratio for all packages
175    pub fn compression_ratio(&self) -> f64 {
176        if self.total_original == 0 {
177            return 0.0;
178        }
179        1.0 - (self.total_compressed as f64 / self.total_original as f64)
180    }
181}
182
183/// Format bytes into human-readable size
184fn format_size(bytes: usize) -> String {
185    const KB: usize = 1024;
186    const MB: usize = KB * 1024;
187
188    if bytes >= MB {
189        format!("{:.2} MB", bytes as f64 / MB as f64)
190    } else if bytes >= KB {
191        format!("{:.1} KB", bytes as f64 / KB as f64)
192    } else {
193        format!("{} B", bytes)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_format_size_bytes() {
203        assert_eq!(format_size(0), "0 B");
204        assert_eq!(format_size(512), "512 B");
205        assert_eq!(format_size(1023), "1023 B");
206    }
207
208    #[test]
209    fn test_format_size_kilobytes() {
210        assert_eq!(format_size(1024), "1.0 KB");
211        assert_eq!(format_size(1536), "1.5 KB");
212        assert_eq!(format_size(10240), "10.0 KB");
213    }
214
215    #[test]
216    fn test_format_size_megabytes() {
217        assert_eq!(format_size(1048576), "1.00 MB");
218        assert_eq!(format_size(1572864), "1.50 MB");
219    }
220
221    #[test]
222    fn test_compression_ratio_zero_original() {
223        let stats = CategoryStats {
224            original_size: 0,
225            compressed_size: 0,
226            file_count: 0,
227        };
228        assert_eq!(stats.compression_ratio(), 0.0);
229    }
230
231    #[test]
232    fn test_compression_ratio_75_percent() {
233        // Asymmetric values to distinguish from incorrect calculation (original/compressed)
234        // Correct: 1 - (250/1000) = 0.75
235        // Wrong:   1 - (1000/250) = -3.0
236        let stats = CategoryStats {
237            original_size: 1000,
238            compressed_size: 250,
239            file_count: 1,
240        };
241        assert!((stats.compression_ratio() - 0.75).abs() < 0.001);
242    }
243
244    #[test]
245    fn test_embed_stats_totals() {
246        let stats = EmbedStats {
247            templates: CategoryStats {
248                original_size: 1000,
249                compressed_size: 200, // 80% compression
250                file_count: 1,
251            },
252            fonts: CategoryStats {
253                original_size: 2000,
254                compressed_size: 600, // 70% compression
255                file_count: 2,
256            },
257            packages: PackageStats {
258                packages: vec![],
259                total_original: 1000,
260                total_compressed: 200, // 80% compression
261            },
262        };
263        // Total: 4000 -> 1000 (75% compression)
264        assert_eq!(stats.total_original(), 4000);
265        assert_eq!(stats.total_compressed(), 1000);
266        assert!((stats.compression_ratio() - 0.75).abs() < 0.001);
267    }
268}