Skip to main content

truss/
core.rs

1//! Shared Core types for transformations, validation, and media inspection.
2
3use std::error::Error;
4use std::fmt;
5use std::str::FromStr;
6use std::time::Duration;
7
8/// Maximum number of pixels in the output image (width × height).
9///
10/// This limit prevents resize operations from producing excessively large
11/// output buffers. The value matches the API specification in `doc/api.md`.
12///
13/// ```
14/// assert_eq!(truss::MAX_OUTPUT_PIXELS, 67_108_864);
15/// ```
16pub const MAX_OUTPUT_PIXELS: u64 = 67_108_864;
17
18/// Maximum number of decoded pixels allowed for an input image (width × height).
19///
20/// This limit prevents decompression bombs from consuming unbounded memory.
21/// The value matches the API specification in `doc/api.md`.
22///
23/// ```
24/// assert_eq!(truss::MAX_DECODED_PIXELS, 100_000_000);
25/// ```
26pub const MAX_DECODED_PIXELS: u64 = 100_000_000;
27
28/// Maximum number of decoded pixels allowed for a watermark image.
29///
30/// This prevents a single watermark overlay from dominating memory during
31/// compositing. The value (4 MP) is generous for typical watermarks.
32///
33/// ```
34/// assert_eq!(truss::MAX_WATERMARK_PIXELS, 4_000_000);
35/// ```
36pub const MAX_WATERMARK_PIXELS: u64 = 4_000_000;
37
38/// Raw input bytes before media-type detection has completed.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RawArtifact {
41    /// The raw input bytes.
42    pub bytes: Vec<u8>,
43    /// The media type declared by an adapter, if one is available.
44    pub declared_media_type: Option<MediaType>,
45}
46
47impl RawArtifact {
48    /// Creates a new raw artifact value.
49    pub fn new(bytes: Vec<u8>, declared_media_type: Option<MediaType>) -> Self {
50        Self {
51            bytes,
52            declared_media_type,
53        }
54    }
55}
56
57/// A decoded or otherwise classified artifact handled by the Core layer.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Artifact {
60    /// The artifact bytes.
61    pub bytes: Vec<u8>,
62    /// The detected media type for the bytes.
63    pub media_type: MediaType,
64    /// Additional metadata extracted from the artifact.
65    pub metadata: ArtifactMetadata,
66}
67
68impl Artifact {
69    /// Creates a new artifact value.
70    pub fn new(bytes: Vec<u8>, media_type: MediaType, metadata: ArtifactMetadata) -> Self {
71        Self {
72            bytes,
73            media_type,
74            metadata,
75        }
76    }
77}
78
79/// Metadata that the Core layer can carry between decode and encode steps.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct ArtifactMetadata {
82    /// The rendered width in pixels, when known.
83    pub width: Option<u32>,
84    /// The rendered height in pixels, when known.
85    pub height: Option<u32>,
86    /// The number of frames contained in the artifact.
87    pub frame_count: u32,
88    /// The total animation duration, when known.
89    pub duration: Option<Duration>,
90    /// Whether the artifact contains alpha, when known.
91    pub has_alpha: Option<bool>,
92}
93
94impl Default for ArtifactMetadata {
95    fn default() -> Self {
96        Self {
97            width: None,
98            height: None,
99            frame_count: 1,
100            duration: None,
101            has_alpha: None,
102        }
103    }
104}
105
106/// Supported media types for the current implementation phase.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum MediaType {
109    /// JPEG image data.
110    Jpeg,
111    /// PNG image data.
112    Png,
113    /// WebP image data.
114    Webp,
115    /// AVIF image data.
116    Avif,
117    /// SVG image data.
118    Svg,
119    /// BMP image data.
120    Bmp,
121    /// TIFF image data.
122    Tiff,
123}
124
125impl MediaType {
126    /// Returns the canonical media type name used by the API and CLI.
127    pub const fn as_name(self) -> &'static str {
128        match self {
129            Self::Jpeg => "jpeg",
130            Self::Png => "png",
131            Self::Webp => "webp",
132            Self::Avif => "avif",
133            Self::Svg => "svg",
134            Self::Bmp => "bmp",
135            Self::Tiff => "tiff",
136        }
137    }
138
139    /// Returns the canonical MIME type string.
140    pub const fn as_mime(self) -> &'static str {
141        match self {
142            Self::Jpeg => "image/jpeg",
143            Self::Png => "image/png",
144            Self::Webp => "image/webp",
145            Self::Avif => "image/avif",
146            Self::Svg => "image/svg+xml",
147            Self::Bmp => "image/bmp",
148            Self::Tiff => "image/tiff",
149        }
150    }
151
152    /// Reports whether the media type is typically encoded with lossy quality controls.
153    pub const fn is_lossy(self) -> bool {
154        matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
155    }
156
157    /// Returns `true` if this is a raster (bitmap) format, `false` for vector formats.
158    pub const fn is_raster(self) -> bool {
159        !matches!(self, Self::Svg)
160    }
161}
162
163impl fmt::Display for MediaType {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        f.write_str(self.as_mime())
166    }
167}
168
169impl FromStr for MediaType {
170    type Err = String;
171
172    fn from_str(value: &str) -> Result<Self, Self::Err> {
173        match value {
174            "jpeg" | "jpg" => Ok(Self::Jpeg),
175            "png" => Ok(Self::Png),
176            "webp" => Ok(Self::Webp),
177            "avif" => Ok(Self::Avif),
178            "svg" => Ok(Self::Svg),
179            "bmp" => Ok(Self::Bmp),
180            "tiff" | "tif" => Ok(Self::Tiff),
181            _ => Err(format!("unsupported media type `{value}`")),
182        }
183    }
184}
185
186/// A watermark image to composite onto the output.
187///
188/// The watermark is alpha-composited onto the main image after all other
189/// transforms (resize, blur) and before encoding.
190///
191/// ```
192/// use truss::{Artifact, ArtifactMetadata, MediaType, Position, WatermarkInput};
193///
194/// let wm = WatermarkInput {
195///     image: Artifact::new(vec![0], MediaType::Png, ArtifactMetadata::default()),
196///     position: Position::BottomRight,
197///     opacity: 50,
198///     margin: 10,
199/// };
200/// assert_eq!(wm.opacity, 50);
201/// ```
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct WatermarkInput {
204    /// The watermark image (already classified via [`sniff_artifact`]).
205    pub image: Artifact,
206    /// Where to place the watermark on the main image.
207    pub position: Position,
208    /// Opacity of the watermark (1–100). Default: 50.
209    pub opacity: u8,
210    /// Margin in pixels from the nearest edge. Default: 10.
211    pub margin: u32,
212}
213
214/// A complete transform request for the Core layer.
215#[derive(Debug, Clone, PartialEq)]
216pub struct TransformRequest {
217    /// The already-resolved input artifact.
218    pub input: Artifact,
219    /// Raw transform options as provided by an adapter.
220    pub options: TransformOptions,
221    /// Optional watermark image to composite onto the output.
222    pub watermark: Option<WatermarkInput>,
223}
224
225impl TransformRequest {
226    /// Creates a new transform request.
227    pub fn new(input: Artifact, options: TransformOptions) -> Self {
228        Self {
229            input,
230            options,
231            watermark: None,
232        }
233    }
234
235    /// Creates a new transform request with a watermark.
236    pub fn with_watermark(
237        input: Artifact,
238        options: TransformOptions,
239        watermark: WatermarkInput,
240    ) -> Self {
241        Self {
242            input,
243            options,
244            watermark: Some(watermark),
245        }
246    }
247
248    /// Normalizes the request into a form that does not require adapter-specific defaults.
249    pub fn normalize(self) -> Result<NormalizedTransformRequest, TransformError> {
250        let options = self.options.normalize(self.input.media_type)?;
251
252        if let Some(ref wm) = self.watermark {
253            validate_watermark(wm)?;
254        }
255
256        Ok(NormalizedTransformRequest {
257            input: self.input,
258            options,
259            watermark: self.watermark,
260        })
261    }
262}
263
264/// A fully normalized transform request.
265#[derive(Debug, Clone, PartialEq)]
266pub struct NormalizedTransformRequest {
267    /// The normalized input artifact.
268    pub input: Artifact,
269    /// Fully normalized transform options.
270    pub options: NormalizedTransformOptions,
271    /// Optional watermark to composite onto the output.
272    pub watermark: Option<WatermarkInput>,
273}
274
275/// An explicit crop region applied before resize.
276///
277/// Crop extracts a rectangular sub-image at the given pixel coordinates.
278/// The origin `(x, y)` is the top-left corner and `(width, height)` define
279/// the size of the extracted region. Both dimensions must be non-zero.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub struct CropRegion {
282    /// Horizontal offset from the left edge.
283    pub x: u32,
284    /// Vertical offset from the top edge.
285    pub y: u32,
286    /// Width of the crop region.
287    pub width: u32,
288    /// Height of the crop region.
289    pub height: u32,
290}
291
292impl FromStr for CropRegion {
293    type Err = String;
294
295    fn from_str(s: &str) -> Result<Self, Self::Err> {
296        let parts: Vec<&str> = s.split(',').collect();
297        if parts.len() != 4 {
298            return Err(format!(
299                "crop must be x,y,w,h (four comma-separated integers), got '{s}'"
300            ));
301        }
302        let x = parts[0]
303            .parse::<u32>()
304            .map_err(|_| format!("crop x must be a non-negative integer, got '{}'", parts[0]))?;
305        let y = parts[1]
306            .parse::<u32>()
307            .map_err(|_| format!("crop y must be a non-negative integer, got '{}'", parts[1]))?;
308        let width = parts[2].parse::<u32>().map_err(|_| {
309            format!(
310                "crop width must be a non-negative integer, got '{}'",
311                parts[2]
312            )
313        })?;
314        let height = parts[3].parse::<u32>().map_err(|_| {
315            format!(
316                "crop height must be a non-negative integer, got '{}'",
317                parts[3]
318            )
319        })?;
320        if width == 0 || height == 0 {
321            return Err("crop width and height must be greater than zero".to_string());
322        }
323        Ok(CropRegion {
324            x,
325            y,
326            width,
327            height,
328        })
329    }
330}
331
332impl fmt::Display for CropRegion {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        write!(f, "{},{},{},{}", self.x, self.y, self.width, self.height)
335    }
336}
337
338/// Raw transform options before defaulting and validation has completed.
339#[derive(Debug, Clone, PartialEq)]
340pub struct TransformOptions {
341    /// The desired output width in pixels.
342    pub width: Option<u32>,
343    /// The desired output height in pixels.
344    pub height: Option<u32>,
345    /// The requested resize fit mode.
346    pub fit: Option<Fit>,
347    /// The requested positioning mode.
348    pub position: Option<Position>,
349    /// The requested output format.
350    pub format: Option<MediaType>,
351    /// The requested lossy quality.
352    pub quality: Option<u8>,
353    /// The requested background color.
354    pub background: Option<Rgba8>,
355    /// The requested extra rotation.
356    pub rotate: Rotation,
357    /// Whether EXIF-based auto-orientation should run.
358    pub auto_orient: bool,
359    /// Whether metadata should be stripped from the output.
360    pub strip_metadata: bool,
361    /// Whether EXIF metadata should be preserved.
362    pub preserve_exif: bool,
363    /// Gaussian blur sigma.
364    ///
365    /// When set, a Gaussian blur with the given sigma is applied after resizing
366    /// and before encoding. Valid range is 0.1–100.0.
367    pub blur: Option<f32>,
368    /// Unsharp-mask (sharpen) sigma.
369    ///
370    /// When set, an unsharp mask with the given sigma is applied after resizing
371    /// and before encoding. Valid range is 0.1–100.0. The sharpening threshold
372    /// is fixed at 1.
373    pub sharpen: Option<f32>,
374    /// Optional explicit crop region applied before resize.
375    ///
376    /// When set, the image is cropped to the specified rectangle before any resize
377    /// operation. The crop region is validated at runtime against the decoded image
378    /// dimensions.
379    pub crop: Option<CropRegion>,
380    /// Optional wall-clock deadline for the transform pipeline.
381    ///
382    /// When set, the transform checks elapsed time at each pipeline stage and returns
383    /// [`TransformError::LimitExceeded`] if the deadline is exceeded. Adapters inject
384    /// this value based on their operational requirements — for example, the HTTP server
385    /// sets a 30-second deadline while the CLI leaves it as `None` (unlimited).
386    pub deadline: Option<Duration>,
387}
388
389impl Default for TransformOptions {
390    fn default() -> Self {
391        Self {
392            width: None,
393            height: None,
394            fit: None,
395            position: None,
396            format: None,
397            quality: None,
398            background: None,
399            rotate: Rotation::Deg0,
400            auto_orient: true,
401            strip_metadata: true,
402            preserve_exif: false,
403            blur: None,
404            sharpen: None,
405            crop: None,
406            deadline: None,
407        }
408    }
409}
410
411impl TransformOptions {
412    /// Normalizes and validates the options against the input media type.
413    pub fn normalize(
414        self,
415        input_media_type: MediaType,
416    ) -> Result<NormalizedTransformOptions, TransformError> {
417        validate_dimension("width", self.width)?;
418        validate_dimension("height", self.height)?;
419        validate_quality(self.quality)?;
420        validate_blur(self.blur)?;
421        validate_sharpen(self.sharpen)?;
422        if let Some(crop) = self.crop
423            && (crop.width == 0 || crop.height == 0)
424        {
425            return Err(TransformError::InvalidOptions(
426                "crop width and height must be greater than zero".to_string(),
427            ));
428        }
429
430        let has_bounded_resize = self.width.is_some() && self.height.is_some();
431
432        if self.fit.is_some() && !has_bounded_resize {
433            return Err(TransformError::InvalidOptions(
434                "fit requires both width and height".to_string(),
435            ));
436        }
437
438        if self.position.is_some() && !has_bounded_resize {
439            return Err(TransformError::InvalidOptions(
440                "position requires both width and height".to_string(),
441            ));
442        }
443
444        if self.preserve_exif && self.strip_metadata {
445            return Err(TransformError::InvalidOptions(
446                "preserve_exif requires strip_metadata to be false".to_string(),
447            ));
448        }
449
450        let format = self.format.unwrap_or(input_media_type);
451
452        if self.preserve_exif && format == MediaType::Svg {
453            return Err(TransformError::InvalidOptions(
454                "preserveExif is not supported with SVG output".to_string(),
455            ));
456        }
457
458        if self.quality.is_some() && !format.is_lossy() {
459            return Err(TransformError::InvalidOptions(
460                "quality requires a lossy output format".to_string(),
461            ));
462        }
463
464        let fit = if has_bounded_resize {
465            Some(self.fit.unwrap_or(Fit::Contain))
466        } else {
467            None
468        };
469
470        Ok(NormalizedTransformOptions {
471            width: self.width,
472            height: self.height,
473            fit,
474            position: self.position.unwrap_or(Position::Center),
475            format,
476            quality: self.quality,
477            background: self.background,
478            rotate: self.rotate,
479            auto_orient: self.auto_orient,
480            metadata_policy: normalize_metadata_policy(self.strip_metadata, self.preserve_exif),
481            blur: self.blur,
482            sharpen: self.sharpen,
483            crop: self.crop,
484            deadline: self.deadline,
485        })
486    }
487}
488
489/// Fully normalized transform options ready for a backend pipeline.
490#[derive(Debug, Clone, PartialEq)]
491pub struct NormalizedTransformOptions {
492    /// The desired output width in pixels.
493    pub width: Option<u32>,
494    /// The desired output height in pixels.
495    pub height: Option<u32>,
496    /// The normalized resize fit mode.
497    pub fit: Option<Fit>,
498    /// The normalized positioning mode.
499    pub position: Position,
500    /// The resolved output format.
501    pub format: MediaType,
502    /// The requested lossy quality.
503    pub quality: Option<u8>,
504    /// The requested background color.
505    pub background: Option<Rgba8>,
506    /// The requested extra rotation.
507    pub rotate: Rotation,
508    /// Whether EXIF-based auto-orientation should run.
509    pub auto_orient: bool,
510    /// The normalized metadata handling strategy.
511    pub metadata_policy: MetadataPolicy,
512    /// Gaussian blur sigma, when requested.
513    pub blur: Option<f32>,
514    /// Unsharp-mask (sharpen) sigma, when requested.
515    pub sharpen: Option<f32>,
516    /// Optional explicit crop region applied before resize.
517    pub crop: Option<CropRegion>,
518    /// Optional wall-clock deadline for the transform pipeline.
519    pub deadline: Option<Duration>,
520}
521
522/// Resize behavior for bounded transforms.
523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
524pub enum Fit {
525    /// Scale to fit within the box while preserving aspect ratio.
526    Contain,
527    /// Scale to cover the box while preserving aspect ratio.
528    Cover,
529    /// Stretch to fill the box.
530    Fill,
531    /// Scale down only when the input is larger than the box.
532    Inside,
533}
534
535impl Fit {
536    /// Returns the canonical option name used by the API and CLI.
537    pub const fn as_name(self) -> &'static str {
538        match self {
539            Self::Contain => "contain",
540            Self::Cover => "cover",
541            Self::Fill => "fill",
542            Self::Inside => "inside",
543        }
544    }
545}
546
547impl FromStr for Fit {
548    type Err = String;
549
550    fn from_str(value: &str) -> Result<Self, Self::Err> {
551        match value {
552            "contain" => Ok(Self::Contain),
553            "cover" => Ok(Self::Cover),
554            "fill" => Ok(Self::Fill),
555            "inside" => Ok(Self::Inside),
556            _ => Err(format!("unsupported fit mode `{value}`")),
557        }
558    }
559}
560
561/// Positioning behavior for bounded transforms.
562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563pub enum Position {
564    /// Center alignment.
565    Center,
566    /// Top alignment.
567    Top,
568    /// Right alignment.
569    Right,
570    /// Bottom alignment.
571    Bottom,
572    /// Left alignment.
573    Left,
574    /// Top-left alignment.
575    TopLeft,
576    /// Top-right alignment.
577    TopRight,
578    /// Bottom-left alignment.
579    BottomLeft,
580    /// Bottom-right alignment.
581    BottomRight,
582}
583
584impl Position {
585    /// Returns the canonical option name used by the API and CLI.
586    pub const fn as_name(self) -> &'static str {
587        match self {
588            Self::Center => "center",
589            Self::Top => "top",
590            Self::Right => "right",
591            Self::Bottom => "bottom",
592            Self::Left => "left",
593            Self::TopLeft => "top-left",
594            Self::TopRight => "top-right",
595            Self::BottomLeft => "bottom-left",
596            Self::BottomRight => "bottom-right",
597        }
598    }
599}
600
601impl FromStr for Position {
602    type Err = String;
603
604    fn from_str(value: &str) -> Result<Self, Self::Err> {
605        match value {
606            "center" => Ok(Self::Center),
607            "top" => Ok(Self::Top),
608            "right" => Ok(Self::Right),
609            "bottom" => Ok(Self::Bottom),
610            "left" => Ok(Self::Left),
611            "top-left" => Ok(Self::TopLeft),
612            "top-right" => Ok(Self::TopRight),
613            "bottom-left" => Ok(Self::BottomLeft),
614            "bottom-right" => Ok(Self::BottomRight),
615            _ => Err(format!("unsupported position `{value}`")),
616        }
617    }
618}
619
620/// Rotation that is applied after auto-orientation.
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622pub enum Rotation {
623    /// No extra rotation.
624    Deg0,
625    /// Rotate 90 degrees clockwise.
626    Deg90,
627    /// Rotate 180 degrees.
628    Deg180,
629    /// Rotate 270 degrees clockwise.
630    Deg270,
631}
632
633impl Rotation {
634    /// Returns the canonical degree value used by the API and CLI.
635    pub const fn as_degrees(self) -> u16 {
636        match self {
637            Self::Deg0 => 0,
638            Self::Deg90 => 90,
639            Self::Deg180 => 180,
640            Self::Deg270 => 270,
641        }
642    }
643}
644
645impl FromStr for Rotation {
646    type Err = String;
647
648    fn from_str(value: &str) -> Result<Self, Self::Err> {
649        match value {
650            "0" => Ok(Self::Deg0),
651            "90" => Ok(Self::Deg90),
652            "180" => Ok(Self::Deg180),
653            "270" => Ok(Self::Deg270),
654            _ => Err(format!("unsupported rotation `{value}`")),
655        }
656    }
657}
658
659/// A simple 8-bit RGBA color.
660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
661pub struct Rgba8 {
662    /// Red channel.
663    pub r: u8,
664    /// Green channel.
665    pub g: u8,
666    /// Blue channel.
667    pub b: u8,
668    /// Alpha channel.
669    pub a: u8,
670}
671
672impl Rgba8 {
673    /// Parses a hexadecimal RGB or RGBA color string without a leading `#`.
674    pub fn from_hex(value: &str) -> Result<Self, String> {
675        if value.len() != 6 && value.len() != 8 {
676            return Err(format!("unsupported color `{value}`"));
677        }
678
679        let r = u8::from_str_radix(&value[0..2], 16)
680            .map_err(|_| format!("unsupported color `{value}`"))?;
681        let g = u8::from_str_radix(&value[2..4], 16)
682            .map_err(|_| format!("unsupported color `{value}`"))?;
683        let b = u8::from_str_radix(&value[4..6], 16)
684            .map_err(|_| format!("unsupported color `{value}`"))?;
685        let a = if value.len() == 8 {
686            u8::from_str_radix(&value[6..8], 16)
687                .map_err(|_| format!("unsupported color `{value}`"))?
688        } else {
689            u8::MAX
690        };
691
692        Ok(Self { r, g, b, a })
693    }
694}
695
696/// Metadata handling after option normalization.
697#[derive(Debug, Clone, Copy, PartialEq, Eq)]
698pub enum MetadataPolicy {
699    /// Drop metadata from the output.
700    StripAll,
701    /// Keep metadata unchanged when possible.
702    KeepAll,
703    /// Preserve EXIF while allowing other metadata policies later.
704    PreserveExif,
705}
706
707/// Resolves the three-way metadata flag semantics shared by all adapters.
708///
709/// Adapters accept different flag names (CLI: `--keep-metadata`/`--strip-metadata`/`--preserve-exif`,
710/// WASM: `keepMetadata`/`preserveExif`, server: `stripMetadata`/`preserveExif`) but the
711/// underlying semantics are identical. This function centralizes the resolution so that
712/// every adapter produces the same `(strip_metadata, preserve_exif)` pair for the same
713/// logical input.
714///
715/// # Arguments
716///
717/// * `strip` — Explicit "strip all metadata" flag, when provided.
718/// * `keep` — Explicit "keep all metadata" flag, when provided.
719/// * `preserve_exif` — Explicit "preserve EXIF only" flag, when provided.
720///
721/// # Errors
722///
723/// Returns [`TransformError::InvalidOptions`] when `keep` and `preserve_exif` are both
724/// explicitly `true`, since those policies are mutually exclusive.
725///
726/// # Examples
727///
728/// ```
729/// use truss::resolve_metadata_flags;
730///
731/// // Default: strip all metadata
732/// let (strip, exif) = resolve_metadata_flags(None, None, None).unwrap();
733/// assert!(strip);
734/// assert!(!exif);
735///
736/// // Explicit keep
737/// let (strip, exif) = resolve_metadata_flags(None, Some(true), None).unwrap();
738/// assert!(!strip);
739/// assert!(!exif);
740///
741/// // Preserve EXIF only
742/// let (strip, exif) = resolve_metadata_flags(None, None, Some(true)).unwrap();
743/// assert!(!strip);
744/// assert!(exif);
745///
746/// // keep + preserve_exif conflict
747/// assert!(resolve_metadata_flags(None, Some(true), Some(true)).is_err());
748/// ```
749pub fn resolve_metadata_flags(
750    strip: Option<bool>,
751    keep: Option<bool>,
752    preserve_exif: Option<bool>,
753) -> Result<(bool, bool), TransformError> {
754    let keep = keep.unwrap_or(false);
755    let preserve_exif = preserve_exif.unwrap_or(false);
756
757    if keep && preserve_exif {
758        return Err(TransformError::InvalidOptions(
759            "keepMetadata and preserveExif cannot both be true".to_string(),
760        ));
761    }
762
763    let strip_metadata = if keep || preserve_exif {
764        false
765    } else {
766        strip.unwrap_or(true)
767    };
768
769    Ok((strip_metadata, preserve_exif))
770}
771
772/// Errors returned by Core validation or backend execution.
773#[derive(Debug, Clone, PartialEq, Eq)]
774pub enum TransformError {
775    /// The input artifact is structurally invalid.
776    InvalidInput(String),
777    /// The provided options are contradictory or unsupported.
778    InvalidOptions(String),
779    /// The input media type cannot be processed.
780    UnsupportedInputMediaType(String),
781    /// The requested output media type cannot be produced.
782    UnsupportedOutputMediaType(MediaType),
783    /// Decoding the input artifact failed.
784    DecodeFailed(String),
785    /// Encoding the output artifact failed.
786    EncodeFailed(String),
787    /// The current runtime does not provide a required capability.
788    CapabilityMissing(String),
789    /// The image exceeds a processing limit such as maximum pixel count.
790    LimitExceeded(String),
791}
792
793impl fmt::Display for TransformError {
794    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
795        match self {
796            Self::InvalidInput(reason) => write!(f, "invalid input: {reason}"),
797            Self::InvalidOptions(reason) => write!(f, "invalid transform options: {reason}"),
798            Self::UnsupportedInputMediaType(reason) => {
799                write!(f, "unsupported input media type: {reason}")
800            }
801            Self::UnsupportedOutputMediaType(media_type) => {
802                write!(f, "unsupported output media type: {media_type}")
803            }
804            Self::DecodeFailed(reason) => write!(f, "decode failed: {reason}"),
805            Self::EncodeFailed(reason) => write!(f, "encode failed: {reason}"),
806            Self::CapabilityMissing(reason) => write!(f, "missing capability: {reason}"),
807            Self::LimitExceeded(reason) => write!(f, "limit exceeded: {reason}"),
808        }
809    }
810}
811
812impl Error for TransformError {}
813
814/// Categories of image metadata that may be present in an artifact.
815///
816/// Used by [`TransformWarning::MetadataDropped`] to identify which metadata type
817/// was silently dropped during a transform operation.
818///
819/// ```
820/// use truss::MetadataKind;
821///
822/// assert_eq!(format!("{}", MetadataKind::Xmp), "XMP");
823/// assert_eq!(format!("{}", MetadataKind::Iptc), "IPTC");
824/// assert_eq!(format!("{}", MetadataKind::Exif), "EXIF");
825/// assert_eq!(format!("{}", MetadataKind::Icc), "ICC profile");
826/// ```
827#[derive(Debug, Clone, Copy, PartialEq, Eq)]
828pub enum MetadataKind {
829    /// XMP (Extensible Metadata Platform) metadata.
830    Xmp,
831    /// IPTC/IIM (International Press Telecommunications Council) metadata.
832    Iptc,
833    /// EXIF (Exchangeable Image File Format) metadata.
834    Exif,
835    /// ICC color profile.
836    Icc,
837}
838
839impl fmt::Display for MetadataKind {
840    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
841        match self {
842            Self::Xmp => f.write_str("XMP"),
843            Self::Iptc => f.write_str("IPTC"),
844            Self::Exif => f.write_str("EXIF"),
845            Self::Icc => f.write_str("ICC profile"),
846        }
847    }
848}
849
850/// A non-fatal warning emitted during a transform operation.
851///
852/// Warnings indicate that the transform completed successfully but some aspect of
853/// the request could not be fully honored. Adapters should surface these to operators
854/// (e.g. CLI prints to stderr, server logs to stderr).
855///
856/// ```
857/// use truss::{MetadataKind, TransformWarning};
858///
859/// let warning = TransformWarning::MetadataDropped(MetadataKind::Xmp);
860/// assert_eq!(
861///     format!("{warning}"),
862///     "XMP metadata was present in the input but could not be preserved by the output encoder"
863/// );
864/// ```
865#[derive(Debug, Clone, PartialEq, Eq)]
866pub enum TransformWarning {
867    /// Metadata of the given kind was present in the input but could not be preserved
868    /// by the output encoder and was silently dropped.
869    MetadataDropped(MetadataKind),
870}
871
872impl fmt::Display for TransformWarning {
873    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
874        match self {
875            Self::MetadataDropped(kind) => write!(
876                f,
877                "{kind} metadata was present in the input but could not be preserved by the output encoder"
878            ),
879        }
880    }
881}
882
883/// The result of a successful transform, containing the output artifact and any warnings.
884///
885/// Warnings indicate aspects of the request that could not be fully honored, such as
886/// metadata types that were silently dropped because the output encoder does not support them.
887#[derive(Debug)]
888pub struct TransformResult {
889    /// The transformed output artifact.
890    pub artifact: Artifact,
891    /// Non-fatal warnings emitted during the transform.
892    pub warnings: Vec<TransformWarning>,
893}
894
895/// Inspects raw bytes, detects the media type, and extracts best-effort metadata.
896///
897/// The caller is expected to pass bytes that have already been resolved by an adapter
898/// such as the CLI or HTTP server runtime. If a declared media type is provided in the
899/// [`RawArtifact`], this function verifies that the declared type matches the detected
900/// signature before returning the classified [`Artifact`].
901///
902/// Detection currently supports JPEG, PNG, WebP, AVIF, and BMP recognition.
903/// Width, height, and alpha extraction are best-effort and depend on the underlying format
904/// and any container metadata the file exposes.
905///
906/// # Errors
907///
908/// Returns [`TransformError::UnsupportedInputMediaType`] when the byte signature does not
909/// match a supported format, [`TransformError::InvalidInput`] when the declared media type
910/// conflicts with the detected type, and [`TransformError::DecodeFailed`] when a supported
911/// format has an invalid or truncated structure.
912///
913/// # Examples
914///
915/// ```
916/// use truss::{sniff_artifact, MediaType, RawArtifact};
917///
918/// let png_bytes = vec![
919///     0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1A, b'\n',
920///     0, 0, 0, 13, b'I', b'H', b'D', b'R',
921///     0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 0,
922///     0, 0, 0, 0,
923/// ];
924///
925/// let artifact = sniff_artifact(RawArtifact::new(png_bytes, Some(MediaType::Png))).unwrap();
926///
927/// assert_eq!(artifact.media_type, MediaType::Png);
928/// assert_eq!(artifact.metadata.width, Some(4));
929/// assert_eq!(artifact.metadata.height, Some(3));
930/// ```
931///
932/// ```
933/// use image::codecs::avif::AvifEncoder;
934/// use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
935/// use truss::{sniff_artifact, MediaType, RawArtifact};
936///
937/// let image = RgbaImage::from_pixel(3, 2, Rgba([10, 20, 30, 0]));
938/// let mut bytes = Vec::new();
939/// AvifEncoder::new(&mut bytes)
940///     .write_image(&image, 3, 2, ColorType::Rgba8.into())
941///     .unwrap();
942///
943/// let artifact = sniff_artifact(RawArtifact::new(bytes, Some(MediaType::Avif))).unwrap();
944///
945/// assert_eq!(artifact.media_type, MediaType::Avif);
946/// assert_eq!(artifact.metadata.width, Some(3));
947/// assert_eq!(artifact.metadata.height, Some(2));
948/// assert_eq!(artifact.metadata.has_alpha, Some(true));
949/// ```
950pub fn sniff_artifact(input: RawArtifact) -> Result<Artifact, TransformError> {
951    let (media_type, metadata) = detect_artifact(&input.bytes)?;
952
953    if let Some(declared_media_type) = input.declared_media_type
954        && declared_media_type != media_type
955    {
956        return Err(TransformError::InvalidInput(
957            "declared media type does not match detected media type".to_string(),
958        ));
959    }
960
961    Ok(Artifact::new(input.bytes, media_type, metadata))
962}
963
964fn validate_dimension(name: &str, value: Option<u32>) -> Result<(), TransformError> {
965    if matches!(value, Some(0)) {
966        return Err(TransformError::InvalidOptions(format!(
967            "{name} must be greater than zero"
968        )));
969    }
970
971    Ok(())
972}
973
974fn validate_quality(value: Option<u8>) -> Result<(), TransformError> {
975    if matches!(value, Some(0) | Some(101..=u8::MAX)) {
976        return Err(TransformError::InvalidOptions(
977            "quality must be between 1 and 100".to_string(),
978        ));
979    }
980
981    Ok(())
982}
983
984fn validate_blur(value: Option<f32>) -> Result<(), TransformError> {
985    if let Some(sigma) = value
986        && !(0.1..=100.0).contains(&sigma)
987    {
988        return Err(TransformError::InvalidOptions(
989            "blur sigma must be between 0.1 and 100.0".to_string(),
990        ));
991    }
992
993    Ok(())
994}
995
996fn validate_sharpen(value: Option<f32>) -> Result<(), TransformError> {
997    if let Some(sigma) = value
998        && !(0.1..=100.0).contains(&sigma)
999    {
1000        return Err(TransformError::InvalidOptions(
1001            "sharpen sigma must be between 0.1 and 100.0".to_string(),
1002        ));
1003    }
1004
1005    Ok(())
1006}
1007
1008fn validate_watermark(wm: &WatermarkInput) -> Result<(), TransformError> {
1009    if wm.opacity == 0 || wm.opacity > 100 {
1010        return Err(TransformError::InvalidOptions(
1011            "watermark opacity must be between 1 and 100".to_string(),
1012        ));
1013    }
1014
1015    if !wm.image.media_type.is_raster() {
1016        return Err(TransformError::InvalidOptions(
1017            "watermark image must be a raster format".to_string(),
1018        ));
1019    }
1020
1021    Ok(())
1022}
1023
1024fn normalize_metadata_policy(strip_metadata: bool, preserve_exif: bool) -> MetadataPolicy {
1025    if preserve_exif {
1026        MetadataPolicy::PreserveExif
1027    } else if strip_metadata {
1028        MetadataPolicy::StripAll
1029    } else {
1030        MetadataPolicy::KeepAll
1031    }
1032}
1033
1034fn detect_artifact(bytes: &[u8]) -> Result<(MediaType, ArtifactMetadata), TransformError> {
1035    if is_png(bytes) {
1036        return Ok((MediaType::Png, sniff_png(bytes)?));
1037    }
1038
1039    if is_jpeg(bytes) {
1040        return Ok((MediaType::Jpeg, sniff_jpeg(bytes)?));
1041    }
1042
1043    if is_webp(bytes) {
1044        return Ok((MediaType::Webp, sniff_webp(bytes)?));
1045    }
1046
1047    if is_avif(bytes) {
1048        return Ok((MediaType::Avif, sniff_avif(bytes)?));
1049    }
1050
1051    if is_bmp(bytes) {
1052        return Ok((MediaType::Bmp, sniff_bmp(bytes)?));
1053    }
1054
1055    if is_tiff(bytes) {
1056        return Ok((MediaType::Tiff, sniff_tiff(bytes)?));
1057    }
1058
1059    // SVG check goes last: it relies on text scanning which is slower than binary
1060    // magic-number checks and could produce false positives on non-SVG XML.
1061    if is_svg(bytes) {
1062        return Ok((MediaType::Svg, sniff_svg(bytes)));
1063    }
1064
1065    Err(TransformError::UnsupportedInputMediaType(
1066        "unknown file signature".to_string(),
1067    ))
1068}
1069
1070fn is_png(bytes: &[u8]) -> bool {
1071    bytes.starts_with(b"\x89PNG\r\n\x1a\n")
1072}
1073
1074fn is_jpeg(bytes: &[u8]) -> bool {
1075    bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
1076}
1077
1078fn is_webp(bytes: &[u8]) -> bool {
1079    bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP"
1080}
1081
1082fn is_avif(bytes: &[u8]) -> bool {
1083    bytes.len() >= 16 && &bytes[4..8] == b"ftyp" && has_avif_brand(&bytes[8..])
1084}
1085
1086/// Detects SVG by scanning for a `<svg` root element, skipping XML declarations,
1087/// doctypes, comments, and whitespace.
1088fn is_svg(bytes: &[u8]) -> bool {
1089    let text = match std::str::from_utf8(bytes) {
1090        Ok(s) => s,
1091        Err(_) => return false,
1092    };
1093
1094    let mut remaining = text.trim_start();
1095
1096    // Skip UTF-8 BOM if present.
1097    remaining = remaining.strip_prefix('\u{FEFF}').unwrap_or(remaining);
1098    remaining = remaining.trim_start();
1099
1100    // Skip XML declaration: <?xml ... ?>
1101    if let Some(rest) = remaining.strip_prefix("<?xml") {
1102        if let Some(end) = rest.find("?>") {
1103            remaining = rest[end + 2..].trim_start();
1104        } else {
1105            return false;
1106        }
1107    }
1108
1109    // Skip DOCTYPE: <!DOCTYPE ... >
1110    if let Some(rest) = remaining.strip_prefix("<!DOCTYPE") {
1111        if let Some(end) = rest.find('>') {
1112            remaining = rest[end + 1..].trim_start();
1113        } else {
1114            return false;
1115        }
1116    }
1117
1118    // Skip comments: <!-- ... -->
1119    while let Some(rest) = remaining.strip_prefix("<!--") {
1120        if let Some(end) = rest.find("-->") {
1121            remaining = rest[end + 3..].trim_start();
1122        } else {
1123            return false;
1124        }
1125    }
1126
1127    remaining.starts_with("<svg")
1128        && remaining
1129            .as_bytes()
1130            .get(4)
1131            .is_some_and(|&b| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>')
1132}
1133
1134/// Extracts basic SVG metadata. SVGs inherently support transparency.
1135/// Width and height are left unknown because SVGs may define dimensions via
1136/// `viewBox`, percentage-based attributes, or not at all.
1137fn sniff_svg(_bytes: &[u8]) -> ArtifactMetadata {
1138    ArtifactMetadata {
1139        width: None,
1140        height: None,
1141        frame_count: 1,
1142        duration: None,
1143        has_alpha: Some(true),
1144    }
1145}
1146
1147/// Detects BMP files by checking for the "BM" signature at offset 0.
1148fn is_bmp(bytes: &[u8]) -> bool {
1149    bytes.len() >= 26 && bytes[0] == 0x42 && bytes[1] == 0x4D
1150}
1151
1152/// Extracts BMP metadata from the DIB header.
1153///
1154/// The BITMAPINFOHEADER layout (and compatible V4/V5 headers) stores:
1155/// - width as a signed 32-bit integer at file offset 18
1156/// - height as a signed 32-bit integer at file offset 22 (negative = top-down)
1157/// - bits per pixel at file offset 28
1158fn sniff_bmp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1159    if bytes.len() < 30 {
1160        return Err(TransformError::DecodeFailed(
1161            "bmp file is too short".to_string(),
1162        ));
1163    }
1164
1165    let width = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]);
1166    let raw_height = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
1167    let height = raw_height.unsigned_abs();
1168    let bits_per_pixel = u16::from_le_bytes([bytes[28], bytes[29]]);
1169
1170    let has_alpha = bits_per_pixel == 32;
1171
1172    Ok(ArtifactMetadata {
1173        width: Some(width),
1174        height: Some(height),
1175        frame_count: 1,
1176        duration: None,
1177        has_alpha: Some(has_alpha),
1178    })
1179}
1180
1181/// Detects TIFF files by checking the byte-order marker and magic number.
1182///
1183/// Little-endian: `II` + 0x002A, big-endian: `MM` + 0x002A.
1184fn is_tiff(bytes: &[u8]) -> bool {
1185    bytes.len() >= 4
1186        && ((bytes[0] == b'I' && bytes[1] == b'I' && bytes[2] == 0x2A && bytes[3] == 0x00)
1187            || (bytes[0] == b'M' && bytes[1] == b'M' && bytes[2] == 0x00 && bytes[3] == 0x2A))
1188}
1189
1190/// Extracts TIFF metadata by decoding the image header via the `image` crate.
1191fn sniff_tiff(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1192    let cursor = std::io::Cursor::new(bytes);
1193    let decoder = image::codecs::tiff::TiffDecoder::new(cursor)
1194        .map_err(|e| TransformError::DecodeFailed(format!("tiff decode: {e}")))?;
1195    let (width, height) = image::ImageDecoder::dimensions(&decoder);
1196    let color = image::ImageDecoder::color_type(&decoder);
1197    let has_alpha = matches!(
1198        color,
1199        image::ColorType::La8
1200            | image::ColorType::Rgba8
1201            | image::ColorType::La16
1202            | image::ColorType::Rgba16
1203            | image::ColorType::Rgba32F
1204    );
1205    Ok(ArtifactMetadata {
1206        width: Some(width),
1207        height: Some(height),
1208        frame_count: 1,
1209        duration: None,
1210        has_alpha: Some(has_alpha),
1211    })
1212}
1213
1214fn sniff_png(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1215    if bytes.len() < 29 {
1216        return Err(TransformError::DecodeFailed(
1217            "png file is too short".to_string(),
1218        ));
1219    }
1220
1221    if &bytes[12..16] != b"IHDR" {
1222        return Err(TransformError::DecodeFailed(
1223            "png file is missing an IHDR chunk".to_string(),
1224        ));
1225    }
1226
1227    let width = read_u32_be(&bytes[16..20])?;
1228    let height = read_u32_be(&bytes[20..24])?;
1229    let color_type = bytes[25];
1230    let has_alpha = match color_type {
1231        4 | 6 => Some(true),
1232        0 | 2 | 3 => Some(false),
1233        _ => None,
1234    };
1235
1236    Ok(ArtifactMetadata {
1237        width: Some(width),
1238        height: Some(height),
1239        frame_count: 1,
1240        duration: None,
1241        has_alpha,
1242    })
1243}
1244
1245fn sniff_jpeg(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1246    let mut offset = 2;
1247
1248    while offset + 1 < bytes.len() {
1249        if bytes[offset] != 0xFF {
1250            return Err(TransformError::DecodeFailed(
1251                "jpeg file has an invalid marker prefix".to_string(),
1252            ));
1253        }
1254
1255        while offset < bytes.len() && bytes[offset] == 0xFF {
1256            offset += 1;
1257        }
1258
1259        if offset >= bytes.len() {
1260            break;
1261        }
1262
1263        let marker = bytes[offset];
1264        offset += 1;
1265
1266        if marker == 0xD9 || marker == 0xDA {
1267            break;
1268        }
1269
1270        if (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
1271            continue;
1272        }
1273
1274        if offset + 2 > bytes.len() {
1275            return Err(TransformError::DecodeFailed(
1276                "jpeg segment is truncated".to_string(),
1277            ));
1278        }
1279
1280        let segment_length = read_u16_be(&bytes[offset..offset + 2])? as usize;
1281        if segment_length < 2 || offset + segment_length > bytes.len() {
1282            return Err(TransformError::DecodeFailed(
1283                "jpeg segment length is invalid".to_string(),
1284            ));
1285        }
1286
1287        if is_jpeg_sof_marker(marker) {
1288            if segment_length < 7 {
1289                return Err(TransformError::DecodeFailed(
1290                    "jpeg SOF segment is too short".to_string(),
1291                ));
1292            }
1293
1294            let height = read_u16_be(&bytes[offset + 3..offset + 5])? as u32;
1295            let width = read_u16_be(&bytes[offset + 5..offset + 7])? as u32;
1296
1297            return Ok(ArtifactMetadata {
1298                width: Some(width),
1299                height: Some(height),
1300                frame_count: 1,
1301                duration: None,
1302                has_alpha: Some(false),
1303            });
1304        }
1305
1306        offset += segment_length;
1307    }
1308
1309    Err(TransformError::DecodeFailed(
1310        "jpeg file is missing a SOF segment".to_string(),
1311    ))
1312}
1313
1314fn sniff_webp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1315    let mut offset = 12;
1316
1317    while offset + 8 <= bytes.len() {
1318        let chunk_tag = &bytes[offset..offset + 4];
1319        let chunk_size = read_u32_le(&bytes[offset + 4..offset + 8])? as usize;
1320        let chunk_start = offset + 8;
1321        let chunk_end = chunk_start
1322            .checked_add(chunk_size)
1323            .ok_or_else(|| TransformError::DecodeFailed("webp chunk is too large".to_string()))?;
1324
1325        if chunk_end > bytes.len() {
1326            return Err(TransformError::DecodeFailed(
1327                "webp chunk exceeds file length".to_string(),
1328            ));
1329        }
1330
1331        let chunk_data = &bytes[chunk_start..chunk_end];
1332
1333        match chunk_tag {
1334            b"VP8X" => return sniff_webp_vp8x(chunk_data),
1335            b"VP8 " => return sniff_webp_vp8(chunk_data),
1336            b"VP8L" => return sniff_webp_vp8l(chunk_data),
1337            _ => {}
1338        }
1339
1340        offset = chunk_end + (chunk_size % 2);
1341    }
1342
1343    Err(TransformError::DecodeFailed(
1344        "webp file is missing an image chunk".to_string(),
1345    ))
1346}
1347
1348fn sniff_webp_vp8x(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1349    if bytes.len() < 10 {
1350        return Err(TransformError::DecodeFailed(
1351            "webp VP8X chunk is too short".to_string(),
1352        ));
1353    }
1354
1355    let flags = bytes[0];
1356    let width = read_u24_le(&bytes[4..7])? + 1;
1357    let height = read_u24_le(&bytes[7..10])? + 1;
1358    let has_alpha = Some(flags & 0b0001_0000 != 0);
1359
1360    Ok(ArtifactMetadata {
1361        width: Some(width),
1362        height: Some(height),
1363        frame_count: 1,
1364        duration: None,
1365        has_alpha,
1366    })
1367}
1368
1369fn sniff_webp_vp8(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1370    if bytes.len() < 10 {
1371        return Err(TransformError::DecodeFailed(
1372            "webp VP8 chunk is too short".to_string(),
1373        ));
1374    }
1375
1376    if bytes[3..6] != [0x9D, 0x01, 0x2A] {
1377        return Err(TransformError::DecodeFailed(
1378            "webp VP8 chunk has an invalid start code".to_string(),
1379        ));
1380    }
1381
1382    let width = (read_u16_le(&bytes[6..8])? & 0x3FFF) as u32;
1383    let height = (read_u16_le(&bytes[8..10])? & 0x3FFF) as u32;
1384
1385    Ok(ArtifactMetadata {
1386        width: Some(width),
1387        height: Some(height),
1388        frame_count: 1,
1389        duration: None,
1390        has_alpha: Some(false),
1391    })
1392}
1393
1394fn sniff_webp_vp8l(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1395    if bytes.len() < 5 {
1396        return Err(TransformError::DecodeFailed(
1397            "webp VP8L chunk is too short".to_string(),
1398        ));
1399    }
1400
1401    if bytes[0] != 0x2F {
1402        return Err(TransformError::DecodeFailed(
1403            "webp VP8L chunk has an invalid signature".to_string(),
1404        ));
1405    }
1406
1407    let bits = read_u32_le(&bytes[1..5])?;
1408    let width = (bits & 0x3FFF) + 1;
1409    let height = ((bits >> 14) & 0x3FFF) + 1;
1410
1411    Ok(ArtifactMetadata {
1412        width: Some(width),
1413        height: Some(height),
1414        frame_count: 1,
1415        duration: None,
1416        has_alpha: None,
1417    })
1418}
1419
1420fn sniff_avif(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1421    if bytes.len() < 16 {
1422        return Err(TransformError::DecodeFailed(
1423            "avif file is too short".to_string(),
1424        ));
1425    }
1426
1427    if !has_avif_brand(&bytes[8..]) {
1428        return Err(TransformError::DecodeFailed(
1429            "avif file is missing a compatible AVIF brand".to_string(),
1430        ));
1431    }
1432
1433    let inspection = inspect_avif_container(bytes)?;
1434
1435    Ok(ArtifactMetadata {
1436        width: inspection.dimensions.map(|(width, _)| width),
1437        height: inspection.dimensions.map(|(_, height)| height),
1438        frame_count: 1,
1439        duration: None,
1440        has_alpha: inspection.has_alpha(),
1441    })
1442}
1443
1444fn has_avif_brand(bytes: &[u8]) -> bool {
1445    if bytes.len() < 8 {
1446        return false;
1447    }
1448
1449    if is_avif_brand(&bytes[0..4]) {
1450        return true;
1451    }
1452
1453    let mut offset = 8;
1454    while offset + 4 <= bytes.len() {
1455        if is_avif_brand(&bytes[offset..offset + 4]) {
1456            return true;
1457        }
1458        offset += 4;
1459    }
1460
1461    false
1462}
1463
1464fn is_avif_brand(bytes: &[u8]) -> bool {
1465    matches!(bytes, b"avif" | b"avis")
1466}
1467
1468const AVIF_ALPHA_AUX_TYPE: &[u8] = b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha";
1469
1470#[derive(Debug, Default)]
1471struct AvifInspection {
1472    dimensions: Option<(u32, u32)>,
1473    saw_structured_meta: bool,
1474    found_alpha_item: bool,
1475}
1476
1477impl AvifInspection {
1478    fn has_alpha(&self) -> Option<bool> {
1479        if self.saw_structured_meta {
1480            Some(self.found_alpha_item)
1481        } else {
1482            None
1483        }
1484    }
1485}
1486
1487fn inspect_avif_container(bytes: &[u8]) -> Result<AvifInspection, TransformError> {
1488    let mut inspection = AvifInspection::default();
1489    inspect_avif_boxes(bytes, &mut inspection)?;
1490    Ok(inspection)
1491}
1492
1493fn inspect_avif_boxes(bytes: &[u8], inspection: &mut AvifInspection) -> Result<(), TransformError> {
1494    let mut offset = 0;
1495
1496    while offset + 8 <= bytes.len() {
1497        let (box_type, payload, next_offset) = parse_mp4_box(bytes, offset)?;
1498
1499        match box_type {
1500            b"meta" | b"iref" => {
1501                inspection.saw_structured_meta = true;
1502                if payload.len() < 4 {
1503                    return Err(TransformError::DecodeFailed(format!(
1504                        "{} box is too short",
1505                        String::from_utf8_lossy(box_type)
1506                    )));
1507                }
1508                inspect_avif_boxes(&payload[4..], inspection)?;
1509            }
1510            b"iprp" | b"ipco" => {
1511                inspection.saw_structured_meta = true;
1512                inspect_avif_boxes(payload, inspection)?;
1513            }
1514            b"ispe" => {
1515                inspection.saw_structured_meta = true;
1516                if inspection.dimensions.is_none() {
1517                    inspection.dimensions = Some(parse_avif_ispe(payload)?);
1518                }
1519            }
1520            b"auxC" => {
1521                inspection.saw_structured_meta = true;
1522                if avif_auxc_declares_alpha(payload)? {
1523                    inspection.found_alpha_item = true;
1524                }
1525            }
1526            b"auxl" => {
1527                inspection.saw_structured_meta = true;
1528                inspection.found_alpha_item = true;
1529            }
1530            _ => {}
1531        }
1532
1533        offset = next_offset;
1534    }
1535
1536    if offset != bytes.len() {
1537        return Err(TransformError::DecodeFailed(
1538            "avif box payload has trailing bytes".to_string(),
1539        ));
1540    }
1541
1542    Ok(())
1543}
1544
1545fn parse_mp4_box(bytes: &[u8], offset: usize) -> Result<(&[u8; 4], &[u8], usize), TransformError> {
1546    if offset + 8 > bytes.len() {
1547        return Err(TransformError::DecodeFailed(
1548            "mp4 box header is truncated".to_string(),
1549        ));
1550    }
1551
1552    let size = read_u32_be(&bytes[offset..offset + 4])?;
1553    let box_type = bytes[offset + 4..offset + 8]
1554        .try_into()
1555        .map_err(|_| TransformError::DecodeFailed("expected 4-byte box type".to_string()))?;
1556    let mut header_len = 8_usize;
1557    let end = match size {
1558        0 => bytes.len(),
1559        1 => {
1560            if offset + 16 > bytes.len() {
1561                return Err(TransformError::DecodeFailed(
1562                    "extended mp4 box header is truncated".to_string(),
1563                ));
1564            }
1565            header_len = 16;
1566            let extended_size = read_u64_be(&bytes[offset + 8..offset + 16])?;
1567            usize::try_from(extended_size)
1568                .map_err(|_| TransformError::DecodeFailed("mp4 box is too large".to_string()))?
1569        }
1570        _ => size as usize,
1571    };
1572
1573    if end < header_len {
1574        return Err(TransformError::DecodeFailed(
1575            "mp4 box size is smaller than its header".to_string(),
1576        ));
1577    }
1578
1579    let box_end = offset
1580        .checked_add(end)
1581        .ok_or_else(|| TransformError::DecodeFailed("mp4 box is too large".to_string()))?;
1582    if box_end > bytes.len() {
1583        return Err(TransformError::DecodeFailed(
1584            "mp4 box exceeds file length".to_string(),
1585        ));
1586    }
1587
1588    Ok((box_type, &bytes[offset + header_len..box_end], box_end))
1589}
1590
1591fn parse_avif_ispe(bytes: &[u8]) -> Result<(u32, u32), TransformError> {
1592    if bytes.len() < 12 {
1593        return Err(TransformError::DecodeFailed(
1594            "avif ispe box is too short".to_string(),
1595        ));
1596    }
1597
1598    let width = read_u32_be(&bytes[4..8])?;
1599    let height = read_u32_be(&bytes[8..12])?;
1600    Ok((width, height))
1601}
1602
1603fn avif_auxc_declares_alpha(bytes: &[u8]) -> Result<bool, TransformError> {
1604    if bytes.len() < 5 {
1605        return Err(TransformError::DecodeFailed(
1606            "avif auxC box is too short".to_string(),
1607        ));
1608    }
1609
1610    let urn = &bytes[4..];
1611    Ok(urn
1612        .strip_suffix(&[0])
1613        .is_some_and(|urn| urn == AVIF_ALPHA_AUX_TYPE))
1614}
1615
1616fn is_jpeg_sof_marker(marker: u8) -> bool {
1617    matches!(
1618        marker,
1619        0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
1620    )
1621}
1622
1623fn read_u16_be(bytes: &[u8]) -> Result<u16, TransformError> {
1624    let array: [u8; 2] = bytes
1625        .try_into()
1626        .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
1627    Ok(u16::from_be_bytes(array))
1628}
1629
1630fn read_u16_le(bytes: &[u8]) -> Result<u16, TransformError> {
1631    let array: [u8; 2] = bytes
1632        .try_into()
1633        .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
1634    Ok(u16::from_le_bytes(array))
1635}
1636
1637fn read_u24_le(bytes: &[u8]) -> Result<u32, TransformError> {
1638    if bytes.len() != 3 {
1639        return Err(TransformError::DecodeFailed("expected 3 bytes".to_string()));
1640    }
1641
1642    Ok(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16))
1643}
1644
1645fn read_u32_be(bytes: &[u8]) -> Result<u32, TransformError> {
1646    let array: [u8; 4] = bytes
1647        .try_into()
1648        .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
1649    Ok(u32::from_be_bytes(array))
1650}
1651
1652fn read_u32_le(bytes: &[u8]) -> Result<u32, TransformError> {
1653    let array: [u8; 4] = bytes
1654        .try_into()
1655        .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
1656    Ok(u32::from_le_bytes(array))
1657}
1658
1659fn read_u64_be(bytes: &[u8]) -> Result<u64, TransformError> {
1660    let array: [u8; 8] = bytes
1661        .try_into()
1662        .map_err(|_| TransformError::DecodeFailed("expected 8 bytes".to_string()))?;
1663    Ok(u64::from_be_bytes(array))
1664}
1665
1666#[cfg(test)]
1667mod tests {
1668    use super::{
1669        Artifact, ArtifactMetadata, Fit, MediaType, MetadataPolicy, Position, RawArtifact, Rgba8,
1670        Rotation, TransformError, TransformOptions, TransformRequest, sniff_artifact,
1671    };
1672    use image::codecs::avif::AvifEncoder;
1673    use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
1674
1675    fn jpeg_artifact() -> Artifact {
1676        Artifact::new(vec![1, 2, 3], MediaType::Jpeg, ArtifactMetadata::default())
1677    }
1678
1679    fn png_bytes(width: u32, height: u32, color_type: u8) -> Vec<u8> {
1680        let mut bytes = Vec::new();
1681        bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
1682        bytes.extend_from_slice(&13_u32.to_be_bytes());
1683        bytes.extend_from_slice(b"IHDR");
1684        bytes.extend_from_slice(&width.to_be_bytes());
1685        bytes.extend_from_slice(&height.to_be_bytes());
1686        bytes.push(8);
1687        bytes.push(color_type);
1688        bytes.push(0);
1689        bytes.push(0);
1690        bytes.push(0);
1691        bytes.extend_from_slice(&0_u32.to_be_bytes());
1692        bytes
1693    }
1694
1695    fn jpeg_bytes(width: u16, height: u16) -> Vec<u8> {
1696        let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
1697        bytes.extend_from_slice(&[0; 14]);
1698        bytes.extend_from_slice(&[
1699            0xFF,
1700            0xC0,
1701            0x00,
1702            0x11,
1703            0x08,
1704            (height >> 8) as u8,
1705            height as u8,
1706            (width >> 8) as u8,
1707            width as u8,
1708            0x03,
1709            0x01,
1710            0x11,
1711            0x00,
1712            0x02,
1713            0x11,
1714            0x00,
1715            0x03,
1716            0x11,
1717            0x00,
1718        ]);
1719        bytes.extend_from_slice(&[0xFF, 0xD9]);
1720        bytes
1721    }
1722
1723    fn webp_vp8x_bytes(width: u32, height: u32, flags: u8) -> Vec<u8> {
1724        let width_minus_one = width - 1;
1725        let height_minus_one = height - 1;
1726        let mut bytes = Vec::new();
1727        bytes.extend_from_slice(b"RIFF");
1728        bytes.extend_from_slice(&30_u32.to_le_bytes());
1729        bytes.extend_from_slice(b"WEBP");
1730        bytes.extend_from_slice(b"VP8X");
1731        bytes.extend_from_slice(&10_u32.to_le_bytes());
1732        bytes.push(flags);
1733        bytes.extend_from_slice(&[0, 0, 0]);
1734        bytes.extend_from_slice(&[
1735            (width_minus_one & 0xFF) as u8,
1736            ((width_minus_one >> 8) & 0xFF) as u8,
1737            ((width_minus_one >> 16) & 0xFF) as u8,
1738        ]);
1739        bytes.extend_from_slice(&[
1740            (height_minus_one & 0xFF) as u8,
1741            ((height_minus_one >> 8) & 0xFF) as u8,
1742            ((height_minus_one >> 16) & 0xFF) as u8,
1743        ]);
1744        bytes
1745    }
1746
1747    fn webp_vp8l_bytes(width: u32, height: u32) -> Vec<u8> {
1748        let packed = (width - 1) | ((height - 1) << 14);
1749        let mut bytes = Vec::new();
1750        bytes.extend_from_slice(b"RIFF");
1751        bytes.extend_from_slice(&17_u32.to_le_bytes());
1752        bytes.extend_from_slice(b"WEBP");
1753        bytes.extend_from_slice(b"VP8L");
1754        bytes.extend_from_slice(&5_u32.to_le_bytes());
1755        bytes.push(0x2F);
1756        bytes.extend_from_slice(&packed.to_le_bytes());
1757        bytes.push(0);
1758        bytes
1759    }
1760
1761    fn avif_bytes() -> Vec<u8> {
1762        let mut bytes = Vec::new();
1763        bytes.extend_from_slice(&24_u32.to_be_bytes());
1764        bytes.extend_from_slice(b"ftyp");
1765        bytes.extend_from_slice(b"avif");
1766        bytes.extend_from_slice(&0_u32.to_be_bytes());
1767        bytes.extend_from_slice(b"mif1");
1768        bytes.extend_from_slice(b"avif");
1769        bytes
1770    }
1771
1772    fn encoded_avif_bytes(width: u32, height: u32, fill: Rgba<u8>) -> Vec<u8> {
1773        let image = RgbaImage::from_pixel(width, height, fill);
1774        let mut bytes = Vec::new();
1775        AvifEncoder::new(&mut bytes)
1776            .write_image(&image, width, height, ColorType::Rgba8.into())
1777            .expect("encode avif");
1778        bytes
1779    }
1780
1781    #[test]
1782    fn default_transform_options_match_documented_defaults() {
1783        let options = TransformOptions::default();
1784
1785        assert_eq!(options.width, None);
1786        assert_eq!(options.height, None);
1787        assert_eq!(options.fit, None);
1788        assert_eq!(options.position, None);
1789        assert_eq!(options.format, None);
1790        assert_eq!(options.quality, None);
1791        assert_eq!(options.rotate, Rotation::Deg0);
1792        assert!(options.auto_orient);
1793        assert!(options.strip_metadata);
1794        assert!(!options.preserve_exif);
1795    }
1796
1797    #[test]
1798    fn media_type_helpers_report_expected_values() {
1799        assert_eq!(MediaType::Jpeg.as_name(), "jpeg");
1800        assert_eq!(MediaType::Jpeg.as_mime(), "image/jpeg");
1801        assert!(MediaType::Webp.is_lossy());
1802        assert!(!MediaType::Png.is_lossy());
1803    }
1804
1805    #[test]
1806    fn media_type_parsing_accepts_documented_names() {
1807        assert_eq!("jpeg".parse::<MediaType>(), Ok(MediaType::Jpeg));
1808        assert_eq!("jpg".parse::<MediaType>(), Ok(MediaType::Jpeg));
1809        assert_eq!("png".parse::<MediaType>(), Ok(MediaType::Png));
1810        assert!("gif".parse::<MediaType>().is_err());
1811    }
1812
1813    #[test]
1814    fn fit_position_rotation_and_color_parsing_work() {
1815        assert_eq!("cover".parse::<Fit>(), Ok(Fit::Cover));
1816        assert_eq!(
1817            "bottom-right".parse::<Position>(),
1818            Ok(Position::BottomRight)
1819        );
1820        assert_eq!("270".parse::<Rotation>(), Ok(Rotation::Deg270));
1821        assert_eq!(
1822            Rgba8::from_hex("AABBCCDD"),
1823            Ok(Rgba8 {
1824                r: 0xAA,
1825                g: 0xBB,
1826                b: 0xCC,
1827                a: 0xDD
1828            })
1829        );
1830        assert!(Rgba8::from_hex("AABB").is_err());
1831    }
1832
1833    #[test]
1834    fn normalize_defaults_fit_and_position_for_bounded_resize() {
1835        let normalized = TransformOptions {
1836            width: Some(1200),
1837            height: Some(630),
1838            ..TransformOptions::default()
1839        }
1840        .normalize(MediaType::Jpeg)
1841        .expect("normalize bounded resize");
1842
1843        assert_eq!(normalized.fit, Some(Fit::Contain));
1844        assert_eq!(normalized.position, Position::Center);
1845        assert_eq!(normalized.format, MediaType::Jpeg);
1846        assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
1847    }
1848
1849    #[test]
1850    fn normalize_uses_requested_fit_and_output_format() {
1851        let normalized = TransformOptions {
1852            width: Some(320),
1853            height: Some(320),
1854            fit: Some(Fit::Cover),
1855            position: Some(Position::BottomRight),
1856            format: Some(MediaType::Webp),
1857            quality: Some(70),
1858            strip_metadata: false,
1859            preserve_exif: true,
1860            ..TransformOptions::default()
1861        }
1862        .normalize(MediaType::Jpeg)
1863        .expect("normalize explicit values");
1864
1865        assert_eq!(normalized.fit, Some(Fit::Cover));
1866        assert_eq!(normalized.position, Position::BottomRight);
1867        assert_eq!(normalized.format, MediaType::Webp);
1868        assert_eq!(normalized.quality, Some(70));
1869        assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveExif);
1870    }
1871
1872    #[test]
1873    fn normalize_can_keep_all_metadata() {
1874        let normalized = TransformOptions {
1875            strip_metadata: false,
1876            ..TransformOptions::default()
1877        }
1878        .normalize(MediaType::Jpeg)
1879        .expect("normalize keep metadata");
1880
1881        assert_eq!(normalized.metadata_policy, MetadataPolicy::KeepAll);
1882    }
1883
1884    #[test]
1885    fn normalize_keeps_fit_none_when_resize_is_not_bounded() {
1886        let normalized = TransformOptions {
1887            width: Some(500),
1888            ..TransformOptions::default()
1889        }
1890        .normalize(MediaType::Jpeg)
1891        .expect("normalize unbounded resize");
1892
1893        assert_eq!(normalized.fit, None);
1894        assert_eq!(normalized.position, Position::Center);
1895    }
1896
1897    #[test]
1898    fn normalize_rejects_zero_dimensions() {
1899        let err = TransformOptions {
1900            width: Some(0),
1901            ..TransformOptions::default()
1902        }
1903        .normalize(MediaType::Jpeg)
1904        .expect_err("zero width should fail");
1905
1906        assert_eq!(
1907            err,
1908            TransformError::InvalidOptions("width must be greater than zero".to_string())
1909        );
1910    }
1911
1912    #[test]
1913    fn normalize_rejects_fit_without_both_dimensions() {
1914        let err = TransformOptions {
1915            width: Some(300),
1916            fit: Some(Fit::Contain),
1917            ..TransformOptions::default()
1918        }
1919        .normalize(MediaType::Jpeg)
1920        .expect_err("fit without bounded resize should fail");
1921
1922        assert_eq!(
1923            err,
1924            TransformError::InvalidOptions("fit requires both width and height".to_string())
1925        );
1926    }
1927
1928    #[test]
1929    fn normalize_rejects_position_without_both_dimensions() {
1930        let err = TransformOptions {
1931            height: Some(300),
1932            position: Some(Position::Top),
1933            ..TransformOptions::default()
1934        }
1935        .normalize(MediaType::Jpeg)
1936        .expect_err("position without bounded resize should fail");
1937
1938        assert_eq!(
1939            err,
1940            TransformError::InvalidOptions("position requires both width and height".to_string())
1941        );
1942    }
1943
1944    #[test]
1945    fn normalize_rejects_quality_for_lossless_output() {
1946        let err = TransformOptions {
1947            format: Some(MediaType::Png),
1948            quality: Some(80),
1949            ..TransformOptions::default()
1950        }
1951        .normalize(MediaType::Jpeg)
1952        .expect_err("quality for png should fail");
1953
1954        assert_eq!(
1955            err,
1956            TransformError::InvalidOptions("quality requires a lossy output format".to_string())
1957        );
1958    }
1959
1960    #[test]
1961    fn normalize_rejects_zero_quality() {
1962        let err = TransformOptions {
1963            quality: Some(0),
1964            ..TransformOptions::default()
1965        }
1966        .normalize(MediaType::Jpeg)
1967        .expect_err("zero quality should fail");
1968
1969        assert_eq!(
1970            err,
1971            TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
1972        );
1973    }
1974
1975    #[test]
1976    fn normalize_rejects_quality_above_one_hundred() {
1977        let err = TransformOptions {
1978            quality: Some(101),
1979            ..TransformOptions::default()
1980        }
1981        .normalize(MediaType::Jpeg)
1982        .expect_err("quality above one hundred should fail");
1983
1984        assert_eq!(
1985            err,
1986            TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
1987        );
1988    }
1989
1990    #[test]
1991    fn normalize_rejects_preserve_exif_when_metadata_is_stripped() {
1992        let err = TransformOptions {
1993            preserve_exif: true,
1994            ..TransformOptions::default()
1995        }
1996        .normalize(MediaType::Jpeg)
1997        .expect_err("preserve_exif should require metadata retention");
1998
1999        assert_eq!(
2000            err,
2001            TransformError::InvalidOptions(
2002                "preserve_exif requires strip_metadata to be false".to_string()
2003            )
2004        );
2005    }
2006
2007    #[test]
2008    fn transform_request_normalize_uses_input_media_type_as_default_output() {
2009        let request = TransformRequest::new(jpeg_artifact(), TransformOptions::default());
2010        let normalized = request.normalize().expect("normalize request");
2011
2012        assert_eq!(normalized.input.media_type, MediaType::Jpeg);
2013        assert_eq!(normalized.options.format, MediaType::Jpeg);
2014        assert_eq!(normalized.options.metadata_policy, MetadataPolicy::StripAll);
2015    }
2016
2017    #[test]
2018    fn sniff_artifact_detects_png_dimensions_and_alpha() {
2019        let artifact =
2020            sniff_artifact(RawArtifact::new(png_bytes(64, 32, 6), None)).expect("sniff png");
2021
2022        assert_eq!(artifact.media_type, MediaType::Png);
2023        assert_eq!(artifact.metadata.width, Some(64));
2024        assert_eq!(artifact.metadata.height, Some(32));
2025        assert_eq!(artifact.metadata.has_alpha, Some(true));
2026    }
2027
2028    #[test]
2029    fn sniff_artifact_detects_jpeg_dimensions() {
2030        let artifact =
2031            sniff_artifact(RawArtifact::new(jpeg_bytes(320, 240), None)).expect("sniff jpeg");
2032
2033        assert_eq!(artifact.media_type, MediaType::Jpeg);
2034        assert_eq!(artifact.metadata.width, Some(320));
2035        assert_eq!(artifact.metadata.height, Some(240));
2036        assert_eq!(artifact.metadata.has_alpha, Some(false));
2037    }
2038
2039    #[test]
2040    fn sniff_artifact_detects_webp_vp8x_dimensions() {
2041        let artifact = sniff_artifact(RawArtifact::new(
2042            webp_vp8x_bytes(800, 600, 0b0001_0000),
2043            None,
2044        ))
2045        .expect("sniff webp vp8x");
2046
2047        assert_eq!(artifact.media_type, MediaType::Webp);
2048        assert_eq!(artifact.metadata.width, Some(800));
2049        assert_eq!(artifact.metadata.height, Some(600));
2050        assert_eq!(artifact.metadata.has_alpha, Some(true));
2051    }
2052
2053    #[test]
2054    fn sniff_artifact_detects_webp_vp8l_dimensions() {
2055        let artifact = sniff_artifact(RawArtifact::new(webp_vp8l_bytes(123, 77), None))
2056            .expect("sniff webp vp8l");
2057
2058        assert_eq!(artifact.media_type, MediaType::Webp);
2059        assert_eq!(artifact.metadata.width, Some(123));
2060        assert_eq!(artifact.metadata.height, Some(77));
2061    }
2062
2063    #[test]
2064    fn sniff_artifact_detects_avif_brand() {
2065        let artifact = sniff_artifact(RawArtifact::new(avif_bytes(), None)).expect("sniff avif");
2066
2067        assert_eq!(artifact.media_type, MediaType::Avif);
2068        assert_eq!(artifact.metadata, ArtifactMetadata::default());
2069    }
2070
2071    #[test]
2072    fn sniff_artifact_detects_avif_dimensions_and_alpha() {
2073        let artifact = sniff_artifact(RawArtifact::new(
2074            encoded_avif_bytes(7, 5, Rgba([10, 20, 30, 0])),
2075            None,
2076        ))
2077        .expect("sniff avif with alpha");
2078
2079        assert_eq!(artifact.media_type, MediaType::Avif);
2080        assert_eq!(artifact.metadata.width, Some(7));
2081        assert_eq!(artifact.metadata.height, Some(5));
2082        assert_eq!(artifact.metadata.has_alpha, Some(true));
2083    }
2084
2085    #[test]
2086    fn sniff_artifact_detects_opaque_avif_without_alpha_item() {
2087        let artifact = sniff_artifact(RawArtifact::new(
2088            encoded_avif_bytes(9, 4, Rgba([10, 20, 30, 255])),
2089            None,
2090        ))
2091        .expect("sniff opaque avif");
2092
2093        assert_eq!(artifact.media_type, MediaType::Avif);
2094        assert_eq!(artifact.metadata.width, Some(9));
2095        assert_eq!(artifact.metadata.height, Some(4));
2096        assert_eq!(artifact.metadata.has_alpha, Some(false));
2097    }
2098
2099    #[test]
2100    fn sniff_artifact_rejects_declared_media_type_mismatch() {
2101        let err = sniff_artifact(RawArtifact::new(png_bytes(8, 8, 2), Some(MediaType::Jpeg)))
2102            .expect_err("declared mismatch should fail");
2103
2104        assert_eq!(
2105            err,
2106            TransformError::InvalidInput(
2107                "declared media type does not match detected media type".to_string()
2108            )
2109        );
2110    }
2111
2112    #[test]
2113    fn sniff_artifact_rejects_unknown_signatures() {
2114        let err =
2115            sniff_artifact(RawArtifact::new(vec![1, 2, 3, 4], None)).expect_err("unknown bytes");
2116
2117        assert_eq!(
2118            err,
2119            TransformError::UnsupportedInputMediaType("unknown file signature".to_string())
2120        );
2121    }
2122
2123    #[test]
2124    fn sniff_artifact_rejects_invalid_png_structure() {
2125        let err = sniff_artifact(RawArtifact::new(b"\x89PNG\r\n\x1a\nbroken".to_vec(), None))
2126            .expect_err("broken png should fail");
2127
2128        assert_eq!(
2129            err,
2130            TransformError::DecodeFailed("png file is too short".to_string())
2131        );
2132    }
2133
2134    #[test]
2135    fn sniff_artifact_detects_bmp_dimensions() {
2136        // Build a minimal BMP with BITMAPINFOHEADER (40 bytes DIB header).
2137        // File header: 14 bytes, DIB header: 40 bytes minimum.
2138        let mut bmp = Vec::new();
2139        // BM signature
2140        bmp.extend_from_slice(b"BM");
2141        // File size (placeholder)
2142        bmp.extend_from_slice(&0u32.to_le_bytes());
2143        // Reserved
2144        bmp.extend_from_slice(&0u32.to_le_bytes());
2145        // Pixel data offset (14 + 40 = 54)
2146        bmp.extend_from_slice(&54u32.to_le_bytes());
2147        // DIB header size (BITMAPINFOHEADER = 40)
2148        bmp.extend_from_slice(&40u32.to_le_bytes());
2149        // Width = 8
2150        bmp.extend_from_slice(&8u32.to_le_bytes());
2151        // Height = 6
2152        bmp.extend_from_slice(&6i32.to_le_bytes());
2153        // Planes = 1
2154        bmp.extend_from_slice(&1u16.to_le_bytes());
2155        // Bits per pixel = 24
2156        bmp.extend_from_slice(&24u16.to_le_bytes());
2157        // Padding to reach minimum sniff length
2158        bmp.resize(54, 0);
2159
2160        let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
2161        assert_eq!(artifact.media_type, MediaType::Bmp);
2162        assert_eq!(artifact.metadata.width, Some(8));
2163        assert_eq!(artifact.metadata.height, Some(6));
2164        assert_eq!(artifact.metadata.has_alpha, Some(false));
2165    }
2166
2167    #[test]
2168    fn sniff_artifact_detects_bmp_32bit_alpha() {
2169        let mut bmp = Vec::new();
2170        bmp.extend_from_slice(b"BM");
2171        bmp.extend_from_slice(&0u32.to_le_bytes());
2172        bmp.extend_from_slice(&0u32.to_le_bytes());
2173        bmp.extend_from_slice(&54u32.to_le_bytes());
2174        bmp.extend_from_slice(&40u32.to_le_bytes());
2175        // Width = 4
2176        bmp.extend_from_slice(&4u32.to_le_bytes());
2177        // Height = 4
2178        bmp.extend_from_slice(&4i32.to_le_bytes());
2179        // Planes = 1
2180        bmp.extend_from_slice(&1u16.to_le_bytes());
2181        // Bits per pixel = 32 (has alpha)
2182        bmp.extend_from_slice(&32u16.to_le_bytes());
2183        bmp.resize(54, 0);
2184
2185        let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
2186        assert_eq!(artifact.media_type, MediaType::Bmp);
2187        assert_eq!(artifact.metadata.has_alpha, Some(true));
2188    }
2189
2190    #[test]
2191    fn sniff_artifact_rejects_too_short_bmp() {
2192        // "BM" + enough padding to pass is_bmp (>= 26 bytes) but not sniff_bmp (>= 30)
2193        let mut data = b"BM".to_vec();
2194        data.resize(27, 0);
2195        let err =
2196            sniff_artifact(RawArtifact::new(data, None)).expect_err("too-short BMP should fail");
2197
2198        assert_eq!(
2199            err,
2200            TransformError::DecodeFailed("bmp file is too short".to_string())
2201        );
2202    }
2203
2204    #[test]
2205    fn normalize_rejects_blur_sigma_below_minimum() {
2206        let err = TransformOptions {
2207            blur: Some(0.0),
2208            ..TransformOptions::default()
2209        }
2210        .normalize(MediaType::Jpeg)
2211        .expect_err("blur sigma 0.0 should be rejected");
2212
2213        assert_eq!(
2214            err,
2215            TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
2216        );
2217    }
2218
2219    #[test]
2220    fn normalize_rejects_blur_sigma_above_maximum() {
2221        let err = TransformOptions {
2222            blur: Some(100.1),
2223            ..TransformOptions::default()
2224        }
2225        .normalize(MediaType::Jpeg)
2226        .expect_err("blur sigma 100.1 should be rejected");
2227
2228        assert_eq!(
2229            err,
2230            TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
2231        );
2232    }
2233
2234    #[test]
2235    fn normalize_accepts_blur_sigma_at_boundaries() {
2236        let opts_min = TransformOptions {
2237            blur: Some(0.1),
2238            ..TransformOptions::default()
2239        }
2240        .normalize(MediaType::Jpeg)
2241        .expect("blur sigma 0.1 should be accepted");
2242        assert_eq!(opts_min.blur, Some(0.1));
2243
2244        let opts_max = TransformOptions {
2245            blur: Some(100.0),
2246            ..TransformOptions::default()
2247        }
2248        .normalize(MediaType::Jpeg)
2249        .expect("blur sigma 100.0 should be accepted");
2250        assert_eq!(opts_max.blur, Some(100.0));
2251    }
2252
2253    #[test]
2254    fn normalize_rejects_sharpen_sigma_below_minimum() {
2255        let err = TransformOptions {
2256            sharpen: Some(0.0),
2257            ..TransformOptions::default()
2258        }
2259        .normalize(MediaType::Jpeg)
2260        .expect_err("sharpen sigma 0.0 should be rejected");
2261
2262        assert_eq!(
2263            err,
2264            TransformError::InvalidOptions(
2265                "sharpen sigma must be between 0.1 and 100.0".to_string()
2266            )
2267        );
2268    }
2269
2270    #[test]
2271    fn normalize_rejects_sharpen_sigma_above_maximum() {
2272        let err = TransformOptions {
2273            sharpen: Some(100.1),
2274            ..TransformOptions::default()
2275        }
2276        .normalize(MediaType::Jpeg)
2277        .expect_err("sharpen sigma 100.1 should be rejected");
2278
2279        assert_eq!(
2280            err,
2281            TransformError::InvalidOptions(
2282                "sharpen sigma must be between 0.1 and 100.0".to_string()
2283            )
2284        );
2285    }
2286
2287    #[test]
2288    fn normalize_accepts_sharpen_sigma_at_boundaries() {
2289        let opts_min = TransformOptions {
2290            sharpen: Some(0.1),
2291            ..TransformOptions::default()
2292        }
2293        .normalize(MediaType::Jpeg)
2294        .expect("sharpen sigma 0.1 should be accepted");
2295        assert_eq!(opts_min.sharpen, Some(0.1));
2296
2297        let opts_max = TransformOptions {
2298            sharpen: Some(100.0),
2299            ..TransformOptions::default()
2300        }
2301        .normalize(MediaType::Jpeg)
2302        .expect("sharpen sigma 100.0 should be accepted");
2303        assert_eq!(opts_max.sharpen, Some(100.0));
2304    }
2305
2306    #[test]
2307    fn validate_watermark_rejects_zero_opacity() {
2308        let wm = super::WatermarkInput {
2309            image: jpeg_artifact(),
2310            position: Position::BottomRight,
2311            opacity: 0,
2312            margin: 10,
2313        };
2314        let err = super::validate_watermark(&wm).expect_err("opacity 0 should be rejected");
2315        assert_eq!(
2316            err,
2317            TransformError::InvalidOptions(
2318                "watermark opacity must be between 1 and 100".to_string()
2319            )
2320        );
2321    }
2322
2323    #[test]
2324    fn validate_watermark_rejects_opacity_above_100() {
2325        let wm = super::WatermarkInput {
2326            image: jpeg_artifact(),
2327            position: Position::BottomRight,
2328            opacity: 101,
2329            margin: 10,
2330        };
2331        let err = super::validate_watermark(&wm).expect_err("opacity 101 should be rejected");
2332        assert_eq!(
2333            err,
2334            TransformError::InvalidOptions(
2335                "watermark opacity must be between 1 and 100".to_string()
2336            )
2337        );
2338    }
2339
2340    #[test]
2341    fn validate_watermark_rejects_svg_image() {
2342        let wm = super::WatermarkInput {
2343            image: Artifact::new(vec![1], MediaType::Svg, ArtifactMetadata::default()),
2344            position: Position::BottomRight,
2345            opacity: 50,
2346            margin: 10,
2347        };
2348        let err = super::validate_watermark(&wm).expect_err("SVG watermark should be rejected");
2349        assert_eq!(
2350            err,
2351            TransformError::InvalidOptions("watermark image must be a raster format".to_string())
2352        );
2353    }
2354
2355    #[test]
2356    fn validate_watermark_accepts_valid_input() {
2357        let wm = super::WatermarkInput {
2358            image: jpeg_artifact(),
2359            position: Position::BottomRight,
2360            opacity: 50,
2361            margin: 10,
2362        };
2363        super::validate_watermark(&wm).expect("valid watermark should be accepted");
2364    }
2365
2366    #[test]
2367    fn crop_region_from_str_valid() {
2368        use super::CropRegion;
2369        let crop: CropRegion = "10,20,100,200".parse().expect("valid crop");
2370        assert_eq!(crop.x, 10);
2371        assert_eq!(crop.y, 20);
2372        assert_eq!(crop.width, 100);
2373        assert_eq!(crop.height, 200);
2374    }
2375
2376    #[test]
2377    fn crop_region_from_str_zero_width() {
2378        use super::CropRegion;
2379        let err = "10,20,0,200"
2380            .parse::<CropRegion>()
2381            .expect_err("zero width should fail");
2382        assert!(err.contains("greater than zero"), "unexpected error: {err}");
2383    }
2384
2385    #[test]
2386    fn crop_region_from_str_wrong_parts() {
2387        use super::CropRegion;
2388        let err = "10,20,100"
2389            .parse::<CropRegion>()
2390            .expect_err("three parts should fail");
2391        assert!(
2392            err.contains("four comma-separated"),
2393            "unexpected error: {err}"
2394        );
2395    }
2396
2397    #[test]
2398    fn crop_region_display() {
2399        use super::CropRegion;
2400        let crop = CropRegion {
2401            x: 1,
2402            y: 2,
2403            width: 3,
2404            height: 4,
2405        };
2406        assert_eq!(crop.to_string(), "1,2,3,4");
2407    }
2408
2409    #[test]
2410    fn normalize_rejects_zero_dimension_crop() {
2411        use super::{CropRegion, MediaType, TransformOptions};
2412        let opts = TransformOptions {
2413            crop: Some(CropRegion {
2414                x: 0,
2415                y: 0,
2416                width: 0,
2417                height: 100,
2418            }),
2419            ..TransformOptions::default()
2420        };
2421        let err = opts
2422            .normalize(MediaType::Jpeg)
2423            .expect_err("zero-width crop should fail");
2424        assert!(
2425            matches!(err, super::TransformError::InvalidOptions(_)),
2426            "unexpected error: {err:?}"
2427        );
2428    }
2429}