pigeon-cli 0.4.22

Pigeon: authenticate, sink, and transform personal data from external services.
Documentation
//! `--input-file-type` (ADR-0112, extended to video by ADR-0122): a small,
//! vetted menu, not an arbitrary string -- mirrors
//! `rclone::cli::RcloneAction`'s precedent. Named `InputFileType`, not
//! `ImageInputType`, and the job
//! itself named `transform`, not `image-transform`, so a later iteration
//! could add a non-image input kind to this same enum/command without a
//! rename -- ADR-0122's `Mov`/`M4v`/`Mp4` variants are that iteration.

/// Which input format this run transcodes. Image kinds (ADR-0112) always
/// output full-size, maximum-quality `.jpg`; video kinds (ADR-0122) always
/// output `.mp4` (H.265/HEVC) at the chosen `VideoQuality`. See
/// `output_extension`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InputFileType {
    Png,
    Jpeg,
    Heic,
    Mov,
    M4v,
    Mp4,
}

impl InputFileType {
    /// Every on-disk spelling this input kind matches -- what the source
    /// listing's `rclone --include` filters and the per-file pipeline's
    /// local validation both check against. Lowercase, no leading dot.
    ///
    /// A kind may have more than one spelling (ADR-0139): `.jpg` and
    /// `.jpeg` are the same format, and `parse` has accepted both as
    /// *input* since ADR-0112 -- this is that same leniency finally applied
    /// to file *matching*, which it never reached. A run against a
    /// directory of `.jpg` files with `--input-file-type=jpeg` silently
    /// matched nothing until this returned a set rather than one string.
    ///
    /// Only `Jpeg` has a second spelling; every other kind keeps exactly
    /// one, deliberately unspeculative -- a new alias gets added when a
    /// real run is observed to miss files, not in anticipation.
    pub(crate) fn extensions(self) -> &'static [&'static str] {
        match self {
            InputFileType::Png => &["png"],
            InputFileType::Jpeg => &["jpg", "jpeg"],
            InputFileType::Heic => &["heic"],
            InputFileType::Mov => &["mov"],
            InputFileType::M4v => &["m4v"],
            InputFileType::Mp4 => &["mp4"],
        }
    }

    /// The one spelling used for display and for a default bucket prefix
    /// (ADR-0138's `--source`/`--destination` inference) -- never for
    /// matching, which always considers every entry in `extensions()`.
    ///
    /// For `Jpeg` this is `jpg`, not `jpeg` (ADR-0139): it agrees with
    /// `output_extension()`, which has always returned `jpg` for this
    /// kind, and with ADR-0112's own documented `source:png/` ->
    /// `destination:jpg/` example.
    pub(crate) fn canonical_extension(self) -> &'static str {
        self.extensions()[0]
    }

    /// Whether `ext` (no leading dot, any case) is one of this kind's
    /// spellings -- the membership test `manifest::gather_pending` uses in
    /// place of an exact-string compare against a single extension.
    pub(crate) fn matches_extension(self, ext: &str) -> bool {
        let ext = ext.to_ascii_lowercase();
        self.extensions().contains(&ext.as_str())
    }

    /// Case-insensitive; `jpg` is accepted as an alias for `jpeg`. Any
    /// other value is a hard error -- an invalid `--input-file-type` never
    /// falls through to an interactive prompt.
    pub(crate) fn parse(value: &str) -> Result<Self, String> {
        match value.to_ascii_lowercase().as_str() {
            "png" => Ok(InputFileType::Png),
            "jpeg" | "jpg" => Ok(InputFileType::Jpeg),
            "heic" => Ok(InputFileType::Heic),
            "mov" => Ok(InputFileType::Mov),
            "m4v" => Ok(InputFileType::M4v),
            "mp4" => Ok(InputFileType::Mp4),
            other => Err(format!(
                "unknown --input-file-type '{other}' (expected png, jpeg, heic, mov, m4v, or mp4)"
            )),
        }
    }

    /// Video kinds are always recompressed (ADR-0122) -- never
    /// copy-through, unlike `Jpeg` -- so this job's one justified departure
    /// from "preserve what's already in the target format" is explicit here
    /// rather than implied by a `match` elsewhere.
    pub(crate) fn is_video(self) -> bool {
        matches!(
            self,
            InputFileType::Mov | InputFileType::M4v | InputFileType::Mp4
        )
    }

    /// The fixed output extension for this input kind: `jpg` for every image
    /// kind (ADR-0112), `mp4` for every video kind (ADR-0122) regardless of
    /// which video extension was the input.
    pub(crate) fn output_extension(self) -> &'static str {
        if self.is_video() { "mp4" } else { "jpg" }
    }
}

