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 value of one fact.
72    pub fn get(&self, key: &str) -> Option<&str> {
73        self.0.get(key).map(String::as_str)
74    }
75}
76
77impl fmt::Display for Config {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        for (i, (k, v)) in self.0.iter().enumerate() {
80            if i > 0 {
81                f.write_str(";")?;
82            }
83            write!(f, "{k}={v}")?;
84        }
85        Ok(())
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn round_trips_and_sorts() {
95        let c = Config::parse("schema=none;collation=utf8mb4_bin").unwrap();
96        assert_eq!(c.to_string(), "collation=utf8mb4_bin;schema=none");
97        assert_eq!(Config::parse(&c.to_string()).unwrap(), c);
98        assert_eq!(Config::parse("").unwrap(), Config::new());
99    }
100
101    #[test]
102    fn rejects_malformed_pieces() {
103        assert_eq!(Config::parse("a"), Err(ConfigError::BadPair("a".into())));
104        assert_eq!(Config::parse("=v"), Err(ConfigError::BadPair("=v".into())));
105        assert_eq!(
106            Config::parse("a=1;a=2"),
107            Err(ConfigError::DuplicateKey("a".into()))
108        );
109    }
110
111    #[test]
112    fn holds_in_is_a_subset_test_with_negative_controls() {
113        let row = Config::parse("collation=utf8mb4_bin").unwrap();
114        let actual = Config::parse("collation=utf8mb4_bin;schema=none").unwrap();
115        assert!(row.holds_in(&actual));
116        assert!(Config::new().holds_in(&actual));
117        let other = Config::parse("collation=utf8mb4_0900_ai_ci").unwrap();
118        assert!(
119            !row.holds_in(&other),
120            "control: a different value must not match"
121        );
122        assert!(
123            !row.holds_in(&Config::new()),
124            "control: an absent fact must not match"
125        );
126    }
127}