pakx-core 0.1.10

pakx core — manifest, lockfile, resolver, installer logic
Documentation
//! Typed error variants for parsing and validating manifests / lockfiles.
//!
//! Both error enums carry an optional `path` (set by the caller when the
//! source originated from a file) and either a wrapped parser error or a
//! `Schema` variant for validation failures. No panics across crate
//! boundaries; library code returns `Result<T, ManifestError | LockfileError>`.

use std::path::PathBuf;

use thiserror::Error;

/// Failures returned from parsing or validating `agents.yml`.
#[derive(Debug, Error)]
pub enum ManifestError {
    /// Filesystem access failure (open, read, write, permission denied).
    #[error("agents.yml io error{path}: {source}", path = fmt_path(.path.as_ref()))]
    Io {
        #[source]
        source: std::io::Error,
        path: Option<PathBuf>,
    },
    /// The source text was not valid YAML.
    #[error("agents.yml is not valid YAML{path}: {source}", path = fmt_path(.path.as_ref()))]
    ParseYaml {
        #[source]
        source: serde_yaml_ng::Error,
        path: Option<PathBuf>,
    },
    /// The YAML parsed but did not match the manifest schema.
    #[error("agents.yml failed schema validation{path}: {message}", path = fmt_path(.path.as_ref()))]
    Schema {
        message: String,
        path: Option<PathBuf>,
    },
}

impl ManifestError {
    #[must_use]
    pub fn with_path(mut self, p: impl Into<PathBuf>) -> Self {
        let new_path = p.into();
        match &mut self {
            Self::Io { path, .. } | Self::ParseYaml { path, .. } | Self::Schema { path, .. } => {
                *path = Some(new_path);
            }
        }
        self
    }

    pub const fn path(&self) -> Option<&PathBuf> {
        match self {
            Self::Io { path, .. } | Self::ParseYaml { path, .. } | Self::Schema { path, .. } => {
                path.as_ref()
            }
        }
    }
}

/// Failures returned from parsing or validating `agents.lock`.
#[derive(Debug, Error)]
pub enum LockfileError {
    /// Filesystem access failure (open, read, write, permission denied).
    /// Routed through a dedicated variant so a permission-denied on
    /// `agents.lock` is not rendered to the user as "failed schema
    /// validation" — the previous code wrapped every `std::io::Error`
    /// in `Schema { message: "io error: ..." }`, which was misleading.
    #[error("agents.lock io error{path}: {source}", path = fmt_path(.path.as_ref()))]
    Io {
        #[source]
        source: std::io::Error,
        path: Option<PathBuf>,
    },
    /// The source text was not valid JSON.
    #[error("agents.lock is not valid JSON{path}: {source}", path = fmt_path(.path.as_ref()))]
    ParseJson {
        #[source]
        source: serde_json::Error,
        path: Option<PathBuf>,
    },
    /// The JSON parsed but did not match the lockfile schema.
    #[error("agents.lock failed schema validation{path}: {message}", path = fmt_path(.path.as_ref()))]
    Schema {
        message: String,
        path: Option<PathBuf>,
    },
}

impl LockfileError {
    #[must_use]
    pub fn with_path(mut self, p: impl Into<PathBuf>) -> Self {
        let new_path = p.into();
        match &mut self {
            Self::Io { path, .. } | Self::ParseJson { path, .. } | Self::Schema { path, .. } => {
                *path = Some(new_path);
            }
        }
        self
    }

    pub const fn path(&self) -> Option<&PathBuf> {
        match self {
            Self::Io { path, .. } | Self::ParseJson { path, .. } | Self::Schema { path, .. } => {
                path.as_ref()
            }
        }
    }
}

/// Render the optional `path` annotation that appears in every error
/// variant's `Display` form. The raw absolute path leaks the host's
/// runner workspace into CI logs (and on self-hosted runners, the
/// operator's username), so we redact it: relative to cwd when the
/// path lives there, otherwise just the file name. The full absolute
/// path stays available programmatically via `path()` for downstream
/// consumers that need it.
fn fmt_path(p: Option<&PathBuf>) -> String {
    p.map_or_else(String::new, |path| format!(" at {}", redact(path)))
}

fn redact(path: &std::path::Path) -> String {
    if let Ok(cwd) = std::env::current_dir() {
        if let Ok(rel) = path.strip_prefix(&cwd) {
            return rel.to_string_lossy().replace('\\', "/");
        }
    }
    path.file_name().map_or_else(
        || path.display().to_string(),
        |n| n.to_string_lossy().into_owned(),
    )
}