envelope_cli/audit/
entry.rs

1//! Audit entry data structures
2//!
3//! Defines the structure of audit log entries including operation types,
4//! entity types, and the entry format itself.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Types of operations that can be audited
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum Operation {
13    /// Entity was created
14    Create,
15    /// Entity was updated
16    Update,
17    /// Entity was deleted
18    Delete,
19}
20
21impl std::fmt::Display for Operation {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            Operation::Create => write!(f, "CREATE"),
25            Operation::Update => write!(f, "UPDATE"),
26            Operation::Delete => write!(f, "DELETE"),
27        }
28    }
29}
30
31/// Types of entities that can be audited
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum EntityType {
35    Account,
36    Transaction,
37    Category,
38    CategoryGroup,
39    BudgetAllocation,
40    BudgetTarget,
41    Payee,
42}
43
44impl std::fmt::Display for EntityType {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            EntityType::Account => write!(f, "Account"),
48            EntityType::Transaction => write!(f, "Transaction"),
49            EntityType::Category => write!(f, "Category"),
50            EntityType::CategoryGroup => write!(f, "CategoryGroup"),
51            EntityType::BudgetAllocation => write!(f, "BudgetAllocation"),
52            EntityType::BudgetTarget => write!(f, "BudgetTarget"),
53            EntityType::Payee => write!(f, "Payee"),
54        }
55    }
56}
57
58/// A single audit log entry
59///
60/// Records a single operation on an entity with optional before/after values
61/// for tracking changes.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AuditEntry {
64    /// When the operation occurred (UTC)
65    pub timestamp: DateTime<Utc>,
66
67    /// Type of operation performed
68    pub operation: Operation,
69
70    /// Type of entity affected
71    pub entity_type: EntityType,
72
73    /// ID of the affected entity
74    pub entity_id: String,
75
76    /// Human-readable description of the entity (e.g., account name)
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub entity_name: Option<String>,
79
80    /// JSON representation of the entity before the operation (for updates/deletes)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub before: Option<serde_json::Value>,
83
84    /// JSON representation of the entity after the operation (for creates/updates)
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub after: Option<serde_json::Value>,
87
88    /// Human-readable diff summary
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub diff_summary: Option<String>,
91}
92
93impl AuditEntry {
94    /// Create a new audit entry for a create operation
95    pub fn create<T: Serialize>(
96        entity_type: EntityType,
97        entity_id: impl Into<String>,
98        entity_name: Option<String>,
99        entity: &T,
100    ) -> Self {
101        Self {
102            timestamp: Utc::now(),
103            operation: Operation::Create,
104            entity_type,
105            entity_id: entity_id.into(),
106            entity_name,
107            before: None,
108            after: serde_json::to_value(entity).ok(),
109            diff_summary: None,
110        }
111    }
112
113    /// Create a new audit entry for an update operation
114    pub fn update<T: Serialize>(
115        entity_type: EntityType,
116        entity_id: impl Into<String>,
117        entity_name: Option<String>,
118        before: &T,
119        after: &T,
120        diff_summary: Option<String>,
121    ) -> Self {
122        Self {
123            timestamp: Utc::now(),
124            operation: Operation::Update,
125            entity_type,
126            entity_id: entity_id.into(),
127            entity_name,
128            before: serde_json::to_value(before).ok(),
129            after: serde_json::to_value(after).ok(),
130            diff_summary,
131        }
132    }
133
134    /// Create a new audit entry for a delete operation
135    pub fn delete<T: Serialize>(
136        entity_type: EntityType,
137        entity_id: impl Into<String>,
138        entity_name: Option<String>,
139        entity: &T,
140    ) -> Self {
141        Self {
142            timestamp: Utc::now(),
143            operation: Operation::Delete,
144            entity_type,
145            entity_id: entity_id.into(),
146            entity_name,
147            before: serde_json::to_value(entity).ok(),
148            after: None,
149            diff_summary: None,
150        }
151    }
152
153    /// Format the entry for human-readable output
154    pub fn format_human_readable(&self) -> String {
155        let mut output = format!(
156            "[{}] {} {} {}",
157            self.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
158            self.operation,
159            self.entity_type,
160            self.entity_id
161        );
162
163        if let Some(name) = &self.entity_name {
164            output.push_str(&format!(" ({})", name));
165        }
166
167        if let Some(diff) = &self.diff_summary {
168            output.push_str(&format!("\n  Changes: {}", diff));
169        }
170
171        output
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use serde_json::json;
179
180    #[test]
181    fn test_operation_display() {
182        assert_eq!(Operation::Create.to_string(), "CREATE");
183        assert_eq!(Operation::Update.to_string(), "UPDATE");
184        assert_eq!(Operation::Delete.to_string(), "DELETE");
185    }
186
187    #[test]
188    fn test_entity_type_display() {
189        assert_eq!(EntityType::Account.to_string(), "Account");
190        assert_eq!(EntityType::Transaction.to_string(), "Transaction");
191    }
192
193    #[test]
194    fn test_create_entry() {
195        let data = json!({"name": "Checking", "balance": 1000});
196        let entry = AuditEntry::create(
197            EntityType::Account,
198            "acc-12345678",
199            Some("Checking".to_string()),
200            &data,
201        );
202
203        assert_eq!(entry.operation, Operation::Create);
204        assert_eq!(entry.entity_type, EntityType::Account);
205        assert_eq!(entry.entity_id, "acc-12345678");
206        assert!(entry.before.is_none());
207        assert!(entry.after.is_some());
208    }
209
210    #[test]
211    fn test_update_entry() {
212        let before = json!({"name": "Checking", "balance": 1000});
213        let after = json!({"name": "Checking", "balance": 1500});
214
215        let entry = AuditEntry::update(
216            EntityType::Account,
217            "acc-12345678",
218            Some("Checking".to_string()),
219            &before,
220            &after,
221            Some("balance: 1000 -> 1500".to_string()),
222        );
223
224        assert_eq!(entry.operation, Operation::Update);
225        assert!(entry.before.is_some());
226        assert!(entry.after.is_some());
227        assert_eq!(
228            entry.diff_summary,
229            Some("balance: 1000 -> 1500".to_string())
230        );
231    }
232
233    #[test]
234    fn test_delete_entry() {
235        let data = json!({"name": "Old Account"});
236        let entry = AuditEntry::delete(
237            EntityType::Account,
238            "acc-12345678",
239            Some("Old Account".to_string()),
240            &data,
241        );
242
243        assert_eq!(entry.operation, Operation::Delete);
244        assert!(entry.before.is_some());
245        assert!(entry.after.is_none());
246    }
247
248    #[test]
249    fn test_serialization() {
250        let data = json!({"name": "Test"});
251        let entry = AuditEntry::create(EntityType::Account, "acc-123", None, &data);
252
253        let json = serde_json::to_string(&entry).unwrap();
254        let deserialized: AuditEntry = serde_json::from_str(&json).unwrap();
255
256        assert_eq!(deserialized.operation, Operation::Create);
257        assert_eq!(deserialized.entity_type, EntityType::Account);
258    }
259
260    #[test]
261    fn test_human_readable_format() {
262        let data = json!({"name": "Checking"});
263        let entry = AuditEntry::create(
264            EntityType::Account,
265            "acc-12345678",
266            Some("Checking".to_string()),
267            &data,
268        );
269
270        let formatted = entry.format_human_readable();
271        assert!(formatted.contains("CREATE"));
272        assert!(formatted.contains("Account"));
273        assert!(formatted.contains("acc-12345678"));
274        assert!(formatted.contains("Checking"));
275    }
276}