Skip to main content

shift_preflight/
report.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4use crate::cost::{ImageMetrics, TokenSavings};
5
6/// Record of a single transformation action taken.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ActionRecord {
9    /// Which image (by index in the payload)
10    pub image_index: usize,
11    /// What action was taken
12    pub action: String,
13    /// Details (e.g., "resized from 4000x3000 to 2048x1536")
14    pub detail: String,
15}
16
17/// Report of all transformations applied by SHIFT.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Report {
20    /// Total payload size before transformation (bytes)
21    pub original_size: usize,
22    /// Total payload size after transformation (bytes)
23    pub transformed_size: usize,
24    /// Number of images found in the payload
25    pub images_found: usize,
26    /// Number of images that were modified
27    pub images_modified: usize,
28    /// Number of images dropped
29    pub images_dropped: usize,
30    /// Number of SVGs rasterized
31    pub svgs_rasterized: usize,
32    /// Individual action records
33    pub actions: Vec<ActionRecord>,
34    /// Warnings (non-fatal issues)
35    pub warnings: Vec<String>,
36    /// Whether this was a dry run (no actual changes)
37    pub dry_run: bool,
38    /// Per-image before/after metrics (dimensions, bytes, tokens)
39    pub image_metrics: Vec<ImageMetrics>,
40    /// Aggregate token savings across all images
41    pub token_savings: TokenSavings,
42}
43
44impl Report {
45    pub fn new() -> Self {
46        Report {
47            original_size: 0,
48            transformed_size: 0,
49            images_found: 0,
50            images_modified: 0,
51            images_dropped: 0,
52            svgs_rasterized: 0,
53            actions: Vec::new(),
54            warnings: Vec::new(),
55            dry_run: false,
56            image_metrics: Vec::new(),
57            token_savings: TokenSavings::default(),
58        }
59    }
60
61    pub fn add_action(&mut self, image_index: usize, action: &str, detail: &str) {
62        self.actions.push(ActionRecord {
63            image_index,
64            action: action.to_string(),
65            detail: detail.to_string(),
66        });
67    }
68
69    pub fn add_warning(&mut self, warning: &str) {
70        self.warnings.push(warning.to_string());
71    }
72
73    pub fn add_image_metrics(&mut self, metrics: ImageMetrics) {
74        self.image_metrics.push(metrics);
75    }
76
77    /// Recompute aggregate token savings from per-image metrics.
78    pub fn finalize_token_savings(&mut self) {
79        self.token_savings = TokenSavings::from_metrics(&self.image_metrics);
80    }
81
82    /// Size reduction as a percentage.
83    pub fn size_reduction_pct(&self) -> f64 {
84        if self.original_size == 0 {
85            return 0.0;
86        }
87        let reduction = self.original_size as f64 - self.transformed_size as f64;
88        (reduction / self.original_size as f64) * 100.0
89    }
90
91    /// Whether any transformations were actually applied.
92    pub fn has_changes(&self) -> bool {
93        self.images_modified > 0 || self.images_dropped > 0 || self.svgs_rasterized > 0
94    }
95}
96
97impl Default for Report {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103/// Format a token count with thousands separators.
104pub fn fmt_tokens(n: u64) -> String {
105    if n < 1_000 {
106        return n.to_string();
107    }
108    let s = n.to_string();
109    let mut result = String::new();
110    for (i, c) in s.chars().rev().enumerate() {
111        if i > 0 && i % 3 == 0 {
112            result.push(',');
113        }
114        result.push(c);
115    }
116    result.chars().rev().collect()
117}
118
119impl fmt::Display for Report {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        if self.dry_run {
122            writeln!(f, "=== SHIFT Dry Run Report ===")?;
123        } else {
124            writeln!(f, "=== SHIFT Report ===")?;
125        }
126
127        writeln!(f, "Images found:      {}", self.images_found)?;
128        writeln!(f, "Images modified:   {}", self.images_modified)?;
129        writeln!(f, "Images dropped:    {}", self.images_dropped)?;
130        if self.svgs_rasterized > 0 {
131            writeln!(f, "SVGs rasterized:   {}", self.svgs_rasterized)?;
132        }
133        writeln!(f, "Original size:     {} bytes", self.original_size)?;
134        writeln!(f, "Transformed size:  {} bytes", self.transformed_size)?;
135        if self.original_size > 0 {
136            writeln!(f, "Size reduction:    {:.1}%", self.size_reduction_pct())?;
137        }
138
139        // Token savings section
140        let ts = &self.token_savings;
141        if ts.openai_before > 0 || ts.anthropic_before > 0 {
142            writeln!(f)?;
143            writeln!(f, "Token Savings (estimated):")?;
144            if ts.openai_before > 0 {
145                writeln!(
146                    f,
147                    "  OpenAI:    {} -> {} tokens  ({:.1}% saved)",
148                    fmt_tokens(ts.openai_before),
149                    fmt_tokens(ts.openai_after),
150                    ts.openai_pct()
151                )?;
152            }
153            if ts.anthropic_before > 0 {
154                writeln!(
155                    f,
156                    "  Anthropic: {} -> {} tokens  ({:.1}% saved)",
157                    fmt_tokens(ts.anthropic_before),
158                    fmt_tokens(ts.anthropic_after),
159                    ts.anthropic_pct()
160                )?;
161            }
162        }
163
164        // Per-image breakdown
165        if !self.image_metrics.is_empty()
166            && self.image_metrics.iter().any(|m| {
167                m.original_width != m.transformed_width || m.original_height != m.transformed_height
168            })
169        {
170            writeln!(f)?;
171            writeln!(f, "Per-image breakdown:")?;
172            for m in &self.image_metrics {
173                let dims_changed = m.original_width != m.transformed_width
174                    || m.original_height != m.transformed_height;
175                let fmt_changed = m.format_before != m.format_after;
176
177                if dims_changed || fmt_changed {
178                    let fmt_str = if fmt_changed {
179                        format!(
180                            "  {}->{}",
181                            m.format_before.to_uppercase(),
182                            m.format_after.to_uppercase()
183                        )
184                    } else {
185                        String::new()
186                    };
187                    writeln!(
188                        f,
189                        "  [{}] {}x{} -> {}x{}{}  (OpenAI: {} -> {}, Anthropic: {} -> {})",
190                        m.image_index,
191                        m.original_width,
192                        m.original_height,
193                        m.transformed_width,
194                        m.transformed_height,
195                        fmt_str,
196                        fmt_tokens(m.tokens_before.openai_tokens),
197                        fmt_tokens(m.tokens_after.openai_tokens),
198                        fmt_tokens(m.tokens_before.anthropic_tokens),
199                        fmt_tokens(m.tokens_after.anthropic_tokens),
200                    )?;
201                }
202            }
203        }
204
205        if !self.actions.is_empty() {
206            writeln!(f, "\nActions:")?;
207            for action in &self.actions {
208                writeln!(
209                    f,
210                    "  [image {}] {} — {}",
211                    action.image_index, action.action, action.detail
212                )?;
213            }
214        }
215
216        if !self.warnings.is_empty() {
217            writeln!(f, "\nWarnings:")?;
218            for warning in &self.warnings {
219                writeln!(f, "  ! {}", warning)?;
220            }
221        }
222
223        Ok(())
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_report_new() {
233        let report = Report::new();
234        assert_eq!(report.images_found, 0);
235        assert!(!report.has_changes());
236    }
237
238    #[test]
239    fn test_report_size_reduction() {
240        let mut report = Report::new();
241        report.original_size = 1000;
242        report.transformed_size = 750;
243        assert!((report.size_reduction_pct() - 25.0).abs() < 0.001);
244    }
245
246    #[test]
247    fn test_report_size_reduction_zero() {
248        let report = Report::new();
249        assert_eq!(report.size_reduction_pct(), 0.0);
250    }
251
252    #[test]
253    fn test_report_has_changes() {
254        let mut report = Report::new();
255        assert!(!report.has_changes());
256
257        report.images_modified = 1;
258        assert!(report.has_changes());
259    }
260
261    #[test]
262    fn test_report_display() {
263        let mut report = Report::new();
264        report.images_found = 2;
265        report.images_modified = 1;
266        report.original_size = 5000;
267        report.transformed_size = 3000;
268        report.add_action(0, "resize", "from 4000x3000 to 2048x1536");
269        report.add_warning("image 1 is very small, may lose detail");
270
271        let output = format!("{}", report);
272        assert!(output.contains("Images found:      2"));
273        assert!(output.contains("Images modified:   1"));
274        assert!(output.contains("resize"));
275        assert!(output.contains("may lose detail"));
276    }
277
278    #[test]
279    fn test_report_display_with_token_savings() {
280        use crate::cost::{estimate_tokens, ImageMetrics};
281
282        let mut report = Report::new();
283        report.images_found = 1;
284        report.images_modified = 1;
285        report.original_size = 5_000_000;
286        report.transformed_size = 500_000;
287
288        let before = estimate_tokens(4000, 3000);
289        let after = estimate_tokens(2048, 1536);
290        report.add_image_metrics(ImageMetrics {
291            image_index: 0,
292            original_width: 4000,
293            original_height: 3000,
294            transformed_width: 2048,
295            transformed_height: 1536,
296            original_bytes: 5_000_000,
297            transformed_bytes: 500_000,
298            format_before: "png".to_string(),
299            format_after: "png".to_string(),
300            tokens_before: before,
301            tokens_after: after,
302        });
303        report.finalize_token_savings();
304
305        let output = format!("{}", report);
306        assert!(output.contains("Token Savings"));
307        assert!(output.contains("OpenAI:"));
308        assert!(output.contains("Anthropic:"));
309        assert!(output.contains("Per-image breakdown:"));
310    }
311
312    #[test]
313    fn test_fmt_tokens() {
314        assert_eq!(fmt_tokens(0), "0");
315        assert_eq!(fmt_tokens(42), "42");
316        assert_eq!(fmt_tokens(999), "999");
317        assert_eq!(fmt_tokens(1000), "1,000");
318        assert_eq!(fmt_tokens(12345), "12,345");
319        assert_eq!(fmt_tokens(1234567), "1,234,567");
320    }
321}