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 change as a percentage.
83    ///
84    /// Positive values indicate reduction (smaller output).
85    /// Negative values indicate the output grew (e.g., format conversion).
86    pub fn size_reduction_pct(&self) -> f64 {
87        if self.original_size == 0 {
88            return 0.0;
89        }
90        let reduction = self.original_size as f64 - self.transformed_size as f64;
91        (reduction / self.original_size as f64) * 100.0
92    }
93
94    /// Whether any transformations were actually applied.
95    pub fn has_changes(&self) -> bool {
96        self.images_modified > 0 || self.images_dropped > 0 || self.svgs_rasterized > 0
97    }
98}
99
100impl Default for Report {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106/// Format a token count with comma thousands separators.
107///
108/// # Examples
109/// ```
110/// # use shift_preflight::report::fmt_tokens;
111/// assert_eq!(fmt_tokens(1234567), "1,234,567");
112/// assert_eq!(fmt_tokens(42), "42");
113/// ```
114pub fn fmt_tokens(n: u64) -> String {
115    if n < 1_000 {
116        return n.to_string();
117    }
118    let s = n.to_string();
119    let mut result = String::new();
120    for (i, c) in s.chars().rev().enumerate() {
121        if i > 0 && i % 3 == 0 {
122            result.push(',');
123        }
124        result.push(c);
125    }
126    result.chars().rev().collect()
127}
128
129impl fmt::Display for Report {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        if self.dry_run {
132            writeln!(f, "=== SHIFT Dry Run Report ===")?;
133        } else {
134            writeln!(f, "=== SHIFT Report ===")?;
135        }
136
137        writeln!(f, "Images found:      {}", self.images_found)?;
138        writeln!(f, "Images modified:   {}", self.images_modified)?;
139        writeln!(f, "Images dropped:    {}", self.images_dropped)?;
140        if self.svgs_rasterized > 0 {
141            writeln!(f, "SVGs rasterized:   {}", self.svgs_rasterized)?;
142        }
143        writeln!(f, "Original size:     {} bytes", self.original_size)?;
144        writeln!(f, "Transformed size:  {} bytes", self.transformed_size)?;
145        if self.original_size > 0 {
146            let pct = self.size_reduction_pct();
147            if pct >= 0.0 {
148                writeln!(f, "Size reduction:    {:.1}%", pct)?;
149            } else {
150                writeln!(f, "Size increased:    {:.1}%", pct.abs())?;
151            }
152        }
153
154        // Token savings section
155        let ts = &self.token_savings;
156        if ts.openai_before > 0 || ts.anthropic_before > 0 {
157            writeln!(f)?;
158            writeln!(f, "Token Savings (estimated):")?;
159            if ts.openai_before > 0 {
160                writeln!(
161                    f,
162                    "  OpenAI:    {} -> {} tokens  ({:.1}% saved)",
163                    fmt_tokens(ts.openai_before),
164                    fmt_tokens(ts.openai_after),
165                    ts.openai_pct()
166                )?;
167            }
168            if ts.anthropic_before > 0 {
169                writeln!(
170                    f,
171                    "  Anthropic: {} -> {} tokens  ({:.1}% saved)",
172                    fmt_tokens(ts.anthropic_before),
173                    fmt_tokens(ts.anthropic_after),
174                    ts.anthropic_pct()
175                )?;
176            }
177        }
178
179        // Per-image breakdown
180        if !self.image_metrics.is_empty()
181            && self.image_metrics.iter().any(|m| {
182                m.original_width != m.transformed_width || m.original_height != m.transformed_height
183            })
184        {
185            writeln!(f)?;
186            writeln!(f, "Per-image breakdown:")?;
187            for m in &self.image_metrics {
188                let dims_changed = m.original_width != m.transformed_width
189                    || m.original_height != m.transformed_height;
190                let fmt_changed = m.format_before != m.format_after;
191
192                if dims_changed || fmt_changed {
193                    let fmt_str = if fmt_changed {
194                        format!(
195                            "  {}->{}",
196                            m.format_before.to_uppercase(),
197                            m.format_after.to_uppercase()
198                        )
199                    } else {
200                        String::new()
201                    };
202                    writeln!(
203                        f,
204                        "  [{}] {}x{} -> {}x{}{}  (OpenAI: {} -> {}, Anthropic: {} -> {})",
205                        m.image_index,
206                        m.original_width,
207                        m.original_height,
208                        m.transformed_width,
209                        m.transformed_height,
210                        fmt_str,
211                        fmt_tokens(m.tokens_before.openai_tokens),
212                        fmt_tokens(m.tokens_after.openai_tokens),
213                        fmt_tokens(m.tokens_before.anthropic_tokens),
214                        fmt_tokens(m.tokens_after.anthropic_tokens),
215                    )?;
216                }
217            }
218        }
219
220        if !self.actions.is_empty() {
221            writeln!(f, "\nActions:")?;
222            for action in &self.actions {
223                writeln!(
224                    f,
225                    "  [image {}] {} — {}",
226                    action.image_index, action.action, action.detail
227                )?;
228            }
229        }
230
231        if !self.warnings.is_empty() {
232            writeln!(f, "\nWarnings:")?;
233            for warning in &self.warnings {
234                writeln!(f, "  ! {}", warning)?;
235            }
236        }
237
238        Ok(())
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_report_new() {
248        let report = Report::new();
249        assert_eq!(report.images_found, 0);
250        assert!(!report.has_changes());
251    }
252
253    #[test]
254    fn test_report_size_reduction() {
255        let mut report = Report::new();
256        report.original_size = 1000;
257        report.transformed_size = 750;
258        assert!((report.size_reduction_pct() - 25.0).abs() < 0.001);
259    }
260
261    #[test]
262    fn test_report_size_reduction_zero() {
263        let report = Report::new();
264        assert_eq!(report.size_reduction_pct(), 0.0);
265    }
266
267    #[test]
268    fn test_report_has_changes() {
269        let mut report = Report::new();
270        assert!(!report.has_changes());
271
272        report.images_modified = 1;
273        assert!(report.has_changes());
274    }
275
276    #[test]
277    fn test_report_display() {
278        let mut report = Report::new();
279        report.images_found = 2;
280        report.images_modified = 1;
281        report.original_size = 5000;
282        report.transformed_size = 3000;
283        report.add_action(0, "resize", "from 4000x3000 to 2048x1536");
284        report.add_warning("image 1 is very small, may lose detail");
285
286        let output = format!("{}", report);
287        assert!(output.contains("Images found:      2"));
288        assert!(output.contains("Images modified:   1"));
289        assert!(output.contains("resize"));
290        assert!(output.contains("may lose detail"));
291    }
292
293    #[test]
294    fn test_report_display_with_token_savings() {
295        use crate::cost::{estimate_tokens, ImageMetrics};
296
297        let mut report = Report::new();
298        report.images_found = 1;
299        report.images_modified = 1;
300        report.original_size = 5_000_000;
301        report.transformed_size = 500_000;
302
303        let before = estimate_tokens(4000, 3000);
304        let after = estimate_tokens(2048, 1536);
305        report.add_image_metrics(ImageMetrics {
306            image_index: 0,
307            original_width: 4000,
308            original_height: 3000,
309            transformed_width: 2048,
310            transformed_height: 1536,
311            original_bytes: 5_000_000,
312            transformed_bytes: 500_000,
313            format_before: "png".to_string(),
314            format_after: "png".to_string(),
315            tokens_before: before,
316            tokens_after: after,
317        });
318        report.finalize_token_savings();
319
320        let output = format!("{}", report);
321        assert!(output.contains("Token Savings"));
322        assert!(output.contains("OpenAI:"));
323        assert!(output.contains("Anthropic:"));
324        assert!(output.contains("Per-image breakdown:"));
325    }
326
327    #[test]
328    fn test_fmt_tokens() {
329        assert_eq!(fmt_tokens(0), "0");
330        assert_eq!(fmt_tokens(42), "42");
331        assert_eq!(fmt_tokens(999), "999");
332        assert_eq!(fmt_tokens(1000), "1,000");
333        assert_eq!(fmt_tokens(12345), "12,345");
334        assert_eq!(fmt_tokens(1234567), "1,234,567");
335    }
336}