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    /// `repograph find` was invoked before any search index was built. The
85    /// index is a "resource" that does not exist yet, so this maps to exit
86    /// code `3` (not-found), mirroring a missing repo/workspace. The Display
87    /// text guides the user to `repograph index`.
88    #[error("no search index found — run `repograph index` first")]
89    IndexMissing,
90
91    /// The search index database is present but could not be opened, read, or
92    /// queried (corruption, a schema the binary can't drive, a failed SQL
93    /// statement). Maps to exit code `1`. A missing index is
94    /// [`RepographError::IndexMissing`] (exit `3`) instead.
95    #[error("search index error: {0}")]
96    Index(String),
97}
98
99impl From<rusqlite::Error> for RepographError {
100    fn from(e: rusqlite::Error) -> Self {
101        Self::Index(e.to_string())
102    }
103}
104
105impl RepographError {
106    /// Map this error to the documented exit code:
107    /// `1` general, `3` not-found, `4` permission-denied, `5` conflict.
108    #[must_use]
109    pub fn exit_code(&self) -> u8 {
110        match self {
111            Self::Io(e) if e.kind() == std::io::ErrorKind::PermissionDenied => 4,
112            Self::PermissionDenied { .. } => 4,
113            Self::GitOpen { .. } | Self::NotFound { .. } | Self::IndexMissing => 3,
114            Self::Conflict { .. } => 5,
115            Self::InvalidName { .. } | Self::NeedsInit { .. } | Self::UsageError(_) => 2,
116            Self::Io(_)
117            | Self::ConfigParse(_)
118            | Self::ConfigWrite(_)
119            | Self::DoctorErrorsFound { .. }
120            | Self::UpdateFailed(_)
121            | Self::Index(_) => 1,
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    #![allow(clippy::unwrap_used)]
129    use super::*;
130
131    #[test]
132    fn io_permission_denied_maps_to_4() {
133        let err = RepographError::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
134        assert_eq!(err.exit_code(), 4);
135    }
136
137    #[test]
138    fn other_io_maps_to_1() {
139        let err = RepographError::Io(std::io::Error::from(std::io::ErrorKind::NotFound));
140        assert_eq!(err.exit_code(), 1);
141    }
142
143    #[test]
144    fn explicit_permission_denied_maps_to_4() {
145        let err = RepographError::PermissionDenied {
146            path: PathBuf::from("/nope"),
147        };
148        assert_eq!(err.exit_code(), 4);
149    }
150
151    #[test]
152    fn not_found_maps_to_3() {
153        let err = RepographError::NotFound {
154            kind: "repo",
155            name: "foo".into(),
156        };
157        assert_eq!(err.exit_code(), 3);
158    }
159
160    #[test]
161    fn git_open_maps_to_3() {
162        let err = RepographError::GitOpen {
163            path: PathBuf::from("/tmp/x"),
164            source: git2::Error::from_str("synthetic"),
165        };
166        assert_eq!(err.exit_code(), 3);
167    }
168
169    #[test]
170    fn conflict_maps_to_5() {
171        let err = RepographError::Conflict {
172            kind: "name",
173            name: "foo".into(),
174        };
175        assert_eq!(err.exit_code(), 5);
176    }
177
178    #[test]
179    fn usage_error_maps_to_2() {
180        let err = RepographError::UsageError("nope".into());
181        assert_eq!(err.exit_code(), 2);
182    }
183
184    #[test]
185    fn invalid_name_maps_to_2() {
186        let err = RepographError::InvalidName {
187            kind: "workspace",
188            name: "Bad Name".into(),
189            reason: "must be lowercase",
190        };
191        assert_eq!(err.exit_code(), 2);
192    }
193
194    #[test]
195    fn needs_init_maps_to_2() {
196        let err = RepographError::NeedsInit("agents not configured; run `repograph init`".into());
197        assert_eq!(err.exit_code(), 2);
198        assert!(err.to_string().contains("repograph init"));
199    }
200
201    #[test]
202    fn update_failed_maps_to_1() {
203        let err = RepographError::UpdateFailed("network unreachable".into());
204        assert_eq!(err.exit_code(), 1);
205    }
206
207    #[test]
208    fn index_missing_maps_to_3_and_names_index_command() {
209        let err = RepographError::IndexMissing;
210        assert_eq!(err.exit_code(), 3);
211        assert!(err.to_string().contains("repograph index"));
212    }
213
214    #[test]
215    fn index_error_maps_to_1() {
216        let err = RepographError::Index("disk image is malformed".into());
217        assert_eq!(err.exit_code(), 1);
218    }
219
220    #[test]
221    fn config_parse_maps_to_1() {
222        let err: RepographError = toml::from_str::<toml::Value>("[unterminated")
223            .unwrap_err()
224            .into();
225        assert_eq!(err.exit_code(), 1);
226    }
227}