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 ctx: test_ctx(),
214 }
215 }
216
217 #[test]
218 fn no_issues_for_valid_sequence() {
219 let events = vec![
220 make_event(
221 "task",
222 "1.1",
223 Some("ch"),
224 "create",
225 None,
226 Some("pending"),
227 "2026-01-01T00:00:00Z",
228 ),
229 make_event(
230 "task",
231 "1.1",
232 Some("ch"),
233 "status_change",
234 Some("pending"),
235 Some("in-progress"),
236 "2026-01-01T00:01:00Z",
237 ),
238 make_event(
239 "task",
240 "1.1",
241 Some("ch"),
242 "status_change",
243 Some("in-progress"),
244 Some("complete"),
245 "2026-01-01T00:02:00Z",
246 ),
247 ];
248
249 let issues = run_checks(&events);
250 assert!(issues.is_empty());
251 }
252
253 #[test]
254 fn detect_duplicate_create() {
255 let events = vec![
256 make_event(
257 "task",
258 "1.1",
259 Some("ch"),
260 "create",
261 None,
262 Some("pending"),
263 "2026-01-01T00:00:00Z",
264 ),
265 make_event(
266 "task",
267 "1.1",
268 Some("ch"),
269 "create",
270 None,
271 Some("pending"),
272 "2026-01-01T00:01:00Z",
273 ),
274 ];
275
276 let issues = run_checks(&events);
277 assert_eq!(issues.len(), 1);
278 assert!(issues[0].message.contains("Duplicate"));
279 }
280
281 #[test]
282 fn detect_status_transition_mismatch() {
283 let events = vec![
284 make_event(
285 "task",
286 "1.1",
287 Some("ch"),
288 "create",
289 None,
290 Some("pending"),
291 "2026-01-01T00:00:00Z",
292 ),
293 make_event(
295 "task",
296 "1.1",
297 Some("ch"),
298 "status_change",
299 Some("in-progress"),
300 Some("complete"),
301 "2026-01-01T00:01:00Z",
302 ),
303 ];
304
305 let issues = run_checks(&events);
306 assert_eq!(issues.len(), 1);
307 assert!(issues[0].message.contains("transition mismatch"));
308 }
309
310 #[test]
311 fn detect_timestamp_ordering_violation() {
312 let events = vec![
313 make_event(
314 "task",
315 "1.1",
316 Some("ch"),
317 "create",
318 None,
319 Some("pending"),
320 "2026-01-01T00:02:00Z",
321 ),
322 make_event(
323 "task",
324 "1.2",
325 Some("ch"),
326 "create",
327 None,
328 Some("pending"),
329 "2026-01-01T00:01:00Z",
330 ),
331 ];
332
333 let issues = run_checks(&events);
334 assert_eq!(issues.len(), 1);
335 assert!(issues[0].message.contains("Timestamp ordering"));
336 }
337
338 #[test]
339 fn empty_events_no_issues() {
340 let issues = run_checks(&[]);
341 assert!(issues.is_empty());
342 }
343
344 #[test]
345 fn different_scopes_are_independent() {
346 let events = vec![
347 make_event(
348 "task",
349 "1.1",
350 Some("ch-1"),
351 "create",
352 None,
353 Some("pending"),
354 "2026-01-01T00:00:00Z",
355 ),
356 make_event(
357 "task",
358 "1.1",
359 Some("ch-2"),
360 "create",
361 None,
362 Some("pending"),
363 "2026-01-01T00:01:00Z",
364 ),
365 ];
366
367 let issues = run_checks(&events);
368 assert!(issues.is_empty()); }
370}