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