Skip to main content

okf_core/
actor.rs

1//! The actor convention: who or what performed an action.
2//!
3//! Fields that record an identity (`generated.by`, `verified[].by`, and
4//! `sources[].author`) share one convention:
5//!
6//! | Form                    | Meaning                | Example                          |
7//! |-------------------------|------------------------|----------------------------------|
8//! | `<producer>/<version>`  | an agent or tool       | `reference_agent/gemini-2.5-pro` |
9//! | `human:<id>`            | a person               | `human:walter`                 |
10//! | `process:<id>`          | an automated process   | `process:finance-nightly`        |
11//!
12//! The `human:` prefix is significant: trust tiers are derived from it,
13//! so [`Actor::is_human`] is the single place that decision is made.
14//!
15//! Anything else parses as [`ActorKind::Other`] rather than an error. The spec
16//! itself writes `author: team:ga4-docs`, so other `<scheme>:<id>` forms do
17//! occur in practice; a consumer must keep them, not reject them.
18
19use std::fmt;
20
21/// The category an actor string falls into.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum ActorKind {
24    /// A person: `human:<id>`.
25    Human,
26    /// An automated process: `process:<id>`.
27    Process,
28    /// An agent or tool: `<producer>/<version>`.
29    Agent,
30    /// Any other identity string (for example the spec's `team:ga4-docs`).
31    Other,
32}
33
34impl ActorKind {
35    /// String representation of this actor kind.
36    #[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/// Error returned when a string cannot be parsed into an [`ActorKind`].
54#[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/// A parsed actor string, retaining the text exactly as written.
85#[derive(Clone, Debug, PartialEq, Eq, Hash)]
86pub struct Actor {
87    raw: String,
88    kind: ActorKind,
89}
90
91impl Actor {
92    /// Classifies an actor string. Never fails: an unrecognized form becomes
93    /// [`ActorKind::Other`] with the raw text preserved.
94    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    /// The actor string exactly as written.
110    #[must_use]
111    pub fn as_str(&self) -> &str {
112        &self.raw
113    }
114
115    /// Which actor form this uses.
116    #[must_use]
117    pub const fn kind(&self) -> ActorKind {
118        self.kind
119    }
120
121    /// `true` for a `human:<id>` actor, the signal trust tiers key off.
122    #[must_use]
123    pub fn is_human(&self) -> bool {
124        self.kind == ActorKind::Human
125    }
126
127    /// The identifying part: the text after `human:` / `process:`, the producer
128    /// of an agent, or the whole string otherwise.
129    #[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    /// The `<producer>` half of an agent actor.
141    #[must_use]
142    pub fn producer(&self) -> Option<&str> {
143        self.agent_halves().map(|(p, _)| p)
144    }
145
146    /// The `<version>` half of an agent actor.
147    #[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
198/// `<producer>/<version>`: a single `/` with non-empty text on both sides, and
199/// no scheme separator that would make it a URL.
200fn 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        // The spec's own `sources[].author` example.
235        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}