if_changed/
engine.rs

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    /// Iterate over changed files that match the given patterns and patterns that don't match any file.
14    ///
15    /// If patterns is empty, all changed files are returned.
16    fn matches(
17        &self,
18        patterns: impl IntoIterator<Item = impl AsRef<Path>>,
19    ) -> impl Iterator<Item = Result<PathBuf, PathBuf>>;
20
21    /// Resolve a path to an absolute path.
22    fn resolve(&self, path: impl AsRef<Path>) -> PathBuf;
23
24    /// Check if a file has been ignored.
25    fn is_ignored(&self, path: impl AsRef<Path>) -> bool;
26
27    /// Check if a range of lines in a file has been modified.
28    fn is_range_modified(&self, path: impl AsRef<Path>, range: (usize, usize)) -> bool;
29
30    /// Check a file for dependent changes.
31    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            // Resolve patterns based on the current file.
53            let resolved_patterns = block
54                .patterns
55                .into_iter()
56                .map(|mut pattern| {
57                    // Empty pattern means current file.
58                    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                    // Try to open the file in search of the named block.
97                    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                    // Search for the named block, accumulating errors along the way.
108                    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}