1use std::time::Instant;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5#[non_exhaustive]
6pub enum StepStatus {
7 Executed,
9 Failed,
11 Compensated,
13 CompensationFailed,
15}
16
17#[derive(Debug)]
19pub struct StepRecord {
20 pub name: String,
22 pub status: StepStatus,
24 pub started_at: Instant,
26 pub completed_at: Option<Instant>,
28 pub compensation_description: Option<String>,
30}
31
32#[derive(Debug, Default)]
34pub struct SagaAuditLog {
35 records: Vec<StepRecord>,
36}
37
38impl SagaAuditLog {
39 #[must_use]
41 pub fn new() -> Self {
42 Self::default()
43 }
44
45 #[must_use]
47 pub fn records(&self) -> &[StepRecord] {
48 &self.records
49 }
50
51 #[must_use]
53 pub fn summary(&self) -> String {
54 let mut lines = Vec::new();
55 for record in &self.records {
56 let status = match record.status {
57 StepStatus::Executed => "✓",
58 StepStatus::Failed => "✗",
59 StepStatus::Compensated => "↩",
60 StepStatus::CompensationFailed => "⚠",
61 };
62 lines.push(format!("{status} {}", record.name));
63 }
64 lines.join("\n")
65 }
66
67 pub(crate) fn record_start(&mut self, name: &str) {
69 self.records.push(StepRecord {
70 name: name.to_string(),
71 status: StepStatus::Executed,
72 started_at: Instant::now(),
73 completed_at: None,
74 compensation_description: None,
75 });
76 }
77
78 pub(crate) fn record_failure(&mut self) {
80 if let Some(record) = self.records.last_mut() {
81 record.status = StepStatus::Failed;
82 record.completed_at = Some(Instant::now());
83 }
84 }
85
86 pub(crate) fn record_success(&mut self, compensation_description: String) {
88 if let Some(record) = self.records.last_mut() {
89 record.status = StepStatus::Executed;
90 record.completed_at = Some(Instant::now());
91 record.compensation_description = Some(compensation_description);
92 }
93 }
94
95 pub(crate) fn record_compensated(&mut self, step_name: &str) {
97 for record in &mut self.records {
98 if record.name == step_name {
99 record.status = StepStatus::Compensated;
100 record.completed_at = Some(Instant::now());
101 }
102 }
103 }
104
105 pub(crate) fn record_compensation_failed(&mut self, step_name: &str) {
107 for record in &mut self.records {
108 if record.name == step_name {
109 record.status = StepStatus::CompensationFailed;
110 record.completed_at = Some(Instant::now());
111 }
112 }
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}