ito_core/audit/
reconcile.rs

1//! Reconciliation engine: compare audit log against file-on-disk state,
2//! detect drift, and optionally emit compensating events.
3
4use std::path::Path;
5
6use ito_domain::audit::context::resolve_context;
7use ito_domain::audit::materialize::{EntityKey, materialize_state};
8use ito_domain::audit::reconcile::{Drift, FileState, compute_drift, generate_compensating_events};
9use ito_domain::audit::writer::AuditWriter;
10use ito_domain::tasks::{TaskStatus, parse_tasks_tracking_file};
11
12use super::reader::read_audit_events;
13use super::writer::FsAuditWriter;
14
15/// Result of a reconciliation run.
16#[derive(Debug)]
17pub struct ReconcileReport {
18    /// Drift items detected.
19    pub drifts: Vec<Drift>,
20    /// Number of compensating events written (0 if dry-run).
21    pub events_written: usize,
22    /// Scope of the reconciliation (change ID or "project").
23    pub scoped_to: String,
24}
25
26/// Build file state from a change's tracking file for a specific change.
27///
28/// Reads the tasks file and produces a `FileState` map of task statuses.
29pub fn build_file_state(ito_path: &Path, change_id: &str) -> FileState {
30    let Ok(path) = crate::tasks::tracking_file_path(ito_path, change_id) else {
31        return FileState::new();
32    };
33    let Ok(contents) = ito_common::io::read_to_string_std(&path) else {
34        return FileState::new();
35    };
36
37    let parsed = parse_tasks_tracking_file(&contents);
38    let mut state = FileState::new();
39
40    for task in &parsed.tasks {
41        let key = EntityKey {
42            entity: "task".to_string(),
43            entity_id: task.id.clone(),
44            scope: Some(change_id.to_string()),
45        };
46
47        let status_str = match task.status {
48            TaskStatus::Pending => "pending",
49            TaskStatus::InProgress => "in-progress",
50            TaskStatus::Complete => "complete",
51            TaskStatus::Shelved => "shelved",
52        };
53
54        state.insert(key, status_str.to_string());
55    }
56
57    state
58}
59
60/// Run reconciliation: compare audit log against file state, report drift,
61/// and optionally write compensating events.
62///
63/// If `change_id` is Some, reconciles only tasks for that change.
64/// If `fix` is true, writes compensating events to the log.
65pub fn run_reconcile(ito_path: &Path, change_id: Option<&str>, fix: bool) -> ReconcileReport {
66    let Some(change_id) = change_id else {
67        // Project-wide reconciliation: iterate all active changes
68        return run_project_reconcile(ito_path, fix);
69    };
70
71    // Read all events and filter to this change's scope
72    let all_events = read_audit_events(ito_path);
73    let mut scoped_events = Vec::new();
74    for event in &all_events {
75        if event.scope.as_deref() == Some(change_id) && event.entity == "task" {
76            scoped_events.push(event.clone());
77        }
78    }
79
80    let audit_state = materialize_state(&scoped_events);
81    let file_state = build_file_state(ito_path, change_id);
82    let drifts = compute_drift(&audit_state.entities, &file_state);
83
84    let events_written = if fix && !drifts.is_empty() {
85        let ctx = resolve_context(ito_path);
86        let compensating = generate_compensating_events(&drifts, Some(change_id), &ctx);
87        let writer = FsAuditWriter::new(ito_path);
88        let mut written = 0;
89        for event in &compensating {
90            if writer.append(event).is_ok() {
91                written += 1;
92            }
93        }
94        written
95    } else {
96        0
97    };
98
99    ReconcileReport {
100        drifts,
101        events_written,
102        scoped_to: change_id.to_string(),
103    }
104}
105
106/// Project-wide reconciliation across all active changes.
107fn run_project_reconcile(ito_path: &Path, fix: bool) -> ReconcileReport {
108    let changes_dir = ito_common::paths::changes_dir(ito_path);
109
110    let Ok(entries) = std::fs::read_dir(&changes_dir) else {
111        return ReconcileReport {
112            drifts: Vec::new(),
113            events_written: 0,
114            scoped_to: "project".to_string(),
115        };
116    };
117
118    let mut all_drifts = Vec::new();
119    let mut total_written = 0;
120
121    for entry in entries {
122        let Ok(entry) = entry else { continue };
123        let path = entry.path();
124        if !path.is_dir() {
125            continue;
126        }
127        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
128            continue;
129        };
130        // Skip the archive directory
131        if name == "archive" {
132            continue;
133        }
134
135        let report = run_reconcile(ito_path, Some(name), fix);
136        all_drifts.extend(report.drifts);
137        total_written += report.events_written;
138    }
139
140    ReconcileReport {
141        drifts: all_drifts,
142        events_written: total_written,
143        scoped_to: "project".to_string(),
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use ito_domain::audit::event::{AuditEvent, EventContext, SCHEMA_VERSION};
151    use ito_domain::audit::writer::AuditWriter;
152
153    fn test_ctx() -> EventContext {
154        EventContext {
155            session_id: "test".to_string(),
156            harness_session_id: None,
157            branch: None,
158            worktree: None,
159            commit: None,
160        }
161    }
162
163    fn make_event(entity_id: &str, scope: &str, op: &str, to: Option<&str>) -> AuditEvent {
164        AuditEvent {
165            v: SCHEMA_VERSION,
166            ts: "2026-02-08T14:30:00.000Z".to_string(),
167            entity: "task".to_string(),
168            entity_id: entity_id.to_string(),
169            scope: Some(scope.to_string()),
170            op: op.to_string(),
171            from: None,
172            to: to.map(String::from),
173            actor: "cli".to_string(),
174            by: "@test".to_string(),
175            meta: None,
176            ctx: test_ctx(),
177        }
178    }
179
180    fn write_tasks_file(root: &Path, change_id: &str, file: &str, content: &str) {
181        let path = root.join(".ito/changes").join(change_id);
182        std::fs::create_dir_all(&path).expect("create dirs");
183        std::fs::write(path.join(file), content).expect("write tasks");
184    }
185
186    fn write_tasks(root: &Path, change_id: &str, content: &str) {
187        write_tasks_file(root, change_id, "tasks.md", content);
188    }
189
190    fn write_schema_apply_tracks(root: &Path, tracking_file: &str) {
191        let schema_dir = root
192            .join(".ito")
193            .join("templates")
194            .join("schemas")
195            .join("spec-driven");
196        std::fs::create_dir_all(&schema_dir).expect("schema dirs");
197        std::fs::write(
198            schema_dir.join("schema.yaml"),
199            format!(
200                "name: spec-driven\nversion: 1\nartifacts: []\napply:\n  tracks: {tracking_file}\n"
201            ),
202        )
203        .expect("write schema.yaml");
204    }
205
206    #[test]
207    fn build_file_state_from_default_tasks_md() {
208        let tmp = tempfile::tempdir().expect("tempdir");
209        let ito_path = tmp.path().join(".ito");
210
211        write_tasks(
212            tmp.path(),
213            "test-change",
214            "# Tasks\n\n## Wave 1\n\n### Task 1.1: Test\n- **Status**: [x] complete\n\n### Task 1.2: Test2\n- **Status**: [ ] pending\n",
215        );
216
217        let state = build_file_state(&ito_path, "test-change");
218        assert_eq!(state.len(), 2);
219
220        let key1 = EntityKey {
221            entity: "task".to_string(),
222            entity_id: "1.1".to_string(),
223            scope: Some("test-change".to_string()),
224        };
225        assert_eq!(state.get(&key1), Some(&"complete".to_string()));
226    }
227
228    #[test]
229    fn build_file_state_uses_apply_tracks_when_set() {
230        let tmp = tempfile::tempdir().expect("tempdir");
231        let ito_path = tmp.path().join(".ito");
232
233        write_schema_apply_tracks(tmp.path(), "todo.md");
234        write_tasks_file(
235            tmp.path(),
236            "test-change",
237            "todo.md",
238            "# Tasks\n\n## Wave 1\n\n### Task 1.1: Test\n- **Status**: [x] complete\n",
239        );
240        std::fs::write(
241            tmp.path().join(".ito/changes/test-change/.ito.yaml"),
242            "schema: spec-driven\n",
243        )
244        .expect("write .ito.yaml");
245
246        let state = build_file_state(&ito_path, "test-change");
247        assert_eq!(state.len(), 1);
248
249        let key = EntityKey {
250            entity: "task".to_string(),
251            entity_id: "1.1".to_string(),
252            scope: Some("test-change".to_string()),
253        };
254        assert_eq!(state.get(&key), Some(&"complete".to_string()));
255    }
256
257    #[test]
258    fn reconcile_no_drift() {
259        let tmp = tempfile::tempdir().expect("tempdir");
260        let ito_path = tmp.path().join(".ito");
261
262        write_tasks(
263            tmp.path(),
264            "ch",
265            "# Tasks\n\n## Wave 1\n\n### Task 1.1: Test\n- **Status**: [ ] pending\n",
266        );
267
268        // Write a matching audit event
269        let writer = FsAuditWriter::new(&ito_path);
270        writer
271            .append(&make_event("1.1", "ch", "create", Some("pending")))
272            .unwrap();
273
274        let report = run_reconcile(&ito_path, Some("ch"), false);
275        assert!(report.drifts.is_empty());
276        assert_eq!(report.events_written, 0);
277    }
278
279    #[test]
280    fn reconcile_detects_drift() {
281        let tmp = tempfile::tempdir().expect("tempdir");
282        let ito_path = tmp.path().join(".ito");
283
284        // File says complete, log says pending
285        write_tasks(
286            tmp.path(),
287            "ch",
288            "# Tasks\n\n## Wave 1\n\n### Task 1.1: Test\n- **Status**: [x] complete\n",
289        );
290
291        let writer = FsAuditWriter::new(&ito_path);
292        writer
293            .append(&make_event("1.1", "ch", "create", Some("pending")))
294            .unwrap();
295
296        let report = run_reconcile(&ito_path, Some("ch"), false);
297        assert_eq!(report.drifts.len(), 1);
298        assert_eq!(report.events_written, 0);
299    }
300
301    #[test]
302    fn reconcile_fix_writes_compensating_events() {
303        let tmp = tempfile::tempdir().expect("tempdir");
304        let ito_path = tmp.path().join(".ito");
305
306        write_tasks(
307            tmp.path(),
308            "ch",
309            "# Tasks\n\n## Wave 1\n\n### Task 1.1: Test\n- **Status**: [x] complete\n",
310        );
311
312        let writer = FsAuditWriter::new(&ito_path);
313        writer
314            .append(&make_event("1.1", "ch", "create", Some("pending")))
315            .unwrap();
316
317        let report = run_reconcile(&ito_path, Some("ch"), true);
318        assert_eq!(report.drifts.len(), 1);
319        assert_eq!(report.events_written, 1);
320
321        // Read events to verify compensating event was written
322        let events = read_audit_events(&ito_path);
323        assert_eq!(events.len(), 2);
324        assert_eq!(events[1].op, "reconciled");
325        assert_eq!(events[1].actor, "reconcile");
326    }
327
328    #[test]
329    fn reconcile_empty_log() {
330        let tmp = tempfile::tempdir().expect("tempdir");
331        let ito_path = tmp.path().join(".ito");
332
333        write_tasks(
334            tmp.path(),
335            "ch",
336            "# Tasks\n\n## Wave 1\n\n### Task 1.1: Test\n- **Status**: [ ] pending\n",
337        );
338
339        // No audit log at all
340        let report = run_reconcile(&ito_path, Some("ch"), false);
341        assert_eq!(report.drifts.len(), 1); // Missing
342    }
343
344    #[test]
345    fn reconcile_missing_tasks_file() {
346        let tmp = tempfile::tempdir().expect("tempdir");
347        let ito_path = tmp.path().join(".ito");
348
349        // No tasks.md but has events
350        let writer = FsAuditWriter::new(&ito_path);
351        writer
352            .append(&make_event("1.1", "ch", "create", Some("pending")))
353            .unwrap();
354
355        let report = run_reconcile(&ito_path, Some("ch"), false);
356        // Task in log but not in files -> Extra
357        assert_eq!(report.drifts.len(), 1);
358    }
359}