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 {
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 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
103pub 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 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 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}