impl std::fmt::Display for InputFileType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.canonical_extension())
    }
}

/// `--video-quality` (ADR-0122): a small, vetted CRF menu for `libx265`,
/// mirroring ADR-0077's per-category menu precedent -- never raw CRF/bitrate
/// exposed directly. Only consulted when `InputFileType::is_video()`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum VideoQuality {
    Low,
    Medium,
    High,
    Lossless,
}

impl VideoQuality {
    /// `libx265` CRF for this tier, or `None` for `Lossless`, which uses
    /// `-x265-params lossless=1` instead of a CRF value (see `media.rs`).
    /// `Medium`'s `24` is deliberately tuned a bit better than the
    /// conventional H.265 "medium" of ~26-28 -- this project's own stated
    /// preference for personal archival video, not a generic default.
    pub(crate) fn crf(self) -> Option<u8> {
        match self {
            VideoQuality::Low => Some(30),
            VideoQuality::Medium => Some(24),
            VideoQuality::High => Some(20),
            VideoQuality::Lossless => None,
        }
    }

    pub(crate) fn as_str(self) -> &'static str {
        match self {
            VideoQuality::Low => "low",
            VideoQuality::Medium => "medium",
            VideoQuality::High => "high",
            VideoQuality::Lossless => "lossless",
        }
    }

    /// Case-insensitive; any other value is a hard error -- same discipline
    /// as `InputFileType::parse`.
    pub(crate) fn parse(value: &str) -> Result<Self, String> {
        match value.to_ascii_lowercase().as_str() {
            "low" => Ok(VideoQuality::Low),
            "medium" => Ok(VideoQuality::Medium),
            "high" => Ok(VideoQuality::High),
            "lossless" => Ok(VideoQuality::Lossless),
            other => Err(format!(
                "unknown --video-quality '{other}' (expected low, medium, high, or lossless)"
            )),
        }
    }
}

impl std::fmt::Display for VideoQuality {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_accepts_every_canonical_value_case_insensitively() {
        assert_eq!(InputFileType::parse("png").unwrap(), InputFileType::Png);
        assert_eq!(InputFileType::parse("PNG").unwrap(), InputFileType::Png);
        assert_eq!(InputFileType::parse("jpeg").unwrap(), InputFileType::Jpeg);
        assert_eq!(InputFileType::parse("JPEG").unwrap(), InputFileType::Jpeg);
        assert_eq!(InputFileType::parse("heic").unwrap(), InputFileType::Heic);
        assert_eq!(InputFileType::parse("HEIC").unwrap(), InputFileType::Heic);
        assert_eq!(InputFileType::parse("mov").unwrap(), InputFileType::Mov);
        assert_eq!(InputFileType::parse("MOV").unwrap(), InputFileType::Mov);
        assert_eq!(InputFileType::parse("m4v").unwrap(), InputFileType::M4v);
        assert_eq!(InputFileType::parse("M4V").unwrap(), InputFileType::M4v);
        assert_eq!(InputFileType::parse("mp4").unwrap(), InputFileType::Mp4);
        assert_eq!(InputFileType::parse("MP4").unwrap(), InputFileType::Mp4);
    }

    #[test]
    fn parse_accepts_jpg_as_an_alias_for_jpeg() {
        assert_eq!(InputFileType::parse("jpg").unwrap(), InputFileType::Jpeg);
        assert_eq!(InputFileType::parse("JPG").unwrap(), InputFileType::Jpeg);
    }

    #[test]
    fn parse_rejects_anything_else() {
        assert!(InputFileType::parse("gif").is_err());
        assert!(InputFileType::parse("").is_err());
    }

    #[test]
    fn canonical_extension_and_display_agree() {
        for kind in [
            InputFileType::Png,
            InputFileType::Jpeg,
            InputFileType::Heic,
            InputFileType::Mov,
            InputFileType::M4v,
            InputFileType::Mp4,
        ] {
            assert_eq!(kind.canonical_extension(), kind.to_string());
        }
    }

