Skip to main content

yarp_cli/rule_pack/
model.rs

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// Serde skip predicates receive references.
231#[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// Serde skip predicates receive references.
240#[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// Serde skip predicates receive references.
249#[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}