Skip to main content

repograph_core/
error.rs

1//! Error type for the repograph domain.
2//!
3//! Every variant maps to a documented exit code via `exit_code()`. The code map
4//! is the binary's contract with downstream consumers (humans, agents, CI).
5
6use std::path::PathBuf;
7
8/// Domain error for the repograph core and binary. Each variant is wired to a
9/// specific exit code documented in `CLAUDE.md` and the `registry-core` spec.
10#[derive(Debug, thiserror::Error)]
11pub enum RepographError {
12    /// Generic I/O failure; permission-denied is detected and mapped to code 4.
13    #[error("i/o error: {0}")]
14    Io(#[from] std::io::Error),
15
16    /// Config file present but not parseable as TOML.
17    #[error("invalid TOML in config file: {0}")]
18    ConfigParse(#[from] toml::de::Error),
19
20    /// Could not serialize config to TOML.
21    #[error("failed to serialize config: {0}")]
22    ConfigWrite(#[from] toml::ser::Error),
23
24    /// `git2::Repository::open` rejected the path.
25    #[error("not a git repository: {path}: {source}")]
26    GitOpen {
27        path: PathBuf,
28        #[source]
29        source: git2::Error,
30    },
31
32    /// A required entity does not exist (repo, workspace, path).
33    #[error("{kind} '{name}' not found")]
34    NotFound { kind: &'static str, name: String },
35
36    /// A unique-constraint violation (name or path already registered).
37    #[error("{kind} '{name}' already registered")]
38    Conflict { kind: &'static str, name: String },
39
40    /// Explicit permission failure (raised when we can attribute it to a known path).
41    #[error("permission denied: {path}")]
42    PermissionDenied { path: PathBuf },
43
44    /// Runtime usage failure (e.g. no config-dir resolvable). CLI argument
45    /// errors are handled by clap and exit with code 2 directly.
46    #[error("{0}")]
47    UsageError(String),
48
49    /// User-supplied identifier violates a naming rule (e.g. workspace name
50    /// fails the RFC 1123 label policy). Maps to exit code `2`, matching how
51    /// clap reports bad arguments.
52    #[error("invalid {kind} name '{name}': {reason}")]
53    InvalidName {
54        kind: &'static str,
55        name: String,
56        reason: &'static str,
57    },
58
59    /// An interactive code path required a TTY but stdout was redirected, and
60    /// no non-interactive escape hatch (flag, env var) was provided. Maps to
61    /// exit code `2`. The payload is the full user-visible guidance message
62    /// (e.g. "agents not configured; run `repograph init`" or "stdout is not
63    /// a TTY; pass `--no-prompt --agents <list>` …").
64    #[error("{0}")]
65    NeedsInit(String),
66
67    /// `repograph doctor` found one or more error-severity findings. The
68    /// report is the success output (already written to stdout); this variant
69    /// only carries the exit-code signal. Maps to exit code `1`. The binary
70    /// special-cases this variant to suppress the generic "repograph failed"
71    /// `tracing::error!` line, since the report itself is the user-facing
72    /// surface, not the error message.
73    #[error("doctor found {count} error finding(s) — see report above")]
74    DoctorErrorsFound { count: u32 },
75
76    /// `repograph update` failed to reach, download, or verify a release.
77    /// Covers network/IO failures and checksum/signature verification
78    /// failures. Maps to exit code `1`. A binary-write permission failure is
79    /// reported through [`RepographError::PermissionDenied`] (exit `4`)
80    /// instead, so this variant is reserved for general update failures.
81    #[error("update failed: {0}")]
82    UpdateFailed(String),
83}
84
85impl RepographError {
86    /// Map this error to the documented exit code:
87    /// `1` general, `3` not-found, `4` permission-denied, `5` conflict.
88    #[must_use]
89    pub fn exit_code(&self) -> u8 {
90        match self {
91            Self::Io(e) if e.kind() == std::io::ErrorKind::PermissionDenied => 4,
92            Self::PermissionDenied { .. } => 4,
93            Self::GitOpen { .. } | Self::NotFound { .. } => 3,
94            Self::Conflict { .. } => 5,
95            Self::InvalidName { .. } | Self::NeedsInit { .. } | Self::UsageError(_) => 2,
96            Self::Io(_)
97            | Self::ConfigParse(_)
98            | Self::ConfigWrite(_)
99            | Self::DoctorErrorsFound { .. }
100            | Self::UpdateFailed(_) => 1,
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    #![allow(clippy::unwrap_used)]
108    use super::*;
109
110    #[test]
111    fn io_permission_denied_maps_to_4() {
112        let err = RepographError::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
113        assert_eq!(err.exit_code(), 4);
114    }
115
116    #[test]
117    fn other_io_maps_to_1() {
118        let err = RepographError::Io(std::io::Error::from(std::io::ErrorKind::NotFound));
119        assert_eq!(err.exit_code(), 1);
120    }
121
122    #[test]
123    fn explicit_permission_denied_maps_to_4() {
124        let err = RepographError::PermissionDenied {
125            path: PathBuf::from("/nope"),
126        };
127        assert_eq!(err.exit_code(), 4);
128    }
129
130    #[test]
131    fn not_found_maps_to_3() {
132        let err = RepographError::NotFound {
133            kind: "repo",
134            name: "foo".into(),
135        };
136        assert_eq!(err.exit_code(), 3);
137    }
138
139    #[test]
140    fn git_open_maps_to_3() {
141        let err = RepographError::GitOpen {
142            path: PathBuf::from("/tmp/x"),
143            source: git2::Error::from_str("synthetic"),
144        };
145        assert_eq!(err.exit_code(), 3);
146    }
147
148    #[test]
149    fn conflict_maps_to_5() {
150        let err = RepographError::Conflict {
151            kind: "name",
152            name: "foo".into(),
153        };
154        assert_eq!(err.exit_code(), 5);
155    }
156
157    #[test]
158    fn usage_error_maps_to_2() {
159        let err = RepographError::UsageError("nope".into());
160        assert_eq!(err.exit_code(), 2);
161    }
162
163    #[test]
164    fn invalid_name_maps_to_2() {
165        let err = RepographError::InvalidName {
166            kind: "workspace",
167            name: "Bad Name".into(),
168            reason: "must be lowercase",
169        };
170        assert_eq!(err.exit_code(), 2);
171    }
172
173    #[test]
174    fn needs_init_maps_to_2() {
175        let err = RepographError::NeedsInit("agents not configured; run `repograph init`".into());
176        assert_eq!(err.exit_code(), 2);
177        assert!(err.to_string().contains("repograph init"));
178    }
179
180    #[test]
181    fn update_failed_maps_to_1() {
182        let err = RepographError::UpdateFailed("network unreachable".into());
183        assert_eq!(err.exit_code(), 1);
184    }
185
186    #[test]
187    fn config_parse_maps_to_1() {
188        let err: RepographError = toml::from_str::<toml::Value>("[unterminated")
189            .unwrap_err()
190            .into();
191        assert_eq!(err.exit_code(), 1);
192    }
193}