1use std::path::Path;
4
5use ito_domain::audit::event::AuditEvent;
6
7use super::writer::audit_log_path;
8
9#[derive(Debug, Default, Clone)]
11pub struct EventFilter {
12 pub entity: Option<String>,
14 pub scope: Option<String>,
16 pub op: Option<String>,
18}
19
20impl EventFilter {
21 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
43pub 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
71pub 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 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); }
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 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}