ito_domain/audit/
materialize.rs

1//! State materialization from audit event sequences.
2//!
3//! Replays an ordered sequence of audit events to reconstruct the latest
4//! known status of each entity. The resulting `AuditState` is used by the
5//! reconciliation engine to compare against file-on-disk state.
6
7use std::collections::HashMap;
8
9use super::event::AuditEvent;
10
11/// Key for uniquely identifying an entity in the materialized state.
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub struct EntityKey {
14    /// Entity type string (e.g., "task", "change").
15    pub entity: String,
16    /// Entity identifier (e.g., "1.1", "009-02").
17    pub entity_id: String,
18    /// Scoping context (e.g., change_id for tasks).
19    pub scope: Option<String>,
20}
21
22/// The materialized state: a map from entity keys to their last-known status.
23#[derive(Debug, Clone)]
24pub struct AuditState {
25    /// Map from entity key to last-known status string.
26    pub entities: HashMap<EntityKey, String>,
27    /// Total number of events replayed.
28    pub event_count: usize,
29}
30
31/// Replay a sequence of events to build the materialized state.
32///
33/// Events are processed in order. For each event, the `to` field (if present)
34/// becomes the current status of the entity identified by
35/// `(entity, entity_id, scope)`.
36pub 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 the event has a `to` value, that becomes the current state.
47        // For events like "archive" that don't have a `to`, we use the op
48        // as a sentinel value (e.g., "archived").
49        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}