heddle-object-model 0.29.0

Heddle's content-addressed object model and stable codecs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
// SPDX-License-Identifier: Apache-2.0
//! Shared error types across Heddle crates.

use std::{error::Error, fmt, io, path::Path};

use crate::object::{ContentHash, StateId, TreeError, TreeStreamError};

/// Structured recovery details that can cross the embeddable facade boundary.
#[derive(Debug, Clone, PartialEq)]
pub struct RecoveryDetails {
    pub kind: &'static str,
    pub error: String,
    pub hint: String,
    pub unsafe_condition: String,
    pub would_change: String,
    pub preserved: String,
    /// Explicit, path-specific recovery commands. When present these override
    /// the `kind`-keyed fallback the CLI envelope would otherwise reconstruct
    /// (the first entry is the primary command). `None` = use the generic
    /// per-`kind` recovery mapping.
    pub recovery_commands: Option<Vec<String>>,
}

impl RecoveryDetails {
    pub fn safety_refusal(
        kind: &'static str,
        error: impl Into<String>,
        hint: impl Into<String>,
        unsafe_condition: impl Into<String>,
        would_change: impl Into<String>,
        already_preserved: impl Into<String>,
    ) -> Self {
        Self {
            kind,
            error: error.into(),
            hint: hint.into(),
            unsafe_condition: unsafe_condition.into(),
            would_change: would_change.into(),
            preserved: already_preserved.into(),
            recovery_commands: None,
        }
    }

    /// Attach explicit, path-specific recovery commands (the first entry is the
    /// primary command). Used where the callsite has context — e.g. a source
    /// checkout path — that the `kind`-keyed CLI fallback cannot reconstruct.
    #[must_use]
    pub fn with_recovery_commands(mut self, commands: Vec<String>) -> Self {
        self.recovery_commands = Some(commands);
        self
    }

    pub fn invalid_usage(
        kind: &'static str,
        error: impl Into<String>,
        hint: impl Into<String>,
    ) -> Self {
        Self::safety_refusal(
            kind,
            error,
            hint,
            "the command arguments do not describe a valid operation",
            "running with ambiguous or invalid arguments could target the wrong repository state or metadata",
            "no repository objects, refs, metadata, or worktree files were changed",
        )
    }

    pub fn feature_unavailable(command: &str, feature: &str) -> Self {
        Self::safety_refusal(
            "feature_unavailable",
            format!("{command} requires building heddle with --features {feature}"),
            format!(
                "Use a binary built with the `{feature}` feature, or rerun without the feature-specific flag."
            ),
            format!("this heddle binary was built without the `{feature}` feature"),
            format!("{command} cannot run because the requested analysis engine is unavailable"),
            "repository state, refs, and worktree files were left unchanged",
        )
    }

    pub fn serialization_error(detail: impl fmt::Display) -> Self {
        Self::safety_refusal(
            "state_corrupted",
            "Repository state is corrupted or unreadable",
            "Inspect repository integrity before attempting repair.",
            format!("a stored repository object failed to decode: {detail}"),
            "continuing would read or write through repository state Heddle cannot decode",
            "the command stopped before mutating repository state; intact objects were left unchanged",
        )
    }

    pub fn repository_integrity_error(error: impl Into<String>) -> Self {
        Self::safety_refusal(
            "repository_integrity_error",
            error,
            "Inspect repository integrity, then restore or repair the reported object/ref.",
            "repository object or ref integrity did not pass validation",
            "continuing could compound corruption or hide the missing object",
            "the command stopped before applying the requested mutation",
        )
    }

    pub fn repository_not_found(path: &Path) -> Self {
        Self::safety_refusal(
            "repository_not_found",
            format!("repository not found at {}", path.display()),
            "Initialize the requested repository before running repository commands.",
            format!("no Heddle repository was found at '{}'", path.display()),
            "the command cannot inspect or change repository state until initialization",
            "no repository objects, refs, metadata, or worktree files were changed",
        )
    }

    pub fn state_not_found(state_id: impl fmt::Display) -> Self {
        Self::safety_refusal(
            "state_not_found",
            format!("State not found: {state_id}"),
            "List recent states with `heddle log`, then choose an existing state id.",
            "the requested state id does not exist in this repository",
            "continuing with a guessed state could target the wrong history point",
            "repository state, refs, metadata, and worktree files were left unchanged",
        )
    }
}

impl fmt::Display for RecoveryDetails {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{}. Unsafe: {}. Would change: {}. Preserved: {}.",
            self.error, self.unsafe_condition, self.would_change, self.preserved
        )?;
        Ok(())
    }
}

impl Error for RecoveryDetails {}

