use std::collections::BTreeMap;
use std::error::Error as _;
use std::fmt::Display;
use std::path::Path;

use anyhow::Result;
use fancy_regex::Regex;
use globset::{Glob, GlobSet};
use itertools::Itertools;
use owo_colors::OwoColorize;
use prek_identify::TagSet;
use rustc_hash::FxHashMap;
use serde::de::{DeserializeSeed, Error as DeError, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use strum::EnumCount;

use crate::fs::Simplified;
use crate::install_source::InstallSource;
use crate::version;
use crate::warn_user;
use crate::warn_user_once;

pub(crate) fn validate_group_name(group: &str) -> std::result::Result<(), &'static str> {
    if group.is_empty() {
        Err("cannot be empty")
    } else if group.chars().any(char::is_whitespace) {
        Err("cannot contain whitespace")
    } else {
        Ok(())
    }
}

fn deserialize_groups<'de, D>(deserializer: D) -> std::result::Result<Option<Vec<String>>, D::Error>
where
    D: Deserializer<'de>,
{
    let groups = Option::<Vec<String>>::deserialize(deserializer)?;
    if let Some(groups) = &groups {
        for group in groups {
            if let Err(reason) = validate_group_name(group) {
                return Err(D::Error::custom(format!("group name `{group}` {reason}")));
            }
        }
    }
    Ok(groups)
}

#[derive(Clone)]
pub(crate) struct GlobPatterns {
    patterns: Vec<Glob>,
    set: GlobSet,
}

impl GlobPatterns {
    pub(crate) fn new(patterns: Vec<String>) -> Result<Self, globset::Error> {
        let patterns = patterns
            .into_iter()
            .map(|pattern| pattern.parse())
            .collect::<Result<Vec<_>, _>>()?;
        Self::from_globs(patterns)
    }

    pub(crate) fn from_globs(patterns: Vec<Glob>) -> Result<Self, globset::Error> {
        let mut builder = GlobSet::builder();
        for pattern in &patterns {
            builder.add(pattern.clone());
        }
        let set = builder.build()?;
        Ok(Self { patterns, set })
    }

    pub(crate) fn is_empty(&self) -> bool {
        self.patterns.is_empty()
    }

    pub(crate) fn is_match(&self, path: &Path) -> bool {
        self.set.is_match(path)
    }
}

fn debug_globs(globs: &[Glob]) -> impl std::fmt::Debug + '_ {
    std::fmt::from_fn(|f| {
        f.debug_list()
            .entries(globs.iter().map(Glob::glob))
            .finish()
    })
}

impl std::fmt::Debug for GlobPatterns {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GlobPatterns")
            .field("patterns", &debug_globs(&self.patterns))
            .finish_non_exhaustive()
    }
}

enum FilePatternWire {
    Glob { glob: String },
    GlobList { glob: Vec<String> },
    Regex(String),
}

impl<'de> Deserialize<'de> for FilePatternWire {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct FilePatternVisitor;
        struct GlobFieldVisitor;

        impl<'de> DeserializeSeed<'de> for GlobFieldVisitor {
            type Value = FilePatternWire;

            fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
            where
                D: Deserializer<'de>,
            {
                deserializer.deserialize_any(self)
            }
        }

        impl<'de> Visitor<'de> for GlobFieldVisitor {
            type Value = FilePatternWire;

            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
                formatter.write_str("a string or a list of strings")
            }

            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
            where
                E: DeError,
            {
                Ok(FilePatternWire::Glob {
                    glob: value.to_owned(),
                })
            }

            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
            where
                E: DeError,
            {
                Ok(FilePatternWire::Glob { glob: value })
            }

            fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
            where
                A: serde::de::SeqAccess<'de>,
            {
                Ok(FilePatternWire::GlobList {
                    glob: Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(
                        seq,
                    ))?,
                })
            }
        }

        impl<'de> Visitor<'de> for FilePatternVisitor {
            type Value = FilePatternWire;

            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
                formatter.write_str(
                    "a regex string or a mapping with `glob` set to a string or list of strings",
                )
            }

            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
            where
                E: DeError,
            {
                Ok(FilePatternWire::Regex(value.to_owned()))
            }

            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
            where
                E: DeError,
            {
                Ok(FilePatternWire::Regex(value))
            }

            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
            where
                M: MapAccess<'de>,
            {
                let mut glob = None;

                while let Some(key) = map.next_key::<String>()? {
                    match key.as_str() {
                        "glob" => {
                            if glob.is_some() {
                                return Err(M::Error::duplicate_field("glob"));
                            }
                            glob = Some(map.next_value_seed(GlobFieldVisitor)?);
                        }
                        _ => {
                            return Err(M::Error::unknown_field(&key, &["glob"]));
                        }
                    }
                }

                glob.ok_or_else(|| M::Error::missing_field("glob"))
            }
        }

        deserializer.deserialize_any(FilePatternVisitor)
    }
}

#[derive(Debug, thiserror::Error)]
enum FilePatternWireError {
    #[error(transparent)]
    Glob(#[from] globset::Error),

    #[error(transparent)]
    Regex(#[from] fancy_regex::Error),
}

#[derive(Debug, Clone, Deserialize)]
#[serde(try_from = "FilePatternWire")]
pub(crate) enum FilePattern {
    Never,
    Regex(Regex),
    Glob(GlobPatterns),
}

impl FilePattern {
    pub(crate) fn glob(patterns: Vec<String>) -> Result<Self, globset::Error> {
        Ok(Self::Glob(GlobPatterns::new(patterns)?))
    }

    pub(crate) fn regex(pattern: &str) -> Result<Self, fancy_regex::Error> {
        Ok(Self::Regex(Regex::new(pattern)?))
    }

    pub(crate) fn is_match(&self, path: &Path) -> bool {
        match self {
            FilePattern::Never => false,
            // Regex patterns are text matchers; globs can match OS paths directly.
            FilePattern::Regex(regex) => path
                .to_str()
                .is_some_and(|path| regex.is_match(path).unwrap_or(false)),
            FilePattern::Glob(globs) => globs.is_match(path),
        }
    }
}

impl Display for FilePattern {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            FilePattern::Never => f.write_str("never"),
            FilePattern::Regex(regex) => write!(f, "regex: {}", regex.as_str()),
            FilePattern::Glob(globs) => {
                let patterns = globs.patterns.iter().join(", ");
                write!(f, "glob: [{patterns}]")
            }
        }
    }
}

impl TryFrom<FilePatternWire> for FilePattern {
    type Error = FilePatternWireError;

    fn try_from(value: FilePatternWire) -> Result<Self, Self::Error> {
        match value {
            FilePatternWire::Glob { glob } => Ok(Self::Glob(GlobPatterns::new(vec![glob])?)),
            FilePatternWire::GlobList { glob } => Ok(Self::Glob(GlobPatterns::new(glob)?)),
            FilePatternWire::Regex(pattern) => Ok(Self::Regex(Regex::new(&pattern)?)),
        }
    }
}

#[derive(
    Debug,
    Copy,
    Clone,
    PartialEq,
    Eq,
    Hash,
    Deserialize,
    Serialize,
    clap::ValueEnum,
    strum::AsRefStr,
    strum::Display,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum Language {
    Bun,
    Conda,
    Coursier,
    Dart,
    Deno,
    Docker,
    DockerImage,
    Dotnet,
    Fail,
    Golang,
    Haskell,
    Julia,
    Lua,
    Node,
    Perl,
    Php,
    Pygrep,
    Python,
    R,
    Ruby,
    Rust,
    #[serde(alias = "unsupported_script")]
    Script,
    Swift,
    #[serde(alias = "unsupported")]
    System,
}

#[derive(
    Debug, Clone, Copy, Default, Deserialize, clap::ValueEnum, strum::AsRefStr, strum::Display,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub(crate) enum HookType {
    CommitMsg,
    PostCheckout,
    PostCommit,
    PostMerge,
    PostRewrite,
    #[default]
    PreCommit,
    PreMergeCommit,
    PrePush,
    PreRebase,
    PrepareCommitMsg,
}

#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    Default,
    Hash,
    Deserialize,
    Serialize,
    clap::ValueEnum,
    strum::AsRefStr,
    strum::Display,
    strum::EnumCount,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[repr(u8)]
pub(crate) enum Stage {
    Manual,
    CommitMsg,
    PostCheckout,
    PostCommit,
    PostMerge,
    PostRewrite,
    #[default]
    #[serde(alias = "commit")]
    PreCommit,
    #[serde(alias = "merge-commit")]
    PreMergeCommit,
    #[serde(alias = "push")]
    PrePush,
    PreRebase,
    PrepareCommitMsg,
}

impl From<HookType> for Stage {
    fn from(value: HookType) -> Self {
        match value {
            HookType::CommitMsg => Self::CommitMsg,
            HookType::PostCheckout => Self::PostCheckout,
            HookType::PostCommit => Self::PostCommit,
            HookType::PostMerge => Self::PostMerge,
            HookType::PostRewrite => Self::PostRewrite,
            HookType::PreCommit => Self::PreCommit,
            HookType::PreMergeCommit => Self::PreMergeCommit,
            HookType::PrePush => Self::PrePush,
            HookType::PreRebase => Self::PreRebase,
            HookType::PrepareCommitMsg => Self::PrepareCommitMsg,
        }
    }
}

