use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ParseError {
#[error("invalid width")]
InvalidWidth,
#[error("invalid height")]
InvalidHeight,
#[error("invalid format")]
InvalidFormat,
#[error("invalid quality")]
InvalidQuality,
#[error("invalid fit mode")]
InvalidFitMode,
#[error("invalid gravity")]
InvalidGravity,
#[error("invalid sharpen value")]
InvalidSharpen,
#[error("invalid blur value")]
InvalidBlur,
#[error("invalid dpr value")]
InvalidDpr,
#[error("invalid rotation")]
InvalidRotation,
#[error("invalid flip")]
InvalidFlip,
#[error("invalid brightness value")]
InvalidBrightness,
#[error("invalid contrast value")]
InvalidContrast,
#[error("invalid saturation value")]
InvalidSaturation,
#[error("invalid gamma value")]
InvalidGamma,
#[error("invalid background color")]
InvalidBackground,
#[error("invalid metadata mode")]
InvalidMetadata,
#[error("invalid trim value")]
InvalidTrim,
#[error("invalid anim mode")]
InvalidAnim,
#[error("invalid frame index")]
InvalidFrame,
#[error("unknown transform parameter")]
InvalidParameter,
#[error("empty parameter value")]
EmptyValue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputFormat {
#[default]
Auto,
Jpeg,
Png,
Webp,
Avif,
Gif,
}
impl OutputFormat {
pub fn content_type(self) -> &'static str {
match self {
OutputFormat::Jpeg => "image/jpeg",
OutputFormat::Png => "image/png",
OutputFormat::Webp => "image/webp",
OutputFormat::Avif => "image/avif",
OutputFormat::Gif => "image/gif",
OutputFormat::Auto => "application/octet-stream",
}
}
pub fn extension(self) -> &'static str {
match self {
OutputFormat::Jpeg => "jpg",
OutputFormat::Png => "png",
OutputFormat::Webp => "webp",
OutputFormat::Avif => "avif",
OutputFormat::Gif => "gif",
OutputFormat::Auto => "",
}
}
pub fn parse_str(s: &str) -> Result<Self, ParseError> {
match s {
"jpeg" | "jpg" => Ok(OutputFormat::Jpeg),
"png" => Ok(OutputFormat::Png),
"webp" => Ok(OutputFormat::Webp),
"avif" => Ok(OutputFormat::Avif),
"gif" => Ok(OutputFormat::Gif),
"auto" => Ok(OutputFormat::Auto),
_ => Err(ParseError::InvalidFormat),
}
}
pub fn as_str(self) -> &'static str {
match self {
OutputFormat::Jpeg => "jpeg",
OutputFormat::Png => "png",
OutputFormat::Webp => "webp",
OutputFormat::Avif => "avif",
OutputFormat::Gif => "gif",
OutputFormat::Auto => "auto",
}
}
pub fn supports_animation(self) -> bool {
matches!(self, OutputFormat::Gif | OutputFormat::Webp)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rotation {
Deg0,
Deg90,
Deg180,
Deg270,
}
impl Rotation {
pub fn parse_str(s: &str) -> Result<Self, ParseError> {
match s {
"0" => Ok(Rotation::Deg0),
"90" => Ok(Rotation::Deg90),
"180" => Ok(Rotation::Deg180),
"270" => Ok(Rotation::Deg270),
_ => Err(ParseError::InvalidRotation),
}
}
pub fn as_str(self) -> &'static str {
match self {
Rotation::Deg0 => "0",
Rotation::Deg90 => "90",
Rotation::Deg180 => "180",
Rotation::Deg270 => "270",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlipMode {
H,
V,
Hv,
}
impl FlipMode {
pub fn parse_str(s: &str) -> Result<Self, ParseError> {
match s {
"h" => Ok(FlipMode::H),
"v" => Ok(FlipMode::V),
"hv" | "vh" => Ok(FlipMode::Hv),
_ => Err(ParseError::InvalidFlip),
}
}
pub fn as_str(self) -> &'static str {
match self {
FlipMode::H => "h",
FlipMode::V => "v",
FlipMode::Hv => "hv",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MetadataMode {
#[default]
Strip,
Keep,
Copyright,
}
impl MetadataMode {
pub fn parse_str(s: &str) -> Result<Self, ParseError> {
match s {
"strip" | "none" => Ok(MetadataMode::Strip),
"keep" | "all" => Ok(MetadataMode::Keep),
"copyright" => Ok(MetadataMode::Copyright),
_ => Err(ParseError::InvalidMetadata),
}
}
pub fn as_str(self) -> &'static str {
match self {
MetadataMode::Strip => "strip",
MetadataMode::Keep => "keep",
MetadataMode::Copyright => "copyright",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnimMode {
#[default]
Auto,
Static,
Animate,
}
impl AnimMode {
pub fn parse_str(s: &str) -> Result<Self, ParseError> {
match s {
"auto" | "true" => Ok(AnimMode::Auto),
"static" | "false" => Ok(AnimMode::Static),
"animate" => Ok(AnimMode::Animate),
_ => Err(ParseError::InvalidAnim),
}
}
pub fn as_str(self) -> &'static str {
match self {
AnimMode::Auto => "auto",
AnimMode::Static => "static",
AnimMode::Animate => "animate",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FitMode {
#[default]
Contain,
Cover,
Fill,
Inside,
Outside,
Pad,
}
impl FitMode {
pub fn parse_str(s: &str) -> Result<Self, ParseError> {
match s {
"contain" => Ok(FitMode::Contain),
"cover" => Ok(FitMode::Cover),
"fill" => Ok(FitMode::Fill),
"inside" => Ok(FitMode::Inside),
"outside" => Ok(FitMode::Outside),
"pad" => Ok(FitMode::Pad),
_ => Err(ParseError::InvalidFitMode),
}
}
pub fn as_str(self) -> &'static str {
match self {
FitMode::Contain => "contain",
FitMode::Cover => "cover",
FitMode::Fill => "fill",
FitMode::Inside => "inside",
FitMode::Outside => "outside",
FitMode::Pad => "pad",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Gravity {
#[default]
Center,
North,
South,
East,
West,
Northeast,
Northwest,
Southeast,
Southwest,
Smart,
Attention,
}
impl Gravity {
pub fn parse_str(s: &str) -> Result<Self, ParseError> {
match s {
"center" | "centre" => Ok(Gravity::Center),
"north" | "n" => Ok(Gravity::North),
"south" | "s" => Ok(Gravity::South),
"east" | "e" => Ok(Gravity::East),
"west" | "w" => Ok(Gravity::West),
"northeast" | "ne" => Ok(Gravity::Northeast),
"northwest" | "nw" => Ok(Gravity::Northwest),
"southeast" | "se" => Ok(Gravity::Southeast),
"southwest" | "sw" => Ok(Gravity::Southwest),
"smart" => Ok(Gravity::Smart),
"attention" | "att" => Ok(Gravity::Attention),
_ => Err(ParseError::InvalidGravity),
}
}
pub fn as_str(self) -> &'static str {
match self {
Gravity::Center => "center",
Gravity::North => "north",
Gravity::South => "south",
Gravity::East => "east",
Gravity::West => "west",
Gravity::Northeast => "northeast",
Gravity::Northwest => "northwest",
Gravity::Southeast => "southeast",
Gravity::Southwest => "southwest",
Gravity::Smart => "smart",
Gravity::Attention => "attention",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TransformParams {
pub width: Option<u32>,
pub height: Option<u32>,
pub quality: u8,
pub format: Option<OutputFormat>,
pub fit: FitMode,
pub gravity: Gravity,
pub sharpen: Option<f32>,
pub blur: Option<f32>,
pub dpr: f32,
pub rotate: Option<Rotation>,
pub flip: Option<FlipMode>,
pub brightness: Option<f32>,
pub contrast: Option<f32>,
pub saturation: Option<f32>,
pub gamma: Option<f32>,
pub background: Option<[u8; 3]>,
pub metadata: MetadataMode,
pub trim: Option<f32>,
pub anim: AnimMode,
pub frame: Option<u32>,
}
impl Default for TransformParams {
fn default() -> Self {
Self {
width: None,
height: None,
quality: 80,
format: None,
fit: FitMode::default(),
gravity: Gravity::default(),
sharpen: None,
blur: None,
dpr: 1.0,
rotate: None,
flip: None,
brightness: None,
contrast: None,
saturation: None,
gamma: None,
background: None,
metadata: MetadataMode::default(),
trim: None,
anim: AnimMode::default(),
frame: None,
}
}
}
impl TransformParams {
pub fn effective_width(&self) -> Option<u32> {
let w = self.width?;
let result = w as f32 * self.dpr;
Some(result.min(8192.0) as u32)
}
pub fn effective_height(&self) -> Option<u32> {
let h = self.height?;
let result = h as f32 * self.dpr;
Some(result.min(8192.0) as u32)
}
pub fn validate(&self) -> Result<(), ParseError> {
if let Some(w) = self.width
&& !(1..=8192).contains(&w)
{
return Err(ParseError::InvalidWidth);
}
if let Some(h) = self.height
&& !(1..=8192).contains(&h)
{
return Err(ParseError::InvalidHeight);
}
if !(1..=100).contains(&self.quality) {
return Err(ParseError::InvalidQuality);
}
if !(1.0..=5.0).contains(&self.dpr) {
return Err(ParseError::InvalidDpr);
}
if let Some(v) = self.sharpen
&& !(0.0..=10.0).contains(&v)
{
return Err(ParseError::InvalidSharpen);
}
if let Some(v) = self.blur
&& !(0.1..=250.0).contains(&v)
{
return Err(ParseError::InvalidBlur);
}
if let Some(v) = self.brightness
&& !(0.0..=2.0).contains(&v)
{
return Err(ParseError::InvalidBrightness);
}
if let Some(v) = self.contrast
&& !(0.0..=2.0).contains(&v)
{
return Err(ParseError::InvalidContrast);
}
if let Some(v) = self.saturation
&& !(0.0..=2.0).contains(&v)
{
return Err(ParseError::InvalidSaturation);
}
if let Some(v) = self.gamma
&& !(0.1..=10.0).contains(&v)
{
return Err(ParseError::InvalidGamma);
}
if let Some(v) = self.trim
&& !(1.0..=100.0).contains(&v)
{
return Err(ParseError::InvalidTrim);
}
if let Some(f) = self.frame
&& f > 999
{
return Err(ParseError::InvalidFrame);
}
Ok(())
}
pub fn to_cache_key(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(w) = self.width {
parts.push(format!("w={w}"));
}
if let Some(h) = self.height {
parts.push(format!("h={h}"));
}
parts.push(format!("q={}", self.quality));
if let Some(f) = self.format {
parts.push(format!("f={}", f.as_str()));
}
parts.push(format!("fit={}", self.fit.as_str()));
parts.push(format!("g={}", self.gravity.as_str()));
if let Some(v) = self.sharpen {
parts.push(format!("sharpen={v:.2}"));
}
if let Some(v) = self.blur {
parts.push(format!("blur={v:.2}"));
}
parts.push(format!("dpr={:.1}", self.dpr));
if let Some(r) = self.rotate {
parts.push(format!("rotate={}", r.as_str()));
}
if let Some(fl) = self.flip {
parts.push(format!("flip={}", fl.as_str()));
}
if let Some(v) = self.brightness {
parts.push(format!("brightness={v:.2}"));
}
if let Some(v) = self.contrast {
parts.push(format!("contrast={v:.2}"));
}
if let Some(v) = self.saturation {
parts.push(format!("saturation={v:.2}"));
}
if let Some(v) = self.gamma {
parts.push(format!("gamma={v:.2}"));
}
if let Some(bg) = self.background {
parts.push(format!("bg={:02X}{:02X}{:02X}", bg[0], bg[1], bg[2]));
}
if self.metadata != MetadataMode::default() {
parts.push(format!("metadata={}", self.metadata.as_str()));
}
if let Some(v) = self.trim {
parts.push(format!("trim={v:.1}"));
}
if self.anim != AnimMode::default() {
parts.push(format!("anim={}", self.anim.as_str()));
}
if let Some(f) = self.frame {
parts.push(format!("frame={f}"));
}
parts.join(",")
}
}
pub fn parse(input: &str) -> Result<TransformParams, ParseError> {
let mut params = TransformParams::default();
if input.is_empty() {
return Ok(params);
}
for pair in input.split(',') {
if pair.is_empty() {
continue;
}
let eq_pos = pair.find('=').ok_or(ParseError::InvalidParameter)?;
let key = &pair[..eq_pos];
let value = &pair[eq_pos + 1..];
if value.is_empty() {
return Err(ParseError::EmptyValue);
}
match key {
"w" | "width" => params.width = Some(parse_u32(value).ok_or(ParseError::InvalidWidth)?),
"h" | "height" => {
params.height = Some(parse_u32(value).ok_or(ParseError::InvalidHeight)?)
}
"q" | "quality" => {
let q = parse_u32(value).ok_or(ParseError::InvalidQuality)?;
if q > 255 {
return Err(ParseError::InvalidQuality);
}
params.quality = q as u8;
}
"format" | "fmt" | "f" => params.format = Some(OutputFormat::parse_str(value)?),
"fit" => params.fit = FitMode::parse_str(value)?,
"gravity" | "g" => params.gravity = Gravity::parse_str(value)?,
"sharpen" => params.sharpen = Some(parse_f32(value).ok_or(ParseError::InvalidSharpen)?),
"blur" => params.blur = Some(parse_f32(value).ok_or(ParseError::InvalidBlur)?),
"dpr" => params.dpr = parse_f32(value).ok_or(ParseError::InvalidDpr)?,
"rotate" => params.rotate = Some(Rotation::parse_str(value)?),
"flip" => params.flip = Some(FlipMode::parse_str(value)?),
"brightness" => {
params.brightness = Some(parse_f32(value).ok_or(ParseError::InvalidBrightness)?)
}
"contrast" => {
params.contrast = Some(parse_f32(value).ok_or(ParseError::InvalidContrast)?)
}
"saturation" => {
params.saturation = Some(parse_f32(value).ok_or(ParseError::InvalidSaturation)?)
}
"gamma" => params.gamma = Some(parse_f32(value).ok_or(ParseError::InvalidGamma)?),
"bg" | "background" => {
params.background =
Some(parse_hex_color(value).ok_or(ParseError::InvalidBackground)?)
}
"metadata" => params.metadata = MetadataMode::parse_str(value)?,
"trim" => params.trim = Some(parse_f32(value).ok_or(ParseError::InvalidTrim)?),
"anim" => params.anim = AnimMode::parse_str(value)?,
"frame" => params.frame = Some(parse_u32(value).ok_or(ParseError::InvalidFrame)?),
_ => return Err(ParseError::InvalidParameter),
}
}
Ok(params)
}
fn parse_u32(s: &str) -> Option<u32> {
s.parse::<u32>().ok()
}
fn parse_f32(s: &str) -> Option<f32> {
let val = s.parse::<f32>().ok()?;
if val.is_nan() || val.is_infinite() {
None
} else {
Some(val)
}
}
fn parse_hex_color(s: &str) -> Option<[u8; 3]> {
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some([r, g, b])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn output_format_content_type() {
assert_eq!(OutputFormat::Jpeg.content_type(), "image/jpeg");
assert_eq!(OutputFormat::Png.content_type(), "image/png");
assert_eq!(OutputFormat::Webp.content_type(), "image/webp");
assert_eq!(OutputFormat::Avif.content_type(), "image/avif");
}
#[test]
fn output_format_extension() {
assert_eq!(OutputFormat::Jpeg.extension(), "jpg");
assert_eq!(OutputFormat::Webp.extension(), "webp");
}
#[test]
fn parse_empty_string_returns_default_params() {
let params = parse("").unwrap();
assert_eq!(params.width, None);
assert_eq!(params.height, None);
assert_eq!(params.quality, 80);
assert_eq!(params.format, None);
assert_eq!(params.fit, FitMode::Contain);
assert_eq!(params.gravity, Gravity::Center);
assert_eq!(params.sharpen, None);
assert_eq!(params.blur, None);
assert_eq!(params.dpr, 1.0);
}
#[test]
fn parse_width_only() {
let params = parse("w=400").unwrap();
assert_eq!(params.width, Some(400));
assert_eq!(params.height, None);
assert_eq!(params.quality, 80);
}
#[test]
fn parse_multiple_params() {
let params = parse("w=400,h=300,format=webp,q=85").unwrap();
assert_eq!(params.width, Some(400));
assert_eq!(params.height, Some(300));
assert_eq!(params.format, Some(OutputFormat::Webp));
assert_eq!(params.quality, 85);
}
#[test]
fn parse_all_fit_modes() {
let modes = [
("contain", FitMode::Contain),
("cover", FitMode::Cover),
("fill", FitMode::Fill),
("inside", FitMode::Inside),
("outside", FitMode::Outside),
("pad", FitMode::Pad),
];
for (s, expected) in modes {
let params = parse(&format!("fit={s}")).unwrap();
assert_eq!(params.fit, expected);
}
}
#[test]
fn parse_all_gravity_values() {
let gravities = [
("center", Gravity::Center),
("north", Gravity::North),
("south", Gravity::South),
("east", Gravity::East),
("west", Gravity::West),
("northeast", Gravity::Northeast),
("northwest", Gravity::Northwest),
("southeast", Gravity::Southeast),
("southwest", Gravity::Southwest),
("smart", Gravity::Smart),
("attention", Gravity::Attention),
];
for (s, expected) in gravities {
let params = parse(&format!("g={s}")).unwrap();
assert_eq!(params.gravity, expected);
}
}
#[test]
fn parse_format_aliases() {
assert_eq!(parse("format=png").unwrap().format, Some(OutputFormat::Png));
assert_eq!(parse("fmt=jpeg").unwrap().format, Some(OutputFormat::Jpeg));
assert_eq!(parse("f=avif").unwrap().format, Some(OutputFormat::Avif));
}
#[test]
fn validate_width_0_returns_error() {
let mut p = TransformParams {
width: Some(0),
..Default::default()
};
assert_eq!(p.validate(), Err(ParseError::InvalidWidth));
p.width = Some(1);
assert!(p.validate().is_ok());
}
#[test]
fn validate_width_9000_returns_error() {
let p = TransformParams {
width: Some(9000),
..Default::default()
};
assert_eq!(p.validate(), Err(ParseError::InvalidWidth));
}
#[test]
fn validate_quality_0_returns_error() {
let p = TransformParams {
quality: 0,
..Default::default()
};
assert_eq!(p.validate(), Err(ParseError::InvalidQuality));
}
#[test]
fn validate_quality_101_returns_error() {
let p = TransformParams {
quality: 101,
..Default::default()
};
assert_eq!(p.validate(), Err(ParseError::InvalidQuality));
}
#[test]
fn invalid_key_returns_error() {
assert_eq!(parse("banana=42"), Err(ParseError::InvalidParameter));
}
#[test]
fn invalid_value_non_numeric_width_returns_error() {
assert_eq!(parse("w=abc"), Err(ParseError::InvalidWidth));
}
#[test]
fn cache_key_is_deterministic() {
let params = parse("w=400,h=300,format=webp,q=85").unwrap();
assert_eq!(params.to_cache_key(), params.to_cache_key());
}
#[test]
fn cache_key_differs_when_params_differ() {
let p1 = parse("w=400,h=300").unwrap();
let p2 = parse("w=400,h=301").unwrap();
assert_ne!(p1.to_cache_key(), p2.to_cache_key());
}
#[test]
fn dpr_multiplies_effective_dimensions() {
let params = parse("w=400,h=300,dpr=2.0").unwrap();
assert_eq!(params.effective_width(), Some(800));
assert_eq!(params.effective_height(), Some(600));
}
#[test]
fn dpr_effective_dimensions_with_null_width_and_height() {
let params = parse("dpr=3.0").unwrap();
assert_eq!(params.effective_width(), None);
assert_eq!(params.effective_height(), None);
}
#[test]
fn sharpen_bounds_validation() {
assert!(
TransformParams {
sharpen: Some(5.0),
..Default::default()
}
.validate()
.is_ok()
);
assert_eq!(
TransformParams {
sharpen: Some(11.0),
..Default::default()
}
.validate(),
Err(ParseError::InvalidSharpen)
);
assert_eq!(
TransformParams {
sharpen: Some(-1.0),
..Default::default()
}
.validate(),
Err(ParseError::InvalidSharpen)
);
}
#[test]
fn blur_bounds_validation() {
assert!(
TransformParams {
blur: Some(1.0),
..Default::default()
}
.validate()
.is_ok()
);
assert_eq!(
TransformParams {
blur: Some(300.0),
..Default::default()
}
.validate(),
Err(ParseError::InvalidBlur)
);
assert_eq!(
TransformParams {
blur: Some(0.05),
..Default::default()
}
.validate(),
Err(ParseError::InvalidBlur)
);
}
#[test]
fn dpr_bounds_validation() {
assert!(
TransformParams {
dpr: 2.5,
..Default::default()
}
.validate()
.is_ok()
);
assert_eq!(
TransformParams {
dpr: 0.5,
..Default::default()
}
.validate(),
Err(ParseError::InvalidDpr)
);
assert_eq!(
TransformParams {
dpr: 6.0,
..Default::default()
}
.validate(),
Err(ParseError::InvalidDpr)
);
}
#[test]
fn empty_value_returns_error() {
assert_eq!(parse("w="), Err(ParseError::EmptyValue));
}
#[test]
fn parse_sharpen_and_blur_values() {
let params = parse("sharpen=1.5,blur=3.0").unwrap();
assert_eq!(params.sharpen, Some(1.5));
assert_eq!(params.blur, Some(3.0));
}
#[test]
fn parse_rejects_nan_values() {
assert_eq!(parse("sharpen=nan"), Err(ParseError::InvalidSharpen));
assert_eq!(parse("blur=nan"), Err(ParseError::InvalidBlur));
assert_eq!(parse("dpr=nan"), Err(ParseError::InvalidDpr));
}
#[test]
fn parse_rejects_inf_values() {
assert_eq!(parse("sharpen=inf"), Err(ParseError::InvalidSharpen));
assert_eq!(parse("blur=inf"), Err(ParseError::InvalidBlur));
assert_eq!(parse("gamma=inf"), Err(ParseError::InvalidGamma));
}
#[test]
fn cache_key_order_is_canonical_regardless_of_parse_order() {
let p1 = parse("h=300,w=400,q=90").unwrap();
let p2 = parse("w=400,q=90,h=300").unwrap();
assert_eq!(p1.to_cache_key(), p2.to_cache_key());
}
#[test]
fn valid_params_pass_validation() {
let params = parse("w=800,h=600,q=90,format=webp,fit=cover,g=north,dpr=2.0").unwrap();
assert!(params.validate().is_ok());
}
#[test]
fn width_boundary_values() {
assert!(
TransformParams {
width: Some(1),
..Default::default()
}
.validate()
.is_ok()
);
assert!(
TransformParams {
width: Some(8192),
..Default::default()
}
.validate()
.is_ok()
);
assert_eq!(
TransformParams {
width: Some(8193),
..Default::default()
}
.validate(),
Err(ParseError::InvalidWidth)
);
}
#[test]
fn height_validation_at_boundaries() {
assert_eq!(
TransformParams {
height: Some(0),
..Default::default()
}
.validate(),
Err(ParseError::InvalidHeight)
);
assert_eq!(
TransformParams {
height: Some(8193),
..Default::default()
}
.validate(),
Err(ParseError::InvalidHeight)
);
}
#[test]
fn invalid_format_returns_error() {
assert_eq!(parse("format=bmp"), Err(ParseError::InvalidFormat));
}
#[test]
fn invalid_fit_mode_returns_error() {
assert_eq!(parse("fit=stretch"), Err(ParseError::InvalidFitMode));
}
#[test]
fn invalid_gravity_returns_error() {
assert_eq!(parse("g=diagonal"), Err(ParseError::InvalidGravity));
}
#[test]
fn parse_rotate_values() {
let rotations = [
("0", Rotation::Deg0),
("90", Rotation::Deg90),
("180", Rotation::Deg180),
("270", Rotation::Deg270),
];
for (s, expected) in rotations {
let params = parse(&format!("rotate={s}")).unwrap();
assert_eq!(params.rotate, Some(expected));
}
}
#[test]
fn invalid_rotate_returns_error() {
assert_eq!(parse("rotate=45"), Err(ParseError::InvalidRotation));
}
#[test]
fn parse_flip_values() {
assert_eq!(parse("flip=h").unwrap().flip, Some(FlipMode::H));
assert_eq!(parse("flip=v").unwrap().flip, Some(FlipMode::V));
assert_eq!(parse("flip=hv").unwrap().flip, Some(FlipMode::Hv));
}
#[test]
fn invalid_flip_returns_error() {
assert_eq!(parse("flip=x"), Err(ParseError::InvalidFlip));
}
#[test]
fn parse_brightness_contrast_gamma() {
let params = parse("brightness=1.5,contrast=0.8,gamma=2.2").unwrap();
assert_eq!(params.brightness, Some(1.5));
assert_eq!(params.contrast, Some(0.8));
assert_eq!(params.gamma, Some(2.2));
}
#[test]
fn brightness_bounds_validation() {
assert!(
TransformParams {
brightness: Some(1.0),
..Default::default()
}
.validate()
.is_ok()
);
assert_eq!(
TransformParams {
brightness: Some(2.1),
..Default::default()
}
.validate(),
Err(ParseError::InvalidBrightness)
);
}
#[test]
fn contrast_bounds_validation() {
assert!(
TransformParams {
contrast: Some(0.0),
..Default::default()
}
.validate()
.is_ok()
);
assert_eq!(
TransformParams {
contrast: Some(2.1),
..Default::default()
}
.validate(),
Err(ParseError::InvalidContrast)
);
}
#[test]
fn saturation_bounds_validation() {
assert!(
TransformParams {
saturation: Some(1.5),
..Default::default()
}
.validate()
.is_ok()
);
assert_eq!(
TransformParams {
saturation: Some(2.1),
..Default::default()
}
.validate(),
Err(ParseError::InvalidSaturation)
);
}
#[test]
fn gamma_bounds_validation() {
assert!(
TransformParams {
gamma: Some(2.2),
..Default::default()
}
.validate()
.is_ok()
);
assert_eq!(
TransformParams {
gamma: Some(0.05),
..Default::default()
}
.validate(),
Err(ParseError::InvalidGamma)
);
assert_eq!(
TransformParams {
gamma: Some(11.0),
..Default::default()
}
.validate(),
Err(ParseError::InvalidGamma)
);
}
#[test]
fn parse_background_hex_color() {
let params = parse("bg=FF0000").unwrap();
assert_eq!(params.background, Some([255, 0, 0]));
}
#[test]
fn parse_background_alias() {
let params = parse("background=00FF00").unwrap();
assert_eq!(params.background, Some([0, 255, 0]));
}
#[test]
fn invalid_background_returns_error() {
assert_eq!(parse("bg=red"), Err(ParseError::InvalidBackground));
assert_eq!(parse("bg=GGHHII"), Err(ParseError::InvalidBackground));
assert_eq!(parse("bg=FFF"), Err(ParseError::InvalidBackground));
}
#[test]
fn parse_metadata_modes() {
assert_eq!(
parse("metadata=strip").unwrap().metadata,
MetadataMode::Strip
);
assert_eq!(parse("metadata=keep").unwrap().metadata, MetadataMode::Keep);
assert_eq!(
parse("metadata=copyright").unwrap().metadata,
MetadataMode::Copyright
);
}
#[test]
fn invalid_metadata_returns_error() {
assert_eq!(parse("metadata=partial"), Err(ParseError::InvalidMetadata));
}
#[test]
fn parse_trim_value() {
assert_eq!(parse("trim=10").unwrap().trim, Some(10.0));
}
#[test]
fn trim_bounds_validation() {
assert!(
TransformParams {
trim: Some(50.0),
..Default::default()
}
.validate()
.is_ok()
);
assert_eq!(
TransformParams {
trim: Some(0.5),
..Default::default()
}
.validate(),
Err(ParseError::InvalidTrim)
);
assert_eq!(
TransformParams {
trim: Some(101.0),
..Default::default()
}
.validate(),
Err(ParseError::InvalidTrim)
);
}
#[test]
fn cache_key_includes_new_params() {
let params = parse("w=400,rotate=90,flip=h,brightness=1.5,bg=FF0000").unwrap();
let key = params.to_cache_key();
assert!(key.contains("rotate=90"));
assert!(key.contains("flip=h"));
assert!(key.contains("brightness=1.50"));
assert!(key.contains("bg=FF0000"));
}
#[test]
fn cache_key_omits_default_metadata() {
let params = parse("w=400").unwrap();
assert!(!params.to_cache_key().contains("metadata"));
}
#[test]
fn cache_key_includes_non_default_metadata() {
let params = parse("w=400,metadata=keep").unwrap();
assert!(params.to_cache_key().contains("metadata=keep"));
}
#[test]
fn parse_anim_modes() {
assert_eq!(parse("anim=auto").unwrap().anim, AnimMode::Auto);
assert_eq!(parse("anim=static").unwrap().anim, AnimMode::Static);
assert_eq!(parse("anim=animate").unwrap().anim, AnimMode::Animate);
}
#[test]
fn parse_anim_cloudflare_aliases() {
assert_eq!(parse("anim=true").unwrap().anim, AnimMode::Auto);
assert_eq!(parse("anim=false").unwrap().anim, AnimMode::Static);
}
#[test]
fn invalid_anim_returns_error() {
assert_eq!(parse("anim=fast"), Err(ParseError::InvalidAnim));
}
#[test]
fn parse_frame_value() {
assert_eq!(parse("frame=2").unwrap().frame, Some(2));
}
#[test]
fn parse_frame_0() {
assert_eq!(parse("frame=0").unwrap().frame, Some(0));
}
#[test]
fn invalid_frame_returns_error() {
assert_eq!(parse("frame=abc"), Err(ParseError::InvalidFrame));
}
#[test]
fn frame_validation_rejects_large_values() {
let p = TransformParams {
frame: Some(1000),
..Default::default()
};
assert_eq!(p.validate(), Err(ParseError::InvalidFrame));
}
#[test]
fn frame_validation_accepts_valid_values() {
let p = TransformParams {
frame: Some(999),
..Default::default()
};
assert!(p.validate().is_ok());
}
#[test]
fn cache_key_includes_anim_when_not_default() {
let params = parse("w=400,anim=static").unwrap();
assert!(params.to_cache_key().contains("anim=static"));
}
#[test]
fn cache_key_omits_anim_when_auto() {
let params = parse("w=400").unwrap();
assert!(!params.to_cache_key().contains("anim"));
}
#[test]
fn cache_key_includes_frame_when_set() {
let params = parse("w=400,frame=1").unwrap();
assert!(params.to_cache_key().contains("frame=1"));
}
#[test]
fn output_format_gif_content_type() {
assert_eq!(OutputFormat::Gif.content_type(), "image/gif");
}
#[test]
fn output_format_gif_extension() {
assert_eq!(OutputFormat::Gif.extension(), "gif");
}
#[test]
fn output_format_gif_parse_str() {
assert_eq!(OutputFormat::parse_str("gif"), Ok(OutputFormat::Gif));
}
#[test]
fn output_format_supports_animation() {
assert!(OutputFormat::Gif.supports_animation());
assert!(OutputFormat::Webp.supports_animation());
assert!(!OutputFormat::Jpeg.supports_animation());
assert!(!OutputFormat::Png.supports_animation());
assert!(!OutputFormat::Avif.supports_animation());
}
#[test]
fn cache_key_matches_zig_golden_strings() {
assert_eq!(
parse("w=400,h=300,format=webp,q=85")
.unwrap()
.to_cache_key(),
"w=400,h=300,q=85,f=webp,fit=contain,g=center,dpr=1.0"
);
assert_eq!(
TransformParams::default().to_cache_key(),
"q=80,fit=contain,g=center,dpr=1.0"
);
assert_eq!(
parse("w=400,rotate=90,flip=h,brightness=1.5,bg=FF0000")
.unwrap()
.to_cache_key(),
"w=400,q=80,fit=contain,g=center,dpr=1.0,rotate=90,flip=h,brightness=1.50,bg=FF0000"
);
assert_eq!(
parse("w=400,h=400,fit=cover,g=smart,dpr=2")
.unwrap()
.to_cache_key(),
"w=400,h=400,q=80,fit=cover,g=smart,dpr=2.0"
);
}
}