1use std::fs::OpenOptions;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::sync::OnceLock;
10
11use ito_config::{ConfigContext, load_cascading_project_config, resolve_audit_mirror_settings};
12use ito_domain::audit::event::AuditEvent;
13use ito_domain::audit::writer::AuditWriter;
14
15pub struct FsAuditWriter {
19 log_path: PathBuf,
20 ito_path: PathBuf,
21 mirror_settings: OnceLock<(bool, String)>,
22}
23
24impl FsAuditWriter {
25 pub fn new(ito_path: &Path) -> Self {
27 let log_path = audit_log_path(ito_path);
28 Self {
29 log_path,
30 ito_path: ito_path.to_path_buf(),
31 mirror_settings: OnceLock::new(),
32 }
33 }
34
35 pub fn log_path(&self) -> &Path {
37 &self.log_path
38 }
39
40 fn resolve_mirror_settings(&self) -> (bool, String) {
41 self.mirror_settings
42 .get_or_init(|| {
43 let Some(project_root) = self.ito_path.parent() else {
44 return (false, String::new());
45 };
46 let ctx = ConfigContext::from_process_env();
47 let resolved = load_cascading_project_config(project_root, &self.ito_path, &ctx);
48 resolve_audit_mirror_settings(&resolved.merged)
49 })
50 .clone()
51 }
52}
53
54impl AuditWriter for FsAuditWriter {
55 fn append(&self, event: &AuditEvent) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
56 if let Err(e) = append_event_to_file(&self.log_path, event) {
59 tracing::warn!("audit log write failed: {e}");
60 return Ok(());
61 }
62
63 let (enabled, branch) = self.resolve_mirror_settings();
64 if enabled {
65 let Some(repo_root) = self.ito_path.parent() else {
66 return Ok(());
67 };
68 if let Err(err) = super::mirror::sync_audit_mirror(repo_root, &self.ito_path, &branch) {
69 eprintln!(
70 "Warning: audit mirror sync failed (branch '{}'): {err}",
71 branch
72 );
73 }
74 }
75 Ok(())
76 }
77}
78
79fn append_event_to_file(path: &Path, event: &AuditEvent) -> std::io::Result<()> {
81 if let Some(parent) = path.parent() {
83 std::fs::create_dir_all(parent)?;
84 }
85
86 let json = serde_json::to_string(event)
87 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
88
89 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
90
91 writeln!(file, "{json}")?;
92 file.flush()?;
93
94 Ok(())
95}
96
97pub fn audit_log_path(ito_path: &Path) -> PathBuf {
99 ito_path.join(".state").join("audit").join("events.jsonl")
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use ito_domain::audit::event::{EventContext, SCHEMA_VERSION};
106
107 fn test_event(entity_id: &str) -> AuditEvent {
108 AuditEvent {
109 v: SCHEMA_VERSION,
110 ts: "2026-02-08T14:30:00.000Z".to_string(),
111 entity: "task".to_string(),
112 entity_id: entity_id.to_string(),
113 scope: Some("test-change".to_string()),
114 op: "create".to_string(),
115 from: None,
116 to: Some("pending".to_string()),
117 actor: "cli".to_string(),
118 by: "@test".to_string(),
119 meta: None,
120 ctx: EventContext {
121 session_id: "test-sid".to_string(),
122 harness_session_id: None,
123 branch: None,
124 worktree: None,
125 commit: None,
126 },
127 }
128 }
129
130 #[test]
131 fn creates_directory_and_file_on_first_write() {
132 let tmp = tempfile::tempdir().expect("tempdir");
133 let ito_path = tmp.path().join(".ito");
134
135 let writer = FsAuditWriter::new(&ito_path);
136 writer.append(&test_event("1.1")).expect("append");
137
138 assert!(writer.log_path().exists());
139 }
140
141 #[test]
142 fn appends_events_to_existing_file() {
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.append(&test_event("1.1")).expect("first append");
148 writer.append(&test_event("1.2")).expect("second append");
149
150 let contents = std::fs::read_to_string(writer.log_path()).expect("read");
151 let lines: Vec<&str> = contents.lines().collect();
152 assert_eq!(lines.len(), 2);
153 }
154
155 #[test]
156 fn preserves_existing_content() {
157 let tmp = tempfile::tempdir().expect("tempdir");
158 let ito_path = tmp.path().join(".ito");
159
160 let writer = FsAuditWriter::new(&ito_path);
161 writer.append(&test_event("1.1")).expect("first append");
162
163 let first_line = std::fs::read_to_string(writer.log_path()).expect("read");
164
165 writer.append(&test_event("1.2")).expect("second append");
166
167 let contents = std::fs::read_to_string(writer.log_path()).expect("read");
168 assert!(contents.starts_with(first_line.trim()));
169 }
170
171 #[test]
172 fn events_deserialize_back_correctly() {
173 let tmp = tempfile::tempdir().expect("tempdir");
174 let ito_path = tmp.path().join(".ito");
175
176 let event = test_event("1.1");
177 let writer = FsAuditWriter::new(&ito_path);
178 writer.append(&event).expect("append");
179
180 let contents = std::fs::read_to_string(writer.log_path()).expect("read");
181 let parsed: AuditEvent =
182 serde_json::from_str(contents.lines().next().expect("line")).expect("parse");
183 assert_eq!(parsed, event);
184 }
185
186 #[test]
187 fn best_effort_returns_ok_even_on_failure() {
188 let tmp = tempfile::tempdir().expect("tempdir");
190 let file_path = tmp.path().join("not_a_dir");
191 std::fs::write(&file_path, "block").expect("write blocker");
192
193 let writer = FsAuditWriter {
194 log_path: file_path.join("subdir").join("events.jsonl"),
195 ito_path: PathBuf::from("/project/.ito"),
196 mirror_settings: OnceLock::new(),
197 };
198 let result = writer.append(&test_event("1.1"));
200 assert!(result.is_ok());
201 }
202
203 #[test]
204 fn audit_log_path_resolves_correctly() {
205 let path = audit_log_path(Path::new("/project/.ito"));
206 assert_eq!(
207 path,
208 PathBuf::from("/project/.ito/.state/audit/events.jsonl")
209 );
210 }
211
212 #[test]
213 fn each_line_is_valid_json() {
214 let tmp = tempfile::tempdir().expect("tempdir");
215 let ito_path = tmp.path().join(".ito");
216
217 let writer = FsAuditWriter::new(&ito_path);
218 for i in 0..5 {
219 writer
220 .append(&test_event(&format!("1.{i}")))
221 .expect("append");
222 }
223
224 let contents = std::fs::read_to_string(writer.log_path()).expect("read");
225 for line in contents.lines() {
226 let _: AuditEvent = serde_json::from_str(line).expect("valid JSON");
227 }
228 }
229}