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, TreeStreamError};
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("no worktree changes to capture")]
162    NoChanges,
163    #[error("state not found: {0}")]
164    StateNotFound(StateId),
165    #[error("invalid object: {0}")]
166    InvalidObject(String),
167    #[error("repository not found at {0}")]
168    RepositoryNotFound(std::path::PathBuf),
169    #[error("repository already exists at {0}")]
170    RepositoryExists(std::path::PathBuf),
171    #[error("repository clone at {0} is incomplete and must be repaired from its origin")]
172    IncompleteClone(std::path::PathBuf),
173    #[error(
174        "repository config at {path} uses repository format {found} but this binary supports {supported}; upgrade Heddle before opening it"
175    )]
176    RepositoryFormatTooNew {
177        path: std::path::PathBuf,
178        found: u32,
179        supported: u32,
180    },
181    #[error(
182        "repository at {path} predates format v{required} (found v{found}); re-clone or re-import into a new directory. First copy uncommitted and untracked work from every checkout, and back up the entire .heddle directory (including local threads, discussions, context, coordination and operation history). Keep the old repository until recovery is verified; do not edit its format version"
183    )]
184    RepositoryFormatTooOld {
185        path: std::path::PathBuf,
186        found: u32,
187        required: u32,
188    },
189    #[error(
190        "{storage} uses format {found}, but this binary supports {supported}; upgrade Heddle before opening it"
191    )]
192    StorageFormatTooNew {
193        storage: String,
194        found: u32,
195        supported: u32,
196    },
197    #[error(
198        "{storage} predates required format {required} (found {found}); recreate the repository or re-adopt its Git history with this Heddle version"
199    )]
200    StorageFormatTooOld {
201        storage: String,
202        found: u32,
203        required: u32,
204    },
205    #[error("io error: {0}")]
206    Io(#[from] std::io::Error),
207    #[error("repository lock unavailable: {0}")]
208    Lock(#[from] LockError),
209    #[error("serialization error: {0}")]
210    Serialization(String),
211    #[error("configuration error: {0}")]
212    Config(String),
213    /// A checkout attached to a native Thread cannot sign a source operation
214    /// with that Thread's owner key, so capture fails closed. Distinct from
215    /// [`Self::Config`] so callers can tell "no signer" from any other refusal.
216    #[error("native Thread '{thread}' owner signing key is unavailable: {reason}")]
217    NativeSourceSignerUnavailable { thread: String, reason: String },
218    #[error("configuration parse error at {path}: {source}")]
219    ConfigParse {
220        path: std::path::PathBuf,
221        // Keep the original `toml::de::Error` as the error source — not a
222        // flattened string — so `HeddleExitCode::from_error` can still
223        // downcast through the chain and classify config-parse failures as
224        // EX_DATAERR (65) rather than falling through to EX_IOERR (74).
225        #[source]
226        source: toml::de::Error,
227    },
228    #[error(
229        "invalid {key}: '{value}' — valid values are {} (in {path})",
230        valid_values.join(" or ")
231    )]
232    ConfigInvalidValue {
233        path: std::path::PathBuf,
234        key: String,
235        value: String,
236        valid_values: Vec<String>,
237    },
238    #[error("conflict: {0}")]
239    Conflict(String),
240    #[error("compression error: {0}")]
241    Compression(String),
242    #[error("invalid ref name: {0}")]
243    InvalidRefName(String),
244    #[error("file too large: {0} bytes")]
245    InvalidFileSize(u64),
246    #[error(
247        "symlink target escapes repository: {} -> {}",
248        path.display(),
249        target.display()
250    )]
251    InvalidSymlinkTarget {
252        path: std::path::PathBuf,
253        target: std::path::PathBuf,
254    },
255    #[error("object corruption: expected {expected}, found {found}")]
256    Corruption {
257        expected: ContentHash,
258        found: ContentHash,
259    },
260    #[error(
261        "missing {object_type} object: {id} (run `heddle maintenance fsck --full` to inspect store integrity)"
262    )]
263    MissingObject { object_type: String, id: String },
264    #[error("invalid tree entry: {0}")]
265    InvalidTreeEntry(#[from] TreeError),
266    #[error("tree stream error: {0}")]
267    TreeStream(TreeStreamError),
268    /// A redacted-tree (HRT1) projection was encountered where a full,
269    /// materializable tree is required — e.g. asked to store, pack, or read an
270    /// `HRT1` body as a `Tree`, or capture over a `PartialTree` whose withheld
271    /// leaves cannot be re-authored. This is a distinct, fail-loud signal (v4
272    /// redactable trees, Fable F): the wire-status mapping is handled by the
273    /// weft serve leg, but the heddle side must never silently drop the
274    /// withheld leaves.
275    #[error("redacted tree: {0}")]
276    RedactedTree(String),
277}
278
279impl From<TreeStreamError> for HeddleError {
280    fn from(error: TreeStreamError) -> Self {
281        match error {
282            TreeStreamError::Invalid(error) => Self::InvalidTreeEntry(error),
283            other => Self::TreeStream(other),
284        }
285    }
286}
287
288impl HeddleError {
289    pub fn recovery(details: RecoveryDetails) -> Self {
290        HeddleError::Recovery(Box::new(details))
291    }
292}
293
294impl From<rmp_serde::encode::Error> for HeddleError {
295    fn from(e: rmp_serde::encode::Error) -> Self {
296        HeddleError::Serialization(e.to_string())
297    }
298}
299
300impl From<rmp_serde::decode::Error> for HeddleError {
301    fn from(e: rmp_serde::decode::Error) -> Self {
302        HeddleError::Serialization(e.to_string())
303    }
304}
305
306impl From<crate::object::SemanticIndexError> for HeddleError {
307    fn from(e: crate::object::SemanticIndexError) -> Self {
308        HeddleError::InvalidObject(e.to_string())
309    }
310}
311
312impl From<toml::de::Error> for HeddleError {
313    fn from(e: toml::de::Error) -> Self {
314        HeddleError::Config(e.to_string())
315    }
316}
317
318impl From<toml::ser::Error> for HeddleError {
319    fn from(e: toml::ser::Error) -> Self {
320        HeddleError::Config(e.to_string())
321    }
322}
323
324impl From<serde_json::Error> for HeddleError {
325    fn from(e: serde_json::Error) -> Self {
326        HeddleError::Serialization(e.to_string())
327    }
328}
329
330impl From<heddle_format::compression::CompressionError> for HeddleError {
331    fn from(e: heddle_format::compression::CompressionError) -> Self {
332        HeddleError::Compression(e.to_string())
333    }
334}
335
336/// Result type for repository/storage-adjacent operations.
337pub type Result<T> = std::result::Result<T, HeddleError>;
338
339#[cfg(test)]
340mod tests {
341    use super::{HeddleError, RecoveryDetails};
342
343    #[test]
344    fn safety_refusal_formats_domain_details() {
345        let details = RecoveryDetails::safety_refusal(
346            "example",
347            "error",
348            "hint",
349            "unsafe",
350            "would change",
351            "preserved",
352        );
353
354        assert_eq!(
355            details.to_string(),
356            "error. Unsafe: unsafe. Would change: would change. Preserved: preserved."
357        );
358    }
359
360    #[test]
361    fn recovery_error_displays_structured_error_copy() {
362        let err = HeddleError::recovery(RecoveryDetails::serialization_error("bad marker"));
363
364        assert!(err.to_string().contains("Repository state is corrupted"));
365        assert!(!err.to_string().contains("heddle maintenance fsck --full"));
366    }
367}