1use std::fs::OpenOptions;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use ito_domain::audit::event::AuditEvent;
11use ito_domain::audit::writer::AuditWriter;
12
13pub struct FsAuditWriter {
17 log_path: PathBuf,
18}
19
20impl FsAuditWriter {
21 pub fn new(ito_path: &Path) -> Self {
23 let log_path = audit_log_path(ito_path);
24 Self { log_path }
25 }
26
27 pub fn log_path(&self) -> &Path {
29 &self.log_path
30 }
31}
32
33impl AuditWriter for FsAuditWriter {
34 fn append(&self, event: &AuditEvent) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
35 if let Err(e) = append_event_to_file(&self.log_path, event) {
38 tracing::warn!("audit log write failed: {e}");
39 }
40 Ok(())
41 }
42}
43
44fn append_event_to_file(path: &Path, event: &AuditEvent) -> std::io::Result<()> {
46 if let Some(parent) = path.parent() {
48 std::fs::create_dir_all(parent)?;
49 }
50
51 let json = serde_json::to_string(event)
52 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
53
54 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
55
56 writeln!(file, "{json}")?;
57 file.flush()?;
58
59 Ok(())
60}
61
62pub fn audit_log_path(ito_path: &Path) -> PathBuf {
64 ito_path.join(".state").join("audit").join("events.jsonl")
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70 use ito_domain::audit::event::{EventContext, SCHEMA_VERSION};
71
72 fn test_event(entity_id: &str) -> AuditEvent {
73 AuditEvent {
74 v: SCHEMA_VERSION,
75 ts: "2026-02-08T14:30:00.000Z".to_string(),
76 entity: "task".to_string(),
77 entity_id: entity_id.to_string(),
78 scope: Some("test-change".to_string()),
79 op: "create".to_string(),
80 from: None,
81 to: Some("pending".to_string()),
82 actor: "cli".to_string(),
83 by: "@test".to_string(),
84 meta: None,
85 ctx: EventContext {
86 session_id: "test-sid".to_string(),
87 harness_session_id: None,
88 branch: None,
89 worktree: None,
90 commit: None,
91 },
92 }
93 }
94
95 #[test]
96 fn creates_directory_and_file_on_first_write() {
97 let tmp = tempfile::tempdir().expect("tempdir");
98 let ito_path = tmp.path().join(".ito");
99
100 let writer = FsAuditWriter::new(&ito_path);
101 writer.append(&test_event("1.1")).expect("append");
102
103 assert!(writer.log_path().exists());
104 }
105
106 #[test]
107 fn appends_events_to_existing_file() {
108 let tmp = tempfile::tempdir().expect("tempdir");
109 let ito_path = tmp.path().join(".ito");
110
111 let writer = FsAuditWriter::new(&ito_path);
112 writer.append(&test_event("1.1")).expect("first append");
113 writer.append(&test_event("1.2")).expect("second append");
114
115 let contents = std::fs::read_to_string(writer.log_path()).expect("read");
116 let lines: Vec<&str> = contents.lines().collect();
117 assert_eq!(lines.len(), 2);
118 }
119
120 #[test]
121 fn preserves_existing_content() {
122 let tmp = tempfile::tempdir().expect("tempdir");
123 let ito_path = tmp.path().join(".ito");
124
125 let writer = FsAuditWriter::new(&ito_path);
126 writer.append(&test_event("1.1")).expect("first append");
127
128 let first_line = std::fs::read_to_string(writer.log_path()).expect("read");
129
130 writer.append(&test_event("1.2")).expect("second append");
131
132 let contents = std::fs::read_to_string(writer.log_path()).expect("read");
133 assert!(contents.starts_with(first_line.trim()));
134 }
135
136 #[test]
137 fn events_deserialize_back_correctly() {
138 let tmp = tempfile::tempdir().expect("tempdir");
139 let ito_path = tmp.path().join(".ito");
140
141 let event = test_event("1.1");
142 let writer = FsAuditWriter::new(&ito_path);
143 writer.append(&event).expect("append");
144
145 let contents = std::fs::read_to_string(writer.log_path()).expect("read");
146 let parsed: AuditEvent =
147 serde_json::from_str(contents.lines().next().expect("line")).expect("parse");
148 assert_eq!(parsed, event);
149 }
150
151 #[test]
152 fn best_effort_returns_ok_even_on_failure() {
153 let tmp = tempfile::tempdir().expect("tempdir");
155 let file_path = tmp.path().join("not_a_dir");
156 std::fs::write(&file_path, "block").expect("write blocker");
157
158 let writer = FsAuditWriter {
159 log_path: file_path.join("subdir").join("events.jsonl"),
160 };
161 let result = writer.append(&test_event("1.1"));
163 assert!(result.is_ok());
164 }
165
166 #[test]
167 fn audit_log_path_resolves_correctly() {
168 let path = audit_log_path(Path::new("/project/.ito"));
169 assert_eq!(
170 path,
171 PathBuf::from("/project/.ito/.state/audit/events.jsonl")
172 );
173 }
174
175 #[test]
176 fn each_line_is_valid_json() {
177 let tmp = tempfile::tempdir().expect("tempdir");
178 let ito_path = tmp.path().join(".ito");
179
180 let writer = FsAuditWriter::new(&ito_path);
181 for i in 0..5 {
182 writer
183 .append(&test_event(&format!("1.{i}")))
184 .expect("append");
185 }
186
187 let contents = std::fs::read_to_string(writer.log_path()).expect("read");
188 for line in contents.lines() {
189 let _: AuditEvent = serde_json::from_str(line).expect("valid JSON");
190 }
191 }
192}