Skip to main content

vision_squeezer/
lib.rs

1use std::io::Cursor;
2
3use base64::{Engine, engine::general_purpose::STANDARD as B64};
4use chrono::Utc;
5use image::{DynamicImage, ImageBuffer, Luma, imageops::FilterType};
6use rusqlite::{Connection, params};
7use std::path::PathBuf;
8// ── Config ────────────────────────────────────────────────────────────────────
9
10/// Output encoding format.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum OutputFormat {
13    /// JPEG at configured quality (default).
14    #[default]
15    Jpeg,
16    /// WebP at configured quality — typically 30-50% smaller than JPEG at equal quality.
17    WebP,
18    /// AVIF at configured quality — typically 20-50% smaller than WebP at equal quality.
19    Avif,
20}
21
22/// All tuneable knobs for the pipeline.
23#[derive(Clone, Debug)]
24pub struct ProcessConfig {
25    /// Output quality 1–100 (default 75). Applies to both JPEG and WebP.
26    pub quality: u8,
27    /// LLM patch size in pixels. Overridden when `target_model` is set.
28    pub tile_size: u32,
29    /// Remove solid-color padding borders before resizing (default true).
30    pub crop: bool,
31    /// Max channel delta to treat a pixel as background (default 15).
32    pub bg_tolerance: u8,
33    /// Output encoding format (default: JPEG).
34    pub output_format: OutputFormat,
35    /// When set, resizing is model-aware (accounts for pre-scaling behavior).
36    pub target_model: Option<VisionModel>,
37    /// Limit the maximum number of tiles the output image can consume.
38    pub max_tiles: Option<u32>,
39    /// Use saliency (edge-energy) based crop instead of corner-tolerance crop.
40    pub smart_crop: bool,
41}
42
43impl Default for ProcessConfig {
44    fn default() -> Self {
45        Self {
46            quality: 75,
47            tile_size: 512,
48            crop: true,
49            bg_tolerance: 15,
50            output_format: OutputFormat::Jpeg,
51            target_model: None,
52            max_tiles: None,
53            smart_crop: false,
54        }
55    }
56}
57
58impl ProcessConfig {
59    pub fn builder() -> ProcessConfigBuilder {
60        ProcessConfigBuilder(Self::default())
61    }
62}
63
64pub struct ProcessConfigBuilder(ProcessConfig);
65
66impl ProcessConfigBuilder {
67    pub fn quality(mut self, q: u8) -> Self {
68        self.0.quality = q.clamp(1, 100);
69        self
70    }
71    pub fn tile_size(mut self, t: u32) -> Self {
72        self.0.tile_size = t.max(1);
73        self
74    }
75    pub fn crop(mut self, c: bool) -> Self {
76        self.0.crop = c;
77        self
78    }
79    pub fn bg_tolerance(mut self, t: u8) -> Self {
80        self.0.bg_tolerance = t;
81        self
82    }
83    pub fn output_format(mut self, f: OutputFormat) -> Self {
84        self.0.output_format = f;
85        self
86    }
87    pub fn target_model(mut self, m: VisionModel) -> Self {
88        self.0.target_model = Some(m);
89        self
90    }
91    pub fn max_tiles(mut self, m: u32) -> Self {
92        self.0.max_tiles = Some(m);
93        self
94    }
95    pub fn smart_crop(mut self, b: bool) -> Self {
96        self.0.smart_crop = b;
97        self
98    }
99    pub fn build(self) -> ProcessConfig {
100        self.0
101    }
102}
103
104// ── Token Estimation ──────────────────────────────────────────────────────────
105
106/// Supported vision model families with their patch pricing.
107#[derive(Clone, Copy, Debug)]
108pub enum VisionModel {
109    /// Claude 3.5/4.5/4.6/4.7: Area-based calculation (Tokens ≈ width × height / 750).
110    Claude,
111    /// GPT-4o / GPT-4.5 high detail: fits in 2048x2048, scales short side to 768, then 512x512 tiles.
112    Gpt4o,
113    /// GPT-5/5.5: 6000px max dim, 10.24M max pixels, 512×512 tiles, 1536 token cap.
114    Gpt5,
115    /// Gemini 2.0/3.0: flat 258 tokens if ≤ 384x384, else 258 per 768x768 tile.
116    Gemini15,
117}
118
119#[derive(Debug)]
120pub struct TokenEstimate {
121    pub model: VisionModel,
122    pub tokens: u32,
123    pub tiles: u32,
124}
125
126/// Estimate LLM vision tokens for an image of given dimensions.
127pub fn estimate_tokens(width: u32, height: u32, model: VisionModel) -> TokenEstimate {
128    match model {
129        VisionModel::Claude => {
130            // 2026 area-based pricing for Claude
131            let tokens = ((width as u64 * height as u64) / 750) as u32;
132            TokenEstimate {
133                model,
134                tiles: 1,
135                tokens: tokens.max(85),
136            }
137        }
138        VisionModel::Gpt4o => {
139            // GPT-4o / 4.5: fit within 2048x2048, then short side scaled to 768px, then 512x512 tiles.
140            let (mut w, mut h) = fit_within(width, height, 2048);
141            let short_side = w.min(h);
142            if short_side > 768 {
143                let scale = 768.0 / short_side as f64;
144                w = (w as f64 * scale).round() as u32;
145                h = (h as f64 * scale).round() as u32;
146            }
147            let tiles = tile_count(w, 512) * tile_count(h, 512);
148            TokenEstimate {
149                model,
150                tiles,
151                tokens: 85 + tiles * 170,
152            }
153        }
154        VisionModel::Gpt5 => {
155            let (w, h) = fit_within_pixels(width, height, 6000, 10_240_000);
156            let tiles = tile_count(w, 512) * tile_count(h, 512);
157            TokenEstimate {
158                model,
159                tiles,
160                tokens: (85 + tiles * 170).min(1536),
161            }
162        }
163        VisionModel::Gemini15 => {
164            // Gemini 2026: flat 258 if <= 384x384, else 768x768 tiles.
165            if width <= 384 && height <= 384 {
166                TokenEstimate {
167                    model,
168                    tiles: 1,
169                    tokens: 258,
170                }
171            } else {
172                let tiles = tile_count(width, 768) * tile_count(height, 768);
173                TokenEstimate {
174                    model,
175                    tiles,
176                    tokens: tiles * 258,
177                }
178            }
179        }
180    }
181}
182
183/// Scale dimensions to fit within `max_side` while preserving aspect ratio.
184pub fn fit_within(width: u32, height: u32, max_side: u32) -> (u32, u32) {
185    if width <= max_side && height <= max_side {
186        return (width, height);
187    }
188    let scale = max_side as f64 / width.max(height) as f64;
189    (
190        (width as f64 * scale) as u32,
191        (height as f64 * scale) as u32,
192    )
193}
194
195/// Scale dimensions to fit within both a max-side limit and a total-pixel limit.
196pub fn fit_within_pixels(width: u32, height: u32, max_side: u32, max_pixels: u64) -> (u32, u32) {
197    let (mut w, mut h) = fit_within(width, height, max_side);
198    let total = w as u64 * h as u64;
199    if total > max_pixels {
200        let scale = (max_pixels as f64 / total as f64).sqrt();
201        w = (w as f64 * scale) as u32;
202        h = (h as f64 * scale) as u32;
203    }
204    (w.max(1), h.max(1))
205}
206
207/// Compute the optimal dimensions to *send* to a given model to minimize tiles.
208///
209/// For models that pre-scale images (GPT-4o, Gemini), we simulate their scaling,
210/// snap the scaled result to tile boundaries, then invert back to input space.
211/// For Claude (no pre-scaling), we snap the input directly.
212pub fn optimal_send_dimensions(width: u32, height: u32, model: VisionModel) -> (u32, u32) {
213    match model {
214        VisionModel::Claude => {
215            // Claude is now area-based, so tiling doesn't dictate a specific rigid boundary.
216            // But we still snap to 256 or 512 so dimensions aren't completely arbitrary.
217            (
218                snap_to_tile_boundary(width, 256),
219                snap_to_tile_boundary(height, 256),
220            )
221        }
222        VisionModel::Gpt4o => optimal_for_prescaling_model(width, height, 2048, 512),
223        VisionModel::Gpt5 => {
224            let (fw, fh) = fit_within_pixels(width, height, 6000, 10_240_000);
225            (
226                snap_to_tile_boundary(fw, 512).max(512),
227                snap_to_tile_boundary(fh, 512).max(512),
228            )
229        }
230        VisionModel::Gemini15 => {
231            // Gemini uses 768x768 tiles if > 384x384
232            if width <= 384 && height <= 384 {
233                (width, height)
234            } else {
235                optimal_for_prescaling_model(width, height, 4096, 768)
236            }
237        }
238    }
239}
240
241/// For models that pre-scale (GPT-4o, Gemini), find the smallest input dimensions
242/// that, after the model's internal fit-within + tiling, produce the fewest tiles.
243///
244/// Strategy: enumerate candidate tile-grid dimensions (tw*tile, th*tile) that fit
245/// within max_side, compute the input size that would map to each, and pick the
246/// candidate that uses the fewest tiles while preserving the original aspect ratio
247/// as closely as possible.
248fn optimal_for_prescaling_model(width: u32, height: u32, max_side: u32, tile: u32) -> (u32, u32) {
249    let (fw, fh) = fit_within(width, height, max_side);
250
251    // Simply snap the fitted dimensions to the nearest tile boundary
252    let target_w = snap_to_tile_boundary(fw, tile).max(tile);
253    let target_h = snap_to_tile_boundary(fh, tile).max(tile);
254
255    // If image was larger than max_side, scale back to input space
256    if width > max_side || height > max_side {
257        let scale = width.max(height) as f64 / max_side as f64;
258        let opt_w = (target_w as f64 * scale).round() as u32;
259        let opt_h = (target_h as f64 * scale).round() as u32;
260        return (opt_w.max(1), opt_h.max(1));
261    }
262
263    (target_w, target_h)
264}
265
266/// Full token savings report for a before/after dimension pair across all models.
267pub struct TokenSavingsTable {
268    pub claude_before: TokenEstimate,
269    pub claude_after: TokenEstimate,
270    pub gpt4o_before: TokenEstimate,
271    pub gpt4o_after: TokenEstimate,
272    pub gpt5_before: TokenEstimate,
273    pub gpt5_after: TokenEstimate,
274    pub gemini_before: TokenEstimate,
275    pub gemini_after: TokenEstimate,
276}
277
278pub fn token_savings_table(orig_w: u32, orig_h: u32, opt_w: u32, opt_h: u32) -> TokenSavingsTable {
279    TokenSavingsTable {
280        claude_before: estimate_tokens(orig_w, orig_h, VisionModel::Claude),
281        claude_after: estimate_tokens(opt_w, opt_h, VisionModel::Claude),
282        gpt4o_before: estimate_tokens(orig_w, orig_h, VisionModel::Gpt4o),
283        gpt4o_after: estimate_tokens(opt_w, opt_h, VisionModel::Gpt4o),
284        gpt5_before: estimate_tokens(orig_w, orig_h, VisionModel::Gpt5),
285        gpt5_after: estimate_tokens(opt_w, opt_h, VisionModel::Gpt5),
286        gemini_before: estimate_tokens(orig_w, orig_h, VisionModel::Gemini15),
287        gemini_after: estimate_tokens(opt_w, opt_h, VisionModel::Gemini15),
288    }
289}
290
291impl TokenSavingsTable {
292    pub fn print(&self) {
293        println!(
294            "{:<12} {:>8} {:>8} {:>10}",
295            "Model", "Before", "After", "Saved"
296        );
297        println!("{}", "-".repeat(42));
298        self.print_row("Claude", &self.claude_before, &self.claude_after);
299        self.print_row("GPT-4o", &self.gpt4o_before, &self.gpt4o_after);
300        self.print_row("GPT-5", &self.gpt5_before, &self.gpt5_after);
301        self.print_row("Gemini", &self.gemini_before, &self.gemini_after);
302    }
303
304    fn print_row(&self, name: &str, before: &TokenEstimate, after: &TokenEstimate) {
305        let saved = before.tokens.saturating_sub(after.tokens);
306        let pct = if before.tokens > 0 {
307            saved as f64 / before.tokens as f64 * 100.0
308        } else {
309            0.0
310        };
311        println!(
312            "{:<12} {:>8} {:>8} {:>8} ({:.1}%)",
313            name, before.tokens, after.tokens, saved, pct
314        );
315    }
316}
317
318// ── Types ─────────────────────────────────────────────────────────────────────
319
320pub struct DimensionResult {
321    pub width: u32,
322    pub height: u32,
323    pub tiles_before: u32,
324    pub tiles_after: u32,
325}
326
327impl DimensionResult {
328    pub fn tokens_saved(&self) -> u32 {
329        self.tiles_before.saturating_sub(self.tiles_after)
330    }
331}
332
333#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
334pub enum ProcessMode {
335    /// General LLM vision — JPEG output at configured quality.
336    Standard,
337    /// Text extraction — high-contrast grayscale binarization (Otsu threshold).
338    Ocr,
339    /// Auto-detects if the image is mostly text (monochrome/grayscale).
340    #[default]
341    Auto,
342}
343
344pub fn detect_ocr_mode(img: &DynamicImage) -> bool {
345    let rgb = img.to_rgb8();
346    let mut colorful_count = 0;
347    let mut total_count = 0;
348    // Sample every 4th pixel for speed
349    for (x, y, p) in rgb.enumerate_pixels() {
350        if x % 4 == 0 && y % 4 == 0 {
351            total_count += 1;
352            let min = p[0].min(p[1]).min(p[2]);
353            let max = p[0].max(p[1]).max(p[2]);
354            if max.saturating_sub(min) > 25 {
355                colorful_count += 1;
356            }
357        }
358    }
359    let colorful_ratio = colorful_count as f64 / total_count.max(1) as f64;
360    colorful_ratio < 0.1 // if less than 10% of pixels are colorful, assume OCR
361}
362
363pub struct SavingsReport {
364    pub tiles_before: u32,
365    pub tiles_after: u32,
366    pub tiles_saved: u32,
367    pub bytes_before: Option<u64>,
368    pub bytes_after: Option<u64>,
369}
370
371impl SavingsReport {
372    pub fn size_reduction_pct(&self) -> Option<f64> {
373        match (self.bytes_before, self.bytes_after) {
374            (Some(b), Some(a)) if b > 0 => Some((1.0 - a as f64 / b as f64) * 100.0),
375            _ => None,
376        }
377    }
378
379    pub fn token_reduction_pct(&self) -> f64 {
380        if self.tiles_before == 0 {
381            return 0.0;
382        }
383        self.tiles_saved as f64 / self.tiles_before as f64 * 100.0
384    }
385}
386
387pub struct ProcessResult {
388    pub image: DynamicImage,
389    pub width: u32,
390    pub height: u32,
391    pub report: SavingsReport,
392}
393
394impl ProcessResult {
395    pub fn tokens_saved(&self) -> u32 {
396        self.report.tiles_saved
397    }
398}
399
400// ── Pipeline ──────────────────────────────────────────────────────────────────
401
402/// Full pipeline: [crop] → tile-snap resize → [OCR binarize].
403/// Pass `input_bytes = 0` if unknown (omits file-size from report).
404pub fn process(
405    img: DynamicImage,
406    mode: ProcessMode,
407    input_bytes: u64,
408    cfg: &ProcessConfig,
409) -> ProcessResult {
410    let (orig_w, orig_h) = (img.width(), img.height());
411    let tiles_before = match cfg.target_model {
412        Some(model) => estimate_tokens(orig_w, orig_h, model).tiles,
413        None => tile_count(orig_w, cfg.tile_size) * tile_count(orig_h, cfg.tile_size),
414    };
415
416    let after_crop = if cfg.crop {
417        if cfg.smart_crop {
418            saliency_crop(&img, 16)
419        } else {
420            crop_padding(img, cfg.bg_tolerance)
421        }
422    } else {
423        img
424    };
425    let (mut opt_w, mut opt_h) = match cfg.target_model {
426        Some(model) => optimal_send_dimensions(after_crop.width(), after_crop.height(), model),
427        None => {
428            let d = calculate_optimal_dimensions_with(
429                after_crop.width(),
430                after_crop.height(),
431                cfg.tile_size,
432            );
433            (d.width, d.height)
434        }
435    };
436
437    if let Some(max_t) = cfg.max_tiles {
438        let (nw, nh) = enforce_max_tiles(opt_w, opt_h, max_t, cfg.tile_size, cfg.target_model);
439        opt_w = nw;
440        opt_h = nh;
441    }
442
443    let tiles_after = match cfg.target_model {
444        Some(model) => {
445            let est = estimate_tokens(opt_w, opt_h, model);
446            est.tiles
447        }
448        None => tile_count(opt_w, cfg.tile_size) * tile_count(opt_h, cfg.tile_size),
449    };
450    let resized = after_crop.resize_exact(opt_w, opt_h, FilterType::Lanczos3);
451
452    let actual_mode = match mode {
453        ProcessMode::Auto => {
454            if detect_ocr_mode(&after_crop) {
455                ProcessMode::Ocr
456            } else {
457                ProcessMode::Standard
458            }
459        }
460        m => m,
461    };
462
463    let final_image = match actual_mode {
464        ProcessMode::Standard | ProcessMode::Auto => resized,
465        ProcessMode::Ocr => binarize(resized),
466    };
467
468    ProcessResult {
469        width: final_image.width(),
470        height: final_image.height(),
471        image: final_image,
472        report: SavingsReport {
473            tiles_before,
474            tiles_after,
475            tiles_saved: tiles_before.saturating_sub(tiles_after),
476            bytes_before: if input_bytes > 0 {
477                Some(input_bytes)
478            } else {
479                None
480            },
481            bytes_after: None,
482        },
483    }
484}
485
486fn enforce_max_tiles(
487    mut width: u32,
488    mut height: u32,
489    max_tiles: u32,
490    default_tile_size: u32,
491    model: Option<VisionModel>,
492) -> (u32, u32) {
493    if max_tiles == 0 {
494        return (width, height);
495    }
496
497    let mut scale = 1.0;
498    let orig_w = width;
499    let orig_h = height;
500
501    loop {
502        let (snapped_w, snapped_h) = match model {
503            Some(m) => optimal_send_dimensions(width, height, m),
504            None => {
505                let d = calculate_optimal_dimensions_with(width, height, default_tile_size);
506                (d.width, d.height)
507            }
508        };
509
510        let tiles = match model {
511            Some(m) => estimate_tokens(snapped_w, snapped_h, m).tiles,
512            None => {
513                tile_count(snapped_w, default_tile_size) * tile_count(snapped_h, default_tile_size)
514            }
515        };
516
517        if tiles <= max_tiles || scale < 0.1 {
518            return (snapped_w, snapped_h);
519        }
520
521        scale *= 0.95;
522        width = (orig_w as f64 * scale) as u32;
523        height = (orig_h as f64 * scale) as u32;
524        width = width.max(1);
525        height = height.max(1);
526    }
527}
528
529// ── Step 1: Tile-Aware Dimension Calculation ───────────────────────────────────
530
531/// Snap W×H to tile boundaries using default tile size (512).
532pub fn calculate_optimal_dimensions(width: u32, height: u32) -> DimensionResult {
533    calculate_optimal_dimensions_with(width, height, 512)
534}
535
536/// Snap W×H to tile boundaries using a custom tile size.
537pub fn calculate_optimal_dimensions_with(
538    width: u32,
539    height: u32,
540    tile_size: u32,
541) -> DimensionResult {
542    let opt_w = snap_to_tile_boundary(width, tile_size);
543    let opt_h = snap_to_tile_boundary(height, tile_size);
544
545    DimensionResult {
546        width: opt_w,
547        height: opt_h,
548        tiles_before: tile_count(width, tile_size) * tile_count(height, tile_size),
549        tiles_after: tile_count(opt_w, tile_size) * tile_count(opt_h, tile_size),
550    }
551}
552
553fn tile_count(dim: u32, tile_size: u32) -> u32 {
554    dim.div_ceil(tile_size)
555}
556
557fn snap_to_tile_boundary(dim: u32, tile_size: u32) -> u32 {
558    if dim.is_multiple_of(tile_size) {
559        return dim;
560    }
561    ((dim / tile_size) * tile_size).max(tile_size)
562}
563
564// ── Step 2: Semantic Crop (padding removal) ────────────────────────────────────
565
566/// Remove solid-color borders using corner sampling + configurable tolerance.
567pub fn crop_padding(img: DynamicImage, bg_tolerance: u8) -> DynamicImage {
568    let rgba = img.to_rgba8();
569    let (w, h) = rgba.dimensions();
570
571    let corners = [
572        *rgba.get_pixel(0, 0),
573        *rgba.get_pixel(w - 1, 0),
574        *rgba.get_pixel(0, h - 1),
575        *rgba.get_pixel(w - 1, h - 1),
576    ];
577    let bg = corners[0]; // first corner as background reference
578
579    let top = first_non_bg_row(&rgba, bg, bg_tolerance, true);
580    let bottom = first_non_bg_row(&rgba, bg, bg_tolerance, false);
581    let left = first_non_bg_col(&rgba, bg, bg_tolerance, true);
582    let right = first_non_bg_col(&rgba, bg, bg_tolerance, false);
583
584    if top >= bottom || left >= right {
585        return DynamicImage::ImageRgba8(rgba);
586    }
587
588    DynamicImage::ImageRgba8(
589        image::imageops::crop_imm(&rgba, left, top, right - left, bottom - top).to_image(),
590    )
591}
592
593fn is_bg(pixel: image::Rgba<u8>, bg: image::Rgba<u8>, tolerance: u8) -> bool {
594    pixel.0[3] < 10
595        || pixel.0[..3]
596            .iter()
597            .zip(bg.0[..3].iter())
598            .all(|(&a, &b)| a.abs_diff(b) <= tolerance)
599}
600
601fn first_non_bg_row(img: &image::RgbaImage, bg: image::Rgba<u8>, tol: u8, from_top: bool) -> u32 {
602    let (w, h) = img.dimensions();
603    let rows: Box<dyn Iterator<Item = u32>> = if from_top {
604        Box::new(0..h)
605    } else {
606        Box::new((0..h).rev())
607    };
608    for y in rows {
609        if (0..w).any(|x| !is_bg(*img.get_pixel(x, y), bg, tol)) {
610            return y;
611        }
612    }
613    0
614}
615
616fn first_non_bg_col(img: &image::RgbaImage, bg: image::Rgba<u8>, tol: u8, from_left: bool) -> u32 {
617    let (w, h) = img.dimensions();
618    let cols: Box<dyn Iterator<Item = u32>> = if from_left {
619        Box::new(0..w)
620    } else {
621        Box::new((0..w).rev())
622    };
623    for x in cols {
624        if (0..h).any(|y| !is_bg(*img.get_pixel(x, y), bg, tol)) {
625            return x;
626        }
627    }
628    0
629}
630
631// ── Saliency Crop (edge-energy based) ─────────────────────────────────────────
632
633/// Crop to the bounding box of high-energy (edge) pixels.
634/// Uses a Sobel-lite gradient magnitude per luma pixel. Pixels above 2× the
635/// mean gradient energy define the salient region; the bbox is expanded by
636/// `margin` pixels on every side.
637///
638/// Falls back to the input unchanged for uniform images (no salient region).
639pub fn saliency_crop(img: &DynamicImage, margin: u32) -> DynamicImage {
640    let gray = img.to_luma8();
641    let (w, h) = gray.dimensions();
642    if w < 3 || h < 3 {
643        return img.clone();
644    }
645
646    let mut energy = vec![0u32; (w * h) as usize];
647    let mut total: u64 = 0;
648    for y in 1..h - 1 {
649        for x in 1..w - 1 {
650            let l = gray.get_pixel(x - 1, y).0[0] as i32;
651            let r = gray.get_pixel(x + 1, y).0[0] as i32;
652            let t = gray.get_pixel(x, y - 1).0[0] as i32;
653            let b = gray.get_pixel(x, y + 1).0[0] as i32;
654            let e = ((r - l).abs() + (b - t).abs()) as u32;
655            energy[(y * w + x) as usize] = e;
656            total += e as u64;
657        }
658    }
659    let count = (w as u64) * (h as u64);
660    let mean = (total / count.max(1)) as u32;
661    let threshold = mean.saturating_mul(2).max(8);
662
663    let (mut min_x, mut min_y, mut max_x, mut max_y) = (w, h, 0u32, 0u32);
664    for y in 0..h {
665        for x in 0..w {
666            if energy[(y * w + x) as usize] > threshold {
667                if x < min_x {
668                    min_x = x;
669                }
670                if y < min_y {
671                    min_y = y;
672                }
673                if x > max_x {
674                    max_x = x;
675                }
676                if y > max_y {
677                    max_y = y;
678                }
679            }
680        }
681    }
682
683    if min_x >= max_x || min_y >= max_y {
684        return img.clone();
685    }
686
687    let x0 = min_x.saturating_sub(margin);
688    let y0 = min_y.saturating_sub(margin);
689    let x1 = (max_x + 1 + margin).min(w);
690    let y1 = (max_y + 1 + margin).min(h);
691    img.crop_imm(x0, y0, x1 - x0, y1 - y0)
692}
693
694// ── SSIM + Auto-Quality ───────────────────────────────────────────────────────
695
696/// Compute structural-similarity (single-window, luma) between two images.
697///
698/// Returns a value in [-1, 1]; 1.0 means identical. Both images are converted
699/// to grayscale; if dimensions differ, the smaller is rescaled to match the
700/// larger via Lanczos3.
701pub fn ssim(a: &DynamicImage, b: &DynamicImage) -> f64 {
702    let (aw, ah) = (a.width(), a.height());
703    let (bw, bh) = (b.width(), b.height());
704    let (target_w, target_h) = (aw.max(bw), ah.max(bh));
705
706    let resize_if_needed = |img: &DynamicImage| -> image::GrayImage {
707        if img.width() == target_w && img.height() == target_h {
708            img.to_luma8()
709        } else {
710            img.resize_exact(target_w, target_h, FilterType::Lanczos3)
711                .to_luma8()
712        }
713    };
714
715    let a_luma = resize_if_needed(a);
716    let b_luma = resize_if_needed(b);
717
718    let n = (target_w as u64 * target_h as u64).max(1) as f64;
719    let (mut sum_a, mut sum_b) = (0f64, 0f64);
720    for (pa, pb) in a_luma.pixels().zip(b_luma.pixels()) {
721        sum_a += pa.0[0] as f64;
722        sum_b += pb.0[0] as f64;
723    }
724    let mean_a = sum_a / n;
725    let mean_b = sum_b / n;
726
727    let (mut var_a, mut var_b, mut cov) = (0f64, 0f64, 0f64);
728    for (pa, pb) in a_luma.pixels().zip(b_luma.pixels()) {
729        let da = pa.0[0] as f64 - mean_a;
730        let db = pb.0[0] as f64 - mean_b;
731        var_a += da * da;
732        var_b += db * db;
733        cov += da * db;
734    }
735    var_a /= n;
736    var_b /= n;
737    cov /= n;
738
739    let c1 = (0.01f64 * 255.0).powi(2);
740    let c2 = (0.03f64 * 255.0).powi(2);
741    let num = (2.0 * mean_a * mean_b + c1) * (2.0 * cov + c2);
742    let den = (mean_a.powi(2) + mean_b.powi(2) + c1) * (var_a + var_b + c2);
743    if den.abs() < f64::EPSILON {
744        1.0
745    } else {
746        num / den
747    }
748}
749
750/// Binary-search the lowest quality in [`min_q`, `max_q`] that meets a given
751/// SSIM target against the original. Returns the encoded bytes and the quality used.
752///
753/// Used when callers want "automatic" quality: pick the smallest file that still
754/// passes a perceptual threshold (typically 0.95).
755pub fn encode_with_auto_quality(
756    original: &DynamicImage,
757    cfg: &ProcessConfig,
758    target_ssim: f64,
759    min_q: u8,
760    max_q: u8,
761) -> Result<(Vec<u8>, u8), String> {
762    let mut lo = min_q.max(1);
763    let mut hi = max_q.min(100).max(lo + 1);
764    let mut best: Option<(Vec<u8>, u8)> = None;
765
766    while hi.saturating_sub(lo) > 2 {
767        let mid = lo + (hi - lo) / 2;
768        let trial = ProcessConfig {
769            quality: mid,
770            ..cfg.clone()
771        };
772        let bytes = encode_to_bytes(original, &trial)?;
773        let decoded = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
774        let score = ssim(original, &decoded);
775        if score >= target_ssim {
776            best = Some((bytes, mid));
777            hi = mid;
778        } else {
779            lo = mid;
780        }
781    }
782
783    // If no quality met the target during the search, encode at max_q as fallback.
784    if let Some((b, q)) = best {
785        Ok((b, q))
786    } else {
787        let trial = ProcessConfig {
788            quality: hi,
789            ..cfg.clone()
790        };
791        let bytes = encode_to_bytes(original, &trial)?;
792        Ok((bytes, hi))
793    }
794}
795
796// ── Step 3: OCR Binarization ───────────────────────────────────────────────────
797
798pub fn binarize(img: DynamicImage) -> DynamicImage {
799    let gray = img.to_luma8();
800    let (w, h) = gray.dimensions();
801    let threshold = otsu_threshold(&gray);
802    let binary: ImageBuffer<Luma<u8>, Vec<u8>> = ImageBuffer::from_fn(w, h, |x, y| {
803        let p = gray.get_pixel(x, y).0[0];
804        Luma([if p < threshold { 0u8 } else { 255u8 }])
805    });
806    DynamicImage::ImageLuma8(binary)
807}
808
809fn otsu_threshold(img: &image::GrayImage) -> u8 {
810    let mut histogram = [0u32; 256];
811    for p in img.pixels() {
812        histogram[p.0[0] as usize] += 1;
813    }
814    let total = img.width() * img.height();
815    let (mut sum, mut sum_bg, mut weight_bg) = (0f64, 0f64, 0f64);
816    for (i, &h) in histogram.iter().enumerate() {
817        sum += i as f64 * h as f64;
818    }
819    let (mut best_thresh, mut best_var) = (0u8, 0f64);
820    for (t, &h) in histogram.iter().enumerate() {
821        weight_bg += h as f64;
822        if weight_bg == 0.0 {
823            continue;
824        }
825        let weight_fg = total as f64 - weight_bg;
826        if weight_fg == 0.0 {
827            break;
828        }
829        sum_bg += t as f64 * h as f64;
830        let mean_bg = sum_bg / weight_bg;
831        let mean_fg = (sum - sum_bg) / weight_fg;
832        let var = weight_bg * weight_fg * (mean_bg - mean_fg).powi(2);
833        if var > best_var {
834            best_var = var;
835            best_thresh = t as u8;
836        }
837    }
838    best_thresh
839}
840
841// ── Base64 I/O ────────────────────────────────────────────────────────────────
842
843pub fn decode_base64_image(input: &str) -> Result<DynamicImage, String> {
844    let data = if let Some(c) = input.find(',') {
845        &input[c + 1..]
846    } else {
847        input
848    };
849    let bytes = B64.decode(data.trim()).map_err(|e| e.to_string())?;
850    image::load_from_memory(&bytes).map_err(|e| e.to_string())
851}
852
853pub fn encode_image_base64(img: &DynamicImage, cfg: &ProcessConfig) -> Result<String, String> {
854    let bytes = encode_to_bytes(img, cfg)?;
855    Ok(B64.encode(bytes))
856}
857
858/// Encode image to raw bytes using the configured output format.
859pub fn encode_to_bytes(img: &DynamicImage, cfg: &ProcessConfig) -> Result<Vec<u8>, String> {
860    match cfg.output_format {
861        OutputFormat::Jpeg => {
862            use image::codecs::jpeg::JpegEncoder;
863            let mut buf = Cursor::new(Vec::new());
864            let rgb = img.to_rgb8();
865            JpegEncoder::new_with_quality(&mut buf, cfg.quality)
866                .encode_image(&DynamicImage::ImageRgb8(rgb))
867                .map_err(|e| e.to_string())?;
868            Ok(buf.into_inner())
869        }
870        OutputFormat::WebP => {
871            let rgb = img.to_rgb8();
872            let enc = webp::Encoder::from_rgb(rgb.as_raw(), rgb.width(), rgb.height());
873            let mem = enc.encode(cfg.quality as f32);
874            Ok(mem.to_vec())
875        }
876        OutputFormat::Avif => {
877            use image::ImageEncoder;
878            use image::codecs::avif::AvifEncoder;
879            let mut buf = Cursor::new(Vec::new());
880            let rgba = img.to_rgba8();
881            // Speed 6 is a reasonable balance; lower = better compression but slower.
882            AvifEncoder::new_with_speed_quality(&mut buf, 6, cfg.quality)
883                .write_image(
884                    rgba.as_raw(),
885                    rgba.width(),
886                    rgba.height(),
887                    image::ExtendedColorType::Rgba8,
888                )
889                .map_err(|e| e.to_string())?;
890            Ok(buf.into_inner())
891        }
892    }
893}
894
895// ── MCP Tool: optimize_image ──────────────────────────────────────────────────
896
897pub struct OptimizeResult {
898    pub optimized_base64: String,
899    pub report: SavingsReport,
900    pub original_width: u32,
901    pub original_height: u32,
902    pub width: u32,
903    pub height: u32,
904    pub optimized_bytes: usize,
905}
906
907/// MCP entry point: base64 in → base64 JPEG out + savings report.
908pub fn optimize_image(
909    input_base64: &str,
910    mode: ProcessMode,
911    cfg: &ProcessConfig,
912) -> Result<OptimizeResult, String> {
913    let img = decode_base64_image(input_base64)?;
914    let (orig_w, orig_h) = (img.width(), img.height());
915    let input_bytes = {
916        let data = if let Some(c) = input_base64.find(',') {
917            &input_base64[c + 1..]
918        } else {
919            input_base64
920        };
921        B64.decode(data.trim()).map_err(|e| e.to_string())?.len() as u64
922    };
923
924    let mut result = process(img, mode, input_bytes, cfg);
925    let bytes = encode_to_bytes(&result.image, cfg)?;
926    let encoded = B64.encode(&bytes);
927    result.report.bytes_after = Some(bytes.len() as u64);
928
929    Ok(OptimizeResult {
930        optimized_base64: encoded,
931        report: result.report,
932        original_width: orig_w,
933        original_height: orig_h,
934        width: result.width,
935        height: result.height,
936        optimized_bytes: bytes.len(),
937    })
938}
939
940// ── Tests ─────────────────────────────────────────────────────────────────────
941
942// ── Step 4: Sandbox (Think in Code) ──────────────────────────────────────────
943
944/// Atomic image operations for the Sandbox mode.
945#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
946#[serde(rename_all = "lowercase", tag = "op")]
947pub enum ImageOp {
948    /// Crop a specific region: { x, y, width, height }
949    Crop {
950        x: u32,
951        y: u32,
952        width: u32,
953        height: u32,
954    },
955    /// Convert to grayscale.
956    Grayscale,
957    /// Binarize using Otsu's threshold (if threshold is None).
958    Binarize { threshold: Option<u8> },
959    /// Resize to exact dimensions.
960    Resize { width: u32, height: u32 },
961    /// Adjust contrast (e.g., 2.0 for double contrast).
962    Contrast { amount: f32 },
963    /// Adjust brightness (e.g., -20 to darken).
964    Brightness { amount: f32 },
965}
966
967/// Execute a sequence of operations on an image.
968pub fn process_with_operations(mut img: DynamicImage, ops: Vec<ImageOp>) -> DynamicImage {
969    for op in ops {
970        img = match op {
971            ImageOp::Crop {
972                x,
973                y,
974                width,
975                height,
976            } => img.crop_imm(x, y, width, height),
977            ImageOp::Grayscale => DynamicImage::ImageLuma8(img.to_luma8()),
978            ImageOp::Binarize { threshold } => {
979                let gray = img.to_luma8();
980                let thr = threshold.unwrap_or(128);
981                let mut binarized = ImageBuffer::new(gray.width(), gray.height());
982                for (x, y, p) in gray.enumerate_pixels() {
983                    let val = if p[0] > thr { 255 } else { 0 };
984                    binarized.put_pixel(x, y, Luma([val]));
985                }
986                DynamicImage::ImageLuma8(binarized)
987            }
988            ImageOp::Resize { width, height } => {
989                img.resize_exact(width, height, FilterType::Lanczos3)
990            }
991            ImageOp::Contrast { amount } => img.adjust_contrast(amount),
992            ImageOp::Brightness { amount } => img.brighten(amount as i32),
993        };
994    }
995    img
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001
1002    fn cfg() -> ProcessConfig {
1003        ProcessConfig::default()
1004    }
1005
1006    #[test]
1007    fn exact_boundary_unchanged() {
1008        let r = calculate_optimal_dimensions(1024, 512);
1009        assert_eq!((r.width, r.height), (1024, 512));
1010        assert_eq!(r.tokens_saved(), 0);
1011    }
1012
1013    #[test]
1014    fn one_pixel_over_saves_full_tile_row() {
1015        let r = calculate_optimal_dimensions(1025, 1025);
1016        assert_eq!((r.width, r.height), (1024, 1024));
1017        assert_eq!(r.tiles_before, 9);
1018        assert_eq!(r.tiles_after, 4);
1019        assert_eq!(r.tokens_saved(), 5);
1020    }
1021
1022    #[test]
1023    fn small_image_never_below_one_tile() {
1024        let r = calculate_optimal_dimensions(100, 200);
1025        assert_eq!((r.width, r.height), (512, 512));
1026    }
1027
1028    #[test]
1029    fn mid_boundary_snaps_down() {
1030        let r = calculate_optimal_dimensions(768, 512);
1031        assert_eq!(r.width, 512);
1032        assert_eq!(r.tiles_after, 1);
1033    }
1034
1035    #[test]
1036    fn custom_tile_size_256() {
1037        let r = calculate_optimal_dimensions_with(257, 512, 256);
1038        assert_eq!(r.width, 256); // 257 → snaps down to 256
1039        assert_eq!(r.tiles_before, 2 * 2); // ceil(257/256)*ceil(512/256) = 2*2
1040        assert_eq!(r.tiles_after, 1 * 2); // 256/256 * 512/256 = 1*2
1041    }
1042
1043    #[test]
1044    fn full_pipeline_reduces_tiles() {
1045        use image::{DynamicImage, Rgba, RgbaImage};
1046        let mut img = RgbaImage::from_pixel(1025, 1025, Rgba([255, 255, 255, 255]));
1047        for x in 400..600 {
1048            for y in 400..600 {
1049                img.put_pixel(x, y, Rgba([0, 0, 0, 255]));
1050            }
1051        }
1052        let result = process(
1053            DynamicImage::ImageRgba8(img),
1054            ProcessMode::Standard,
1055            0,
1056            &cfg(),
1057        );
1058        assert!(result.report.tiles_after < result.report.tiles_before);
1059    }
1060
1061    #[test]
1062    fn crop_disabled_preserves_size() {
1063        use image::{DynamicImage, Rgba, RgbaImage};
1064        let img = RgbaImage::from_pixel(1024, 1024, Rgba([255, 255, 255, 255]));
1065        let no_crop = ProcessConfig::builder().crop(false).build();
1066        let result = process(
1067            DynamicImage::ImageRgba8(img),
1068            ProcessMode::Standard,
1069            0,
1070            &no_crop,
1071        );
1072        assert_eq!(result.width, 1024);
1073    }
1074
1075    #[test]
1076    fn crop_removes_white_border() {
1077        use image::{Rgba, RgbaImage};
1078        let mut img = RgbaImage::from_pixel(100, 100, Rgba([255, 255, 255, 255]));
1079        for x in 45..55 {
1080            for y in 45..55 {
1081                img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1082            }
1083        }
1084        let cropped = crop_padding(DynamicImage::ImageRgba8(img), 15);
1085        assert!(cropped.width() < 100 && cropped.height() < 100);
1086    }
1087
1088    #[test]
1089    fn binarize_produces_only_black_white() {
1090        use image::{DynamicImage, GrayImage, Luma};
1091        let img = GrayImage::from_fn(64, 64, |x, _| Luma([if x < 32 { 50u8 } else { 200u8 }]));
1092        let result = binarize(DynamicImage::ImageLuma8(img)).to_luma8();
1093        for p in result.pixels() {
1094            assert!(p.0[0] == 0 || p.0[0] == 255);
1095        }
1096    }
1097
1098    #[test]
1099    fn ssim_identical_images_is_one() {
1100        use image::{DynamicImage, Rgba, RgbaImage};
1101        let img =
1102            DynamicImage::ImageRgba8(RgbaImage::from_pixel(64, 64, Rgba([128, 128, 128, 255])));
1103        let s = ssim(&img, &img);
1104        assert!((s - 1.0).abs() < 1e-9);
1105    }
1106
1107    #[test]
1108    fn ssim_very_different_images_is_low() {
1109        use image::{DynamicImage, Rgba, RgbaImage};
1110        let black = DynamicImage::ImageRgba8(RgbaImage::from_pixel(64, 64, Rgba([0, 0, 0, 255])));
1111        let white =
1112            DynamicImage::ImageRgba8(RgbaImage::from_pixel(64, 64, Rgba([255, 255, 255, 255])));
1113        let s = ssim(&black, &white);
1114        assert!(s < 0.1, "expected low SSIM, got {s}");
1115    }
1116
1117    #[test]
1118    fn saliency_crop_tightens_around_high_energy_region() {
1119        use image::{DynamicImage, Rgba, RgbaImage};
1120        // Uniform white field with a 200x200 textured block in the middle of a 1000x1000 image.
1121        let mut img = RgbaImage::from_pixel(1000, 1000, Rgba([255, 255, 255, 255]));
1122        for x in 400..600 {
1123            for y in 400..600 {
1124                // checker pattern to generate edge energy
1125                let v = if (x + y) % 2 == 0 { 0 } else { 255 };
1126                img.put_pixel(x, y, Rgba([v, v, v, 255]));
1127            }
1128        }
1129        let dyn_img = DynamicImage::ImageRgba8(img);
1130        let cropped = saliency_crop(&dyn_img, 8);
1131        assert!(cropped.width() < 1000);
1132        assert!(cropped.height() < 1000);
1133        // expect to land near the 200×200 block plus margin
1134        assert!(cropped.width() < 400);
1135        assert!(cropped.height() < 400);
1136    }
1137
1138    #[test]
1139    fn auto_quality_returns_quality_in_range() {
1140        use image::{DynamicImage, Rgba, RgbaImage};
1141        let mut img = RgbaImage::from_pixel(256, 256, Rgba([100, 100, 100, 255]));
1142        for x in 0..256 {
1143            for y in 0..256 {
1144                img.put_pixel(x, y, Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255]));
1145            }
1146        }
1147        let dyn_img = DynamicImage::ImageRgba8(img);
1148        let cfg = ProcessConfig::default();
1149        let (bytes, q) = encode_with_auto_quality(&dyn_img, &cfg, 0.95, 40, 95).expect("ok");
1150        assert!((40..=95).contains(&q));
1151        assert!(!bytes.is_empty());
1152    }
1153
1154    #[test]
1155    fn high_bg_tolerance_crops_more() {
1156        use image::{DynamicImage, Rgba, RgbaImage};
1157        // Corners: pure white [255,255,255]. Border: off-white [240,240,240]. Center: black.
1158        // diff = 15. strict(5): 15 > 5 → border NOT bg → no crop.
1159        // loose(20): 15 ≤ 20 → border IS bg → crops.
1160        let mut img = RgbaImage::from_pixel(100, 100, Rgba([240, 240, 240, 255]));
1161        for corner in [(0u32, 0u32), (99, 0), (0, 99), (99, 99)] {
1162            img.put_pixel(corner.0, corner.1, Rgba([255, 255, 255, 255]));
1163        }
1164        for x in 45..55 {
1165            for y in 45..55 {
1166                img.put_pixel(x, y, Rgba([0, 0, 0, 255]));
1167            }
1168        }
1169        let strict = crop_padding(DynamicImage::ImageRgba8(img.clone()), 5);
1170        let loose = crop_padding(DynamicImage::ImageRgba8(img), 20);
1171        assert!(loose.width() < strict.width());
1172    }
1173}
1174// ── Persistence & Analytics ───────────────────────────────────────────────────
1175
1176#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1177pub struct OptimizationReport {
1178    pub timestamp: String,
1179    pub model: String,
1180    pub original_tokens: u32,
1181    pub optimized_tokens: u32,
1182    pub original_bytes: u64,
1183    pub optimized_bytes: u64,
1184    pub mode: String,
1185}
1186
1187#[derive(Debug, serde::Serialize, serde::Deserialize)]
1188pub struct SqueezerStats {
1189    pub total_optimizations: u64,
1190    pub total_original_tokens: u64,
1191    pub total_optimized_tokens: u64,
1192    pub total_original_bytes: u64,
1193    pub total_optimized_bytes: u64,
1194    pub history: Vec<OptimizationReport>,
1195}
1196
1197impl SqueezerStats {
1198    pub fn total_token_savings(&self) -> u64 {
1199        self.total_original_tokens
1200            .saturating_sub(self.total_optimized_tokens)
1201    }
1202
1203    pub fn total_byte_savings(&self) -> u64 {
1204        self.total_original_bytes
1205            .saturating_sub(self.total_optimized_bytes)
1206    }
1207
1208    pub fn estimated_usd_saved(&self) -> f64 {
1209        // Blended average: $2.50 per 1M tokens (Claude/GPT-4o blend)
1210        (self.total_token_savings() as f64 / 1_000_000.0) * 2.50
1211    }
1212}
1213
1214pub struct Persistence;
1215
1216impl Persistence {
1217    fn get_db_path() -> PathBuf {
1218        let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
1219        path.push(".vision-squeezer");
1220        let _ = std::fs::create_dir_all(&path);
1221        path.push("stats.db");
1222        path
1223    }
1224
1225    pub fn init_db() -> Result<(), String> {
1226        let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1227        conn.execute(
1228            "CREATE TABLE IF NOT EXISTS optimizations (
1229                id INTEGER PRIMARY KEY AUTOINCREMENT,
1230                timestamp TEXT NOT NULL,
1231                model TEXT NOT NULL,
1232                original_tokens INTEGER NOT NULL,
1233                optimized_tokens INTEGER NOT NULL,
1234                original_bytes INTEGER NOT NULL,
1235                optimized_bytes INTEGER NOT NULL,
1236                mode TEXT NOT NULL
1237            )",
1238            [],
1239        )
1240        .map_err(|e| e.to_string())?;
1241        Ok(())
1242    }
1243
1244    pub fn log_optimization(
1245        model: &str,
1246        orig_tokens: u32,
1247        opt_tokens: u32,
1248        orig_bytes: u64,
1249        opt_bytes: u64,
1250        mode: &str,
1251    ) -> Result<(), String> {
1252        let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1253        conn.execute(
1254            "INSERT INTO optimizations (timestamp, model, original_tokens, optimized_tokens, original_bytes, optimized_bytes, mode)
1255             VALUES (?, ?, ?, ?, ?, ?, ?)",
1256            params![
1257                Utc::now().to_rfc3339(),
1258                model,
1259                orig_tokens,
1260                opt_tokens,
1261                orig_bytes as i64,
1262                opt_bytes as i64,
1263                mode,
1264            ],
1265        ).map_err(|e| e.to_string())?;
1266        Ok(())
1267    }
1268
1269    pub fn get_stats() -> Result<SqueezerStats, String> {
1270        let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1271
1272        let mut stmt = conn
1273            .prepare(
1274                "SELECT 
1275                COUNT(*), 
1276                SUM(original_tokens), 
1277                SUM(optimized_tokens), 
1278                SUM(original_bytes), 
1279                SUM(optimized_bytes) 
1280             FROM optimizations",
1281            )
1282            .map_err(|e| e.to_string())?;
1283
1284        let (count, orig_t, opt_t, orig_b, opt_b) = stmt
1285            .query_row([], |row| {
1286                Ok((
1287                    row.get::<_, Option<i64>>(0)?.unwrap_or(0) as u64,
1288                    row.get::<_, Option<i64>>(1)?.unwrap_or(0) as u64,
1289                    row.get::<_, Option<i64>>(2)?.unwrap_or(0) as u64,
1290                    row.get::<_, Option<i64>>(3)?.unwrap_or(0) as u64,
1291                    row.get::<_, Option<i64>>(4)?.unwrap_or(0) as u64,
1292                ))
1293            })
1294            .map_err(|e| e.to_string())?;
1295
1296        let mut stmt = conn.prepare(
1297            "SELECT timestamp, model, original_tokens, optimized_tokens, original_bytes, optimized_bytes, mode 
1298             FROM optimizations ORDER BY timestamp DESC LIMIT 50"
1299        ).map_err(|e| e.to_string())?;
1300
1301        let history = stmt
1302            .query_map([], |row| {
1303                Ok(OptimizationReport {
1304                    timestamp: row.get(0)?,
1305                    model: row.get(1)?,
1306                    original_tokens: row.get(2)?,
1307                    optimized_tokens: row.get(3)?,
1308                    original_bytes: row.get::<_, i64>(4)? as u64,
1309                    optimized_bytes: row.get::<_, i64>(5)? as u64,
1310                    mode: row.get(6)?,
1311                })
1312            })
1313            .map_err(|e| e.to_string())?
1314            .collect::<Result<Vec<_>, _>>()
1315            .map_err(|e| e.to_string())?;
1316
1317        Ok(SqueezerStats {
1318            total_optimizations: count,
1319            total_original_tokens: orig_t,
1320            total_optimized_tokens: opt_t,
1321            total_original_bytes: orig_b,
1322            total_optimized_bytes: opt_b,
1323            history,
1324        })
1325    }
1326
1327    pub fn get_all_history() -> Result<Vec<OptimizationReport>, String> {
1328        let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1329        let mut stmt = conn.prepare(
1330            "SELECT timestamp, model, original_tokens, optimized_tokens, original_bytes, optimized_bytes, mode
1331             FROM optimizations ORDER BY timestamp ASC"
1332        ).map_err(|e| e.to_string())?;
1333
1334        stmt.query_map([], |row| {
1335            Ok(OptimizationReport {
1336                timestamp: row.get(0)?,
1337                model: row.get(1)?,
1338                original_tokens: row.get(2)?,
1339                optimized_tokens: row.get(3)?,
1340                original_bytes: row.get::<_, i64>(4)? as u64,
1341                optimized_bytes: row.get::<_, i64>(5)? as u64,
1342                mode: row.get(6)?,
1343            })
1344        })
1345        .map_err(|e| e.to_string())?
1346        .collect::<Result<Vec<_>, _>>()
1347        .map_err(|e| e.to_string())
1348    }
1349}