1use serde::{Deserialize, Serialize};
2
3pub const SOURCE_SCHEMA_VERSION: u32 = 1;
4pub const ENGINE_ABI_VERSION: u16 = 2;
5
6#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7#[serde(deny_unknown_fields)]
8pub struct PackManifest {
9 pub schema_version: u32,
10 pub id: String,
11 pub rules: Vec<String>,
12}
13
14#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
15#[serde(deny_unknown_fields)]
16pub struct Rule {
17 pub id: String,
18 #[serde(rename = "match")]
19 pub matcher: CommandMatcher,
20 pub action: Action,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub transform: Option<Transform>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub reducer: Option<Reducer>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub success: Option<OutputPolicy>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub failure: Option<OutputPolicy>,
29}
30
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
32#[serde(deny_unknown_fields)]
33pub struct CommandMatcher {
34 pub program: Vec<String>,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub argv_prefix: Vec<String>,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub argv_contains_all: Vec<String>,
39}
40
41#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Action {
44 Reduce,
45 Transform,
46 Passthrough,
47}
48
49#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
50#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
51pub enum Transform {
52 LinePreserving,
53}
54
55#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
56#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
57pub enum Reducer {
58 SearchSummary,
59 DiffSummary,
60 TestSummary,
61 BuildSummary,
62 LogSummary,
63 StatusSummary,
64 ListSummary,
65 LineFilter {
66 #[serde(default, skip_serializing_if = "is_false")]
67 strip_ansi: bool,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 drop: Vec<LinePattern>,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 keep: Vec<LinePattern>,
72 },
73}
74
75#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
76#[serde(deny_unknown_fields)]
77pub struct LinePattern {
78 pub kind: PatternKind,
79 pub value: String,
80 #[serde(default, skip_serializing_if = "is_sensitive")]
81 pub case: PatternCase,
82 #[serde(default, skip_serializing_if = "is_no_trim")]
83 pub trim: PatternTrim,
84}
85
86#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
87#[serde(rename_all = "snake_case")]
88pub enum PatternKind {
89 Exact,
90 Prefix,
91 Suffix,
92 Contains,
93}
94
95#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
96#[serde(rename_all = "snake_case")]
97pub enum PatternCase {
98 #[default]
99 Sensitive,
100 AsciiInsensitive,
101}
102
103#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
104#[serde(rename_all = "snake_case")]
105pub enum PatternTrim {
106 #[default]
107 None,
108 Start,
109 Both,
110}
111
112#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
113#[serde(deny_unknown_fields)]
114pub struct OutputPolicy {
115 pub max_line_bytes: usize,
116 pub max_output_bytes: usize,
117 pub min_savings_bytes: usize,
118 pub min_savings_basis_points: u16,
119}
120
121impl CommandMatcher {
122 #[must_use]
123 pub fn matches(&self, arguments: &[String]) -> bool {
124 let Some(program) = arguments.first() else {
125 return false;
126 };
127 if !self.program.iter().any(|candidate| candidate == program) {
128 return false;
129 }
130 let tail = normalized_arguments(program, &arguments[1..]);
131 if !tail.starts_with(&self.argv_prefix) {
132 return false;
133 }
134 self.argv_contains_all.iter().all(|required| {
135 tail.iter()
136 .any(|argument| contains_normalized_argument(argument, required))
137 })
138 }
139}
140
141fn contains_normalized_argument(argument: &str, required: &str) -> bool {
142 argument == required
143 || (required.starts_with("--")
144 && argument
145 .strip_prefix(required)
146 .is_some_and(|suffix| suffix.starts_with('=')))
147 || contains_short_option(argument, required)
148}
149
150fn contains_short_option(argument: &str, required: &str) -> bool {
151 let required = required.as_bytes();
152 let argument = argument.as_bytes();
153 required.len() == 2
154 && required[0] == b'-'
155 && required[1] != b'-'
156 && argument.len() > 2
157 && argument[0] == b'-'
158 && argument[1] != b'-'
159 && argument[1..].contains(&required[1])
160}
161
162fn normalized_arguments<'a>(program: &str, arguments: &'a [String]) -> &'a [String] {
163 let mut index = 0;
164 while index < arguments.len() {
165 let argument = arguments[index].as_str();
166 let takes_value = match program {
167 "git" => matches!(argument, "-C" | "-c" | "--git-dir" | "--work-tree"),
168 "npm" => matches!(argument, "--prefix" | "-w" | "--workspace"),
169 "pnpm" | "yarn" | "bun" => {
170 matches!(argument, "-C" | "--dir" | "--filter")
171 }
172 "uv" => matches!(argument, "--project"),
173 "gh" => matches!(argument, "-R" | "--repo"),
174 _ => false,
175 };
176 if takes_value {
177 if index + 1 >= arguments.len() {
178 return arguments;
179 }
180 index += 2;
181 continue;
182 }
183 let standalone = match program {
184 "git" => {
185 argument.starts_with("--git-dir=")
186 || argument.starts_with("--work-tree=")
187 || matches!(
188 argument,
189 "--no-pager"
190 | "--paginate"
191 | "--literal-pathspecs"
192 | "--glob-pathspecs"
193 | "--noglob-pathspecs"
194 )
195 }
196 "npm" => {
197 argument.starts_with("--prefix=")
198 || argument.starts_with("--workspace=")
199 || matches!(argument, "--workspaces" | "-ws" | "-s" | "--silent")
200 }
201 "pnpm" | "yarn" | "bun" => {
202 argument.starts_with("--dir=")
203 || argument.starts_with("--filter=")
204 || matches!(
205 argument,
206 "-r" | "--recursive"
207 | "-w"
208 | "--workspace-root"
209 | "--parallel"
210 | "--stream"
211 | "--aggregate-output"
212 | "-s"
213 | "--silent"
214 )
215 }
216 "uv" => argument.starts_with("--project="),
217 "gh" => argument.starts_with("--repo="),
218 "cargo" => argument.starts_with('+') && argument.len() > 1,
219 _ => false,
220 };
221 if standalone {
222 index += 1;
223 continue;
224 }
225 break;
226 }
227 &arguments[index..]
228}
229
230#[allow(
232 clippy::trivially_copy_pass_by_ref,
233 reason = "Serde skip predicates receive references"
234)]
235const fn is_false(value: &bool) -> bool {
236 !*value
237}
238
239#[allow(
241 clippy::trivially_copy_pass_by_ref,
242 reason = "Serde skip predicates receive references"
243)]
244const fn is_sensitive(value: &PatternCase) -> bool {
245 matches!(value, PatternCase::Sensitive)
246}
247
248#[allow(
250 clippy::trivially_copy_pass_by_ref,
251 reason = "Serde skip predicates receive references"
252)]
253const fn is_no_trim(value: &PatternTrim) -> bool {
254 matches!(value, PatternTrim::None)
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn matcher(program: &str, prefix: &[&str]) -> CommandMatcher {
262 CommandMatcher {
263 program: vec![program.to_owned()],
264 argv_prefix: prefix.iter().map(ToString::to_string).collect(),
265 argv_contains_all: Vec::new(),
266 }
267 }
268
269 fn arguments(values: &[&str]) -> Vec<String> {
270 values.iter().map(ToString::to_string).collect()
271 }
272
273 #[test]
274 fn skips_reviewed_program_level_options() {
275 assert!(matcher("git", &["diff"]).matches(&arguments(&[
276 "git",
277 "-C",
278 "/repo",
279 "--no-pager",
280 "diff",
281 "--check"
282 ])));
283 assert!(
284 matcher("pnpm", &["test"]).matches(&arguments(&["pnpm", "-C", "frontend", "test"]))
285 );
286 assert!(matcher("pnpm", &["lint"]).matches(&arguments(&[
287 "pnpm", "--filter", "web", "-r", "--silent", "lint"
288 ])));
289 assert!(matcher("npm", &["run", "test"]).matches(&arguments(&[
290 "npm",
291 "--workspaces",
292 "run",
293 "test"
294 ])));
295 assert!(matcher("cargo", &["test"]).matches(&arguments(&["cargo", "+nightly", "test"])));
296 }
297
298 #[test]
299 fn recognizes_reviewed_option_assignment_forms() {
300 let long = CommandMatcher {
301 program: vec!["gh".to_owned()],
302 argv_prefix: vec!["pr".to_owned(), "view".to_owned()],
303 argv_contains_all: vec!["--json".to_owned()],
304 };
305 assert!(long.matches(&arguments(&["gh", "pr", "view", "--json=number,title"])));
306
307 let short = CommandMatcher {
308 program: vec!["kubectl".to_owned()],
309 argv_prefix: Vec::new(),
310 argv_contains_all: vec!["-o".to_owned()],
311 };
312 assert!(short.matches(&arguments(&["kubectl", "get", "pods", "-ojson"])));
313
314 let bundled = CommandMatcher {
315 program: vec!["git".to_owned()],
316 argv_prefix: vec!["status".to_owned()],
317 argv_contains_all: vec!["-z".to_owned()],
318 };
319 assert!(bundled.matches(&arguments(&["git", "status", "-sbz"])));
320 }
321
322 #[test]
323 fn does_not_skip_unknown_options_or_missing_values() {
324 assert!(!matcher("git", &["diff"]).matches(&arguments(&["git", "--unknown", "diff"])));
325 assert!(!matcher("git", &["diff"]).matches(&arguments(&["git", "-C"])));
326 }
327}