Skip to main content

mur_common/
actor.rs

1use serde::{Deserialize, Serialize};
2
3/// The platform/tool that produced a signal or observation.
4///
5/// Kept intentionally narrow — new sources should be added here rather than
6/// passed as freeform strings, so the set of valid origins is a compile-time concern.
7#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ActorSource {
10    ClaudeCode,
11    Cursor,
12    Aider,
13    Slack,
14    Telegram,
15    Discord,
16    CommanderDaemon,
17    MurCli,
18}
19
20impl ActorSource {
21    /// Stable string identifier used by [`Actor::key`] — persisted in YAML
22    /// dedupe keys (`Evidence.contributions`). Explicit to decouple the wire
23    /// format from `#[derive(Debug)]`, whose output is **not** guaranteed stable
24    /// across compiler releases or refactors.
25    pub fn as_str(&self) -> &'static str {
26        match self {
27            Self::ClaudeCode => "ClaudeCode",
28            Self::Cursor => "Cursor",
29            Self::Aider => "Aider",
30            Self::Slack => "Slack",
31            Self::Telegram => "Telegram",
32            Self::Discord => "Discord",
33            Self::CommanderDaemon => "CommanderDaemon",
34            Self::MurCli => "MurCli",
35        }
36    }
37}
38
39/// Provenance for a signal or pattern — records WHO produced it, without
40/// trying to resolve identity to a canonical user.
41///
42/// - `source + native_id` is the authoritative tuple (e.g. `Slack + U123ABC`).
43/// - `display_name` is a non-authoritative hint for humans reading YAML.
44/// - `resolved_user_id` is filled by mur-server after lookup, stays `None` on the client.
45#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub struct Actor {
47    pub source: ActorSource,
48    pub native_id: String,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub display_name: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub resolved_user_id: Option<String>,
53}
54
55impl Actor {
56    /// Stable dedupe key used as HashMap key in `Evidence.contributions`.
57    /// Format: `"{ActorSource}:{native_id}"` (e.g. `"Slack:U123ABC"`).
58    /// Backed by [`ActorSource::as_str`] (not `Debug`) so the wire format is
59    /// safe across compiler refactors.
60    pub fn key(&self) -> String {
61        format!("{}:{}", self.source.as_str(), self.native_id)
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn key_format_slack() {
71        let a = Actor {
72            source: ActorSource::Slack,
73            native_id: "U123ABC".into(),
74            display_name: Some("alice".into()),
75            resolved_user_id: None,
76        };
77        assert_eq!(a.key(), "Slack:U123ABC");
78    }
79
80    #[test]
81    fn key_format_commander_daemon() {
82        let a = Actor {
83            source: ActorSource::CommanderDaemon,
84            native_id: "svc-1".into(),
85            display_name: None,
86            resolved_user_id: None,
87        };
88        assert_eq!(a.key(), "CommanderDaemon:svc-1");
89    }
90
91    #[test]
92    fn yaml_roundtrip_minimal() {
93        let a = Actor {
94            source: ActorSource::CommanderDaemon,
95            native_id: "svc-1".into(),
96            display_name: None,
97            resolved_user_id: None,
98        };
99        let y = serde_yaml::to_string(&a).unwrap();
100        let back: Actor = serde_yaml::from_str(&y).unwrap();
101        assert_eq!(back, a);
102    }
103
104    #[test]
105    fn yaml_roundtrip_full() {
106        let a = Actor {
107            source: ActorSource::Slack,
108            native_id: "U999".into(),
109            display_name: Some("bob".into()),
110            resolved_user_id: Some("user-uuid-123".into()),
111        };
112        let y = serde_yaml::to_string(&a).unwrap();
113        let back: Actor = serde_yaml::from_str(&y).unwrap();
114        assert_eq!(back, a);
115    }
116
117    #[test]
118    fn yaml_omits_none_fields() {
119        let a = Actor {
120            source: ActorSource::MurCli,
121            native_id: "local".into(),
122            display_name: None,
123            resolved_user_id: None,
124        };
125        let y = serde_yaml::to_string(&a).unwrap();
126        assert!(!y.contains("display_name"));
127        assert!(!y.contains("resolved_user_id"));
128    }
129
130    #[test]
131    fn as_str_covers_all_variants() {
132        // Guard against silent wire-format drift: every ActorSource variant
133        // must return an explicit, reviewed string. If a new variant is added
134        // without updating as_str, this test fails on compile (non-exhaustive match)
135        // or at runtime if someone adds a `_ =>` fallback.
136        assert_eq!(ActorSource::ClaudeCode.as_str(), "ClaudeCode");
137        assert_eq!(ActorSource::Cursor.as_str(), "Cursor");
138        assert_eq!(ActorSource::Aider.as_str(), "Aider");
139        assert_eq!(ActorSource::Slack.as_str(), "Slack");
140        assert_eq!(ActorSource::Telegram.as_str(), "Telegram");
141        assert_eq!(ActorSource::Discord.as_str(), "Discord");
142        assert_eq!(ActorSource::CommanderDaemon.as_str(), "CommanderDaemon");
143        assert_eq!(ActorSource::MurCli.as_str(), "MurCli");
144    }
145
146    #[test]
147    fn actor_source_serializes_snake_case() {
148        // ClaudeCode variant should serialize as "claude_code"
149        let a = Actor {
150            source: ActorSource::ClaudeCode,
151            native_id: "x".into(),
152            display_name: None,
153            resolved_user_id: None,
154        };
155        let y = serde_yaml::to_string(&a).unwrap();
156        assert!(y.contains("source: claude_code"));
157    }
158}