Skip to main content

pakx_core/
errors.rs

1//! Typed error variants for parsing and validating manifests / lockfiles.
2//!
3//! Both error enums carry an optional `path` (set by the caller when the
4//! source originated from a file) and either a wrapped parser error or a
5//! `Schema` variant for validation failures. No panics across crate
6//! boundaries; library code returns `Result<T, ManifestError | LockfileError>`.
7
8use std::path::PathBuf;
9
10use thiserror::Error;
11
12/// Failures returned from parsing or validating `agents.yml`.
13#[derive(Debug, Error)]
14pub enum ManifestError {
15    /// Filesystem access failure (open, read, write, permission denied).
16    #[error("agents.yml io error{path}: {source}", path = fmt_path(.path.as_ref()))]
17    Io {
18        #[source]
19        source: std::io::Error,
20        path: Option<PathBuf>,
21    },
22    /// The source text was not valid YAML.
23    #[error("agents.yml is not valid YAML{path}: {source}", path = fmt_path(.path.as_ref()))]
24    ParseYaml {
25        #[source]
26        source: serde_yaml_ng::Error,
27        path: Option<PathBuf>,
28    },
29    /// The YAML parsed but did not match the manifest schema.
30    #[error("agents.yml failed schema validation{path}: {message}", path = fmt_path(.path.as_ref()))]
31    Schema {
32        message: String,
33        path: Option<PathBuf>,
34    },
35}
36
37impl ManifestError {
38    #[must_use]
39    pub fn with_path(mut self, p: impl Into<PathBuf>) -> Self {
40        let new_path = p.into();
41        match &mut self {
42            Self::Io { path, .. } | Self::ParseYaml { path, .. } | Self::Schema { path, .. } => {
43                *path = Some(new_path);
44            }
45        }
46        self
47    }
48
49    pub const fn path(&self) -> Option<&PathBuf> {
50        match self {
51            Self::Io { path, .. } | Self::ParseYaml { path, .. } | Self::Schema { path, .. } => {
52                path.as_ref()
53            }
54        }
55    }
56}
57
58/// Failures returned from parsing or validating `agents.lock`.
59#[derive(Debug, Error)]
60pub enum LockfileError {
61    /// Filesystem access failure (open, read, write, permission denied).
62    /// Routed through a dedicated variant so a permission-denied on
63    /// `agents.lock` is not rendered to the user as "failed schema
64    /// validation" — the previous code wrapped every `std::io::Error`
65    /// in `Schema { message: "io error: ..." }`, which was misleading.
66    #[error("agents.lock io error{path}: {source}", path = fmt_path(.path.as_ref()))]
67    Io {
68        #[source]
69        source: std::io::Error,
70        path: Option<PathBuf>,
71    },
72    /// The source text was not valid JSON.
73    #[error("agents.lock is not valid JSON{path}: {source}", path = fmt_path(.path.as_ref()))]
74    ParseJson {
75        #[source]
76        source: serde_json::Error,
77        path: Option<PathBuf>,
78    },
79    /// The JSON parsed but did not match the lockfile schema.
80    #[error("agents.lock failed schema validation{path}: {message}", path = fmt_path(.path.as_ref()))]
81    Schema {
82        message: String,
83        path: Option<PathBuf>,
84    },
85}
86
87impl LockfileError {
88    #[must_use]
89    pub fn with_path(mut self, p: impl Into<PathBuf>) -> Self {
90        let new_path = p.into();
91        match &mut self {
92            Self::Io { path, .. } | Self::ParseJson { path, .. } | Self::Schema { path, .. } => {
93                *path = Some(new_path);
94            }
95        }
96        self
97    }
98
99    pub const fn path(&self) -> Option<&PathBuf> {
100        match self {
101            Self::Io { path, .. } | Self::ParseJson { path, .. } | Self::Schema { path, .. } => {
102                path.as_ref()
103            }
104        }
105    }
106}
107
108/// Render the optional `path` annotation that appears in every error
109/// variant's `Display` form. The raw absolute path leaks the host's
110/// runner workspace into CI logs (and on self-hosted runners, the
111/// operator's username), so we redact it: relative to cwd when the
112/// path lives there, otherwise just the file name. The full absolute
113/// path stays available programmatically via `path()` for downstream
114/// consumers that need it.
115fn fmt_path(p: Option<&PathBuf>) -> String {
116    p.map_or_else(String::new, |path| format!(" at {}", redact(path)))
117}
118
119fn redact(path: &std::path::Path) -> String {
120    if let Ok(cwd) = std::env::current_dir() {
121        if let Ok(rel) = path.strip_prefix(&cwd) {
122            return rel.to_string_lossy().replace('\\', "/");
123        }
124    }
125    path.file_name().map_or_else(
126        || path.display().to_string(),
127        |n| n.to_string_lossy().into_owned(),
128    )
129}