Skip to main content

objects/
error.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared error types across Heddle crates.
3
4use std::{error::Error, fmt, path::Path};
5
6use crate::object::{ChangeId, ContentHash, 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}
18
19impl RecoveryDetails {
20    pub fn safety_refusal(
21        kind: &'static str,
22        error: impl Into<String>,
23        hint: impl Into<String>,
24        unsafe_condition: impl Into<String>,
25        would_change: impl Into<String>,
26        already_preserved: impl Into<String>,
27    ) -> Self {
28        Self {
29            kind,
30            error: error.into(),
31            hint: hint.into(),
32            unsafe_condition: unsafe_condition.into(),
33            would_change: would_change.into(),
34            preserved: already_preserved.into(),
35        }
36    }
37
38    pub fn invalid_usage(
39        kind: &'static str,
40        error: impl Into<String>,
41        hint: impl Into<String>,
42    ) -> Self {
43        Self::safety_refusal(
44            kind,
45            error,
46            hint,
47            "the command arguments do not describe a valid operation",
48            "running with ambiguous or invalid arguments could target the wrong repository state or metadata",
49            "no repository objects, refs, metadata, or worktree files were changed",
50        )
51    }
52
53    pub fn feature_unavailable(command: &str, feature: &str) -> Self {
54        Self::safety_refusal(
55            "feature_unavailable",
56            format!("{command} requires building heddle with --features {feature}"),
57            format!(
58                "Use a binary built with the `{feature}` feature, or rerun without the feature-specific flag."
59            ),
60            format!("this heddle binary was built without the `{feature}` feature"),
61            format!("{command} cannot run because the requested analysis engine is unavailable"),
62            "repository state, refs, and worktree files were left unchanged",
63        )
64    }
65
66    pub fn serialization_error(detail: impl fmt::Display) -> Self {
67        Self::safety_refusal(
68            "state_corrupted",
69            "Repository state is corrupted or unreadable",
70            "Inspect repository integrity before attempting repair.",
71            format!("a stored repository object failed to decode: {detail}"),
72            "continuing would read or write through repository state Heddle cannot decode",
73            "the command stopped before mutating repository state; intact objects were left unchanged",
74        )
75    }
76
77    pub fn repository_integrity_error(error: impl Into<String>) -> Self {
78        Self::safety_refusal(
79            "repository_integrity_error",
80            error,
81            "Inspect repository integrity, then restore or repair the reported object/ref.",
82            "repository object or ref integrity did not pass validation",
83            "continuing could compound corruption or hide the missing object",
84            "the command stopped before applying the requested mutation",
85        )
86    }
87
88    pub fn repository_not_found(path: &Path) -> Self {
89        Self::safety_refusal(
90            "repository_not_found",
91            format!("repository not found at {}", path.display()),
92            "Initialize the requested repository before running repository commands.",
93            format!("no Heddle repository was found at '{}'", path.display()),
94            "the command cannot inspect or change repository state until initialization",
95            "no repository objects, refs, metadata, or worktree files were changed",
96        )
97    }
98
99    pub fn state_not_found(state_id: impl fmt::Display) -> Self {
100        Self::safety_refusal(
101            "state_not_found",
102            format!("State not found: {state_id}"),
103            "List recent states with `heddle log`, then choose an existing state id.",
104            "the requested state id does not exist in this repository",
105            "continuing with a guessed state could target the wrong history point",
106            "repository state, refs, metadata, and worktree files were left unchanged",
107        )
108    }
109}
110
111impl fmt::Display for RecoveryDetails {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        write!(
114            f,
115            "{}. Unsafe: {}. Would change: {}. Preserved: {}.",
116            self.error, self.unsafe_condition, self.would_change, self.preserved
117        )?;
118        Ok(())
119    }
120}
121
122impl Error for RecoveryDetails {}
123
124/// Error type for repository/storage-adjacent operations.
125#[derive(Debug, thiserror::Error)]
126pub enum HeddleError {
127    #[error("{0}")]
128    Recovery(Box<RecoveryDetails>),
129    #[error("object not found: {0}")]
130    NotFound(String),
131    #[error("No merge in progress")]
132    NoMergeInProgress,
133    #[error("state not found: {0}")]
134    StateNotFound(ChangeId),
135    #[error("invalid object: {0}")]
136    InvalidObject(String),
137    #[error("repository not found at {0}")]
138    RepositoryNotFound(std::path::PathBuf),
139    #[error("repository already exists at {0}")]
140    RepositoryExists(std::path::PathBuf),
141    #[error(
142        "repository config at {path} uses repository format {found} but this binary supports {supported}; upgrade heddle or run `heddle migrate`"
143    )]
144    RepositoryFormatTooNew {
145        path: std::path::PathBuf,
146        found: u32,
147        supported: u32,
148    },
149    #[error("io error: {0}")]
150    Io(#[from] std::io::Error),
151    #[error("serialization error: {0}")]
152    Serialization(String),
153    #[error("configuration error: {0}")]
154    Config(String),
155    #[error("configuration parse error at {path}: {source}")]
156    ConfigParse {
157        path: std::path::PathBuf,
158        // Keep the original `toml::de::Error` as the error source — not a
159        // flattened string — so `HeddleExitCode::from_error` can still
160        // downcast through the chain and classify config-parse failures as
161        // EX_DATAERR (65) rather than falling through to EX_IOERR (74).
162        #[source]
163        source: toml::de::Error,
164    },
165    #[error(
166        "invalid {key}: '{value}' — valid values are {} (in {path})",
167        valid_values.join(" or ")
168    )]
169    ConfigInvalidValue {
170        path: std::path::PathBuf,
171        key: String,
172        value: String,
173        valid_values: Vec<String>,
174    },
175    #[error("conflict: {0}")]
176    Conflict(String),
177    #[error("compression error: {0}")]
178    Compression(String),
179    #[error("invalid ref name: {0}")]
180    InvalidRefName(String),
181    #[error("file too large: {0} bytes")]
182    InvalidFileSize(u64),
183    #[error("symlink target escapes repository: {0}")]
184    InvalidSymlinkTarget(std::path::PathBuf),
185    #[error("object corruption: expected {expected}, found {found}")]
186    Corruption {
187        expected: ContentHash,
188        found: ContentHash,
189    },
190    #[error(
191        "missing {object_type} object: {id} (run `heddle fsck --full` to inspect store integrity)"
192    )]
193    MissingObject { object_type: String, id: String },
194    #[error("invalid tree entry: {0}")]
195    InvalidTreeEntry(#[from] TreeError),
196}
197
198impl HeddleError {
199    pub fn recovery(details: RecoveryDetails) -> Self {
200        HeddleError::Recovery(Box::new(details))
201    }
202}
203
204impl From<rmp_serde::encode::Error> for HeddleError {
205    fn from(e: rmp_serde::encode::Error) -> Self {
206        HeddleError::Serialization(e.to_string())
207    }
208}
209
210impl From<rmp_serde::decode::Error> for HeddleError {
211    fn from(e: rmp_serde::decode::Error) -> Self {
212        HeddleError::Serialization(e.to_string())
213    }
214}
215
216impl From<toml::de::Error> for HeddleError {
217    fn from(e: toml::de::Error) -> Self {
218        HeddleError::Config(e.to_string())
219    }
220}
221
222impl From<toml::ser::Error> for HeddleError {
223    fn from(e: toml::ser::Error) -> Self {
224        HeddleError::Config(e.to_string())
225    }
226}
227
228impl From<serde_json::Error> for HeddleError {
229    fn from(e: serde_json::Error) -> Self {
230        HeddleError::Serialization(e.to_string())
231    }
232}
233
234/// Result type for repository/storage-adjacent operations.
235pub type Result<T> = std::result::Result<T, HeddleError>;
236
237#[cfg(test)]
238mod tests {
239    use super::{HeddleError, RecoveryDetails};
240
241    #[test]
242    fn safety_refusal_formats_domain_details() {
243        let details = RecoveryDetails::safety_refusal(
244            "example",
245            "error",
246            "hint",
247            "unsafe",
248            "would change",
249            "preserved",
250        );
251
252        assert_eq!(
253            details.to_string(),
254            "error. Unsafe: unsafe. Would change: would change. Preserved: preserved."
255        );
256    }
257
258    #[test]
259    fn recovery_error_displays_structured_error_copy() {
260        let err = HeddleError::recovery(RecoveryDetails::serialization_error("bad marker"));
261
262        assert!(err.to_string().contains("Repository state is corrupted"));
263        assert!(!err.to_string().contains("heddle fsck --full"));
264    }
265}