1use std::error::Error;
9use std::fmt;
10
11use chrono::NaiveDateTime;
12use serde::{Deserialize, Deserializer, Serialize};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
16#[serde(rename_all = "snake_case")]
17pub enum ActionDispositionStatus {
18 #[default]
20 Applied,
21 Skipped,
23 Rejected,
25 Failed,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(default)]
32pub struct ActionDisposition {
33 pub action_id: String,
34 pub action_kind: Option<String>,
36 pub signal_ts: Option<NaiveDateTime>,
37 pub effective_ts: Option<NaiveDateTime>,
38 pub status: ActionDispositionStatus,
39 pub reason: Option<String>,
41 pub position_ids: Vec<String>,
43}
44
45impl Default for ActionDisposition {
46 fn default() -> Self {
47 Self {
48 action_id: String::new(),
49 action_kind: None,
50 signal_ts: None,
51 effective_ts: None,
52 status: ActionDispositionStatus::Applied,
53 reason: None,
54 position_ids: Vec::new(),
55 }
56 }
57}
58
59impl ActionDisposition {
60 pub fn new(action_id: impl Into<String>, status: ActionDispositionStatus) -> Self {
61 Self {
62 action_id: action_id.into(),
63 status,
64 ..Self::default()
65 }
66 }
67
68 pub fn applied(action_id: impl Into<String>) -> Self {
69 Self::new(action_id, ActionDispositionStatus::Applied)
70 }
71
72 pub fn skipped(action_id: impl Into<String>, reason: impl Into<String>) -> Self {
73 Self::with_reason(action_id, ActionDispositionStatus::Skipped, reason)
74 }
75
76 pub fn rejected(action_id: impl Into<String>, reason: impl Into<String>) -> Self {
77 Self::with_reason(action_id, ActionDispositionStatus::Rejected, reason)
78 }
79
80 pub fn failed(action_id: impl Into<String>, reason: impl Into<String>) -> Self {
81 Self::with_reason(action_id, ActionDispositionStatus::Failed, reason)
82 }
83
84 fn with_reason(
85 action_id: impl Into<String>,
86 status: ActionDispositionStatus,
87 reason: impl Into<String>,
88 ) -> Self {
89 Self {
90 action_id: action_id.into(),
91 status,
92 reason: Some(reason.into()),
93 ..Self::default()
94 }
95 }
96
97 pub const fn is_terminal(&self) -> bool {
99 true
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum LedgerError {
105 EmptyActionId,
106 DuplicateTerminalRecord { action_id: String },
107}
108
109impl fmt::Display for LedgerError {
110 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111 match self {
112 Self::EmptyActionId => write!(formatter, "action id must not be empty"),
113 Self::DuplicateTerminalRecord { action_id } => {
114 write!(
115 formatter,
116 "action '{action_id}' already has a terminal record"
117 )
118 }
119 }
120 }
121}
122
123impl Error for LedgerError {}
124
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
127pub struct LifecycleLedger {
128 records: Vec<ActionDisposition>,
129}
130
131impl LifecycleLedger {
132 pub fn new() -> Self {
133 Self::default()
134 }
135
136 pub fn from_records(
137 records: impl IntoIterator<Item = ActionDisposition>,
138 ) -> Result<Self, LedgerError> {
139 let mut ledger = Self::new();
140 for record in records {
141 ledger.record(record)?;
142 }
143 Ok(ledger)
144 }
145
146 pub fn record(&mut self, disposition: ActionDisposition) -> Result<(), LedgerError> {
148 if disposition.action_id.is_empty() {
149 return Err(LedgerError::EmptyActionId);
150 }
151 if self.contains(&disposition.action_id) {
152 return Err(LedgerError::DuplicateTerminalRecord {
153 action_id: disposition.action_id,
154 });
155 }
156 self.records.push(disposition);
157 Ok(())
158 }
159
160 pub fn contains(&self, action_id: &str) -> bool {
161 self.records
162 .iter()
163 .any(|record| record.action_id == action_id)
164 }
165
166 pub fn get(&self, action_id: &str) -> Option<&ActionDisposition> {
167 self.records
168 .iter()
169 .find(|record| record.action_id == action_id)
170 }
171
172 pub fn iter(&self) -> impl ExactSizeIterator<Item = &ActionDisposition> {
173 self.records.iter()
174 }
175
176 pub fn as_slice(&self) -> &[ActionDisposition] {
177 &self.records
178 }
179
180 pub fn len(&self) -> usize {
181 self.records.len()
182 }
183
184 pub fn is_empty(&self) -> bool {
185 self.records.is_empty()
186 }
187
188 pub fn count(&self, status: ActionDispositionStatus) -> usize {
189 self.records
190 .iter()
191 .filter(|record| record.status == status)
192 .count()
193 }
194}
195
196#[derive(Deserialize, Default)]
197#[serde(default)]
198struct LifecycleLedgerWire {
199 records: Vec<ActionDisposition>,
200}
201
202impl<'de> Deserialize<'de> for LifecycleLedger {
203 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
204 where
205 D: Deserializer<'de>,
206 {
207 let wire = LifecycleLedgerWire::deserialize(deserializer)?;
208 Self::from_records(wire.records).map_err(serde::de::Error::custom)
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use chrono::NaiveDate;
216
217 fn ts() -> NaiveDateTime {
218 NaiveDate::from_ymd_opt(2026, 2, 3)
219 .unwrap()
220 .and_hms_opt(4, 5, 6)
221 .unwrap()
222 }
223
224 #[test]
225 fn records_exactly_one_terminal_disposition_per_action() {
226 let mut ledger = LifecycleLedger::new();
227 let mut applied = ActionDisposition::applied("action-1");
228 applied.action_kind = Some("open".into());
229 applied.signal_ts = Some(ts());
230 applied.effective_ts = Some(ts());
231 applied.position_ids.push("position-1".into());
232
233 ledger.record(applied).unwrap();
234 let duplicate = ledger.record(ActionDisposition::rejected("action-1", "too late"));
235
236 assert_eq!(
237 duplicate,
238 Err(LedgerError::DuplicateTerminalRecord {
239 action_id: "action-1".into()
240 })
241 );
242 assert_eq!(ledger.len(), 1);
243 assert_eq!(ledger.get("action-1").unwrap().position_ids, ["position-1"]);
244 }
245
246 #[test]
247 fn rejects_empty_action_ids() {
248 let mut ledger = LifecycleLedger::new();
249 assert_eq!(
250 ledger.record(ActionDisposition::applied("")),
251 Err(LedgerError::EmptyActionId)
252 );
253 assert!(ledger.is_empty());
254 }
255
256 #[test]
257 fn status_helpers_capture_reasons_and_counts() {
258 let mut ledger = LifecycleLedger::new();
259 ledger.record(ActionDisposition::applied("a")).unwrap();
260 ledger
261 .record(ActionDisposition::skipped("b", "no matching position"))
262 .unwrap();
263 ledger
264 .record(ActionDisposition::rejected("c", "invalid size"))
265 .unwrap();
266 ledger
267 .record(ActionDisposition::failed("d", "adapter failure"))
268 .unwrap();
269
270 assert_eq!(ledger.count(ActionDispositionStatus::Applied), 1);
271 assert_eq!(ledger.count(ActionDispositionStatus::Skipped), 1);
272 assert_eq!(ledger.count(ActionDispositionStatus::Rejected), 1);
273 assert_eq!(ledger.count(ActionDispositionStatus::Failed), 1);
274 assert_eq!(
275 ledger.get("c").unwrap().reason.as_deref(),
276 Some("invalid size")
277 );
278 assert!(ledger.iter().all(ActionDisposition::is_terminal));
279 }
280
281 #[test]
282 fn serde_roundtrip_preserves_order() {
283 let ledger = LifecycleLedger::from_records([
284 ActionDisposition::applied("z"),
285 ActionDisposition::rejected("a", "bad request"),
286 ])
287 .unwrap();
288 let json = serde_json::to_string(&ledger).unwrap();
289 let decoded: LifecycleLedger = serde_json::from_str(&json).unwrap();
290
291 assert_eq!(decoded, ledger);
292 let ids: Vec<_> = decoded
293 .iter()
294 .map(|record| record.action_id.as_str())
295 .collect();
296 assert_eq!(ids, ["z", "a"]);
297 }
298
299 #[test]
300 fn deserialization_cannot_bypass_uniqueness_invariant() {
301 let json = r#"{
302 "records": [
303 {"action_id":"a","status":"applied"},
304 {"action_id":"a","status":"rejected","reason":"duplicate"}
305 ]
306 }"#;
307 let error = serde_json::from_str::<LifecycleLedger>(json).unwrap_err();
308 assert!(error.to_string().contains("already has a terminal record"));
309 }
310
311 #[test]
312 fn additive_disposition_fields_have_serde_defaults() {
313 let disposition: ActionDisposition = serde_json::from_str(r#"{"action_id":"a"}"#).unwrap();
314 assert_eq!(disposition.status, ActionDispositionStatus::Applied);
315 assert_eq!(disposition.reason, None);
316 assert!(disposition.position_ids.is_empty());
317
318 let ledger: LifecycleLedger = serde_json::from_str("{}").unwrap();
319 assert!(ledger.is_empty());
320 }
321}