Skip to main content

shift_preflight/
pipeline.rs

1//! Core SHIFT pipeline: inspect → policy → transform → reconstruct.
2
3use anyhow::{Context, Result};
4use serde_json::Value;
5
6use crate::cost::{estimate_tokens, ImageMetrics};
7use crate::inspector;
8use crate::inspector::MediaFormat;
9use crate::mode::{ShiftConfig, SvgMode};
10use crate::payload;
11use crate::policy;
12use crate::report::Report;
13use crate::transformer;
14
15/// Process a payload through the SHIFT pipeline.
16///
17/// Returns the transformed payload and a report of changes.
18pub fn process(payload: &Value, config: &ShiftConfig) -> Result<(Value, Report)> {
19    let mut report = Report::new();
20    report.dry_run = config.dry_run;
21
22    // Detect provider format if not specified
23    let provider_format = payload::detect_provider(payload);
24
25    // Fix #7: Load provider profile from config, not env var
26    let profile = if let Some(ref custom_path) = config.profile_path {
27        // R7: Validate the profile path more thoroughly
28        let path = std::path::Path::new(custom_path);
29
30        // Must have a .json extension
31        match path.extension().and_then(|e| e.to_str()) {
32            Some("json") => {}
33            _ => anyhow::bail!("profile path must have a .json extension"),
34        }
35
36        // Reject path traversal components
37        for component in path.components() {
38            if matches!(component, std::path::Component::ParentDir) {
39                anyhow::bail!("profile path must not contain '..' path traversal");
40            }
41        }
42
43        // Canonicalize to resolve symlinks, then verify the canonical path
44        // ends with .json (symlink to /etc/passwd would fail this)
45        if path.exists() {
46            let canonical = std::fs::canonicalize(path)
47                .with_context(|| "failed to resolve profile path".to_string())?;
48            match canonical.extension().and_then(|e| e.to_str()) {
49                Some("json") => {}
50                _ => anyhow::bail!(
51                    "profile path resolves to a non-JSON file (possible symlink attack)"
52                ),
53            }
54            policy::load_from_file(canonical.to_str().unwrap_or(custom_path))?
55        } else {
56            policy::load_from_file(custom_path)?
57        }
58    } else {
59        policy::load_builtin(&config.provider)?
60    };
61
62    // Get model-specific constraints
63    let model_name = config
64        .model
65        .as_deref()
66        .or_else(|| payload.get("model").and_then(|m| m.as_str()));
67    let constraints = profile.constraints_for(model_name);
68
69    // R8: Extract images with configured safety limits
70    let images = match provider_format {
71        Some("openai") => payload::openai::extract_images_with_limits(payload, &config.limits)?,
72        Some("anthropic") => {
73            payload::anthropic::extract_images_with_limits(payload, &config.limits)?
74        }
75        _ => {
76            // No images found or text-only payload — pass through
77            return Ok((payload.clone(), report));
78        }
79    };
80
81    if images.is_empty() {
82        return Ok((payload.clone(), report));
83    }
84
85    report.images_found = images.len();
86    // Fix #16: Track image byte sizes separately from JSON serialization
87    let original_image_bytes: usize = images.iter().map(|img| img.data.len()).sum();
88    report.original_size = original_image_bytes;
89
90    let total_images = images.len();
91    let mut transformed_images: Vec<(usize, Vec<u8>, String)> = Vec::new();
92
93    for extracted in &images {
94        // Fix #15: Inspect with skip-and-warn on individual failures
95        let meta = match inspector::image::inspect_bytes(&extracted.data) {
96            Ok(m) => m,
97            Err(e) => {
98                report.add_warning(&format!(
99                    "image {}: skipped ({})",
100                    extracted.global_index, e
101                ));
102                // R6: Use the original MIME type from the image reference,
103                // not a hardcoded "image/png" which would mislabel JPEG/WebP/GIF.
104                let original_mime = match &extracted.original_ref {
105                    payload::ImageRef::DataUri { mime_type, .. } => mime_type.clone(),
106                    payload::ImageRef::Base64 { media_type, .. } => media_type.clone(),
107                    payload::ImageRef::Url(_) => "application/octet-stream".to_string(),
108                };
109                let orig_bytes = extracted.data.len();
110                let format_short = mime_to_short(&original_mime);
111                // Record metrics for skipped images (0x0 = unknown dims)
112                report.add_image_metrics(ImageMetrics {
113                    image_index: extracted.global_index,
114                    original_width: 0,
115                    original_height: 0,
116                    transformed_width: 0,
117                    transformed_height: 0,
118                    original_bytes: orig_bytes,
119                    transformed_bytes: orig_bytes,
120                    format_before: format_short.clone(),
121                    format_after: format_short,
122                    tokens_before: estimate_tokens(0, 0),
123                    tokens_after: estimate_tokens(0, 0),
124                });
125                // Push original data through unchanged
126                transformed_images.push((
127                    extracted.global_index,
128                    extracted.data.clone(),
129                    original_mime,
130                ));
131                continue;
132            }
133        };
134
135        // Capture original dimensions for token estimation
136        let orig_w = meta.width;
137        let orig_h = meta.height;
138        let orig_bytes = extracted.data.len();
139        let format_before = meta.format.to_string();
140
141        // Evaluate policy
142        let actions = policy::evaluate(
143            &meta,
144            constraints,
145            config.mode,
146            extracted.global_index,
147            total_images,
148        );
149
150        // Handle SVG mode
151        if meta.format == MediaFormat::Svg {
152            let result = handle_svg(
153                &extracted.data,
154                &meta,
155                &actions,
156                config,
157                extracted.global_index,
158                &mut report,
159            )?;
160
161            // Record metrics for SVG
162            let (_, ref out_data, ref out_mime) = result;
163            let (tw, th) = if out_data.is_empty() {
164                // SVG was dropped (source mode)
165                (0, 0)
166            } else if config.dry_run {
167                // Dry-run: estimate target dims from policy actions so we
168                // can preview token savings without actually rasterizing.
169                estimate_dims_from_actions(&actions, orig_w, orig_h)
170            } else {
171                inspector::image::inspect_bytes(out_data)
172                    .map(|m| (m.width, m.height))
173                    .unwrap_or((orig_w, orig_h))
174            };
175            let format_after = if config.dry_run && !out_data.is_empty() {
176                // In dry-run the data is still SVG, but we'd produce PNG
177                "png".to_string()
178            } else {
179                mime_to_short(out_mime)
180            };
181            report.add_image_metrics(ImageMetrics {
182                image_index: extracted.global_index,
183                original_width: orig_w,
184                original_height: orig_h,
185                transformed_width: tw,
186                transformed_height: th,
187                original_bytes: orig_bytes,
188                transformed_bytes: out_data.len(),
189                format_before: format_before.clone(),
190                format_after,
191                tokens_before: estimate_tokens(orig_w, orig_h),
192                tokens_after: estimate_tokens(tw, th),
193            });
194
195            transformed_images.push(result);
196            continue;
197        }
198
199        // Apply transformations
200        let mut current_data = extracted.data.clone();
201        let mut was_modified = false;
202        let mut output_mime = meta.format.mime_type().to_string();
203        let mut was_dropped = false;
204        let mut did_jpeg_resize = false;
205
206        for action in &actions {
207            match action {
208                policy::Action::Pass => {}
209                policy::Action::Drop { reason } => {
210                    report.add_action(extracted.global_index, "drop", reason);
211                    report.images_dropped += 1;
212                    current_data = Vec::new();
213                    was_modified = true;
214                    was_dropped = true;
215                    break;
216                }
217                policy::Action::Recompress { .. } if did_jpeg_resize => {
218                    // Skip: Resize already re-encoded as JPEG. A second
219                    // lossy encode would introduce unnecessary generational
220                    // quality loss without meaningful size benefit.
221                    let detail = "skipped: resize already produced JPEG".to_string();
222                    report.add_action(extracted.global_index, "skip_recompress", &detail);
223                }
224                _ => {
225                    if !config.dry_run {
226                        let new_data = transformer::transform_image(&current_data, action)?;
227                        let detail = describe_action(action, &meta);
228                        report.add_action(extracted.global_index, action_name(action), &detail);
229                        current_data = new_data;
230                        was_modified = true;
231
232                        if matches!(action, policy::Action::Resize { .. }) {
233                            did_jpeg_resize =
234                                inspector::detect_format(&current_data) == MediaFormat::Jpeg;
235                        }
236                    } else {
237                        let detail = describe_action(action, &meta);
238                        report.add_action(
239                            extracted.global_index,
240                            &format!("would_{}", action_name(action)),
241                            &detail,
242                        );
243                        was_modified = true;
244
245                        // Dry-run: predict that resize of JPEG input stays JPEG
246                        if matches!(action, policy::Action::Resize { .. })
247                            && meta.format == MediaFormat::Jpeg
248                        {
249                            did_jpeg_resize = true;
250                        }
251                    }
252
253                    // Update output MIME based on the actual output format.
254                    // Derived from the transformed bytes when available,
255                    // falling back to metadata for dry-run.
256                    match action {
257                        policy::Action::ConvertFormat { to } => {
258                            output_mime = format!("image/{}", to);
259                        }
260                        policy::Action::Resize { .. } => {
261                            if !config.dry_run {
262                                // Use actual output format, not original metadata
263                                let actual = inspector::detect_format(&current_data);
264                                output_mime = actual.mime_type().to_string();
265                            } else if meta.format == MediaFormat::Jpeg {
266                                output_mime = "image/jpeg".to_string();
267                            } else {
268                                output_mime = "image/png".to_string();
269                            }
270                        }
271                        policy::Action::Recompress { .. } => {
272                            output_mime = "image/jpeg".to_string();
273                        }
274                        _ => {}
275                    }
276                }
277            }
278        }
279
280        if was_modified {
281            report.images_modified += 1;
282        }
283
284        // Determine transformed dimensions
285        let (tw, th) = if was_dropped || current_data.is_empty() {
286            (0, 0)
287        } else if was_modified && !config.dry_run {
288            // Re-inspect transformed data to get actual dimensions
289            inspector::image::inspect_bytes(&current_data)
290                .map(|m| (m.width, m.height))
291                .unwrap_or((orig_w, orig_h))
292        } else {
293            // Dry-run or unchanged: estimate from policy actions
294            estimate_dims_from_actions(&actions, orig_w, orig_h)
295        };
296
297        let format_after = mime_to_short(&output_mime);
298        report.add_image_metrics(ImageMetrics {
299            image_index: extracted.global_index,
300            original_width: orig_w,
301            original_height: orig_h,
302            transformed_width: tw,
303            transformed_height: th,
304            original_bytes: orig_bytes,
305            transformed_bytes: current_data.len(),
306            format_before,
307            format_after,
308            tokens_before: estimate_tokens(orig_w, orig_h),
309            tokens_after: estimate_tokens(tw, th),
310        });
311
312        transformed_images.push((extracted.global_index, current_data, output_mime));
313    }
314
315    // Reconstruct the payload
316    let result = if config.dry_run {
317        payload.clone()
318    } else {
319        match provider_format {
320            Some("openai") => payload::openai::reconstruct(payload, &transformed_images)?,
321            Some("anthropic") => payload::anthropic::reconstruct(payload, &transformed_images)?,
322            _ => payload.clone(),
323        }
324    };
325
326    // Fix #16: Track transformed image byte sizes
327    let transformed_image_bytes: usize = transformed_images
328        .iter()
329        .map(|(_, data, _)| data.len())
330        .sum();
331    report.transformed_size = transformed_image_bytes;
332
333    // Finalize aggregate token savings from per-image metrics
334    report.finalize_token_savings();
335
336    Ok((result, report))
337}
338
339/// Extract a short format name from a MIME type (e.g. "image/png" -> "png").
340fn mime_to_short(mime: &str) -> String {
341    mime.strip_prefix("image/").unwrap_or(mime).to_string()
342}
343
344/// Estimate target dimensions from policy actions (for dry-run reporting).
345fn estimate_dims_from_actions(actions: &[policy::Action], orig_w: u32, orig_h: u32) -> (u32, u32) {
346    for action in actions {
347        match action {
348            policy::Action::Resize {
349                target_width,
350                target_height,
351            } => return (*target_width, *target_height),
352            policy::Action::RasterizeSvg {
353                target_width,
354                target_height,
355            } => return (*target_width, *target_height),
356            policy::Action::Drop { .. } => return (0, 0),
357            _ => {}
358        }
359    }
360    (orig_w, orig_h)
361}
362
363/// Handle SVG images according to the configured SvgMode.
364fn handle_svg(
365    data: &[u8],
366    meta: &inspector::ImageMetadata,
367    actions: &[policy::Action],
368    config: &ShiftConfig,
369    global_index: usize,
370    report: &mut Report,
371) -> Result<(usize, Vec<u8>, String)> {
372    match config.svg_mode {
373        SvgMode::Raster => {
374            // Rasterize SVG to PNG
375            if config.dry_run {
376                let detail = format!("would rasterize {}x{} SVG to PNG", meta.width, meta.height);
377                report.add_action(global_index, "would_rasterize_svg", &detail);
378                report.images_modified += 1;
379                return Ok((global_index, data.to_vec(), "image/svg+xml".to_string()));
380            }
381
382            // Find the rasterize action to get target dims
383            let (tw, th) = actions
384                .iter()
385                .find_map(|a| match a {
386                    policy::Action::RasterizeSvg {
387                        target_width,
388                        target_height,
389                    } => Some((*target_width, *target_height)),
390                    _ => None,
391                })
392                .unwrap_or((meta.width.max(256), meta.height.max(256)));
393
394            let svg_text = std::str::from_utf8(data).context("SVG is not valid UTF-8")?;
395            let png_data = transformer::rasterize_svg(svg_text, tw, th)?;
396
397            report.add_action(
398                global_index,
399                "rasterize_svg",
400                &format!(
401                    "SVG ({}x{}) -> PNG ({}x{})",
402                    meta.width, meta.height, tw, th
403                ),
404            );
405            report.svgs_rasterized += 1;
406            report.images_modified += 1;
407
408            Ok((global_index, png_data, "image/png".to_string()))
409        }
410
411        SvgMode::Source => {
412            // Fix #5: SVG Source mode drops the image and records it as dropped.
413            // The image block is removed from the payload. In the future, we could
414            // inject the SVG XML as a text content block, but for now we drop + warn.
415            report.add_action(
416                global_index,
417                "svg_dropped_as_source",
418                &format!(
419                    "SVG ({}x{}) removed (source mode: SVG not supported by provider)",
420                    meta.width, meta.height
421                ),
422            );
423            report.images_dropped += 1;
424            report.add_warning(
425                "SVG source mode dropped an image. Consider --svg-mode raster for provider compatibility.",
426            );
427
428            Ok((global_index, Vec::new(), "text/plain".to_string()))
429        }
430
431        SvgMode::Hybrid => {
432            // Rasterize but the caller could also add SVG source as text
433            if config.dry_run {
434                report.add_action(
435                    global_index,
436                    "would_rasterize_svg_hybrid",
437                    &format!(
438                        "would rasterize {}x{} SVG (hybrid mode)",
439                        meta.width, meta.height
440                    ),
441                );
442                report.images_modified += 1;
443                return Ok((global_index, data.to_vec(), "image/svg+xml".to_string()));
444            }
445
446            let (tw, th) = actions
447                .iter()
448                .find_map(|a| match a {
449                    policy::Action::RasterizeSvg {
450                        target_width,
451                        target_height,
452                    } => Some((*target_width, *target_height)),
453                    _ => None,
454                })
455                .unwrap_or((meta.width.max(256), meta.height.max(256)));
456
457            let svg_text = std::str::from_utf8(data).context("SVG is not valid UTF-8")?;
458            let png_data = transformer::rasterize_svg(svg_text, tw, th)?;
459
460            report.add_action(
461                global_index,
462                "rasterize_svg_hybrid",
463                &format!(
464                    "SVG ({}x{}) -> PNG ({}x{}) + source retained",
465                    meta.width, meta.height, tw, th
466                ),
467            );
468            report.svgs_rasterized += 1;
469            report.images_modified += 1;
470
471            Ok((global_index, png_data, "image/png".to_string()))
472        }
473    }
474}
475
476fn action_name(action: &policy::Action) -> &'static str {
477    match action {
478        policy::Action::Pass => "pass",
479        policy::Action::Resize { .. } => "resize",
480        policy::Action::Recompress { .. } => "recompress",
481        policy::Action::ConvertFormat { .. } => "convert",
482        policy::Action::RasterizeSvg { .. } => "rasterize_svg",
483        policy::Action::Drop { .. } => "drop",
484    }
485}
486
487fn describe_action(action: &policy::Action, meta: &inspector::ImageMetadata) -> String {
488    match action {
489        policy::Action::Pass => "no changes needed".to_string(),
490        policy::Action::Resize {
491            target_width,
492            target_height,
493        } => format!(
494            "{}x{} -> {}x{}",
495            meta.width, meta.height, target_width, target_height
496        ),
497        policy::Action::Recompress { quality } => {
498            format!("recompress at quality {}", quality)
499        }
500        policy::Action::ConvertFormat { to } => {
501            format!("{} -> {}", meta.format, to)
502        }
503        policy::Action::RasterizeSvg {
504            target_width,
505            target_height,
506        } => format!("SVG -> PNG at {}x{}", target_width, target_height),
507        policy::Action::Drop { reason } => reason.clone(),
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::mode::DriveMode;
515    use serde_json::json;
516
517    fn make_png_data_uri(width: u32, height: u32) -> String {
518        use base64::Engine;
519        let img = image::RgbaImage::new(width, height);
520        let mut buf = Vec::new();
521        let encoder = image::codecs::png::PngEncoder::new(&mut buf);
522        image::ImageEncoder::write_image(
523            encoder,
524            img.as_raw(),
525            width,
526            height,
527            image::ExtendedColorType::Rgba8,
528        )
529        .unwrap();
530        let b64 = base64::engine::general_purpose::STANDARD.encode(&buf);
531        format!("data:image/png;base64,{}", b64)
532    }
533
534    fn make_anthropic_png_base64(width: u32, height: u32) -> String {
535        use base64::Engine;
536        let img = image::RgbaImage::new(width, height);
537        let mut buf = Vec::new();
538        let encoder = image::codecs::png::PngEncoder::new(&mut buf);
539        image::ImageEncoder::write_image(
540            encoder,
541            img.as_raw(),
542            width,
543            height,
544            image::ExtendedColorType::Rgba8,
545        )
546        .unwrap();
547        base64::engine::general_purpose::STANDARD.encode(&buf)
548    }
549
550    #[test]
551    fn test_text_only_passthrough() {
552        let payload = json!({
553            "model": "gpt-4o",
554            "messages": [{"role": "user", "content": "Hello"}]
555        });
556        let config = ShiftConfig::default();
557        let (result, report) = process(&payload, &config).unwrap();
558        assert_eq!(result, payload);
559        assert_eq!(report.images_found, 0);
560        assert!(!report.has_changes());
561    }
562
563    #[test]
564    fn test_small_image_passthrough() {
565        let data_uri = make_png_data_uri(640, 480);
566        let payload = json!({
567            "model": "gpt-4o",
568            "messages": [{
569                "role": "user",
570                "content": [
571                    {"type": "text", "text": "What's this?"},
572                    {"type": "image_url", "image_url": {"url": data_uri}}
573                ]
574            }]
575        });
576        let config = ShiftConfig::default();
577        let (_result, report) = process(&payload, &config).unwrap();
578        assert_eq!(report.images_found, 1);
579    }
580
581    #[test]
582    fn test_oversized_image_resized_openai() {
583        let data_uri = make_png_data_uri(4000, 3000);
584        let payload = json!({
585            "model": "gpt-4o",
586            "messages": [{
587                "role": "user",
588                "content": [
589                    {"type": "image_url", "image_url": {"url": data_uri}}
590                ]
591            }]
592        });
593        let config = ShiftConfig {
594            provider: "openai".to_string(),
595            mode: DriveMode::Balanced,
596            ..Default::default()
597        };
598        let (_result, report) = process(&payload, &config).unwrap();
599        assert_eq!(report.images_found, 1);
600        assert!(report.has_changes());
601        assert!(report.actions.iter().any(|a| a.action == "resize"));
602    }
603
604    #[test]
605    fn test_oversized_image_resized_anthropic() {
606        let b64 = make_anthropic_png_base64(4000, 3000);
607        let payload = json!({
608            "model": "claude-sonnet-4-20250514",
609            "messages": [{
610                "role": "user",
611                "content": [{
612                    "type": "image",
613                    "source": {"type": "base64", "media_type": "image/png", "data": b64}
614                }]
615            }]
616        });
617        let config = ShiftConfig {
618            provider: "anthropic".to_string(),
619            mode: DriveMode::Balanced,
620            ..Default::default()
621        };
622        let (_result, report) = process(&payload, &config).unwrap();
623        assert_eq!(report.images_found, 1);
624        assert!(report.has_changes());
625    }
626
627    #[test]
628    fn test_dry_run_no_modifications() {
629        let data_uri = make_png_data_uri(4000, 3000);
630        let payload = json!({
631            "model": "gpt-4o",
632            "messages": [{
633                "role": "user",
634                "content": [
635                    {"type": "image_url", "image_url": {"url": data_uri.clone()}}
636                ]
637            }]
638        });
639        let config = ShiftConfig {
640            dry_run: true,
641            ..Default::default()
642        };
643        let (result, report) = process(&payload, &config).unwrap();
644        // Dry run should not modify the payload
645        assert_eq!(result, payload);
646        // But should report what would happen
647        assert!(report.has_changes());
648        assert!(report.dry_run);
649        assert!(report
650            .actions
651            .iter()
652            .any(|a| a.action.starts_with("would_")));
653    }
654
655    #[test]
656    fn test_svg_rasterization_in_openai_payload() {
657        use base64::Engine;
658        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="200" height="100" fill="red"/></svg>"#;
659        let b64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
660        let data_uri = format!("data:image/svg+xml;base64,{}", b64);
661
662        let payload = json!({
663            "model": "gpt-4o",
664            "messages": [{
665                "role": "user",
666                "content": [
667                    {"type": "image_url", "image_url": {"url": data_uri}}
668                ]
669            }]
670        });
671        let config = ShiftConfig {
672            svg_mode: SvgMode::Raster,
673            ..Default::default()
674        };
675        let (_result, report) = process(&payload, &config).unwrap();
676        assert_eq!(report.svgs_rasterized, 1);
677        assert!(report.actions.iter().any(|a| a.action == "rasterize_svg"));
678    }
679
680    #[test]
681    fn test_economy_mode_aggressive() {
682        // 1500px image — within OpenAI limits but economy mode will downscale
683        let data_uri = make_png_data_uri(1500, 1000);
684        let payload = json!({
685            "model": "gpt-4o",
686            "messages": [{
687                "role": "user",
688                "content": [
689                    {"type": "image_url", "image_url": {"url": data_uri}}
690                ]
691            }]
692        });
693        let config = ShiftConfig {
694            mode: DriveMode::Economy,
695            ..Default::default()
696        };
697        let (_result, report) = process(&payload, &config).unwrap();
698        assert!(report.has_changes());
699    }
700
701    fn make_anthropic_jpeg_base64(width: u32, height: u32) -> String {
702        use base64::Engine;
703        let img = image::RgbImage::new(width, height);
704        let mut buf = Vec::new();
705        let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
706        image::ImageEncoder::write_image(
707            encoder,
708            img.as_raw(),
709            width,
710            height,
711            image::ExtendedColorType::Rgb8,
712        )
713        .unwrap();
714        base64::engine::general_purpose::STANDARD.encode(&buf)
715    }
716
717    #[test]
718    fn test_resize_preserves_jpeg_format_in_anthropic_payload() {
719        let b64 = make_anthropic_jpeg_base64(4000, 3000);
720        let payload = json!({
721            "model": "claude-sonnet-4-20250514",
722            "messages": [{
723                "role": "user",
724                "content": [{
725                    "type": "image",
726                    "source": {"type": "base64", "media_type": "image/jpeg", "data": b64}
727                }]
728            }]
729        });
730        let config = ShiftConfig {
731            provider: "anthropic".to_string(),
732            mode: DriveMode::Balanced,
733            ..Default::default()
734        };
735        let (result, report) = process(&payload, &config).unwrap();
736
737        // Should have been resized
738        assert!(report.has_changes());
739        assert!(report.actions.iter().any(|a| a.action == "resize"));
740
741        // The output payload's media_type must still be image/jpeg
742        let media_type = result["messages"][0]["content"][0]["source"]["media_type"]
743            .as_str()
744            .unwrap();
745        assert_eq!(
746            media_type, "image/jpeg",
747            "resized JPEG in Anthropic payload should retain image/jpeg media_type, got {}",
748            media_type
749        );
750
751        // Verify the image data is actually JPEG
752        use base64::Engine;
753        let out_b64 = result["messages"][0]["content"][0]["source"]["data"]
754            .as_str()
755            .unwrap();
756        let out_bytes = base64::engine::general_purpose::STANDARD
757            .decode(out_b64)
758            .unwrap();
759        assert_eq!(
760            crate::inspector::detect_format(&out_bytes),
761            crate::inspector::MediaFormat::Jpeg,
762            "decoded image bytes should be JPEG format"
763        );
764
765        // Verify report shows jpeg -> jpeg, not jpeg -> png
766        let img_metrics = &report.image_metrics[0];
767        assert_eq!(img_metrics.format_before, "jpeg");
768        assert_eq!(
769            img_metrics.format_after, "jpeg",
770            "report should show jpeg -> jpeg, not jpeg -> png"
771        );
772    }
773
774    #[test]
775    fn test_resize_preserves_png_format_in_anthropic_payload() {
776        let b64 = make_anthropic_png_base64(4000, 3000);
777        let payload = json!({
778            "model": "claude-sonnet-4-20250514",
779            "messages": [{
780                "role": "user",
781                "content": [{
782                    "type": "image",
783                    "source": {"type": "base64", "media_type": "image/png", "data": b64}
784                }]
785            }]
786        });
787        let config = ShiftConfig {
788            provider: "anthropic".to_string(),
789            mode: DriveMode::Balanced,
790            ..Default::default()
791        };
792        let (result, report) = process(&payload, &config).unwrap();
793
794        assert!(report.has_changes());
795
796        // PNG should still be PNG
797        let media_type = result["messages"][0]["content"][0]["source"]["media_type"]
798            .as_str()
799            .unwrap();
800        assert_eq!(media_type, "image/png");
801    }
802
803    #[test]
804    fn test_performance_mode_minimal() {
805        // 1500px image — within limits, performance mode should pass
806        let data_uri = make_png_data_uri(1500, 1000);
807        let payload = json!({
808            "model": "gpt-4o",
809            "messages": [{
810                "role": "user",
811                "content": [
812                    {"type": "image_url", "image_url": {"url": data_uri}}
813                ]
814            }]
815        });
816        let config = ShiftConfig {
817            mode: DriveMode::Performance,
818            ..Default::default()
819        };
820        let (_result, report) = process(&payload, &config).unwrap();
821        // Performance mode should not modify images within limits
822        assert!(!report.has_changes() || report.images_modified == 0);
823    }
824}