impl Stage {
    const ORDER: [Self; Self::COUNT] = [
        Self::Manual,
        Self::CommitMsg,
        Self::PostCheckout,
        Self::PostCommit,
        Self::PostMerge,
        Self::PostRewrite,
        Self::PreCommit,
        Self::PreMergeCommit,
        Self::PrePush,
        Self::PreRebase,
        Self::PrepareCommitMsg,
    ];

    const fn bit(self) -> u16 {
        1u16 << (self as u8)
    }

    fn from_index(index: u32) -> Self {
        Self::ORDER[index as usize]
    }
}

#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct Stages(u16);

impl Stages {
    const ALL_BITS: u16 = (1u16 << Stage::COUNT) - 1;

    pub(crate) const ALL: Self = Self(Self::ALL_BITS);

    pub(crate) fn iter(self) -> impl Iterator<Item = Stage> {
        let mut bits = self.0;
        std::iter::from_fn(move || {
            if bits == 0 {
                return None;
            }
            let index = bits.trailing_zeros();
            bits &= bits - 1;
            Some(Stage::from_index(index))
        })
    }

    pub(crate) fn is_empty(self) -> bool {
        self.0 == 0
    }

    pub(crate) fn contains(self, stage: Stage) -> bool {
        (self.0 & stage.bit()) != 0
    }
}

impl Display for Stages {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if *self == Self::ALL {
            write!(f, "all")
        } else if self.is_empty() {
            write!(f, "none")
        } else {
            let stages_str = self.iter().map(|stage| stage.to_string()).join(", ");
            write!(f, "{stages_str}")
        }
    }
}

impl std::fmt::Debug for Stages {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Stages({self})")
    }
}

impl From<Vec<Stage>> for Stages {
    fn from(value: Vec<Stage>) -> Self {
        let bits = value.into_iter().fold(0, |bits, stage| bits | stage.bit());
        Self(bits & Self::ALL_BITS)
    }
}

impl<const N: usize> From<[Stage; N]> for Stages {
    fn from(value: [Stage; N]) -> Self {
        Self::from(Vec::from(value))
    }
}

impl<'de> Deserialize<'de> for Stages {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let stages = Vec::<Stage>::deserialize(deserializer)?;
        Ok(Self::from(stages))
    }
}

/// Controls whether filenames are appended to a hook's command line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PassFilenames {
    /// Pass all matching filenames (default). Corresponds to `pass_filenames:
    /// true`.
    All,
    /// Pass no filenames. Corresponds to `pass_filenames: false`.
    None,
    /// Pass at most `n` filenames per invocation. Corresponds to
    /// `pass_filenames: n`.
    Limited(std::num::NonZeroUsize),
}

impl<'de> Deserialize<'de> for PassFilenames {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct PassFilenamesVisitor;

        impl serde::de::Visitor<'_> for PassFilenamesVisitor {
            type Value = PassFilenames;

            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                f.write_str("a boolean or a positive integer")
            }

            fn visit_bool<E: DeError>(self, v: bool) -> Result<PassFilenames, E> {
                Ok(if v {
                    PassFilenames::All
                } else {
                    PassFilenames::None
                })
            }

            fn visit_u64<E: DeError>(self, v: u64) -> Result<PassFilenames, E> {
                let n = usize::try_from(v)
                    .ok()
                    .and_then(std::num::NonZeroUsize::new)
                    .ok_or_else(|| {
                        E::custom(
                            "pass_filenames must be a positive integer; use `false` to pass no filenames",
                        )
                    })?;
                Ok(PassFilenames::Limited(n))
            }

            fn visit_i64<E: DeError>(self, v: i64) -> Result<PassFilenames, E> {
                if v <= 0 {
                    return Err(E::custom(
                        "pass_filenames must be a positive integer; use `false` to pass no filenames",
                    ));
                }
                #[allow(clippy::cast_sign_loss)]
                self.visit_u64(v as u64)
            }
        }

        deserializer.deserialize_any(PassFilenamesVisitor)
    }
}

/// A predefined shell adapter used to run hook entries as shell source.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(rename_all = "lowercase"))]
pub(crate) enum Shell {
    Sh,
    Bash,
    Pwsh,
    Powershell,
    Cmd,
}

/// Common hook options.
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub(crate) struct HookOptions {
    /// Not documented in the official docs.
    pub alias: Option<String>,
    /// The pattern of files to run on.
    pub files: Option<FilePattern>,
    /// Exclude files that were matched by `files`.
    /// Default is `$^`, which matches nothing.
    pub exclude: Option<FilePattern>,
    /// List of file types to run on (AND).
    /// Default is `[file]`, which matches all files.
    pub types: Option<TagSet>,
    /// List of file types to run on (OR).
    /// Default is `[]`.
    pub types_or: Option<TagSet>,
    /// List of file types to exclude.
    /// Default is `[]`.
    pub exclude_types: Option<TagSet>,
    /// Not documented in the official docs.
    pub additional_dependencies: Option<Vec<String>>,
    /// Additional arguments to pass to the hook.
    pub args: Option<Vec<String>>,
    /// Environment variables to set for the hook.
    pub env: Option<FxHashMap<String, String>>,
    /// This hook will run even if there are no matching files.
    /// Default is false.
    pub always_run: Option<bool>,
    /// If this hook fails, don't run any more hooks.
    /// Default is false.
    pub fail_fast: Option<bool>,
    /// Append filenames that would be checked to the hook entry as arguments.
    /// Default is true.
    pub pass_filenames: Option<PassFilenames>,
    /// A description of the hook. For metadata only.
    pub description: Option<String>,
    /// Run the hook on a specific version of the language.
    /// Default is `default`.
    /// See <https://pre-commit.com/#overriding-language-version>.
    pub language_version: Option<String>,
    /// Write the output of the hook to a file when the hook fails or verbose is enabled.
    pub log_file: Option<String>,
    /// Run the hook entry through a predefined shell adapter.
    pub shell: Option<Shell>,
    /// This hook will execute using a single process instead of in parallel.
    /// Default is false.
    pub require_serial: Option<bool>,
    /// Select which Git hook stages this hook runs for.
    /// Default all stages are selected.
    /// See <https://pre-commit.com/#confining-hooks-to-run-at-certain-stages>.
    pub stages: Option<Stages>,
    /// Print the output of the hook even if it passes.
    /// Default is false.
    pub verbose: Option<bool>,
    /// The minimum version of prek required to run this hook.
    #[serde(deserialize_with = "deserialize_and_validate_minimum_version", default)]
    pub minimum_prek_version: Option<String>,

    #[serde(skip_serializing, flatten)]
    pub _unused_keys: BTreeMap<String, serde_json::Value>,
}

