1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4use crate::cost::{ImageMetrics, TokenSavings};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ActionRecord {
9 pub image_index: usize,
11 pub action: String,
13 pub detail: String,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Report {
20 pub original_size: usize,
22 pub transformed_size: usize,
24 pub images_found: usize,
26 pub images_modified: usize,
28 pub images_dropped: usize,
30 pub svgs_rasterized: usize,
32 pub actions: Vec<ActionRecord>,
34 pub warnings: Vec<String>,
36 pub dry_run: bool,
38 pub image_metrics: Vec<ImageMetrics>,
40 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 pub fn finalize_token_savings(&mut self) {
79 self.token_savings = TokenSavings::from_metrics(&self.image_metrics);
80 }
81
82 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 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
106pub 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 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 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}