/// Failure to acquire or access a repository lock.
///
/// The lock implementation lives in `heddle-objects`; this pure error value
/// lives with [`HeddleError`] so the object-model crate does not depend on a
/// storage backend.
#[derive(Debug, thiserror::Error)]
pub enum LockError {
    #[error("failed to acquire lock: {0}")]
    Acquire(#[source] io::Error),
    #[error("lock file not accessible: {0}")]
    Io(#[source] io::Error),
}

/// Why discovery refused a repository candidate. See
/// [`HeddleError::UntrustedRepository`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UntrustedRepositoryReason {
    /// The candidate lies inside the worktree of the enclosing repository at
    /// `enclosing`, so its `.heddle` may be tracked or checked-out content.
    Embedded { enclosing: std::path::PathBuf },
    /// The repository metadata at `path` is owned by `owner`, not by the
    /// current effective user `current`.
    ForeignOwner {
        path: std::path::PathBuf,
        owner: u32,
        current: u32,
    },
}

impl fmt::Display for UntrustedRepositoryReason {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Embedded { enclosing } => write!(
                f,
                "it lies inside the worktree of the Heddle repository at {}, so its metadata may be checked-out content",
                enclosing.display()
            ),
            Self::ForeignOwner {
                path,
                owner,
                current,
            } => write!(
                f,
                "{} is owned by uid {owner}, not by the current user (uid {current})",
                path.display()
            ),
        }
    }
}

/// Error type for repository/storage-adjacent operations.
#[derive(Debug, thiserror::Error)]
pub enum HeddleError {
    #[error("{0}")]
    Recovery(Box<RecoveryDetails>),
    #[error("object not found: {0}")]
    NotFound(String),
    #[error("No merge in progress")]
    NoMergeInProgress,
    #[error("no worktree changes to capture")]
    NoChanges,
    #[error("state not found: {0}")]
    StateNotFound(StateId),
    #[error("invalid object: {0}")]
    InvalidObject(String),
    #[error("repository not found at {0}")]
    RepositoryNotFound(std::path::PathBuf),
    #[error("repository already exists at {0}")]
    RepositoryExists(std::path::PathBuf),
    #[error("repository clone at {0} is incomplete and must be repaired from its origin")]
    IncompleteClone(std::path::PathBuf),
    /// Discovery found repository metadata the user never vouched for: it
    /// lies inside another Heddle repository's worktree (so it may be
    /// checked-out content), or another user owns it. Opening it would let
    /// its config, hooks, and store pointer act on this user's behalf.
    #[error(
        "refusing to use the Heddle repository at {root}: {reason}. If you trust it, add it to `[safe] repositories` in your Heddle user config"
    )]
    UntrustedRepository {
        root: std::path::PathBuf,
        reason: UntrustedRepositoryReason,
    },
    #[error(
        "repository config at {path} uses repository format {found} but this binary supports {supported}; upgrade Heddle before opening it"
    )]
    RepositoryFormatTooNew {
        path: std::path::PathBuf,
        found: u32,
        supported: u32,
    },
    #[error(
        "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"
    )]
    RepositoryFormatTooOld {
        path: std::path::PathBuf,
        found: u32,
        required: u32,
    },
    #[error(
        "{storage} uses format {found}, but this binary supports {supported}; upgrade Heddle before opening it"
    )]
    StorageFormatTooNew {
        storage: String,
        found: u32,
        supported: u32,
    },
    #[error(
        "{storage} predates required format {required} (found {found}); recreate the repository or re-adopt its Git history with this Heddle version"
    )]
    StorageFormatTooOld {
        storage: String,
        found: u32,
        required: u32,
    },
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
    #[error("repository lock unavailable: {0}")]
    Lock(#[from] LockError),
    #[error("serialization error: {0}")]
    Serialization(String),
    #[error("configuration error: {0}")]
    Config(String),
    /// A checkout attached to a native Thread cannot sign a source operation
    /// with that Thread's owner key, so capture fails closed. Distinct from
    /// [`Self::Config`] so callers can tell "no signer" from any other refusal.
    #[error("native Thread '{thread}' owner signing key is unavailable: {reason}")]
    NativeSourceSignerUnavailable { thread: String, reason: String },
    #[error("configuration parse error at {path}: {source}")]
    ConfigParse {
        path: std::path::PathBuf,
        // Keep the original `toml::de::Error` as the error source — not a
        // flattened string — so `HeddleExitCode::from_error` can still
        // downcast through the chain and classify config-parse failures as
        // EX_DATAERR (65) rather than falling through to EX_IOERR (74).
        #[source]
        source: toml::de::Error,
    },
    #[error(
        "invalid {key}: '{value}' — valid values are {} (in {path})",
        valid_values.join(" or ")
    )]
    ConfigInvalidValue {
        path: std::path::PathBuf,
        key: String,
        value: String,
        valid_values: Vec<String>,
    },
    #[error("conflict: {0}")]
    Conflict(String),
    #[error("compression error: {0}")]
    Compression(String),
    #[error("invalid ref name: {0}")]
    InvalidRefName(String),
    #[error("file too large: {0} bytes")]
    InvalidFileSize(u64),
    #[error("object corruption: expected {expected}, found {found}")]
    Corruption {
        expected: ContentHash,
        found: ContentHash,
    },
    #[error(
        "missing {object_type} object: {id} is not available locally (run `heddle maintenance fsck --full` to inspect store integrity)"
    )]
    MissingObject { object_type: String, id: String },
    #[error("invalid tree entry: {0}")]
    InvalidTreeEntry(#[from] TreeError),
    #[error("tree stream error: {0}")]
    TreeStream(TreeStreamError),
    /// A redacted-tree (HRT1) projection was encountered where a full,
    /// materializable tree is required — e.g. asked to store, pack, or read an
    /// `HRT1` body as a `Tree`, or capture over a `PartialTree` whose withheld
    /// leaves cannot be re-authored. This is a distinct, fail-loud signal (v4
    /// redactable trees, Fable F): the wire-status mapping is handled by the
    /// weft serve leg, but the heddle side must never silently drop the
    /// withheld leaves.
    #[error("redacted tree: {0}")]
    RedactedTree(String),
    /// A worktree write would create a path through a repository metadata
    /// directory (heddle#2028): `.git` at any depth, or the root `.heddle`.
    #[error("refusing to write '{}': {reason}", path.display())]
    ReservedWorktreePath {
        path: std::path::PathBuf,
        reason: crate::object::ReservedPathComponent,
    },
}

