1use std::fmt;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6#[non_exhaustive]
7pub enum IdError {
8 Empty,
10 BadChar { at: usize },
12 BadDot,
14}
15
16impl fmt::Display for IdError {
17 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18 match self {
19 IdError::Empty => write!(f, "capability id is empty"),
20 IdError::BadChar { at } => {
21 write!(
22 f,
23 "capability id has a character outside a-z 0-9 _ . : at byte {at}"
24 )
25 }
26 IdError::BadDot => write!(f, "capability id has a leading, trailing or doubled '.'"),
27 }
28 }
29}
30
31impl std::error::Error for IdError {}
32
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub struct CapabilityId(String);
40
41impl CapabilityId {
42 pub fn new(s: impl Into<String>) -> Result<Self, IdError> {
44 let s = s.into();
45 if s.is_empty() {
46 return Err(IdError::Empty);
47 }
48 let ok =
49 |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b':');
50 if let Some(at) = s.bytes().position(|b| !ok(b)) {
51 return Err(IdError::BadChar { at });
52 }
53 if s.starts_with('.') || s.ends_with('.') || s.contains("..") {
54 return Err(IdError::BadDot);
55 }
56 Ok(CapabilityId(s))
57 }
58
59 pub fn as_str(&self) -> &str {
61 &self.0
62 }
63
64 pub fn is_under(&self, other: &CapabilityId) -> bool {
68 self.0 == other.0
69 || (self.0.starts_with(&other.0) && self.0.as_bytes().get(other.0.len()) == Some(&b'.'))
70 }
71}
72
73impl fmt::Display for CapabilityId {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 f.write_str(&self.0)
76 }
77}
78
79impl std::str::FromStr for CapabilityId {
80 type Err = IdError;
81 fn from_str(s: &str) -> Result<Self, IdError> {
82 CapabilityId::new(s)
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn accepts_the_ids_in_the_design_and_in_unisql() {
92 for s in [
93 "kv.prefix_list.exactly_once",
94 "cast.to_uuid",
95 "fn:max",
96 "fn:string_agg.order_by",
97 "blob.max_value_size",
98 "rel.source_dialect",
99 ] {
100 assert_eq!(CapabilityId::new(s).unwrap().as_str(), s);
101 }
102 }
103
104 #[test]
105 fn rejects_what_would_not_diff_or_parse() {
106 assert_eq!(CapabilityId::new(""), Err(IdError::Empty));
107 assert_eq!(CapabilityId::new("Kv.get"), Err(IdError::BadChar { at: 0 }));
108 assert_eq!(CapabilityId::new("kv get"), Err(IdError::BadChar { at: 2 }));
109 assert_eq!(CapabilityId::new("kv;get"), Err(IdError::BadChar { at: 2 }));
110 assert_eq!(CapabilityId::new("kv..get"), Err(IdError::BadDot));
111 assert_eq!(CapabilityId::new(".kv"), Err(IdError::BadDot));
112 assert_eq!(CapabilityId::new("kv."), Err(IdError::BadDot));
113 }
114
115 #[test]
116 fn is_under_matches_a_dot_boundary_only() {
117 let a = CapabilityId::new("kv.prefix_list").unwrap();
118 let b = CapabilityId::new("kv.prefix_list.exactly_once").unwrap();
119 let c = CapabilityId::new("kv.prefix_listing").unwrap();
120 assert!(b.is_under(&a) && a.is_under(&a));
121 assert!(
122 !c.is_under(&a),
123 "control: a shared string prefix is not a namespace parent"
124 );
125 assert!(!a.is_under(&b));
126 }
127}