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
77impl RepographError {
78    /// Map this error to the documented exit code:
79    /// `1` general, `3` not-found, `4` permission-denied, `5` conflict.
80    #[must_use]
81    pub fn exit_code(&self) -> u8 {
82        match self {
83            Self::Io(e) if e.kind() == std::io::ErrorKind::PermissionDenied => 4,
84            Self::PermissionDenied { .. } => 4,
85            Self::GitOpen { .. } | Self::NotFound { .. } => 3,
86            Self::Conflict { .. } => 5,
87            Self::InvalidName { .. } | Self::NeedsInit { .. } | Self::UsageError(_) => 2,
88            Self::Io(_)
89            | Self::ConfigParse(_)
90            | Self::ConfigWrite(_)
91            | Self::DoctorErrorsFound { .. } => 1,
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    #![allow(clippy::unwrap_used)]
99    use super::*;
100
101    #[test]
102    fn io_permission_denied_maps_to_4() {
103        let err = RepographError::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
104        assert_eq!(err.exit_code(), 4);
105    }
106
107    #[test]
108    fn other_io_maps_to_1() {
109        let err = RepographError::Io(std::io::Error::from(std::io::ErrorKind::NotFound));
110        assert_eq!(err.exit_code(), 1);
111    }
112
113    #[test]
114    fn explicit_permission_denied_maps_to_4() {
115        let err = RepographError::PermissionDenied {
116            path: PathBuf::from("/nope"),
117        };
118        assert_eq!(err.exit_code(), 4);
119    }
120
121    #[test]
122    fn not_found_maps_to_3() {
123        let err = RepographError::NotFound {
124            kind: "repo",
125            name: "foo".into(),
126        };
127        assert_eq!(err.exit_code(), 3);
128    }
129
130    #[test]
131    fn git_open_maps_to_3() {
132        let err = RepographError::GitOpen {
133            path: PathBuf::from("/tmp/x"),
134            source: git2::Error::from_str("synthetic"),
135        };
136        assert_eq!(err.exit_code(), 3);
137    }
138
139    #[test]
140    fn conflict_maps_to_5() {
141        let err = RepographError::Conflict {
142            kind: "name",
143            name: "foo".into(),
144        };
145        assert_eq!(err.exit_code(), 5);
146    }
147
148    #[test]
149    fn usage_error_maps_to_2() {
150        let err = RepographError::UsageError("nope".into());
151        assert_eq!(err.exit_code(), 2);
152    }
153
154    #[test]
155    fn invalid_name_maps_to_2() {
156        let err = RepographError::InvalidName {
157            kind: "workspace",
158            name: "Bad Name".into(),
159            reason: "must be lowercase",
160        };
161        assert_eq!(err.exit_code(), 2);
162    }
163
164    #[test]
165    fn needs_init_maps_to_2() {
166        let err = RepographError::NeedsInit("agents not configured; run `repograph init`".into());
167        assert_eq!(err.exit_code(), 2);
168        assert!(err.to_string().contains("repograph init"));
169    }
170
171    #[test]
172    fn config_parse_maps_to_1() {
173        let err: RepographError = toml::from_str::<toml::Value>("[unterminated")
174            .unwrap_err()
175            .into();
176        assert_eq!(err.exit_code(), 1);
177    }
178}