Skip to main content

ito_core/audit/
writer.rs

1//! Filesystem-backed audit log writer.
2//!
3//! Appends events as single-line JSON to `.ito/.state/audit/events.jsonl`.
4//! All writes are best-effort: failures are logged but never block the caller.
5
6use 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
15/// Filesystem-backed implementation of `AuditWriter`.
16///
17/// Appends events to `{ito_path}/.state/audit/events.jsonl` in JSONL format.
18pub struct FsAuditWriter {
19    log_path: PathBuf,
20    ito_path: PathBuf,
21    mirror_settings: OnceLock<(bool, String)>,
22}
23
24impl FsAuditWriter {
25    /// Create a new writer for the given Ito project path.
26    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    /// Return the path to the audit log file.
36    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        // Best-effort: serialize, create dirs, append, flush.
57        // On any failure, log a warning and return Ok.
58        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
79/// Append a single event to the JSONL file at `path`.
80fn append_event_to_file(path: &Path, event: &AuditEvent) -> std::io::Result<()> {
81    // Create parent directories if needed
82    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
97/// Returns the canonical path for the audit log file.
98pub 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        // Write to an invalid path (nested under a file, not a directory)
189        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        // Should not panic and should return Ok
199        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}