Skip to main content

llm_diff/
audit.rs

1// SPDX-License-Identifier: MIT
2//! Append-only audit log for compliance and legal review of output changes.
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7/// A single audit event recorded in the log.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub enum AuditEvent {
10    /// A new version was created and stored.
11    VersionCreated { version_id: String, model: String, timestamp: DateTime<Utc> },
12    /// A branch head was updated.
13    BranchCreated { branch: String, head_version_id: String, timestamp: DateTime<Utc> },
14    /// The store was rolled back from one version to another.
15    Rollback { from_version_id: String, to_version_id: String, timestamp: DateTime<Utc> },
16    /// A diff was computed between two versions.
17    DiffComputed { from_id: String, to_id: String, similarity: f64, timestamp: DateTime<Utc> },
18}
19
20impl AuditEvent {
21    /// Returns a human-readable kind label for the event.
22    pub fn kind(&self) -> &'static str {
23        match self {
24            AuditEvent::VersionCreated { .. } => "VersionCreated",
25            AuditEvent::BranchCreated { .. } => "BranchCreated",
26            AuditEvent::Rollback { .. } => "Rollback",
27            AuditEvent::DiffComputed { .. } => "DiffComputed",
28        }
29    }
30
31    /// Returns the UTC timestamp of the event.
32    pub fn timestamp(&self) -> DateTime<Utc> {
33        match self {
34            AuditEvent::VersionCreated { timestamp, .. }
35            | AuditEvent::BranchCreated { timestamp, .. }
36            | AuditEvent::Rollback { timestamp, .. }
37            | AuditEvent::DiffComputed { timestamp, .. } => *timestamp,
38        }
39    }
40}
41
42/// An append-only audit log. Events are never removed once recorded.
43pub struct AuditLog {
44    events: Vec<AuditEvent>,
45}
46
47impl AuditLog {
48    /// Creates a new, empty audit log.
49    pub fn new() -> Self { Self { events: Vec::new() } }
50
51    /// Appends an event to the log.
52    pub fn record(&mut self, event: AuditEvent) { self.events.push(event); }
53
54    /// Returns all recorded events in insertion order.
55    pub fn events(&self) -> &[AuditEvent] { &self.events }
56
57    /// Returns the number of recorded events.
58    pub fn len(&self) -> usize { self.events.len() }
59
60    /// Returns `true` if no events have been recorded.
61    pub fn is_empty(&self) -> bool { self.events.is_empty() }
62
63    /// Serializes the entire log to a JSON string.
64    ///
65    /// # Errors
66    /// Returns a [`serde_json::Error`] if serialization fails.
67    pub fn to_json(&self) -> Result<String, serde_json::Error> {
68        serde_json::to_string(&self.events)
69    }
70
71    /// Returns all events whose kind label matches `kind`.
72    pub fn events_of_kind(&self, kind: &str) -> Vec<&AuditEvent> {
73        self.events.iter().filter(|e| e.kind() == kind).collect()
74    }
75}
76
77impl Default for AuditLog {
78    fn default() -> Self { Self::new() }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn test_audit_log_record_increments_len() {
87        let mut log = AuditLog::new();
88        log.record(AuditEvent::VersionCreated {
89            version_id: "v1".into(),
90            model: "claude".into(),
91            timestamp: Utc::now(),
92        });
93        assert_eq!(log.len(), 1);
94    }
95
96    #[test]
97    fn test_audit_log_is_empty_initially() {
98        let log = AuditLog::new();
99        assert!(log.is_empty());
100    }
101
102    #[test]
103    fn test_audit_log_filter_by_kind_version_created() {
104        let mut log = AuditLog::new();
105        log.record(AuditEvent::VersionCreated {
106            version_id: "v1".into(),
107            model: "m".into(),
108            timestamp: Utc::now(),
109        });
110        log.record(AuditEvent::Rollback {
111            from_version_id: "v2".into(),
112            to_version_id: "v1".into(),
113            timestamp: Utc::now(),
114        });
115        assert_eq!(log.events_of_kind("VersionCreated").len(), 1);
116        assert_eq!(log.events_of_kind("Rollback").len(), 1);
117    }
118
119    #[test]
120    fn test_audit_log_to_json_contains_event_kind() {
121        let mut log = AuditLog::new();
122        log.record(AuditEvent::DiffComputed {
123            from_id: "a".into(),
124            to_id: "b".into(),
125            similarity: 0.8,
126            timestamp: Utc::now(),
127        });
128        let json = log.to_json().unwrap();
129        assert!(json.contains("DiffComputed"));
130    }
131
132    #[test]
133    fn test_audit_log_grows_monotonically() {
134        let mut log = AuditLog::new();
135        for i in 0..5u32 {
136            log.record(AuditEvent::VersionCreated {
137                version_id: format!("v{i}"),
138                model: "m".into(),
139                timestamp: Utc::now(),
140            });
141            assert_eq!(log.len(), (i + 1) as usize);
142        }
143    }
144
145    #[test]
146    fn test_audit_event_kind_labels_correct() {
147        assert_eq!(AuditEvent::VersionCreated { version_id: "".into(), model: "".into(), timestamp: Utc::now() }.kind(), "VersionCreated");
148        assert_eq!(AuditEvent::BranchCreated { branch: "".into(), head_version_id: "".into(), timestamp: Utc::now() }.kind(), "BranchCreated");
149        assert_eq!(AuditEvent::Rollback { from_version_id: "".into(), to_version_id: "".into(), timestamp: Utc::now() }.kind(), "Rollback");
150        assert_eq!(AuditEvent::DiffComputed { from_id: "".into(), to_id: "".into(), similarity: 0.0, timestamp: Utc::now() }.kind(), "DiffComputed");
151    }
152
153    #[test]
154    fn test_audit_log_events_of_kind_empty_when_no_match() {
155        let mut log = AuditLog::new();
156        log.record(AuditEvent::VersionCreated { version_id: "v1".into(), model: "m".into(), timestamp: Utc::now() });
157        assert!(log.events_of_kind("Rollback").is_empty());
158    }
159}