1use kernel::{AxCode, AxError};
16use serde::Deserialize;
17
18use crate::sieve::CommandKey;
19
20#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Filter {
24 pub id: String,
25 pub command: String,
26 #[serde(default)]
27 pub subcommands: Vec<String>,
28 #[serde(default = "yes")]
29 pub strip_ansi: bool,
30 #[serde(default)]
31 pub drop_prefix: Vec<String>,
32 #[serde(default)]
33 pub drop_contains: Vec<String>,
34 #[serde(default)]
35 pub drop_suffix: Vec<String>,
36 #[serde(default)]
37 pub keep_prefix: Vec<String>,
38 #[serde(default)]
39 pub keep_contains: Vec<String>,
40 #[serde(default)]
41 pub keep_suffix: Vec<String>,
42 #[serde(default)]
45 pub group_until_blank: bool,
46 pub head: Option<u64>,
47 pub tail: Option<u64>,
48 pub on_empty: Option<String>,
50}
51
52fn yes() -> bool {
53 true
54}
55
56const GENERIC_ON_EMPTY: &str = "(no output kept), exit {code}";
57
58impl Filter {
59 fn empty(id: &str, command: &str) -> Filter {
60 Filter {
61 id: id.to_owned(),
62 command: command.to_owned(),
63 subcommands: Vec::new(),
64 strip_ansi: true,
65 drop_prefix: Vec::new(),
66 drop_contains: Vec::new(),
67 drop_suffix: Vec::new(),
68 keep_prefix: Vec::new(),
69 keep_contains: Vec::new(),
70 keep_suffix: Vec::new(),
71 group_until_blank: false,
72 head: None,
73 tail: None,
74 on_empty: None,
75 }
76 }
77
78 fn generic() -> Filter {
79 Filter::empty("generic", "")
80 }
81
82 fn cargo() -> Filter {
83 let mut filter = Filter::empty("cargo", "cargo");
84 filter.subcommands = own(&["build", "check", "test", "clippy", "nextest", "run", "doc"]);
85 filter.drop_prefix = own(&[
86 " Compiling ",
87 " Checking ",
88 " Finished ",
89 " Updating ",
90 " Blocking ",
91 " Downloaded ",
92 " Downloading ",
93 " Running ",
94 " Doc-tests ",
95 ]);
96 filter.keep_contains = own(&["error", "warning:", "panicked at", "test result:", "-->"]);
97 filter.group_until_blank = true;
98 filter.head = Some(20);
99 filter.tail = Some(40);
100 filter.on_empty = Some("cargo {sub}: clean, exit {code}".to_owned());
101 filter
102 }
103
104 fn git() -> Filter {
105 let mut filter = Filter::empty("git", "git");
106 filter.drop_prefix = own(&[
107 "remote: Counting objects",
108 "remote: Compressing objects",
109 "remote: Enumerating objects",
110 "remote: Resolving deltas",
111 "remote: Total ",
112 "Receiving objects",
113 "Resolving deltas",
114 "Compressing objects",
115 "Counting objects",
116 "Enumerating objects",
117 "Writing objects",
118 "Unpacking objects",
119 ]);
120 filter.keep_contains = own(&[
121 "error",
122 "fatal",
123 "CONFLICT",
124 "conflict",
125 "rejected",
126 "hint:",
127 "Your branch",
128 ]);
129 filter.on_empty = Some("git {sub}: no output, exit {code}".to_owned());
130 filter
131 }
132
133 fn hits(&self, key: &CommandKey) -> bool {
136 self.command == key.command()
137 && (self.subcommands.is_empty()
138 || key
139 .subcommand()
140 .is_some_and(|sub| self.subcommands.iter().any(|s| s == sub)))
141 }
142
143 pub(crate) fn keeps(&self, line: &str) -> bool {
146 self.keep_prefix
147 .iter()
148 .any(|p| line.starts_with(p.as_str()))
149 || self.keep_contains.iter().any(|p| line.contains(p.as_str()))
150 || self.keep_suffix.iter().any(|p| line.ends_with(p.as_str()))
151 }
152
153 pub(crate) fn drops(&self, line: &str) -> bool {
154 self.drop_prefix
155 .iter()
156 .any(|p| line.starts_with(p.as_str()))
157 || self.drop_contains.iter().any(|p| line.contains(p.as_str()))
158 || self.drop_suffix.iter().any(|p| line.ends_with(p.as_str()))
159 }
160
161 pub(crate) fn when_empty(&self, key: &CommandKey, exit_code: Option<i64>) -> String {
163 let template = self.on_empty.as_deref().unwrap_or(GENERIC_ON_EMPTY);
164 let code = exit_code.map_or_else(|| "none".to_owned(), |c| c.to_string());
165 template
166 .replace("{sub}", key.subcommand().unwrap_or(""))
167 .replace("{code}", &code)
168 }
169}
170
171fn own(items: &[&str]) -> Vec<String> {
172 items.iter().map(|s| (*s).to_owned()).collect()
173}
174
175#[derive(Deserialize)]
176struct TableFile {
177 #[serde(default)]
178 filter: Vec<Filter>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct FilterTable {
185 filters: Vec<Filter>,
186 generic: Filter,
187}
188
189impl FilterTable {
190 #[must_use]
192 pub fn builtin() -> FilterTable {
193 FilterTable {
194 filters: vec![Filter::cargo(), Filter::git()],
195 generic: Filter::generic(),
196 }
197 }
198
199 pub fn parse(toml_text: &str) -> Result<FilterTable, AxError> {
202 let file: TableFile = toml::from_str(toml_text).map_err(|err| {
203 AxError::failure(AxCode::InvalidArgs, "parse filter table", err.to_string())
204 .with_recovery("fix FILTERS.toml; predicates are prefix, contains, suffix")
205 })?;
206 Ok(FilterTable {
207 filters: file.filter,
208 generic: Filter::generic(),
209 })
210 }
211
212 pub fn resolve(city: Option<&str>, building: Option<&str>) -> Result<FilterTable, AxError> {
215 match building.or(city) {
216 Some(text) => FilterTable::parse(text),
217 None => Ok(FilterTable::builtin()),
218 }
219 }
220
221 #[must_use]
223 pub fn lookup(&self, key: &CommandKey) -> &Filter {
224 self.filters
225 .iter()
226 .find(|filter| filter.hits(key))
227 .unwrap_or(&self.generic)
228 }
229}
230
231#[cfg(test)]
232#[allow(
233 clippy::unwrap_used,
234 clippy::expect_used,
235 clippy::panic,
236 reason = "test code"
237)]
238mod tests {
239 use super::*;
240 use kernel::ExecArm;
241
242 fn key(path: &str, args: &[&str]) -> CommandKey {
243 CommandKey::of(&ExecArm::Program {
244 path: path.to_owned(),
245 args: own(args),
246 })
247 }
248
249 #[test]
250 fn a_command_hits_by_file_name_and_subcommand() {
251 let table = FilterTable::builtin();
252 assert_eq!(table.lookup(&key("/usr/bin/cargo", &["build"])).id, "cargo");
253 assert_eq!(
254 table.lookup(&key("CARGO.EXE", &["--quiet", "test"])).id,
255 "cargo"
256 );
257 assert_eq!(table.lookup(&key("cargo", &["publish"])).id, "generic");
258 assert_eq!(table.lookup(&key("git", &["status"])).id, "git");
259 assert_eq!(table.lookup(&key("git", &[])).id, "git");
260 assert_eq!(table.lookup(&key("ls", &[])).id, "generic");
261 }
262
263 #[test]
264 fn a_keep_wins_over_a_drop_and_the_empty_line_is_filled_in() {
265 let cargo = Filter::cargo();
266 assert!(cargo.drops(" Compiling error v1"));
267 assert!(cargo.keeps(" Compiling error v1"));
268 assert_eq!(
269 cargo.when_empty(&key("cargo", &["build"]), Some(0)),
270 "cargo build: clean, exit 0"
271 );
272 assert_eq!(
273 Filter::generic().when_empty(&key("ls", &[]), None),
274 "(no output kept), exit none"
275 );
276 }
277
278 #[test]
279 fn unknown_fields_and_missing_ids_are_refused() {
280 assert!(
281 FilterTable::parse("[[filter]]\nid = \"x\"\ncommand = \"x\"\nregex = \".*\"\n")
282 .is_err()
283 );
284 assert!(FilterTable::parse("[[filter]]\ncommand = \"x\"\n").is_err());
285 let table =
286 FilterTable::parse("[[filter]]\nid = \"x\"\ncommand = \"x\"\nhead = 3\n").unwrap();
287 assert_eq!(table.lookup(&key("x", &[])).head, Some(3));
288 assert!(table.lookup(&key("x", &[])).strip_ansi);
289 }
290}