Skip to main content

communitas_bindings/permissions/
access_level.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Access level definitions for granular permissions.
4
5use serde::{Deserialize, Serialize};
6use std::cmp::Ordering;
7use std::str::FromStr;
8
9/// Granular access level for a resource type.
10///
11/// Access levels are ordered from most restrictive to least restrictive:
12/// `NotVisible` < `ReadOnly` < `Edit`
13///
14/// This ordering allows permission checks to verify if a user has
15/// "at least" a certain level of access.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum AccessLevel {
19    /// Resource is hidden - member cannot see it exists.
20    ///
21    /// Use this for sensitive resources that should be completely
22    /// invisible to certain members (e.g., admin settings for guests).
23    NotVisible,
24
25    /// Read-only access - can view but not modify.
26    ///
27    /// Members with this level can:
28    /// - View resource content
29    /// - List resources
30    /// - Read metadata
31    ///
32    /// Members cannot:
33    /// - Create new resources
34    /// - Modify existing resources
35    /// - Delete resources
36    ReadOnly,
37
38    /// Full access - can view, create, modify, and delete.
39    ///
40    /// Members with this level have complete control over the resource type,
41    /// subject to other constraints (e.g., cannot delete resources created
42    /// by others without additional permissions).
43    Edit,
44}
45
46impl Default for AccessLevel {
47    /// Default access level is `NotVisible` (secure by default).
48    fn default() -> Self {
49        AccessLevel::NotVisible
50    }
51}
52
53impl AccessLevel {
54    /// Check if this access level allows the required level.
55    ///
56    /// Returns `true` if `self` is at least as permissive as `required`.
57    ///
58    /// # Examples
59    ///
60    /// ```
61    /// use communitas_bindings::permissions::AccessLevel;
62    ///
63    /// assert!(AccessLevel::Edit.allows(AccessLevel::ReadOnly));
64    /// assert!(AccessLevel::Edit.allows(AccessLevel::Edit));
65    /// assert!(!AccessLevel::ReadOnly.allows(AccessLevel::Edit));
66    /// assert!(!AccessLevel::NotVisible.allows(AccessLevel::ReadOnly));
67    /// ```
68    pub fn allows(self, required: AccessLevel) -> bool {
69        self >= required
70    }
71
72    /// Check if this access level can view the resource.
73    ///
74    /// Returns `true` for `ReadOnly` and `Edit` levels.
75    pub fn can_view(self) -> bool {
76        matches!(self, AccessLevel::ReadOnly | AccessLevel::Edit)
77    }
78
79    /// Check if this access level can modify the resource.
80    ///
81    /// Returns `true` only for `Edit` level.
82    pub fn can_edit(self) -> bool {
83        matches!(self, AccessLevel::Edit)
84    }
85
86    /// Get the numeric rank for ordering (0 = NotVisible, 1 = ReadOnly, 2 = Edit).
87    fn rank(self) -> u8 {
88        match self {
89            AccessLevel::NotVisible => 0,
90            AccessLevel::ReadOnly => 1,
91            AccessLevel::Edit => 2,
92        }
93    }
94
95    /// Convert to string representation.
96    pub fn as_str(self) -> &'static str {
97        match self {
98            AccessLevel::NotVisible => "not_visible",
99            AccessLevel::ReadOnly => "read_only",
100            AccessLevel::Edit => "edit",
101        }
102    }
103}
104
105/// Error when parsing an invalid access level string.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ParseAccessLevelError {
108    /// The invalid input string.
109    pub invalid_value: String,
110}
111
112impl std::fmt::Display for ParseAccessLevelError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        write!(
115            f,
116            "invalid access level '{}': expected 'not_visible', 'read_only', or 'edit'",
117            self.invalid_value
118        )
119    }
120}
121
122impl std::error::Error for ParseAccessLevelError {}
123
124impl FromStr for AccessLevel {
125    type Err = ParseAccessLevelError;
126
127    /// Parse from string representation.
128    ///
129    /// Accepts: "not_visible", "read_only", "edit" (case-insensitive).
130    fn from_str(s: &str) -> Result<Self, Self::Err> {
131        match s.to_lowercase().as_str() {
132            "not_visible" | "notvisible" | "hidden" => Ok(AccessLevel::NotVisible),
133            "read_only" | "readonly" | "read" | "view" => Ok(AccessLevel::ReadOnly),
134            "edit" | "write" | "full" => Ok(AccessLevel::Edit),
135            _ => Err(ParseAccessLevelError {
136                invalid_value: s.to_string(),
137            }),
138        }
139    }
140}
141
142impl PartialOrd for AccessLevel {
143    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
144        Some(self.cmp(other))
145    }
146}
147
148impl Ord for AccessLevel {
149    fn cmp(&self, other: &Self) -> Ordering {
150        self.rank().cmp(&other.rank())
151    }
152}
153
154impl std::fmt::Display for AccessLevel {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        write!(f, "{}", self.as_str())
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_access_level_ordering() {
166        assert!(AccessLevel::NotVisible < AccessLevel::ReadOnly);
167        assert!(AccessLevel::ReadOnly < AccessLevel::Edit);
168        assert!(AccessLevel::NotVisible < AccessLevel::Edit);
169    }
170
171    #[test]
172    fn test_allows() {
173        // Edit allows everything
174        assert!(AccessLevel::Edit.allows(AccessLevel::Edit));
175        assert!(AccessLevel::Edit.allows(AccessLevel::ReadOnly));
176        assert!(AccessLevel::Edit.allows(AccessLevel::NotVisible));
177
178        // ReadOnly allows itself and below
179        assert!(AccessLevel::ReadOnly.allows(AccessLevel::ReadOnly));
180        assert!(AccessLevel::ReadOnly.allows(AccessLevel::NotVisible));
181        assert!(!AccessLevel::ReadOnly.allows(AccessLevel::Edit));
182
183        // NotVisible only allows itself
184        assert!(AccessLevel::NotVisible.allows(AccessLevel::NotVisible));
185        assert!(!AccessLevel::NotVisible.allows(AccessLevel::ReadOnly));
186        assert!(!AccessLevel::NotVisible.allows(AccessLevel::Edit));
187    }
188
189    #[test]
190    fn test_can_view_and_edit() {
191        assert!(!AccessLevel::NotVisible.can_view());
192        assert!(!AccessLevel::NotVisible.can_edit());
193
194        assert!(AccessLevel::ReadOnly.can_view());
195        assert!(!AccessLevel::ReadOnly.can_edit());
196
197        assert!(AccessLevel::Edit.can_view());
198        assert!(AccessLevel::Edit.can_edit());
199    }
200
201    #[test]
202    fn test_from_str() {
203        assert_eq!(
204            "not_visible".parse::<AccessLevel>().unwrap(),
205            AccessLevel::NotVisible
206        );
207        assert_eq!(
208            "read_only".parse::<AccessLevel>().unwrap(),
209            AccessLevel::ReadOnly
210        );
211        assert_eq!("edit".parse::<AccessLevel>().unwrap(), AccessLevel::Edit);
212        assert_eq!("EDIT".parse::<AccessLevel>().unwrap(), AccessLevel::Edit);
213        assert!("invalid".parse::<AccessLevel>().is_err());
214    }
215
216    #[test]
217    fn test_default() {
218        assert_eq!(AccessLevel::default(), AccessLevel::NotVisible);
219    }
220
221    #[test]
222    fn test_serialization() {
223        let level = AccessLevel::ReadOnly;
224        let json = serde_json::to_string(&level).unwrap();
225        assert_eq!(json, "\"read_only\"");
226
227        let parsed: AccessLevel = serde_json::from_str(&json).unwrap();
228        assert_eq!(parsed, level);
229    }
230
231    #[test]
232    fn test_display() {
233        assert_eq!(format!("{}", AccessLevel::NotVisible), "not_visible");
234        assert_eq!(format!("{}", AccessLevel::ReadOnly), "read_only");
235        assert_eq!(format!("{}", AccessLevel::Edit), "edit");
236    }
237
238    #[test]
239    fn test_as_str() {
240        assert_eq!(AccessLevel::NotVisible.as_str(), "not_visible");
241        assert_eq!(AccessLevel::ReadOnly.as_str(), "read_only");
242        assert_eq!(AccessLevel::Edit.as_str(), "edit");
243    }
244
245    #[test]
246    fn test_parse_error_display() {
247        let err: Result<AccessLevel, _> = "invalid_level".parse();
248        assert!(err.is_err());
249        let err = err.unwrap_err();
250        assert!(err.to_string().contains("invalid_level"));
251        assert!(err.to_string().contains("not_visible"));
252        assert!(err.to_string().contains("read_only"));
253        assert!(err.to_string().contains("edit"));
254    }
255
256    #[test]
257    fn test_from_str_aliases() {
258        // Test all alias forms
259        assert_eq!(
260            "hidden".parse::<AccessLevel>().unwrap(),
261            AccessLevel::NotVisible
262        );
263        assert_eq!(
264            "notvisible".parse::<AccessLevel>().unwrap(),
265            AccessLevel::NotVisible
266        );
267
268        assert_eq!(
269            "readonly".parse::<AccessLevel>().unwrap(),
270            AccessLevel::ReadOnly
271        );
272        assert_eq!(
273            "read".parse::<AccessLevel>().unwrap(),
274            AccessLevel::ReadOnly
275        );
276        assert_eq!(
277            "view".parse::<AccessLevel>().unwrap(),
278            AccessLevel::ReadOnly
279        );
280
281        assert_eq!("write".parse::<AccessLevel>().unwrap(), AccessLevel::Edit);
282        assert_eq!("full".parse::<AccessLevel>().unwrap(), AccessLevel::Edit);
283    }
284
285    #[test]
286    fn test_rank() {
287        // Test internal ordering via PartialOrd
288        assert!(AccessLevel::NotVisible < AccessLevel::ReadOnly);
289        assert!(AccessLevel::ReadOnly < AccessLevel::Edit);
290        assert!(AccessLevel::NotVisible < AccessLevel::Edit);
291
292        // Test equality
293        assert_eq!(
294            AccessLevel::Edit.cmp(&AccessLevel::Edit),
295            std::cmp::Ordering::Equal
296        );
297        assert_eq!(
298            AccessLevel::ReadOnly.cmp(&AccessLevel::NotVisible),
299            std::cmp::Ordering::Greater
300        );
301    }
302
303    #[test]
304    fn test_partial_ord() {
305        assert!(
306            AccessLevel::NotVisible.partial_cmp(&AccessLevel::ReadOnly)
307                == Some(std::cmp::Ordering::Less)
308        );
309        assert!(
310            AccessLevel::Edit.partial_cmp(&AccessLevel::ReadOnly)
311                == Some(std::cmp::Ordering::Greater)
312        );
313        assert!(
314            AccessLevel::ReadOnly.partial_cmp(&AccessLevel::ReadOnly)
315                == Some(std::cmp::Ordering::Equal)
316        );
317    }
318
319    #[test]
320    fn test_allows_not_visible() {
321        let level = AccessLevel::NotVisible;
322        assert!(level.allows(AccessLevel::NotVisible));
323        assert!(!level.allows(AccessLevel::ReadOnly));
324        assert!(!level.allows(AccessLevel::Edit));
325    }
326}