Skip to main content

weavatrix_worktree/
report.rs

1use core::fmt;
2
3use crate::hash::Sha256Hash;
4
5mod operation;
6
7pub use operation::{OperationChange, OperationKind, WorktreeApplyReport, WorktreeDryRunReport};
8
9/// Deterministic before/after evidence for one repository-relative file.
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct FileChange {
12    path: String,
13    old_sha256: Sha256Hash,
14    new_sha256: Sha256Hash,
15    bytes_before: u64,
16    bytes_after: u64,
17    edits_applied: usize,
18}
19
20impl FileChange {
21    pub(crate) fn new(
22        path: impl Into<String>,
23        old_sha256: Sha256Hash,
24        new_sha256: Sha256Hash,
25        bytes_before: u64,
26        bytes_after: u64,
27        edits_applied: usize,
28    ) -> Self {
29        Self {
30            path: path.into(),
31            old_sha256,
32            new_sha256,
33            bytes_before,
34            bytes_after,
35            edits_applied,
36        }
37    }
38
39    #[must_use]
40    pub fn path(&self) -> &str {
41        &self.path
42    }
43
44    #[must_use]
45    pub const fn old_sha256(&self) -> Sha256Hash {
46        self.old_sha256
47    }
48
49    #[must_use]
50    pub const fn new_sha256(&self) -> Sha256Hash {
51        self.new_sha256
52    }
53
54    #[must_use]
55    pub const fn bytes_before(&self) -> u64 {
56        self.bytes_before
57    }
58
59    #[must_use]
60    pub const fn bytes_after(&self) -> u64 {
61        self.bytes_after
62    }
63
64    #[must_use]
65    pub const fn edits_applied(&self) -> usize {
66        self.edits_applied
67    }
68
69    #[must_use]
70    pub fn changed(&self) -> bool {
71        self.old_sha256 != self.new_sha256
72    }
73}
74
75/// Read-only result of validating and projecting an edit plan.
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct DryRunReport {
78    operation: String,
79    files: Vec<FileChange>,
80}
81
82impl DryRunReport {
83    pub(crate) fn new(operation: impl Into<String>, files: Vec<FileChange>) -> Self {
84        Self {
85            operation: operation.into(),
86            files,
87        }
88    }
89
90    #[must_use]
91    pub fn operation(&self) -> &str {
92        &self.operation
93    }
94
95    #[must_use]
96    pub fn files(&self) -> &[FileChange] {
97        &self.files
98    }
99
100    #[must_use]
101    pub fn total_edits(&self) -> usize {
102        self.files
103            .iter()
104            .fold(0, |total, file| total.saturating_add(file.edits_applied))
105    }
106
107    #[must_use]
108    pub fn total_bytes_before(&self) -> u64 {
109        total_bytes(&self.files, FileChange::bytes_before)
110    }
111
112    #[must_use]
113    pub fn total_bytes_after(&self) -> u64 {
114        total_bytes(&self.files, FileChange::bytes_after)
115    }
116}
117
118/// Successful durable commit report.
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct ApplyReport {
121    transaction_id: String,
122    operation: String,
123    files: Vec<FileChange>,
124}
125
126impl ApplyReport {
127    pub(crate) fn new(
128        transaction_id: impl Into<String>,
129        operation: impl Into<String>,
130        files: Vec<FileChange>,
131    ) -> Self {
132        Self {
133            transaction_id: transaction_id.into(),
134            operation: operation.into(),
135            files,
136        }
137    }
138
139    #[must_use]
140    pub fn transaction_id(&self) -> &str {
141        &self.transaction_id
142    }
143
144    #[must_use]
145    pub fn operation(&self) -> &str {
146        &self.operation
147    }
148
149    #[must_use]
150    pub fn files(&self) -> &[FileChange] {
151        &self.files
152    }
153
154    #[must_use]
155    pub fn total_edits(&self) -> usize {
156        self.files
157            .iter()
158            .fold(0, |total, file| total.saturating_add(file.edits_applied))
159    }
160}
161
162/// Successful explicit cancellation of a prepared transaction.
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct AbortReport {
165    transaction_id: String,
166    prepared_files: usize,
167    artifacts_removed: usize,
168}
169
170impl AbortReport {
171    pub(crate) fn new(
172        transaction_id: impl Into<String>,
173        prepared_files: usize,
174        artifacts_removed: usize,
175    ) -> Self {
176        Self {
177            transaction_id: transaction_id.into(),
178            prepared_files,
179            artifacts_removed,
180        }
181    }
182
183    #[must_use]
184    pub fn transaction_id(&self) -> &str {
185        &self.transaction_id
186    }
187
188    #[must_use]
189    pub const fn prepared_files(&self) -> usize {
190        self.prepared_files
191    }
192
193    #[must_use]
194    pub const fn artifacts_removed(&self) -> usize {
195        self.artifacts_removed
196    }
197}
198
199/// Completed action selected by deterministic journal recovery.
200#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
201pub enum RecoveryAction {
202    NoPendingTransaction,
203    DiscardedStaging,
204    RolledBack,
205    FinishedCommitCleanup,
206}
207
208impl RecoveryAction {
209    #[must_use]
210    pub const fn as_str(self) -> &'static str {
211        match self {
212            Self::NoPendingTransaction => "NO_PENDING_TRANSACTION",
213            Self::DiscardedStaging => "DISCARDED_STAGING",
214            Self::RolledBack => "ROLLED_BACK",
215            Self::FinishedCommitCleanup => "FINISHED_COMMIT_CLEANUP",
216        }
217    }
218}
219
220impl fmt::Display for RecoveryAction {
221    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
222        formatter.write_str(self.as_str())
223    }
224}
225
226/// Successful recovery or recovery inspection result.
227#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct RecoveryReport {
229    transaction_id: Option<String>,
230    action: RecoveryAction,
231    files: Vec<FileChange>,
232    artifacts_removed: usize,
233}
234
235impl RecoveryReport {
236    pub(crate) fn new(
237        transaction_id: Option<String>,
238        action: RecoveryAction,
239        files: Vec<FileChange>,
240        artifacts_removed: usize,
241    ) -> Self {
242        Self {
243            transaction_id,
244            action,
245            files,
246            artifacts_removed,
247        }
248    }
249
250    #[must_use]
251    pub fn transaction_id(&self) -> Option<&str> {
252        self.transaction_id.as_deref()
253    }
254
255    #[must_use]
256    pub const fn action(&self) -> RecoveryAction {
257        self.action
258    }
259
260    #[must_use]
261    pub fn files(&self) -> &[FileChange] {
262        &self.files
263    }
264
265    #[must_use]
266    pub const fn artifacts_removed(&self) -> usize {
267        self.artifacts_removed
268    }
269}
270
271fn total_bytes(files: &[FileChange], projection: fn(&FileChange) -> u64) -> u64 {
272    files
273        .iter()
274        .fold(0, |total, file| total.saturating_add(projection(file)))
275}
276
277#[cfg(test)]
278mod tests {
279    use crate::hash::Sha256Hash;
280    use crate::report::{DryRunReport, FileChange, RecoveryAction};
281
282    #[test]
283    fn totals_and_wire_action_are_deterministic() {
284        let old = Sha256Hash::compute(b"old");
285        let new = Sha256Hash::compute(b"new");
286        let report = DryRunReport::new(
287            "rename",
288            vec![FileChange::new("src/lib.rs", old, new, 3, 3, 2)],
289        );
290        assert_eq!(report.total_edits(), 2);
291        assert_eq!(report.total_bytes_before(), 3);
292        assert_eq!(RecoveryAction::RolledBack.as_str(), "ROLLED_BACK");
293        assert!(report.files()[0].changed());
294    }
295}