Skip to main content

mxr_rules/
history.rs

1use crate::RuleId;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4
5/// A log entry for a rule execution.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct RuleMatchEntry {
8    pub rule_id: RuleId,
9    pub rule_name: String,
10    pub message_id: String,
11    pub actions_applied: Vec<String>,
12    pub timestamp: DateTime<Utc>,
13    pub success: bool,
14    pub error: Option<String>,
15}
16
17/// Factory for rule execution log entries.
18/// Stored in SQLite for auditability ("why was this archived?").
19pub struct RuleExecutionLog;
20
21impl RuleExecutionLog {
22    /// Create a log entry for a rule match.
23    /// Caller persists this via the store.
24    pub fn entry(
25        rule_id: &RuleId,
26        rule_name: &str,
27        message_id: &str,
28        actions: &[String],
29        success: bool,
30        error: Option<&str>,
31    ) -> RuleMatchEntry {
32        RuleMatchEntry {
33            rule_id: rule_id.clone(),
34            rule_name: rule_name.to_string(),
35            message_id: message_id.to_string(),
36            actions_applied: actions.to_vec(),
37            timestamp: Utc::now(),
38            success,
39            error: error.map(String::from),
40        }
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    #[test]
49    fn entry_captures_success() {
50        let entry = RuleExecutionLog::entry(
51            &RuleId("r1".into()),
52            "Archive newsletters",
53            "msg_123",
54            &["archive".into()],
55            true,
56            None,
57        );
58
59        assert_eq!(entry.rule_id, RuleId("r1".into()));
60        assert_eq!(entry.rule_name, "Archive newsletters");
61        assert_eq!(entry.message_id, "msg_123");
62        assert!(entry.success);
63        assert!(entry.error.is_none());
64        assert_eq!(entry.actions_applied, vec!["archive"]);
65    }
66
67    #[test]
68    fn entry_captures_failure() {
69        let entry = RuleExecutionLog::entry(
70            &RuleId("r1".into()),
71            "Shell hook",
72            "msg_456",
73            &["shell_hook".into()],
74            false,
75            Some("command not found"),
76        );
77
78        assert!(!entry.success);
79        assert_eq!(entry.error.as_deref(), Some("command not found"));
80    }
81
82    #[test]
83    fn entry_timestamp_is_recent() {
84        let before = Utc::now();
85        let entry = RuleExecutionLog::entry(
86            &RuleId("r1".into()),
87            "test",
88            "msg",
89            &[],
90            true,
91            None,
92        );
93        let after = Utc::now();
94
95        assert!(entry.timestamp >= before);
96        assert!(entry.timestamp <= after);
97    }
98
99    #[test]
100    fn entry_roundtrips_through_json() {
101        let entry = RuleExecutionLog::entry(
102            &RuleId("r1".into()),
103            "test rule",
104            "msg_1",
105            &["archive".into(), "mark_read".into()],
106            true,
107            None,
108        );
109
110        let json = serde_json::to_string(&entry).unwrap();
111        let parsed: RuleMatchEntry = serde_json::from_str(&json).unwrap();
112        assert_eq!(parsed.rule_id, entry.rule_id);
113        assert_eq!(parsed.actions_applied.len(), 2);
114    }
115}