1use std::fmt;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum ActorKind {
24 Human,
26 Process,
28 Agent,
30 Other,
32}
33
34impl fmt::Display for ActorKind {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 f.write_str(match self {
37 Self::Human => "human",
38 Self::Process => "process",
39 Self::Agent => "agent",
40 Self::Other => "other",
41 })
42 }
43}
44
45#[derive(Clone, Debug, PartialEq, Eq, Hash)]
47pub struct Actor {
48 raw: String,
49 kind: ActorKind,
50}
51
52impl Actor {
53 pub fn parse(s: impl Into<String>) -> Self {
56 let raw = s.into();
57 let t = raw.trim();
58 let kind = if t.strip_prefix("human:").is_some_and(|id| !id.is_empty()) {
59 ActorKind::Human
60 } else if t.strip_prefix("process:").is_some_and(|id| !id.is_empty()) {
61 ActorKind::Process
62 } else if is_agent(t) {
63 ActorKind::Agent
64 } else {
65 ActorKind::Other
66 };
67 Self { raw, kind }
68 }
69
70 #[must_use]
72 pub fn as_str(&self) -> &str {
73 &self.raw
74 }
75
76 #[must_use]
78 pub const fn kind(&self) -> ActorKind {
79 self.kind
80 }
81
82 #[must_use]
84 pub fn is_human(&self) -> bool {
85 self.kind == ActorKind::Human
86 }
87
88 #[must_use]
91 pub fn id(&self) -> &str {
92 let t = self.raw.trim();
93 match self.kind {
94 ActorKind::Human => &t["human:".len()..],
95 ActorKind::Process => &t["process:".len()..],
96 ActorKind::Agent => self.producer().unwrap_or(t),
97 ActorKind::Other => t,
98 }
99 }
100
101 #[must_use]
103 pub fn producer(&self) -> Option<&str> {
104 self.agent_halves().map(|(p, _)| p)
105 }
106
107 #[must_use]
109 pub fn version(&self) -> Option<&str> {
110 self.agent_halves().map(|(_, v)| v)
111 }
112
113 fn agent_halves(&self) -> Option<(&str, &str)> {
114 if self.kind != ActorKind::Agent {
115 return None;
116 }
117 self.raw.trim().split_once('/')
118 }
119}
120
121impl fmt::Display for Actor {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 f.write_str(&self.raw)
124 }
125}
126
127impl From<&str> for Actor {
128 fn from(s: &str) -> Self {
129 Self::parse(s)
130 }
131}
132
133fn is_agent(t: &str) -> bool {
136 match t.split_once('/') {
137 Some((producer, version)) => {
138 !producer.is_empty() && !version.is_empty() && !version.contains('/')
139 }
140 None => false,
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn classifies_the_three_conventional_forms() {
150 let agent = Actor::parse("reference_agent/gemini-2.5-pro");
151 assert_eq!(agent.kind(), ActorKind::Agent);
152 assert_eq!(agent.producer(), Some("reference_agent"));
153 assert_eq!(agent.version(), Some("gemini-2.5-pro"));
154 assert!(!agent.is_human());
155
156 let human = Actor::parse("human:ahormati");
157 assert_eq!(human.kind(), ActorKind::Human);
158 assert!(human.is_human());
159 assert_eq!(human.id(), "ahormati");
160
161 let process = Actor::parse("process:finance-nightly");
162 assert_eq!(process.kind(), ActorKind::Process);
163 assert!(!process.is_human());
164 assert_eq!(process.id(), "finance-nightly");
165 }
166
167 #[test]
168 fn other_forms_are_kept_not_rejected() {
169 let team = Actor::parse("team:ga4-docs");
171 assert_eq!(team.kind(), ActorKind::Other);
172 assert_eq!(team.as_str(), "team:ga4-docs");
173 assert_eq!(team.id(), "team:ga4-docs");
174
175 assert_eq!(Actor::parse("human:").kind(), ActorKind::Other);
176 assert_eq!(Actor::parse("a/b/c").kind(), ActorKind::Other);
177 }
178
179 #[test]
180 fn display_round_trips_the_raw_string() {
181 for s in [
182 "human:ahormati",
183 "reference_agent/gemini-2.5-pro",
184 "anything",
185 ] {
186 assert_eq!(Actor::parse(s).to_string(), s);
187 }
188 }
189}