use std::fmt;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ActorKind {
Human,
Process,
Agent,
Other,
}
impl fmt::Display for ActorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
ActorKind::Human => "human",
ActorKind::Process => "process",
ActorKind::Agent => "agent",
ActorKind::Other => "other",
})
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Actor {
raw: String,
kind: ActorKind,
}
impl Actor {
pub fn parse(s: impl Into<String>) -> Actor {
let raw = s.into();
let t = raw.trim();
let kind = if t.strip_prefix("human:").is_some_and(|id| !id.is_empty()) {
ActorKind::Human
} else if t.strip_prefix("process:").is_some_and(|id| !id.is_empty()) {
ActorKind::Process
} else if is_agent(t) {
ActorKind::Agent
} else {
ActorKind::Other
};
Actor { raw, kind }
}
pub fn as_str(&self) -> &str {
&self.raw
}
pub fn kind(&self) -> ActorKind {
self.kind
}
pub fn is_human(&self) -> bool {
self.kind == ActorKind::Human
}
pub fn id(&self) -> &str {
let t = self.raw.trim();
match self.kind {
ActorKind::Human => &t["human:".len()..],
ActorKind::Process => &t["process:".len()..],
ActorKind::Agent => self.producer().unwrap_or(t),
ActorKind::Other => t,
}
}
pub fn producer(&self) -> Option<&str> {
self.agent_halves().map(|(p, _)| p)
}
pub fn version(&self) -> Option<&str> {
self.agent_halves().map(|(_, v)| v)
}
fn agent_halves(&self) -> Option<(&str, &str)> {
if self.kind != ActorKind::Agent {
return None;
}
self.raw.trim().split_once('/')
}
}
impl fmt::Display for Actor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.raw)
}
}
impl From<&str> for Actor {
fn from(s: &str) -> Self {
Actor::parse(s)
}
}
fn is_agent(t: &str) -> bool {
match t.split_once('/') {
Some((producer, version)) => {
!producer.is_empty() && !version.is_empty() && !version.contains('/')
}
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_the_three_conventional_forms() {
let agent = Actor::parse("reference_agent/gemini-2.5-pro");
assert_eq!(agent.kind(), ActorKind::Agent);
assert_eq!(agent.producer(), Some("reference_agent"));
assert_eq!(agent.version(), Some("gemini-2.5-pro"));
assert!(!agent.is_human());
let human = Actor::parse("human:ahormati");
assert_eq!(human.kind(), ActorKind::Human);
assert!(human.is_human());
assert_eq!(human.id(), "ahormati");
let process = Actor::parse("process:finance-nightly");
assert_eq!(process.kind(), ActorKind::Process);
assert!(!process.is_human());
assert_eq!(process.id(), "finance-nightly");
}
#[test]
fn other_forms_are_kept_not_rejected() {
let team = Actor::parse("team:ga4-docs");
assert_eq!(team.kind(), ActorKind::Other);
assert_eq!(team.as_str(), "team:ga4-docs");
assert_eq!(team.id(), "team:ga4-docs");
assert_eq!(Actor::parse("human:").kind(), ActorKind::Other);
assert_eq!(Actor::parse("a/b/c").kind(), ActorKind::Other);
}
#[test]
fn display_round_trips_the_raw_string() {
for s in [
"human:ahormati",
"reference_agent/gemini-2.5-pro",
"anything",
] {
assert_eq!(Actor::parse(s).to_string(), s);
}
}
}