1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Class {
9 Mechanical,
11 Privileged,
14 Specified,
16 Judgment,
19 Never,
22}
23
24pub const PRIVILEGED: [&str; 4] = [".github/**", "LICENSE", "COPYING", "**/.netrc"];
29
30pub const OWNED: [&str; 1] = ["TODO.md"];
35
36const MANIFESTS: [&str; 10] = [
39 "Cargo.toml",
40 "Cargo.lock",
41 "go.mod",
42 "go.sum",
43 "pyproject.toml",
44 "uv.lock",
45 "requirements*.txt",
46 "package.json",
47 "package-lock.json",
48 "*.lock",
49];
50
51impl Class {
52 const ALL: [(&'static str, Class); 5] = [
53 ("A", Class::Mechanical),
54 ("A-", Class::Privileged),
55 ("B", Class::Specified),
56 ("C", Class::Judgment),
57 ("D", Class::Never),
58 ];
59
60 pub fn name(self) -> &'static str {
61 Self::ALL
62 .iter()
63 .find(|(_, c)| *c == self)
64 .map_or("", |(n, _)| n)
65 }
66
67 pub fn parse(s: &str) -> Option<Class> {
68 Self::ALL.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
69 }
70
71 pub fn of(key: &str, tags: &[String]) -> Class {
77 if tags.iter().any(|t| t == "manual") {
78 return Class::Never;
79 }
80 match key {
81 "deps" => Class::Mechanical,
82 _ => Class::Specified,
83 }
84 }
85
86 pub fn scope(self) -> Vec<String> {
90 match self {
91 Class::Mechanical => MANIFESTS.iter().map(|s| (*s).into()).collect(),
92 Class::Privileged => vec![".github/**".into()],
93 _ => Vec::new(),
94 }
95 }
96
97 pub fn violations<'a>(self, scope: &[String], paths: &'a [String]) -> Vec<&'a str> {
102 paths
103 .iter()
104 .filter(|p| {
105 if OWNED.iter().any(|g| crate::scan::glob_match(g, p.as_str())) {
106 return true;
107 }
108 let privileged = PRIVILEGED
109 .iter()
110 .any(|g| crate::scan::glob_match(g, p.as_str()));
111 if privileged {
112 return self != Class::Privileged;
113 }
114 !scope.is_empty() && !scope.iter().any(|g| crate::scan::glob_match(g, p.as_str()))
115 })
116 .map(String::as_str)
117 .collect()
118 }
119
120 pub fn dispatchable(self) -> bool {
122 self != Class::Never
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 fn tags(list: &[&str]) -> Vec<String> {
131 list.iter().map(|s| (*s).to_string()).collect()
132 }
133
134 #[test]
135 fn signals_and_tags_decide_the_class() {
136 assert_eq!(Class::of("deps", &[]), Class::Mechanical);
137 assert_eq!(Class::of("ci", &[]), Class::Specified);
138 assert_eq!(Class::of("fix the parser", &[]), Class::Specified);
139 assert_eq!(
140 Class::of("fix the parser", &tags(["bug"].as_ref())),
141 Class::Specified
142 );
143 assert_eq!(
145 Class::of("fix it", &tags(["agent"].as_ref())),
146 Class::Specified
147 );
148 }
149
150 #[test]
151 fn manual_is_never_dispatched_even_as_a_signal() {
152 assert_eq!(Class::of("deps", &tags(["manual"].as_ref())), Class::Never);
153 assert_eq!(Class::of("ci", &tags(["manual"].as_ref())), Class::Never);
154 assert!(!Class::Never.dispatchable());
155 assert!(Class::Mechanical.dispatchable());
156 }
157
158 #[test]
159 fn only_mechanical_classes_bound_their_paths() {
160 assert!(
161 Class::Mechanical
162 .scope()
163 .contains(&"Cargo.lock".to_string())
164 );
165 assert_eq!(Class::Privileged.scope(), [".github/**"]);
166 assert!(
167 Class::Specified.scope().is_empty(),
168 "the task names its files"
169 );
170 }
171
172 fn paths(list: &[&str]) -> Vec<String> {
173 list.iter().map(|s| (*s).to_string()).collect()
174 }
175
176 #[test]
177 fn a_bounded_class_refuses_paths_outside_its_globs() {
178 let a = Class::Mechanical;
179 let scope = a.scope();
180 assert!(
181 a.violations(&scope, &paths(&["Cargo.lock", "sub/Cargo.toml", "uv.lock"]))
182 .is_empty()
183 );
184 assert_eq!(
185 a.violations(&scope, &paths(&["Cargo.lock", "src/main.rs"])),
186 ["src/main.rs"]
187 );
188 }
189
190 #[test]
191 fn an_unbounded_class_still_refuses_privileged_paths() {
192 let b = Class::Specified;
193 assert!(b.scope().is_empty());
194 assert!(
195 b.violations(&[], &paths(&["src/main.rs", "tests/cli.rs"]))
196 .is_empty()
197 );
198 assert_eq!(
199 b.violations(
200 &[],
201 &paths(&[".github/workflows/ci.yml", "LICENSE", "a/.netrc"])
202 ),
203 [".github/workflows/ci.yml", "LICENSE", "a/.netrc"]
204 );
205 }
206
207 #[test]
210 fn a_misclassified_workflow_edit_cannot_pass() {
211 let a = Class::Mechanical;
212 assert_eq!(
213 a.violations(&a.scope(), &paths(&[".github/workflows/ci.yml"])),
214 [".github/workflows/ci.yml"]
215 );
216 let p = Class::Privileged;
218 assert!(
219 p.violations(&p.scope(), &paths(&[".github/workflows/ci.yml"]))
220 .is_empty()
221 );
222 assert_eq!(
223 p.violations(&p.scope(), &paths(&["src/main.rs"])),
224 ["src/main.rs"]
225 );
226 }
227
228 #[test]
231 fn no_class_may_change_a_file_pma_writes() {
232 for class in [Class::Mechanical, Class::Privileged, Class::Specified] {
233 assert_eq!(
234 class.violations(&class.scope(), &paths(&["TODO.md"])),
235 ["TODO.md"],
236 "{class:?}"
237 );
238 }
239 }
240
241 #[test]
242 fn names_round_trip() {
243 for (name, class) in Class::ALL {
244 assert_eq!(Class::parse(name), Some(class));
245 assert_eq!(class.name(), name);
246 }
247 assert_eq!(Class::parse("A+"), None);
248 }
249}