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 ActorKind {
35 #[must_use]
37 pub const fn as_str(&self) -> &'static str {
38 match self {
39 Self::Human => "human",
40 Self::Process => "process",
41 Self::Agent => "agent",
42 Self::Other => "other",
43 }
44 }
45}
46
47impl AsRef<str> for ActorKind {
48 fn as_ref(&self) -> &str {
49 self.as_str()
50 }
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct ParseActorKindError(pub String);
56
57impl fmt::Display for ParseActorKindError {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 write!(f, "unknown actor kind: {:?}", self.0)
60 }
61}
62
63impl std::error::Error for ParseActorKindError {}
64
65impl std::str::FromStr for ActorKind {
66 type Err = ParseActorKindError;
67 fn from_str(s: &str) -> Result<Self, Self::Err> {
68 match s.trim().to_ascii_lowercase().as_str() {
69 "human" => Ok(Self::Human),
70 "process" => Ok(Self::Process),
71 "agent" => Ok(Self::Agent),
72 "other" => Ok(Self::Other),
73 other => Err(ParseActorKindError(other.to_string())),
74 }
75 }
76}
77
78impl fmt::Display for ActorKind {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 f.write_str(self.as_str())
81 }
82}
83
84#[derive(Clone, Debug, PartialEq, Eq, Hash)]
86pub struct Actor {
87 raw: String,
88 kind: ActorKind,
89}
90
91impl Actor {
92 pub fn parse(s: impl Into<String>) -> Self {
95 let raw = s.into();
96 let t = raw.trim();
97 let kind = if t.strip_prefix("human:").is_some_and(|id| !id.is_empty()) {
98 ActorKind::Human
99 } else if t.strip_prefix("process:").is_some_and(|id| !id.is_empty()) {
100 ActorKind::Process
101 } else if is_agent(t) {
102 ActorKind::Agent
103 } else {
104 ActorKind::Other
105 };
106 Self { raw, kind }
107 }
108
109 #[must_use]
111 pub fn as_str(&self) -> &str {
112 &self.raw
113 }
114
115 #[must_use]
117 pub const fn kind(&self) -> ActorKind {
118 self.kind
119 }
120
121 #[must_use]
123 pub fn is_human(&self) -> bool {
124 self.kind == ActorKind::Human
125 }
126
127 #[must_use]
130 pub fn id(&self) -> &str {
131 let t = self.raw.trim();
132 match self.kind {
133 ActorKind::Human => &t["human:".len()..],
134 ActorKind::Process => &t["process:".len()..],
135 ActorKind::Agent => self.producer().unwrap_or(t),
136 ActorKind::Other => t,
137 }
138 }
139
140 #[must_use]
142 pub fn producer(&self) -> Option<&str> {
143 self.agent_halves().map(|(p, _)| p)
144 }
145
146 #[must_use]
148 pub fn version(&self) -> Option<&str> {
149 self.agent_halves().map(|(_, v)| v)
150 }
151
152 fn agent_halves(&self) -> Option<(&str, &str)> {
153 if self.kind != ActorKind::Agent {
154 return None;
155 }
156 self.raw.trim().split_once('/')
157 }
158}
159
160impl fmt::Display for Actor {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 f.write_str(&self.raw)
163 }
164}
165
166impl From<&str> for Actor {
167 fn from(s: &str) -> Self {
168 Self::parse(s)
169 }
170}
171
172impl From<String> for Actor {
173 fn from(s: String) -> Self {
174 Self::parse(s)
175 }
176}
177
178impl std::str::FromStr for Actor {
179 type Err = std::convert::Infallible;
180 fn from_str(s: &str) -> Result<Self, Self::Err> {
181 Ok(Self::parse(s))
182 }
183}
184
185impl AsRef<str> for Actor {
186 fn as_ref(&self) -> &str {
187 self.as_str()
188 }
189}
190
191impl std::ops::Deref for Actor {
192 type Target = str;
193 fn deref(&self) -> &str {
194 self.as_str()
195 }
196}
197
198fn is_agent(t: &str) -> bool {
201 match t.split_once('/') {
202 Some((producer, version)) => {
203 !producer.is_empty() && !version.is_empty() && !version.contains('/')
204 }
205 None => false,
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn classifies_the_three_conventional_forms() {
215 let agent = Actor::parse("reference_agent/gemini-2.5-pro");
216 assert_eq!(agent.kind(), ActorKind::Agent);
217 assert_eq!(agent.producer(), Some("reference_agent"));
218 assert_eq!(agent.version(), Some("gemini-2.5-pro"));
219 assert!(!agent.is_human());
220
221 let human = Actor::parse("human:walter");
222 assert_eq!(human.kind(), ActorKind::Human);
223 assert!(human.is_human());
224 assert_eq!(human.id(), "walter");
225
226 let process = Actor::parse("process:finance-nightly");
227 assert_eq!(process.kind(), ActorKind::Process);
228 assert!(!process.is_human());
229 assert_eq!(process.id(), "finance-nightly");
230 }
231
232 #[test]
233 fn other_forms_are_kept_not_rejected() {
234 let team = Actor::parse("team:ga4-docs");
236 assert_eq!(team.kind(), ActorKind::Other);
237 assert_eq!(team.as_str(), "team:ga4-docs");
238 assert_eq!(team.id(), "team:ga4-docs");
239
240 assert_eq!(Actor::parse("human:").kind(), ActorKind::Other);
241 assert_eq!(Actor::parse("a/b/c").kind(), ActorKind::Other);
242 }
243
244 #[test]
245 fn display_round_trips_the_raw_string() {
246 for s in ["human:walter", "reference_agent/gemini-2.5-pro", "anything"] {
247 assert_eq!(Actor::parse(s).to_string(), s);
248 }
249 }
250}