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