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