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};
9
10use ito_domain::audit::event::AuditEvent;
11use ito_domain::audit::writer::AuditWriter;
12
13/// Filesystem-backed implementation of `AuditWriter`.
14///
15/// Appends events to `{ito_path}/.state/audit/events.jsonl` in JSONL format.
16pub struct FsAuditWriter {
17    log_path: PathBuf,
18}
19
20impl FsAuditWriter {
21    /// Create a new writer for the given Ito project path.
22    pub fn new(ito_path: &Path) -> Self {
23        let log_path = audit_log_path(ito_path);
24        Self { log_path }
25    }
26
27    /// Return the path to the audit log file.
28    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        // Best-effort: serialize, create dirs, append, flush.
36        // On any failure, log a warning and return Ok.
37        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
44/// Append a single event to the JSONL file at `path`.
45fn append_event_to_file(path: &Path, event: &AuditEvent) -> std::io::Result<()> {
46    // Create parent directories if needed
47    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
62/// Returns the canonical path for the audit log file.
63pub 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        // Write to an invalid path (nested under a file, not a directory)
154        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        // Should not panic and should return Ok
162        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}