impl HookOptions {
    pub fn update(&mut self, other: &Self) {
        macro_rules! update_if_some {
            ($($field:ident),* $(,)?) => {
                $(
                if other.$field.is_some() {
                    self.$field.clone_from(&other.$field);
                }
                )*
            };
        }

        update_if_some!(
            alias,
            files,
            exclude,
            types,
            types_or,
            exclude_types,
            additional_dependencies,
            args,
            always_run,
            fail_fast,
            pass_filenames,
            description,
            language_version,
            log_file,
            shell,
            require_serial,
            stages,
            verbose,
            minimum_prek_version,
        );

        // Merge environment variables.
        if let Some(other_env) = &other.env {
            if let Some(self_env) = &mut self.env {
                self_env.extend(other_env.clone());
            } else {
                self.env.clone_from(&other.env);
            }
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub(crate) struct ManifestHook {
    /// The id of the hook.
    pub id: String,
    /// The name of the hook.
    pub name: String,
    /// The command to run. It can contain arguments that will not be overridden.
    pub entry: String,
    /// The language of the hook. Tells prek how to install and run the hook.
    pub language: Language,
    #[serde(flatten)]
    pub options: HookOptions,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
#[serde(transparent)]
pub(crate) struct Manifest {
    pub hooks: Vec<ManifestHook>,
}

/// A remote hook in the configuration file.
///
/// All keys in manifest hook dict are valid in a config hook dict, but are optional.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub(crate) struct RemoteHook {
    /// The id of the hook.
    pub id: String,
    /// Override the name of the hook.
    pub name: Option<String>,
    /// Override the entrypoint. Not documented in the official docs but works.
    pub entry: Option<String>,
    /// Override the language. Not documented in the official docs but works.
    pub language: Option<Language>,
    /// Priority used by the scheduler to determine ordering and concurrency.
    /// Hooks with the same priority can run in parallel.
    ///
    /// This is only allowed in project config files (e.g. `.pre-commit-config.yaml`).
    /// It is not allowed in manifests (e.g. `.pre-commit-hooks.yaml`).
    pub priority: Option<u32>,
    /// User-defined hook groups used by `prek run --group` and `--no-group`.
    /// Group names cannot be empty or contain whitespace.
    #[serde(default, deserialize_with = "deserialize_groups")]
    #[cfg_attr(feature = "schemars", schemars(inner(regex(pattern = r"^\S+$"))))]
    pub groups: Option<Vec<String>>,
    #[serde(flatten)]
    pub options: HookOptions,
}

/// A local hook in the configuration file.
///
/// This is similar to `ManifestHook`, but includes config-only fields (like `priority`).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub(crate) struct LocalHook {
    /// The id of the hook.
    pub id: String,
    /// The name of the hook.
    pub name: String,
    /// The command to run. It can contain arguments that will not be overridden.
    pub entry: String,
    /// The language of the hook. Tells prek how to install and run the hook.
    pub language: Language,
    /// Priority used by the scheduler to determine ordering and concurrency.
    /// Hooks with the same priority can run in parallel.
    pub priority: Option<u32>,
    /// User-defined hook groups used by `prek run --group` and `--no-group`.
    /// Group names cannot be empty or contain whitespace.
    #[serde(default, deserialize_with = "deserialize_groups")]
    #[cfg_attr(feature = "schemars", schemars(inner(regex(pattern = r"^\S+$"))))]
    pub groups: Option<Vec<String>>,
    #[serde(flatten)]
    pub options: HookOptions,
}

/// A meta hook predefined in pre-commit.
///
/// It's the same as the manifest hook definition but with only a few predefined id allowed.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
#[serde(try_from = "RemoteHook")]
pub(crate) struct MetaHook {
    /// The id of the hook.
    pub id: String,
    /// The name of the hook.
    pub name: String,
    /// Priority used by the scheduler to determine ordering and concurrency.
    /// Hooks with the same priority can run in parallel.
    pub priority: Option<u32>,
    /// User-defined hook groups used by `prek run --group` and `--no-group`.
    /// Group names cannot be empty or contain whitespace.
    #[serde(default, deserialize_with = "deserialize_groups")]
    pub groups: Option<Vec<String>>,
    #[serde(flatten)]
    pub options: HookOptions,
}

#[derive(Debug, thiserror::Error)]
pub(crate) enum PredefinedHookWireError {
    #[error("unknown {kind} hook id `{id}`")]
    UnknownId {
        kind: PredefinedHookKind,
        id: String,
    },

    #[error("language must be `system` for {kind} hooks")]
    InvalidLanguage { kind: PredefinedHookKind },

    #[error("`entry` is not allowed for {kind} hooks")]
    EntryNotAllowed { kind: PredefinedHookKind },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PredefinedHookKind {
    Meta,
    Builtin,
}

impl Display for PredefinedHookKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Meta => f.write_str("meta"),
            Self::Builtin => f.write_str("builtin"),
        }
    }
}

impl TryFrom<RemoteHook> for MetaHook {
    type Error = PredefinedHookWireError;

    fn try_from(hook_options: RemoteHook) -> Result<Self, Self::Error> {
        let mut meta_hook = MetaHook::from_id(&hook_options.id).map_err(|()| {
            PredefinedHookWireError::UnknownId {
                kind: PredefinedHookKind::Meta,
                id: hook_options.id,
            }
        })?;

        if hook_options.language.is_some_and(|l| l != Language::System) {
            return Err(PredefinedHookWireError::InvalidLanguage {
                kind: PredefinedHookKind::Meta,
            });
        }
        if hook_options.entry.is_some() {
            return Err(PredefinedHookWireError::EntryNotAllowed {
                kind: PredefinedHookKind::Meta,
            });
        }

        if let Some(name) = &hook_options.name {
            meta_hook.name.clone_from(name);
        }
        if hook_options.priority.is_some() {
            meta_hook.priority = hook_options.priority;
        }
        if hook_options.groups.is_some() {
            meta_hook.groups = hook_options.groups;
        }
        meta_hook.options.update(&hook_options.options);

        Ok(meta_hook)
    }
}

/// A builtin hook predefined in prek.
/// Basically the same as meta hooks, but defined under `builtin` repo, and do other non-meta checks.
#[derive(Debug, Clone, Deserialize)]
#[serde(try_from = "RemoteHook")]
pub(crate) struct BuiltinHook {
    /// The id of the hook.
    pub id: String,
    /// The name of the hook.
    ///
    /// This is populated from the predefined builtin hook definition.
    pub name: String,
    /// The command to run. It can contain arguments that will not be overridden.
    pub entry: String,
    /// Priority used by the scheduler to determine ordering and concurrency.
    /// Hooks with the same priority can run in parallel.
    pub priority: Option<u32>,
    /// User-defined hook groups used by `prek run --group` and `--no-group`.
    /// Group names cannot be empty or contain whitespace.
    #[serde(default, deserialize_with = "deserialize_groups")]
    pub groups: Option<Vec<String>>,
    /// Common hook options.
    ///
    /// Builtin hooks allow the same set of options overrides as other hooks.
    #[serde(flatten)]
    pub options: HookOptions,
}

impl TryFrom<RemoteHook> for BuiltinHook {
    type Error = PredefinedHookWireError;

    fn try_from(hook_options: RemoteHook) -> Result<Self, Self::Error> {
        let mut builtin_hook = BuiltinHook::from_id(&hook_options.id).map_err(|()| {
            PredefinedHookWireError::UnknownId {
                kind: PredefinedHookKind::Builtin,
                id: hook_options.id,
            }
        })?;

        if hook_options.language.is_some_and(|l| l != Language::System) {
            return Err(PredefinedHookWireError::InvalidLanguage {
                kind: PredefinedHookKind::Builtin,
            });
        }
        if hook_options.entry.is_some() {
            return Err(PredefinedHookWireError::EntryNotAllowed {
                kind: PredefinedHookKind::Builtin,
            });
        }

        if let Some(name) = &hook_options.name {
            builtin_hook.name.clone_from(name);
        }
        if hook_options.priority.is_some() {
            builtin_hook.priority = hook_options.priority;
        }
        if hook_options.groups.is_some() {
            builtin_hook.groups = hook_options.groups;
        }
        builtin_hook.options.update(&hook_options.options);

        Ok(builtin_hook)
    }
}

#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct RemoteRepo {
    repo: String,
    #[serde(skip)]
    resolved_source: Option<String>,
    pub rev: String,
    #[serde(skip_serializing)]
    pub hooks: Vec<RemoteHook>,

    #[serde(skip_serializing, flatten)]
    _unused_keys: BTreeMap<String, serde_json::Value>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct RemoteRepoKey<'a> {
    source: &'a str,
    rev: &'a str,
}

impl<'a> RemoteRepoKey<'a> {
    pub(crate) fn source(self) -> &'a str {
        self.source
    }

    pub(crate) fn rev(self) -> &'a str {
        self.rev
    }
}

impl RemoteRepo {
    pub fn new(repo: String, rev: String, hooks: Vec<RemoteHook>) -> Self {
        Self {
            repo,
            resolved_source: None,
            rev,
            hooks,
            _unused_keys: BTreeMap::new(),
        }
    }

    /// The repository value exactly as written in the configuration.
    pub(crate) fn repo(&self) -> &str {
        &self.repo
    }

    /// The repository source used for fetch and cache identity.
    pub(crate) fn source(&self) -> &str {
        self.resolved_source.as_deref().unwrap_or(&self.repo)
    }

    pub(crate) fn set_resolved_source(&mut self, source: String) {
        self.resolved_source = Some(source);
    }

    pub fn key(&self) -> RemoteRepoKey<'_> {
        RemoteRepoKey {
            source: self.source(),
            rev: &self.rev,
        }
    }
}

impl std::fmt::Debug for RemoteRepo {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let mut debug = f.debug_struct("RemoteRepo");
        debug.field("repo", &self.repo);
        if let Some(source) = &self.resolved_source {
            debug.field("source", source);
        }
        debug
            .field("rev", &self.rev)
            .field("hooks", &self.hooks)
            .field("_unused_keys", &self._unused_keys)
            .finish()
    }
}

impl Display for RemoteRepo {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}@{}", self.repo(), self.rev)
    }
}

#[derive(Debug, Clone, Deserialize)]
pub(crate) struct LocalRepo {
    pub repo: String,
    pub hooks: Vec<LocalHook>,

    #[serde(skip_serializing, flatten)]
    _unused_keys: BTreeMap<String, serde_json::Value>,
}

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

#[derive(Debug, Clone, Deserialize)]
pub(crate) struct MetaRepo {
    pub repo: String,
    pub hooks: Vec<MetaHook>,

    #[serde(skip_serializing, flatten)]
    _unused_keys: BTreeMap<String, serde_json::Value>,
}

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

#[derive(Debug, Clone, Deserialize)]
pub(crate) struct BuiltinRepo {
    pub repo: String,
    pub hooks: Vec<BuiltinHook>,

    #[serde(skip_serializing, flatten)]
    _unused_keys: BTreeMap<String, serde_json::Value>,
}

