Skip to main content

persistant_core/
config.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use std::collections::BTreeMap;
3use std::fmt;
4
5/// Why a `k=v;k=v` string is not a [`Config`].
6#[derive(Debug, Clone, PartialEq, Eq)]
7#[non_exhaustive]
8pub enum ConfigError {
9    /// A piece without `=` or with an empty key.
10    BadPair(String),
11    /// The same key twice.
12    DuplicateKey(String),
13}
14
15impl fmt::Display for ConfigError {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            ConfigError::BadPair(p) => write!(f, "config piece {p:?} is not key=value"),
19            ConfigError::DuplicateKey(k) => write!(f, "config key {k:?} appears twice"),
20        }
21    }
22}
23
24impl std::error::Error for ConfigError {}
25
26/// Facts a gate can read at open, as `k=v;k=v` (a locale, a collation, `schema=none`).
27///
28/// Keys are kept sorted, so the string form is canonical and files diff stably. Values may not
29/// contain `;` or a tab.
30#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub struct Config(BTreeMap<String, String>);
32
33impl Config {
34    /// The empty configuration.
35    pub fn new() -> Self {
36        Config::default()
37    }
38
39    /// Parse `k=v;k=v`. The empty string is the empty configuration.
40    pub fn parse(s: &str) -> Result<Self, ConfigError> {
41        let mut map = BTreeMap::new();
42        if s.is_empty() {
43            return Ok(Config(map));
44        }
45        for piece in s.split(';') {
46            match piece.split_once('=') {
47                Some((k, v)) if !k.is_empty() => {
48                    if map.insert(k.to_string(), v.to_string()).is_some() {
49                        return Err(ConfigError::DuplicateKey(k.to_string()));
50                    }
51                }
52                _ => return Err(ConfigError::BadPair(piece.to_string())),
53            }
54        }
55        Ok(Config(map))
56    }
57
58    /// Add or replace one fact.
59    pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
60        self.0.insert(key.into(), value.into());
61        self
62    }
63
64    /// Whether every fact of `self` is also true of `actual`. A row recorded under
65    /// `collation=utf8mb4_bin` holds for a backend whose readable facts include that one; a fact
66    /// the row does not name is not constrained.
67    pub fn holds_in(&self, actual: &Config) -> bool {
68        self.0.iter().all(|(k, v)| actual.0.get(k) == Some(v))
69    }
70
71    /// The facts, sorted by key.
72    pub(crate) fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
73        self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
74    }
75
76    /// The value of one fact.
77    pub fn get(&self, key: &str) -> Option<&str> {
78        self.0.get(key).map(String::as_str)
79    }
80}
81
82impl fmt::Display for Config {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        for (i, (k, v)) in self.0.iter().enumerate() {
85            if i > 0 {
86                f.write_str(";")?;
87            }
88            write!(f, "{k}={v}")?;
89        }
90        Ok(())
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn round_trips_and_sorts() {
100        let c = Config::parse("schema=none;collation=utf8mb4_bin").unwrap();
101        assert_eq!(c.to_string(), "collation=utf8mb4_bin;schema=none");
102        assert_eq!(Config::parse(&c.to_string()).unwrap(), c);
103        assert_eq!(Config::parse("").unwrap(), Config::new());
104    }
105
106    #[test]
107    fn rejects_malformed_pieces() {
108        assert_eq!(Config::parse("a"), Err(ConfigError::BadPair("a".into())));
109        assert_eq!(Config::parse("=v"), Err(ConfigError::BadPair("=v".into())));
110        assert_eq!(
111            Config::parse("a=1;a=2"),
112            Err(ConfigError::DuplicateKey("a".into()))
113        );
114    }
115
116    #[test]
117    fn holds_in_is_a_subset_test_with_negative_controls() {
118        let row = Config::parse("collation=utf8mb4_bin").unwrap();
119        let actual = Config::parse("collation=utf8mb4_bin;schema=none").unwrap();
120        assert!(row.holds_in(&actual));
121        assert!(Config::new().holds_in(&actual));
122        let other = Config::parse("collation=utf8mb4_0900_ai_ci").unwrap();
123        assert!(
124            !row.holds_in(&other),
125            "control: a different value must not match"
126        );
127        assert!(
128            !row.holds_in(&Config::new()),
129            "control: an absent fact must not match"
130        );
131    }
132}