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/// Why discovery refused a repository candidate. See
153/// [`HeddleError::UntrustedRepository`].
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum UntrustedRepositoryReason {
156    /// The candidate lies inside the worktree of the enclosing repository at
157    /// `enclosing`, so its `.heddle` may be tracked or checked-out content.
158    Embedded { enclosing: std::path::PathBuf },
159    /// The repository metadata at `path` is owned by `owner`, not by the
160    /// current effective user `current`.
161    ForeignOwner {
162        path: std::path::PathBuf,
163        owner: u32,
164        current: u32,
165    },
166}
167
168impl fmt::Display for UntrustedRepositoryReason {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        match self {
171            Self::Embedded { enclosing } => write!(
172                f,
173                "it lies inside the worktree of the Heddle repository at {}, so its metadata may be checked-out content",
174                enclosing.display()
175            ),
176            Self::ForeignOwner {
177                path,
178                owner,
179                current,
180            } => write!(
181                f,
182                "{} is owned by uid {owner}, not by the current user (uid {current})",
183                path.display()
184            ),
185        }
186    }
187}
188
189/// Error type for repository/storage-adjacent operations.
190#[derive(Debug, thiserror::Error)]
191pub enum HeddleError {
192    #[error("{0}")]
193    Recovery(Box<RecoveryDetails>),
194    #[error("object not found: {0}")]
195    NotFound(String),
196    #[error("No merge in progress")]
197    NoMergeInProgress,
198    #[error("no worktree changes to capture")]
199    NoChanges,
200    #[error("state not found: {0}")]
201    StateNotFound(StateId),
202    #[error("invalid object: {0}")]
203    InvalidObject(String),
204    #[error("repository not found at {0}")]
205    RepositoryNotFound(std::path::PathBuf),
206    #[error("repository already exists at {0}")]
207    RepositoryExists(std::path::PathBuf),
208    #[error("repository clone at {0} is incomplete and must be repaired from its origin")]
209    IncompleteClone(std::path::PathBuf),
210    /// Discovery found repository metadata the user never vouched for: it
211    /// lies inside another Heddle repository's worktree (so it may be
212    /// checked-out content), or another user owns it. Opening it would let
213    /// its config, hooks, and store pointer act on this user's behalf.
214    #[error(
215        "refusing to use the Heddle repository at {root}: {reason}. If you trust it, add it to `[safe] repositories` in your Heddle user config"
216    )]
217    UntrustedRepository {
218        root: std::path::PathBuf,
219        reason: UntrustedRepositoryReason,
220    },
221    #[error(
222        "repository config at {path} uses repository format {found} but this binary supports {supported}; upgrade Heddle before opening it"
223    )]
224    RepositoryFormatTooNew {
225        path: std::path::PathBuf,
226        found: u32,
227        supported: u32,
228    },
229    #[error(
230        "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"
231    )]
232    RepositoryFormatTooOld {
233        path: std::path::PathBuf,
234        found: u32,
235        required: u32,
236    },
237    #[error(
238        "{storage} uses format {found}, but this binary supports {supported}; upgrade Heddle before opening it"
239    )]
240    StorageFormatTooNew {
241        storage: String,
242        found: u32,
243        supported: u32,
244    },
245    #[error(
246        "{storage} predates required format {required} (found {found}); recreate the repository or re-adopt its Git history with this Heddle version"
247    )]
248    StorageFormatTooOld {
249        storage: String,
250        found: u32,
251        required: u32,
252    },
253    #[error("io error: {0}")]
254    Io(#[from] std::io::Error),
255    #[error("repository lock unavailable: {0}")]
256    Lock(#[from] LockError),
257    #[error("serialization error: {0}")]
258    Serialization(String),
259    #[error("configuration error: {0}")]
260    Config(String),
261    /// A checkout attached to a native Thread cannot sign a source operation
262    /// with that Thread's owner key, so capture fails closed. Distinct from
263    /// [`Self::Config`] so callers can tell "no signer" from any other refusal.
264    #[error("native Thread '{thread}' owner signing key is unavailable: {reason}")]
265    NativeSourceSignerUnavailable { thread: String, reason: String },
266    #[error("configuration parse error at {path}: {source}")]
267    ConfigParse {
268        path: std::path::PathBuf,
269        // Keep the original `toml::de::Error` as the error source — not a
270        // flattened string — so `HeddleExitCode::from_error` can still
271        // downcast through the chain and classify config-parse failures as
272        // EX_DATAERR (65) rather than falling through to EX_IOERR (74).
273        #[source]
274        source: toml::de::Error,
275    },
276    #[error(
277        "invalid {key}: '{value}' — valid values are {} (in {path})",
278        valid_values.join(" or ")
279    )]
280    ConfigInvalidValue {
281        path: std::path::PathBuf,
282        key: String,
283        value: String,
284        valid_values: Vec<String>,
285    },
286    #[error("conflict: {0}")]
287    Conflict(String),
288    #[error("compression error: {0}")]
289    Compression(String),
290    #[error("invalid ref name: {0}")]
291    InvalidRefName(String),
292    #[error("file too large: {0} bytes")]
293    InvalidFileSize(u64),
294    #[error("object corruption: expected {expected}, found {found}")]
295    Corruption {
296        expected: ContentHash,
297        found: ContentHash,
298    },
299    #[error(
300        "missing {object_type} object: {id} is not available locally (run `heddle maintenance fsck --full` to inspect store integrity)"
301    )]
302    MissingObject { object_type: String, id: String },
303    #[error("invalid tree entry: {0}")]
304    InvalidTreeEntry(#[from] TreeError),
305    #[error("tree stream error: {0}")]
306    TreeStream(TreeStreamError),
307    /// A redacted-tree (HRT1) projection was encountered where a full,
308    /// materializable tree is required — e.g. asked to store, pack, or read an
309    /// `HRT1` body as a `Tree`, or capture over a `PartialTree` whose withheld
310    /// leaves cannot be re-authored. This is a distinct, fail-loud signal (v4
311    /// redactable trees, Fable F): the wire-status mapping is handled by the
312    /// weft serve leg, but the heddle side must never silently drop the
313    /// withheld leaves.
314    #[error("redacted tree: {0}")]
315    RedactedTree(String),
316    /// A worktree write would create a path through a repository metadata
317    /// directory (heddle#2028): `.git` at any depth, or the root `.heddle`.
318    #[error("refusing to write '{}': {reason}", path.display())]
319    ReservedWorktreePath {
320        path: std::path::PathBuf,
321        reason: crate::object::ReservedPathComponent,
322    },
323}
324
325impl From<TreeStreamError> for HeddleError {
326    fn from(error: TreeStreamError) -> Self {
327        match error {
328            TreeStreamError::Invalid(error) => Self::InvalidTreeEntry(error),
329            other => Self::TreeStream(other),
330        }
331    }
332}
333
334impl HeddleError {
335    pub fn recovery(details: RecoveryDetails) -> Self {
336        HeddleError::Recovery(Box::new(details))
337    }
338}
339
340impl From<rmp_serde::encode::Error> for HeddleError {
341    fn from(e: rmp_serde::encode::Error) -> Self {
342        HeddleError::Serialization(e.to_string())
343    }
344}
345
346impl From<rmp_serde::decode::Error> for HeddleError {
347    fn from(e: rmp_serde::decode::Error) -> Self {
348        HeddleError::Serialization(e.to_string())
349    }
350}
351
352impl From<crate::object::SemanticIndexError> for HeddleError {
353    fn from(e: crate::object::SemanticIndexError) -> Self {
354        HeddleError::InvalidObject(e.to_string())
355    }
356}
357
358impl From<toml::de::Error> for HeddleError {
359    fn from(e: toml::de::Error) -> Self {
360        HeddleError::Config(e.to_string())
361    }
362}
363
364impl From<toml::ser::Error> for HeddleError {
365    fn from(e: toml::ser::Error) -> Self {
366        HeddleError::Config(e.to_string())
367    }
368}
369
370impl From<serde_json::Error> for HeddleError {
371    fn from(e: serde_json::Error) -> Self {
372        HeddleError::Serialization(e.to_string())
373    }
374}
375
376impl From<heddle_format::compression::CompressionError> for HeddleError {
377    fn from(e: heddle_format::compression::CompressionError) -> Self {
378        HeddleError::Compression(e.to_string())
379    }
380}
381
382/// Result type for repository/storage-adjacent operations.
383pub type Result<T> = std::result::Result<T, HeddleError>;
384
385impl From<anyhow::Error> for HeddleError {
386    fn from(error: anyhow::Error) -> Self {
387        match error.downcast::<Self>() {
388            Ok(error) => error,
389            Err(error) => Self::InvalidObject(error.to_string()),
390        }
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::{HeddleError, RecoveryDetails};
397
398    #[test]
399    fn safety_refusal_formats_domain_details() {
400        let details = RecoveryDetails::safety_refusal(
401            "example",
402            "error",
403            "hint",
404            "unsafe",
405            "would change",
406            "preserved",
407        );
408
409        assert_eq!(
410            details.to_string(),
411            "error. Unsafe: unsafe. Would change: would change. Preserved: preserved."
412        );
413    }
414
415    #[test]
416    fn recovery_error_displays_structured_error_copy() {
417        let err = HeddleError::recovery(RecoveryDetails::serialization_error("bad marker"));
418
419        assert!(err.to_string().contains("Repository state is corrupted"));
420        assert!(!err.to_string().contains("heddle maintenance fsck --full"));
421    }
422}