#[derive(Debug, Clone)]
pub(crate) enum Repo {
    Remote(RemoteRepo),
    Local(LocalRepo),
    Meta(MetaRepo),
    Builtin(BuiltinRepo),
}

impl<'de> Deserialize<'de> for Repo {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct RepoVisitor;

        impl<'de> Visitor<'de> for RepoVisitor {
            type Value = Repo;

            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
                formatter.write_str("a repo mapping")
            }

            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
            where
                M: MapAccess<'de>,
            {
                enum HooksValue {
                    Remote(Vec<RemoteHook>),
                    Local(Vec<LocalHook>),
                    Meta(Vec<MetaHook>),
                    Builtin(Vec<BuiltinHook>),
                }

                let mut repo: Option<String> = None;
                let mut rev: Option<String> = None;
                let mut hooks: Option<HooksValue> = None;
                let mut unused = BTreeMap::new();

                while let Some(key) = map.next_key::<String>()? {
                    match key.as_str() {
                        "repo" => {
                            let repo_value: String = map.next_value()?;
                            repo = Some(repo_value);
                        }
                        "rev" => {
                            rev = Some(map.next_value()?);
                        }
                        "hooks" => {
                            hooks = Some(match repo.as_deref() {
                                Some("local") => HooksValue::Local(map.next_value()?),
                                Some("meta") => HooksValue::Meta(map.next_value()?),
                                Some("builtin") => HooksValue::Builtin(map.next_value()?),
                                // Not seen `repo` yet, assume remote.
                                _ => HooksValue::Remote(map.next_value()?),
                            });
                        }
                        _ => {
                            let value = map.next_value::<serde_json::Value>()?;
                            unused.insert(key, value);
                        }
                    }
                }

                let repo_value = repo.ok_or_else(|| M::Error::missing_field("repo"))?;
                match repo_value.as_str() {
                    "local" => {
                        if rev.is_some() {
                            return Err(M::Error::custom("`rev` is not allowed for local repos"));
                        }
                        let hooks = match hooks.ok_or_else(|| M::Error::missing_field("hooks"))? {
                            HooksValue::Local(hooks) => hooks,
                            HooksValue::Remote(hooks) => hooks
                                .into_iter()
                                .map(remote_hook_to_local::<M::Error>)
                                .collect::<Result<Vec<_>, _>>()?,
                            HooksValue::Meta(_) | HooksValue::Builtin(_) => {
                                return Err(M::Error::custom("invalid hooks for local repo"));
                            }
                        };
                        Ok(Repo::Local(LocalRepo {
                            repo: "local".to_string(),
                            hooks,
                            _unused_keys: unused,
                        }))
                    }
                    "meta" => {
                        if rev.is_some() {
                            return Err(M::Error::custom("`rev` is not allowed for meta repos"));
                        }
                        let hooks = match hooks.ok_or_else(|| M::Error::missing_field("hooks"))? {
                            HooksValue::Meta(hooks) => hooks,
                            HooksValue::Remote(hooks) => hooks
                                .into_iter()
                                .map(|hook| MetaHook::try_from(hook).map_err(M::Error::custom))
                                .collect::<Result<Vec<_>, _>>()?,
                            HooksValue::Local(_) | HooksValue::Builtin(_) => {
                                return Err(M::Error::custom("invalid hooks for meta repo"));
                            }
                        };
                        Ok(Repo::Meta(MetaRepo {
                            repo: "meta".to_string(),
                            hooks,
                            _unused_keys: unused,
                        }))
                    }
                    "builtin" => {
                        if rev.is_some() {
                            return Err(M::Error::custom("`rev` is not allowed for builtin repos"));
                        }
                        let hooks = match hooks.ok_or_else(|| M::Error::missing_field("hooks"))? {
                            HooksValue::Builtin(hooks) => hooks,
                            HooksValue::Remote(hooks) => hooks
                                .into_iter()
                                .map(|hook| BuiltinHook::try_from(hook).map_err(M::Error::custom))
                                .collect::<Result<Vec<_>, _>>()?,
                            HooksValue::Local(_) | HooksValue::Meta(_) => {
                                return Err(M::Error::custom("invalid hooks for builtin repo"));
                            }
                        };
                        Ok(Repo::Builtin(BuiltinRepo {
                            repo: "builtin".to_string(),
                            hooks,
                            _unused_keys: unused,
                        }))
                    }
                    _ => {
                        let rev = rev.ok_or_else(|| M::Error::missing_field("rev"))?;
                        let hooks = match hooks.ok_or_else(|| M::Error::missing_field("hooks"))? {
                            HooksValue::Remote(hooks) => hooks,
                            HooksValue::Local(_) | HooksValue::Meta(_) | HooksValue::Builtin(_) => {
                                return Err(M::Error::custom("invalid hooks for remote repo"));
                            }
                        };
                        Ok(Repo::Remote(RemoteRepo {
                            repo: repo_value,
                            resolved_source: None,
                            rev,
                            hooks,
                            _unused_keys: unused,
                        }))
                    }
                }
            }
        }

        deserializer.deserialize_map(RepoVisitor)
    }
}

fn remote_hook_to_local<E>(hook: RemoteHook) -> Result<LocalHook, E>
where
    E: DeError,
{
    Ok(LocalHook {
        id: hook.id,
        name: hook.name.ok_or_else(|| E::missing_field("name"))?,
        entry: hook.entry.ok_or_else(|| E::missing_field("entry"))?,
        language: hook.language.ok_or_else(|| E::missing_field("language"))?,
        priority: hook.priority,
        groups: hook.groups,
        options: hook.options,
    })
}

/// A configuration value that accepts either one string or a list of strings.
#[derive(Clone, Eq, PartialEq)]
pub(crate) enum StringOrList {
    One(Glob),
    Many(Vec<Glob>),
}

impl std::fmt::Debug for StringOrList {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::One(pattern) => f.debug_tuple("One").field(&pattern.glob()).finish(),
            Self::Many(patterns) => f.debug_tuple("Many").field(&debug_globs(patterns)).finish(),
        }
    }
}

impl<'de> Deserialize<'de> for StringOrList {
    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(untagged)]
        enum RawStringOrList {
            One(String),
            Many(Vec<String>),
        }

        match RawStringOrList::deserialize(deserializer)? {
            RawStringOrList::One(pattern) => {
                pattern.parse().map(Self::One).map_err(D::Error::custom)
            }
            RawStringOrList::Many(patterns) => patterns
                .into_iter()
                .map(|pattern| pattern.parse().map_err(D::Error::custom))
                .collect::<Result<_, _>>()
                .map(Self::Many),
        }
    }
}

impl StringOrList {
    pub(crate) fn as_slice(&self) -> &[Glob] {
        match self {
            Self::One(value) => std::slice::from_ref(value),
            Self::Many(values) => values,
        }
    }
}

/// Overrides tag selection for one repository during `prek update`.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub(crate) struct RepoTagFilterOptions {
    pub(crate) include_tags: Option<StringOrList>,
    pub(crate) exclude_tags: Option<StringOrList>,
}

/// Controls how `prek update` selects eligible releases.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub(crate) struct UpdateOptions {
    pub(crate) cooldown_days: Option<u8>,
    pub(crate) freeze: Option<bool>,
    pub(crate) include_tags: Option<StringOrList>,
    pub(crate) exclude_tags: Option<StringOrList>,
    pub(crate) repos: BTreeMap<String, RepoTagFilterOptions>,
}

// TODO: warn sensible regex
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(
    feature = "schemars",
    derive(schemars::JsonSchema),
    schemars(title = "prek.toml"),
    schemars(description = "The configuration file for prek, a git hook manager written in Rust."),
    schemars(extend("$id" = "https://www.schemastore.org/prek.json")),
    schemars(extend("x-tombi-toml-version" = "v1.1.0")),
)]
pub(crate) struct Config {
    /// Default settings for `prek update` in this project.
    #[serde(alias = "auto_update")]
    pub update: Option<UpdateOptions>,
    pub repos: Vec<Repo>,
    /// A list of `--hook-types` which will be used by default when running `prek install`.
    /// Default is `[pre-commit]`.
    pub default_install_hook_types: Option<Vec<HookType>>,
    /// A mapping from language to the default `language_version`.
    pub default_language_version: Option<FxHashMap<Language, String>>,
    /// A configuration-wide default for the stages property of hooks.
    /// Default to all stages.
    pub default_stages: Option<Stages>,
    /// Default runtime environment variables for hooks.
    pub default_env: Option<FxHashMap<String, String>>,
    /// Global file include pattern.
    pub files: Option<FilePattern>,
    /// Global file exclude pattern.
    pub exclude: Option<FilePattern>,
    /// Set to true to have prek stop running hooks after the first failure.
    /// Default is false.
    pub fail_fast: Option<bool>,
    /// The minimum version of prek required to run this configuration.
    #[serde(deserialize_with = "deserialize_and_validate_minimum_version", default)]
    pub minimum_prek_version: Option<String>,
    /// Set to true to isolate this project from parent configurations in workspace mode.
    /// When true, files in this project are "consumed" by this project and will not be processed
    /// by parent projects.
    /// When false (default), files in subprojects are processed by both the subproject and
    /// any parent projects that contain them.
    pub orphan: Option<bool>,

