Skip to main content

weavatrix_worktree/
error.rs

1use core::fmt;
2
3/// Stable transaction phase attached to every worktree failure.
4#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
5pub enum TransactionPhase {
6    Open,
7    Validate,
8    Lock,
9    DryRun,
10    Prepare,
11    Stage,
12    Commit,
13    Rollback,
14    Recover,
15    Cleanup,
16}
17
18impl TransactionPhase {
19    #[must_use]
20    pub const fn as_str(self) -> &'static str {
21        match self {
22            Self::Open => "OPEN",
23            Self::Validate => "VALIDATE",
24            Self::Lock => "LOCK",
25            Self::DryRun => "DRY_RUN",
26            Self::Prepare => "PREPARE",
27            Self::Stage => "STAGE",
28            Self::Commit => "COMMIT",
29            Self::Rollback => "ROLLBACK",
30            Self::Recover => "RECOVER",
31            Self::Cleanup => "CLEANUP",
32        }
33    }
34}
35
36impl fmt::Display for TransactionPhase {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        formatter.write_str(self.as_str())
39    }
40}
41
42/// Stable machine-readable worktree failure categories.
43#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
44pub enum WorktreeErrorCode {
45    InvalidRoot,
46    InvalidOptions,
47    RootBusy,
48    RecoveryRequired,
49    InvalidPlan,
50    OperationConflict,
51    PathExists,
52    PathMissing,
53    ReservedPath,
54    PathEscape,
55    SymlinkNotAllowed,
56    CrossFilesystem,
57    NotRegularFile,
58    HardlinkNotAllowed,
59    ReadOnlyFile,
60    SourceTooLarge,
61    TransactionTooLarge,
62    NonUtf8Source,
63    SourceHashMismatch,
64    ConcurrentModification,
65    EditRejected,
66    StageFailed,
67    DurabilityFailed,
68    CommitFailed,
69    RollbackFailed,
70    JournalCorrupt,
71    UndoNotFound,
72    UndoConflict,
73    UndoStoreFull,
74    UndoCorrupt,
75    UndoFailed,
76    WorkerPanicked,
77    Cancelled,
78    Io,
79}
80
81impl WorktreeErrorCode {
82    #[must_use]
83    pub const fn as_str(self) -> &'static str {
84        match self {
85            Self::InvalidRoot => "INVALID_ROOT",
86            Self::InvalidOptions => "INVALID_OPTIONS",
87            Self::RootBusy => "ROOT_BUSY",
88            Self::RecoveryRequired => "RECOVERY_REQUIRED",
89            Self::InvalidPlan => "INVALID_PLAN",
90            Self::OperationConflict => "OPERATION_CONFLICT",
91            Self::PathExists => "PATH_EXISTS",
92            Self::PathMissing => "PATH_MISSING",
93            Self::ReservedPath => "RESERVED_PATH",
94            Self::PathEscape => "PATH_ESCAPE",
95            Self::SymlinkNotAllowed => "SYMLINK_NOT_ALLOWED",
96            Self::CrossFilesystem => "CROSS_FILESYSTEM",
97            Self::NotRegularFile => "NOT_REGULAR_FILE",
98            Self::HardlinkNotAllowed => "HARDLINK_NOT_ALLOWED",
99            Self::ReadOnlyFile => "READ_ONLY_FILE",
100            Self::SourceTooLarge => "SOURCE_TOO_LARGE",
101            Self::TransactionTooLarge => "TRANSACTION_TOO_LARGE",
102            Self::NonUtf8Source => "NON_UTF8_SOURCE",
103            Self::SourceHashMismatch => "SOURCE_HASH_MISMATCH",
104            Self::ConcurrentModification => "CONCURRENT_MODIFICATION",
105            Self::EditRejected => "EDIT_REJECTED",
106            Self::StageFailed => "STAGE_FAILED",
107            Self::DurabilityFailed => "DURABILITY_FAILED",
108            Self::CommitFailed => "COMMIT_FAILED",
109            Self::RollbackFailed => "ROLLBACK_FAILED",
110            Self::JournalCorrupt => "JOURNAL_CORRUPT",
111            Self::UndoNotFound => "UNDO_NOT_FOUND",
112            Self::UndoConflict => "UNDO_CONFLICT",
113            Self::UndoStoreFull => "UNDO_STORE_FULL",
114            Self::UndoCorrupt => "UNDO_CORRUPT",
115            Self::UndoFailed => "UNDO_FAILED",
116            Self::WorkerPanicked => "WORKER_PANICKED",
117            Self::Cancelled => "CANCELLED",
118            Self::Io => "IO",
119        }
120    }
121}
122
123impl fmt::Display for WorktreeErrorCode {
124    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125        formatter.write_str(self.as_str())
126    }
127}
128
129/// Structured worktree error with stable routing fields.
130#[derive(Debug)]
131pub struct WorktreeError {
132    code: WorktreeErrorCode,
133    phase: TransactionPhase,
134    message: String,
135    path: Option<String>,
136    file_index: Option<usize>,
137    transaction_id: Option<String>,
138    recovery_required: bool,
139    source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
140}
141
142impl WorktreeError {
143    pub(crate) fn new(
144        code: WorktreeErrorCode,
145        phase: TransactionPhase,
146        message: impl Into<String>,
147    ) -> Self {
148        Self {
149            code,
150            phase,
151            message: message.into(),
152            path: None,
153            file_index: None,
154            transaction_id: None,
155            recovery_required: matches!(
156                code,
157                WorktreeErrorCode::RecoveryRequired | WorktreeErrorCode::RollbackFailed
158            ),
159            source: None,
160        }
161    }
162
163    pub(crate) fn with_source<E>(
164        code: WorktreeErrorCode,
165        phase: TransactionPhase,
166        message: impl Into<String>,
167        source: E,
168    ) -> Self
169    where
170        E: std::error::Error + Send + Sync + 'static,
171    {
172        let mut error = Self::new(code, phase, message);
173        error.source = Some(Box::new(source));
174        error
175    }
176
177    #[must_use]
178    pub(crate) fn at_path(mut self, path: impl Into<String>) -> Self {
179        self.path = Some(path.into());
180        self
181    }
182
183    #[must_use]
184    pub(crate) const fn at_file(mut self, file_index: usize) -> Self {
185        self.file_index = Some(file_index);
186        self
187    }
188
189    #[must_use]
190    pub(crate) fn in_transaction(mut self, transaction_id: impl Into<String>) -> Self {
191        self.transaction_id = Some(transaction_id.into());
192        self
193    }
194
195    #[must_use]
196    pub(crate) const fn requiring_recovery(mut self) -> Self {
197        self.recovery_required = true;
198        self
199    }
200
201    #[must_use]
202    pub const fn code(&self) -> WorktreeErrorCode {
203        self.code
204    }
205
206    #[must_use]
207    pub const fn phase(&self) -> TransactionPhase {
208        self.phase
209    }
210
211    #[must_use]
212    pub fn message(&self) -> &str {
213        &self.message
214    }
215
216    #[must_use]
217    pub fn path(&self) -> Option<&str> {
218        self.path.as_deref()
219    }
220
221    #[must_use]
222    pub const fn file_index(&self) -> Option<usize> {
223        self.file_index
224    }
225
226    #[must_use]
227    pub fn transaction_id(&self) -> Option<&str> {
228        self.transaction_id.as_deref()
229    }
230
231    #[must_use]
232    pub const fn recovery_required(&self) -> bool {
233        self.recovery_required
234    }
235}
236
237impl fmt::Display for WorktreeError {
238    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
239        write!(formatter, "{} during {}", self.code, self.phase)?;
240        if let Some(path) = &self.path {
241            write!(formatter, " at {path}")?;
242        }
243        if let Some(index) = self.file_index {
244            write!(formatter, " [file {index}]")?;
245        }
246        write!(formatter, ": {}", self.message)
247    }
248}
249
250impl std::error::Error for WorktreeError {
251    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
252        self.source
253            .as_deref()
254            .map(|source| source as &(dyn std::error::Error + 'static))
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use crate::error::{TransactionPhase, WorktreeError, WorktreeErrorCode};
261
262    #[test]
263    fn stable_fields_survive_context_builders() {
264        let error = WorktreeError::new(
265            WorktreeErrorCode::CommitFailed,
266            TransactionPhase::Commit,
267            "replace failed",
268        )
269        .at_path("src/lib.rs")
270        .at_file(2)
271        .in_transaction("abc")
272        .requiring_recovery();
273
274        assert_eq!(error.code().as_str(), "COMMIT_FAILED");
275        assert_eq!(error.path(), Some("src/lib.rs"));
276        assert_eq!(error.file_index(), Some(2));
277        assert_eq!(error.transaction_id(), Some("abc"));
278        assert!(error.recovery_required());
279    }
280}