Skip to main content

heddle_object_model/
error.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared error types across Heddle crates.
3
4use std::{error::Error, fmt, io, path::Path};
5
6use crate::object::{ContentHash, StateId, TreeError};
7
8/// Structured recovery details that can cross the embeddable facade boundary.
9#[derive(Debug, Clone, PartialEq)]
10pub struct RecoveryDetails {
11    pub kind: &'static str,
12    pub error: String,
13    pub hint: String,
14    pub unsafe_condition: String,
15    pub would_change: String,
16    pub preserved: String,
17    /// Explicit, path-specific recovery commands. When present these override
18    /// the `kind`-keyed fallback the CLI envelope would otherwise reconstruct
19    /// (the first entry is the primary command). `None` = use the generic
20    /// per-`kind` recovery mapping.
21    pub recovery_commands: Option<Vec<String>>,
22}
23
24impl RecoveryDetails {
25    pub fn safety_refusal(
26        kind: &'static str,
27        error: impl Into<String>,
28        hint: impl Into<String>,
29        unsafe_condition: impl Into<String>,
30        would_change: impl Into<String>,
31        already_preserved: impl Into<String>,
32    ) -> Self {
33        Self {
34            kind,
35            error: error.into(),
36            hint: hint.into(),
37            unsafe_condition: unsafe_condition.into(),
38            would_change: would_change.into(),
39            preserved: already_preserved.into(),
40            recovery_commands: None,
41        }
42    }
43
44    /// Attach explicit, path-specific recovery commands (the first entry is the
45    /// primary command). Used where the callsite has context — e.g. a source
46    /// checkout path — that the `kind`-keyed CLI fallback cannot reconstruct.
47    #[must_use]
48    pub fn with_recovery_commands(mut self, commands: Vec<String>) -> Self {
49        self.recovery_commands = Some(commands);
50        self
51    }
52
53    pub fn invalid_usage(
54        kind: &'static str,
55        error: impl Into<String>,
56        hint: impl Into<String>,
57    ) -> Self {
58        Self::safety_refusal(
59            kind,
60            error,
61            hint,
62            "the command arguments do not describe a valid operation",
63            "running with ambiguous or invalid arguments could target the wrong repository state or metadata",
64            "no repository objects, refs, metadata, or worktree files were changed",
65        )
66    }
67
68    pub fn feature_unavailable(command: &str, feature: &str) -> Self {
69        Self::safety_refusal(
70            "feature_unavailable",
71            format!("{command} requires building heddle with --features {feature}"),
72            format!(
73                "Use a binary built with the `{feature}` feature, or rerun without the feature-specific flag."
74            ),
75            format!("this heddle binary was built without the `{feature}` feature"),
76            format!("{command} cannot run because the requested analysis engine is unavailable"),
77            "repository state, refs, and worktree files were left unchanged",
78        )
79    }
80
81    pub fn serialization_error(detail: impl fmt::Display) -> Self {
82        Self::safety_refusal(
83            "state_corrupted",
84            "Repository state is corrupted or unreadable",
85            "Inspect repository integrity before attempting repair.",
86            format!("a stored repository object failed to decode: {detail}"),
87            "continuing would read or write through repository state Heddle cannot decode",
88            "the command stopped before mutating repository state; intact objects were left unchanged",
89        )
90    }
91
92    pub fn repository_integrity_error(error: impl Into<String>) -> Self {
93        Self::safety_refusal(
94            "repository_integrity_error",
95            error,
96            "Inspect repository integrity, then restore or repair the reported object/ref.",
97            "repository object or ref integrity did not pass validation",
98            "continuing could compound corruption or hide the missing object",
99            "the command stopped before applying the requested mutation",
100        )
101    }
102
103    pub fn repository_not_found(path: &Path) -> Self {
104        Self::safety_refusal(
105            "repository_not_found",
106            format!("repository not found at {}", path.display()),
107            "Initialize the requested repository before running repository commands.",
108            format!("no Heddle repository was found at '{}'", path.display()),
109            "the command cannot inspect or change repository state until initialization",
110            "no repository objects, refs, metadata, or worktree files were changed",
111        )
112    }
113
114    pub fn state_not_found(state_id: impl fmt::Display) -> Self {
115        Self::safety_refusal(
116            "state_not_found",
117            format!("State not found: {state_id}"),
118            "List recent states with `heddle log`, then choose an existing state id.",
119            "the requested state id does not exist in this repository",
120            "continuing with a guessed state could target the wrong history point",
121            "repository state, refs, metadata, and worktree files were left unchanged",
122        )
123    }
124}
125
126impl fmt::Display for RecoveryDetails {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        write!(
129            f,
130            "{}. Unsafe: {}. Would change: {}. Preserved: {}.",
131            self.error, self.unsafe_condition, self.would_change, self.preserved
132        )?;
133        Ok(())
134    }
135}
136
137impl Error for RecoveryDetails {}
138
139/// Failure to acquire or access a repository lock.
140///
141/// The lock implementation lives in `heddle-objects`; this pure error value
142/// lives with [`HeddleError`] so the object-model crate does not depend on a
143/// storage backend.
144#[derive(Debug, thiserror::Error)]
145pub enum LockError {
146    #[error("failed to acquire lock: {0}")]
147    Acquire(#[source] io::Error),
148    #[error("lock file not accessible: {0}")]
149    Io(#[source] io::Error),
150}
151
152/// Error type for repository/storage-adjacent operations.
153#[derive(Debug, thiserror::Error)]
154pub enum HeddleError {
155    #[error("{0}")]
156    Recovery(Box<RecoveryDetails>),
157    #[error("object not found: {0}")]
158    NotFound(String),
159    #[error("No merge in progress")]
160    NoMergeInProgress,
161    #[error("state not found: {0}")]
162    StateNotFound(StateId),
163    #[error("invalid object: {0}")]
164    InvalidObject(String),
165    #[error("repository not found at {0}")]
166    RepositoryNotFound(std::path::PathBuf),
167    #[error("repository already exists at {0}")]
168    RepositoryExists(std::path::PathBuf),
169    #[error("repository clone at {0} is incomplete and must be repaired from its origin")]
170    IncompleteClone(std::path::PathBuf),
171    #[error(
172        "repository config at {path} uses repository format {found} but this binary supports {supported}; upgrade heddle or run `heddle migrate`"
173    )]
174    RepositoryFormatTooNew {
175        path: std::path::PathBuf,
176        found: u32,
177        supported: u32,
178    },
179    #[error(
180        "repository at {path} predates format v{required} (found v{found}); recreate it or re-adopt its Git history with this Heddle version"
181    )]
182    RepositoryFormatMigrationRequired {
183        path: std::path::PathBuf,
184        found: u32,
185        required: u32,
186    },
187    #[error(
188        "{storage} uses format {found}, but this binary supports {supported}; upgrade Heddle before opening it"
189    )]
190    StorageFormatTooNew {
191        storage: String,
192        found: u32,
193        supported: u32,
194    },
195    #[error(
196        "{storage} predates required format {required} (found {found}); recreate the repository or re-adopt its Git history with this Heddle version"
197    )]
198    StorageFormatMigrationRequired {
199        storage: String,
200        found: u32,
201        required: u32,
202    },
203    #[error("io error: {0}")]
204    Io(#[from] std::io::Error),
205    #[error("repository lock unavailable: {0}")]
206    Lock(#[from] LockError),
207    #[error("serialization error: {0}")]
208    Serialization(String),
209    #[error("configuration error: {0}")]
210    Config(String),
211    #[error("configuration parse error at {path}: {source}")]
212    ConfigParse {
213        path: std::path::PathBuf,
214        // Keep the original `toml::de::Error` as the error source — not a
215        // flattened string — so `HeddleExitCode::from_error` can still
216        // downcast through the chain and classify config-parse failures as
217        // EX_DATAERR (65) rather than falling through to EX_IOERR (74).
218        #[source]
219        source: toml::de::Error,
220    },
221    #[error(
222        "invalid {key}: '{value}' — valid values are {} (in {path})",
223        valid_values.join(" or ")
224    )]
225    ConfigInvalidValue {
226        path: std::path::PathBuf,
227        key: String,
228        value: String,
229        valid_values: Vec<String>,
230    },
231    #[error("conflict: {0}")]
232    Conflict(String),
233    #[error("compression error: {0}")]
234    Compression(String),
235    #[error("invalid ref name: {0}")]
236    InvalidRefName(String),
237    #[error("file too large: {0} bytes")]
238    InvalidFileSize(u64),
239    #[error(
240        "symlink target escapes repository: {} -> {}",
241        path.display(),
242        target.display()
243    )]
244    InvalidSymlinkTarget {
245        path: std::path::PathBuf,
246        target: std::path::PathBuf,
247    },
248    #[error("object corruption: expected {expected}, found {found}")]
249    Corruption {
250        expected: ContentHash,
251        found: ContentHash,
252    },
253    #[error(
254        "missing {object_type} object: {id} (run `heddle fsck --full` to inspect store integrity)"
255    )]
256    MissingObject { object_type: String, id: String },
257    #[error("invalid tree entry: {0}")]
258    InvalidTreeEntry(#[from] TreeError),
259}
260
261impl HeddleError {
262    pub fn recovery(details: RecoveryDetails) -> Self {
263        HeddleError::Recovery(Box::new(details))
264    }
265}
266
267impl From<rmp_serde::encode::Error> for HeddleError {
268    fn from(e: rmp_serde::encode::Error) -> Self {
269        HeddleError::Serialization(e.to_string())
270    }
271}
272
273impl From<rmp_serde::decode::Error> for HeddleError {
274    fn from(e: rmp_serde::decode::Error) -> Self {
275        HeddleError::Serialization(e.to_string())
276    }
277}
278
279impl From<crate::object::SemanticIndexError> for HeddleError {
280    fn from(e: crate::object::SemanticIndexError) -> Self {
281        HeddleError::InvalidObject(e.to_string())
282    }
283}
284
285impl From<toml::de::Error> for HeddleError {
286    fn from(e: toml::de::Error) -> Self {
287        HeddleError::Config(e.to_string())
288    }
289}
290
291impl From<toml::ser::Error> for HeddleError {
292    fn from(e: toml::ser::Error) -> Self {
293        HeddleError::Config(e.to_string())
294    }
295}
296
297impl From<serde_json::Error> for HeddleError {
298    fn from(e: serde_json::Error) -> Self {
299        HeddleError::Serialization(e.to_string())
300    }
301}
302
303impl From<heddle_format::compression::CompressionError> for HeddleError {
304    fn from(e: heddle_format::compression::CompressionError) -> Self {
305        HeddleError::Compression(e.to_string())
306    }
307}
308
309/// Result type for repository/storage-adjacent operations.
310pub type Result<T> = std::result::Result<T, HeddleError>;
311
312#[cfg(test)]
313mod tests {
314    use super::{HeddleError, RecoveryDetails};
315
316    #[test]
317    fn safety_refusal_formats_domain_details() {
318        let details = RecoveryDetails::safety_refusal(
319            "example",
320            "error",
321            "hint",
322            "unsafe",
323            "would change",
324            "preserved",
325        );
326
327        assert_eq!(
328            details.to_string(),
329            "error. Unsafe: unsafe. Would change: would change. Preserved: preserved."
330        );
331    }
332
333    #[test]
334    fn recovery_error_displays_structured_error_copy() {
335        let err = HeddleError::recovery(RecoveryDetails::serialization_error("bad marker"));
336
337        assert!(err.to_string().contains("Repository state is corrupted"));
338        assert!(!err.to_string().contains("heddle fsck --full"));
339    }
340}