    #[serde(skip_serializing, flatten)]
    _unused_keys: BTreeMap<String, serde_json::Value>,
}

impl Config {
    /// Resolve local relative repository sources from the config file, not the process cwd.
    fn resolve_relative_repo_sources(&mut self, config_path: &Path) -> Result<(), Error> {
        let config_dir = config_path
            .parent()
            .expect("config file must have a parent");
        for repo in &mut self.repos {
            let Repo::Remote(remote) = repo else {
                continue;
            };

            let configured_repo = remote.repo();
            let repo_path = Path::new(configured_repo);
            if configured_repo.starts_with("http://")
                || configured_repo.starts_with("https://")
                || !repo_path.is_relative()
            {
                continue;
            }

            let resolved = config_dir.join(repo_path);
            if resolved.is_dir() {
                remote.set_resolved_source(
                    dunce::canonicalize(resolved)?
                        .to_string_lossy()
                        .into_owned(),
                );
            }
        }

        Ok(())
    }
}

#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
    #[error(transparent)]
    Io(#[from] std::io::Error),

    #[error("Failed to parse `{0}`")]
    Yaml(String, #[source] Box<serde_saphyr::Error>),

    #[error("Failed to parse `{0}`")]
    Toml(String, #[source] Box<toml::de::Error>),
}

impl Error {
    /// Warn the user if the config error is a parse error (not "file not found").
    pub(crate) fn warn_parse_error(&self) {
        // Skip file not found errors.
        if matches!(self, Self::Io(e) if e.kind() == std::io::ErrorKind::NotFound) {
            return;
        }
        if let Some(cause) = self.source() {
            warn_user_once!("{self}: {cause}");
        } else {
            warn_user_once!("{self}");
        }
    }
}

/// Keys that prek does not use.
const EXPECTED_UNUSED: &[&str] = &["minimum_pre_commit_version", "ci"];

fn push_unused_paths<'a, I>(acc: &mut Vec<String>, prefix: &str, keys: I)
where
    I: Iterator<Item = &'a str>,
{
    for key in keys {
        let path = if prefix.is_empty() {
            key.to_string()
        } else {
            format!("{prefix}.{key}")
        };
        acc.push(path);
    }
}

fn collect_unused_paths(config: &Config) -> Vec<String> {
    let mut paths = Vec::new();

    push_unused_paths(
        &mut paths,
        "",
        config._unused_keys.keys().filter_map(|key| {
            let key = key.as_str();
            (!EXPECTED_UNUSED.contains(&key)).then_some(key)
        }),
    );

    for (repo_idx, repo) in config.repos.iter().enumerate() {
        let repo_prefix = format!("repos[{repo_idx}]");
        let (repo_unused_keys, hooks_options): (_, Box<dyn Iterator<Item = &HookOptions>>) =
            match repo {
                Repo::Remote(remote) => (
                    &remote._unused_keys,
                    Box::new(remote.hooks.iter().map(|h| &h.options)),
                ),
                Repo::Local(local) => (
                    &local._unused_keys,
                    Box::new(local.hooks.iter().map(|h| &h.options)),
                ),
                Repo::Meta(meta) => (
                    &meta._unused_keys,
                    Box::new(meta.hooks.iter().map(|h| &h.options)),
                ),
                Repo::Builtin(builtin) => (
                    &builtin._unused_keys,
                    Box::new(builtin.hooks.iter().map(|h| &h.options)),
                ),
            };

        push_unused_paths(
            &mut paths,
            &repo_prefix,
            repo_unused_keys.keys().map(String::as_str),
        );
        for (hook_idx, options) in hooks_options.enumerate() {
            let hook_prefix = format!("{repo_prefix}.hooks[{hook_idx}]");
            push_unused_paths(
                &mut paths,
                &hook_prefix,
                options._unused_keys.keys().map(String::as_str),
            );
        }
    }

    paths
}

fn warn_unused_paths(path: &Path, entries: &[String]) {
    if entries.is_empty() {
        return;
    }

    if entries.len() < 4 {
        let inline = entries
            .iter()
            .map(|entry| format!("`{}`", entry.yellow()))
            .join(", ");
        warn_user!(
            "Ignored unexpected keys in `{}`: {inline}",
            path.user_display().cyan()
        );
    } else {
        let list = entries
            .iter()
            .map(|entry| format!("  - `{}`", entry.yellow()))
            .join("\n");
        warn_user!(
            "Ignored unexpected keys in `{}`:\n{list}",
            path.user_display().cyan()
        );
    }
}

/// Read the configuration file from the given path.
pub(crate) fn load_config(path: &Path) -> Result<Config, Error> {
    let content = fs_err::read_to_string(path)?;

    let mut config: Config = match path.extension() {
        Some(ext) if ext.eq_ignore_ascii_case("toml") => toml::from_str(&content)
            .map_err(|e| Error::Toml(path.user_display().to_string(), Box::new(e)))?,
        _ => serde_saphyr::from_str(&content)
            .map_err(|e| Error::Yaml(path.user_display().to_string(), Box::new(e)))?,
    };
    config.resolve_relative_repo_sources(path)?;

    Ok(config)
}

/// Read the configuration file from the given path, and warn about certain issues.
pub(crate) fn read_config(path: &Path) -> Result<Config, Error> {
    let config = load_config(path)?;

    let unused_paths = collect_unused_paths(&config);
    warn_unused_paths(path, &unused_paths);

    // Check for mutable revs and warn the user.
    let repos_has_mutable_rev = config
        .repos
        .iter()
        .filter_map(|repo| {
            if let Repo::Remote(repo) = repo {
                let rev = &repo.rev;
                // A rev is considered mutable if it doesn't contain a '.' (like a version)
                // and is not a hexadecimal string (like a commit SHA).
                if !rev.contains('.') && !looks_like_sha(rev) {
                    return Some(repo);
                }
            }
            None
        })
        .collect::<Vec<_>>();
    if !repos_has_mutable_rev.is_empty() {
        let msg = repos_has_mutable_rev
            .iter()
            .map(|repo| format!("{}: {}", repo.repo().cyan(), repo.rev.yellow()))
            .join("\n");

        warn_user!(
            "{}",
            indoc::formatdoc! { r#"
            The following repos have mutable `rev` fields (moving tag / branch):
            {}
            Mutable references are never updated after first install and are not supported.
            See https://pre-commit.com/#using-the-latest-version-for-a-repository for more details.
            hint: `prek update` often fixes this",
            "#,
            msg
            }
        );
    }

    Ok(config)
}

/// Read the manifest file from the given path.
pub(crate) fn read_manifest(path: &Path) -> Result<Manifest, Error> {
    let content = fs_err::read_to_string(path)?;
    let manifest: Manifest = serde_saphyr::from_str(&content)
        .map_err(|e| Error::Yaml(path.user_display().to_string(), Box::new(e)))?;

    Ok(manifest)
}

/// Check if a string looks like a git SHA-1.
pub(crate) fn looks_like_sha(s: &str) -> bool {
    !s.is_empty() && s.as_bytes().iter().all(u8::is_ascii_hexdigit)
}

fn deserialize_and_validate_minimum_version<'de, D>(
    deserializer: D,
) -> Result<Option<String>, D::Error>
where
    D: Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    if s.is_empty() {
        return Ok(None);
    }

    let version = s
        .parse::<semver::Version>()
        .map_err(serde::de::Error::custom)?;
    let cur_version = version::version()
        .version
        .parse::<semver::Version>()
        .expect("Invalid prek version");
    if version > cur_version {
        let hint = InstallSource::detect()
            .map(|s| format!("To update, run `{}`.", s.update_instructions()))
            .unwrap_or("Please consider updating prek".to_string());

        return Err(serde::de::Error::custom(format!(
            "Required minimum prek version `{version}` is greater than current version `{cur_version}`; {hint}",
        )));
    }

    Ok(Some(s))
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::ValueEnum;
    use std::io::Write as _;

    /// Filter to replace dynamic version in snapshots
    const VERSION_FILTER: (&str, &str) = (
        r"current version `\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?`",
        "current version `[CURRENT_VERSION]`",
    );

    #[test]
    fn stages_deserialize_empty_as_empty() {
        #[derive(Debug, Deserialize)]
        struct Wrapper {
            stages: Stages,
        }

        let parsed: Wrapper = serde_saphyr::from_str("stages: []\n").expect("stages should parse");
        assert_eq!(parsed.stages, Stages::from([]));
        assert!(!parsed.stages.contains(Stage::Manual));
        assert!(!parsed.stages.contains(Stage::PreCommit));
    }

    #[test]
    fn config_default_stages_deserialize_empty_as_empty() {
        let parsed: Config =
            serde_saphyr::from_str("repos: []\ndefault_stages: []\n").expect("config should parse");

        assert_eq!(parsed.default_stages, Some(Stages::from([])));
    }

    #[test]
    fn config_default_stages_omitted_keeps_none() {
        let parsed: Config = serde_saphyr::from_str("repos: []\n").expect("config should parse");

        assert_eq!(parsed.default_stages, None);
    }

    #[test]
    fn hook_groups_reject_invalid_names_during_deserialize() {
        for (groups, expected) in [
            (r#"[""]"#, "group name `` cannot be empty"),
            (
                r#"["ci slow"]"#,
                "group name `ci slow` cannot contain whitespace",
            ),
            (
                r#"["ci\tslow"]"#,
                "group name `ci\tslow` cannot contain whitespace",
            ),
        ] {
            let yaml = indoc::formatdoc! {r"
                repos:
                  - repo: local
                    hooks:
                      - id: lint
                        name: Lint
                        language: system
                        entry: echo lint
                        groups: {groups}
            "};
            let err = serde_saphyr::from_str::<Config>(&yaml).unwrap_err();
            let msg = err.to_string();
            assert!(msg.contains(expected), "expected `{expected}` in `{msg}`");
        }
    }

    #[test]
    fn stages_deserialize_to_subset() {
        #[derive(Debug, Deserialize)]
        struct Wrapper {
            stages: Stages,
        }

        let parsed: Wrapper =
            serde_saphyr::from_str("stages: [pre-commit, manual]\n").expect("stages should parse");
        assert!(parsed.stages.contains(Stage::PreCommit));
        assert!(parsed.stages.contains(Stage::Manual));
        assert!(!parsed.stages.contains(Stage::PrePush));
    }

    #[test]
    fn stages_full_set_normalizes_to_all() {
        assert_eq!(Stages::from(Stage::value_variants().to_vec()), Stages::ALL);
    }

    #[test]
    fn parse_file_patterns_regex_and_glob() {
        #[derive(Debug, Deserialize)]
        struct Wrapper {
            files: FilePattern,
            exclude: FilePattern,
        }

        let regex_yaml = indoc::indoc! {r"
            files: ^src/
            exclude: ^target/
        "};
        let parsed: Wrapper =
            serde_saphyr::from_str(regex_yaml).expect("regex patterns should parse");
        assert!(
            matches!(parsed.files, FilePattern::Regex(_)),
            "expected regex pattern"
        );
        assert!(parsed.files.is_match(Path::new("src/main.rs")));
        assert!(!parsed.files.is_match(Path::new("other/main.rs")));
        assert!(parsed.exclude.is_match(Path::new("target/debug/app")));

        let glob_yaml = indoc::indoc! {r"
            files:
              glob: src/**/*.rs
            exclude:
              glob: target/**
        "};
        let parsed: Wrapper =
            serde_saphyr::from_str(glob_yaml).expect("glob patterns should parse");
        assert!(
            matches!(parsed.files, FilePattern::Glob(_)),
            "expected glob pattern"
        );
        assert!(parsed.files.is_match(Path::new("src/lib/main.rs")));
        assert!(!parsed.files.is_match(Path::new("src/lib/main.py")));
        assert!(parsed.exclude.is_match(Path::new("target/debug/app")));
        assert!(!parsed.exclude.is_match(Path::new("src/lib/main.rs")));

        let glob_list_yaml = indoc::indoc! {r"
            files:
              glob:
                - src/**/*.rs
                - crates/**/src/**/*.rs
            exclude:
              glob:
                - target/**
                - dist/**
        "};
        let parsed: Wrapper =
            serde_saphyr::from_str(glob_list_yaml).expect("glob list patterns should parse");
        assert!(parsed.files.is_match(Path::new("src/lib/main.rs")));
        assert!(parsed.files.is_match(Path::new("crates/foo/src/lib.rs")));
        assert!(!parsed.files.is_match(Path::new("tests/main.rs")));
        assert!(parsed.exclude.is_match(Path::new("target/debug/app")));
        assert!(parsed.exclude.is_match(Path::new("dist/app")));
    }

    #[test]
    fn file_patterns_expose_sources_and_display() {
        let pattern: FilePattern = serde_saphyr::from_str(indoc::indoc! {r"
            glob:
              - src/**/*.rs
              - crates/**/src/**/*.rs
        "})
        .expect("glob list should parse");
        assert_eq!(
            pattern.to_string(),
            "glob: [src/**/*.rs, crates/**/src/**/*.rs]"
        );
        assert!(pattern.is_match(Path::new("src/main.rs")));
        assert!(pattern.is_match(Path::new("crates/foo/src/lib.rs")));
        assert!(!pattern.is_match(Path::new("tests/main.rs")));
    }

    #[test]
    fn file_pattern_never_matches() {
        let pattern = FilePattern::Never;
        assert!(!pattern.is_match(Path::new("")));
        assert!(!pattern.is_match(Path::new("foo.txt")));
        assert!(!pattern.is_match(Path::new("nested/path.rs")));
    }

    #[test]
    fn empty_glob_list_matches_nothing() {
        let pattern = serde_saphyr::from_str::<FilePattern>("glob: []").unwrap();
        assert!(!pattern.is_match(Path::new("any/file.rs")));
        assert!(!pattern.is_match(Path::new("")));
    }

    #[test]
    fn invalid_glob_pattern_errors() {
        let err = serde_saphyr::from_str::<FilePattern>("glob: \"[\"")
            .expect_err("invalid glob should fail");
        let msg = err.to_string().to_lowercase();
        assert!(
            msg.contains("glob"),
            "error should mention glob issues: {msg}"
        );
    }

    #[test]
    fn parse_repos() {
        let yaml = indoc::indoc! {r"
            repos:
              - repo: local
                hooks:
                  - id: cargo-fmt
                    name: cargo fmt
                    entry: cargo fmt --
                    language: system
        "};
        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();
        insta::assert_debug_snapshot!(result);

        // Local hook should not have `rev`
        let yaml = indoc::indoc! {r"
            repos:
              - repo: local
                rev: v1.0.0
                hooks:
                  - id: cargo-fmt
                    name: cargo fmt
                    language: system
                    entry: cargo fmt
                    types:
                      - rust
        "};
        // Error on extra `rev` field, but not other fields
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 2 column 5: `rev` is not allowed for local repos
         --> <input>:2:5
          |
        1 | repos:
        2 |   - repo: local
          |     ^ `rev` is not allowed for local repos
        3 |     rev: v1.0.0
        4 |     hooks:
          |
        ");

        // Allow but warn on extra fields (other than `rev`)
        let yaml = indoc::indoc! {r"
            repos:
              - repo: local
                unknown_field: some_value
                hooks:
                  - id: cargo-fmt
                    name: cargo fmt
                    entry: cargo fmt
                    language: system
                    types:
                      - rust
        "};
        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();
        insta::assert_debug_snapshot!(result);

        // Remote hook should have `rev`.
        let yaml = indoc::indoc! {r"
            repos:
              - repo: https://github.com/crate-ci/typos
                rev: v1.0.0
                hooks:
                  - id: typos
        "};
        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();
        insta::assert_debug_snapshot!(result);

        let yaml = indoc::indoc! {r"
            repos:
              - repo: https://github.com/crate-ci/typos
                hooks:
                  - id: typos
        "};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 3 column 5: missing field `rev`
         --> <input>:3:5
          |
        1 | repos:
        2 |   - repo: https://github.com/crate-ci/typos
        3 |     hooks:
          |     ^ missing field `rev`
        4 |       - id: typos
          |
        ");

        // Allow `rev` before `repo`
        let yaml = indoc::indoc! {r"
            repos:
              - rev: v1.0.0
                repo: https://github.com/crate-ci/typos
                hooks:
                  - id: typos
        "};
        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();
        insta::assert_debug_snapshot!(result);

        let yaml = indoc::indoc! {r"
            repos:
              - rev: v1.0.0
                repo: local
                hooks:
                  - id: typos
        "};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 5 column 9: missing field `name`
         --> <input>:5:9
          |
        3 |     repo: local
        4 |     hooks:
        5 |       - id: typos
          |         ^ missing field `name`
        ");

        let yaml = indoc::indoc! {r"
            repos:
              - rev: v1.0.0
                repo: meta
                hooks:
                  - id: typos
        "};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 5 column 9: unknown meta hook id `typos`
         --> <input>:5:9
          |
        3 |     repo: meta
        4 |     hooks:
        5 |       - id: typos
          |         ^ unknown meta hook id `typos`
        ");

        let yaml = indoc::indoc! {r"
            repos:
              - rev: v1.0.0
                repo: builtin
                hooks:
                  - id: typos
        "};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 5 column 9: unknown builtin hook id `typos`
         --> <input>:5:9
          |
        3 |     repo: builtin
        4 |     hooks:
        5 |       - id: typos
          |         ^ unknown builtin hook id `typos`
        ");
    }

    #[test]
    fn parse_hooks() {
        // Remote hook only `id` is required.
        let yaml = indoc::indoc! { r"
            repos:
              - repo: https://github.com/crate-ci/typos
                rev: v1.0.0
                hooks:
                  - name: typos
                    alias: typo
        "};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 6 column 9: missing field `id`
         --> <input>:6:9
          |
        4 |     hooks:
        5 |       - name: typos
        6 |         alias: typo
          |         ^ missing field `id`
        ");

        // Local hook should have `id`, `name`, and `entry` and `language`.
        let yaml = indoc::indoc! { r"
            repos:
              - repo: local
                hooks:
                  - id: cargo-fmt
                    name: cargo fmt
                    entry: cargo fmt
                    types:
                      - rust
        "};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 7 column 9: missing field `language`
         --> <input>:7:9
          |
        5 |         name: cargo fmt
        6 |         entry: cargo fmt
        7 |         types:
          |         ^ missing field `language`
        8 |           - rust
          |
        ");

        let yaml = indoc::indoc! { r"
            repos:
              - repo: local
                hooks:
                  - id: cargo-fmt
                    name: cargo fmt
                    entry: cargo fmt
                    language: rust
        "};
        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();
        insta::assert_debug_snapshot!(result);
    }

    #[test]
    fn meta_hooks() {
        // Invalid rev
        let yaml = indoc::indoc! { r"
            repos:
              - repo: meta
                rev: v1.0.0
                hooks:
                  - name: typos
                    alias: typo
        "};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 6 column 9: missing field `id`
         --> <input>:6:9
          |
        4 |     hooks:
        5 |       - name: typos
        6 |         alias: typo
          |         ^ missing field `id`
        ");

        // Invalid meta hook id
        let yaml = indoc::indoc! { r"
            repos:
              - repo: meta
                hooks:
                  - id: hello
        "};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 4 column 9: unknown meta hook id `hello`
         --> <input>:4:9
          |
        2 |   - repo: meta
        3 |     hooks:
        4 |       - id: hello
          |         ^ unknown meta hook id `hello`
        ");

        // Invalid language
        let yaml = indoc::indoc! { r"
            repos:
              - repo: meta
                hooks:
                  - id: check-hooks-apply
                    language: python
        "};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 4 column 9: language must be `system` for meta hooks
         --> <input>:4:9
          |
        2 |   - repo: meta
        3 |     hooks:
        4 |       - id: check-hooks-apply
          |         ^ language must be `system` for meta hooks
        5 |         language: python
          |
        ");

        // Invalid entry
        let yaml = indoc::indoc! { r"
            repos:
              - repo: meta
                hooks:
                  - id: check-hooks-apply
                    entry: echo hell world
        "};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 4 column 9: `entry` is not allowed for meta hooks
         --> <input>:4:9
          |
        2 |   - repo: meta
        3 |     hooks:
        4 |       - id: check-hooks-apply
          |         ^ `entry` is not allowed for meta hooks
        5 |         entry: echo hell world
          |
        ");

        // Valid meta hook
        let yaml = indoc::indoc! { r"
            repos:
              - repo: meta
                hooks:
                  - id: check-hooks-apply
                  - id: check-useless-excludes
                  - id: identity
        "};
        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();
        insta::assert_debug_snapshot!(result);
    }

    #[test]
    fn language_version() {
        let yaml = indoc::indoc! { r"
            repos:
              - repo: local
                hooks:
                  - id: hook-1
                    name: hook 1
                    entry: echo hello world
                    language: system
                    language_version: default
                  - id: hook-2
                    name: hook 2
                    entry: echo hello world
                    language: system
                    language_version: system
                  - id: hook-3
                    name: hook 3
                    entry: echo hello world
                    language: system
                    language_version: '3.8'
        "};
        let result = serde_saphyr::from_str::<Config>(yaml);
        insta::assert_debug_snapshot!(result);
    }

    #[test]
    fn parse_update_options() {
        let yaml = indoc::indoc! {r#"
            update:
              cooldown_days: 7
              freeze: true
              include_tags: "v*"
              exclude_tags: ["*-rc*"]
              repos:
                "https://example.com/repo":
                  include_tags: "v1.*"
                  exclude_tags: [nightly, "*-dev*"]
            repos: []
        "#};
        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();
        let options = result.update.unwrap();

        insta::assert_debug_snapshot!(options, @r###"
        UpdateOptions {
            cooldown_days: Some(
                7,
            ),
            freeze: Some(
                true,
            ),
            include_tags: Some(
                One(
                    "v*",
                ),
            ),
            exclude_tags: Some(
                Many(
                    [
                        "*-rc*",
                    ],
                ),
            ),
            repos: {
                "https://example.com/repo": RepoTagFilterOptions {
                    include_tags: Some(
                        One(
                            "v1.*",
                        ),
                    ),
                    exclude_tags: Some(
                        Many(
                            [
                                "nightly",
                                "*-dev*",
                            ],
                        ),
                    ),
                },
            },
        }
        "###);
    }

    #[test]
    fn parse_update_options_rejects_invalid_glob() {
        let yaml = indoc::indoc! {r#"
            update:
              include_tags: "["
            repos: []
        "#};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();

        insta::assert_snapshot!(err, @r#"
        error: line 2 column 17: error parsing glob '[': unclosed character class; missing ']'
         --> <input>:2:17
          |
        1 | update:
        2 |   include_tags: "["
          |                 ^ error parsing glob '[': unclosed character class; missing ']'
        3 | repos: []
          |
        "#);
    }

    #[test]
    fn parse_legacy_update_key_alias() {
        let yaml = indoc::indoc! {r"
            auto_update:
              cooldown_days: 7
            repos: []
        "};
        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();

        assert_eq!(
            result.update.and_then(|options| options.cooldown_days),
            Some(7)
        );
    }

    #[test]
    fn test_read_yaml_config() -> Result<()> {
        let config = read_config(Path::new("tests/fixtures/uv-pre-commit-config.yaml"))?;
        insta::assert_debug_snapshot!(config);
        Ok(())
    }

    #[test]
    fn test_read_toml_config() -> Result<()> {
        let dir = tempfile::tempdir()?;
        let toml_path = dir.path().join("prek.toml");
        fs_err::write(
            &toml_path,
            indoc::indoc! {r#"
            fail_fast = true

            [[repos]]
            repo = "local"

            [[repos.hooks]]
            id = "cargo-fmt"
            name = "cargo fmt"
            entry = "cargo fmt --"
            language = "system"

            [[repos]]
            repo = "https://github.com/pre-commit/pre-commit-hooks"
            rev = "v6.0.0"
            hooks = [
            { id = "trailing-whitespace" },
            {
                id = "end-of-file-fixer",
                args = ["--fix", "crlf"]
            }
            ]
        "#},
        )?;

        let config = read_config(&toml_path)?;
        insta::assert_debug_snapshot!(config);

        Ok(())
    }

    #[test]
    fn test_read_invalid_toml_config() {
        let raw = indoc::indoc! {r#"
            fail_fast = true

            [[repos]]
            repo = "local"

            [[repos.hooks]]
            id = "cargo-fmt"
            name = "cargo fmt"
            entry = "cargo fmt --"
            language = "system"

            [[repos]]
            repo = "https://github.com/pre-commit/pre-commit-hooks"
            hooks = [
            { id = "trailing-whitespace" },
            {
                id = "end-of-file-fixer",
                args = ["--fix", "crlf"]
            }
            ]
        "#};

        let err = toml::from_str::<Config>(raw).unwrap_err();
        insta::assert_snapshot!(err, @"
        TOML parse error at line 12, column 1
           |
        12 | [[repos]]
           | ^^^^^^^^^
        missing field `rev`
        ");

        let raw = indoc::indoc! {r#"
            fail_fast = true

            [[repos]]
            repo = "local"
            rev = "v1.0.0"

            [[repos.hooks]]
            id = "cargo-fmt"
            name = "cargo fmt"
            entry = "cargo fmt --"
            language = "system"

            [[repos]]
            repo = "https://github.com/pre-commit/pre-commit-hooks"
            rev = "v6.0.0"
            hooks = [
            { id = "trailing-whitespace" },
            {
                id = "end-of-file-fixer",
                args = ["--fix", "crlf"]
            }
            ]
        "#};

        let err = toml::from_str::<Config>(raw).unwrap_err();
        insta::assert_snapshot!(err, @"
        TOML parse error at line 3, column 1
          |
        3 | [[repos]]
          | ^^^^^^^^^
        `rev` is not allowed for local repos
        ");
    }

    #[test]
    fn test_read_manifest() -> Result<()> {
        let manifest = read_manifest(Path::new("tests/fixtures/uv-pre-commit-hooks.yaml"))?;
        insta::assert_debug_snapshot!(manifest);
        Ok(())
    }

    #[test]
    fn test_minimum_prek_version() {
        // Test that missing minimum_prek_version field doesn't cause an error
        let yaml = indoc::indoc! {r"
            repos:
              - repo: local
                hooks:
                  - id: test-hook
                    name: Test Hook
                    entry: echo test
                    language: system
        "};
        let config = serde_saphyr::from_str::<Config>(yaml).unwrap();
        assert!(config.minimum_prek_version.is_none());

        // Test that empty minimum_prek_version field is treated as None
        let yaml = indoc::indoc! {r"
            repos:
              - repo: local
                hooks:
                  - id: test-hook
                    name: Test Hook
                    entry: echo test
                    language: system
            minimum_prek_version: ''
        "};
        let config = serde_saphyr::from_str::<Config>(yaml).unwrap();
        assert!(config.minimum_prek_version.is_none());

        // Test that valid minimum_prek_version field works in top-level config
        let yaml = indoc::indoc! {r"
            repos:
              - repo: local
                hooks:
                  - id: test-hook
                    name: Test Hook
                    entry: echo test
                    language: system
            minimum_prek_version: '10.0.0'
        "};
        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
        insta::with_settings!({ filters => vec![VERSION_FILTER] }, {
            insta::assert_snapshot!(err, @"
            error: line 8 column 23: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek
             --> <input>:8:23
              |
            6 |         entry: echo test
            7 |         language: system
            8 | minimum_prek_version: '10.0.0'
              |                       ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek
            ");
        });

        // Test that valid minimum_prek_version field works in hook config
        let yaml = indoc::indoc! {r"
          - id: test-hook
            name: Test Hook
            entry: echo test
            language: system
            minimum_prek_version: '10.0.0'
        "};
        let err = serde_saphyr::from_str::<Manifest>(yaml).unwrap_err();
        insta::with_settings!({ filters => vec![VERSION_FILTER] }, {
            insta::assert_snapshot!(err, @"
            error: line 1 column 3: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek
             --> <input>:1:3
              |
            1 | - id: test-hook
              |   ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek
            2 |   name: Test Hook
            3 |   entry: echo test
              |
            ");
        });
    }

    #[test]
    fn test_validate_type_tags() {
        // Valid tags should parse successfully
        let yaml_valid = indoc::indoc! { r"
            repos:
              - repo: local
                hooks:
                  - id: my-hook
                    name: My Hook
                    entry: echo
                    language: system
                    types: [python, file]
                    types_or: [text, binary]
                    exclude_types: [symlink]
        "};
        let result = serde_saphyr::from_str::<Config>(yaml_valid);
        assert!(result.is_ok(), "Should parse valid tags successfully");

        // Empty lists and missing keys should also be fine
        let yaml_empty = indoc::indoc! { r"
            repos:
              - repo: local
                hooks:
                  - id: my-hook
                    name: My Hook
                    entry: echo
                    language: system
                    types: []
                    exclude_types: []
                    # types_or is missing, which is also valid
        "};
        let result_empty = serde_saphyr::from_str::<Config>(yaml_empty);
        assert!(
            result_empty.is_ok(),
            "Should parse empty/missing tags successfully"
        );

        // Invalid tag in 'types' should fail
        let yaml_invalid_types = indoc::indoc! { r"
            repos:
              - repo: local
                hooks:
                  - id: my-hook
                    name: My Hook
                    entry: echo
                    language: system
                    types: [pythoon] # Deliberate typo
        "};
        let err = serde_saphyr::from_str::<Config>(yaml_invalid_types).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 4 column 9: Type tag `pythoon` is not recognized. Check for typos or upgrade prek to get new tags.
         --> <input>:4:9
          |
        2 |   - repo: local
        3 |     hooks:
        4 |       - id: my-hook
          |         ^ Type tag `pythoon` is not recognized. Check for typos or upgrade prek to get new tags.
        5 |         name: My Hook
        6 |         entry: echo
          |
        ");

        // Invalid tag in 'types_or' should fail
        let yaml_invalid_types_or = indoc::indoc! { r"
            repos:
              - repo: local
                hooks:
                  - id: my-hook
                    name: My Hook
                    entry: echo
                    language: system
                    types_or: [invalidtag]
        "};
        let err = serde_saphyr::from_str::<Config>(yaml_invalid_types_or).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 4 column 9: Type tag `invalidtag` is not recognized. Check for typos or upgrade prek to get new tags.
         --> <input>:4:9
          |
        2 |   - repo: local
        3 |     hooks:
        4 |       - id: my-hook
          |         ^ Type tag `invalidtag` is not recognized. Check for typos or upgrade prek to get new tags.
        5 |         name: My Hook
        6 |         entry: echo
          |
        ");

        // Invalid tag in 'exclude_types' should fail
        let yaml_invalid_exclude_types = indoc::indoc! { r"
            repos:
              - repo: local
                hooks:
                  - id: my-hook
                    name: My Hook
                    entry: echo
                    language: system
                    exclude_types: [not-a-real-tag]
        "};
        let err = serde_saphyr::from_str::<Config>(yaml_invalid_exclude_types).unwrap_err();
        insta::assert_snapshot!(err, @"
        error: line 4 column 9: Type tag `not-a-real-tag` is not recognized. Check for typos or upgrade prek to get new tags.
         --> <input>:4:9
          |
        2 |   - repo: local
        3 |     hooks:
        4 |       - id: my-hook
          |         ^ Type tag `not-a-real-tag` is not recognized. Check for typos or upgrade prek to get new tags.
        5 |         name: My Hook
        6 |         entry: echo
          |
        ");
    }

    #[test]
    fn read_config_with_merge_keys() -> Result<()> {
        let yaml = indoc::indoc! {r#"
            repos:
              - repo: local
                hooks:
                  - id: mypy-local
                    name: Local mypy
                    entry: python tools/pre_commit/mypy.py 0 "local"
                    <<: &mypy_common
                      language: python
                      types_or: [python, pyi]
                  - id: mypy-3.10
                    name: Mypy 3.10
                    entry: python tools/pre_commit/mypy.py 1 "3.10"
                    <<: *mypy_common
        "#};

        let mut file = tempfile::NamedTempFile::new()?;
        file.write_all(yaml.as_bytes())?;

        let config = read_config(file.path())?;
        insta::assert_debug_snapshot!(config);

        Ok(())
    }

    #[test]
    fn read_config_with_nested_merge_keys() -> Result<()> {
        let yaml = indoc::indoc! {r"
            local: &local
              language: system
              pass_filenames: false
              require_serial: true

            local-commit: &local-commit
              <<: *local
              stages: [pre-commit]

            repos:
            - repo: local
              hooks:
              - id: test-yaml
                name: Test YAML compatibility
                entry: prek --help
                <<: *local-commit
        "};

        let mut file = tempfile::NamedTempFile::new()?;
        file.write_all(yaml.as_bytes())?;

        let config = read_config(file.path())?;
        insta::assert_debug_snapshot!(config);

        Ok(())
    }

    #[test]
    fn test_list_with_unindented_square() {
        let yaml = indoc::indoc! {r#"
        repos:
          - repo: https://github.com/pre-commit/mirrors-mypy
            rev: v1.18.2
            hooks:
              - id: mypy
                exclude: tests/data
                args: [ "--pretty", "--show-error-codes" ]
                additional_dependencies: [
                  'keyring==24.2.0',
                  'nox==2024.03.02',
                  'pytest',
                  'types-docutils==0.20.0.3',
                  'types-setuptools==68.2.0.0',
                  'types-freezegun==1.1.10',
                  'types-pyyaml==6.0.12.12',
                  'typing-extensions',
                ]
        "#};
        let result = serde_saphyr::from_str::<Config>(yaml);
        assert!(result.is_ok());
    }

    #[test]
    fn test_numeric_rev_is_parsed_as_string() {
        // Because we define `rev` as a String, `serde-saphyr` can automatically parse numeric
        // revs as strings.
        let yaml = indoc::indoc! {r"
        repos:
          - repo: https://github.com/pre-commit/mirrors-mypy
            rev: 1.0
            hooks:
              - id: mypy
        "};
        let config = serde_saphyr::from_str::<Config>(yaml).unwrap();
        insta::assert_debug_snapshot!(config);
    }

    #[test]
    fn pass_filenames_zero_is_rejected() {
        let yaml = indoc::indoc! {r"
            repos:
              - repo: local
                hooks:
                  - id: invalid-pass-filenames-zero
                    name: invalid pass_filenames zero
                    entry: echo
                    language: system
                    pass_filenames: 0
        "};
        let result = serde_saphyr::from_str::<Config>(yaml);
        assert!(result.is_err());
    }

    #[test]
    fn pass_filenames_negative_is_rejected() {
        let yaml = indoc::indoc! {r"
            repos:
              - repo: local
                hooks:
                  - id: invalid-pass-filenames-negative
                    name: invalid pass_filenames negative
                    entry: echo
                    language: system
                    pass_filenames: -1
        "};
        let result = serde_saphyr::from_str::<Config>(yaml);
        assert!(result.is_err());
    }

    #[test]
    fn pass_filenames_string_is_rejected() {
        let yaml = indoc::indoc! {r#"
            repos:
              - repo: local
                hooks:
                  - id: invalid-pass-filenames-string
                    name: invalid pass_filenames string
                    entry: echo
                    language: system
                    pass_filenames: "foo"
        "#};
        let result = serde_saphyr::from_str::<Config>(yaml);
        assert!(result.is_err());
    }
}
