Skip to main content

shift_preflight/
mode.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5/// Drive mode controls the aggressiveness of transformations.
6///
7/// - **Performance**: minimal transforms, only enforce hard provider limits
8/// - **Balanced**: moderate optimization, remove obvious waste (default)
9/// - **Economy**: aggressive optimization, minimize token usage
10#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum DriveMode {
13    Performance,
14    #[default]
15    Balanced,
16    Economy,
17}
18
19impl fmt::Display for DriveMode {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            DriveMode::Performance => write!(f, "performance"),
23            DriveMode::Balanced => write!(f, "balanced"),
24            DriveMode::Economy => write!(f, "economy"),
25        }
26    }
27}
28
29impl FromStr for DriveMode {
30    type Err = String;
31
32    fn from_str(s: &str) -> Result<Self, Self::Err> {
33        match s.to_lowercase().as_str() {
34            "performance" | "perf" => Ok(DriveMode::Performance),
35            "balanced" | "bal" => Ok(DriveMode::Balanced),
36            "economy" | "eco" => Ok(DriveMode::Economy),
37            _ => Err(format!(
38                "unknown drive mode '{}': expected performance, balanced, or economy",
39                s
40            )),
41        }
42    }
43}
44
45/// SVG handling strategy.
46///
47/// - **Raster**: rasterize SVG to PNG before sending (default, safest)
48/// - **Source**: pass SVG XML as text content instead of image
49/// - **Hybrid**: rasterize but also include SVG source as text
50#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum SvgMode {
53    #[default]
54    Raster,
55    Source,
56    Hybrid,
57}
58
59impl fmt::Display for SvgMode {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            SvgMode::Raster => write!(f, "raster"),
63            SvgMode::Source => write!(f, "source"),
64            SvgMode::Hybrid => write!(f, "hybrid"),
65        }
66    }
67}
68
69impl FromStr for SvgMode {
70    type Err = String;
71
72    fn from_str(s: &str) -> Result<Self, Self::Err> {
73        match s.to_lowercase().as_str() {
74            "raster" => Ok(SvgMode::Raster),
75            "source" | "src" => Ok(SvgMode::Source),
76            "hybrid" => Ok(SvgMode::Hybrid),
77            _ => Err(format!(
78                "unknown svg mode '{}': expected raster, source, or hybrid",
79                s
80            )),
81        }
82    }
83}
84
85/// Safety limits for untrusted input processing.
86#[derive(Debug, Clone)]
87pub struct SafetyLimits {
88    /// Maximum pixels (width * height) before rejecting an image decode.
89    /// Default: 100 megapixels (100_000_000).
90    pub max_pixels: u64,
91    /// Maximum base64 input length in bytes before rejecting.
92    /// Default: 30 MB (30_000_000). Decoded size is ~75% of this.
93    pub max_base64_bytes: usize,
94    /// Maximum HTTP response body size in bytes.
95    /// Default: 25 MB (25_000_000).
96    pub max_download_bytes: usize,
97    /// Maximum images to extract from a single payload.
98    /// Default: 50. Prevents OOM from payloads with hundreds of images.
99    pub max_images_extract: usize,
100    /// Maximum stdin input size in bytes.
101    /// Default: 500 MB (500_000_000).
102    pub max_stdin_bytes: u64,
103}
104
105impl Default for SafetyLimits {
106    fn default() -> Self {
107        SafetyLimits {
108            max_pixels: 100_000_000,
109            max_base64_bytes: 30_000_000,
110            max_download_bytes: 25_000_000,
111            max_images_extract: 50,
112            max_stdin_bytes: 500_000_000,
113        }
114    }
115}
116
117/// Configuration bundle for a single SHIFT processing run.
118#[derive(Debug, Clone)]
119pub struct ShiftConfig {
120    pub mode: DriveMode,
121    pub svg_mode: SvgMode,
122    pub provider: String,
123    pub model: Option<String>,
124    pub dry_run: bool,
125    pub verbose: bool,
126    /// Optional path to a custom provider profile JSON file.
127    pub profile_path: Option<String>,
128    /// Safety limits for untrusted input processing.
129    /// R8: Now wired through the pipeline to extraction functions.
130    pub limits: SafetyLimits,
131}
132
133impl Default for ShiftConfig {
134    fn default() -> Self {
135        ShiftConfig {
136            mode: DriveMode::default(),
137            svg_mode: SvgMode::default(),
138            provider: "openai".to_string(),
139            model: None,
140            dry_run: false,
141            verbose: false,
142            profile_path: None,
143            limits: SafetyLimits::default(),
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_drive_mode_from_str() {
154        assert_eq!(
155            "performance".parse::<DriveMode>().unwrap(),
156            DriveMode::Performance
157        );
158        assert_eq!("perf".parse::<DriveMode>().unwrap(), DriveMode::Performance);
159        assert_eq!(
160            "balanced".parse::<DriveMode>().unwrap(),
161            DriveMode::Balanced
162        );
163        assert_eq!("bal".parse::<DriveMode>().unwrap(), DriveMode::Balanced);
164        assert_eq!("economy".parse::<DriveMode>().unwrap(), DriveMode::Economy);
165        assert_eq!("eco".parse::<DriveMode>().unwrap(), DriveMode::Economy);
166        assert!("invalid".parse::<DriveMode>().is_err());
167    }
168
169    #[test]
170    fn test_drive_mode_display() {
171        assert_eq!(DriveMode::Performance.to_string(), "performance");
172        assert_eq!(DriveMode::Balanced.to_string(), "balanced");
173        assert_eq!(DriveMode::Economy.to_string(), "economy");
174    }
175
176    #[test]
177    fn test_svg_mode_from_str() {
178        assert_eq!("raster".parse::<SvgMode>().unwrap(), SvgMode::Raster);
179        assert_eq!("source".parse::<SvgMode>().unwrap(), SvgMode::Source);
180        assert_eq!("src".parse::<SvgMode>().unwrap(), SvgMode::Source);
181        assert_eq!("hybrid".parse::<SvgMode>().unwrap(), SvgMode::Hybrid);
182        assert!("invalid".parse::<SvgMode>().is_err());
183    }
184
185    #[test]
186    fn test_default_config() {
187        let cfg = ShiftConfig::default();
188        assert_eq!(cfg.mode, DriveMode::Balanced);
189        assert_eq!(cfg.svg_mode, SvgMode::Raster);
190        assert_eq!(cfg.provider, "openai");
191        assert!(cfg.model.is_none());
192        assert!(!cfg.dry_run);
193        assert!(!cfg.verbose);
194        assert!(cfg.profile_path.is_none());
195        assert_eq!(cfg.limits.max_pixels, 100_000_000);
196    }
197
198    #[test]
199    fn test_default_safety_limits() {
200        let limits = SafetyLimits::default();
201        assert_eq!(limits.max_pixels, 100_000_000);
202        assert_eq!(limits.max_base64_bytes, 30_000_000);
203        assert_eq!(limits.max_download_bytes, 25_000_000);
204        assert_eq!(limits.max_images_extract, 50);
205    }
206}