Skip to main content

ironflow_store/entities/
assignee.rs

1//! [`Assignee`] — the user or group an approval gate is assigned to.
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Who an approval gate is currently assigned to: an individual user or a group.
10///
11/// The distinction is carried in the type rather than in a free-form string, so
12/// callers cannot confuse a user with a group. On the wire and in the store the
13/// value is a single prefixed string — `user:{name}` for [`Assignee::User`] and
14/// `group:{name}` for [`Assignee::Group`] — which keeps the database column a
15/// plain `TEXT` and the OpenAPI type a plain `string`.
16///
17/// The distinction is advisory: it drives notification routing and audit, not
18/// authorization. Whether an approver is allowed to resolve a gate is decided by
19/// the API layer, not by this type.
20///
21/// # Examples
22///
23/// ```
24/// use ironflow_store::entities::Assignee;
25///
26/// let alice = Assignee::user("alice");
27/// assert_eq!(alice.to_string(), "user:alice");
28/// assert_eq!(alice.name(), "alice");
29///
30/// let sre: Assignee = "group:sre-oncall".parse()?;
31/// assert_eq!(sre, Assignee::group("sre-oncall"));
32/// assert!(sre.is_group());
33/// # Ok::<(), ironflow_store::entities::AssigneeParseError>(())
34/// ```
35#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(into = "String", try_from = "String")]
37pub enum Assignee {
38    /// An individual user, identified by an opaque name.
39    User(String),
40    /// A group, identified by an opaque name.
41    Group(String),
42}
43
44impl Assignee {
45    /// Build a [`Assignee::User`] from any string-like value.
46    ///
47    /// # Examples
48    ///
49    /// ```
50    /// use ironflow_store::entities::Assignee;
51    ///
52    /// assert_eq!(Assignee::user("alice").to_string(), "user:alice");
53    /// ```
54    pub fn user(name: impl Into<String>) -> Self {
55        Assignee::User(name.into())
56    }
57
58    /// Build a [`Assignee::Group`] from any string-like value.
59    ///
60    /// # Examples
61    ///
62    /// ```
63    /// use ironflow_store::entities::Assignee;
64    ///
65    /// assert_eq!(Assignee::group("sre").to_string(), "group:sre");
66    /// ```
67    pub fn group(name: impl Into<String>) -> Self {
68        Assignee::Group(name.into())
69    }
70
71    /// The bare name, without the `user:`/`group:` prefix.
72    ///
73    /// # Examples
74    ///
75    /// ```
76    /// use ironflow_store::entities::Assignee;
77    ///
78    /// assert_eq!(Assignee::group("sre").name(), "sre");
79    /// ```
80    pub fn name(&self) -> &str {
81        match self {
82            Assignee::User(name) | Assignee::Group(name) => name,
83        }
84    }
85
86    /// Whether this assignee is an individual user.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use ironflow_store::entities::Assignee;
92    ///
93    /// assert!(Assignee::user("alice").is_user());
94    /// assert!(!Assignee::group("sre").is_user());
95    /// ```
96    pub fn is_user(&self) -> bool {
97        matches!(self, Assignee::User(_))
98    }
99
100    /// Whether this assignee is a group.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use ironflow_store::entities::Assignee;
106    ///
107    /// assert!(Assignee::group("sre").is_group());
108    /// assert!(!Assignee::user("alice").is_group());
109    /// ```
110    pub fn is_group(&self) -> bool {
111        matches!(self, Assignee::Group(_))
112    }
113}
114
115impl fmt::Display for Assignee {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        match self {
118            Assignee::User(name) => write!(f, "user:{name}"),
119            Assignee::Group(name) => write!(f, "group:{name}"),
120        }
121    }
122}
123
124/// Error returned when a string cannot be parsed into an [`Assignee`].
125#[derive(Debug, Clone, PartialEq, Eq, Error)]
126pub enum AssigneeParseError {
127    /// The string carried no `user:`/`group:` prefix.
128    #[error("assignee must be prefixed with `user:` or `group:`, got `{0}`")]
129    MissingPrefix(String),
130    /// The name after the prefix was empty.
131    #[error("assignee name must not be empty")]
132    EmptyName,
133}
134
135impl FromStr for Assignee {
136    type Err = AssigneeParseError;
137
138    fn from_str(s: &str) -> Result<Self, Self::Err> {
139        let assignee = if let Some(name) = s.strip_prefix("user:") {
140            Assignee::User(name.to_string())
141        } else if let Some(name) = s.strip_prefix("group:") {
142            Assignee::Group(name.to_string())
143        } else {
144            return Err(AssigneeParseError::MissingPrefix(s.to_string()));
145        };
146
147        if assignee.name().is_empty() {
148            return Err(AssigneeParseError::EmptyName);
149        }
150
151        Ok(assignee)
152    }
153}
154
155impl From<Assignee> for String {
156    fn from(assignee: Assignee) -> Self {
157        assignee.to_string()
158    }
159}
160
161impl TryFrom<String> for Assignee {
162    type Error = AssigneeParseError;
163
164    fn try_from(value: String) -> Result<Self, Self::Error> {
165        value.parse()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn user_and_group_render_with_prefix() {
175        assert_eq!(Assignee::user("alice").to_string(), "user:alice");
176        assert_eq!(Assignee::group("sre").to_string(), "group:sre");
177    }
178
179    #[test]
180    fn round_trips_through_string() {
181        for value in [Assignee::user("alice"), Assignee::group("sre-oncall")] {
182            let text = value.to_string();
183            let parsed: Assignee = text.parse().expect("parse");
184            assert_eq!(parsed, value);
185        }
186    }
187
188    #[test]
189    fn round_trips_through_json() {
190        let value = Assignee::group("sre-oncall");
191        let json = serde_json::to_string(&value).expect("serialize");
192        assert_eq!(json, "\"group:sre-oncall\"");
193        let back: Assignee = serde_json::from_str(&json).expect("deserialize");
194        assert_eq!(back, value);
195    }
196
197    #[test]
198    fn missing_prefix_is_rejected() {
199        assert_eq!(
200            "sre-oncall".parse::<Assignee>(),
201            Err(AssigneeParseError::MissingPrefix("sre-oncall".to_string()))
202        );
203    }
204
205    #[test]
206    fn empty_name_is_rejected() {
207        assert_eq!(
208            "user:".parse::<Assignee>(),
209            Err(AssigneeParseError::EmptyName)
210        );
211        assert_eq!(
212            "group:".parse::<Assignee>(),
213            Err(AssigneeParseError::EmptyName)
214        );
215    }
216
217    #[test]
218    fn deserialize_rejects_unprefixed() {
219        assert!(serde_json::from_str::<Assignee>("\"sre-oncall\"").is_err());
220    }
221
222    #[test]
223    fn name_strips_the_prefix() {
224        assert_eq!(Assignee::user("alice").name(), "alice");
225        assert_eq!(Assignee::group("sre").name(), "sre");
226    }
227}