impl From<TreeStreamError> for HeddleError {
    fn from(error: TreeStreamError) -> Self {
        match error {
            TreeStreamError::Invalid(error) => Self::InvalidTreeEntry(error),
            other => Self::TreeStream(other),
        }
    }
}

impl HeddleError {
    pub fn recovery(details: RecoveryDetails) -> Self {
        HeddleError::Recovery(Box::new(details))
    }
}

impl From<rmp_serde::encode::Error> for HeddleError {
    fn from(e: rmp_serde::encode::Error) -> Self {
        HeddleError::Serialization(e.to_string())
    }
}

impl From<rmp_serde::decode::Error> for HeddleError {
    fn from(e: rmp_serde::decode::Error) -> Self {
        HeddleError::Serialization(e.to_string())
    }
}

impl From<crate::object::SemanticIndexError> for HeddleError {
    fn from(e: crate::object::SemanticIndexError) -> Self {
        HeddleError::InvalidObject(e.to_string())
    }
}

impl From<toml::de::Error> for HeddleError {
    fn from(e: toml::de::Error) -> Self {
        HeddleError::Config(e.to_string())
    }
}

impl From<toml::ser::Error> for HeddleError {
    fn from(e: toml::ser::Error) -> Self {
        HeddleError::Config(e.to_string())
    }
}

impl From<serde_json::Error> for HeddleError {
    fn from(e: serde_json::Error) -> Self {
        HeddleError::Serialization(e.to_string())
    }
}

impl From<heddle_format::compression::CompressionError> for HeddleError {
    fn from(e: heddle_format::compression::CompressionError) -> Self {
        HeddleError::Compression(e.to_string())
    }
}

/// Result type for repository/storage-adjacent operations.
pub type Result<T> = std::result::Result<T, HeddleError>;

impl From<anyhow::Error> for HeddleError {
    fn from(error: anyhow::Error) -> Self {
        match error.downcast::<Self>() {
            Ok(error) => error,
            Err(error) => Self::InvalidObject(error.to_string()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{HeddleError, RecoveryDetails};

    #[test]
    fn safety_refusal_formats_domain_details() {
        let details = RecoveryDetails::safety_refusal(
            "example",
            "error",
            "hint",
            "unsafe",
            "would change",
            "preserved",
        );

        assert_eq!(
            details.to_string(),
            "error. Unsafe: unsafe. Would change: would change. Preserved: preserved."
        );
    }

    #[test]
    fn recovery_error_displays_structured_error_copy() {
        let err = HeddleError::recovery(RecoveryDetails::serialization_error("bad marker"));

        assert!(err.to_string().contains("Repository state is corrupted"));
        assert!(!err.to_string().contains("heddle maintenance fsck --full"));
    }
}