Skip to main content

ito_core/audit/
reader.rs

1//! Audit log reader: parse events from JSONL file with optional filtering.
2
3use std::path::Path;
4
5use ito_domain::audit::event::AuditEvent;
6
7use super::writer::audit_log_path;
8
9/// Filter criteria for reading audit events.
10#[derive(Debug, Default, Clone)]
11pub struct EventFilter {
12    /// Only include events for this entity type.
13    pub entity: Option<String>,
14    /// Only include events scoped to this change.
15    pub scope: Option<String>,
16    /// Only include events with this operation.
17    pub op: Option<String>,
18}
19
20impl EventFilter {
21    /// Check if an event matches this filter.
22    fn matches(&self, event: &AuditEvent) -> bool {
23        if let Some(entity) = &self.entity
24            && event.entity != *entity
25        {
26            return false;
27        }
28        if let Some(scope) = &self.scope {
29            match &event.scope {
30                Some(event_scope) if event_scope == scope => {}
31                _ => return false,
32            }
33        }
34        if let Some(op) = &self.op
35            && event.op != *op
36        {
37            return false;
38        }
39        true
40    }
41}
42
43/// Read all audit events from the project's JSONL file.
44///
45/// Malformed lines are skipped with a tracing warning. If the file does not
46/// exist, returns an empty vector.
47pub fn read_audit_events(ito_path: &Path) -> Vec<AuditEvent> {
48    let path = audit_log_path(ito_path);
49
50    let Ok(contents) = std::fs::read_to_string(&path) else {
51        return Vec::new();
52    };
53
54    let mut events = Vec::new();
55    for (line_num, line) in contents.lines().enumerate() {
56        let line = line.trim();
57        if line.is_empty() {
58            continue;
59        }
60        match serde_json::from_str::<AuditEvent>(line) {
61            Ok(event) => events.push(event),
62            Err(e) => {
63                tracing::warn!("audit log line {}: malformed event: {e}", line_num + 1);
64            }
65        }
66    }
67
68    events
69}
70
71/// Read audit events with a filter applied.
72///
73/// Only events matching all filter criteria are returned.
74pub fn read_audit_events_filtered(ito_path: &Path, filter: &EventFilter) -> Vec<AuditEvent> {
75    let all = read_audit_events(ito_path);
76    let mut filtered = Vec::new();
77    for event in all {
78        if filter.matches(&event) {
79            filtered.push(event);
80        }
81    }
82    filtered
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::audit::writer::FsAuditWriter;
89    use ito_domain::audit::event::{EventContext, SCHEMA_VERSION};
90    use ito_domain::audit::writer::AuditWriter;
91
92    fn make_event(entity: &str, entity_id: &str, scope: Option<&str>, op: &str) -> AuditEvent {
93        AuditEvent {
94            v: SCHEMA_VERSION,
95            ts: "2026-02-08T14:30:00.000Z".to_string(),
96            entity: entity.to_string(),
97            entity_id: entity_id.to_string(),
98            scope: scope.map(String::from),
99            op: op.to_string(),
100            from: None,
101            to: Some("pending".to_string()),
102            actor: "cli".to_string(),
103            by: "@test".to_string(),
104            meta: None,
105            ctx: EventContext {
106                session_id: "test-sid".to_string(),
107                harness_session_id: None,
108                branch: None,
109                worktree: None,
110                commit: None,
111            },
112        }
113    }
114
115    #[test]
116    fn read_from_missing_file_returns_empty() {
117        let tmp = tempfile::tempdir().expect("tempdir");
118        let events = read_audit_events(&tmp.path().join(".ito"));
119        assert!(events.is_empty());
120    }
121
122    #[test]
123    fn read_parses_valid_events() {
124        let tmp = tempfile::tempdir().expect("tempdir");
125        let ito_path = tmp.path().join(".ito");
126
127        let writer = FsAuditWriter::new(&ito_path);
128        writer
129            .append(&make_event("task", "1.1", Some("ch"), "create"))
130            .unwrap();
131        writer
132            .append(&make_event("task", "1.2", Some("ch"), "create"))
133            .unwrap();
134
135        let events = read_audit_events(&ito_path);
136        assert_eq!(events.len(), 2);
137        assert_eq!(events[0].entity_id, "1.1");
138        assert_eq!(events[1].entity_id, "1.2");
139    }
140
141    #[test]
142    fn skips_malformed_lines() {
143        let tmp = tempfile::tempdir().expect("tempdir");
144        let ito_path = tmp.path().join(".ito");
145
146        let writer = FsAuditWriter::new(&ito_path);
147        writer
148            .append(&make_event("task", "1.1", Some("ch"), "create"))
149            .unwrap();
150
151        // Manually append a malformed line
152        let log_path = super::super::writer::audit_log_path(&ito_path);
153        let mut file = std::fs::OpenOptions::new()
154            .append(true)
155            .open(&log_path)
156            .unwrap();
157        use std::io::Write;
158        writeln!(file, "{{invalid json").unwrap();
159
160        writer
161            .append(&make_event("task", "1.2", Some("ch"), "create"))
162            .unwrap();
163
164        let events = read_audit_events(&ito_path);
165        assert_eq!(events.len(), 2); // Malformed line skipped
166    }
167
168    #[test]
169    fn skips_empty_lines() {
170        let tmp = tempfile::tempdir().expect("tempdir");
171        let ito_path = tmp.path().join(".ito");
172
173        let writer = FsAuditWriter::new(&ito_path);
174        writer
175            .append(&make_event("task", "1.1", Some("ch"), "create"))
176            .unwrap();
177
178        // Append empty lines
179        let log_path = super::super::writer::audit_log_path(&ito_path);
180        let mut file = std::fs::OpenOptions::new()
181            .append(true)
182            .open(&log_path)
183            .unwrap();
184        use std::io::Write;
185        writeln!(file).unwrap();
186        writeln!(file).unwrap();
187
188        let events = read_audit_events(&ito_path);
189        assert_eq!(events.len(), 1);
190    }
191
192    #[test]
193    fn filter_by_entity_type() {
194        let tmp = tempfile::tempdir().expect("tempdir");
195        let ito_path = tmp.path().join(".ito");
196
197        let writer = FsAuditWriter::new(&ito_path);
198        writer
199            .append(&make_event("task", "1.1", Some("ch"), "create"))
200            .unwrap();
201        writer
202            .append(&make_event("change", "ch", None, "create"))
203            .unwrap();
204
205        let filter = EventFilter {
206            entity: Some("task".to_string()),
207            ..Default::default()
208        };
209        let events = read_audit_events_filtered(&ito_path, &filter);
210        assert_eq!(events.len(), 1);
211        assert_eq!(events[0].entity, "task");
212    }
213
214    #[test]
215    fn filter_by_scope() {
216        let tmp = tempfile::tempdir().expect("tempdir");
217        let ito_path = tmp.path().join(".ito");
218
219        let writer = FsAuditWriter::new(&ito_path);
220        writer
221            .append(&make_event("task", "1.1", Some("ch-1"), "create"))
222            .unwrap();
223        writer
224            .append(&make_event("task", "2.1", Some("ch-2"), "create"))
225            .unwrap();
226
227        let filter = EventFilter {
228            scope: Some("ch-1".to_string()),
229            ..Default::default()
230        };
231        let events = read_audit_events_filtered(&ito_path, &filter);
232        assert_eq!(events.len(), 1);
233        assert_eq!(events[0].scope, Some("ch-1".to_string()));
234    }
235
236    #[test]
237    fn filter_by_operation() {
238        let tmp = tempfile::tempdir().expect("tempdir");
239        let ito_path = tmp.path().join(".ito");
240
241        let writer = FsAuditWriter::new(&ito_path);
242        writer
243            .append(&make_event("task", "1.1", Some("ch"), "create"))
244            .unwrap();
245        writer
246            .append(&make_event("task", "1.1", Some("ch"), "status_change"))
247            .unwrap();
248
249        let filter = EventFilter {
250            op: Some("status_change".to_string()),
251            ..Default::default()
252        };
253        let events = read_audit_events_filtered(&ito_path, &filter);
254        assert_eq!(events.len(), 1);
255        assert_eq!(events[0].op, "status_change");
256    }
257
258    #[test]
259    fn combined_filters() {
260        let tmp = tempfile::tempdir().expect("tempdir");
261        let ito_path = tmp.path().join(".ito");
262
263        let writer = FsAuditWriter::new(&ito_path);
264        writer
265            .append(&make_event("task", "1.1", Some("ch-1"), "create"))
266            .unwrap();
267        writer
268            .append(&make_event("task", "1.1", Some("ch-1"), "status_change"))
269            .unwrap();
270        writer
271            .append(&make_event("task", "2.1", Some("ch-2"), "create"))
272            .unwrap();
273        writer
274            .append(&make_event("change", "ch-1", None, "create"))
275            .unwrap();
276
277        let filter = EventFilter {
278            entity: Some("task".to_string()),
279            scope: Some("ch-1".to_string()),
280            op: Some("create".to_string()),
281        };
282        let events = read_audit_events_filtered(&ito_path, &filter);
283        assert_eq!(events.len(), 1);
284        assert_eq!(events[0].entity_id, "1.1");
285    }
286}