muntjac 0.2.0

Translate uv.lock into Buck2 build rules
Documentation
use std::path::PathBuf;

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("failed to parse muntjac.toml: {0}")]
    Parse(#[from] toml::de::Error),

    #[error("missing required field `{0}`")]
    MissingField(&'static str),

    #[error(
        "config cannot mix top-level manifest_path/third_party_dir/python_versions with [tree.*] sections"
    )]
    IncompatibleShape,

    #[error("trees {trees:?} share third_party_dir `{dir}`; each tree needs a distinct directory")]
    DuplicateTreeDir { dir: String, trees: Vec<String> },

    #[error(
        "tree `{tree}`'s third_party_dir `{dir}` collides with the shared cfg_dir; move the tree under a subdirectory or set [buck] cfg_dir explicitly"
    )]
    TreeDirIsCfgDir { tree: String, dir: String },

    #[error(
        "cannot derive a shared cfg_dir: trees have no common parent directory; set [buck] cfg_dir explicitly"
    )]
    CfgDirNotDerivable,

    #[error("invalid platform `{name}`: {reason}")]
    BadPlatform { name: String, reason: String },

    #[error("invalid python version `{0}`")]
    BadPythonVersion(String),

    #[error(
        "invalid registry `{0}`: expected \"none\", \"file://<path>\", or \"github.com/<owner>/<repo>\""
    )]
    BadRegistry(String),

    #[error(
        "registry path must be absolute (got `{path}`); use a full file:// URL or set registry to a path relative to muntjac.toml"
    )]
    RegistryPathNotAbsolute { path: String },

    #[error("invalid dependency-group name `{0}`: must match [a-z][a-z0-9-]*")]
    BadGroupName(String),
}

#[derive(Debug, Error)]
pub enum LockfileError {
    #[error("failed to parse uv.lock: {0}")]
    Parse(#[from] toml::de::Error),

    #[error("unsupported uv.lock version `{0}`; muntjac supports version = 1")]
    UnsupportedVersion(u32),

    #[error("package `{package}`: invalid version `{value}`: {reason}")]
    BadVersion {
        package: String,
        value: String,
        reason: String,
    },

    #[error("package `{package}`: invalid {field} URL `{url}`: {reason}")]
    BadUrl {
        package: String,
        field: &'static str,
        url: String,
        reason: String,
    },

    #[error(
        "package `{package}`: source must have exactly one of registry/git/virtual/editable/directory/path; found {found:?}"
    )]
    AmbiguousSource {
        package: String,
        found: Vec<&'static str>,
    },

    #[error("package `{package}`: dep `{dep}`: marker `{marker}` failed to parse: {reason}")]
    BadMarker {
        package: String,
        dep: String,
        marker: String,
        reason: String,
    },

    #[error("package `{from_pkg}`: dep `{dep}` has no matching [[package]] entry")]
    UnresolvedDep { from_pkg: String, dep: String },

    #[error(
        "duplicate package name `{name}` (found versions {versions:?}); S1 expects one version per name"
    )]
    DuplicatePackageName { name: String, versions: Vec<String> },

    #[error(fmt = fmt_cycle)]
    Cycle(Vec<Vec<String>>),

    #[error("invalid package name `{0}`: {1}")]
    BadPackageName(String, String),
}

#[derive(Debug, thiserror::Error)]
pub enum CacheError {
    #[error("could not resolve cache root: {0}")]
    CacheRootResolve(String),

    #[error("could not create cache directory {path}: {source}")]
    CreateDir {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
}

fn fmt_cycle(cycles: &Vec<Vec<String>>, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    writeln!(f, "dependency cycle(s) detected:")?;
    for cycle in cycles {
        write!(f, "  - ")?;
        for (i, node) in cycle.iter().enumerate() {
            if i > 0 {
                write!(f, " -> ")?;
            }
            write!(f, "{node}")?;
        }
        if let Some(first) = cycle.first() {
            writeln!(f, " -> {first}")?;
        } else {
            writeln!(f)?;
        }
    }
    Ok(())
}

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

    #[test]
    fn cache_root_resolve_error_message() {
        let e = CacheError::CacheRootResolve("no XDG_CACHE_HOME or HOME".into());
        assert_eq!(
            e.to_string(),
            "could not resolve cache root: no XDG_CACHE_HOME or HOME"
        );
    }

    #[test]
    fn registry_path_not_absolute_message_is_exact() {
        let e = ConfigError::RegistryPathNotAbsolute {
            path: "./registry".into(),
        };
        assert_eq!(
            e.to_string(),
            "registry path must be absolute (got `./registry`); use a full file:// URL or set registry to a path relative to muntjac.toml"
        );
    }

    #[test]
    fn duplicate_tree_dir_message_is_exact() {
        let e = ConfigError::DuplicateTreeDir {
            dir: "tp/shared".into(),
            trees: vec!["a".into(), "b".into()],
        };
        assert_eq!(
            e.to_string(),
            "trees [\"a\", \"b\"] share third_party_dir `tp/shared`; each tree needs a distinct directory"
        );
    }

    #[test]
    fn tree_dir_is_cfg_dir_message_is_exact() {
        let e = ConfigError::TreeDirIsCfgDir {
            tree: "a".into(),
            dir: "tp".into(),
        };
        assert_eq!(
            e.to_string(),
            "tree `a`'s third_party_dir `tp` collides with the shared cfg_dir; move the tree under a subdirectory or set [buck] cfg_dir explicitly"
        );
    }

    #[test]
    fn cfg_dir_not_derivable_message_is_exact() {
        let e = ConfigError::CfgDirNotDerivable;
        assert_eq!(
            e.to_string(),
            "cannot derive a shared cfg_dir: trees have no common parent directory; set [buck] cfg_dir explicitly"
        );
    }

    #[test]
    fn lockfile_error_messages_include_context() {
        let err = LockfileError::UnsupportedVersion(2);
        assert_eq!(
            err.to_string(),
            "unsupported uv.lock version `2`; muntjac supports version = 1"
        );

        let err = LockfileError::UnresolvedDep {
            from_pkg: "requests-2.34.2".into(),
            dep: "ghost".into(),
        };
        assert!(err.to_string().contains("requests-2.34.2"));
        assert!(err.to_string().contains("ghost"));

        let err = LockfileError::DuplicatePackageName {
            name: "numpy".into(),
            versions: vec!["1.2.0".into(), "2.0.0".into()],
        };
        assert!(err.to_string().contains("numpy"));
    }
}