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(&RuleId("r1".into()), "test", "msg", &[], true, None);
86        let after = Utc::now();
87
88        assert!(entry.timestamp >= before);
89        assert!(entry.timestamp <= after);
90    }
91
92    #[test]
93    fn entry_roundtrips_through_json() {
94        let entry = RuleExecutionLog::entry(
95            &RuleId("r1".into()),
96            "test rule",
97            "msg_1",
98            &["archive".into(), "mark_read".into()],
99            true,
100            None,
101        );
102
103        let json = serde_json::to_string(&entry).unwrap();
104        let parsed: RuleMatchEntry = serde_json::from_str(&json).unwrap();
105        assert_eq!(parsed.rule_id, entry.rule_id);
106        assert_eq!(parsed.actions_applied.len(), 2);
107    }
108}