    /// The ADR-0139 regression: a `.jpg` file must be matched by a `jpeg`
    /// run. Before this, `extension()` returned only `"jpeg"`, so the
    /// source listing's `--include '*.jpeg'` silently matched nothing in a
    /// directory of `.jpg` files.
    #[test]
    fn jpeg_matches_both_jpg_and_jpeg_spellings() {
        assert_eq!(InputFileType::Jpeg.extensions(), &["jpg", "jpeg"]);
        assert!(InputFileType::Jpeg.matches_extension("jpg"));
        assert!(InputFileType::Jpeg.matches_extension("jpeg"));
    }

    #[test]
    fn jpeg_canonical_extension_is_jpg_agreeing_with_its_output_extension() {
        // Both halves of ADR-0138's prefix inference now agree: a jpeg run
        // defaults to `source:jpg/` -> `destination:jpg/`, where the source
        // half used to default to `jpeg/`.
        assert_eq!(InputFileType::Jpeg.canonical_extension(), "jpg");
        assert_eq!(InputFileType::Jpeg.output_extension(), "jpg");
    }

    #[test]
    fn every_other_kind_has_exactly_one_spelling() {
        for kind in [
            InputFileType::Png,
            InputFileType::Heic,
            InputFileType::Mov,
            InputFileType::M4v,
            InputFileType::Mp4,
        ] {
            assert_eq!(
                kind.extensions().len(),
                1,
                "{kind} gained an unintended alias"
            );
        }
    }

    #[test]
    fn matches_extension_is_case_insensitive_and_rejects_other_formats() {
        assert!(InputFileType::Jpeg.matches_extension("JPG"));
        assert!(InputFileType::Jpeg.matches_extension("JpEg"));
        assert!(!InputFileType::Jpeg.matches_extension("png"));
        assert!(!InputFileType::Jpeg.matches_extension(""));
        assert!(InputFileType::Png.matches_extension("PNG"));
        assert!(!InputFileType::Png.matches_extension("jpg"));
        // A video kind must not match a sibling video extension -- each
        // run still transcodes exactly one input kind.
        assert!(!InputFileType::Mov.matches_extension("mp4"));
    }

    #[test]
    fn is_video_is_true_only_for_video_kinds() {
        assert!(!InputFileType::Png.is_video());
        assert!(!InputFileType::Jpeg.is_video());
        assert!(!InputFileType::Heic.is_video());
        assert!(InputFileType::Mov.is_video());
        assert!(InputFileType::M4v.is_video());
        assert!(InputFileType::Mp4.is_video());
    }

    #[test]
    fn output_extension_is_jpg_for_image_kinds_and_mp4_for_video_kinds() {
        assert_eq!(InputFileType::Png.output_extension(), "jpg");
        assert_eq!(InputFileType::Jpeg.output_extension(), "jpg");
        assert_eq!(InputFileType::Heic.output_extension(), "jpg");
        assert_eq!(InputFileType::Mov.output_extension(), "mp4");
        assert_eq!(InputFileType::M4v.output_extension(), "mp4");
        assert_eq!(InputFileType::Mp4.output_extension(), "mp4");
    }

    #[test]
    fn video_quality_parse_accepts_every_canonical_value_case_insensitively() {
        assert_eq!(VideoQuality::parse("low").unwrap(), VideoQuality::Low);
        assert_eq!(VideoQuality::parse("LOW").unwrap(), VideoQuality::Low);
        assert_eq!(VideoQuality::parse("medium").unwrap(), VideoQuality::Medium);
        assert_eq!(VideoQuality::parse("high").unwrap(), VideoQuality::High);
        assert_eq!(
            VideoQuality::parse("lossless").unwrap(),
            VideoQuality::Lossless
        );
    }

    #[test]
    fn video_quality_parse_rejects_anything_else() {
        assert!(VideoQuality::parse("ultra").is_err());
        assert!(VideoQuality::parse("").is_err());
    }

    #[test]
    fn video_quality_crf_is_none_only_for_lossless() {
        assert_eq!(VideoQuality::Low.crf(), Some(30));
        assert_eq!(VideoQuality::Medium.crf(), Some(24));
        assert_eq!(VideoQuality::High.crf(), Some(20));
        assert_eq!(VideoQuality::Lossless.crf(), None);
    }

    #[test]
    fn video_quality_as_str_and_display_agree() {
        for quality in [
            VideoQuality::Low,
            VideoQuality::Medium,
            VideoQuality::High,
            VideoQuality::Lossless,
        ] {
            assert_eq!(quality.as_str(), quality.to_string());
        }
    }
}