Skip to main content

qk_cache/
recorded.rs

1//! Path sets: what a task did to the workspace when it last ran, kept so a
2//! later run can key it by how those paths are now. See
3//! [`crate::ultracache`] for the target field that opts a task in.
4//!
5//! A path set holds the files the task read, the directories it listed and
6//! the paths it looked up, found or missing. Its key, the strong key, hashes
7//! each file's content, each directory's listing and each path's existence,
8//! on top of the weak key: the task's key without its declared file inputs.
9//! A new file in a listed directory changes the listing; a file created
10//! where the task found nothing changes its existence.
11//!
12//! Paths outside the workspace, `node_modules`, `.git`, qk's own state, the
13//! task's outputs and warm paths, files it wrote before reading them and its
14//! `ignoreReads` are left out.
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::path::{Path, PathBuf};
18use std::time::SystemTime;
19
20use anyhow::Result;
21use qk_input_analysis::{Access, Operation};
22use serde::{Deserialize, Serialize};
23use serde_json::{Map, Value, json};
24
25use crate::glob::Pattern;
26use crate::hash::{Snapshot, key};
27use crate::paths::Outputs;
28
29/// How many path sets are kept for one weak key, the newest first: a task
30/// that reads differently from run to run, such as by platform or by what it
31/// finds, keeps a set for each way.
32const KEPT: usize = 8;
33
34#[derive(Clone, Debug, Default, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct PathSet {
37    /// Files whose content the task read.
38    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
39    pub reads: BTreeSet<String>,
40    /// Directories it listed; `.` is the workspace root.
41    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
42    pub listings: BTreeSet<String>,
43    /// Paths it found, without reading them. A file among them is keyed by
44    /// its content all the same: a tool that compares timestamps, as
45    /// `tsc --build` does, decides by a file it never reads.
46    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
47    pub present: BTreeSet<String>,
48    /// Paths it looked for and did not find.
49    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
50    pub absent: BTreeSet<String>,
51    /// Paths it wrote in the directories it listed, left out of their
52    /// listings: they are there after it runs, and need not be before.
53    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
54    pub written: BTreeSet<String>,
55    /// How its paths were when it was recorded, to tell which changed once
56    /// its key no longer matches. Not part of the set, nor of its key: sets
57    /// recorded before it was kept have none.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub recorded: Option<Values>,
60}
61
62/// Two path sets are the same set when they hold the same paths, however
63/// those were when each was recorded.
64impl PartialEq for PathSet {
65    fn eq(&self, other: &Self) -> bool {
66        (
67            &self.reads,
68            &self.listings,
69            &self.present,
70            &self.absent,
71            &self.written,
72        ) == (
73            &other.reads,
74            &other.listings,
75            &other.present,
76            &other.absent,
77            &other.written,
78        )
79    }
80}
81
82/// A short digest of how each of a path set's paths was: each read file's
83/// content, each listed directory's entries and each looked-up path's kind.
84#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
85pub struct Values {
86    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
87    pub reads: BTreeMap<String, String>,
88    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
89    pub listings: BTreeMap<String, String>,
90    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
91    pub existence: BTreeMap<String, String>,
92}
93
94impl Values {
95    /// The digests of `values`, as [`PathSet::values`] gives them.
96    fn of(values: &Value) -> Result<Self> {
97        let digests = |part: &str| -> Result<BTreeMap<String, String>> {
98            values[part]
99                .as_object()
100                .into_iter()
101                .flatten()
102                .map(|(path, value)| Ok((path.clone(), key(value)?[..16].to_owned())))
103                .collect()
104        };
105        Ok(Self {
106            reads: digests("reads")?,
107            listings: digests("listings")?,
108            existence: digests("existence")?,
109        })
110    }
111}
112
113/// What a path set leaves out.
114pub struct Exclusions<'a> {
115    /// The workspace root, as given and with its links resolved.
116    pub roots: [&'a Path; 2],
117    /// The task's outputs and warm paths.
118    pub outputs: &'a [&'a Outputs],
119    /// `ignoreReads`.
120    pub ignore: Vec<Pattern>,
121    /// qk's own directories inside the workspace, relative to it.
122    pub state: Vec<String>,
123}
124
125impl Exclusions<'_> {
126    /// `path`, absolute, relative to the workspace: `None` outside it.
127    fn relative(&self, path: &str) -> Option<String> {
128        let path = qk_input_analysis::strace::normalize(Path::new(path));
129        let relative = self
130            .roots
131            .iter()
132            .find_map(|root| path.strip_prefix(root).ok())?;
133        let relative = relative.to_str()?.replace('\\', "/");
134        Some(if relative.is_empty() {
135            ".".into()
136        } else {
137            relative
138        })
139    }
140
141    fn excluded(&self, path: &str) -> bool {
142        let mut parts = path.split('/');
143        parts.clone().next() == Some(".git")
144            || parts.any(|part| part == "node_modules")
145            || self.state.iter().any(|state| {
146                path == state
147                    || path
148                        .strip_prefix(state.as_str())
149                        .is_some_and(|rest| rest.starts_with('/'))
150            })
151            || self.outputs.iter().any(|outputs| outputs.matches(path))
152            || self.ignore.iter().any(|pattern| pattern.is_match(path))
153    }
154}
155
156impl PathSet {
157    /// The path set of a task's recorded accesses, in the order it made
158    /// them. `root` is the workspace root, to tell a directory opened for
159    /// listing from a file opened for reading.
160    pub fn from_accesses(accesses: &[Access], exclusions: &Exclusions, root: &Path) -> Self {
161        let mut set = Self::default();
162        let mut written = BTreeSet::new();
163        for access in accesses {
164            let Some(path) = exclusions.relative(&access.path) else {
165                continue;
166            };
167            if access.operation.writes() {
168                written.insert(path);
169                continue;
170            }
171            // What it wrote itself is no input.
172            if written.contains(&path) || exclusions.excluded(&path) {
173                continue;
174            }
175            let found = matches!(access.result.as_str(), "success" | "unknown");
176            let missing = matches!(access.result.as_str(), "missing" | "ENOTDIR");
177            match access.operation {
178                Operation::OpenRead | Operation::ReadData | Operation::Execute if found => {
179                    // A directory is opened to be listed, which its listing
180                    // records.
181                    if !root.join(&path).is_dir() {
182                        set.reads.insert(path);
183                    }
184                }
185                Operation::ListDirectory | Operation::ReadDirectory if found => {
186                    set.listings.insert(path);
187                }
188                Operation::Metadata | Operation::ReadLink if found => {
189                    set.present.insert(path);
190                }
191                _ if missing => {
192                    set.absent.insert(path);
193                }
194                _ => {}
195            }
196        }
197        set.present.retain(|path| !set.reads.contains(path));
198        set.absent
199            .retain(|path| !set.reads.contains(path) && !set.present.contains(path));
200        set.written = written
201            .into_iter()
202            .filter(|path| set.listings.contains(parent(path)))
203            .collect();
204        set
205    }
206
207    /// Whether every path it names lies within the workspace: relative, and
208    /// never through `..`.
209    pub fn is_relative(&self) -> bool {
210        [
211            &self.reads,
212            &self.listings,
213            &self.present,
214            &self.absent,
215            &self.written,
216        ]
217        .into_iter()
218        .flatten()
219        .all(|path| {
220            !path.is_empty()
221                && Path::new(path).components().all(|component| {
222                    matches!(
223                        component,
224                        std::path::Component::Normal(_) | std::path::Component::CurDir
225                    )
226                })
227        })
228    }
229
230    pub fn is_empty(&self) -> bool {
231        self.reads.is_empty()
232            && self.listings.is_empty()
233            && self.present.is_empty()
234            && self.absent.is_empty()
235    }
236
237    /// A path the set keys that changed after `started`, when the task ran:
238    /// keyed by how it is now, a recording would key the task by what was
239    /// left there rather than by what the task saw.
240    ///
241    /// A file read or found changed if it was written. A listing, which keys
242    /// its entries' names and kinds, changed if an entry was added, removed
243    /// or renamed. In a directory `before` holds, that is told exactly, by
244    /// comparing its entries with those before the task ran, leaving out
245    /// what the task wrote and what listings leave out. Elsewhere it moves
246    /// the directory's own modification time; what is written inside an
247    /// entry does not change it, though it moves the entry's change time.
248    /// Where the task wrote into the directory, or its own outputs are
249    /// there, its time moves with the task's own writes, so there an entry
250    /// other than those changed it: a file whose change time moved, by being
251    /// added, moved in or rewritten, or a subdirectory created after
252    /// `started`, by its birth time, which a file system without one cannot
253    /// tell. A removal there, or a subdirectory moved in, cannot be told
254    /// from the task's own writes.
255    pub(crate) fn changed_since(
256        &self,
257        root: &Path,
258        started: SystemTime,
259        unlisted: &Unlisted,
260        before: &Before,
261    ) -> Option<Changed<'_>> {
262        let file = self.reads.iter().chain(&self.present).find(|path| {
263            std::fs::symlink_metadata(root.join(path))
264                .is_ok_and(|metadata| !metadata.is_dir() && changed(&metadata) >= started)
265        });
266        if let Some(path) = file {
267            return Some(Changed { path, how: None });
268        }
269        self.listings.iter().find_map(|directory| {
270            self.listing_changed(root, directory, started, unlisted, before)
271                .map(|how| Changed {
272                    path: directory,
273                    how,
274                })
275        })
276    }
277
278    /// Whether `directory`'s listing changed while the task ran, and if so,
279    /// what changed in it, where that can be told.
280    fn listing_changed(
281        &self,
282        root: &Path,
283        directory: &str,
284        started: SystemTime,
285        unlisted: &Unlisted,
286        before: &Before,
287    ) -> Option<Option<String>> {
288        // A directory listings leave out, such as another target's output,
289        // has nothing in it to key.
290        if directory != "." && unlisted.matches(directory) {
291            return None;
292        }
293        let absolute = if directory == "." {
294            root.to_path_buf()
295        } else {
296            root.join(directory)
297        };
298        let skipped = |path: &str| self.written.contains(path) || unlisted.matches(path);
299        if let Some(earlier) = before.0.get(directory) {
300            let earlier: BTreeSet<_> = earlier
301                .iter()
302                .filter(|(name, _)| !self.written.contains(&child(directory, name)))
303                .cloned()
304                .collect();
305            let Some(now) = entries(root, directory, skipped) else {
306                return Some(Some("it could no longer be read".to_owned()));
307            };
308            if let Some((name, _)) = now.difference(&earlier).next() {
309                return Some(Some(format!("{name} added")));
310            }
311            if let Some((name, _)) = earlier.difference(&now).next() {
312                return Some(Some(format!("{name} removed")));
313            }
314            return None;
315        }
316        let Ok(entries) = std::fs::read_dir(&absolute) else {
317            return Some(Some("it was removed or could no longer be read".to_owned()));
318        };
319        // Each entry, whether listings leave it out, and whether the task
320        // wrote it or it is among the task's own outputs.
321        let entries: Vec<(std::fs::DirEntry, bool, bool)> = entries
322            .flatten()
323            .map(|entry| {
324                let path = child(directory, &entry.file_name().to_string_lossy());
325                let own = self.written.contains(&path) || unlisted.own(&path);
326                (entry, skipped(&path), own)
327            })
328            .collect();
329        // A file's change time moves when it is moved in, and with a rewrite,
330        // which counts too. A subdirectory's moves with what is written
331        // inside it, so it counts only once it was created.
332        let new = |entry: &std::fs::DirEntry| {
333            std::fs::symlink_metadata(entry.path()).is_ok_and(|metadata| {
334                if metadata.is_dir() {
335                    metadata.created().is_ok_and(|born| born >= started)
336                } else {
337                    changed(&metadata) >= started
338                }
339            })
340        };
341        let unskipped = || entries.iter().filter(|(_, skipped, _)| !skipped);
342        let name = |entry: &std::fs::DirEntry| entry.file_name().to_string_lossy().into_owned();
343        let own = self.written.iter().any(|path| parent(path) == directory)
344            || entries.iter().any(|(_, _, own)| *own);
345        if !own {
346            let moved = std::fs::symlink_metadata(&absolute)
347                .and_then(|metadata| metadata.modified())
348                .is_ok_and(|modified| modified >= started);
349            return moved.then(|| {
350                Some(unskipped().find(|(entry, _, _)| new(entry)).map_or_else(
351                    || "an entry removed or renamed".to_owned(),
352                    |(entry, _, _)| format!("{} added or changed", name(entry)),
353                ))
354            });
355        }
356        unskipped()
357            .find(|(entry, _, _)| new(entry))
358            .map(|(entry, _, _)| Some(format!("{} added or changed", name(entry))))
359    }
360
361    /// The set's paths, as its key holds them.
362    pub fn digest(&self) -> Result<String> {
363        let paths = Self {
364            recorded: None,
365            ..self.clone()
366        };
367        key(&serde_json::to_value(paths)?)
368    }
369
370    /// The paths that are not as they were when it was recorded, each with
371    /// what the task did to it: `read`, `listed`, `found` or `looked for`.
372    /// None when it kept no record of them.
373    pub(crate) fn changed(
374        &self,
375        snapshot: &Snapshot,
376        root: &Path,
377        unlisted: &Unlisted,
378    ) -> Result<Vec<(&str, &'static str)>> {
379        let Some(recorded) = &self.recorded else {
380            return Ok(Vec::new());
381        };
382        let now = Values::of(&self.values(snapshot, root, unlisted)?)?;
383        let differs = |then: &BTreeMap<String, String>,
384                       now: &BTreeMap<String, String>,
385                       path: &str| { then.get(path) != now.get(path) };
386        let mut changed = Vec::new();
387        for path in &self.reads {
388            if differs(&recorded.reads, &now.reads, path) {
389                changed.push((path.as_str(), "read"));
390            }
391        }
392        for path in &self.listings {
393            if differs(&recorded.listings, &now.listings, path) {
394                changed.push((path.as_str(), "listed"));
395            }
396        }
397        for (paths, how) in [(&self.present, "found"), (&self.absent, "looked for")] {
398            for path in paths {
399                if differs(&recorded.existence, &now.existence, path) {
400                    changed.push((path.as_str(), how));
401                }
402            }
403        }
404        Ok(changed)
405    }
406
407    /// How the set's paths are now: each read file's content, each listed
408    /// directory's entries and each looked-up path's kind. `outputs` are the
409    /// task's own, left out of listings.
410    pub(crate) fn values(
411        &self,
412        snapshot: &Snapshot,
413        root: &Path,
414        unlisted: &Unlisted,
415    ) -> Result<Value> {
416        // A file by its content, anything else by what it is.
417        let value = |path: &str| match kind(&root.join(path)) {
418            "file" | "symlink" => snapshot
419                .recorded_value(root, path)
420                .unwrap_or_else(|_| json!({"unreadable": true})),
421            other => json!(other),
422        };
423        let reads: Map<String, Value> = self
424            .reads
425            .iter()
426            .map(|path| (path.clone(), value(path)))
427            .collect();
428        let mut listings = Map::new();
429        for directory in &self.listings {
430            listings.insert(directory.clone(), self.listing(root, directory, unlisted)?);
431        }
432        let existence: Map<String, Value> = self
433            .present
434            .iter()
435            .chain(&self.absent)
436            .map(|path| (path.clone(), value(path)))
437            .collect();
438        Ok(json!({"reads": reads, "listings": listings, "existence": existence}))
439    }
440
441    /// A directory's entries by name and kind, without what the task writes.
442    fn listing(&self, root: &Path, directory: &str, unlisted: &Unlisted) -> Result<Value> {
443        let absolute = if directory == "." {
444            root.to_path_buf()
445        } else {
446            root.join(directory)
447        };
448        let entries = match std::fs::read_dir(&absolute) {
449            Ok(entries) => entries,
450            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
451                return Ok(json!("absent"));
452            }
453            Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
454                return Ok(json!(kind(&absolute)));
455            }
456            Err(error) => return Err(error.into()),
457        };
458        let mut listed = Map::new();
459        for entry in entries {
460            let entry = entry?;
461            let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
462                continue;
463            };
464            let path = if directory == "." {
465                name.clone()
466            } else {
467                format!("{directory}/{name}")
468            };
469            if self.written.contains(&path) || unlisted.matches(&path) {
470                continue;
471            }
472            listed.insert(name, json!(kind(&entry.path())));
473        }
474        Ok(Value::Object(listed))
475    }
476}
477
478/// The directory a workspace-relative path is in, `.` for the root.
479fn parent(path: &str) -> &str {
480    path.rsplit_once('/').map_or(".", |(parent, _)| parent)
481}
482
483/// The path of `name` in `directory`, `.` being the workspace root.
484fn child(directory: &str, name: &str) -> String {
485    if directory == "." {
486        name.to_owned()
487    } else {
488        format!("{directory}/{name}")
489    }
490}
491
492/// The names and kinds of `directory`'s entries but those `skipped`; none
493/// when it, or any of its entries or their kinds, cannot be read.
494fn entries(
495    root: &Path,
496    directory: &str,
497    skipped: impl Fn(&str) -> bool,
498) -> Option<BTreeSet<(String, &'static str)>> {
499    let absolute = if directory == "." {
500        root.to_path_buf()
501    } else {
502        root.join(directory)
503    };
504    let mut found = BTreeSet::new();
505    for entry in std::fs::read_dir(&absolute).ok()? {
506        let entry = entry.ok()?;
507        let name = entry.file_name().to_string_lossy().into_owned();
508        if !skipped(&child(directory, &name)) {
509            found.insert((name, file_kind(entry.file_type().ok()?)));
510        }
511    }
512    Some(found)
513}
514
515/// What is at `path`, without following a link.
516fn kind(path: &Path) -> &'static str {
517    std::fs::symlink_metadata(path).map_or("absent", |metadata| file_kind(metadata.file_type()))
518}
519
520fn file_kind(file_type: std::fs::FileType) -> &'static str {
521    if file_type.is_symlink() {
522        "symlink"
523    } else if file_type.is_dir() {
524        "directory"
525    } else if file_type.is_file() {
526        "file"
527    } else {
528        "other"
529    }
530}
531
532/// A project's root as output paths name it: without a leading `./` or a
533/// trailing `/`, and empty for the workspace's root, which `""` and `"."`
534/// both name.
535fn relative_root(root: &str) -> &str {
536    let mut root = root.trim_end_matches('/');
537    while let Some(rest) = root.strip_prefix("./") {
538        root = rest;
539    }
540    if root == "." { "" } else { root }
541}
542
543/// A path whose recording changed while the task ran, and what changed in
544/// it, where that can be told.
545#[derive(Debug, PartialEq, Eq)]
546pub(crate) struct Changed<'a> {
547    pub path: &'a str,
548    pub how: Option<String>,
549}
550
551impl std::fmt::Display for Changed<'_> {
552    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553        match &self.how {
554            Some(how) => write!(f, "{} changed while it ran ({how})", self.path),
555            None => write!(f, "{} changed while it ran", self.path),
556        }
557    }
558}
559
560/// The declared outputs of every target in the workspace, and of each of
561/// their configurations, which can move an output with its options, by the
562/// workspace's configuration rather than what a run schedules. Indexed by
563/// their anchors, so a path is matched only against the outputs that can
564/// hold it.
565#[derive(Default)]
566pub(crate) struct WorkspaceOutputs {
567    outputs: Vec<(TargetOf, Outputs)>,
568    by_anchor: BTreeMap<String, Vec<usize>>,
569    /// The directories between each output's project root and its anchor,
570    /// such as `.cache` for `.cache/build`, which exist to hold it.
571    folders: Vec<Vec<String>>,
572    by_folder: BTreeMap<String, Vec<usize>>,
573}
574
575impl WorkspaceOutputs {
576    /// Whether `path`, workspace-relative, is a declared output of any
577    /// target.
578    pub(crate) fn matches(&self, path: &str) -> bool {
579        self.outputs
580            .iter()
581            .any(|(_, outputs)| outputs.matches(path))
582    }
583}
584
585impl WorkspaceOutputs {
586    /// `roots` holds each project's root.
587    pub(crate) fn new(outputs: Vec<(TargetOf, Outputs)>, roots: &BTreeMap<String, String>) -> Self {
588        let mut by_anchor: BTreeMap<String, Vec<usize>> = BTreeMap::new();
589        let mut by_folder: BTreeMap<String, Vec<usize>> = BTreeMap::new();
590        let mut folders = Vec::new();
591        // No project's root, nor a directory holding one, is a folder an
592        // output owns.
593        let projects: BTreeSet<&str> = roots
594            .values()
595            .map(|root| relative_root(root))
596            .filter(|root| !root.is_empty())
597            .flat_map(|root| {
598                std::iter::once(root).chain(root.match_indices('/').map(|(at, _)| &root[..at]))
599            })
600            .collect();
601        for (index, ((project, _, _), declared)) in outputs.iter().enumerate() {
602            let root = roots.get(project).map_or("", |root| relative_root(root));
603            let mut held = Vec::new();
604            for anchor in declared.anchors() {
605                by_anchor.entry(anchor.to_owned()).or_default().push(index);
606                let rest = if root.is_empty() {
607                    Some(anchor)
608                } else {
609                    anchor
610                        .strip_prefix(root)
611                        .and_then(|rest| rest.strip_prefix('/'))
612                };
613                let Some(rest) = rest else {
614                    continue;
615                };
616                // Each directory below the root that holds the anchor.
617                for (at, _) in rest.match_indices('/') {
618                    let folder = if root.is_empty() {
619                        rest[..at].to_owned()
620                    } else {
621                        format!("{root}/{}", &rest[..at])
622                    };
623                    if projects.contains(folder.as_str()) {
624                        continue;
625                    }
626                    by_folder.entry(folder.clone()).or_default().push(index);
627                    held.push(folder);
628                }
629            }
630            folders.push(held);
631        }
632        Self {
633            outputs,
634            by_anchor,
635            folders,
636            by_folder,
637        }
638    }
639
640    /// The outputs whose anchor is `path` or a directory holding it.
641    fn holding<'a>(&'a self, path: &'a str) -> impl Iterator<Item = &'a (TargetOf, Outputs)> {
642        std::iter::once(path)
643            .chain(path.match_indices('/').map(|(at, _)| &path[..at]))
644            .filter_map(|anchor| self.by_anchor.get(anchor))
645            .flatten()
646            .map(|&index| &self.outputs[index])
647    }
648}
649
650/// A target by project, target and configuration, whose options can move
651/// its outputs.
652pub(crate) type TargetOf = (String, String, Option<String>);
653
654/// The outputs a recording's listings leave out: those of every target in
655/// the workspace, the task's own included, except the targets the task
656/// depends on, whose outputs are its inputs.
657pub(crate) struct Others<'a> {
658    pub outputs: &'a WorkspaceOutputs,
659    /// The targets the task depends on.
660    pub upstream: BTreeSet<TargetOf>,
661}
662
663impl Others<'_> {
664    /// Whether `path` is another target's output, and none of the
665    /// upstream's: an output both declare is the task's input.
666    ///
667    /// A directory that only holds another target's outputs, between its
668    /// project's root and the outputs, counts as one, as `.cache` does for
669    /// `.cache/build`: it comes and goes with them.
670    pub(crate) fn matches(&self, path: &str) -> bool {
671        let mut other = false;
672        let holding = self
673            .outputs
674            .holding(path)
675            .filter(|(_, outputs)| outputs.matches(path))
676            .map(|(target, _)| target);
677        let folders = self
678            .outputs
679            .by_folder
680            .get(path)
681            .into_iter()
682            .flatten()
683            .map(|&index| &self.outputs.outputs[index].0);
684        for target in holding.chain(folders) {
685            if self.upstream.contains(target) {
686                return false;
687            }
688            other = true;
689        }
690        other
691    }
692}
693
694/// What a recording's listings leave out of a directory's entries: what is
695/// there after a task runs whether or not it read anything, rather than what
696/// it read. The task's own outputs, the outputs of other targets, such as a
697/// build's output beside a lint's sources or another project's coverage, but
698/// not those of the targets it depends on, and what its `ignoreReads` names.
699/// Only listings leave them out: a file among them that the task read is
700/// still keyed by its content.
701pub(crate) struct Unlisted<'a> {
702    pub own: Vec<&'a Outputs>,
703    pub others: Option<Others<'a>>,
704    pub ignore: &'a [Pattern],
705}
706
707impl Unlisted<'_> {
708    fn matches(&self, path: &str) -> bool {
709        self.own(path)
710            || self
711                .others
712                .as_ref()
713                .is_some_and(|others| others.matches(path))
714            || self.ignore.iter().any(|pattern| pattern.is_match(path))
715    }
716
717    fn own(&self, path: &str) -> bool {
718        self.own.iter().any(|outputs| outputs.matches(path))
719    }
720
721    /// Where other targets' outputs and ignored paths come and go while the
722    /// task runs: the directories holding those outputs, up to their
723    /// project's root, and their anchors, where an output a glob declares
724    /// comes and goes, and the root of the task's project, where a tool's
725    /// `ignoreReads` state usually sits.
726    pub(crate) fn directories(&self, project_root: &str) -> BTreeSet<String> {
727        let project_root = match relative_root(project_root) {
728            "" => ".",
729            root => root,
730        };
731        let mut directories = BTreeSet::from([project_root.to_owned()]);
732        if let Some(others) = &self.others {
733            let all = &others.outputs;
734            for (index, (target, outputs)) in all.outputs.iter().enumerate() {
735                if others.upstream.contains(target) {
736                    continue;
737                }
738                for anchor in outputs.anchors() {
739                    directories.insert(anchor.to_owned());
740                    directories.insert(parent(anchor).to_owned());
741                }
742                // Each folder, and the directory holding it, which can be
743                // another project's root.
744                for folder in &all.folders[index] {
745                    directories.insert(folder.clone());
746                    directories.insert(parent(folder).to_owned());
747                }
748            }
749        }
750        directories
751    }
752}
753
754/// The entries of directories before a task ran, but those its listings
755/// leave out, so a listing whose directory gained or lost something else as
756/// it ran is told apart from one where only those came and went.
757#[derive(Default)]
758pub(crate) struct Before(BTreeMap<String, BTreeSet<(String, &'static str)>>);
759
760impl Before {
761    pub(crate) fn take(root: &Path, directories: BTreeSet<String>, unlisted: &Unlisted) -> Self {
762        Self(
763            directories
764                .into_iter()
765                .filter_map(|directory| {
766                    let entries = entries(root, &directory, |path| unlisted.matches(path))?;
767                    Some((directory, entries))
768                })
769                .collect(),
770        )
771    }
772}
773
774/// The path sets kept for weak keys, in `pathsets/` beside the entries.
775#[derive(Serialize, Deserialize)]
776struct Stored {
777    version: u32,
778    weak: String,
779    sets: Vec<PathSet>,
780}
781
782fn location(cache: &Path, weak: &str) -> PathBuf {
783    cache.join("pathsets").join(format!("{weak}.json"))
784}
785
786/// The path sets kept for `weak`, newest first; none when there are none or
787/// they cannot be read.
788pub(crate) fn load(cache: &Path, weak: &str) -> Vec<PathSet> {
789    std::fs::read(location(cache, weak))
790        .ok()
791        .and_then(|bytes| serde_json::from_slice::<Stored>(&bytes).ok())
792        .filter(|stored| stored.version == 1 && stored.weak == weak)
793        .map(|stored| stored.sets)
794        .unwrap_or_default()
795}
796
797/// Marks `weak`'s path sets as used, so eviction keeps them as it keeps the
798/// entries they lead to.
799pub(crate) fn touch(cache: &Path, weak: &str) {
800    if let Ok(file) = std::fs::File::options()
801        .append(true)
802        .open(location(cache, weak))
803    {
804        let _ = file.set_modified(std::time::SystemTime::now());
805    }
806}
807
808/// Keeps `set` as the newest for `weak`, and at most [`KEPT`] in all.
809pub(crate) fn save(cache: &Path, weak: &str, set: PathSet) -> Result<()> {
810    let mut sets = load(cache, weak);
811    sets.retain(|kept| kept != &set);
812    sets.insert(0, set);
813    keep(cache, weak, sets)
814}
815
816/// The record of `weak`'s path sets as the local cache keeps them, for the
817/// remote store.
818pub(crate) fn record(cache: &Path, weak: &str) -> Option<Vec<u8>> {
819    std::fs::read(location(cache, weak)).ok()
820}
821
822/// Adds the path sets in a record the remote store held for `weak` behind
823/// those kept locally, returning the ones that are new here. A record for
824/// another weak key or another version adds nothing, and neither does a set
825/// naming a path outside the workspace.
826pub(crate) fn merge(cache: &Path, weak: &str, record: &[u8]) -> Result<Vec<PathSet>> {
827    let Some(fetched) = serde_json::from_slice::<Stored>(record)
828        .ok()
829        .filter(|stored| stored.version == 1 && stored.weak == weak)
830    else {
831        return Ok(Vec::new());
832    };
833    let mut sets = load(cache, weak);
834    let mut added = Vec::new();
835    for set in fetched.sets {
836        if sets.len() + added.len() < KEPT
837            && set.is_relative()
838            && !set.is_empty()
839            && !sets.contains(&set)
840            && !added.contains(&set)
841        {
842            added.push(set);
843        }
844    }
845    if !added.is_empty() {
846        sets.extend(added.iter().cloned());
847        keep(cache, weak, sets)?;
848    }
849    Ok(added)
850}
851
852fn keep(cache: &Path, weak: &str, mut sets: Vec<PathSet>) -> Result<()> {
853    sets.truncate(KEPT);
854    std::fs::create_dir_all(cache.join("pathsets"))?;
855    crate::record::publish(
856        cache,
857        &location(cache, weak),
858        &Stored {
859            version: 1,
860            weak: weak.to_owned(),
861            sets,
862        },
863    )
864}
865
866/// The key of a result for `set` under `weak`, from how its paths are now.
867pub(crate) fn strong_key(
868    weak: &str,
869    set: &PathSet,
870    snapshot: &Snapshot,
871    root: &Path,
872    unlisted: &Unlisted,
873) -> Result<String> {
874    keyed(weak, set, &set.values(snapshot, root, unlisted)?)
875}
876
877fn keyed(weak: &str, set: &PathSet, values: &Value) -> Result<String> {
878    key(&json!({
879        "schema": "qk-recorded-v1",
880        "weak": weak,
881        "set": set.digest()?,
882        "values": values,
883    }))
884}
885
886/// `set`'s key under `weak`, from how its paths are now, and `set` with
887/// how they are kept for telling later which changed.
888pub(crate) fn with_values(
889    weak: &str,
890    mut set: PathSet,
891    snapshot: &Snapshot,
892    root: &Path,
893    unlisted: &Unlisted,
894) -> Result<(String, PathSet)> {
895    let values = set.values(snapshot, root, unlisted)?;
896    let strong = keyed(weak, &set, &values)?;
897    set.recorded = Some(Values::of(&values)?);
898    Ok((strong, set))
899}
900
901/// Now, as the file system in `directory` stamps a file it writes. The kernel
902/// stamps files from a clock coarser than the system's, so a file written
903/// just after a [`SystemTime::now`] can bear an earlier time; this one is
904/// never later than the stamp of a file written after it.
905pub(crate) fn now(directory: &Path) -> SystemTime {
906    tempfile::NamedTempFile::new_in(directory)
907        .and_then(|file| file.as_file().metadata())
908        .map(|metadata| changed(&metadata))
909        .unwrap_or_else(|_| SystemTime::now())
910}
911
912/// When a path's content or metadata last changed: its ctime where there is
913/// one, which a tool resetting the modification time does not hide.
914fn changed(metadata: &std::fs::Metadata) -> SystemTime {
915    let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
916    #[cfg(unix)]
917    {
918        use std::os::unix::fs::MetadataExt;
919        let ctime = u64::try_from(metadata.ctime()).ok().map(|seconds| {
920            SystemTime::UNIX_EPOCH
921                + std::time::Duration::new(
922                    seconds,
923                    u32::try_from(metadata.ctime_nsec()).unwrap_or(0),
924                )
925        });
926        if let Some(ctime) = ctime {
927            return modified.max(ctime);
928        }
929    }
930    modified
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936
937    /// Nothing left out of listings.
938    const NOTHING: Unlisted<'static> = Unlisted {
939        own: Vec::new(),
940        others: None,
941        ignore: &[],
942    };
943    use qk_input_analysis::Category;
944
945    fn access(path: &str, operation: Operation, result: &str) -> Access {
946        Access {
947            process: 1,
948            path: path.into(),
949            resolved_path: None,
950            keyed_path: None,
951            package: None,
952            operation,
953            result: result.into(),
954            category: Category::Uncovered,
955        }
956    }
957
958    #[test]
959    fn keeps_what_the_task_read_listed_and_looked_up() {
960        let temp = tempfile::tempdir().unwrap();
961        let root = temp.path().canonicalize().unwrap();
962        for directory in ["src", "icons", "dist"] {
963            std::fs::create_dir(root.join(directory)).unwrap();
964        }
965        let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
966        let outputs = Outputs::from_paths(&["dist".into()]).unwrap();
967        let exclusions = Exclusions {
968            roots: [&root, &root],
969            outputs: &[&outputs],
970            ignore: vec![Pattern::new("src/*.log", false).unwrap()],
971            state: vec![".qk".into()],
972        };
973        let accesses = [
974            access(&path("src/a.ts"), Operation::OpenRead, "success"),
975            access(&path("src"), Operation::OpenRead, "success"),
976            access(&path("icons"), Operation::ListDirectory, "success"),
977            access(&path("config.local"), Operation::Metadata, "missing"),
978            access(&path("src/b.ts"), Operation::Metadata, "success"),
979            // A file it writes and then reads is its own.
980            access(&path("icons/sprite.svg"), Operation::Write, "success"),
981            access(&path("icons/sprite.svg"), Operation::OpenRead, "success"),
982            // Left out wholesale.
983            access("/usr/lib/libc.so", Operation::OpenRead, "success"),
984            access(
985                &path("node_modules/x/index.js"),
986                Operation::OpenRead,
987                "success",
988            ),
989            access(&path(".git/HEAD"), Operation::OpenRead, "success"),
990            access(&path(".qk/state.json"), Operation::OpenRead, "success"),
991            access(&path("dist/out.js"), Operation::OpenRead, "success"),
992            access(&path("src/debug.log"), Operation::OpenRead, "success"),
993            // A file read is not also only present.
994            access(&path("src/a.ts"), Operation::Metadata, "success"),
995        ];
996        let set = PathSet::from_accesses(&accesses, &exclusions, &root);
997        assert_eq!(
998            set,
999            PathSet {
1000                reads: ["src/a.ts".into()].into(),
1001                listings: ["icons".into()].into(),
1002                present: ["src/b.ts".into()].into(),
1003                absent: ["config.local".into()].into(),
1004                written: ["icons/sprite.svg".into()].into(),
1005                recorded: None,
1006            }
1007        );
1008    }
1009
1010    #[test]
1011    fn values_change_with_contents_listings_and_existence() {
1012        let temp = tempfile::tempdir().unwrap();
1013        let root = temp.path().canonicalize().unwrap();
1014        std::fs::create_dir_all(root.join("icons")).unwrap();
1015        std::fs::write(root.join("a.txt"), "one").unwrap();
1016        std::fs::write(root.join("icons/a.svg"), "").unwrap();
1017        std::fs::write(root.join("nx.json"), "{}").unwrap();
1018        std::fs::write(
1019            root.join("project.json"),
1020            r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1021        )
1022        .unwrap();
1023        let workspace = qk_config::Workspace::load(&root).unwrap();
1024        let graph = qk_taskgraph::TaskGraph::build(
1025            &workspace,
1026            &[qk_taskgraph::Request::parse("app:build").unwrap()],
1027        )
1028        .unwrap();
1029        let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
1030        std::fs::write(root.join("checked.txt"), "one").unwrap();
1031        let set = PathSet {
1032            reads: ["a.txt".into()].into(),
1033            listings: ["icons".into()].into(),
1034            present: ["checked.txt".into()].into(),
1035            absent: ["optional.txt".into()].into(),
1036            written: ["icons/sprite.svg".into()].into(),
1037            recorded: None,
1038        };
1039        let outputs = Outputs::from_paths(&["icons/out".into()]).unwrap();
1040        let unlisted = Unlisted {
1041            own: vec![&outputs],
1042            others: None,
1043            ignore: &[],
1044        };
1045        let strong = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
1046        let first = strong();
1047        // Kept with how its paths were, the set has the same key, and names
1048        // what changes.
1049        let (kept_key, kept) =
1050            with_values("weak", set.clone(), &snapshot, &root, &unlisted).unwrap();
1051        assert_eq!(kept_key, first);
1052        assert_eq!(kept, set);
1053        assert_eq!(kept.digest().unwrap(), set.digest().unwrap());
1054        assert!(
1055            kept.changed(&snapshot, &root, &unlisted)
1056                .unwrap()
1057                .is_empty()
1058        );
1059        std::fs::write(root.join("a.txt"), "changed").unwrap();
1060        std::fs::write(root.join("optional.txt"), "").unwrap();
1061        assert_eq!(
1062            kept.changed(&snapshot, &root, &unlisted).unwrap(),
1063            [("a.txt", "read"), ("optional.txt", "looked for")]
1064        );
1065        assert!(set.changed(&snapshot, &root, &unlisted).unwrap().is_empty());
1066        std::fs::write(root.join("a.txt"), "one").unwrap();
1067        std::fs::remove_file(root.join("optional.txt")).unwrap();
1068        // What the task writes in a listed directory, or its outputs there,
1069        // and files it never read change nothing.
1070        std::fs::write(root.join("icons/sprite.svg"), "").unwrap();
1071        std::fs::create_dir(root.join("icons/out")).unwrap();
1072        std::fs::write(root.join("b.txt"), "unread").unwrap();
1073        assert_eq!(strong(), first);
1074        for change in [
1075            |root: &Path| std::fs::write(root.join("icons/b.svg"), "").unwrap(),
1076            |root: &Path| std::fs::write(root.join("optional.txt"), "").unwrap(),
1077            |root: &Path| std::fs::write(root.join("a.txt"), "two").unwrap(),
1078            // A file it only looked at, as by its timestamp.
1079            |root: &Path| std::fs::write(root.join("checked.txt"), "two").unwrap(),
1080        ] {
1081            let before = strong();
1082            change(&root);
1083            assert_ne!(strong(), before);
1084        }
1085    }
1086
1087    #[test]
1088    fn keeps_the_newest_sets_first() {
1089        let temp = tempfile::tempdir().unwrap();
1090        std::fs::create_dir(temp.path().join("tmp")).unwrap();
1091        let set = |read: &str| PathSet {
1092            reads: [read.to_owned()].into(),
1093            ..PathSet::default()
1094        };
1095        for index in 0..10 {
1096            save(temp.path(), "weak", set(&format!("{index}"))).unwrap();
1097        }
1098        save(temp.path(), "weak", set("5")).unwrap();
1099        let kept: Vec<String> = load(temp.path(), "weak")
1100            .into_iter()
1101            .map(|set| set.reads.into_iter().next().unwrap())
1102            .collect();
1103        assert_eq!(kept, ["5", "9", "8", "7", "6", "4", "3", "2"]);
1104        assert!(load(temp.path(), "other").is_empty());
1105    }
1106
1107    #[test]
1108    fn merges_the_sets_another_machine_recorded_behind_its_own() {
1109        let temp = tempfile::tempdir().unwrap();
1110        std::fs::create_dir(temp.path().join("tmp")).unwrap();
1111        let set = |read: &str| PathSet {
1112            reads: [read.to_owned()].into(),
1113            ..PathSet::default()
1114        };
1115        save(temp.path(), "weak", set("a")).unwrap();
1116        let record = |weak: &str, sets: Vec<PathSet>| {
1117            serde_json::to_vec(&Stored {
1118                version: 1,
1119                weak: weak.into(),
1120                sets,
1121            })
1122            .unwrap()
1123        };
1124        let fetched = record(
1125            "weak",
1126            vec![set("b"), set("a"), set("../escape"), set("/abs")],
1127        );
1128        assert_eq!(merge(temp.path(), "weak", &fetched).unwrap(), [set("b")]);
1129        assert_eq!(load(temp.path(), "weak"), [set("a"), set("b")]);
1130        assert!(merge(temp.path(), "weak", &fetched).unwrap().is_empty());
1131        assert!(
1132            merge(temp.path(), "weak", &record("other", vec![set("c")]))
1133                .unwrap()
1134                .is_empty()
1135        );
1136        let many = (0..10).map(|index| set(&format!("{index}"))).collect();
1137        assert_eq!(
1138            merge(temp.path(), "weak", &record("weak", many))
1139                .unwrap()
1140                .len(),
1141            6
1142        );
1143        assert_eq!(load(temp.path(), "weak").len(), KEPT);
1144    }
1145
1146    #[test]
1147    fn finds_what_changed_while_the_task_ran() {
1148        let temp = tempfile::tempdir().unwrap();
1149        for path in ["read.txt", "found.txt", "listed.txt", "before.txt"] {
1150            std::fs::write(temp.path().join(path), "before").unwrap();
1151        }
1152        for directory in ["listed", "written"] {
1153            std::fs::create_dir(temp.path().join(directory)).unwrap();
1154            std::fs::write(temp.path().join(directory).join("kept.txt"), "").unwrap();
1155        }
1156        std::fs::create_dir(temp.path().join("listed/sub")).unwrap();
1157        let set = PathSet {
1158            reads: ["read.txt".to_owned(), "gone.txt".to_owned()].into(),
1159            present: ["found.txt".to_owned(), ".".to_owned()].into(),
1160            listings: ["listed".to_owned(), "written".to_owned()].into(),
1161            written: ["written/new.txt".to_owned()].into(),
1162            ..PathSet::default()
1163        };
1164        std::thread::sleep(std::time::Duration::from_millis(20));
1165        let started = now(temp.path());
1166        let changed = |set: &PathSet| {
1167            set.changed_since(temp.path(), started, &NOTHING, &Before::default())
1168                .map(|changed| changed.path.to_owned())
1169        };
1170        assert_eq!(changed(&set), None);
1171        // What the task wrote, and content in a listed directory, keys nothing.
1172        std::fs::write(temp.path().join("written/new.txt"), "").unwrap();
1173        std::fs::write(temp.path().join("listed/kept.txt"), "after").unwrap();
1174        std::fs::write(temp.path().join("listed.txt"), "after").unwrap();
1175        // Content in a listed directory is not part of the listing.
1176        let unlisted = PathSet {
1177            listings: ["written".to_owned()].into(),
1178            ..set.clone()
1179        };
1180        assert_eq!(changed(&unlisted), None);
1181        std::fs::write(temp.path().join("found.txt"), "after").unwrap();
1182        assert_eq!(changed(&set), Some("found.txt".into()));
1183        let listings = PathSet {
1184            reads: [].into(),
1185            present: [].into(),
1186            ..set.clone()
1187        };
1188        // Another task writing inside an entry, or rewriting one in place,
1189        // leaves the listing as it was.
1190        std::fs::write(temp.path().join("listed/sub/made.txt"), "").unwrap();
1191        assert_eq!(changed(&listings), None);
1192        // An entry another process adds where the task wrote too.
1193        std::fs::write(temp.path().join("written/other.txt"), "").unwrap();
1194        if std::fs::metadata(temp.path().join("written/other.txt"))
1195            .and_then(|metadata| metadata.created())
1196            .is_ok()
1197        {
1198            assert_eq!(changed(&listings), Some("written".into()));
1199        }
1200        std::fs::remove_file(temp.path().join("written/other.txt")).unwrap();
1201        // A file that was there before, unchanged, moved in where the task
1202        // wrote too: a move updates its change time, which Unix keeps.
1203        #[cfg(unix)]
1204        {
1205            std::fs::rename(
1206                temp.path().join("before.txt"),
1207                temp.path().join("written/moved.txt"),
1208            )
1209            .unwrap();
1210            assert_eq!(changed(&listings), Some("written".into()));
1211            std::fs::rename(
1212                temp.path().join("written/moved.txt"),
1213                temp.path().join("before.txt"),
1214            )
1215            .unwrap();
1216        }
1217        // An entry added where the task wrote nothing.
1218        std::fs::write(temp.path().join("listed/new.txt"), "").unwrap();
1219        assert_eq!(changed(&listings), Some("listed".into()));
1220        // A removal, where the task wrote nothing.
1221        let removed = tempfile::tempdir().unwrap();
1222        std::fs::write(removed.path().join("gone.txt"), "").unwrap();
1223        std::thread::sleep(std::time::Duration::from_millis(20));
1224        // Taken elsewhere, as qk takes it in its cache: taking it changes the
1225        // directory it is taken in.
1226        let started = now(temp.path());
1227        let root = PathSet {
1228            listings: [".".to_owned()].into(),
1229            ..PathSet::default()
1230        };
1231        assert_eq!(
1232            root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1233                .map(|changed| changed.path),
1234            None
1235        );
1236        std::fs::remove_file(removed.path().join("gone.txt")).unwrap();
1237        assert_eq!(
1238            root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1239                .map(|changed| changed.path),
1240            Some(".")
1241        );
1242    }
1243
1244    #[test]
1245    fn listings_leave_out_other_targets_outputs_and_ignored_reads() {
1246        let temp = tempfile::tempdir().unwrap();
1247        let root = &temp.path().canonicalize().unwrap();
1248        std::fs::create_dir_all(root.join("app/src")).unwrap();
1249        std::fs::write(root.join("app/src/main.ts"), "").unwrap();
1250        std::fs::write(root.join("nx.json"), "{}").unwrap();
1251        std::fs::write(
1252            root.join("project.json"),
1253            r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1254        )
1255        .unwrap();
1256        let workspace = qk_config::Workspace::load(root).unwrap();
1257        let graph = qk_taskgraph::TaskGraph::build(
1258            &workspace,
1259            &[qk_taskgraph::Request::parse("app:build").unwrap()],
1260        )
1261        .unwrap();
1262        let set = PathSet {
1263            listings: ["app".to_owned()].into(),
1264            ..PathSet::default()
1265        };
1266        std::fs::write(root.join("app/kept.txt"), "").unwrap();
1267        std::fs::create_dir(root.join("app/out")).unwrap();
1268        let roots = [("app".to_owned(), "app".to_owned())].into();
1269        let outputs = WorkspaceOutputs::new(
1270            vec![
1271                (
1272                    ("app".into(), "tsc".into(), None),
1273                    Outputs::from_patterns(&["app/.tsc"]),
1274                ),
1275                (
1276                    ("app".into(), "codegen".into(), None),
1277                    Outputs::from_patterns(&["app/generated"]),
1278                ),
1279                // An output a glob declares, inside a directory the task lists.
1280                (
1281                    ("app".into(), "bundle".into(), None),
1282                    Outputs::from_patterns(&["app/out/*.js"]),
1283                ),
1284                // Another target declaring the dependency's output too.
1285                (
1286                    ("app".into(), "package".into(), None),
1287                    Outputs::from_patterns(&["app/generated"]),
1288                ),
1289                // An output inside a directory that only exists to hold it.
1290                (
1291                    ("app".into(), "deploy".into(), None),
1292                    Outputs::from_patterns(&["app/.cache/deploy"]),
1293                ),
1294            ],
1295            &roots,
1296        );
1297        let ignore = [Pattern::new("**/.fallow", false).unwrap()];
1298        let unlisted = Unlisted {
1299            own: Vec::new(),
1300            others: Some(Others {
1301                outputs: &outputs,
1302                // It depends on codegen, whose output is its input.
1303                upstream: [
1304                    ("app".into(), "build".into(), None),
1305                    ("app".into(), "codegen".into(), None),
1306                ]
1307                .into(),
1308            }),
1309            ignore: &ignore,
1310        };
1311        assert_eq!(
1312            unlisted.directories("app"),
1313            [
1314                "app".to_owned(),
1315                "app/.cache".to_owned(),
1316                "app/.cache/deploy".to_owned(),
1317                "app/.tsc".to_owned(),
1318                "app/generated".to_owned(),
1319                "app/out".to_owned()
1320            ]
1321            .into()
1322        );
1323        let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
1324        let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
1325        let before = key();
1326        std::thread::sleep(std::time::Duration::from_millis(20));
1327        let taken = Before::take(root, unlisted.directories("app"), &unlisted);
1328        let started = now(root);
1329        let changed = |taken: &Before| {
1330            set.changed_since(root, started, &unlisted, taken)
1331                .map(|changed| changed.path)
1332        };
1333        // Another target's output, the directory holding another's, and an
1334        // ignored path appear as it runs.
1335        std::fs::create_dir(root.join("app/.tsc")).unwrap();
1336        std::fs::create_dir_all(root.join("app/.cache/deploy")).unwrap();
1337        std::fs::create_dir(root.join("app/.fallow")).unwrap();
1338        assert_eq!(key(), before);
1339        assert_eq!(changed(&taken), None);
1340        // Another target's output listed itself has nothing to key, though
1341        // nothing listed it before.
1342        std::fs::write(root.join("app/.tsc/index.js"), "").unwrap();
1343        let output = PathSet {
1344            listings: ["app/.tsc".to_owned()].into(),
1345            ..PathSet::default()
1346        };
1347        assert_eq!(
1348            output.changed_since(root, started, &unlisted, &Before::default()),
1349            None
1350        );
1351        // The project's root is no folder holding its outputs.
1352        assert!(!unlisted.matches("app"));
1353        // A listed directory removed as it ran, with no listing from before.
1354        std::fs::create_dir(root.join("app/gone")).unwrap();
1355        let gone = PathSet {
1356            listings: ["app/gone".to_owned()].into(),
1357            ..PathSet::default()
1358        };
1359        std::fs::remove_dir(root.join("app/gone")).unwrap();
1360        assert_eq!(
1361            gone.changed_since(root, started, &unlisted, &Before::default())
1362                .map(|changed| changed.path),
1363            Some("app/gone")
1364        );
1365        // An output appearing in a listed directory a glob declares it in.
1366        std::fs::write(root.join("app/out/new.js"), "").unwrap();
1367        let out = PathSet {
1368            listings: ["app/out".to_owned()].into(),
1369            ..PathSet::default()
1370        };
1371        assert_eq!(out.changed_since(root, started, &unlisted, &taken), None);
1372        assert_eq!(
1373            out.changed_since(root, started, &unlisted, &Before::default())
1374                .map(|changed| changed.path),
1375            Some("app/out")
1376        );
1377        // A dependency's output is the task's input, and stays listed.
1378        assert!(!unlisted.matches("app/generated"));
1379        assert!(unlisted.matches("app/.tsc/index.js"));
1380        // Without the entries from before, the directory's time is all there
1381        // is, and it moved.
1382        assert_eq!(changed(&Before::default()), Some("app"));
1383        // A removal is still a change, though left-out entries are there.
1384        std::fs::remove_file(root.join("app/kept.txt")).unwrap();
1385        assert_ne!(key(), before);
1386        assert_eq!(
1387            set.changed_since(root, started, &unlisted, &taken)
1388                .and_then(|changed| changed.how),
1389            Some("kept.txt removed".to_owned())
1390        );
1391        std::fs::write(root.join("app/kept.txt"), "").unwrap();
1392        assert_eq!(changed(&taken), None);
1393        // So is anything else added, which the reason names.
1394        std::fs::write(root.join("app/README.md"), "").unwrap();
1395        assert_ne!(key(), before);
1396        assert_eq!(
1397            set.changed_since(root, started, &unlisted, &taken)
1398                .map(|changed| changed.to_string()),
1399            Some("app changed while it ran (README.md added)".to_owned())
1400        );
1401    }
1402}