Skip to main content

shift_preflight/policy/
rules.rs

1use super::provider::ModelConstraints;
2use crate::inspector::{ImageMetadata, MediaFormat};
3use crate::mode::DriveMode;
4use serde::{Deserialize, Serialize};
5
6/// An action to be taken on an image.
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub enum Action {
9    /// No changes needed
10    Pass,
11    /// Resize to fit within max_dim (preserving aspect ratio)
12    Resize {
13        target_width: u32,
14        target_height: u32,
15    },
16    /// Recompress at a given JPEG quality
17    Recompress { quality: u8 },
18    /// Convert from unsupported format to a safe one
19    ConvertFormat { to: String },
20    /// Rasterize SVG to PNG
21    RasterizeSvg {
22        target_width: u32,
23        target_height: u32,
24    },
25    /// Drop this image entirely
26    Drop { reason: String },
27}
28
29/// Evaluate what actions are needed for a single image.
30pub fn evaluate(
31    meta: &ImageMetadata,
32    constraints: &ModelConstraints,
33    mode: DriveMode,
34    image_index: usize,
35    total_images: usize,
36) -> Vec<Action> {
37    let mut actions = Vec::new();
38
39    // 1. SVG handling — always needs conversion for provider safety
40    if meta.format == MediaFormat::Svg {
41        let (w, h) = svg_raster_dimensions(meta, constraints, mode);
42        actions.push(Action::RasterizeSvg {
43            target_width: w,
44            target_height: h,
45        });
46        // After rasterization, the image is PNG — further checks apply to the rasterized output
47        // but we can predict whether resizing will be needed based on the raster dimensions
48        return actions;
49    }
50
51    // 2. Format conversion — BMP, TIFF, etc. need converting to provider-safe format
52    if !meta.format.is_provider_safe() {
53        actions.push(Action::ConvertFormat {
54            to: "png".to_string(),
55        });
56    }
57
58    // 3. Dimension + megapixel checks (unified)
59    // Fix #4: Compute target dimensions that satisfy BOTH constraints simultaneously.
60    let resize_target = compute_resize_target(meta, constraints, mode);
61    if let Some((tw, th)) = resize_target {
62        actions.push(Action::Resize {
63            target_width: tw,
64            target_height: th,
65        });
66    }
67
68    // 4. File size check
69    // Fix #6: Only recompress JPEG. For other formats, we rely on resize to reduce size.
70    if meta.size_bytes > constraints.max_image_size_bytes && meta.format == MediaFormat::Jpeg {
71        let quality = match mode {
72            DriveMode::Performance => 90,
73            DriveMode::Balanced => 80,
74            DriveMode::Economy => 60,
75        };
76        actions.push(Action::Recompress { quality });
77    }
78
79    // 5. Economy mode: drop excess images
80    if mode == DriveMode::Economy
81        && total_images > constraints.max_images
82        && image_index >= constraints.max_images
83    {
84        actions.clear();
85        actions.push(Action::Drop {
86            reason: format!(
87                "economy mode: image {} exceeds max_images limit of {}",
88                image_index + 1,
89                constraints.max_images
90            ),
91        });
92    }
93
94    // 6. Mode-based aggressive resizing (economy)
95    // Fix #22: Use `actions.is_empty()` instead of vacuous truth `.all(Pass)`.
96    if mode == DriveMode::Economy && actions.is_empty() && meta.max_dim() > 1024 {
97        // In economy mode, aggressively downscale even if within limits
98        let scale = 1024.0 / meta.max_dim() as f64;
99        let tw = (meta.width as f64 * scale).max(1.0) as u32;
100        let th = (meta.height as f64 * scale).max(1.0) as u32;
101        actions.push(Action::Resize {
102            target_width: tw,
103            target_height: th,
104        });
105    }
106
107    // If no actions were added, it's a pass
108    if actions.is_empty() {
109        actions.push(Action::Pass);
110    }
111
112    actions
113}
114
115/// Compute resize target that satisfies BOTH dimension AND megapixel constraints.
116///
117/// Fix #4: Previously, dimension resize was computed independently and if it fired,
118/// the megapixel check was skipped. Now we compute the most conservative (smallest)
119/// target that satisfies both constraints simultaneously.
120fn compute_resize_target(
121    meta: &ImageMetadata,
122    constraints: &ModelConstraints,
123    mode: DriveMode,
124) -> Option<(u32, u32)> {
125    let max_dim = match mode {
126        DriveMode::Performance => constraints.max_image_dim,
127        DriveMode::Balanced => constraints.max_image_dim,
128        DriveMode::Economy => constraints.max_image_dim.min(1024),
129    };
130
131    // Start with a scale of 1.0 (no resize)
132    let mut scale = 1.0_f64;
133    let mut needs_resize = false;
134
135    // Dimension constraint
136    if meta.max_dim() > max_dim {
137        let dim_scale = max_dim as f64 / meta.max_dim() as f64;
138        scale = scale.min(dim_scale);
139        needs_resize = true;
140    }
141
142    // Megapixel constraint
143    if let Some(max_mp) = constraints.max_image_megapixels {
144        if meta.megapixels > max_mp {
145            let mp_scale = (max_mp / meta.megapixels).sqrt();
146            scale = scale.min(mp_scale);
147            needs_resize = true;
148        }
149    }
150
151    if needs_resize {
152        // Fix #21: Ensure dimensions are at least 1
153        let tw = (meta.width as f64 * scale).max(1.0) as u32;
154        let th = (meta.height as f64 * scale).max(1.0) as u32;
155        Some((tw, th))
156    } else {
157        None
158    }
159}
160
161/// Determine rasterization dimensions for SVG.
162fn svg_raster_dimensions(
163    meta: &ImageMetadata,
164    constraints: &ModelConstraints,
165    mode: DriveMode,
166) -> (u32, u32) {
167    let max_target = match mode {
168        DriveMode::Performance => constraints.max_image_dim.min(2048),
169        DriveMode::Balanced => constraints.max_image_dim.min(1024),
170        DriveMode::Economy => 512,
171    };
172
173    let w = meta.width;
174    let h = meta.height;
175
176    if w == 0 || h == 0 {
177        return (max_target, max_target);
178    }
179
180    if w.max(h) > max_target {
181        let scale = max_target as f64 / w.max(h) as f64;
182        let tw = (w as f64 * scale) as u32;
183        let th = (h as f64 * scale) as u32;
184        (tw.max(1), th.max(1))
185    } else if w.max(h) < 64 {
186        // Very small SVG: scale up to at least 256px
187        let scale = 256.0 / w.max(h) as f64;
188        let tw = (w as f64 * scale) as u32;
189        let th = (h as f64 * scale) as u32;
190        (tw, th)
191    } else {
192        (w, h)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::inspector::Encoding;
200
201    fn make_constraints() -> ModelConstraints {
202        ModelConstraints {
203            max_images: 10,
204            max_image_dim: 2048,
205            max_image_size_bytes: 20_971_520,
206            max_image_megapixels: None,
207            supported_formats: vec!["png".into(), "jpeg".into(), "gif".into(), "webp".into()],
208        }
209    }
210
211    fn make_anthropic_constraints() -> ModelConstraints {
212        ModelConstraints {
213            max_images: 20,
214            max_image_dim: 8000,
215            max_image_size_bytes: 5_242_880,
216            max_image_megapixels: Some(1.15),
217            supported_formats: vec!["png".into(), "jpeg".into(), "gif".into(), "webp".into()],
218        }
219    }
220
221    fn make_meta(format: MediaFormat, w: u32, h: u32, size: usize) -> ImageMetadata {
222        ImageMetadata::new(format, w, h, size, Encoding::Base64)
223    }
224
225    #[test]
226    fn test_pass_small_png() {
227        let meta = make_meta(MediaFormat::Png, 640, 480, 50_000);
228        let actions = evaluate(&meta, &make_constraints(), DriveMode::Balanced, 0, 1);
229        assert_eq!(actions, vec![Action::Pass]);
230    }
231
232    #[test]
233    fn test_resize_oversized_image() {
234        let meta = make_meta(MediaFormat::Png, 4000, 3000, 100_000);
235        let actions = evaluate(&meta, &make_constraints(), DriveMode::Balanced, 0, 1);
236        assert!(actions.iter().any(|a| matches!(a, Action::Resize { .. })));
237        if let Action::Resize {
238            target_width,
239            target_height,
240        } = &actions[0]
241        {
242            assert!(*target_width <= 2048);
243            assert!(*target_height <= 2048);
244        }
245    }
246
247    #[test]
248    fn test_resize_performance_mode_only_if_over_limit() {
249        // 2000px is under 2048 limit — performance mode should pass
250        let meta = make_meta(MediaFormat::Png, 2000, 1500, 100_000);
251        let actions = evaluate(&meta, &make_constraints(), DriveMode::Performance, 0, 1);
252        assert_eq!(actions, vec![Action::Pass]);
253    }
254
255    #[test]
256    fn test_economy_mode_aggressive_resize() {
257        // 1500px is under 2048 but economy mode caps at 1024
258        let meta = make_meta(MediaFormat::Png, 1500, 1000, 100_000);
259        let actions = evaluate(&meta, &make_constraints(), DriveMode::Economy, 0, 1);
260        assert!(actions.iter().any(|a| matches!(a, Action::Resize { .. })));
261    }
262
263    #[test]
264    fn test_economy_mode_drops_excess_images() {
265        let meta = make_meta(MediaFormat::Png, 640, 480, 50_000);
266        let constraints = make_constraints(); // max 10 images
267        let actions = evaluate(&meta, &constraints, DriveMode::Economy, 10, 11);
268        assert!(actions.iter().any(|a| matches!(a, Action::Drop { .. })));
269    }
270
271    #[test]
272    fn test_svg_rasterized() {
273        let mut meta = make_meta(MediaFormat::Svg, 800, 600, 5_000);
274        meta.svg_source = Some("<svg></svg>".to_string());
275        let actions = evaluate(&meta, &make_constraints(), DriveMode::Balanced, 0, 1);
276        assert!(actions
277            .iter()
278            .any(|a| matches!(a, Action::RasterizeSvg { .. })));
279    }
280
281    #[test]
282    fn test_bmp_converted() {
283        let meta = make_meta(MediaFormat::Bmp, 640, 480, 900_000);
284        let actions = evaluate(&meta, &make_constraints(), DriveMode::Balanced, 0, 1);
285        assert!(actions
286            .iter()
287            .any(|a| matches!(a, Action::ConvertFormat { .. })));
288    }
289
290    #[test]
291    fn test_anthropic_megapixel_limit() {
292        // 2000x1000 = 2.0 MP, over 1.15 MP limit
293        let meta = make_meta(MediaFormat::Png, 2000, 1000, 100_000);
294        let actions = evaluate(
295            &meta,
296            &make_anthropic_constraints(),
297            DriveMode::Balanced,
298            0,
299            1,
300        );
301        assert!(actions.iter().any(|a| matches!(a, Action::Resize { .. })));
302    }
303
304    #[test]
305    fn test_anthropic_under_megapixel_limit() {
306        // 1000x800 = 0.8 MP, under 1.15 MP limit
307        let meta = make_meta(MediaFormat::Png, 1000, 800, 100_000);
308        let actions = evaluate(
309            &meta,
310            &make_anthropic_constraints(),
311            DriveMode::Balanced,
312            0,
313            1,
314        );
315        assert_eq!(actions, vec![Action::Pass]);
316    }
317
318    #[test]
319    fn test_oversized_jpeg_recompressed() {
320        // 25 MB JPEG file, over 20 MB limit — JPEG should get recompressed
321        let meta = make_meta(MediaFormat::Jpeg, 1000, 800, 25_000_000);
322        let actions = evaluate(&meta, &make_constraints(), DriveMode::Balanced, 0, 1);
323        assert!(actions
324            .iter()
325            .any(|a| matches!(a, Action::Recompress { quality: 80 })));
326    }
327
328    #[test]
329    fn test_oversized_png_not_recompressed() {
330        // Fix #6: 25 MB PNG should NOT get Recompress (which would lossy-convert to JPEG)
331        let meta = make_meta(MediaFormat::Png, 1000, 800, 25_000_000);
332        let actions = evaluate(&meta, &make_constraints(), DriveMode::Balanced, 0, 1);
333        assert!(
334            !actions
335                .iter()
336                .any(|a| matches!(a, Action::Recompress { .. })),
337            "PNG should not get JPEG recompress action"
338        );
339    }
340
341    #[test]
342    fn test_recompress_quality_by_mode() {
343        let meta = make_meta(MediaFormat::Jpeg, 1000, 800, 25_000_000);
344        let constraints = make_constraints();
345
346        let perf_actions = evaluate(&meta, &constraints, DriveMode::Performance, 0, 1);
347        assert!(perf_actions
348            .iter()
349            .any(|a| matches!(a, Action::Recompress { quality: 90 })));
350
351        let eco_actions = evaluate(&meta, &constraints, DriveMode::Economy, 0, 1);
352        assert!(eco_actions
353            .iter()
354            .any(|a| matches!(a, Action::Recompress { quality: 60 })));
355    }
356
357    // Fix #4: Megapixel + dimension interaction
358    #[test]
359    fn test_dimension_and_megapixel_both_enforced() {
360        // 10000x10000: exceeds both max_dim (8000) and megapixels (1.15)
361        // Should resize to satisfy BOTH — not just dimension
362        let meta = make_meta(MediaFormat::Png, 10000, 10000, 100_000);
363        let actions = evaluate(
364            &meta,
365            &make_anthropic_constraints(),
366            DriveMode::Balanced,
367            0,
368            1,
369        );
370        assert!(actions.iter().any(|a| matches!(a, Action::Resize { .. })));
371        if let Action::Resize {
372            target_width,
373            target_height,
374        } = &actions[0]
375        {
376            // Post-resize should be under BOTH limits
377            let post_mp = (*target_width as f64 * *target_height as f64) / 1_000_000.0;
378            assert!(
379                post_mp <= 1.15,
380                "post-resize megapixels {} exceeds 1.15",
381                post_mp
382            );
383            assert!(*target_width <= 8000);
384            assert!(*target_height <= 8000);
385        }
386    }
387}