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