ito_domain/audit/
reconcile.rs

1//! Reconciliation diff logic: compare materialized audit state against
2//! file-on-disk state and produce drift items and compensating events.
3//!
4//! This module contains only pure functions with no I/O. The orchestration
5//! (reading files, writing events) lives in `ito-core`.
6
7use std::collections::HashMap;
8
9use super::event::{Actor, AuditEvent, AuditEventBuilder, EntityType, EventContext, ops};
10use super::materialize::EntityKey;
11
12/// File-on-disk state: a map from entity keys to their current status as
13/// read from the filesystem (e.g., from tasks.md).
14pub type FileState = HashMap<EntityKey, String>;
15
16/// A single drift item: a discrepancy between audit log state and file state.
17#[derive(Debug, Clone, PartialEq)]
18pub enum Drift {
19    /// Entity exists in files but has no events in the audit log.
20    Missing {
21        /// Entity key.
22        key: EntityKey,
23        /// Status found in the file.
24        file_status: String,
25    },
26    /// Audit log and file disagree on the entity's status.
27    Diverged {
28        /// Entity key.
29        key: EntityKey,
30        /// Status according to the audit log.
31        log_status: String,
32        /// Status according to the file.
33        file_status: String,
34    },
35    /// Entity has events in the audit log but does not exist in the files.
36    Extra {
37        /// Entity key.
38        key: EntityKey,
39        /// Status according to the audit log.
40        log_status: String,
41    },
42}
43
44impl std::fmt::Display for Drift {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Drift::Missing { key, file_status } => write!(
48                f,
49                "Missing: {}/{} (scope: {:?}) has file status '{}' but no audit events",
50                key.entity, key.entity_id, key.scope, file_status
51            ),
52            Drift::Diverged {
53                key,
54                log_status,
55                file_status,
56            } => write!(
57                f,
58                "Diverged: {}/{} (scope: {:?}) audit='{}' file='{}'",
59                key.entity, key.entity_id, key.scope, log_status, file_status
60            ),
61            Drift::Extra { key, log_status } => write!(
62                f,
63                "Extra: {}/{} (scope: {:?}) has audit status '{}' but no file entry",
64                key.entity, key.entity_id, key.scope, log_status
65            ),
66        }
67    }
68}
69
70/// Compare materialized audit state against file-on-disk state.
71///
72/// Returns a list of drift items. An empty list means the log and files agree.
73pub fn compute_drift(
74    audit_entities: &HashMap<EntityKey, String>,
75    file_state: &FileState,
76) -> Vec<Drift> {
77    let mut drifts = Vec::new();
78
79    // Check all file entries against audit log
80    for (key, file_status) in file_state {
81        match audit_entities.get(key) {
82            None => {
83                drifts.push(Drift::Missing {
84                    key: key.clone(),
85                    file_status: file_status.clone(),
86                });
87            }
88            Some(log_status) if log_status != file_status => {
89                drifts.push(Drift::Diverged {
90                    key: key.clone(),
91                    log_status: log_status.clone(),
92                    file_status: file_status.clone(),
93                });
94            }
95            Some(_) => {
96                // Match -- no drift
97            }
98        }
99    }
100
101    // Check for audit entries not in files (extras)
102    for (key, log_status) in audit_entities {
103        // Only report extras for task entities (other entities like config
104        // may not have a corresponding file entry).
105        if key.entity == "task" && !file_state.contains_key(key) {
106            drifts.push(Drift::Extra {
107                key: key.clone(),
108                log_status: log_status.clone(),
109            });
110        }
111    }
112
113    // Sort for deterministic output
114    drifts.sort_by(|a, b| {
115        let key_a = match a {
116            Drift::Missing { key, .. } => key,
117            Drift::Diverged { key, .. } => key,
118            Drift::Extra { key, .. } => key,
119        };
120        let key_b = match b {
121            Drift::Missing { key, .. } => key,
122            Drift::Diverged { key, .. } => key,
123            Drift::Extra { key, .. } => key,
124        };
125        (&key_a.entity, &key_a.entity_id).cmp(&(&key_b.entity, &key_b.entity_id))
126    });
127
128    drifts
129}
130
131/// Generate compensating events that bring the audit log in sync with the file state.
132///
133/// Each drift item produces a single `reconciled` event with `actor: "reconcile"`.
134pub fn generate_compensating_events(
135    drifts: &[Drift],
136    scope: Option<&str>,
137    ctx: &EventContext,
138) -> Vec<AuditEvent> {
139    let mut events = Vec::new();
140
141    for drift in drifts {
142        let event = match drift {
143            Drift::Missing { key, file_status } => AuditEventBuilder::new()
144                .entity(parse_entity_type(&key.entity))
145                .entity_id(&key.entity_id)
146                .op(ops::RECONCILED)
147                .to(file_status)
148                .actor(Actor::Reconcile)
149                .by("@reconcile")
150                .meta(serde_json::json!({
151                    "reason": format!(
152                        "{} '{}' has file status '{}' but no audit events",
153                        key.entity, key.entity_id, file_status
154                    )
155                }))
156                .ctx(ctx.clone()),
157            Drift::Diverged {
158                key,
159                log_status,
160                file_status,
161            } => AuditEventBuilder::new()
162                .entity(parse_entity_type(&key.entity))
163                .entity_id(&key.entity_id)
164                .op(ops::RECONCILED)
165                .from(log_status)
166                .to(file_status)
167                .actor(Actor::Reconcile)
168                .by("@reconcile")
169                .meta(serde_json::json!({
170                    "reason": format!(
171                        "{} '{}' audit status '{}' differs from file status '{}'",
172                        key.entity, key.entity_id, log_status, file_status
173                    )
174                }))
175                .ctx(ctx.clone()),
176            Drift::Extra { key, log_status } => AuditEventBuilder::new()
177                .entity(parse_entity_type(&key.entity))
178                .entity_id(&key.entity_id)
179                .op(ops::RECONCILED)
180                .from(log_status)
181                .actor(Actor::Reconcile)
182                .by("@reconcile")
183                .meta(serde_json::json!({
184                    "reason": format!(
185                        "{} '{}' has audit status '{}' but no file entry",
186                        key.entity, key.entity_id, log_status
187                    )
188                }))
189                .ctx(ctx.clone()),
190        };
191
192        // Add scope if provided
193        let event = if let Some(s) = scope {
194            event.scope(s)
195        } else if let Some(s) = match drift {
196            Drift::Missing { key, .. } => key.scope.as_deref(),
197            Drift::Diverged { key, .. } => key.scope.as_deref(),
198            Drift::Extra { key, .. } => key.scope.as_deref(),
199        } {
200            event.scope(s)
201        } else {
202            event
203        };
204
205        if let Some(built) = event.build() {
206            events.push(built);
207        }
208    }
209
210    events
211}
212
213/// Parse entity type string to `EntityType`, defaulting to `Task` for unknown types.
214fn parse_entity_type(s: &str) -> EntityType {
215    match s {
216        "task" => EntityType::Task,
217        "change" => EntityType::Change,
218        "module" => EntityType::Module,
219        "wave" => EntityType::Wave,
220        "planning" => EntityType::Planning,
221        "config" => EntityType::Config,
222        // Default to Task for any unrecognized entity type
223        _ => EntityType::Task,
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::audit::materialize::EntityKey;
231
232    fn test_ctx() -> EventContext {
233        EventContext {
234            session_id: "test-session".to_string(),
235            harness_session_id: None,
236            branch: None,
237            worktree: None,
238            commit: None,
239        }
240    }
241
242    fn task_key(id: &str, scope: &str) -> EntityKey {
243        EntityKey {
244            entity: "task".to_string(),
245            entity_id: id.to_string(),
246            scope: Some(scope.to_string()),
247        }
248    }
249
250    #[test]
251    fn no_drift_when_states_match() {
252        let mut audit = HashMap::new();
253        audit.insert(task_key("1.1", "ch"), "complete".to_string());
254        audit.insert(task_key("1.2", "ch"), "pending".to_string());
255
256        let mut files = HashMap::new();
257        files.insert(task_key("1.1", "ch"), "complete".to_string());
258        files.insert(task_key("1.2", "ch"), "pending".to_string());
259
260        let drifts = compute_drift(&audit, &files);
261        assert!(drifts.is_empty());
262    }
263
264    #[test]
265    fn detect_missing_entity_in_log() {
266        let audit: HashMap<EntityKey, String> = HashMap::new();
267        let mut files = HashMap::new();
268        files.insert(task_key("1.1", "ch"), "complete".to_string());
269
270        let drifts = compute_drift(&audit, &files);
271        assert_eq!(drifts.len(), 1);
272        match &drifts[0] {
273            Drift::Missing { key, file_status } => {
274                assert_eq!(key.entity_id, "1.1");
275                assert_eq!(file_status, "complete");
276            }
277            other => panic!("Expected Missing, got {other:?}"),
278        }
279    }
280
281    #[test]
282    fn detect_diverged_status() {
283        let mut audit = HashMap::new();
284        audit.insert(task_key("1.1", "ch"), "pending".to_string());
285
286        let mut files = HashMap::new();
287        files.insert(task_key("1.1", "ch"), "complete".to_string());
288
289        let drifts = compute_drift(&audit, &files);
290        assert_eq!(drifts.len(), 1);
291        match &drifts[0] {
292            Drift::Diverged {
293                log_status,
294                file_status,
295                ..
296            } => {
297                assert_eq!(log_status, "pending");
298                assert_eq!(file_status, "complete");
299            }
300            other => panic!("Expected Diverged, got {other:?}"),
301        }
302    }
303
304    #[test]
305    fn detect_extra_in_log() {
306        let mut audit = HashMap::new();
307        audit.insert(task_key("1.1", "ch"), "in-progress".to_string());
308
309        let files: HashMap<EntityKey, String> = HashMap::new();
310
311        let drifts = compute_drift(&audit, &files);
312        assert_eq!(drifts.len(), 1);
313        match &drifts[0] {
314            Drift::Extra { key, log_status } => {
315                assert_eq!(key.entity_id, "1.1");
316                assert_eq!(log_status, "in-progress");
317            }
318            other => panic!("Expected Extra, got {other:?}"),
319        }
320    }
321
322    #[test]
323    fn multiple_drift_types_detected() {
324        let mut audit = HashMap::new();
325        audit.insert(task_key("1.1", "ch"), "pending".to_string()); // diverged
326        audit.insert(task_key("1.3", "ch"), "complete".to_string()); // extra
327
328        let mut files = HashMap::new();
329        files.insert(task_key("1.1", "ch"), "complete".to_string()); // diverged
330        files.insert(task_key("1.2", "ch"), "pending".to_string()); // missing
331
332        let drifts = compute_drift(&audit, &files);
333        assert_eq!(drifts.len(), 3);
334    }
335
336    #[test]
337    fn display_drift_items() {
338        let drift = Drift::Diverged {
339            key: task_key("1.1", "ch"),
340            log_status: "pending".to_string(),
341            file_status: "complete".to_string(),
342        };
343        let s = drift.to_string();
344        assert!(s.contains("Diverged"));
345        assert!(s.contains("1.1"));
346        assert!(s.contains("pending"));
347        assert!(s.contains("complete"));
348    }
349
350    #[test]
351    fn generate_compensating_events_for_missing() {
352        let drifts = vec![Drift::Missing {
353            key: task_key("1.1", "ch"),
354            file_status: "complete".to_string(),
355        }];
356
357        let events = generate_compensating_events(&drifts, Some("ch"), &test_ctx());
358        assert_eq!(events.len(), 1);
359        assert_eq!(events[0].op, "reconciled");
360        assert_eq!(events[0].actor, "reconcile");
361        assert_eq!(events[0].to, Some("complete".to_string()));
362        assert!(events[0].meta.is_some());
363    }
364
365    #[test]
366    fn generate_compensating_events_for_diverged() {
367        let drifts = vec![Drift::Diverged {
368            key: task_key("1.1", "ch"),
369            log_status: "pending".to_string(),
370            file_status: "complete".to_string(),
371        }];
372
373        let events = generate_compensating_events(&drifts, Some("ch"), &test_ctx());
374        assert_eq!(events.len(), 1);
375        assert_eq!(events[0].from, Some("pending".to_string()));
376        assert_eq!(events[0].to, Some("complete".to_string()));
377    }
378
379    #[test]
380    fn generate_compensating_events_for_extra() {
381        let drifts = vec![Drift::Extra {
382            key: task_key("1.1", "ch"),
383            log_status: "in-progress".to_string(),
384        }];
385
386        let events = generate_compensating_events(&drifts, Some("ch"), &test_ctx());
387        assert_eq!(events.len(), 1);
388        assert_eq!(events[0].from, Some("in-progress".to_string()));
389        assert!(events[0].to.is_none());
390    }
391
392    #[test]
393    fn compensating_events_use_scope_from_drift_key() {
394        let drifts = vec![Drift::Missing {
395            key: task_key("1.1", "my-change"),
396            file_status: "pending".to_string(),
397        }];
398
399        // Pass None for scope — should use the key's scope
400        let events = generate_compensating_events(&drifts, None, &test_ctx());
401        assert_eq!(events.len(), 1);
402        assert_eq!(events[0].scope, Some("my-change".to_string()));
403    }
404}