1use std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11use ito_domain::audit::event::AuditEvent;
12
13use super::reader::{EventFilter, read_audit_events, read_audit_events_filtered};
14
15#[derive(Debug, Clone)]
17pub struct AuditIssue {
18 pub level: String,
20 pub message: String,
22 pub event_index: usize,
24}
25
26#[derive(Debug)]
28pub struct AuditValidationReport {
29 pub event_count: usize,
31 pub issues: Vec<AuditIssue>,
33 pub valid: bool,
35}
36
37pub fn validate_audit_log(ito_path: &Path, change_id: Option<&str>) -> AuditValidationReport {
39 let events = if let Some(change_id) = change_id {
40 let filter = EventFilter {
41 scope: Some(change_id.to_string()),
42 ..Default::default()
43 };
44 read_audit_events_filtered(ito_path, &filter)
45 } else {
46 read_audit_events(ito_path)
47 };
48
49 let issues = run_checks(&events);
50
51 let has_errors = issues.iter().any(|i| i.level == "error");
52
53 AuditValidationReport {
54 event_count: events.len(),
55 valid: !has_errors,
56 issues,
57 }
58}
59
60fn run_checks(events: &[AuditEvent]) -> Vec<AuditIssue> {
62 let mut issues = Vec::new();
63
64 issues.extend(check_duplicate_creates(events));
65 issues.extend(check_invalid_status_transitions(events));
66 issues.extend(check_timestamp_ordering(events));
67
68 issues.sort_by_key(|i| i.event_index);
70
71 issues
72}
73
74fn check_duplicate_creates(events: &[AuditEvent]) -> Vec<AuditIssue> {
76 let mut issues = Vec::new();
77 let mut seen: HashSet<(String, String, Option<String>)> = HashSet::new();
78
79 for (i, event) in events.iter().enumerate() {
80 if event.op != "create" && event.op != "add" {
81 continue;
82 }
83 let key = (
84 event.entity.clone(),
85 event.entity_id.clone(),
86 event.scope.clone(),
87 );
88 if !seen.insert(key.clone()) {
89 issues.push(AuditIssue {
90 level: "warning".to_string(),
91 message: format!(
92 "Duplicate {} event for {}/{} (scope: {:?})",
93 event.op, event.entity, event.entity_id, event.scope
94 ),
95 event_index: i,
96 });
97 }
98 }
99
100 issues
101}
102
103fn check_invalid_status_transitions(events: &[AuditEvent]) -> Vec<AuditIssue> {
105 let mut issues = Vec::new();
106
107 let mut last_status: HashMap<(String, String, Option<String>), String> = HashMap::new();
109
110 for (i, event) in events.iter().enumerate() {
111 if event.op != "status_change" {
112 if let Some(to) = &event.to {
114 let key = (
115 event.entity.clone(),
116 event.entity_id.clone(),
117 event.scope.clone(),
118 );
119 last_status.insert(key, to.clone());
120 }
121 continue;
122 }
123
124 let key = (
125 event.entity.clone(),
126 event.entity_id.clone(),
127 event.scope.clone(),
128 );
129
130 if let Some(from) = &event.from
132 && let Some(last) = last_status.get(&key)
133 && last != from
134 {
135 issues.push(AuditIssue {
136 level: "warning".to_string(),
137 message: format!(
138 "Status transition mismatch for {}/{}: expected from='{}' but event says from='{}'",
139 event.entity, event.entity_id, last, from
140 ),
141 event_index: i,
142 });
143 }
144
145 if let Some(to) = &event.to {
147 last_status.insert(key, to.clone());
148 }
149 }
150
151 issues
152}
153
154fn check_timestamp_ordering(events: &[AuditEvent]) -> Vec<AuditIssue> {
156 let mut issues = Vec::new();
157
158 for i in 1..events.len() {
159 if events[i].ts < events[i - 1].ts {
160 issues.push(AuditIssue {
161 level: "warning".to_string(),
162 message: format!(
163 "Timestamp ordering violation: event {} ({}) is earlier than event {} ({})",
164 i + 1,
165 events[i].ts,
166 i,
167 events[i - 1].ts
168 ),
169 event_index: i,
170 });
171 }
172 }
173
174 issues
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use ito_domain::audit::event::{EventContext, SCHEMA_VERSION};
181
182 fn test_ctx() -> EventContext {
183 EventContext {
184 session_id: "test".to_string(),
185 harness_session_id: None,
186 branch: None,
187 worktree: None,
188 commit: None,
189 }
190 }
191
192 fn make_event(
193 entity: &str,
194 entity_id: &str,
195 scope: Option<&str>,
196 op: &str,
197 from: Option<&str>,
198 to: Option<&str>,
199 ts: &str,
200 ) -> AuditEvent {
201 AuditEvent {
202 v: SCHEMA_VERSION,
203 ts: ts.to_string(),
204 entity: entity.to_string(),
205 entity_id: entity_id.to_string(),
206 scope: scope.map(String::from),
207 op: op.to_string(),
208 from: from.map(String::from),
209 to: to.map(String::from),
210 actor: "cli".to_string(),
211 by: "@test".to_string(),
212 meta: None,
213 count: 1,
214 ctx: test_ctx(),
215 }
216 }
217
218 #[test]
219 fn no_issues_for_valid_sequence() {
220 let events = vec![
221 make_event(
222 "task",
223 "1.1",
224 Some("ch"),
225 "create",
226 None,
227 Some("pending"),
228 "2026-01-01T00:00:00Z",
229 ),
230 make_event(
231 "task",
232 "1.1",
233 Some("ch"),
234 "status_change",
235 Some("pending"),
236 Some("in-progress"),
237 "2026-01-01T00:01:00Z",
238 ),
239 make_event(
240 "task",
241 "1.1",
242 Some("ch"),
243 "status_change",
244 Some("in-progress"),
245 Some("complete"),
246 "2026-01-01T00:02:00Z",
247 ),
248 ];
249
250 let issues = run_checks(&events);
251 assert!(issues.is_empty());
252 }
253
254 #[test]
255 fn detect_duplicate_create() {
256 let events = vec![
257 make_event(
258 "task",
259 "1.1",
260 Some("ch"),
261 "create",
262 None,
263 Some("pending"),
264 "2026-01-01T00:00:00Z",
265 ),
266 make_event(
267 "task",
268 "1.1",
269 Some("ch"),
270 "create",
271 None,
272 Some("pending"),
273 "2026-01-01T00:01:00Z",
274 ),
275 ];
276
277 let issues = run_checks(&events);
278 assert_eq!(issues.len(), 1);
279 assert!(issues[0].message.contains("Duplicate"));
280 }
281
282 #[test]
283 fn detect_status_transition_mismatch() {
284 let events = vec![
285 make_event(
286 "task",
287 "1.1",
288 Some("ch"),
289 "create",
290 None,
291 Some("pending"),
292 "2026-01-01T00:00:00Z",
293 ),
294 make_event(
296 "task",
297 "1.1",
298 Some("ch"),
299 "status_change",
300 Some("in-progress"),
301 Some("complete"),
302 "2026-01-01T00:01:00Z",
303 ),
304 ];
305
306 let issues = run_checks(&events);
307 assert_eq!(issues.len(), 1);
308 assert!(issues[0].message.contains("transition mismatch"));
309 }
310
311 #[test]
312 fn detect_timestamp_ordering_violation() {
313 let events = vec![
314 make_event(
315 "task",
316 "1.1",
317 Some("ch"),
318 "create",
319 None,
320 Some("pending"),
321 "2026-01-01T00:02:00Z",
322 ),
323 make_event(
324 "task",
325 "1.2",
326 Some("ch"),
327 "create",
328 None,
329 Some("pending"),
330 "2026-01-01T00:01:00Z",
331 ),
332 ];
333
334 let issues = run_checks(&events);
335 assert_eq!(issues.len(), 1);
336 assert!(issues[0].message.contains("Timestamp ordering"));
337 }
338
339 #[test]
340 fn empty_events_no_issues() {
341 let issues = run_checks(&[]);
342 assert!(issues.is_empty());
343 }
344
345 #[test]
346 fn different_scopes_are_independent() {
347 let events = vec![
348 make_event(
349 "task",
350 "1.1",
351 Some("ch-1"),
352 "create",
353 None,
354 Some("pending"),
355 "2026-01-01T00:00:00Z",
356 ),
357 make_event(
358 "task",
359 "1.1",
360 Some("ch-2"),
361 "create",
362 None,
363 Some("pending"),
364 "2026-01-01T00:01:00Z",
365 ),
366 ];
367
368 let issues = run_checks(&events);
369 assert!(issues.is_empty()); }
371}