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:ahormati`                 |
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 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/// A parsed actor string, retaining the text exactly as written.
46#[derive(Clone, Debug, PartialEq, Eq, Hash)]
47pub struct Actor {
48    raw: String,
49    kind: ActorKind,
50}
51
52impl Actor {
53    /// Classifies an actor string. Never fails: an unrecognized form becomes
54    /// [`ActorKind::Other`] with the raw text preserved.
55    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    /// The actor string exactly as written.
71    #[must_use]
72    pub fn as_str(&self) -> &str {
73        &self.raw
74    }
75
76    /// Which actor form this uses.
77    #[must_use]
78    pub const fn kind(&self) -> ActorKind {
79        self.kind
80    }
81
82    /// `true` for a `human:<id>` actor, the signal trust tiers key off.
83    #[must_use]
84    pub fn is_human(&self) -> bool {
85        self.kind == ActorKind::Human
86    }
87
88    /// The identifying part: the text after `human:` / `process:`, the producer
89    /// of an agent, or the whole string otherwise.
90    #[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    /// The `<producer>` half of an agent actor.
102    #[must_use]
103    pub fn producer(&self) -> Option<&str> {
104        self.agent_halves().map(|(p, _)| p)
105    }
106
107    /// The `<version>` half of an agent actor.
108    #[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
133/// `<producer>/<version>`: a single `/` with non-empty text on both sides, and
134/// no scheme separator that would make it a URL.
135fn 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        // The spec's own `sources[].author` example.
170        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}