Skip to main content

endringer_core/
error.rs

1//! Typed public error model for endringer (RFC 006).
2//!
3//! # Overview
4//!
5//! All public methods on [`crate::backend::VcsBackend`] and on the
6//! `endringer::repository::Repository` façade return
7//! `endringer_core::Result<T>` (equivalently `endringer::Result<T>`).
8//!
9//! The error type is `#[non_exhaustive]`: new variants may be added in minor
10//! releases without a new breaking wave. Consumers must include a wildcard
11//! arm when matching.
12//!
13//! # Matching errors
14//!
15//! Match on variants rather than on `Display` strings:
16//!
17//! ```rust,ignore
18//! use endringer::{Error, NotFoundKind};
19//!
20//! match repo.find_commit(&id) {
21//!     Ok(info) => { /* … */ }
22//!     Err(Error::NotFound { kind: NotFoundKind::Commit, name }) => {
23//!         eprintln!("commit {name} not found");
24//!     }
25//!     Err(err) => return Err(err),
26//! }
27//! ```
28
29use std::fmt;
30
31use crate::types::{BackendKind, CommitId, ObjectId};
32
33// ── Result alias ─────────────────────────────────────────────────────────── //
34
35/// Convenience alias for `std::result::Result<T, endringer_core::Error>`.
36pub type Result<T> = std::result::Result<T, Error>;
37
38// ── Error enum ───────────────────────────────────────────────────────────── //
39
40/// The public error type for all endringer operations.
41///
42/// Marked `#[non_exhaustive]` — new variants may be added in minor releases.
43/// Always include a wildcard arm when matching.
44#[derive(Debug)]
45#[non_exhaustive]
46pub enum Error {
47    /// The path does not contain a recognised repository.
48    NotARepository { path: std::path::PathBuf },
49
50    /// The repository exists but has no commits yet.
51    EmptyRepository,
52
53    /// A named object (commit, ref, branch, tag, remote, …) was not found.
54    NotFound { kind: NotFoundKind, name: String },
55
56    /// A commit ID hex string was invalid (wrong length or non-hex chars).
57    InvalidCommitId { value: String },
58
59    /// An object ID hex string was invalid (wrong length or non-hex chars).
60    InvalidObjectId { value: String },
61
62    /// A ref name string was invalid.
63    InvalidRefName { value: String },
64
65    /// An object was found but is not a commit.
66    NotACommit { id: CommitId },
67
68    /// An object was found but is not a tree.
69    NotATree { id: ObjectId },
70
71    /// A path was not present in the tree of the given commit.
72    PathNotFound {
73        path: std::path::PathBuf,
74        commit: Option<CommitId>,
75    },
76
77    /// A path could not be represented as valid UTF-8.
78    NonUtf8Path { path: std::path::PathBuf },
79
80    /// The operation is not supported on a bare repository.
81    BareRepositoryUnsupported { operation: &'static str },
82
83    /// The backend does not support this feature for this repository.
84    UnsupportedBackendFeature {
85        backend: Option<BackendKind>,
86        feature: &'static str,
87    },
88
89    /// The repository uses an object format endringer does not fully support.
90    UnsupportedObjectFormat { format: String },
91
92    /// A SHA-1 collision was detected by gix's collision-detecting hasher.
93    HashCollision,
94
95    /// The repository data appears corrupt.
96    CorruptRepository { message: String },
97
98    /// An I/O error.
99    Io(std::io::Error),
100
101    /// A `tokio::task::spawn_blocking` task failed to join.
102    TaskJoin { message: String },
103
104    /// An unclassified error from the backend.
105    ///
106    /// `message` carries a human-readable description. `source` carries the
107    /// original error chain for debugging.
108    Backend {
109        message: String,
110        source: Option<Box<dyn std::error::Error + Send + Sync>>,
111    },
112}
113
114// ── NotFoundKind ─────────────────────────────────────────────────────────── //
115
116/// The kind of named object that was not found.
117///
118/// Marked `#[non_exhaustive]` — new variants may be added in minor releases.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120#[non_exhaustive]
121pub enum NotFoundKind {
122    Commit,
123    Ref,
124    Branch,
125    Tag,
126    Remote,
127    Path,
128    Worktree,
129    Submodule,
130}
131
132impl fmt::Display for NotFoundKind {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            NotFoundKind::Commit    => write!(f, "commit"),
136            NotFoundKind::Ref      => write!(f, "ref"),
137            NotFoundKind::Branch   => write!(f, "branch"),
138            NotFoundKind::Tag      => write!(f, "tag"),
139            NotFoundKind::Remote   => write!(f, "remote"),
140            NotFoundKind::Path     => write!(f, "path"),
141            NotFoundKind::Worktree => write!(f, "worktree"),
142            NotFoundKind::Submodule => write!(f, "submodule"),
143        }
144    }
145}
146
147// ── Display ──────────────────────────────────────────────────────────────── //
148
149impl fmt::Display for Error {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            Error::NotARepository { path } =>
153                write!(f, "not a repository: {}", path.display()),
154            Error::EmptyRepository =>
155                write!(f, "repository has no commits"),
156            Error::NotFound { kind, name } =>
157                write!(f, "{kind} not found: {name}"),
158            Error::InvalidCommitId { value } =>
159                write!(f, "invalid commit id {value:?}: expected 40 (SHA-1) or 64 (SHA-256) hex chars"),
160            Error::InvalidObjectId { value } =>
161                write!(f, "invalid object id {value:?}: expected 40 (SHA-1) or 64 (SHA-256) hex chars"),
162            Error::InvalidRefName { value } =>
163                write!(f, "invalid ref name: {value:?}"),
164            Error::NotACommit { id } =>
165                write!(f, "object {} is not a commit", id.short()),
166            Error::NotATree { id } =>
167                write!(f, "object {} is not a tree", id.short()),
168            Error::PathNotFound { path, commit: None } =>
169                write!(f, "path not found: {}", path.display()),
170            Error::PathNotFound { path, commit: Some(c) } =>
171                write!(f, "path not found at commit {}: {}", c.short(), path.display()),
172            Error::NonUtf8Path { path } =>
173                write!(f, "path is not valid UTF-8: {}", path.display()),
174            Error::BareRepositoryUnsupported { operation } =>
175                write!(f, "operation not supported on bare repository: {operation}"),
176            Error::UnsupportedBackendFeature { backend: None, feature } =>
177                write!(f, "backend does not support {feature}"),
178            Error::UnsupportedBackendFeature { backend: Some(b), feature } =>
179                write!(f, "{b:?} backend does not support {feature}"),
180            Error::UnsupportedObjectFormat { format } =>
181                write!(f, "unsupported object format: {format}"),
182            Error::HashCollision =>
183                write!(f, "SHA-1 hash collision detected"),
184            Error::CorruptRepository { message } =>
185                write!(f, "corrupt repository: {message}"),
186            Error::Io(e) =>
187                write!(f, "I/O error: {e}"),
188            Error::TaskJoin { message } =>
189                write!(f, "async task failed to join: {message}"),
190            Error::Backend { message, .. } =>
191                write!(f, "backend error: {message}"),
192        }
193    }
194}
195
196// ── std::error::Error ────────────────────────────────────────────────────── //
197
198impl std::error::Error for Error {
199    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
200        match self {
201            Error::Io(e) => Some(e),
202            Error::Backend { source: Some(s), .. } => Some(&**s),
203            _ => None,
204        }
205    }
206}
207
208// ── From conversions ─────────────────────────────────────────────────────── //
209
210impl From<std::io::Error> for Error {
211    fn from(e: std::io::Error) -> Self {
212        Error::Io(e)
213    }
214}
215
216/// Convert an `anyhow::Error` into a `Backend` variant.
217///
218/// Used by backend crates during the transition from `anyhow` to typed errors.
219/// When a gix call produces an `anyhow::Error` that hasn't been classified,
220/// this wraps it as `Backend { message, source: None }`.
221pub fn anyhow_to_backend(err: anyhow::Error) -> Error {
222    Error::Backend {
223        message: err.to_string(),
224        source: None,
225    }
226}
227
228// ── Tests ─────────────────────────────────────────────────────────────────── //
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn display_not_a_repository() {
236        let e = Error::NotARepository { path: "/tmp/nope".into() };
237        assert!(e.to_string().contains("not a repository"));
238        assert!(e.to_string().contains("nope"));
239    }
240
241    #[test]
242    fn display_not_found_commit() {
243        let e = Error::NotFound {
244            kind: NotFoundKind::Commit,
245            name: "abc123".into(),
246        };
247        assert!(e.to_string().contains("commit"));
248        assert!(e.to_string().contains("abc123"));
249    }
250
251    #[test]
252    fn display_unsupported_jj_tag() {
253        let e = Error::UnsupportedBackendFeature {
254            backend: Some(BackendKind::Jj),
255            feature: "create_annotated_tag",
256        };
257        let s = e.to_string();
258        assert!(s.contains("Jj") || s.contains("jj"));
259        assert!(s.contains("create_annotated_tag"));
260    }
261
262    #[test]
263    fn display_io_error() {
264        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
265        let e = Error::Io(io);
266        assert!(e.to_string().contains("I/O"));
267    }
268
269    #[test]
270    fn source_for_io_error() {
271        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
272        let e = Error::Io(io);
273        assert!(std::error::Error::source(&e).is_some());
274    }
275
276    #[test]
277    fn source_for_backend_without_source() {
278        let e = Error::Backend { message: "oops".into(), source: None };
279        assert!(std::error::Error::source(&e).is_none());
280    }
281
282    #[test]
283    fn from_io_error() {
284        let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
285        let e: Error = io.into();
286        assert!(matches!(e, Error::Io(_)));
287    }
288
289    #[test]
290    fn not_found_kind_display() {
291        assert_eq!(NotFoundKind::Commit.to_string(),    "commit");
292        assert_eq!(NotFoundKind::Branch.to_string(),    "branch");
293        assert_eq!(NotFoundKind::Tag.to_string(),       "tag");
294        assert_eq!(NotFoundKind::Remote.to_string(),    "remote");
295        assert_eq!(NotFoundKind::Worktree.to_string(),  "worktree");
296        assert_eq!(NotFoundKind::Submodule.to_string(), "submodule");
297    }
298
299    #[test]
300    fn hash_collision_display() {
301        let e = Error::HashCollision;
302        assert!(e.to_string().contains("collision"));
303    }
304}