char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somdeslop(task): unify watchset + glob machinerya29d09f

main
55.2 KiB1433 linesraw
1use std::path::{Path, PathBuf};
2use std::process::{Child, Command, Output};
3use std::time::{Duration, Instant};
4
5fn project(config: &str) -> tempfile::TempDir {
6    let dir = tempfile::tempdir().unwrap();
7    std::fs::write(dir.path().join("avril.conf"), config).unwrap();
8    dir
9}
10
11fn avril(dir: &Path, args: &[&str]) -> Output {
12    Command::new(env!("CARGO_BIN_EXE_avril"))
13        .current_dir(dir)
14        .args(args)
15        .output()
16        .unwrap()
17}
18
19fn stdout(output: &Output) -> String {
20    String::from_utf8(output.stdout.clone()).unwrap()
21}
22
23fn stderr(output: &Output) -> String {
24    String::from_utf8(output.stderr.clone()).unwrap()
25}
26
27#[test]
28fn listing_shows_descriptions_commands_and_groupings() {
29    let dir = project(
30        r#"
31        tasks: {
32            build: "echo building",
33            serve: {command: "echo serving", description: "Run the server", dependencies: ["build"]},
34            all: {dependencies: ["serve"]},
35        }
36        "#,
37    );
38    let output = avril(dir.path(), &["task"]);
39    assert!(output.status.success(), "{}", stderr(&output));
40    assert_eq!(
41        stdout(&output),
42        "Available tasks:\n- all\n    dependencies: serve\n- build\n    echo building\n- serve\n    Run the server\n"
43    );
44    let output = avril(dir.path(), &["task", "nope"]);
45    assert!(!output.status.success());
46    assert!(
47        stderr(&output).contains("Unknown task \"nope\""),
48        "{}",
49        stderr(&output)
50    );
51    assert!(stderr(&output).contains("- build"), "{}", stderr(&output));
52}
53
54#[test]
55fn argv_command_tasks_run_without_a_shell() {
56    let dir = project(
57        r#"
58        tasks: {
59            build: {command: ["printf", "%s", "a b >> $(echo nope)"]},
60        }
61        "#,
62    );
63    let output = avril(dir.path(), &["task", "build"]);
64    assert!(output.status.success(), "{}", stderr(&output));
65    // Shell metacharacters arrive literally, and the argument containing
66    // spaces is a single argument.
67    assert_eq!(stdout(&output), "a b >> $(echo nope)");
68}
69
70#[test]
71fn dependencies_run_once_in_order_and_extra_args_are_forwarded() {
72    let dir = project(
73        r#"
74        tasks: {
75            shared: "echo shared",
76            a: {command: "echo a", dependencies: ["shared"]},
77            b: {command: "echo b", dependencies: ["shared"]},
78            greet: {command: "echo hello", dependencies: ["a", "b"]},
79        }
80        "#,
81    );
82    let output = avril(dir.path(), &["task", "greet", "wide", "world"]);
83    assert!(output.status.success(), "{}", stderr(&output));
84    assert_eq!(stdout(&output), "shared\na\nb\nhello wide world\n");
85    // Each command header goes to stderr, in execution order.
86    let errors = stderr(&output);
87    let headers: Vec<_> = errors
88        .lines()
89        .filter(|line| line.starts_with("Task "))
90        .collect();
91    assert_eq!(
92        headers,
93        [
94            "Task shared echo shared",
95            "Task a echo a",
96            "Task b echo b",
97            "Task greet echo hello wide world"
98        ]
99    );
100}
101
102#[test]
103fn commands_run_in_the_package_root_from_a_subdirectory() {
104    // marker.js only exists at the package root, so `avril run ./marker.js`
105    // only succeeds if the command's working directory is the package root.
106    let dir = project(&format!(
107        "tasks: {{ marker: \"{} run ./marker.js\" }}",
108        env!("CARGO_BIN_EXE_avril").replace('\\', "/")
109    ));
110    std::fs::write(dir.path().join("marker.js"), "console.log('ran-at-root');").unwrap();
111    std::fs::create_dir_all(dir.path().join("src/nested")).unwrap();
112    let nested: PathBuf = dir.path().join("src/nested");
113    let output = avril(&nested, &["task", "marker"]);
114    assert!(output.status.success(), "{}", stderr(&output));
115    assert_eq!(stdout(&output), "ran-at-root\n");
116}
117
118#[test]
119fn parallel_dependencies_actually_run_at_the_same_time() {
120    // Each script announces itself, then waits for all three announcements:
121    // run sequentially, the first would time out instead of finishing.
122    let exe = env!("CARGO_BIN_EXE_avril").replace('\\', "/");
123    let dir = project(&format!(
124        "tasks: {{ a: \"{exe} run ./a.js\", b: \"{exe} run ./b.js\", c: \"{exe} run ./c.js\", all: {{command: \"echo all-done\", dependencies: ['a', 'b', 'c'], parallel: true}} }}"
125    ));
126    for name in ["a", "b", "c"] {
127        std::fs::write(
128            dir.path().join(format!("{name}.js")),
129            format!(
130                r#"
131                import {{ exists, writeTextFile }} from "avril:fs";
132                await writeTextFile("{name}.started", "");
133                const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
134                let ready = false;
135                for (let attempt = 0; attempt < 500 && !ready; attempt++) {{
136                    ready = true;
137                    for (const other of ["a", "b", "c"]) {{
138                        if (!(await exists(other + ".started"))) {{ ready = false; break; }}
139                    }}
140                    if (!ready) await sleep(10);
141                }}
142                if (!ready) throw new Error("dependencies did not run in parallel");
143                console.log("{name}");
144                "#
145            ),
146        )
147        .unwrap();
148    }
149    let output = avril(dir.path(), &["task", "all"]);
150    assert!(output.status.success(), "{}", stderr(&output));
151    let text = stdout(&output);
152    let mut lines: Vec<_> = text.lines().collect();
153    lines.sort_unstable();
154    assert_eq!(lines, ["a", "all-done", "b", "c"]);
155}
156
157#[test]
158fn parallel_branches_share_dependencies_and_any_failure_is_status_one() {
159    let dir = project(
160        r#"
161        tasks: {
162            shared: "echo shared",
163            a: {command: "echo a", dependencies: ["shared"]},
164            b: {command: "echo b", dependencies: ["shared"]},
165            all: {dependencies: ["a", "b"], parallel: true},
166        }
167        "#,
168    );
169    let output = avril(dir.path(), &["task", "all"]);
170    assert!(output.status.success(), "{}", stderr(&output));
171    let text = stdout(&output);
172    let mut lines: Vec<_> = text.lines().collect();
173    lines.sort_unstable();
174    assert_eq!(lines, ["a", "b", "shared"]);
175
176    let dir = project(
177        r#"
178        tasks: {
179            one: "exit 3",
180            two: "echo two-ran",
181            three: "exit 5",
182            all: {command: "echo parent", dependencies: ["one", "two", "three"], parallel: true},
183        }
184        "#,
185    );
186    let output = avril(dir.path(), &["task", "all"]);
187    // Not three or five: once failures race, the run is simply status 1.
188    assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
189    assert!(stdout(&output).contains("two-ran"), "{}", stdout(&output));
190    assert!(!stdout(&output).contains("parent"));
191}
192
193#[test]
194fn a_failing_dependency_stops_the_run_and_propagates_its_exit_code() {
195    let dir = project(
196        r#"
197        tasks: {
198            fail: "exit 3",
199            after: {command: "echo should-not-run", dependencies: ["fail"]},
200        }
201        "#,
202    );
203    let output = avril(dir.path(), &["task", "after"]);
204    assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
205    assert!(!stdout(&output).contains("should-not-run"));
206}
207
208#[test]
209fn unknown_dependencies_and_cycles_are_reported_against_the_config() {
210    let dir = project(
211        r#"
212        tasks: {
213            a: {command: "echo a", dependencies: ["missing"]},
214        }
215        "#,
216    );
217    let output = avril(dir.path(), &["task", "a"]);
218    assert!(!output.status.success());
219    let error = stderr(&output);
220    assert!(error.contains("Unknown task \"missing\""), "{error}");
221    assert!(error.contains("avril.conf:3:"), "{error}");
222
223    let dir = project(
224        r#"
225        tasks: {
226            a: {command: "echo a", dependencies: ["b"]},
227            b: {command: "echo b", dependencies: ["a"]},
228            self: {command: "echo self", dependencies: ["self"]},
229        }
230        "#,
231    );
232    for watch in [false, true] {
233        for (name, cycle, line) in [("a", "a -> b -> a", 4), ("self", "self -> self", 5)] {
234            let args = if watch {
235                vec!["task", "--watch", name]
236            } else {
237                vec!["task", name]
238            };
239            let output = avril(dir.path(), &args);
240            assert!(!output.status.success());
241            let error = stderr(&output);
242            assert!(
243                error.contains(&format!("Task cycle detected: {cycle}")),
244                "{error}"
245            );
246            assert!(error.contains(&format!("avril.conf:{line}:")), "{error}");
247        }
248    }
249}
250
251#[cfg(unix)]
252#[test]
253fn task_preserves_native_environment_and_search_paths() {
254    use std::os::unix::{ffi::OsStrExt, fs::symlink};
255    let dir = project(r#"tasks: { run: 'printf "%s" "$RAW"' }"#);
256    let binary = dir.path().join(std::ffi::OsStr::from_bytes(b"bin-\xff"));
257    std::fs::create_dir(&binary).unwrap();
258    symlink("/bin/sh", binary.join("sh")).unwrap();
259    for path in [Some(binary.as_os_str()), None] {
260        let mut command = Command::new(env!("CARGO_BIN_EXE_avril"));
261        command
262            .current_dir(dir.path())
263            .args(["task", "run"])
264            .env("RAW", std::ffi::OsStr::from_bytes(b"\xfe\xff"))
265            .env_remove("PATH");
266        if let Some(path) = path {
267            command.env("PATH", path);
268        }
269        let output = command.output().unwrap();
270        assert!(output.status.success(), "{}", stderr(&output));
271        assert_eq!(output.stdout, b"\xfe\xff");
272    }
273}
274
275#[test]
276fn env_files_apply_to_a_task_and_its_dependencies_recursively() {
277    let bin = env!("CARGO_BIN_EXE_avril").replace('\\', "/");
278    let dir = project(&format!(
279        r#"
280        tasks: {{
281            root: {{command: "{bin} run ./show.js", envFile: ".env.root", dependencies: ["child"]}},
282            child: {{command: "{bin} run ./show.js", envFile: ".env.child", dependencies: ["leaf"]}},
283            leaf: "{bin} run ./show.js",
284        }}
285        "#
286    ));
287    std::fs::write(
288        dir.path().join(".env.root"),
289        "GREETING=root-file\nLEVEL=root\nROOT_ONLY=yes\n",
290    )
291    .unwrap();
292    std::fs::write(
293        dir.path().join(".env.child"),
294        "LEVEL=child\nCHILD_ONLY=yes\n",
295    )
296    .unwrap();
297    std::fs::write(
298        dir.path().join("show.js"),
299        "import {env} from 'avril:std';\nconst show = (name) => env.get(name) ?? '-';\nconsole.log([show('GREETING'), show('LEVEL'), show('ROOT_ONLY'), show('CHILD_ONLY')].join('/'));\n",
300    )
301    .unwrap();
302    let output = Command::new(env!("CARGO_BIN_EXE_avril"))
303        .current_dir(dir.path())
304        .args(["task", "root"])
305        .env("GREETING", "process")
306        .env_remove("LEVEL")
307        .env_remove("ROOT_ONLY")
308        .env_remove("CHILD_ONLY")
309        .output()
310        .unwrap();
311    assert!(output.status.success(), "{}", stderr(&output));
312    // Dependencies run first: leaf and child see the child's file overriding
313    // the root's, while the root itself only sees its own file. The process
314    // environment wins over every file.
315    assert_eq!(
316        stdout(&output),
317        "process/child/yes/yes\nprocess/child/yes/yes\nprocess/root/yes/-\n"
318    );
319}
320
321#[test]
322fn a_missing_task_env_file_is_reported_against_the_config() {
323    let dir = project(
324        "tasks: { root: { command: 'echo hi', envFile: '.env.missing', dependencies: ['leaf'] }, leaf: 'echo leaf' }",
325    );
326    let output = avril(dir.path(), &["task", "root"]);
327    assert!(!output.status.success());
328    let errors = stderr(&output);
329    assert!(errors.contains("Cannot load env file"), "{errors}");
330    assert!(errors.contains("avril.conf:"), "{errors}");
331}
332
333#[cfg(unix)]
334#[test]
335fn an_env_file_path_that_does_not_open_is_not_rerouted() {
336    let dir = project("tasks: { build: { command: 'echo hi', envFile: 'missing/../.env' } }");
337    std::fs::write(dir.path().join(".env"), "FLAVOR=one\n").unwrap();
338    let output = avril(dir.path(), &["task", "build"]);
339    assert!(!output.status.success());
340    assert!(
341        stderr(&output).contains("Cannot load env file"),
342        "{}",
343        stderr(&output)
344    );
345}
346
347#[test]
348fn only_env_listed_env_file_values_are_part_of_the_cache_key() {
349    let dir = project(
350        "tasks: { site: { command: 'echo work', files: ['input.txt'], envFile: '.env', env: ['FLAVOR'] } }",
351    );
352    std::fs::write(dir.path().join("input.txt"), "input").unwrap();
353    std::fs::write(dir.path().join(".env"), "FLAVOR=one\nUNRELATED=one\n").unwrap();
354    let output = avril_cached(dir.path(), &["task", "site"], &[]);
355    assert!(output.status.success(), "{}", stderr(&output));
356    assert_eq!(stdout(&output), "work\n");
357    let output = avril_cached(dir.path(), &["task", "site"], &[]);
358    assert!(output.status.success(), "{}", stderr(&output));
359    assert_eq!(stdout(&output), "");
360    assert!(stderr(&output).contains("Task site (cached)"));
361    // A value the task did not declare in `env` does not invalidate it.
362    std::fs::write(dir.path().join(".env"), "FLAVOR=one\nUNRELATED=two\n").unwrap();
363    let output = avril_cached(dir.path(), &["task", "site"], &[]);
364    assert!(output.status.success(), "{}", stderr(&output));
365    assert_eq!(stdout(&output), "");
366    assert!(stderr(&output).contains("Task site (cached)"));
367    std::fs::write(dir.path().join(".env"), "FLAVOR=two\nUNRELATED=two\n").unwrap();
368    let output = avril_cached(dir.path(), &["task", "site"], &[]);
369    assert!(output.status.success(), "{}", stderr(&output));
370    assert_eq!(stdout(&output), "work\n");
371}
372
373/// A spawned watcher that cannot leak past a failed assertion.
374struct WatchGuard(Child);
375
376impl Drop for WatchGuard {
377    fn drop(&mut self) {
378        let _ = self.0.kill();
379        let _ = self.0.wait();
380    }
381}
382
383impl std::ops::Deref for WatchGuard {
384    type Target = Child;
385    fn deref(&self) -> &Child {
386        &self.0
387    }
388}
389
390impl std::ops::DerefMut for WatchGuard {
391    fn deref_mut(&mut self) -> &mut Child {
392        &mut self.0
393    }
394}
395
396fn spawn_watch(dir: &Path, name: &str) -> WatchGuard {
397    spawn_watch_args(dir, &["task", "--watch", name])
398}
399
400fn spawn_watch_args(dir: &Path, args: &[&str]) -> WatchGuard {
401    WatchGuard(
402        Command::new(env!("CARGO_BIN_EXE_avril"))
403            .current_dir(dir)
404            .args(args)
405            .env("AVRIL_CACHE_DIR", dir.join("cache"))
406            .stdin(std::process::Stdio::null())
407            .stdout(std::process::Stdio::null())
408            .stderr(std::process::Stdio::null())
409            .spawn()
410            .unwrap(),
411    )
412}
413
414fn read_log(dir: &Path) -> String {
415    std::fs::read_to_string(dir.join("runs.log")).unwrap_or_default()
416}
417
418fn wait_for_log(dir: &Path, expected: &str) {
419    let deadline = Instant::now() + Duration::from_secs(10);
420    while Instant::now() < deadline {
421        if read_log(dir) == expected {
422            return;
423        }
424        std::thread::sleep(Duration::from_millis(10));
425    }
426    panic!(
427        "timed out waiting for runs.log to become {expected:?}; it is {:?}",
428        read_log(dir)
429    );
430}
431
432fn stop_watch(child: &mut WatchGuard) {
433    #[cfg(unix)]
434    {
435        let status = Command::new("kill")
436            .args(["-INT", &child.id().to_string()])
437            .status()
438            .unwrap();
439        assert!(status.success());
440        let deadline = Instant::now() + Duration::from_secs(10);
441        while Instant::now() < deadline {
442            if child.try_wait().unwrap().is_some() {
443                return;
444            }
445            std::thread::sleep(Duration::from_millis(10));
446        }
447    }
448    let _ = child.kill();
449    let _ = child.wait();
450}
451
452#[test]
453fn watch_requires_files_somewhere_in_the_task_graph() {
454    let dir = project(r#"tasks: { plain: "echo plain" }"#);
455    let output = avril(dir.path(), &["task", "--watch", "plain"]);
456    assert!(!output.status.success());
457    assert!(
458        stderr(&output).contains("cannot be watched"),
459        "{}",
460        stderr(&output)
461    );
462    assert!(stderr(&output).contains("'files'"), "{}", stderr(&output));
463    let output = avril(dir.path(), &["task", "--watch"]);
464    assert!(!output.status.success());
465    assert!(stderr(&output).contains("Usage: avril task"));
466}
467
468#[cfg(unix)]
469#[test]
470fn watch_reruns_when_inputs_of_a_dependency_change() {
471    let dir = project(
472        "tasks: { build: {command: \"cat input.txt >> runs.log\", files: [\"input.txt\"]}, all: {dependencies: [\"build\"]} }",
473    );
474    std::fs::write(dir.path().join("input.txt"), "one\n").unwrap();
475    let mut child = spawn_watch(dir.path(), "all");
476    wait_for_log(dir.path(), "one\n");
477    // Files outside the task's patterns never rerun it.
478    std::fs::write(dir.path().join("unrelated.txt"), "x").unwrap();
479    std::thread::sleep(Duration::from_millis(400));
480    assert_eq!(read_log(dir.path()), "one\n");
481    // Inputs are compared by size and modification time, so rewriting the
482    // same contents still reruns the task.
483    std::fs::write(dir.path().join("input.txt"), "one\n").unwrap();
484    wait_for_log(dir.path(), "one\none\n");
485    std::fs::write(dir.path().join("input.txt"), "two\n").unwrap();
486    wait_for_log(dir.path(), "one\none\ntwo\n");
487    stop_watch(&mut child);
488    assert!(child.wait().unwrap().success());
489}
490
491#[cfg(unix)]
492#[test]
493fn watch_debounces_a_burst_of_changes_into_one_rerun() {
494    let dir =
495        project("tasks: { build: {command: \"echo run >> runs.log\", files: [\"input.txt\"]} }");
496    std::fs::write(dir.path().join("input.txt"), "start").unwrap();
497    let mut child = spawn_watch(dir.path(), "build");
498    wait_for_log(dir.path(), "run\n");
499    for index in 0..10 {
500        std::fs::write(dir.path().join("input.txt"), format!("edit-{index}")).unwrap();
501    }
502    wait_for_log(dir.path(), "run\nrun\n");
503    std::thread::sleep(Duration::from_millis(400));
504    assert_eq!(read_log(dir.path()), "run\nrun\n");
505    stop_watch(&mut child);
506}
507
508#[cfg(unix)]
509#[test]
510fn watch_keeps_watching_after_the_task_fails() {
511    let dir = project(
512        "tasks: { build: {command: \"echo ran >> runs.log; exit 3\", files: [\"input.txt\"]} }",
513    );
514    std::fs::write(dir.path().join("input.txt"), "one").unwrap();
515    let mut child = spawn_watch(dir.path(), "build");
516    wait_for_log(dir.path(), "ran\n");
517    assert!(child.try_wait().unwrap().is_none());
518    std::fs::write(dir.path().join("input.txt"), "two").unwrap();
519    wait_for_log(dir.path(), "ran\nran\n");
520    assert!(child.try_wait().unwrap().is_none());
521    stop_watch(&mut child);
522}
523
524#[cfg(unix)]
525#[test]
526fn watch_kills_and_restarts_a_running_task() {
527    let dir = project("tasks: { serve: {command: \"sh serve.sh\", files: [\"input.txt\"]} }");
528    std::fs::write(dir.path().join("serve.sh"), "echo $$ >> serve.pid\necho start >> runs.log\ntrap '' INT\ntrap 'echo term >> runs.log; exit 0' TERM\nwhile :; do sleep 1; done\n").unwrap();
529    std::fs::write(dir.path().join("input.txt"), "one").unwrap();
530    let mut child = spawn_watch(dir.path(), "serve");
531    wait_for_log(dir.path(), "start\n");
532    std::fs::write(dir.path().join("input.txt"), "two").unwrap();
533    wait_for_log(dir.path(), "start\nterm\nstart\n");
534    stop_watch(&mut child);
535    assert!(child.wait().unwrap().success());
536    wait_for_log(dir.path(), "start\nterm\nstart\nterm\n");
537    let pids = std::fs::read_to_string(dir.path().join("serve.pid")).unwrap();
538    for pid in pids.lines() {
539        let alive = Command::new("kill")
540            .args(["-0", pid])
541            .status()
542            .unwrap()
543            .success();
544        assert!(!alive, "watched process {pid} outlived the watcher");
545    }
546}
547
548#[cfg(unix)]
549#[test]
550fn watch_restart_reaches_descendants_after_the_shell_exits() {
551    let dir = project(r#"tasks: { serve: {command: "sh launcher.sh", files: ["input.txt"]} }"#);
552    std::fs::write(
553        dir.path().join("bg.sh"),
554        "echo $$ >> bg.pid\necho start >> runs.log\ntrap '' INT TERM\nwhile :; do sleep 1; done\n",
555    )
556    .unwrap();
557    std::fs::write(dir.path().join("launcher.sh"), "sh bg.sh & wait\n").unwrap();
558    std::fs::write(dir.path().join("input.txt"), "one").unwrap();
559    let mut child = spawn_watch(dir.path(), "serve");
560    wait_for_log(dir.path(), "start\n");
561    std::fs::write(dir.path().join("input.txt"), "two").unwrap();
562    wait_for_log(dir.path(), "start\nstart\n");
563    let pids = std::fs::read_to_string(dir.path().join("bg.pid")).unwrap();
564    let first = pids.lines().next().unwrap();
565    assert!(
566        !Command::new("kill")
567            .args(["-0", first])
568            .status()
569            .unwrap()
570            .success(),
571        "first descendant {first} survived the restart"
572    );
573    stop_watch(&mut child);
574    assert!(child.wait().unwrap().success());
575    let pids = std::fs::read_to_string(dir.path().join("bg.pid")).unwrap();
576    for pid in pids.lines() {
577        assert!(
578            !Command::new("kill")
579                .args(["-0", pid])
580                .status()
581                .unwrap()
582                .success(),
583            "descendant {pid} survived shutdown"
584        );
585    }
586}
587
588#[cfg(unix)]
589#[test]
590fn watch_restarts_a_running_task_on_a_same_content_rewrite() {
591    let dir = project(r#"tasks: { serve: {command: "sh serve.sh", files: ["input.txt"]} }"#);
592    std::fs::write(dir.path().join("serve.sh"), "echo start >> runs.log\ntrap '' INT\ntrap 'echo term >> runs.log; exit 0' TERM\nwhile :; do sleep 1; done\n").unwrap();
593    std::fs::write(dir.path().join("input.txt"), "same").unwrap();
594    let mut child = spawn_watch(dir.path(), "serve");
595    wait_for_log(dir.path(), "start\n");
596    // Inputs are compared by size and modification time: the same bytes
597    // with a new mtime still restart the task.
598    std::fs::write(dir.path().join("input.txt"), "same").unwrap();
599    wait_for_log(dir.path(), "start\nterm\nstart\n");
600    std::fs::write(dir.path().join("input.txt"), "changed").unwrap();
601    wait_for_log(dir.path(), "start\nterm\nstart\nterm\nstart\n");
602    stop_watch(&mut child);
603}
604
605#[cfg(unix)]
606#[test]
607fn watch_passes_help_after_the_name_through_to_the_task() {
608    let dir =
609        project(r#"tasks: { build: {command: "echo ran >> runs.log", files: ["input.txt"]} }"#);
610    std::fs::write(dir.path().join("input.txt"), "one").unwrap();
611    let mut child = spawn_watch_args(dir.path(), &["task", "--watch", "build", "--help"]);
612    wait_for_log(dir.path(), "ran --help\n");
613    stop_watch(&mut child);
614}
615
616#[cfg(unix)]
617#[test]
618fn watch_stays_put_when_a_reload_drops_all_files() {
619    let dir =
620        project(r#"tasks: { build: {command: "echo run >> runs.log", files: ["input.txt"]} }"#);
621    std::fs::write(dir.path().join("input.txt"), "one").unwrap();
622    let mut child = spawn_watch(dir.path(), "build");
623    wait_for_log(dir.path(), "run\n");
624    std::fs::write(
625        dir.path().join("avril.conf"),
626        r#"tasks: { build: "echo replaced >> runs.log" }"#,
627    )
628    .unwrap();
629    std::thread::sleep(Duration::from_millis(500));
630    assert_eq!(read_log(dir.path()), "run\n");
631    assert!(child.try_wait().unwrap().is_none());
632    std::fs::write(
633        dir.path().join("avril.conf"),
634        r#"tasks: { build: {command: "echo restored >> runs.log", files: ["input.txt"]} }"#,
635    )
636    .unwrap();
637    wait_for_log(dir.path(), "run\nrestored\n");
638    stop_watch(&mut child);
639}
640
641#[cfg(unix)]
642#[test]
643fn watch_notices_a_populated_directory_moving_in() {
644    let dir = project(
645        r#"tasks: { build: {command: "cat src/a.txt src/sub/b.txt >> runs.log", files: ["src/**/*.txt"]} }"#,
646    );
647    std::fs::create_dir(dir.path().join("src")).unwrap();
648    std::fs::write(dir.path().join("src/a.txt"), "one\n").unwrap();
649    let mut child = spawn_watch(dir.path(), "build");
650    wait_for_log(dir.path(), "one\n");
651    std::fs::create_dir(dir.path().join("stage")).unwrap();
652    std::fs::write(dir.path().join("stage/b.txt"), "two\n").unwrap();
653    std::fs::rename(dir.path().join("stage"), dir.path().join("src/sub")).unwrap();
654    wait_for_log(dir.path(), "one\none\ntwo\n");
655    stop_watch(&mut child);
656}
657
658#[cfg(unix)]
659#[test]
660fn watch_reloads_an_env_file_outside_the_package_root() {
661    let outer = tempfile::tempdir().unwrap();
662    let dir = outer.path().join("pkg");
663    std::fs::create_dir(&dir).unwrap();
664    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "../shared.env"} }"#).unwrap();
665    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
666    std::fs::write(outer.path().join("shared.env"), "FLAVOR=one\n").unwrap();
667    let mut child = spawn_watch(&dir, "build");
668    wait_for_log(&dir, "one\n");
669    std::fs::write(outer.path().join("shared.env"), "FLAVOR=two\n").unwrap();
670    wait_for_log(&dir, "one\none\n");
671    stop_watch(&mut child);
672}
673
674#[cfg(unix)]
675#[test]
676fn watch_cleans_up_background_processes_from_a_completed_run() {
677    let dir = project(r#"tasks: { serve: {command: "sh bg.sh &", files: ["input.txt"]} }"#);
678    std::fs::write(
679        dir.path().join("bg.sh"),
680        "echo $$ > bg.pid\necho start >> runs.log\ntrap '' INT TERM\nwhile :; do sleep 1; done\n",
681    )
682    .unwrap();
683    std::fs::write(dir.path().join("input.txt"), "one").unwrap();
684    let mut child = spawn_watch(dir.path(), "serve");
685    wait_for_log(dir.path(), "start\n");
686    std::thread::sleep(Duration::from_millis(300));
687    stop_watch(&mut child);
688    assert!(child.wait().unwrap().success());
689    let pid = std::fs::read_to_string(dir.path().join("bg.pid")).unwrap();
690    assert!(
691        !Command::new("kill")
692            .args(["-0", pid.trim()])
693            .status()
694            .unwrap()
695            .success(),
696        "background process {} survived shutdown",
697        pid.trim()
698    );
699}
700
701#[cfg(unix)]
702#[test]
703fn watch_carries_a_forced_refresh_through_a_cancelled_run() {
704    let dir = project(
705        r#"tasks: { build: {command: "echo started >> starts.log; sleep 1; echo work >> runs.log", files: ["input.txt"], envFile: ".env"} }"#,
706    );
707    std::fs::write(dir.path().join(".env"), "FLAVOR=one\n").unwrap();
708    std::fs::write(dir.path().join("input.txt"), "A").unwrap();
709    let output = avril_cached(dir.path(), &["task", "build"], &[]);
710    assert!(output.status.success(), "{}", stderr(&output));
711    std::fs::write(dir.path().join("input.txt"), "B").unwrap();
712    let output = avril_cached(dir.path(), &["task", "build"], &[]);
713    assert!(output.status.success(), "{}", stderr(&output));
714    std::fs::write(dir.path().join("input.txt"), "A").unwrap();
715    std::fs::remove_file(dir.path().join("runs.log")).unwrap();
716    std::fs::remove_file(dir.path().join("starts.log")).unwrap();
717    let mut child = spawn_watch(dir.path(), "build");
718    std::thread::sleep(Duration::from_millis(400));
719    std::fs::write(dir.path().join(".env"), "FLAVOR=two\n").unwrap();
720    let deadline = Instant::now() + Duration::from_secs(10);
721    while !std::fs::read_to_string(dir.path().join("starts.log")).is_ok_and(|text| !text.is_empty())
722    {
723        assert!(Instant::now() < deadline, "forced run never started");
724        std::thread::sleep(Duration::from_millis(10));
725    }
726    std::fs::write(dir.path().join("input.txt"), "B").unwrap();
727    wait_for_log(dir.path(), "work\n");
728    stop_watch(&mut child);
729}
730
731#[cfg(unix)]
732#[test]
733fn watch_unrelated_writes_do_not_starve_the_debounce() {
734    let dir = project(
735        r#"tasks: { build: {command: "cat src/input.txt >> runs.log", files: ["src/*.txt"]} }"#,
736    );
737    std::fs::create_dir(dir.path().join("src")).unwrap();
738    std::fs::write(dir.path().join("src/input.txt"), "one\n").unwrap();
739    let mut child = spawn_watch(dir.path(), "build");
740    wait_for_log(dir.path(), "one\n");
741    let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
742    let writer_stop = stop.clone();
743    let writer_dir = dir.path().join("src/unrelated.log");
744    let writer = std::thread::spawn(move || {
745        let staging = writer_dir.with_extension("tmp");
746        while !writer_stop.load(std::sync::atomic::Ordering::SeqCst) {
747            let _ = std::fs::write(&staging, "noise");
748            let _ = std::fs::rename(&staging, &writer_dir);
749            std::thread::sleep(Duration::from_millis(10));
750        }
751    });
752    std::fs::write(dir.path().join("src/input.txt"), "two\n").unwrap();
753    wait_for_log(dir.path(), "one\ntwo\n");
754    stop.store(true, std::sync::atomic::Ordering::SeqCst);
755    writer.join().unwrap();
756    stop_watch(&mut child);
757}
758
759#[cfg(unix)]
760#[test]
761fn watch_follows_an_env_file_through_a_symlink_parent() {
762    let outer = tempfile::tempdir().unwrap();
763    let dir = outer.path().join("pkg");
764    std::fs::create_dir(&dir).unwrap();
765    std::fs::create_dir(outer.path().join("target")).unwrap();
766    std::os::unix::fs::symlink(outer.path().join("target"), dir.join("link")).unwrap();
767    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"#).unwrap();
768    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
769    std::fs::write(outer.path().join("shared.env"), "FLAVOR=one\n").unwrap();
770    let mut child = spawn_watch(&dir, "build");
771    wait_for_log(&dir, "one\n");
772    std::fs::write(outer.path().join("shared.env"), "FLAVOR=two\n").unwrap();
773    wait_for_log(&dir, "one\none\n");
774    stop_watch(&mut child);
775}
776
777#[cfg(unix)]
778#[test]
779fn watch_recovers_when_an_external_env_directory_appears() {
780    let outer = tempfile::tempdir().unwrap();
781    let dir = outer.path().join("pkg");
782    std::fs::create_dir(&dir).unwrap();
783    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "../ext/shared.env"} }"#).unwrap();
784    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
785    let mut child = spawn_watch(&dir, "build");
786    std::thread::sleep(Duration::from_millis(400));
787    std::fs::create_dir(outer.path().join("ext")).unwrap();
788    std::fs::write(outer.path().join("ext/shared.env"), "FLAVOR=one\n").unwrap();
789    wait_for_log(&dir, "one\n");
790    std::fs::write(outer.path().join("ext/shared.env"), "FLAVOR=two\n").unwrap();
791    wait_for_log(&dir, "one\none\n");
792    stop_watch(&mut child);
793}
794
795#[cfg(unix)]
796#[test]
797fn watch_follows_an_env_symlink_being_retargeted() {
798    let dir = project(
799        r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"#,
800    );
801    std::fs::write(dir.path().join("input.txt"), "one\n").unwrap();
802    std::fs::write(dir.path().join("a.env"), "FLAVOR=a\n").unwrap();
803    std::fs::write(dir.path().join("b.env"), "FLAVOR=b\n").unwrap();
804    std::os::unix::fs::symlink("a.env", dir.path().join(".env")).unwrap();
805    let mut child = spawn_watch(dir.path(), "build");
806    wait_for_log(dir.path(), "one\n");
807    std::fs::remove_file(dir.path().join(".env")).unwrap();
808    std::os::unix::fs::symlink("b.env", dir.path().join(".env")).unwrap();
809    wait_for_log(dir.path(), "one\none\n");
810    stop_watch(&mut child);
811}
812
813#[cfg(unix)]
814#[test]
815fn watch_notices_a_nested_directory_moving_out() {
816    let dir = project(
817        r#"tasks: { build: {command: "cat src/a.txt src/sub/b.txt >> runs.log", files: ["src/**/*.txt"]} }"#,
818    );
819    std::fs::create_dir_all(dir.path().join("src/sub")).unwrap();
820    std::fs::write(dir.path().join("src/a.txt"), "A\n").unwrap();
821    std::fs::write(dir.path().join("src/sub/b.txt"), "one\n").unwrap();
822    let mut child = spawn_watch(dir.path(), "build");
823    wait_for_log(dir.path(), "A\none\n");
824    let outside = tempfile::tempdir().unwrap();
825    std::fs::rename(dir.path().join("src/sub"), outside.path().join("sub")).unwrap();
826    wait_for_log(dir.path(), "A\none\nA\n");
827    stop_watch(&mut child);
828}
829
830#[cfg(unix)]
831#[test]
832fn watch_recovers_when_a_dangling_env_symlinks_target_appears() {
833    let outer = tempfile::tempdir().unwrap();
834    let dir = outer.path().join("pkg");
835    std::fs::create_dir(&dir).unwrap();
836    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"#).unwrap();
837    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
838    std::os::unix::fs::symlink("../target.env", dir.join(".env")).unwrap();
839    let mut child = spawn_watch(&dir, "build");
840    std::thread::sleep(Duration::from_millis(400));
841    std::fs::write(outer.path().join("target.env"), "FLAVOR=one\n").unwrap();
842    wait_for_log(&dir, "one\n");
843    std::fs::write(outer.path().join("target.env"), "FLAVOR=two\n").unwrap();
844    wait_for_log(&dir, "one\none\n");
845    stop_watch(&mut child);
846}
847
848#[cfg(unix)]
849#[test]
850fn watch_follows_an_intermediate_env_symlink_being_retargeted() {
851    let outer = tempfile::tempdir().unwrap();
852    let dir = outer.path().join("pkg");
853    std::fs::create_dir(&dir).unwrap();
854    for side in ["a", "c"] {
855        std::fs::create_dir_all(outer.path().join(side).join("b")).unwrap();
856        std::fs::write(
857            outer.path().join(side).join("shared.env"),
858            format!("FLAVOR={side}\n"),
859        )
860        .unwrap();
861    }
862    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"#).unwrap();
863    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
864    std::os::unix::fs::symlink("../a/b", dir.join("link")).unwrap();
865    let mut child = spawn_watch(&dir, "build");
866    wait_for_log(&dir, "one\n");
867    std::fs::remove_file(dir.join("link")).unwrap();
868    std::os::unix::fs::symlink("../c/b", dir.join("link")).unwrap();
869    wait_for_log(&dir, "one\none\n");
870    stop_watch(&mut child);
871}
872
873#[cfg(unix)]
874#[test]
875fn watch_follows_the_middle_of_an_env_symlink_chain() {
876    let outer = tempfile::tempdir().unwrap();
877    let dir = outer.path().join("pkg");
878    std::fs::create_dir(&dir).unwrap();
879    std::fs::create_dir(outer.path().join("links")).unwrap();
880    std::fs::create_dir(outer.path().join("targets")).unwrap();
881    std::fs::write(outer.path().join("targets/a.env"), "FLAVOR=a\n").unwrap();
882    std::fs::write(outer.path().join("targets/b.env"), "FLAVOR=b\n").unwrap();
883    std::os::unix::fs::symlink("../targets/a.env", outer.path().join("links/middle.env")).unwrap();
884    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"#).unwrap();
885    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
886    std::os::unix::fs::symlink("../links/middle.env", dir.join(".env")).unwrap();
887    let mut child = spawn_watch(&dir, "build");
888    wait_for_log(&dir, "one\n");
889    std::fs::remove_file(outer.path().join("links/middle.env")).unwrap();
890    std::os::unix::fs::symlink("../targets/b.env", outer.path().join("links/middle.env")).unwrap();
891    wait_for_log(&dir, "one\none\n");
892    stop_watch(&mut child);
893}
894
895#[cfg(unix)]
896#[test]
897fn watch_recovers_when_an_env_targets_directory_is_created() {
898    let outer = tempfile::tempdir().unwrap();
899    let dir = outer.path().join("pkg");
900    std::fs::create_dir(&dir).unwrap();
901    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"#).unwrap();
902    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
903    std::os::unix::fs::symlink("../missing/shared.env", dir.join(".env")).unwrap();
904    let mut child = spawn_watch(&dir, "build");
905    std::thread::sleep(Duration::from_millis(400));
906    std::fs::create_dir(outer.path().join("missing")).unwrap();
907    std::fs::write(outer.path().join("missing/shared.env"), "FLAVOR=one\n").unwrap();
908    wait_for_log(&dir, "one\n");
909    stop_watch(&mut child);
910}
911
912#[cfg(unix)]
913#[test]
914fn watch_survives_an_external_env_ancestor_being_replaced() {
915    let outer = tempfile::tempdir().unwrap();
916    let dir = outer.path().join("pkg");
917    std::fs::create_dir(&dir).unwrap();
918    std::fs::create_dir_all(outer.path().join("secrets/version")).unwrap();
919    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "../secrets/version/shared.env"} }"#).unwrap();
920    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
921    std::fs::write(
922        outer.path().join("secrets/version/shared.env"),
923        "FLAVOR=one\n",
924    )
925    .unwrap();
926    let mut child = spawn_watch(&dir, "build");
927    wait_for_log(&dir, "one\n");
928    std::fs::rename(
929        outer.path().join("secrets"),
930        outer.path().join("secrets-old"),
931    )
932    .unwrap();
933    std::fs::create_dir_all(outer.path().join("secrets/version")).unwrap();
934    std::fs::write(
935        outer.path().join("secrets/version/shared.env"),
936        "FLAVOR=two\n",
937    )
938    .unwrap();
939    wait_for_log(&dir, "one\none\n");
940    std::fs::write(
941        outer.path().join("secrets/version/shared.env"),
942        "FLAVOR=three\n",
943    )
944    .unwrap();
945    wait_for_log(&dir, "one\none\none\n");
946    stop_watch(&mut child);
947}
948
949#[cfg(unix)]
950#[test]
951fn watch_recovers_through_a_dangling_intermediate_env_symlink() {
952    let outer = tempfile::tempdir().unwrap();
953    let dir = outer.path().join("pkg");
954    std::fs::create_dir(&dir).unwrap();
955    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/shared.env"} }"#).unwrap();
956    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
957    std::os::unix::fs::symlink("../missing", dir.join("link")).unwrap();
958    let mut child = spawn_watch(&dir, "build");
959    std::thread::sleep(Duration::from_millis(400));
960    std::fs::create_dir(outer.path().join("missing")).unwrap();
961    std::fs::write(outer.path().join("missing/shared.env"), "FLAVOR=one\n").unwrap();
962    wait_for_log(&dir, "one\n");
963    stop_watch(&mut child);
964}
965
966#[cfg(unix)]
967#[test]
968fn watch_follows_a_symlinked_config_and_input() {
969    let outer = tempfile::tempdir().unwrap();
970    let dir = outer.path().join("pkg");
971    std::fs::create_dir(&dir).unwrap();
972    std::fs::write(
973        outer.path().join("shared.conf"),
974        r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"#,
975    )
976    .unwrap();
977    std::os::unix::fs::symlink("../shared.conf", dir.join("avril.conf")).unwrap();
978    std::fs::write(outer.path().join("shared-input.txt"), "one\n").unwrap();
979    std::os::unix::fs::symlink("../shared-input.txt", dir.join("input.txt")).unwrap();
980    let mut child = spawn_watch(&dir, "build");
981    wait_for_log(&dir, "one\n");
982    std::fs::write(outer.path().join("shared-input.txt"), "two\n").unwrap();
983    wait_for_log(&dir, "one\ntwo\n");
984    std::fs::write(
985        outer.path().join("shared.conf"),
986        r#"tasks: { build: {command: "echo changed >> runs.log", files: ["input.txt"]} }"#,
987    )
988    .unwrap();
989    wait_for_log(&dir, "one\ntwo\nchanged\n");
990    stop_watch(&mut child);
991}
992
993#[cfg(unix)]
994#[test]
995fn watch_keeps_root_coverage_when_an_env_alias_points_at_the_package() {
996    let outer = tempfile::tempdir().unwrap();
997    let dir = outer.path().join("pkg");
998    std::fs::create_dir(&dir).unwrap();
999    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "../alias/.env"} }"#).unwrap();
1000    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
1001    std::fs::write(dir.join(".env"), "FLAVOR=one\n").unwrap();
1002    std::os::unix::fs::symlink("pkg", outer.path().join("alias")).unwrap();
1003    let mut child = spawn_watch(&dir, "build");
1004    wait_for_log(&dir, "one\n");
1005    std::fs::write(dir.join("input.txt"), "two\n").unwrap();
1006    wait_for_log(&dir, "one\ntwo\n");
1007    stop_watch(&mut child);
1008}
1009
1010#[cfg(unix)]
1011#[test]
1012fn watch_recovers_when_a_config_symlink_is_fixed_after_retargeting() {
1013    let outer = tempfile::tempdir().unwrap();
1014    let dir = outer.path().join("pkg");
1015    std::fs::create_dir(&dir).unwrap();
1016    std::fs::write(
1017        outer.path().join("a.conf"),
1018        r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"#,
1019    )
1020    .unwrap();
1021    std::os::unix::fs::symlink("../a.conf", dir.join("avril.conf")).unwrap();
1022    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
1023    let mut child = spawn_watch(&dir, "build");
1024    wait_for_log(&dir, "one\n");
1025    std::fs::remove_file(dir.join("avril.conf")).unwrap();
1026    std::os::unix::fs::symlink("../b.conf", dir.join("avril.conf")).unwrap();
1027    std::thread::sleep(Duration::from_millis(500));
1028    assert_eq!(read_log(&dir), "one\n");
1029    std::fs::write(
1030        outer.path().join("b.conf"),
1031        r#"tasks: { build: {command: "echo changed >> runs.log", files: ["input.txt"]} }"#,
1032    )
1033    .unwrap();
1034    wait_for_log(&dir, "one\nchanged\n");
1035    stop_watch(&mut child);
1036}
1037
1038#[cfg(unix)]
1039#[test]
1040fn watch_recovers_when_a_missing_env_traversal_directory_appears() {
1041    let outer = tempfile::tempdir().unwrap();
1042    let dir = outer.path().join("pkg");
1043    std::fs::create_dir(&dir).unwrap();
1044    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"#).unwrap();
1045    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
1046    std::fs::write(outer.path().join("shared.env"), "FLAVOR=one\n").unwrap();
1047    std::os::unix::fs::symlink("../missing", dir.join("link")).unwrap();
1048    let mut child = spawn_watch(&dir, "build");
1049    std::thread::sleep(Duration::from_millis(400));
1050    // A regular file where the directory belongs blocks the spelling
1051    // without ending recovery: replacing it with the directory must be
1052    // noticed.
1053    std::fs::write(outer.path().join("missing"), "not a directory").unwrap();
1054    std::thread::sleep(Duration::from_millis(400));
1055    assert_eq!(read_log(&dir), "");
1056    std::fs::remove_file(outer.path().join("missing")).unwrap();
1057    std::fs::create_dir(outer.path().join("missing")).unwrap();
1058    wait_for_log(&dir, "one\n");
1059    stop_watch(&mut child);
1060}
1061
1062#[cfg(unix)]
1063#[test]
1064fn watch_repairs_coverage_when_an_input_ancestor_is_replaced() {
1065    let outer = tempfile::tempdir().unwrap();
1066    let dir = outer.path().join("pkg");
1067    std::fs::create_dir(&dir).unwrap();
1068    std::fs::create_dir_all(outer.path().join("external/sub")).unwrap();
1069    std::fs::write(
1070        dir.join("avril.conf"),
1071        r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"#,
1072    )
1073    .unwrap();
1074    std::fs::write(outer.path().join("external/sub/input.txt"), "one\n").unwrap();
1075    std::os::unix::fs::symlink("../external/sub/input.txt", dir.join("input.txt")).unwrap();
1076    let mut child = spawn_watch(&dir, "build");
1077    wait_for_log(&dir, "one\n");
1078    std::fs::rename(
1079        outer.path().join("external"),
1080        outer.path().join("external-old"),
1081    )
1082    .unwrap();
1083    std::fs::create_dir_all(outer.path().join("external/sub")).unwrap();
1084    std::fs::write(outer.path().join("external/sub/input.txt"), "two\n").unwrap();
1085    wait_for_log(&dir, "one\ntwo\n");
1086    std::fs::write(outer.path().join("external/sub/input.txt"), "three\n").unwrap();
1087    wait_for_log(&dir, "one\ntwo\nthree\n");
1088    stop_watch(&mut child);
1089}
1090
1091#[cfg(unix)]
1092#[test]
1093fn watch_recovers_an_input_through_a_missing_directory() {
1094    let outer = tempfile::tempdir().unwrap();
1095    let dir = outer.path().join("pkg");
1096    std::fs::create_dir(&dir).unwrap();
1097    std::fs::write(
1098        dir.join("avril.conf"),
1099        r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"#,
1100    )
1101    .unwrap();
1102    std::fs::write(outer.path().join("shared-input.txt"), "one\n").unwrap();
1103    std::os::unix::fs::symlink("../missing/../shared-input.txt", dir.join("input.txt")).unwrap();
1104    let mut child = spawn_watch(&dir, "build");
1105    std::thread::sleep(Duration::from_millis(400));
1106    std::fs::create_dir(outer.path().join("missing")).unwrap();
1107    wait_for_log(&dir, "one\n");
1108    stop_watch(&mut child);
1109}
1110
1111#[cfg(unix)]
1112#[test]
1113fn watch_recovers_when_a_replaced_config_directory_is_fixed() {
1114    let outer = tempfile::tempdir().unwrap();
1115    let dir = outer.path().join("pkg");
1116    std::fs::create_dir(&dir).unwrap();
1117    std::fs::create_dir(outer.path().join("shared")).unwrap();
1118    std::fs::write(
1119        outer.path().join("shared/config.conf"),
1120        r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"#,
1121    )
1122    .unwrap();
1123    std::os::unix::fs::symlink("../shared/config.conf", dir.join("avril.conf")).unwrap();
1124    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
1125    let mut child = spawn_watch(&dir, "build");
1126    wait_for_log(&dir, "one\n");
1127    std::fs::rename(outer.path().join("shared"), outer.path().join("shared-old")).unwrap();
1128    std::fs::create_dir(outer.path().join("shared")).unwrap();
1129    std::fs::write(
1130        outer.path().join("shared/config.conf"),
1131        "tasks: { build: 42 }",
1132    )
1133    .unwrap();
1134    std::thread::sleep(Duration::from_millis(500));
1135    assert_eq!(read_log(&dir), "one\n");
1136    std::fs::write(
1137        outer.path().join("shared/config.conf"),
1138        r#"tasks: { build: {command: "echo changed >> runs.log", files: ["input.txt"]} }"#,
1139    )
1140    .unwrap();
1141    wait_for_log(&dir, "one\nchanged\n");
1142    stop_watch(&mut child);
1143}
1144
1145#[cfg(unix)]
1146#[test]
1147fn watch_anchor_takes_the_group_down_when_the_watcher_dies_abruptly() {
1148    let dir = project(r#"tasks: { serve: {command: "sh bg.sh &", files: ["input.txt"]} }"#);
1149    std::fs::write(
1150        dir.path().join("bg.sh"),
1151        "echo $$ > bg.pid\necho start >> runs.log\ntrap '' INT TERM\nwhile :; do sleep 1; done\n",
1152    )
1153    .unwrap();
1154    std::fs::write(dir.path().join("input.txt"), "one").unwrap();
1155    let mut child = spawn_watch(dir.path(), "serve");
1156    wait_for_log(dir.path(), "start\n");
1157    std::thread::sleep(Duration::from_millis(300));
1158    child.kill().unwrap();
1159    let _ = child.wait();
1160    let pid = std::fs::read_to_string(dir.path().join("bg.pid")).unwrap();
1161    let deadline = Instant::now() + Duration::from_secs(10);
1162    while Command::new("kill")
1163        .args(["-0", pid.trim()])
1164        .status()
1165        .unwrap()
1166        .success()
1167    {
1168        assert!(
1169            Instant::now() < deadline,
1170            "background process {} survived the watcher's abrupt death",
1171            pid.trim()
1172        );
1173        std::thread::sleep(Duration::from_millis(10));
1174    }
1175}
1176
1177#[cfg(unix)]
1178#[test]
1179fn watch_shutdown_during_a_restart_does_not_start_another_run() {
1180    let dir = project(r#"tasks: { serve: {command: "sh serve.sh", files: ["input.txt"]} }"#);
1181    // The task acknowledges the restart's SIGINT (and survives it), so
1182    // the test knows the watcher is inside restart termination before
1183    // it sends its own.
1184    std::fs::write(
1185        dir.path().join("serve.sh"),
1186        "echo start >> runs.log\ntrap 'echo int >> runs.log' INT\nwhile :; do sleep 1; done\n",
1187    )
1188    .unwrap();
1189    std::fs::write(dir.path().join("input.txt"), "one").unwrap();
1190    let mut child = spawn_watch(dir.path(), "serve");
1191    wait_for_log(dir.path(), "start\n");
1192    std::fs::write(dir.path().join("input.txt"), "two").unwrap();
1193    let deadline = Instant::now() + Duration::from_secs(10);
1194    while !read_log(dir.path()).contains("int") {
1195        assert!(
1196            Instant::now() < deadline,
1197            "restart never interrupted the task"
1198        );
1199        std::thread::sleep(Duration::from_millis(10));
1200    }
1201    let status = Command::new("kill")
1202        .args(["-INT", &child.id().to_string()])
1203        .status()
1204        .unwrap();
1205    assert!(status.success());
1206    while child.try_wait().unwrap().is_none() {
1207        assert!(Instant::now() < deadline, "watcher did not shut down");
1208        std::thread::sleep(Duration::from_millis(10));
1209    }
1210    assert!(child.wait().unwrap().success());
1211    assert_eq!(
1212        read_log(dir.path())
1213            .lines()
1214            .filter(|line| *line == "start")
1215            .count(),
1216        1,
1217        "a second run started during shutdown: {:?}",
1218        read_log(dir.path())
1219    );
1220}
1221
1222#[cfg(unix)]
1223#[test]
1224fn watch_follows_a_traversed_directory_being_replaced_by_a_symlink() {
1225    let outer = tempfile::tempdir().unwrap();
1226    let dir = outer.path().join("pkg");
1227    std::fs::create_dir(&dir).unwrap();
1228    std::fs::create_dir(outer.path().join("route")).unwrap();
1229    std::fs::create_dir_all(outer.path().join("new/base")).unwrap();
1230    std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"#).unwrap();
1231    std::fs::write(dir.join("input.txt"), "one\n").unwrap();
1232    std::fs::write(outer.path().join("shared.env"), "FLAVOR=orig\n").unwrap();
1233    std::fs::write(outer.path().join("new/shared.env"), "FLAVOR=new\n").unwrap();
1234    std::os::unix::fs::symlink("../route", dir.join("link")).unwrap();
1235    let mut child = spawn_watch(&dir, "build");
1236    wait_for_log(&dir, "one\n");
1237    std::fs::remove_dir(outer.path().join("route")).unwrap();
1238    std::os::unix::fs::symlink("new/base", outer.path().join("route")).unwrap();
1239    wait_for_log(&dir, "one\none\n");
1240    std::fs::write(outer.path().join("new/shared.env"), "FLAVOR=newer\n").unwrap();
1241    wait_for_log(&dir, "one\none\none\n");
1242    stop_watch(&mut child);
1243}
1244
1245#[cfg(unix)]
1246#[test]
1247fn watch_reloads_env_files_and_the_config() {
1248    let dir = project(
1249        "tasks: { build: {command: \"cat input.txt >> runs.log\", files: [\"input.txt\"], envFile: \".env\"} }",
1250    );
1251    std::fs::write(dir.path().join("input.txt"), "one\n").unwrap();
1252    std::fs::write(dir.path().join(".env"), "FLAVOR=one\n").unwrap();
1253    let mut child = spawn_watch(dir.path(), "build");
1254    wait_for_log(dir.path(), "one\n");
1255    // A same-content rewrite of the config changes nothing.
1256    let config = std::fs::read_to_string(dir.path().join("avril.conf")).unwrap();
1257    std::fs::write(dir.path().join("avril.conf"), config).unwrap();
1258    std::thread::sleep(Duration::from_millis(400));
1259    assert_eq!(read_log(dir.path()), "one\n");
1260    std::fs::write(dir.path().join(".env"), "FLAVOR=two\n").unwrap();
1261    wait_for_log(dir.path(), "one\none\n");
1262    std::fs::write(dir.path().join("avril.conf"), "tasks: { build: {command: \"echo changed >> runs.log\", files: [\"input.txt\"], envFile: \".env\"} }").unwrap();
1263    wait_for_log(dir.path(), "one\none\nchanged\n");
1264    stop_watch(&mut child);
1265}
1266
1267fn avril_cached(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Output {
1268    let mut command = Command::new(env!("CARGO_BIN_EXE_avril"));
1269    command
1270        .current_dir(dir)
1271        .args(args)
1272        .env("AVRIL_CACHE_DIR", dir.join("cache"))
1273        .env_remove("SITE_TEST_FLAVOR")
1274        .env_remove("FLAVOR")
1275        .env_remove("UNRELATED");
1276    for (name, value) in env {
1277        command.env(name, value);
1278    }
1279    command.output().unwrap()
1280}
1281
1282#[test]
1283fn cached_tasks_skip_while_outputs_are_intact_and_rerun_when_anything_changes() {
1284    let dir = project(&format!(
1285        "tasks: {{ site: {{ command: \"{} run ./generate.js\", files: ['src/**'], output: ['dist/'], env: ['SITE_TEST_FLAVOR'] }} }}",
1286        env!("CARGO_BIN_EXE_avril").replace('\\', "/")
1287    ));
1288    std::fs::create_dir(dir.path().join("src")).unwrap();
1289    std::fs::write(dir.path().join("src/content.txt"), "one").unwrap();
1290    std::fs::write(
1291        dir.path().join("generate.js"),
1292        r#"
1293        import { mkdirs, readTextFile, writeTextFile } from "avril:fs";
1294        const content = await readTextFile("src/content.txt");
1295        await mkdirs("dist");
1296        await writeTextFile("dist/index.html", `site:${content}`);
1297        console.log("generated");
1298        "#,
1299    )
1300    .unwrap();
1301    let index = dir.path().join("dist/index.html");
1302
1303    let output = avril_cached(dir.path(), &["task", "site"], &[]);
1304    assert!(output.status.success(), "{}", stderr(&output));
1305    assert_eq!(stdout(&output), "generated\n");
1306    assert_eq!(std::fs::read_to_string(&index).unwrap(), "site:one");
1307
1308    // Nothing changed: the task is skipped and its outputs are left alone.
1309    let output = avril_cached(dir.path(), &["task", "site"], &[]);
1310    assert!(output.status.success(), "{}", stderr(&output));
1311    assert_eq!(stdout(&output), "");
1312    assert!(
1313        stderr(&output).contains("Task site (cached)"),
1314        "{}",
1315        stderr(&output)
1316    );
1317    assert_eq!(std::fs::read_to_string(&index).unwrap(), "site:one");
1318
1319    // Outputs are verified, not restored: tampering with one reruns the task.
1320    std::fs::write(&index, "tampered").unwrap();
1321    let output = avril_cached(dir.path(), &["task", "site"], &[]);
1322    assert!(output.status.success(), "{}", stderr(&output));
1323    assert_eq!(stdout(&output), "generated\n");
1324    assert_eq!(std::fs::read_to_string(&index).unwrap(), "site:one");
1325
1326    // Deleting the outputs likewise reruns the task, which recreates them.
1327    std::fs::remove_dir_all(dir.path().join("dist")).unwrap();
1328    let output = avril_cached(dir.path(), &["task", "site"], &[]);
1329    assert!(output.status.success(), "{}", stderr(&output));
1330    assert_eq!(stdout(&output), "generated\n");
1331    assert_eq!(std::fs::read_to_string(&index).unwrap(), "site:one");
1332
1333    std::fs::write(dir.path().join("src/content.txt"), "two").unwrap();
1334    let output = avril_cached(dir.path(), &["task", "site"], &[]);
1335    assert!(output.status.success(), "{}", stderr(&output));
1336    assert_eq!(stdout(&output), "generated\n");
1337    assert_eq!(std::fs::read_to_string(&index).unwrap(), "site:two");
1338
1339    // A listed env var is part of the key even though the command ignores it.
1340    let output = avril_cached(
1341        dir.path(),
1342        &["task", "site"],
1343        &[("SITE_TEST_FLAVOR", "blue")],
1344    );
1345    assert_eq!(stdout(&output), "generated\n");
1346    let output = avril_cached(
1347        dir.path(),
1348        &["task", "site"],
1349        &[("SITE_TEST_FLAVOR", "blue")],
1350    );
1351    assert_eq!(stdout(&output), "");
1352    let output = avril_cached(
1353        dir.path(),
1354        &["task", "site"],
1355        &[("SITE_TEST_FLAVOR", "green")],
1356    );
1357    assert_eq!(stdout(&output), "generated\n");
1358}
1359
1360#[test]
1361fn cache_keys_include_extra_args_and_dependency_fingerprints() {
1362    let dir = project(
1363        r#"
1364        tasks: {
1365            work: {command: "echo work", files: ["input.txt"]},
1366        }
1367        "#,
1368    );
1369    std::fs::write(dir.path().join("input.txt"), "input").unwrap();
1370    let output = avril_cached(dir.path(), &["task", "work", "a"], &[]);
1371    assert_eq!(stdout(&output), "work a\n");
1372    let output = avril_cached(dir.path(), &["task", "work", "a"], &[]);
1373    assert_eq!(stdout(&output), "");
1374    assert!(stderr(&output).contains("(cached)"), "{}", stderr(&output));
1375    let output = avril_cached(dir.path(), &["task", "work", "b"], &[]);
1376    assert_eq!(stdout(&output), "work b\n");
1377
1378    let dir = project(
1379        r#"
1380        tasks: {
1381            child: "echo child1",
1382            parent: {command: "echo parent", dependencies: ["child"], files: ["input.txt"]},
1383        }
1384        "#,
1385    );
1386    std::fs::write(dir.path().join("input.txt"), "input").unwrap();
1387    let output = avril_cached(dir.path(), &["task", "parent"], &[]);
1388    assert_eq!(stdout(&output), "child1\nparent\n");
1389    // The uncached child always runs, but the parent is served from cache.
1390    let output = avril_cached(dir.path(), &["task", "parent"], &[]);
1391    assert_eq!(stdout(&output), "child1\n");
1392    std::fs::write(
1393        dir.path().join("avril.conf"),
1394        r#"
1395        tasks: {
1396            child: "echo child2",
1397            parent: {command: "echo parent", dependencies: ["child"], files: ["input.txt"]},
1398        }
1399        "#,
1400    )
1401    .unwrap();
1402    let output = avril_cached(dir.path(), &["task", "parent"], &[]);
1403    assert_eq!(stdout(&output), "child2\nparent\n");
1404}
1405
1406#[test]
1407fn a_files_pattern_that_matches_nothing_is_an_error_against_the_config() {
1408    let dir = project(
1409        r#"
1410        tasks: {
1411            build: {command: "echo build", files: ["missing/**/*.ts"]},
1412        }
1413        "#,
1414    );
1415    let output = avril_cached(dir.path(), &["task", "build"], &[]);
1416    assert!(!output.status.success());
1417    let error = stderr(&output);
1418    assert!(error.contains("matched no files"), "{error}");
1419    assert!(error.contains("avril.conf:3:"), "{error}");
1420    assert!(!stdout(&output).contains("build"));
1421}
1422
1423#[test]
1424fn without_a_config_there_is_a_clear_error() {
1425    let dir = tempfile::tempdir().unwrap();
1426    let output = avril(dir.path(), &["task", "anything"]);
1427    assert!(!output.status.success());
1428    assert!(
1429        stderr(&output).contains("No avril.conf found"),
1430        "{}",
1431        stderr(&output)
1432    );
1433}