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