1use std::{error::Error, fmt, io, path::Path};
5
6use crate::object::{ContentHash, StateId, TreeError, TreeStreamError};
7
8#[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 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 #[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum UntrustedRepositoryReason {
156 Embedded { enclosing: std::path::PathBuf },
159 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#[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 #[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 #[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 #[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 #[error("redacted tree: {0}")]
315 RedactedTree(String),
316 #[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
382pub 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}