Skip to main content

ephemeris_forge_github/
permission.rs

1use std::fmt;
2use std::str::FromStr;
3
4use ephemeris_forge::Observed;
5use facet::Facet;
6use facet_value::Value;
7
8/// A repository permission GitHub can grant.
9///
10/// The five built-in names are plain strings. A custom repository role is
11/// the object `{"custom":"<role name>"}`.
12#[derive(Clone, Debug, Eq, Facet, PartialEq)]
13#[facet(proxy = Value)]
14#[repr(u8)]
15pub enum Permission {
16    /// Read and clone.
17    Pull,
18    /// Read, plus manage issues and pull requests.
19    Triage,
20    /// Read and write.
21    Push,
22    /// Write, plus manage the repository without sensitive actions.
23    Maintain,
24    /// Full access.
25    Admin,
26    /// A custom repository role of the organization.
27    Custom(RoleName),
28}
29
30/// The name of a custom repository role, which is never empty.
31#[derive(Clone, Debug, Eq, Facet, PartialEq)]
32#[facet(proxy = String)]
33pub struct RoleName(String);
34
35/// A permission that [`Permission`] cannot represent.
36#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
37pub enum PermissionError {
38    /// The name is not one of the five built-in permissions.
39    #[error("unknown permission {name:?}")]
40    Unknown {
41        /// The name as written.
42        name: String,
43    },
44    /// The value is neither a string nor an object with one `custom` string.
45    #[error("a permission is a name or an object with a single custom role name")]
46    Shape,
47    /// The custom role name is the empty string.
48    #[error("custom role name cannot be empty")]
49    EmptyRole,
50}
51
52impl Permission {
53    /// Read the role name GitHub reports for a grant.
54    ///
55    /// GitHub spells the built-in roles `read` and `write` in some
56    /// responses. Any other name is a custom role.
57    #[must_use]
58    pub fn from_role_name(raw: &str) -> Option<Self> {
59        match raw {
60            "" => None,
61            "read" => Some(Self::Pull),
62            "write" => Some(Self::Push),
63            other => Some(
64                other
65                    .parse()
66                    .unwrap_or_else(|_unknown| Self::Custom(RoleName(other.to_owned()))),
67            ),
68        }
69    }
70
71    /// Combine the role name and the highest set permission flag of a grant.
72    ///
73    /// A role name that is not a built-in role is a custom role. Otherwise
74    /// the flag wins, and the role name is the fallback.
75    #[must_use]
76    pub fn observe(role: &str, flagged: Option<Self>) -> Observed<Self> {
77        match Self::from_role_name(role) {
78            Some(custom @ Self::Custom(_)) => Observed::Known(custom),
79            named => flagged
80                .or(named)
81                .map_or_else(|| Observed::Unknown(role.to_owned()), Observed::Known),
82        }
83    }
84
85    /// The name GitHub takes in a `permission` field.
86    #[must_use]
87    pub fn as_str(&self) -> &str {
88        match self {
89            Self::Pull => "pull",
90            Self::Triage => "triage",
91            Self::Push => "push",
92            Self::Maintain => "maintain",
93            Self::Admin => "admin",
94            Self::Custom(role) => role.as_str(),
95        }
96    }
97
98    /// The name an invitation update takes, which has no custom roles.
99    #[must_use]
100    pub const fn invitation_name(&self) -> Option<&'static str> {
101        match self {
102            Self::Pull => Some("read"),
103            Self::Triage => Some("triage"),
104            Self::Push => Some("write"),
105            Self::Maintain => Some("maintain"),
106            Self::Admin => Some("admin"),
107            Self::Custom(_) => None,
108        }
109    }
110
111    /// The custom role name, when this permission is a custom role.
112    #[must_use]
113    pub const fn custom(&self) -> Option<&RoleName> {
114        match self {
115            Self::Custom(role) => Some(role),
116            Self::Pull | Self::Triage | Self::Push | Self::Maintain | Self::Admin => None,
117        }
118    }
119}
120
121impl RoleName {
122    /// Borrow the name.
123    #[must_use]
124    pub fn as_str(&self) -> &str {
125        &self.0
126    }
127}
128
129impl FromStr for Permission {
130    type Err = PermissionError;
131
132    fn from_str(raw: &str) -> Result<Self, Self::Err> {
133        match raw {
134            "pull" => Ok(Self::Pull),
135            "triage" => Ok(Self::Triage),
136            "push" => Ok(Self::Push),
137            "maintain" => Ok(Self::Maintain),
138            "admin" => Ok(Self::Admin),
139            _ => Err(PermissionError::Unknown {
140                name: raw.to_owned(),
141            }),
142        }
143    }
144}
145
146impl TryFrom<Value> for Permission {
147    type Error = PermissionError;
148
149    fn try_from(value: Value) -> Result<Self, Self::Error> {
150        if let Some(name) = value.as_string() {
151            return name.as_str().parse();
152        }
153        let role = value
154            .as_object()
155            .filter(|object| object.len() == 1)
156            .and_then(|object| object.get("custom"))
157            .and_then(Value::as_string)
158            .ok_or(PermissionError::Shape)?;
159        Ok(Self::Custom(RoleName::try_from(role.as_str().to_owned())?))
160    }
161}
162
163impl TryFrom<&Permission> for Value {
164    type Error = PermissionError;
165
166    fn try_from(value: &Permission) -> Result<Self, Self::Error> {
167        Ok(match value {
168            Permission::Custom(role) => {
169                let mut object = facet_value::VObject::new();
170                object.insert("custom", Self::from(role.as_str()));
171                object.into()
172            }
173            named @ (Permission::Pull
174            | Permission::Triage
175            | Permission::Push
176            | Permission::Maintain
177            | Permission::Admin) => Self::from(named.as_str()),
178        })
179    }
180}
181
182impl TryFrom<String> for RoleName {
183    type Error = PermissionError;
184
185    fn try_from(value: String) -> Result<Self, Self::Error> {
186        if value.is_empty() {
187            Err(PermissionError::EmptyRole)
188        } else {
189            Ok(Self(value))
190        }
191    }
192}
193
194impl TryFrom<&RoleName> for String {
195    type Error = PermissionError;
196
197    fn try_from(value: &RoleName) -> Result<Self, Self::Error> {
198        Ok(value.0.clone())
199    }
200}
201
202impl fmt::Display for Permission {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        match self {
205            Self::Custom(role) => write!(f, "custom role {role}"),
206            named @ (Self::Pull | Self::Triage | Self::Push | Self::Maintain | Self::Admin) => {
207                f.write_str(named.as_str())
208            }
209        }
210    }
211}
212
213impl fmt::Display for RoleName {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        self.0.fmt(f)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::{Permission, RoleName};
222
223    #[test]
224    fn the_five_names_decode_from_strings() {
225        let permission: Permission = facet_json::from_str(r#""maintain""#).expect("decodes");
226        assert_eq!(permission, Permission::Maintain);
227    }
228
229    #[test]
230    fn a_custom_role_decodes_from_its_object_and_encodes_back() {
231        let permission: Permission =
232            facet_json::from_str(r#"{"custom":"release-manager"}"#).expect("decodes");
233        let role = RoleName::try_from("release-manager".to_owned()).expect("valid");
234        assert_eq!(permission, Permission::Custom(role));
235        assert_eq!(
236            facet_json::to_string(&permission).expect("encodes"),
237            r#"{"custom":"release-manager"}"#
238        );
239    }
240
241    #[test]
242    fn other_shapes_are_rejected() {
243        for input in [
244            r#""write""#,
245            r#""release-manager""#,
246            r#"{"custom":""}"#,
247            r#"{"custom":"a","extra":"b"}"#,
248            r#"{"role":"a"}"#,
249            "{}",
250            "3",
251        ] {
252            facet_json::from_str::<Permission>(input).expect_err(input);
253        }
254    }
255
256    #[test]
257    fn live_role_names_map_to_permissions() {
258        assert_eq!(Permission::from_role_name("write"), Some(Permission::Push));
259        assert_eq!(
260            Permission::from_role_name("triage"),
261            Some(Permission::Triage)
262        );
263        assert_eq!(
264            Permission::from_role_name("deployer")
265                .and_then(|permission| permission.custom().cloned()),
266            RoleName::try_from("deployer".to_owned()).ok()
267        );
268        assert_eq!(Permission::from_role_name(""), None);
269    }
270}