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