Skip to main content

changeset_saga/
audit.rs

1use std::time::Instant;
2
3/// Status of a step in the audit log.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5#[non_exhaustive]
6pub enum StepStatus {
7    /// Step executed successfully.
8    Executed,
9    /// Step failed during execution.
10    Failed,
11    /// Step was compensated successfully.
12    Compensated,
13    /// Step compensation failed.
14    CompensationFailed,
15}
16
17/// Record of a step's execution in the saga.
18#[derive(Debug)]
19pub struct StepRecord {
20    /// Name of the step.
21    pub name: String,
22    /// Current status.
23    pub status: StepStatus,
24    /// When the step started executing.
25    pub started_at: Instant,
26    /// When the step completed (execution or compensation).
27    pub completed_at: Option<Instant>,
28    /// Description of compensation (if applicable).
29    pub compensation_description: Option<String>,
30}
31
32/// Audit log tracking all step executions in a saga.
33#[derive(Debug, Default)]
34pub struct SagaAuditLog {
35    records: Vec<StepRecord>,
36}
37
38impl SagaAuditLog {
39    /// Create a new empty audit log.
40    #[must_use]
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Record a step execution starting.
46    pub(crate) fn record_start(&mut self, name: &str) {
47        self.records.push(StepRecord {
48            name: name.to_string(),
49            status: StepStatus::Executed,
50            started_at: Instant::now(),
51            completed_at: None,
52            compensation_description: None,
53        });
54    }
55
56    /// Mark the last step as failed.
57    pub(crate) fn record_failure(&mut self) {
58        if let Some(record) = self.records.last_mut() {
59            record.status = StepStatus::Failed;
60            record.completed_at = Some(Instant::now());
61        }
62    }
63
64    /// Mark the last step as completed successfully.
65    pub(crate) fn record_success(&mut self, compensation_description: String) {
66        if let Some(record) = self.records.last_mut() {
67            record.status = StepStatus::Executed;
68            record.completed_at = Some(Instant::now());
69            record.compensation_description = Some(compensation_description);
70        }
71    }
72
73    /// Record that a step was compensated.
74    pub(crate) fn record_compensated(&mut self, step_name: &str) {
75        for record in &mut self.records {
76            if record.name == step_name {
77                record.status = StepStatus::Compensated;
78                record.completed_at = Some(Instant::now());
79            }
80        }
81    }
82
83    /// Record that a step's compensation failed.
84    pub(crate) fn record_compensation_failed(&mut self, step_name: &str) {
85        for record in &mut self.records {
86            if record.name == step_name {
87                record.status = StepStatus::CompensationFailed;
88                record.completed_at = Some(Instant::now());
89            }
90        }
91    }
92
93    /// Get all records in the audit log.
94    #[must_use]
95    pub fn records(&self) -> &[StepRecord] {
96        &self.records
97    }
98
99    /// Get a summary of the saga execution for display.
100    #[must_use]
101    pub fn summary(&self) -> String {
102        let mut lines = Vec::new();
103        for record in &self.records {
104            let status = match record.status {
105                StepStatus::Executed => "✓",
106                StepStatus::Failed => "✗",
107                StepStatus::Compensated => "↩",
108                StepStatus::CompensationFailed => "⚠",
109            };
110            lines.push(format!("{status} {}", record.name));
111        }
112        lines.join("\n")
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn new_audit_log_is_empty() {
122        let log = SagaAuditLog::new();
123        assert!(log.records().is_empty());
124    }
125
126    #[test]
127    fn record_start_adds_step_with_executed_status() {
128        let mut log = SagaAuditLog::new();
129        log.record_start("test_step");
130
131        assert_eq!(log.records().len(), 1);
132        assert_eq!(log.records()[0].name, "test_step");
133        assert_eq!(log.records()[0].status, StepStatus::Executed);
134        assert!(log.records()[0].completed_at.is_none());
135    }
136
137    #[test]
138    fn record_failure_updates_last_step() {
139        let mut log = SagaAuditLog::new();
140        log.record_start("step_1");
141        log.record_failure();
142
143        assert_eq!(log.records()[0].status, StepStatus::Failed);
144        assert!(log.records()[0].completed_at.is_some());
145    }
146
147    #[test]
148    fn record_success_updates_last_step_with_description() {
149        let mut log = SagaAuditLog::new();
150        log.record_start("step_1");
151        log.record_success("undo step_1".to_string());
152
153        assert_eq!(log.records()[0].status, StepStatus::Executed);
154        assert!(log.records()[0].completed_at.is_some());
155        assert_eq!(
156            log.records()[0].compensation_description,
157            Some("undo step_1".to_string())
158        );
159    }
160
161    #[test]
162    fn record_compensated_updates_matching_step() {
163        let mut log = SagaAuditLog::new();
164        log.record_start("step_1");
165        log.record_success("undo".to_string());
166        log.record_start("step_2");
167        log.record_success("undo".to_string());
168        log.record_compensated("step_1");
169
170        assert_eq!(log.records()[0].status, StepStatus::Compensated);
171        assert_eq!(log.records()[1].status, StepStatus::Executed);
172    }
173
174    #[test]
175    fn record_compensation_failed_updates_matching_step() {
176        let mut log = SagaAuditLog::new();
177        log.record_start("step_1");
178        log.record_success("undo".to_string());
179        log.record_compensation_failed("step_1");
180
181        assert_eq!(log.records()[0].status, StepStatus::CompensationFailed);
182    }
183
184    #[test]
185    fn summary_formats_all_steps() {
186        let mut log = SagaAuditLog::new();
187        log.record_start("executed_step");
188        log.record_success("undo".to_string());
189        log.record_start("failed_step");
190        log.record_failure();
191
192        let summary = log.summary();
193        assert!(summary.contains("✓ executed_step"));
194        assert!(summary.contains("✗ failed_step"));
195    }
196
197    #[test]
198    fn summary_shows_compensated_and_compensation_failed() {
199        let mut log = SagaAuditLog::new();
200        log.record_start("compensated_step");
201        log.record_success("undo".to_string());
202        log.record_compensated("compensated_step");
203
204        log.record_start("comp_failed_step");
205        log.record_success("undo".to_string());
206        log.record_compensation_failed("comp_failed_step");
207
208        let summary = log.summary();
209        assert!(summary.contains("↩ compensated_step"));
210        assert!(summary.contains("⚠ comp_failed_step"));
211    }
212}