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 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 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 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 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 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 #[must_use]
95 pub fn records(&self) -> &[StepRecord] {
96 &self.records
97 }
98
99 #[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}