Skip to main content

persistant_core/
matrix.rs

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