1use crate::row::COLUMNS;
3use crate::{CapabilityId, Config, ParseError, Reason, Row};
4use std::collections::BTreeSet;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum Lookup {
10 On,
12 Off(Reason),
14 NoRow,
16}
17
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct Matrix {
21 rows: Vec<Row>,
22}
23
24impl Matrix {
25 pub fn new() -> Self {
27 Matrix::default()
28 }
29
30 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 pub fn from_rows(rows: Vec<Row>) -> Matrix {
72 Matrix { rows }
73 }
74
75 pub fn merge(&mut self, other: Matrix) {
77 self.rows.extend(other.rows);
78 }
79
80 pub fn rows(&self) -> &[Row] {
82 &self.rows
83 }
84
85 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 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 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}