//! TOML configuration loading.
//!
//! Two layers:
//!
//! 1. **Global** (`~/.config/limb/config.toml`). User-wide settings such
//!    as `projects.roots`, theme, shell prefix.
//! 2. **Per-repo** (`.limb.toml` at any ancestor directory). Per-workspace
//!    settings such as shared files, templates, and hooks.
//!
//! The `*File` structs are the serde-deserialise shapes; [`Global`] and
//! [`Repo`] are the resolved runtime views the rest of the crate consumes.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::refresh::{self, Thresholds};

/// File name of the per-repo config: `.limb.toml`.
pub const REPO_CONFIG_FILENAME: &str = ".limb.toml";
/// Directory under `$XDG_CONFIG_HOME` holding global config: `limb`.
pub const GLOBAL_CONFIG_DIRNAME: &str = "limb";
/// File name of the global config: `config.toml`.
pub const GLOBAL_CONFIG_FILENAME: &str = "config.toml";

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GlobalConfigFile {
    #[serde(default)]
    pub projects: ProjectsFile,
    #[serde(default)]
    pub ui: UiFile,
    #[serde(default)]
    pub shell: ShellFile,
    #[serde(default)]
    pub git: GitFile,
    #[serde(default)]
    pub refresh: RefreshFile,
    #[serde(default)]
    pub zoxide: ZoxideFile,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectsFile {
    #[serde(default)]
    pub roots: Vec<PathBuf>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UiFile {
    pub theme: Option<String>,
    pub show_upstream: Option<bool>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ShellFile {
    pub prefix: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GitFile {
    pub default_base: Option<String>,
    pub default_remote: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RefreshFile {
    #[serde(default)]
    pub thresholds: ThresholdsFile,
    #[serde(default)]
    pub doctor: DoctorThresholdsFile,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ThresholdsFile {
    pub fresh: Option<String>,
    pub warn: Option<String>,
    pub stale: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DoctorThresholdsFile {
    pub warn: Option<String>,
    pub error: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZoxideFile {
    pub enabled: Option<bool>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RepoConfigFile {
    #[serde(default)]
    pub worktrees: WorktreesFile,
    #[serde(default)]
    pub templates: BTreeMap<String, TemplateFile>,
    #[serde(default)]
    pub hooks: HooksFile,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorktreesFile {
    #[serde(default)]
    pub shared: Vec<PathBuf>,
    pub shared_source: Option<PathBuf>,
    pub base_dir: Option<PathBuf>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TemplateFile {
    pub base_branch: Option<String>,
    pub name_pattern: Option<String>,
    #[serde(default)]
    pub hooks: HooksFile,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HooksFile {
    pub pre_add: Option<PathBuf>,
    pub post_add: Option<PathBuf>,
    pub pre_remove: Option<PathBuf>,
    pub post_remove: Option<PathBuf>,
}

/// Resolved global configuration.
///
/// Built from `~/.config/limb/config.toml` or an explicit `--config` path,
/// with defaults filled in for missing fields.
#[derive(Debug, Clone, Serialize)]
pub struct Global {
    /// Roots scanned by `--all` and the cross-repo picker, tilde-expanded.
    pub projects_roots: Vec<PathBuf>,
    /// Theme name passed to the TUI picker (`vesper`, `default`, `nord`, ...).
    pub ui_theme: String,
    /// Whether `limb status` shows the UPSTREAM column by default.
    pub ui_show_upstream: bool,
    /// Default prefix for shell wrapper functions (e.g. `gw`).
    pub shell_prefix: String,
    /// Default base branch for `limb add` when no `BASE` is given.
    pub git_default_base: Option<String>,
    /// Default remote name used by `limb update` and friends.
    pub git_default_remote: String,
    /// Resolved freshness thresholds (color buckets in the picker / status).
    #[serde(serialize_with = "serialize_thresholds")]
    pub refresh_thresholds: Thresholds,
    /// Threshold past which `limb doctor` emits a note about a stale repo.
    #[serde(serialize_with = "serialize_duration_short")]
    pub refresh_doctor_warn: Duration,
    /// Threshold past which `limb doctor` emits a problem about a stale repo.
    #[serde(serialize_with = "serialize_duration_short")]
    pub refresh_doctor_error: Duration,
    /// When true, `limb add` registers each new worktree with `zoxide`
    /// and `limb remove` unregisters it. Best-effort: a missing or
    /// failing `zoxide` binary logs a warning and never blocks the
    /// underlying worktree operation.
    pub zoxide_enabled: bool,
    /// Path of the file this config was loaded from, or `None` for
    /// built-in defaults.
    pub source: Option<PathBuf>,
}

fn serialize_thresholds<S: serde::Serializer>(
    t: &Thresholds,
    ser: S,
) -> std::result::Result<S::Ok, S::Error> {
    use serde::ser::SerializeMap;
    let mut m = ser.serialize_map(Some(3))?;
    m.serialize_entry("fresh", &short_secs(t.fresh))?;
    m.serialize_entry("warn", &short_secs(t.warn))?;
    m.serialize_entry("stale", &short_secs(t.stale))?;
    m.end()
}

fn serialize_duration_short<S: serde::Serializer>(
    d: &Duration,
    ser: S,
) -> std::result::Result<S::Ok, S::Error> {
    ser.serialize_str(&short_secs(*d))
}

fn short_secs(d: Duration) -> String {
    let s = d.as_secs();
    if s > 0 && s.is_multiple_of(24 * 60 * 60) {
        return format!("{}d", s / (24 * 60 * 60));
    }
    if s > 0 && s.is_multiple_of(60 * 60) {
        return format!("{}h", s / (60 * 60));
    }
    if s > 0 && s.is_multiple_of(60) {
        return format!("{}m", s / 60);
    }
    format!("{s}s")
}

/// Resolved per-repo configuration (`.limb.toml`).
#[derive(Debug, Clone, Serialize)]
pub struct Repo {
    /// Files listed under `[worktrees] shared = [...]`. Symlinked into
    /// every worktree by `limb setup`.
    pub worktrees_shared: Vec<PathBuf>,
    /// Optional override for the directory holding the shared files.
    /// Defaults to `.shared/` under [`Self::root`]. Relative overrides are
    /// resolved against [`Self::root`] (the directory containing
    /// `.limb.toml`), matching the convention of other config files.
    pub worktrees_shared_source: Option<PathBuf>,
    /// Directory under which `limb add` places new worktrees
    /// (default: `..`, i.e. siblings of the repo).
    pub worktrees_base_dir: PathBuf,
    /// Template name → definition, ordered for stable help output.
    pub templates: BTreeMap<String, Template>,
    /// Default hooks at the repo level (may be overridden by templates).
    pub hooks: Hooks,
    /// Path of the `.limb.toml` file this config was loaded from.
    pub source: PathBuf,
    /// Directory of the repo root (directory containing `source`).
    pub root: PathBuf,
}

/// Worktree-creation template.
///
/// Templates let a user declare a `name_pattern` (e.g. `feat-{slug}`), a
/// default `base_branch`, and template-specific hooks that override the
/// repo-level defaults.
#[derive(Debug, Clone, Serialize)]
pub struct Template {
    /// Base branch for `limb add` when this template is applied.
    pub base_branch: Option<String>,
    /// Name-interpolation pattern; `{slug}` is replaced with the user's
    /// argument.
    pub name_pattern: Option<String>,
    /// Per-template hooks overriding the repo defaults.
    pub hooks: Hooks,
}

/// The four hook points a template or repo can declare.
#[derive(Debug, Clone, Default, Serialize)]
pub struct Hooks {
    /// Runs before `limb add` creates the worktree; failure aborts.
    pub pre_add: Option<PathBuf>,
    /// Runs after `limb add` creates the worktree; failure is logged.
    pub post_add: Option<PathBuf>,
    /// Runs before `limb remove` deletes the worktree; failure aborts.
    pub pre_remove: Option<PathBuf>,
    /// Runs after `limb remove` deletes the worktree; failure is logged.
    pub post_remove: Option<PathBuf>,
}

impl Global {
    /// Loads global config from `explicit` if provided, else from
    /// `$XDG_CONFIG_HOME/limb/config.toml`.
    ///
    /// A missing file is not an error. [`Global::defaults`] is returned.
    ///
    /// # Errors
    ///
    /// Returns an error if the file exists but cannot be read, parsed, or
    /// if `$HOME` / `$XDG_CONFIG_HOME` cannot be resolved.
    pub fn load(explicit: Option<&Path>) -> Result<Self> {
        let (file, source) = load_global_file(explicit)?;
        Ok(Self::resolve(file, source))
    }

    /// Returns the built-in defaults without touching the filesystem.
    #[must_use]
    pub fn defaults() -> Self {
        Self::resolve(GlobalConfigFile::default(), None)
    }

    fn resolve(file: GlobalConfigFile, source: Option<PathBuf>) -> Self {
        let roots = file.projects.roots.into_iter().map(expand_tilde).collect();
        let thresholds_default = Thresholds::default();
        let refresh_thresholds = Thresholds {
            fresh: parse_or(
                file.refresh.thresholds.fresh.as_deref(),
                thresholds_default.fresh,
            ),
            warn: parse_or(
                file.refresh.thresholds.warn.as_deref(),
                thresholds_default.warn,
            ),
            stale: parse_or(
                file.refresh.thresholds.stale.as_deref(),
                thresholds_default.stale,
            ),
        };
        let refresh_doctor_warn = parse_or(
            file.refresh.doctor.warn.as_deref(),
            Duration::from_secs(24 * 60 * 60),
        );
        let refresh_doctor_error = parse_or(
            file.refresh.doctor.error.as_deref(),
            Duration::from_secs(7 * 24 * 60 * 60),
        );
        Self {
            projects_roots: roots,
            ui_theme: file.ui.theme.unwrap_or_else(|| "vesper".into()),
            ui_show_upstream: file.ui.show_upstream.unwrap_or(true),
            shell_prefix: file.shell.prefix.unwrap_or_else(|| "gw".into()),
            git_default_base: file.git.default_base,
            git_default_remote: file.git.default_remote.unwrap_or_else(|| "origin".into()),
            refresh_thresholds,
            refresh_doctor_warn,
            refresh_doctor_error,
            zoxide_enabled: file.zoxide.enabled.unwrap_or(false),
            source,
        }
    }
}

fn parse_or(s: Option<&str>, default: Duration) -> Duration {
    s.and_then(|v| refresh::parse_duration(v).ok())
        .unwrap_or(default)
}

impl Repo {
    /// Walks ancestors of `start` looking for the first `.limb.toml`.
    ///
    /// Returns `Ok(None)` if no config is found. The repo simply has no
    /// per-repo settings, which is not an error.
    ///
    /// # Errors
    ///
    /// Returns an error if a `.limb.toml` is found but cannot be read or
    /// parsed (e.g. invalid TOML, unknown fields).
    pub fn discover(start: &Path) -> Result<Option<Self>> {
        let Some((file, source, root)) = discover_repo_file(start)? else {
            return Ok(None);
        };
        Ok(Some(Self::resolve(file, source, root)))
    }

    fn resolve(file: RepoConfigFile, source: PathBuf, root: PathBuf) -> Self {
        let templates = file
            .templates
            .into_iter()
            .map(|(k, t)| (k, resolve_template(t)))
            .collect();
        Self {
            worktrees_shared: file.worktrees.shared,
            worktrees_shared_source: file.worktrees.shared_source,
            worktrees_base_dir: file
                .worktrees
                .base_dir
                .unwrap_or_else(|| PathBuf::from("..")),
            templates,
            hooks: resolve_hooks(file.hooks),
            source,
            root,
        }
    }

    /// Returns the absolute directory holding the files listed under
    /// `[worktrees] shared = [...]`. The explicit override (resolved
    /// against [`Self::root`] if relative) or `.shared/` under
    /// [`Self::root`].
    #[must_use]
    pub fn resolved_shared_source(&self) -> PathBuf {
        match &self.worktrees_shared_source {
            Some(p) if p.is_absolute() => p.clone(),
            Some(p) => self.root.join(p),
            None => self.root.join(".shared"),
        }
    }
}

fn resolve_template(t: TemplateFile) -> Template {
    Template {
        base_branch: t.base_branch,
        name_pattern: t.name_pattern,
        hooks: resolve_hooks(t.hooks),
    }
}

fn resolve_hooks(h: HooksFile) -> Hooks {
    Hooks {
        pre_add: h.pre_add,
        post_add: h.post_add,
        pre_remove: h.pre_remove,
        post_remove: h.post_remove,
    }
}

fn load_global_file(explicit: Option<&Path>) -> Result<(GlobalConfigFile, Option<PathBuf>)> {
    let path = match explicit {
        Some(p) => p.to_path_buf(),
        None => default_global_path()?,
    };
    if !path.exists() {
        return Ok((GlobalConfigFile::default(), None));
    }
    let contents =
        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
    let file: GlobalConfigFile =
        toml::from_str(&contents).with_context(|| format!("parse {}", path.display()))?;
    Ok((file, Some(path)))
}

fn discover_repo_file(start: &Path) -> Result<Option<(RepoConfigFile, PathBuf, PathBuf)>> {
    for dir in start.ancestors() {
        let path = dir.join(REPO_CONFIG_FILENAME);
        if path.is_file() {
            let contents = std::fs::read_to_string(&path)
                .with_context(|| format!("read {}", path.display()))?;
            let file: RepoConfigFile =
                toml::from_str(&contents).with_context(|| format!("parse {}", path.display()))?;
            return Ok(Some((file, path, dir.to_path_buf())));
        }
    }
    Ok(None)
}

fn default_global_path() -> Result<PathBuf> {
    Ok(xdg_config_home()?
        .join(GLOBAL_CONFIG_DIRNAME)
        .join(GLOBAL_CONFIG_FILENAME))
}

fn xdg_config_home() -> Result<PathBuf> {
    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
        return Ok(PathBuf::from(xdg));
    }
    dirs::home_dir()
        .map(|h| h.join(".config"))
        .context("cannot resolve home directory; set $HOME or $XDG_CONFIG_HOME")
}

fn expand_tilde(p: PathBuf) -> PathBuf {
    let s = p.to_string_lossy();
    if let Some(stripped) = s.strip_prefix("~/")
        && let Some(home) = dirs::home_dir()
    {
        return home.join(stripped);
    }
    if s == "~"
        && let Some(home) = dirs::home_dir()
    {
        return home;
    }
    p
}

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

    fn tmp() -> TempDir {
        tempfile::tempdir().expect("tempdir")
    }

    #[test]
    fn global_defaults_when_file_absent() {
        let (file, source) = load_global_file(Some(Path::new("/nonexistent/limb.toml"))).unwrap();
        assert!(source.is_none());
        let g = Global::resolve(file, source);
        assert!(g.projects_roots.is_empty());
        assert_eq!(g.ui_theme, "vesper");
        assert!(g.ui_show_upstream);
        assert_eq!(g.shell_prefix, "gw");
        assert_eq!(g.git_default_remote, "origin");
        assert_eq!(g.refresh_thresholds, Thresholds::default());
        assert_eq!(g.refresh_doctor_warn, Duration::from_secs(24 * 60 * 60));
        assert_eq!(
            g.refresh_doctor_error,
            Duration::from_secs(7 * 24 * 60 * 60)
        );
        assert!(!g.zoxide_enabled);
    }

    #[test]
    fn zoxide_enabled_parses() {
        let dir = tmp();
        let p = dir.path().join("config.toml");
        std::fs::write(&p, "[zoxide]\nenabled = true\n").unwrap();
        let g = Global::load(Some(&p)).unwrap();
        assert!(g.zoxide_enabled);
    }

    #[test]
    fn refresh_thresholds_parse() {
        let dir = tmp();
        let p = dir.path().join("config.toml");
        std::fs::write(
            &p,
            "[refresh.thresholds]\nfresh = \"2m\"\nwarn = \"15m\"\nstale = \"3h\"\n[refresh.doctor]\nwarn = \"6h\"\nerror = \"2d\"\n",
        )
        .unwrap();
        let g = Global::load(Some(&p)).unwrap();
        assert_eq!(g.refresh_thresholds.fresh, Duration::from_secs(120));
        assert_eq!(g.refresh_thresholds.warn, Duration::from_secs(15 * 60));
        assert_eq!(g.refresh_thresholds.stale, Duration::from_secs(3 * 60 * 60));
        assert_eq!(g.refresh_doctor_warn, Duration::from_secs(6 * 60 * 60));
        assert_eq!(
            g.refresh_doctor_error,
            Duration::from_secs(2 * 24 * 60 * 60)
        );
    }

    #[test]
    fn refresh_thresholds_default_when_invalid() {
        let dir = tmp();
        let p = dir.path().join("config.toml");
        std::fs::write(&p, "[refresh.thresholds]\nfresh = \"bogus\"\n").unwrap();
        let g = Global::load(Some(&p)).unwrap();
        assert_eq!(g.refresh_thresholds.fresh, Thresholds::default().fresh);
    }

    #[test]
    fn global_parses_minimal() {
        let dir = tmp();
        let p = dir.path().join("config.toml");
        std::fs::write(
            &p,
            "[projects]\nroots = [\"~/dev/work\", \"~/dev/personal\"]\n",
        )
        .unwrap();
        let g = Global::load(Some(&p)).unwrap();
        assert_eq!(g.projects_roots.len(), 2);
        let home = dirs::home_dir().unwrap();
        assert_eq!(g.projects_roots[0], home.join("dev/work"));
    }

    #[test]
    fn global_rejects_unknown_field() {
        let dir = tmp();
        let p = dir.path().join("config.toml");
        std::fs::write(&p, "[ui]\nunknown_field = true\n").unwrap();
        assert!(Global::load(Some(&p)).is_err());
    }

    #[test]
    fn repo_discover_returns_none_when_absent() {
        let dir = tmp();
        assert!(Repo::discover(dir.path()).unwrap().is_none());
    }

    #[test]
    fn repo_discovers_limb_toml() {
        let dir = tmp();
        std::fs::write(
            dir.path().join(REPO_CONFIG_FILENAME),
            "[worktrees]\nshared = [\".env\", \".mise.toml\"]\n",
        )
        .unwrap();
        let r = Repo::discover(dir.path()).unwrap().unwrap();
        assert_eq!(r.worktrees_shared.len(), 2);
        assert_eq!(r.root, dir.path());
    }

    #[test]
    fn repo_walks_ancestors() {
        let dir = tmp();
        let nested = dir.path().join("sub/deep");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(
            dir.path().join(REPO_CONFIG_FILENAME),
            "[worktrees]\nshared = [\".env\"]\n",
        )
        .unwrap();
        let r = Repo::discover(&nested).unwrap().unwrap();
        assert_eq!(r.root, dir.path());
    }

    #[test]
    fn repo_templates() {
        let dir = tmp();
        std::fs::write(
            dir.path().join(REPO_CONFIG_FILENAME),
            "[templates.feature]\nbase_branch = \"main\"\nname_pattern = \"feat-{slug}\"\n",
        )
        .unwrap();
        let r = Repo::discover(dir.path()).unwrap().unwrap();
        let feat = r.templates.get("feature").unwrap();
        assert_eq!(feat.base_branch.as_deref(), Some("main"));
        assert_eq!(feat.name_pattern.as_deref(), Some("feat-{slug}"));
    }

    #[test]
    fn repo_hooks() {
        let dir = tmp();
        std::fs::write(
            dir.path().join(REPO_CONFIG_FILENAME),
            "[hooks]\npost_add = \"scripts/setup.sh\"\npre_remove = \"scripts/teardown.sh\"\n",
        )
        .unwrap();
        let r = Repo::discover(dir.path()).unwrap().unwrap();
        assert_eq!(
            r.hooks.post_add.as_deref(),
            Some(Path::new("scripts/setup.sh"))
        );
        assert_eq!(
            r.hooks.pre_remove.as_deref(),
            Some(Path::new("scripts/teardown.sh"))
        );
    }

    #[test]
    fn repo_default_base_dir_is_parent() {
        let dir = tmp();
        std::fs::write(dir.path().join(REPO_CONFIG_FILENAME), "").unwrap();
        let r = Repo::discover(dir.path()).unwrap().unwrap();
        assert_eq!(r.worktrees_base_dir, PathBuf::from(".."));
    }

    #[test]
    fn repo_shared_source_defaults_to_shared_subdir() {
        let dir = tmp();
        std::fs::write(dir.path().join(REPO_CONFIG_FILENAME), "").unwrap();
        let r = Repo::discover(dir.path()).unwrap().unwrap();
        assert_eq!(r.resolved_shared_source(), dir.path().join(".shared"));
    }

    #[test]
    fn repo_shared_source_relative_override_resolved_against_root() {
        let dir = tmp();
        std::fs::write(
            dir.path().join(REPO_CONFIG_FILENAME),
            "[worktrees]\nshared_source = \"vendored/shared\"\n",
        )
        .unwrap();
        let r = Repo::discover(dir.path()).unwrap().unwrap();
        assert_eq!(
            r.resolved_shared_source(),
            dir.path().join("vendored/shared")
        );
    }

    #[test]
    fn repo_shared_source_absolute_override_returned_verbatim() {
        let dir = tmp();
        // `Path::is_absolute` is platform-dependent: `/opt/shared` is absolute
        // on unix but drive-relative on windows, where it'd be re-rooted.
        let abs = if cfg!(windows) {
            r"C:\opt\shared"
        } else {
            "/opt/shared"
        };
        std::fs::write(
            dir.path().join(REPO_CONFIG_FILENAME),
            format!("[worktrees]\nshared_source = {abs:?}\n"),
        )
        .unwrap();
        let r = Repo::discover(dir.path()).unwrap().unwrap();
        assert_eq!(r.resolved_shared_source(), PathBuf::from(abs));
    }

    #[test]
    fn tilde_expansion() {
        let home = dirs::home_dir().unwrap();
        assert_eq!(expand_tilde(PathBuf::from("~/foo")), home.join("foo"));
        assert_eq!(expand_tilde(PathBuf::from("~")), home);
        assert_eq!(expand_tilde(PathBuf::from("/abs")), PathBuf::from("/abs"));
    }
}

#[cfg(test)]
mod proptests {
    use super::*;
    use proptest::prelude::*;

    prop_compose! {
        fn arb_path()(s in "[a-zA-Z0-9._/-]{1,16}") -> PathBuf {
            PathBuf::from(s)
        }
    }

    prop_compose! {
        fn arb_hooks_file()(
            pre_add in prop::option::of(arb_path()),
            post_add in prop::option::of(arb_path()),
            pre_remove in prop::option::of(arb_path()),
            post_remove in prop::option::of(arb_path()),
        ) -> HooksFile {
            HooksFile { pre_add, post_add, pre_remove, post_remove }
        }
    }

    fn arb_short_duration() -> impl Strategy<Value = String> {
        ("[1-9][0-9]?", "[smhd]").prop_map(|(n, suf)| format!("{n}{suf}"))
    }

    prop_compose! {
        fn arb_thresholds_file()(
            fresh in prop::option::of(arb_short_duration()),
            warn in prop::option::of(arb_short_duration()),
            stale in prop::option::of(arb_short_duration()),
        ) -> ThresholdsFile {
            ThresholdsFile { fresh, warn, stale }
        }
    }

    prop_compose! {
        fn arb_doctor_thresholds_file()(
            warn in prop::option::of(arb_short_duration()),
            error in prop::option::of(arb_short_duration()),
        ) -> DoctorThresholdsFile {
            DoctorThresholdsFile { warn, error }
        }
    }

    prop_compose! {
        fn arb_refresh_file()(
            thresholds in arb_thresholds_file(),
            doctor in arb_doctor_thresholds_file(),
        ) -> RefreshFile {
            RefreshFile { thresholds, doctor }
        }
    }

    prop_compose! {
        fn arb_global_file()(
            roots in prop::collection::vec(arb_path(), 0..3),
            theme in prop::option::of("[a-z]{2,8}"),
            show_upstream in prop::option::of(any::<bool>()),
            prefix in prop::option::of("[a-z]{1,4}"),
            default_base in prop::option::of("[a-z]{2,8}"),
            default_remote in prop::option::of("[a-z]{2,8}"),
            refresh in arb_refresh_file(),
            zoxide_enabled in prop::option::of(any::<bool>()),
        ) -> GlobalConfigFile {
            GlobalConfigFile {
                projects: ProjectsFile { roots },
                ui: UiFile { theme, show_upstream },
                shell: ShellFile { prefix },
                git: GitFile { default_base, default_remote },
                refresh,
                zoxide: ZoxideFile { enabled: zoxide_enabled },
            }
        }
    }

    prop_compose! {
        fn arb_template_file()(
            base_branch in prop::option::of("[a-z][a-z0-9-]{0,8}"),
            name_pattern in prop::option::of("[a-z][a-z0-9-]{0,8}"),
            hooks in arb_hooks_file(),
        ) -> TemplateFile {
            TemplateFile { base_branch, name_pattern, hooks }
        }
    }

    prop_compose! {
        fn arb_repo_file()(
            shared in prop::collection::vec(arb_path(), 0..3),
            shared_source in prop::option::of(arb_path()),
            base_dir in prop::option::of(arb_path()),
            templates in prop::collection::btree_map(
                "[a-z][a-z0-9_]{0,8}",
                arb_template_file(),
                0..3,
            ),
            hooks in arb_hooks_file(),
        ) -> RepoConfigFile {
            RepoConfigFile {
                worktrees: WorktreesFile { shared, shared_source, base_dir },
                templates,
                hooks,
            }
        }
    }

    proptest! {
        #[test]
        fn global_file_toml_round_trips(g in arb_global_file()) {
            let s = toml::to_string(&g).expect("serialize");
            let parsed: GlobalConfigFile = toml::from_str(&s).expect("parse");
            prop_assert_eq!(g, parsed);
        }

        #[test]
        fn repo_file_toml_round_trips(r in arb_repo_file()) {
            let s = toml::to_string(&r).expect("serialize");
            let parsed: RepoConfigFile = toml::from_str(&s).expect("parse");
            prop_assert_eq!(r, parsed);
        }
    }
}
