Skip to main content

if_changed/
engine.rs

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    /// Iterate over changed files that match the given patterns and patterns that don't match any file.
15    ///
16    /// If patterns is empty, all changed files are returned.
17    fn matches(
18        &self,
19        patterns: impl IntoIterator<Item = impl AsRef<Path>>,
20    ) -> impl Iterator<Item = Result<PathBuf, PathBuf>>;
21
22    /// Resolve a path to an absolute path.
23    fn resolve(&self, path: impl AsRef<Path>) -> PathBuf;
24
25    /// Read the file content that should be checked.
26    fn read_to_string(&self, path: impl AsRef<Path>) -> Result<String, io::Error> {
27        fs::read_to_string(self.resolve(path))
28    }
29
30    /// Check if a file has been ignored.
31    fn is_ignored(&self, path: impl AsRef<Path>) -> bool;
32
33    /// Check if a range of lines in a file has been modified.
34    fn is_range_modified(&self, path: impl AsRef<Path>, range: (usize, usize)) -> bool;
35
36    /// Check a file for dependent changes.
37    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            // Resolve patterns based on the current file.
59            let resolved_patterns = block
60                .patterns
61                .into_iter()
62                .map(|mut pattern| {
63                    // Empty pattern means current file.
64                    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                    // Try to open the file in search of the named block.
103                    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                    // Search for the named block, accumulating errors along the way.
114                    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}