Skip to main content

runtime/sieve/
filter.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The filter table: what `FILTERS.toml` may say, the three reducers
7//! compiled in, and which entry a command key hits.
8//!
9//! Predicates are prefix, contains and suffix only. A user table and
10//! a built-in entry are the same type, so a building's table can
11//! replace `cargo`'s verdict without touching code. The ladder is
12//! whole-value: the lowest layer that speaks replaces the table above
13//! it entirely, so an under-specified layer can only narrow.
14
15use kernel::{AxCode, AxError};
16use serde::Deserialize;
17
18use crate::sieve::CommandKey;
19
20/// One entry. Everything but `id` and `command` has a default.
21#[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    /// A kept line pulls the lines after it, up to the next blank one,
43    /// into the same candidate. What a rustc diagnostic needs.
44    #[serde(default)]
45    pub group_until_blank: bool,
46    pub head: Option<u64>,
47    pub tail: Option<u64>,
48    /// Said when nothing survives. `{sub}` and `{code}` are filled in.
49    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    /// Whether this entry answers for `key`: the command, and the
134    /// subcommand when the entry names any.
135    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    /// A line the table says to keep. Checked before `drops`, so a keep
144    /// always wins over a drop.
145    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    /// The line said in place of nothing.
162    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/// The entries in the order they answer, and the generic entry behind
182/// them all.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct FilterTable {
185    filters: Vec<Filter>,
186    generic: Filter,
187}
188
189impl FilterTable {
190    /// Exactly three reducers ship: `cargo`, `git`, and `generic`.
191    #[must_use]
192    pub fn builtin() -> FilterTable {
193        FilterTable {
194            filters: vec![Filter::cargo(), Filter::git()],
195            generic: Filter::generic(),
196        }
197    }
198
199    /// Reads one `FILTERS.toml`. A table that does not parse is refused
200    /// rather than partially honoured.
201    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    /// Whole-value override across the ladder: the building's table when
213    /// it has one, else the city's, else the built-in three.
214    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    /// The first entry that answers for the key, or generic.
222    #[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}