1use std::collections::HashMap;
8
9use super::event::AuditEvent;
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub struct EntityKey {
14 pub entity: String,
16 pub entity_id: String,
18 pub scope: Option<String>,
20}
21
22#[derive(Debug, Clone)]
24pub struct AuditState {
25 pub entities: HashMap<EntityKey, String>,
27 pub event_count: usize,
29}
30
31pub fn materialize_state(events: &[AuditEvent]) -> AuditState {
37 let mut entities: HashMap<EntityKey, String> = HashMap::new();
38
39 for event in events {
40 let key = EntityKey {
41 entity: event.entity.clone(),
42 entity_id: event.entity_id.clone(),
43 scope: event.scope.clone(),
44 };
45
46 if let Some(to) = &event.to {
50 entities.insert(key, to.clone());
51 } else if event.op == "archive" {
52 entities.insert(key, "archived".to_string());
53 }
54 }
55
56 AuditState {
57 event_count: events.len(),
58 entities,
59 }
60}
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65 use crate::audit::event::{EventContext, SCHEMA_VERSION};
66
67 fn make_event(
68 entity: &str,
69 entity_id: &str,
70 scope: Option<&str>,
71 op: &str,
72 from: Option<&str>,
73 to: Option<&str>,
74 ) -> AuditEvent {
75 AuditEvent {
76 v: SCHEMA_VERSION,
77 ts: "2026-02-08T14:30:00.000Z".to_string(),
78 entity: entity.to_string(),
79 entity_id: entity_id.to_string(),
80 scope: scope.map(String::from),
81 op: op.to_string(),
82 from: from.map(String::from),
83 to: to.map(String::from),
84 actor: "cli".to_string(),
85 by: "@test".to_string(),
86 meta: None,
87 ctx: EventContext {
88 session_id: "test".to_string(),
89 harness_session_id: None,
90 branch: None,
91 worktree: None,
92 commit: None,
93 },
94 }
95 }
96
97 #[test]
98 fn empty_events_produce_empty_state() {
99 let state = materialize_state(&[]);
100 assert!(state.entities.is_empty());
101 assert_eq!(state.event_count, 0);
102 }
103
104 #[test]
105 fn single_create_event() {
106 let events = vec![make_event(
107 "task",
108 "1.1",
109 Some("change-1"),
110 "create",
111 None,
112 Some("pending"),
113 )];
114
115 let state = materialize_state(&events);
116 let key = EntityKey {
117 entity: "task".to_string(),
118 entity_id: "1.1".to_string(),
119 scope: Some("change-1".to_string()),
120 };
121
122 assert_eq!(state.entities.get(&key), Some(&"pending".to_string()));
123 assert_eq!(state.event_count, 1);
124 }
125
126 #[test]
127 fn status_change_updates_state() {
128 let events = vec![
129 make_event(
130 "task",
131 "1.1",
132 Some("change-1"),
133 "create",
134 None,
135 Some("pending"),
136 ),
137 make_event(
138 "task",
139 "1.1",
140 Some("change-1"),
141 "status_change",
142 Some("pending"),
143 Some("in-progress"),
144 ),
145 ];
146
147 let state = materialize_state(&events);
148 let key = EntityKey {
149 entity: "task".to_string(),
150 entity_id: "1.1".to_string(),
151 scope: Some("change-1".to_string()),
152 };
153
154 assert_eq!(state.entities.get(&key), Some(&"in-progress".to_string()));
155 assert_eq!(state.event_count, 2);
156 }
157
158 #[test]
159 fn multiple_entities_tracked_independently() {
160 let events = vec![
161 make_event(
162 "task",
163 "1.1",
164 Some("change-1"),
165 "create",
166 None,
167 Some("pending"),
168 ),
169 make_event(
170 "task",
171 "1.2",
172 Some("change-1"),
173 "create",
174 None,
175 Some("pending"),
176 ),
177 make_event(
178 "task",
179 "1.1",
180 Some("change-1"),
181 "status_change",
182 Some("pending"),
183 Some("complete"),
184 ),
185 ];
186
187 let state = materialize_state(&events);
188
189 let key1 = EntityKey {
190 entity: "task".to_string(),
191 entity_id: "1.1".to_string(),
192 scope: Some("change-1".to_string()),
193 };
194 let key2 = EntityKey {
195 entity: "task".to_string(),
196 entity_id: "1.2".to_string(),
197 scope: Some("change-1".to_string()),
198 };
199
200 assert_eq!(state.entities.get(&key1), Some(&"complete".to_string()));
201 assert_eq!(state.entities.get(&key2), Some(&"pending".to_string()));
202 }
203
204 #[test]
205 fn archive_event_without_to_uses_sentinel() {
206 let events = vec![make_event("change", "009-02", None, "archive", None, None)];
207
208 let state = materialize_state(&events);
209 let key = EntityKey {
210 entity: "change".to_string(),
211 entity_id: "009-02".to_string(),
212 scope: None,
213 };
214
215 assert_eq!(state.entities.get(&key), Some(&"archived".to_string()));
216 }
217
218 #[test]
219 fn reconciled_events_update_state() {
220 let events = vec![
221 make_event(
222 "task",
223 "1.1",
224 Some("change-1"),
225 "create",
226 None,
227 Some("pending"),
228 ),
229 make_event(
230 "task",
231 "1.1",
232 Some("change-1"),
233 "reconciled",
234 Some("pending"),
235 Some("complete"),
236 ),
237 ];
238
239 let state = materialize_state(&events);
240 let key = EntityKey {
241 entity: "task".to_string(),
242 entity_id: "1.1".to_string(),
243 scope: Some("change-1".to_string()),
244 };
245
246 assert_eq!(state.entities.get(&key), Some(&"complete".to_string()));
247 }
248
249 #[test]
250 fn global_entities_have_no_scope() {
251 let events = vec![make_event(
252 "config",
253 "worktrees.enabled",
254 None,
255 "set",
256 None,
257 Some("true"),
258 )];
259
260 let state = materialize_state(&events);
261 let key = EntityKey {
262 entity: "config".to_string(),
263 entity_id: "worktrees.enabled".to_string(),
264 scope: None,
265 };
266
267 assert_eq!(state.entities.get(&key), Some(&"true".to_string()));
268 }
269
270 #[test]
271 fn last_event_wins() {
272 let events = vec![
273 make_event("task", "1.1", Some("ch"), "status_change", None, Some("a")),
274 make_event(
275 "task",
276 "1.1",
277 Some("ch"),
278 "status_change",
279 Some("a"),
280 Some("b"),
281 ),
282 make_event(
283 "task",
284 "1.1",
285 Some("ch"),
286 "status_change",
287 Some("b"),
288 Some("c"),
289 ),
290 ];
291
292 let state = materialize_state(&events);
293 let key = EntityKey {
294 entity: "task".to_string(),
295 entity_id: "1.1".to_string(),
296 scope: Some("ch".to_string()),
297 };
298
299 assert_eq!(state.entities.get(&key), Some(&"c".to_string()));
300 }
301}