Skip to main content

persistant_core/
matrix.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use crate::{CapabilityId, Config, ParseError, Reason, Row};
3
4/// What the matrix says about one capability on one `(backend, version, config)`.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6#[non_exhaustive]
7pub enum Lookup {
8    /// A matching row says yes and no matching row says no.
9    On,
10    /// A matching row says no, for this reason.
11    Off(Reason),
12    /// No row matches: untested, therefore off.
13    NoRow,
14}
15
16/// A `capant` provider called `id` for the one place [`Matrix::lookup`] is asked about: this
17/// `backend` at this `version` with these `actual` facts. Rows match it exactly when `lookup` would
18/// match them.
19pub fn capant_provider(
20    id: &str,
21    backend: &str,
22    version: &str,
23    actual: &Config,
24) -> capant::Provider {
25    let config = actual
26        .iter()
27        .fold(capant::Config::new(), |acc, (k, v)| acc.with(k, v));
28    capant::Provider::new(id, vec![capant::Surface::new(backend, version, config)])
29        .expect("a provider with one surface has a surface")
30}
31
32/// The recorded facts of one or more matrix files, and the lookup the gate performs.
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct Matrix {
35    rows: Vec<Row>,
36}
37
38impl Matrix {
39    /// An empty matrix: everything is off.
40    pub fn new() -> Self {
41        Matrix::default()
42    }
43
44    /// Read one tab-separated file (by `capant`'s reader; see `adapter.rs`). The first non-comment
45    /// line is the header; columns are matched by name, so order does not matter and unknown
46    /// columns are kept in [`Row::extra`]. Blank lines and lines starting with `#` are skipped.
47    /// The probe id is the `probe` metadata key of the file, and the `test` key of a [`Row`]
48    /// (the older spelling of the file is read too).
49    pub fn parse(text: &str) -> Result<Matrix, ParseError> {
50        crate::adapter::parse(text).map(|rows| Matrix { rows })
51    }
52
53    /// A matrix of exactly these rows (what a conformance run produces).
54    pub fn from_rows(rows: Vec<Row>) -> Matrix {
55        Matrix { rows }
56    }
57
58    /// Add the rows of another file. A reader merges files; nothing is deduplicated.
59    pub fn merge(&mut self, other: Matrix) {
60        self.rows.extend(other.rows);
61    }
62
63    /// The rows, in file order.
64    pub fn rows(&self) -> &[Row] {
65        &self.rows
66    }
67
68    /// Write the known columns, then the sorted union of unknown columns (by `capant`'s writer;
69    /// the probe id is written as the `probe` metadata key). Round-trips what [`Matrix::parse`]
70    /// read, apart from comments, blank lines and column order. A value in `probed_at`,
71    /// `environment` or `inherited_from` that the reader could not have produced is not written.
72    pub fn to_tsv(&self) -> String {
73        crate::adapter::write(&self.rows)
74    }
75
76    /// The rows as a `capant` matrix, so that `capant` can be asked what [`Matrix::lookup`] answers.
77    /// Every row becomes a `capability` row (the lookup ignores `subject_kind` too). `Reason::Untested`
78    /// becomes `State::No(Other("untested"))`, which keeps "a `no` row wins" exact; a `yes` row with
79    /// no probe id (the `test` metadata key) is `Unverified(NoEvidence)` to `capant`.
80    pub fn to_capant(&self) -> capant::Matrix {
81        crate::adapter::view(&self.rows)
82    }
83
84    /// The gate's lookup. Rows match on `subject`, `backend` and `version` exactly, and on `config`
85    /// when every fact the row names is also in `actual`. **Off wins**: one matching `no` makes the
86    /// answer off even if another matching row says yes.
87    pub fn lookup(
88        &self,
89        subject: &CapabilityId,
90        backend: &str,
91        version: &str,
92        actual: &Config,
93    ) -> Lookup {
94        let mut on = false;
95        let mut off = None;
96        for r in &self.rows {
97            if &r.subject == subject
98                && r.backend == backend
99                && r.version == version
100                && r.config.holds_in(actual)
101            {
102                if r.enabled {
103                    on = true;
104                } else if off.is_none() {
105                    off = r.reason;
106                }
107            }
108        }
109        match (on, off) {
110            (_, Some(reason)) => Lookup::Off(reason),
111            (true, None) => Lookup::On,
112            (false, None) => Lookup::NoRow,
113        }
114    }
115
116    /// Whether the capability is on. Anything but [`Lookup::On`] is false.
117    pub fn enabled(
118        &self,
119        subject: &CapabilityId,
120        backend: &str,
121        version: &str,
122        actual: &Config,
123    ) -> bool {
124        self.lookup(subject, backend, version, actual) == Lookup::On
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    const FILE: &str = "\
133# comment
134subject_kind\tsubject\tbackend\tversion\tconfig\tenabled\treason\tmetadata\tnote\tfrom
135construct\ttext_comparison\tmysql\t8.4.11\tcollation=utf8mb4_bin\tyes\t\ttest=a::b\t\tpostgres
136construct\ttext_comparison\tmysql\t8.4.11\tcollation=utf8mb4_0900_ai_ci\tno\twrong\ttest=a::b\tdiverges\tpostgres
137construct\tcast.to_uuid\tmysql\t8.4.11\t\tno\tmissing\ttest=c::d\t\tpostgres
138";
139
140    fn id(s: &str) -> CapabilityId {
141        CapabilityId::new(s).unwrap()
142    }
143
144    #[test]
145    fn reads_a_unisql_shaped_file_and_keeps_the_extra_column() {
146        let m = Matrix::parse(FILE).unwrap();
147        assert_eq!(m.rows().len(), 3);
148        assert_eq!(
149            m.rows()[0].extra.get("from").map(String::as_str),
150            Some("postgres")
151        );
152        let again = Matrix::parse(&m.to_tsv()).unwrap();
153        assert_eq!(again, m, "round trip");
154    }
155
156    #[test]
157    fn the_gate_is_on_only_for_a_matching_yes() {
158        let m = Matrix::parse(FILE).unwrap();
159        let bin = Config::parse("collation=utf8mb4_bin").unwrap();
160        let ci = Config::parse("collation=utf8mb4_0900_ai_ci").unwrap();
161        let t = id("text_comparison");
162        assert!(m.enabled(&t, "mysql", "8.4.11", &bin));
163        assert_eq!(
164            m.lookup(&t, "mysql", "8.4.11", &ci),
165            Lookup::Off(Reason::Wrong),
166            "known-bad row"
167        );
168        assert_eq!(
169            m.lookup(&t, "mysql", "9.0.0", &bin),
170            Lookup::NoRow,
171            "unknown version is off"
172        );
173        assert_eq!(
174            m.lookup(&t, "mysql", "8.4.11", &Config::new()),
175            Lookup::NoRow,
176            "config not readable is off"
177        );
178        assert_eq!(
179            m.lookup(&id("cast.to_uuid"), "mysql", "8.4.11", &bin),
180            Lookup::Off(Reason::Missing)
181        );
182        assert_eq!(
183            m.lookup(&id("never.heard_of_it"), "mysql", "8.4.11", &bin),
184            Lookup::NoRow
185        );
186    }
187
188    #[test]
189    fn off_wins_over_a_yes() {
190        let text = "subject_kind\tsubject\tbackend\tversion\tconfig\tenabled\treason\tmetadata\tnote\n\
191                    capability\tk\tb\t1\t\tyes\t\t\t\n\
192                    capability\tk\tb\t1\tx=1\tno\twrong\t\t\n";
193        let m = Matrix::parse(text).unwrap();
194        let k = id("k");
195        assert!(m.enabled(&k, "b", "1", &Config::new()));
196        assert!(!m.enabled(&k, "b", "1", &Config::new().with("x", "1")));
197    }
198
199    #[test]
200    fn malformed_files_are_errors_not_silence() {
201        assert_eq!(Matrix::parse(""), Err(ParseError::NoHeader));
202        assert!(matches!(
203            Matrix::parse("subject\tbackend\n"),
204            Err(ParseError::MissingColumn(_))
205        ));
206        let h =
207            "subject_kind\tsubject\tbackend\tversion\tconfig\tenabled\treason\tmetadata\tnote\n";
208        let bad_yes = format!("{h}c\tk\tb\t1\t\tmaybe\t\t\t\n");
209        assert!(matches!(
210            Matrix::parse(&bad_yes),
211            Err(ParseError::BadEnabled { line: 2, .. })
212        ));
213        let no_reason = format!("{h}c\tk\tb\t1\t\tno\t\t\t\n");
214        assert_eq!(
215            Matrix::parse(&no_reason),
216            Err(ParseError::ReasonMismatch { line: 2 })
217        );
218        let yes_reason = format!("{h}c\tk\tb\t1\t\tyes\twrong\t\t\n");
219        assert_eq!(
220            Matrix::parse(&yes_reason),
221            Err(ParseError::ReasonMismatch { line: 2 })
222        );
223        let short = format!("{h}c\tk\tb\n");
224        assert!(matches!(
225            Matrix::parse(&short),
226            Err(ParseError::FieldCount { line: 2, .. })
227        ));
228    }
229
230    #[test]
231    fn merge_concatenates() {
232        let mut a = Matrix::parse(FILE).unwrap();
233        a.merge(Matrix::parse(FILE).unwrap());
234        assert_eq!(a.rows().len(), 6);
235    }
236}