1use 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#[derive(Debug)]
17pub struct ReconcileReport {
18 pub drifts: Vec<Drift>,
20 pub events_written: usize,
22 pub scoped_to: String,
24}
25
26pub 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
60pub fn run_reconcile(ito_path: &Path, change_id: Option<&str>, fix: bool) -> ReconcileReport {
66 let Some(change_id) = change_id else {
67 return run_project_reconcile(ito_path, fix);
69 };
70
71 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
106fn 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 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 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 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 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 let report = run_reconcile(&ito_path, Some("ch"), false);
341 assert_eq!(report.drifts.len(), 1); }
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 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 assert_eq!(report.drifts.len(), 1);
358 }
359}