use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Operation {
Create,
Update,
Delete,
}
impl std::fmt::Display for Operation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Operation::Create => write!(f, "CREATE"),
Operation::Update => write!(f, "UPDATE"),
Operation::Delete => write!(f, "DELETE"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EntityType {
Account,
Transaction,
Category,
CategoryGroup,
BudgetAllocation,
BudgetTarget,
Payee,
IncomeExpectation,
}
impl std::fmt::Display for EntityType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EntityType::Account => write!(f, "Account"),
EntityType::Transaction => write!(f, "Transaction"),
EntityType::Category => write!(f, "Category"),
EntityType::CategoryGroup => write!(f, "CategoryGroup"),
EntityType::BudgetAllocation => write!(f, "BudgetAllocation"),
EntityType::BudgetTarget => write!(f, "BudgetTarget"),
EntityType::Payee => write!(f, "Payee"),
EntityType::IncomeExpectation => write!(f, "IncomeExpectation"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: DateTime<Utc>,
pub operation: Operation,
pub entity_type: EntityType,
pub entity_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub entity_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub diff_summary: Option<String>,
}
impl AuditEntry {
pub fn create<T: Serialize>(
entity_type: EntityType,
entity_id: impl Into<String>,
entity_name: Option<String>,
entity: &T,
) -> Self {
Self {
timestamp: Utc::now(),
operation: Operation::Create,
entity_type,
entity_id: entity_id.into(),
entity_name,
before: None,
after: serde_json::to_value(entity).ok(),
diff_summary: None,
}
}
pub fn update<T: Serialize>(
entity_type: EntityType,
entity_id: impl Into<String>,
entity_name: Option<String>,
before: &T,
after: &T,
diff_summary: Option<String>,
) -> Self {
Self {
timestamp: Utc::now(),
operation: Operation::Update,
entity_type,
entity_id: entity_id.into(),
entity_name,
before: serde_json::to_value(before).ok(),
after: serde_json::to_value(after).ok(),
diff_summary,
}
}
pub fn delete<T: Serialize>(
entity_type: EntityType,
entity_id: impl Into<String>,
entity_name: Option<String>,
entity: &T,
) -> Self {
Self {
timestamp: Utc::now(),
operation: Operation::Delete,
entity_type,
entity_id: entity_id.into(),
entity_name,
before: serde_json::to_value(entity).ok(),
after: None,
diff_summary: None,
}
}
pub fn format_human_readable(&self) -> String {
let mut output = format!(
"[{}] {} {} {}",
self.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
self.operation,
self.entity_type,
self.entity_id
);
if let Some(name) = &self.entity_name {
output.push_str(&format!(" ({})", name));
}
if let Some(diff) = &self.diff_summary {
output.push_str(&format!("\n Changes: {}", diff));
}
output
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_operation_display() {
assert_eq!(Operation::Create.to_string(), "CREATE");
assert_eq!(Operation::Update.to_string(), "UPDATE");
assert_eq!(Operation::Delete.to_string(), "DELETE");
}
#[test]
fn test_entity_type_display() {
assert_eq!(EntityType::Account.to_string(), "Account");
assert_eq!(EntityType::Transaction.to_string(), "Transaction");
}
#[test]
fn test_create_entry() {
let data = json!({"name": "Checking", "balance": 1000});
let entry = AuditEntry::create(
EntityType::Account,
"acc-12345678",
Some("Checking".to_string()),
&data,
);
assert_eq!(entry.operation, Operation::Create);
assert_eq!(entry.entity_type, EntityType::Account);
assert_eq!(entry.entity_id, "acc-12345678");
assert!(entry.before.is_none());
assert!(entry.after.is_some());
}
#[test]
fn test_update_entry() {
let before = json!({"name": "Checking", "balance": 1000});
let after = json!({"name": "Checking", "balance": 1500});
let entry = AuditEntry::update(
EntityType::Account,
"acc-12345678",
Some("Checking".to_string()),
&before,
&after,
Some("balance: 1000 -> 1500".to_string()),
);
assert_eq!(entry.operation, Operation::Update);
assert!(entry.before.is_some());
assert!(entry.after.is_some());
assert_eq!(
entry.diff_summary,
Some("balance: 1000 -> 1500".to_string())
);
}
#[test]
fn test_delete_entry() {
let data = json!({"name": "Old Account"});
let entry = AuditEntry::delete(
EntityType::Account,
"acc-12345678",
Some("Old Account".to_string()),
&data,
);
assert_eq!(entry.operation, Operation::Delete);
assert!(entry.before.is_some());
assert!(entry.after.is_none());
}
#[test]
fn test_serialization() {
let data = json!({"name": "Test"});
let entry = AuditEntry::create(EntityType::Account, "acc-123", None, &data);
let json = serde_json::to_string(&entry).unwrap();
let deserialized: AuditEntry = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.operation, Operation::Create);
assert_eq!(deserialized.entity_type, EntityType::Account);
}
#[test]
fn test_human_readable_format() {
let data = json!({"name": "Checking"});
let entry = AuditEntry::create(
EntityType::Account,
"acc-12345678",
Some("Checking".to_string()),
&data,
);
let formatted = entry.format_human_readable();
assert!(formatted.contains("CREATE"));
assert!(formatted.contains("Account"));
assert!(formatted.contains("acc-12345678"));
assert!(formatted.contains("Checking"));
}
}