1mod git;
2
3use std::{
4 collections::BTreeMap,
5 path::{Path, PathBuf},
6};
7
8pub use git::GitEngine;
9
10use super::parser::Parser;
11
12pub trait Engine {
13 fn matches(
17 &self,
18 patterns: impl IntoIterator<Item = impl AsRef<Path>>,
19 ) -> impl Iterator<Item = Result<PathBuf, PathBuf>>;
20
21 fn resolve(&self, path: impl AsRef<Path>) -> PathBuf;
23
24 fn is_ignored(&self, path: impl AsRef<Path>) -> bool;
26
27 fn is_range_modified(&self, path: impl AsRef<Path>, range: (usize, usize)) -> bool;
29
30 fn check(&self, path: impl AsRef<Path>) -> Result<(), Vec<String>> {
32 let path = path.as_ref();
33 let parser = match Parser::new(path, self.resolve(path)) {
34 Ok(parser) => parser,
35 Err(error) => return Err(vec![format!("Could not open {path:?}: {error}")]),
36 };
37
38 let mut errors = Vec::new();
39 for block in parser {
40 let block = match block {
41 Ok(block) => block,
42 Err(error) => {
43 errors.extend(error);
44 continue;
45 }
46 };
47
48 if !self.is_range_modified(path, block.range) {
49 continue;
50 }
51
52 let resolved_patterns = block
54 .patterns
55 .into_iter()
56 .map(|mut pattern| {
57 pattern.value = if pattern.value == Path::new("") {
59 path.to_owned()
60 } else {
61 path.parent().unwrap().join(&pattern.value)
62 };
63 pattern
64 })
65 .collect::<Vec<_>>();
66
67 let mut named_patterns = BTreeMap::new();
68 let mut unnamed_patterns = BTreeMap::new();
69 for pattern in &resolved_patterns {
70 let Some(name) = &pattern.name else {
71 unnamed_patterns.insert(&*pattern.value, pattern.line);
72 continue;
73 };
74 named_patterns.insert(&*pattern.value, (&**name, pattern.line));
75 }
76
77 for pattern in self.matches(unnamed_patterns.keys()).flat_map(Result::err) {
78 let line = unnamed_patterns.get(&*pattern).unwrap();
79 errors.push(format!(
80 "Expected {pattern:?} to be modified because of \"then-change\" in {path:?} at line {line}."
81 ));
82 }
83
84 for (pattern, (name, line)) in named_patterns {
85 for result in self.matches([pattern]) {
86 let dependent = match result {
87 Ok(path) => path,
88 Err(pattern) => {
89 errors.push(format!(
90 "Expected {pattern:?} to be modified because of \"then-change\" in {path:?} at line {line}."
91 ));
92 continue;
93 }
94 };
95
96 let mut parser = match Parser::new(&dependent, self.resolve(&dependent)) {
98 Ok(parser) => parser,
99 Err(error) => {
100 errors.push(format!(
101 "Could not open {dependent:?} for \"then-change\" in {path:?} at line {line}: {error:?}"
102 ));
103 continue;
104 }
105 };
106
107 let Some(block) = parser.find_map(|block| match block {
109 Ok(block) if block.name.as_deref() == Some(name) => Some(Ok(block)),
110 Err(error) => Some(Err(error)),
111 _ => None,
112 }) else {
113 errors.push(format!(
114 "Could not find \"if-changed\" with name \"{name}\" in {dependent:?} for \"then-change\" in {path:?} at line {line}."
115 ));
116 continue;
117 };
118
119 match block {
120 Ok(block) => {
121 if !self.is_range_modified(&dependent, block.range) {
122 errors.push(format!(
123 "Expected {dependent:?} to be modified because of \"then-change\" in {path:?} at line {line}."
124 ));
125 }
126 }
127 Err(error) => errors.extend(error),
128 }
129 }
130 }
131 }
132
133 if errors.is_empty() {
134 Ok(())
135 } else {
136 Err(errors)
137 }
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use std::path::Path;
144
145 use indoc::indoc;
146
147 use crate::{engine::GitEngine, testing::git_test, Engine as _};
148
149 #[test]
150 fn test_check() {
151 let (tempdir, repo) = git_test! {
152 "initial commit": [
153 "src/a.js" => indoc!{"
154 // if-changed
155 foo
156 // then-change(b.js)
157 "},
158 "src/b.js" => ""
159 ]
160 working: [
161 "src/a.js" => indoc!{"
162 // if-changed
163 foobar
164 // then-change(b.js)
165 "},
166 "src/b.js" => "bar"
167 ]
168 };
169
170 let engine = GitEngine::new(&repo, None, None);
171 assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
172
173 insta::assert_compact_json_snapshot!(engine.matches([""; 0]).collect::<Vec<_>>(), @r###"[{"Ok": "src/a.js"}, {"Ok": "src/b.js"}]"###);
174 insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###"{"Ok": null}"###);
175 }
176
177 #[test]
178 fn test_check_fail() {
179 let (tempdir, repo) = git_test! {
180 "initial commit": [
181 "src/a.js" => indoc!{"
182 // if-changed
183 foo
184 // then-change(b.js)
185 "},
186 "src/b.js" => ""
187 ]
188 working: [
189 "src/a.js" => indoc!{"
190 // if-changed
191 foobar
192 // then-change(b.js)
193 "}
194 ]
195 };
196
197 let engine = GitEngine::new(&repo, None, None);
198 assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
199
200 insta::assert_compact_json_snapshot!(engine.matches(["";0]).collect::<Vec<_>>(), @r###"[{"Ok": "src/a.js"}]"###);
201 insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###"{"Err": ["Expected \"src/b.js\" to be modified because of \"then-change\" in \"src/a.js\" at line 3."]}"###);
202 }
203
204 #[test]
205 fn test_check_unrelated() {
206 let (tempdir, repo) = git_test! {
207 "initial commit": [
208 "src/a.js" => indoc!{"
209 // if-changed
210 foo
211 // then-change(b.js)
212 "},
213 "src/b.js" => ""
214 ]
215 working: [
216 "src/a.js" => indoc!{"
217 // if-changed
218 foo
219 // then-change(b.js)
220 this
221 "}
222 ]
223 };
224
225 let engine = GitEngine::new(&repo, None, None);
226 assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
227
228 insta::assert_compact_json_snapshot!(engine.matches(["";0]).collect::<Vec<_>>(), @r###"[{"Ok": "src/a.js"}]"###);
229 insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###"{"Ok": null}"###);
230 }
231
232 #[test]
233 fn test_check_missing_file() {
234 let (tempdir, repo) = git_test! {};
235
236 let engine = GitEngine::new(&repo, None, None);
237 assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
238
239 assert!(engine
240 .check(Path::new("a.js"))
241 .unwrap_err()
242 .first()
243 .unwrap()
244 .contains("Could not open \"a.js\""));
245 }
246
247 #[test]
248 fn test_check_named() {
249 let (tempdir, repo) = git_test! {
250 "initial commit": [
251 "src/a.js" => indoc!{"
252 // if-changed
253 foo
254 // then-change(b.js:bar)
255 "},
256 "src/b.js" => indoc!{"
257 // if-changed(bar)
258 foo
259 // then-change(a.js)
260 "}
261 ]
262 working: [
263 "src/a.js" => indoc!{"
264 // if-changed
265 foobar
266 // then-change(b.js:bar)
267 "},
268 "src/b.js" => indoc!{"
269 // if-changed(bar)
270 foobar
271 // then-change(a.js)
272 "}
273 ]
274 };
275
276 let engine = GitEngine::new(&repo, None, None);
277 assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
278
279 insta::assert_compact_json_snapshot!(engine.matches([""; 0]).collect::<Vec<_>>(), @r###"[{"Ok": "src/a.js"}, {"Ok": "src/b.js"}]"###);
280 insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###"{"Ok": null}"###);
281 }
282
283 #[test]
284 fn test_check_named_fail() {
285 let (tempdir, repo) = git_test! {
286 "initial commit": [
287 "src/a.js" => indoc!{"
288 // if-changed
289 foo
290 // then-change(b.js:bar)
291 "},
292 "src/b.js" => indoc!{"
293 // if-changed(bar)
294 foo
295 // then-change(a.js)
296 "}
297 ]
298 working: [
299 "src/a.js" => indoc!{"
300 // if-changed
301 foobar
302 // then-change(b.js:bar)
303 "},
304 "src/b.js" => indoc!{"
305 // if-changed(bar)
306 foo
307 // then-change(a.js)
308 bar
309 "}
310 ]
311 };
312
313 let engine = GitEngine::new(&repo, None, None);
314 assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
315
316 insta::assert_compact_json_snapshot!(engine.matches([""; 0]).collect::<Vec<_>>(), @r###"[{"Ok": "src/a.js"}, {"Ok": "src/b.js"}]"###);
317 insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###"{"Err": ["Expected \"src/b.js\" to be modified because of \"then-change\" in \"src/a.js\" at line 3."]}"###);
318 }
319
320 #[test]
321 fn test_check_named_missing() {
322 let (tempdir, repo) = git_test! {
323 "initial commit": [
324 "src/a.js" => indoc!{"
325 // if-changed
326 foo
327 // then-change(b.js:bar)
328 "},
329 "src/b.js" => ""
330 ]
331 working: [
332 "src/a.js" => indoc!{"
333 // if-changed
334 foobar
335 // then-change(b.js:bar)
336 "},
337 "src/b.js" => "foo"
338 ]
339 };
340
341 let engine = GitEngine::new(&repo, None, None);
342 assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
343
344 insta::assert_compact_json_snapshot!(engine.matches([""; 0]).collect::<Vec<_>>(), @r###"[{"Ok": "src/a.js"}, {"Ok": "src/b.js"}]"###);
345 insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###"
346 {
347 "Err": [
348 "Could not find \"if-changed\" with name \"bar\" in \"src/b.js\" for \"then-change\" in \"src/a.js\" at line 3."
349 ]
350 }
351 "###);
352 }
353
354 #[test]
355 fn test_check_empty_then_change() {
356 let (tempdir, repo) = git_test! {
357 working: [
358 "a.js" => indoc!{"
359 // if-changed
360 foo
361 // then-change(
362 "}
363 ]
364 };
365
366 let engine = GitEngine::new(&repo, None, None);
367 assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
368
369 insta::assert_compact_json_snapshot!(engine.matches([""; 0]).collect::<Vec<_>>(), @r###"[{"Ok": "a.js"}]"###);
370 insta::assert_compact_json_snapshot!(engine.check(Path::new("a.js")), @r###"{"Err": ["Could not find ')' for \"then-change\" at line 3 for \"a.js\"."]}"###);
371 }
372}