Skip to main content

persistant_core/
id.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use std::fmt;
3
4/// Why a string is not a capability id.
5#[derive(Debug, Clone, PartialEq, Eq)]
6#[non_exhaustive]
7pub enum IdError {
8    /// The string is empty.
9    Empty,
10    /// A character outside `a-z 0-9 _ . :` at this byte offset.
11    BadChar { at: usize },
12    /// A leading, trailing or doubled `.`.
13    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/// A capability id such as `kv.prefix_list`, `cast.to_uuid` or `fn:max`.
34///
35/// Lower-case ASCII, digits, `_`, `.` as the namespace separator and `:` (as in `fn:max`). An id
36/// names a behaviour or feature, never a backend, and never changes meaning: a change in meaning is
37/// a new id. Ordering is plain string ordering so files diff stably.
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub struct CapabilityId(String);
40
41impl CapabilityId {
42    /// Validate and wrap an id.
43    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    /// The id as a string.
60    pub fn as_str(&self) -> &str {
61        &self.0
62    }
63
64    /// Whether this id is `other` or sits beneath it by name (`kv.prefix_list.exactly_once` is
65    /// under `kv.prefix_list`). Naming only: it says nothing about which implies which; that is a
66    /// chain the vocabulary declares, not a string prefix.
67    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}