use std::path::Path;
use ito_domain::audit::context::resolve_context;
use ito_domain::audit::event::AuditEvent;
use ito_domain::audit::materialize::{EntityKey, materialize_state};
use ito_domain::audit::reconcile::{Drift, FileState, compute_drift, generate_compensating_events};
use ito_domain::tasks::{TaskStatus, parse_tasks_tracking_file};
use super::reader::read_audit_events;
use super::store::default_audit_store;
#[derive(Debug)]
pub struct ReconcileReport {
pub drifts: Vec<Drift>,
pub events_written: usize,
pub scoped_to: String,
}
pub fn build_file_state(ito_path: &Path, change_id: &str) -> FileState {
let Ok(path) = crate::tasks::tracking_file_path(ito_path, change_id) else {
return FileState::new();
};
let Ok(contents) = ito_common::io::read_to_string_std(&path) else {
return FileState::new();
};
let parsed = parse_tasks_tracking_file(&contents);
let mut state = FileState::new();
for task in &parsed.tasks {
let key = EntityKey {
entity: "task".to_string(),
entity_id: task.id.clone(),
scope: Some(change_id.to_string()),
};
let status_str = match task.status {
TaskStatus::Pending => "pending",
TaskStatus::InProgress => "in-progress",
TaskStatus::Complete => "complete",
TaskStatus::Shelved => "shelved",
};
state.insert(key, status_str.to_string());
}
state
}
pub fn run_reconcile(ito_path: &Path, change_id: Option<&str>, fix: bool) -> ReconcileReport {
let Some(change_id) = change_id else {
return run_project_reconcile(ito_path, fix);
};
let all_events = read_audit_events(ito_path);
let mut scoped_events = Vec::new();
for event in &all_events {
if event.scope.as_deref() == Some(change_id) && event.entity == "task" {
scoped_events.push(event.clone());
}
}
let audit_state = materialize_state(&scoped_events);
let file_state = build_file_state(ito_path, change_id);
let mut drifts = compute_drift(&audit_state.entities, &file_state);
let events_written = if fix && !drifts.is_empty() {
let ctx = resolve_context(ito_path);
let compensating = generate_compensating_events(&drifts, Some(change_id), &ctx);
let writer = default_audit_store(ito_path);
let mut written = 0;
for event in &compensating {
if has_equivalent_compensating_event(&scoped_events, event) {
continue;
}
if writer.append(event).is_ok() {
written += 1;
}
}
if written > 0 {
let all_events = read_audit_events(ito_path);
let mut scoped_events = Vec::new();
for event in &all_events {
if event.scope.as_deref() == Some(change_id) && event.entity == "task" {
scoped_events.push(event.clone());
}
}
let audit_state = materialize_state(&scoped_events);
let file_state = build_file_state(ito_path, change_id);
drifts = compute_drift(&audit_state.entities, &file_state);
}
written
} else {
0
};
ReconcileReport {
drifts,
events_written,
scoped_to: change_id.to_string(),
}
}
fn has_equivalent_compensating_event(events: &[AuditEvent], event: &AuditEvent) -> bool {
events.iter().any(|existing| {
existing.entity == event.entity
&& existing.entity_id == event.entity_id
&& existing.scope == event.scope
&& existing.op == event.op
&& existing.from == event.from
&& existing.to == event.to
&& existing.actor == event.actor
&& existing.by == event.by
})
}
fn run_project_reconcile(ito_path: &Path, fix: bool) -> ReconcileReport {
let changes_dir = ito_common::paths::changes_dir(ito_path);
let Ok(entries) = std::fs::read_dir(&changes_dir) else {
return ReconcileReport {
drifts: Vec::new(),
events_written: 0,
scoped_to: "project".to_string(),
};
};
let mut all_drifts = Vec::new();
let mut total_written = 0;
for entry in entries {
let Ok(entry) = entry else { continue };
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name == "archive" {
continue;
}
let report = run_reconcile(ito_path, Some(name), fix);
all_drifts.extend(report.drifts);
total_written += report.events_written;
}
ReconcileReport {
drifts: all_drifts,
events_written: total_written,
scoped_to: "project".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ito_domain::audit::event::{AuditEvent, EventContext, SCHEMA_VERSION};
fn test_ctx() -> EventContext {
EventContext {
session_id: "test".to_string(),
harness_session_id: None,
branch: None,
worktree: None,
commit: None,
}
}
fn make_event(entity_id: &str, scope: &str, op: &str, to: Option<&str>) -> AuditEvent {
AuditEvent {
v: SCHEMA_VERSION,
ts: "2026-02-08T14:30:00.000Z".to_string(),
entity: "task".to_string(),
entity_id: entity_id.to_string(),
scope: Some(scope.to_string()),
op: op.to_string(),
from: None,
to: to.map(String::from),
actor: "cli".to_string(),
by: "@test".to_string(),
meta: None,
count: 1,
ctx: test_ctx(),
}
}
fn write_tasks_file(root: &Path, change_id: &str, file: &str, content: &str) {
let path = root.join(".ito/changes").join(change_id);
std::fs::create_dir_all(&path).expect("create dirs");
std::fs::write(path.join(file), content).expect("write tasks");
}
fn write_tasks(root: &Path, change_id: &str, content: &str) {
write_tasks_file(root, change_id, "tasks.md", content);
}
fn write_schema_apply_tracks(root: &Path, tracking_file: &str) {
let schema_dir = root
.join(".ito")
.join("templates")
.join("schemas")
.join("spec-driven");
std::fs::create_dir_all(&schema_dir).expect("schema dirs");
std::fs::write(
schema_dir.join("schema.yaml"),
format!(
"name: spec-driven\nversion: 1\nartifacts: []\napply:\n tracks: {tracking_file}\n"
),
)
.expect("write schema.yaml");
}
#[test]
fn build_file_state_from_default_tasks_md() {
let tmp = tempfile::tempdir().expect("tempdir");
let ito_path = tmp.path().join(".ito");
write_tasks(
tmp.path(),
"test-change",
"# 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",
);
let state = build_file_state(&ito_path, "test-change");
assert_eq!(state.len(), 2);
let key1 = EntityKey {
entity: "task".to_string(),
entity_id: "1.1".to_string(),
scope: Some("test-change".to_string()),
};
assert_eq!(state.get(&key1), Some(&"complete".to_string()));
}
#[test]
fn build_file_state_uses_apply_tracks_when_set() {
let tmp = tempfile::tempdir().expect("tempdir");
let ito_path = tmp.path().join(".ito");
write_schema_apply_tracks(tmp.path(), "todo.md");
write_tasks_file(
tmp.path(),
"test-change",
"todo.md",
"# Tasks\n\n## Wave 1\n\n### Task 1.1: Test\n- **Status**: [x] complete\n",
);
std::fs::write(
tmp.path().join(".ito/changes/test-change/.ito.yaml"),
"schema: spec-driven\n",
)
.expect("write .ito.yaml");
let state = build_file_state(&ito_path, "test-change");
assert_eq!(state.len(), 1);
let key = EntityKey {
entity: "task".to_string(),
entity_id: "1.1".to_string(),
scope: Some("test-change".to_string()),
};
assert_eq!(state.get(&key), Some(&"complete".to_string()));
}
#[test]
fn reconcile_no_drift() {
let tmp = tempfile::tempdir().expect("tempdir");
let ito_path = tmp.path().join(".ito");
write_tasks(
tmp.path(),
"ch",
"# Tasks\n\n## Wave 1\n\n### Task 1.1: Test\n- **Status**: [ ] pending\n",
);
let writer = default_audit_store(&ito_path);
writer
.append(&make_event("1.1", "ch", "create", Some("pending")))
.unwrap();
let report = run_reconcile(&ito_path, Some("ch"), false);
assert!(report.drifts.is_empty());
assert_eq!(report.events_written, 0);
}
#[test]
fn reconcile_detects_drift() {
let tmp = tempfile::tempdir().expect("tempdir");
let ito_path = tmp.path().join(".ito");
write_tasks(
tmp.path(),
"ch",
"# Tasks\n\n## Wave 1\n\n### Task 1.1: Test\n- **Status**: [x] complete\n",
);
let writer = default_audit_store(&ito_path);
writer
.append(&make_event("1.1", "ch", "create", Some("pending")))
.unwrap();
let report = run_reconcile(&ito_path, Some("ch"), false);
assert_eq!(report.drifts.len(), 1);
assert_eq!(report.events_written, 0);
}
#[test]
fn reconcile_fix_writes_compensating_events() {
let tmp = tempfile::tempdir().expect("tempdir");
let ito_path = tmp.path().join(".ito");
write_tasks(
tmp.path(),
"ch",
"# Tasks\n\n## Wave 1\n\n### Task 1.1: Test\n- **Status**: [x] complete\n",
);
let writer = default_audit_store(&ito_path);
writer
.append(&make_event("1.1", "ch", "create", Some("pending")))
.unwrap();
let report = run_reconcile(&ito_path, Some("ch"), true);
assert!(report.drifts.is_empty());
assert_eq!(report.events_written, 1);
let events = read_audit_events(&ito_path);
assert_eq!(events.len(), 2);
assert_eq!(events[1].op, "reconciled");
assert_eq!(events[1].actor, "reconcile");
}
#[test]
fn reconcile_empty_log() {
let tmp = tempfile::tempdir().expect("tempdir");
let ito_path = tmp.path().join(".ito");
write_tasks(
tmp.path(),
"ch",
"# Tasks\n\n## Wave 1\n\n### Task 1.1: Test\n- **Status**: [ ] pending\n",
);
let report = run_reconcile(&ito_path, Some("ch"), false);
assert_eq!(report.drifts.len(), 1); }
#[test]
fn reconcile_missing_tasks_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let ito_path = tmp.path().join(".ito");
let writer = default_audit_store(&ito_path);
writer
.append(&make_event("1.1", "ch", "create", Some("pending")))
.unwrap();
let report = run_reconcile(&ito_path, Some("ch"), false);
assert_eq!(report.drifts.len(), 1);
}
#[test]
fn reconcile_fix_clears_extra_task_drift() {
let tmp = tempfile::tempdir().expect("tempdir");
let ito_path = tmp.path().join(".ito");
let writer = default_audit_store(&ito_path);
writer
.append(&make_event("1.1", "ch", "create", Some("pending")))
.unwrap();
let report = run_reconcile(&ito_path, Some("ch"), true);
assert!(report.drifts.is_empty());
assert_eq!(report.events_written, 1);
let report = run_reconcile(&ito_path, Some("ch"), true);
assert!(report.drifts.is_empty());
assert_eq!(report.events_written, 0);
}
}