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. What lies in `node_modules` without being
15//! installed there stays: see [`Produced`].
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::path::{Path, PathBuf};
19use std::time::SystemTime;
20
21use anyhow::Result;
22use qk_input_analysis::{Access, Operation};
23use serde::{Deserialize, Serialize};
24use serde_json::{Map, Value, json};
25
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: crate::ultracache::IgnoredReads,
121    /// qk's own directories inside the workspace, relative to it.
122    pub state: Vec<String>,
123    /// What lies in `node_modules` without being installed there.
124    pub produced: &'a Produced,
125}
126
127/// Paths in `node_modules` that the lockfile does not key, which a path set
128/// keeps: what the tasks a task depends on declare as their outputs, such as
129/// a client generated into `node_modules/.prisma`, and what lies below a
130/// directory in `node_modules` that an upstream output or an
131/// `includeIgnored` fileset reads from, with the directories between such a
132/// directory and the `node_modules` holding it, where a task looks for what
133/// was produced. Everything else there is installed.
134#[derive(Default)]
135pub struct Produced {
136    /// The declared outputs of the tasks the task depends on.
137    pub upstream: Vec<Outputs>,
138    /// Directories naming a `node_modules` directory, workspace-relative.
139    pub roots: Vec<String>,
140    /// The outputs of the tasks it depends on as they declare them, sorted.
141    pub declared: Vec<Vec<String>>,
142}
143
144impl Produced {
145    /// The tasks' upstream outputs and `includeIgnored` roots: of the roots,
146    /// only those in `node_modules` count, as a project root would hold
147    /// installed packages too.
148    pub fn new(upstream: Vec<Outputs>, include_ignored: Vec<String>) -> Self {
149        let mut roots: Vec<String> = upstream
150            .iter()
151            .flat_map(|outputs| outputs.anchors().map(str::to_owned))
152            .chain(include_ignored)
153            .filter(|root| in_node_modules(root))
154            .collect();
155        roots.sort_unstable();
156        roots.dedup();
157        Self {
158            upstream,
159            roots,
160            declared: Vec::new(),
161        }
162    }
163
164    /// With the upstream outputs as declared, which [`Self::weak`] keys.
165    pub fn declared(mut self, mut declared: Vec<Vec<String>>) -> Self {
166        declared.sort_unstable();
167        self.declared = declared;
168        self
169    }
170
171    /// Whether `path`, workspace-relative, is produced rather than installed,
172    /// or a directory in `node_modules` holding what is.
173    pub fn holds(&self, path: &str) -> bool {
174        self.roots.iter().any(|root| {
175            path == root || under(path, root) || (under(root, path) && inside_node_modules(path))
176        }) || self.upstream.iter().any(|outputs| outputs.matches(path))
177    }
178
179    /// Whether a listing of `path`, a directory in `node_modules`, is kept,
180    /// and whether, as an entry of one, it is listed: what holds what is
181    /// produced or may come to, as `node_modules` holds `.gen` for an
182    /// output in `node_modules/.gen/app`, or as a directory below the anchor
183    /// of an upstream output's glob holds what it may match. An installed
184    /// package elsewhere is neither listed nor its listing kept.
185    pub fn lists(&self, path: &str) -> bool {
186        self.holds(path)
187            || self.roots.iter().any(|root| under(root, path))
188            || self
189                .upstream
190                .iter()
191                .flat_map(Outputs::anchors)
192                .any(|anchor| under(path, anchor))
193    }
194
195    /// Whether an access to `path`, in `node_modules`, is read from a trace
196    /// at all, and so counts toward its limits: what [`Self::holds`], and
197    /// where only a listing is kept, as [`Self::lists`] says, only listings
198    /// and writes. An installed package read below where an upstream glob
199    /// starts is dropped from a path set anyway.
200    pub fn traced(&self, path: &str, operation: &Operation) -> bool {
201        let listed = matches!(
202            operation,
203            Operation::ListDirectory | Operation::ReadDirectory
204        ) || operation.writes();
205        self.holds(path) || listed && self.lists(path)
206    }
207
208    /// What decides which paths in `node_modules` a recording keeps, for
209    /// the weak key: a path set recorded under other declarations could
210    /// leave out what this task's would key. `None` where nothing does.
211    pub fn weak(&self) -> Option<Value> {
212        (!self.roots.is_empty() || !self.declared.is_empty())
213            .then(|| json!({"roots": self.roots, "upstream": self.declared}))
214    }
215}
216
217/// Whether `path` has a `node_modules` component.
218pub fn in_node_modules(path: &str) -> bool {
219    path.split('/').any(|part| part == "node_modules")
220}
221
222/// Whether `path` lies inside a `node_modules` directory, rather than only
223/// naming one.
224fn inside_node_modules(path: &str) -> bool {
225    path.rsplit_once('/')
226        .is_some_and(|(directory, _)| in_node_modules(directory))
227}
228
229impl Exclusions<'_> {
230    /// `path`, absolute, relative to the workspace: `None` outside it.
231    fn relative(&self, path: &str) -> Option<String> {
232        let path = qk_input_analysis::strace::normalize(Path::new(path));
233        let relative = self
234            .roots
235            .iter()
236            .find_map(|root| path.strip_prefix(root).ok())?;
237        let relative = relative.to_str()?.replace('\\', "/");
238        Some(if relative.is_empty() {
239            ".".into()
240        } else {
241            relative
242        })
243    }
244
245    fn excluded(&self, path: &str) -> bool {
246        self.excluded_unless(path, |path| self.produced.holds(path))
247    }
248
249    /// Whether a listing of `path` is left out: as [`Self::excluded`], but
250    /// a directory in `node_modules` that [`Produced::lists`] is kept.
251    fn excluded_listing(&self, path: &str) -> bool {
252        self.excluded_unless(path, |path| self.produced.lists(path))
253    }
254
255    fn excluded_unless(&self, path: &str, produced: impl Fn(&str) -> bool) -> bool {
256        path.split('/').next() == Some(".git")
257            || in_node_modules(path) && !produced(path)
258            || self.state.iter().any(|state| {
259                path == state
260                    || path
261                        .strip_prefix(state.as_str())
262                        .is_some_and(|rest| rest.starts_with('/'))
263            })
264            || self.outputs.iter().any(|outputs| outputs.matches(path))
265            || self.ignore.is_match(path)
266    }
267}
268
269impl PathSet {
270    /// The path set of a task's recorded accesses, in the order it made
271    /// them. `root` is the workspace root, to tell a directory opened for
272    /// listing from a file opened for reading.
273    pub fn from_accesses(accesses: &[Access], exclusions: &Exclusions, root: &Path) -> Self {
274        let mut set = Self::default();
275        let mut written = BTreeSet::new();
276        for access in accesses {
277            let Some(path) = exclusions.relative(&access.path) else {
278                continue;
279            };
280            if access.operation.writes() {
281                written.insert(path);
282                continue;
283            }
284            let listing = matches!(
285                access.operation,
286                Operation::ListDirectory | Operation::ReadDirectory
287            );
288            // What it wrote itself is no input.
289            if written.contains(&path)
290                || if listing {
291                    exclusions.excluded_listing(&path)
292                } else {
293                    exclusions.excluded(&path)
294                }
295            {
296                continue;
297            }
298            let found = matches!(access.result.as_str(), "success" | "unknown");
299            let missing = matches!(access.result.as_str(), "missing" | "ENOTDIR");
300            match access.operation {
301                Operation::OpenRead | Operation::ReadData | Operation::Execute if found => {
302                    // A directory is opened to be listed, which its listing
303                    // records.
304                    if !root.join(&path).is_dir() {
305                        set.reads.insert(path);
306                    }
307                }
308                Operation::ListDirectory | Operation::ReadDirectory if found => {
309                    set.listings.insert(path);
310                }
311                Operation::Metadata | Operation::ReadLink if found => {
312                    set.present.insert(path);
313                }
314                _ if missing => {
315                    set.absent.insert(path);
316                }
317                _ => {}
318            }
319        }
320        set.present.retain(|path| !set.reads.contains(path));
321        set.absent
322            .retain(|path| !set.reads.contains(path) && !set.present.contains(path));
323        set.written = written
324            .into_iter()
325            .filter(|path| set.listings.contains(parent(path)))
326            .collect();
327        set
328    }
329
330    /// Whether every path it names lies within the workspace: relative, and
331    /// never through `..`.
332    pub fn is_relative(&self) -> bool {
333        [
334            &self.reads,
335            &self.listings,
336            &self.present,
337            &self.absent,
338            &self.written,
339        ]
340        .into_iter()
341        .flatten()
342        .all(|path| {
343            !path.is_empty()
344                && Path::new(path).components().all(|component| {
345                    matches!(
346                        component,
347                        std::path::Component::Normal(_) | std::path::Component::CurDir
348                    )
349                })
350        })
351    }
352
353    pub fn is_empty(&self) -> bool {
354        self.reads.is_empty()
355            && self.listings.is_empty()
356            && self.present.is_empty()
357            && self.absent.is_empty()
358    }
359
360    /// A path the set keys that changed after `started`, when the task ran:
361    /// keyed by how it is now, a recording would key the task by what was
362    /// left there rather than by what the task saw.
363    ///
364    /// A file read or found changed if it was written. A listing, which keys
365    /// its entries' names and kinds, changed if an entry was added, removed
366    /// or renamed. In a directory `before` holds, that is told exactly, by
367    /// comparing its entries with those before the task ran, leaving out
368    /// what the task wrote and what listings leave out. Elsewhere it moves
369    /// the directory's own modification time; what is written inside an
370    /// entry does not change it, though it moves the entry's change time.
371    /// Where the task wrote into the directory, or its own outputs are
372    /// there, its time moves with the task's own writes, so there an entry
373    /// other than those changed it: a file whose change time moved, by being
374    /// added, moved in or rewritten, or a subdirectory created after
375    /// `started`, by its birth time, which a file system without one cannot
376    /// tell. A removal there, or a subdirectory moved in, cannot be told
377    /// from the task's own writes.
378    pub(crate) fn changed_since(
379        &self,
380        root: &Path,
381        started: SystemTime,
382        unlisted: &Unlisted,
383        before: &Before,
384    ) -> Option<Changed<'_>> {
385        let file = self.reads.iter().chain(&self.present).find(|path| {
386            std::fs::symlink_metadata(root.join(path))
387                .is_ok_and(|metadata| !metadata.is_dir() && changed(&metadata) >= started)
388        });
389        if let Some(path) = file {
390            return Some(Changed { path, how: None });
391        }
392        self.listings.iter().find_map(|directory| {
393            self.listing_changed(root, directory, started, unlisted, before)
394                .map(|how| Changed {
395                    path: directory,
396                    how,
397                })
398        })
399    }
400
401    /// Whether `directory`'s listing changed while the task ran, and if so,
402    /// what changed in it, where that can be told.
403    fn listing_changed(
404        &self,
405        root: &Path,
406        directory: &str,
407        started: SystemTime,
408        unlisted: &Unlisted,
409        before: &Before,
410    ) -> Option<Option<String>> {
411        // A directory listings leave out, such as another target's output,
412        // has nothing in it to key. One only holding the task's outputs
413        // keys what else it holds.
414        if directory != "." && unlisted.matches(directory) && !unlisted.own_folder(directory) {
415            return None;
416        }
417        let absolute = if directory == "." {
418            root.to_path_buf()
419        } else {
420            root.join(directory)
421        };
422        let skipped = |path: &str| self.written.contains(path) || unlisted.matches(path);
423        if let Some(earlier) = before.0.get(directory) {
424            let earlier: BTreeSet<_> = earlier
425                .iter()
426                .filter(|(name, _)| !self.written.contains(&child(directory, name)))
427                .cloned()
428                .collect();
429            let Some(now) = entries(root, directory, skipped) else {
430                return Some(Some("it could no longer be read".to_owned()));
431            };
432            if let Some((name, _)) = now.difference(&earlier).next() {
433                return Some(Some(format!("{name} added")));
434            }
435            if let Some((name, _)) = earlier.difference(&now).next() {
436                return Some(Some(format!("{name} removed")));
437            }
438            return None;
439        }
440        let Ok(entries) = std::fs::read_dir(&absolute) else {
441            return Some(Some("it was removed or could no longer be read".to_owned()));
442        };
443        // Each entry, whether listings leave it out, and whether the task
444        // wrote it or it is among the task's own outputs.
445        let entries: Vec<(std::fs::DirEntry, bool, bool)> = entries
446            .flatten()
447            .map(|entry| {
448                let path = child(directory, &entry.file_name().to_string_lossy());
449                let own = self.written.contains(&path) || unlisted.own(&path);
450                (entry, skipped(&path), own)
451            })
452            .collect();
453        // A file's change time moves when it is moved in, and with a rewrite,
454        // which counts too. A subdirectory's moves with what is written
455        // inside it, so it counts only once it was created.
456        let new = |entry: &std::fs::DirEntry| {
457            std::fs::symlink_metadata(entry.path()).is_ok_and(|metadata| {
458                if metadata.is_dir() {
459                    metadata.created().is_ok_and(|born| born >= started)
460                } else {
461                    changed(&metadata) >= started
462                }
463            })
464        };
465        let unskipped = || entries.iter().filter(|(_, skipped, _)| !skipped);
466        let name = |entry: &std::fs::DirEntry| entry.file_name().to_string_lossy().into_owned();
467        let own = self.written.iter().any(|path| parent(path) == directory)
468            || entries.iter().any(|(_, _, own)| *own);
469        if !own {
470            let moved = std::fs::symlink_metadata(&absolute)
471                .and_then(|metadata| metadata.modified())
472                .is_ok_and(|modified| modified >= started);
473            return moved.then(|| {
474                Some(unskipped().find(|(entry, _, _)| new(entry)).map_or_else(
475                    || "an entry removed or renamed".to_owned(),
476                    |(entry, _, _)| format!("{} added or changed", name(entry)),
477                ))
478            });
479        }
480        unskipped()
481            .find(|(entry, _, _)| new(entry))
482            .map(|(entry, _, _)| Some(format!("{} added or changed", name(entry))))
483    }
484
485    /// The set's paths, as its key holds them.
486    pub fn digest(&self) -> Result<String> {
487        let paths = Self {
488            recorded: None,
489            ..self.clone()
490        };
491        key(&serde_json::to_value(paths)?)
492    }
493
494    /// The paths that are not as they were when it was recorded, each with
495    /// what the task did to it: `read`, `listed`, `found` or `looked for`.
496    /// None when it kept no record of them.
497    pub(crate) fn changed(
498        &self,
499        snapshot: &Snapshot,
500        root: &Path,
501        unlisted: &Unlisted,
502    ) -> Result<Vec<(&str, &'static str)>> {
503        let Some(recorded) = &self.recorded else {
504            return Ok(Vec::new());
505        };
506        let now = Values::of(&self.values(snapshot, root, unlisted)?)?;
507        let differs = |then: &BTreeMap<String, String>,
508                       now: &BTreeMap<String, String>,
509                       path: &str| { then.get(path) != now.get(path) };
510        let mut changed = Vec::new();
511        for path in &self.reads {
512            if differs(&recorded.reads, &now.reads, path) {
513                changed.push((path.as_str(), "read"));
514            }
515        }
516        for path in &self.listings {
517            if differs(&recorded.listings, &now.listings, path) {
518                changed.push((path.as_str(), "listed"));
519            }
520        }
521        for (paths, how) in [(&self.present, "found"), (&self.absent, "looked for")] {
522            for path in paths {
523                if differs(&recorded.existence, &now.existence, path) {
524                    changed.push((path.as_str(), how));
525                }
526            }
527        }
528        Ok(changed)
529    }
530
531    /// How the set's paths are now: each read file's content, each listed
532    /// directory's entries and each looked-up path's kind. `outputs` are the
533    /// task's own, left out of listings.
534    /// Why a file it names cannot be keyed by its content, as one read
535    /// through a linked directory, or a link leading out of the workspace,
536    /// cannot: [`Self::values`] would key it the same whatever it held.
537    pub(crate) fn unkeyable(&self, snapshot: &Snapshot, root: &Path) -> Option<String> {
538        self.reads
539            .iter()
540            .chain(&self.present)
541            .chain(&self.absent)
542            .find_map(|path| match kind(&root.join(path)) {
543                "file" | "symlink" => snapshot
544                    .recorded_value(root, path)
545                    .err()
546                    .map(|error| format!("{path} cannot be keyed by its content ({error:#})")),
547                _ => None,
548            })
549    }
550
551    pub(crate) fn values(
552        &self,
553        snapshot: &Snapshot,
554        root: &Path,
555        unlisted: &Unlisted,
556    ) -> Result<Value> {
557        // A file by its content, anything else by what it is.
558        let value = |path: &str| match kind(&root.join(path)) {
559            "file" | "symlink" => snapshot
560                .recorded_value(root, path)
561                .unwrap_or_else(|_| json!({"unreadable": true})),
562            other => json!(other),
563        };
564        let reads: Map<String, Value> = self
565            .reads
566            .iter()
567            .map(|path| (path.clone(), value(path)))
568            .collect();
569        // A directory that only holds the task's outputs is there once they
570        // are, or once a sandbox made it for them: made or not, it keys the
571        // same, and lists only what else it holds.
572        let absent = json!("absent");
573        let mut listings = Map::new();
574        for directory in &self.listings {
575            let listing = match self.listing(root, directory, unlisted)? {
576                listing if listing == absent && unlisted.own_folder(directory) => json!({}),
577                listing => listing,
578            };
579            listings.insert(directory.clone(), listing);
580        }
581        let existence: Map<String, Value> = self
582            .present
583            .iter()
584            .chain(&self.absent)
585            .map(|path| {
586                let value = match value(path) {
587                    value
588                        if unlisted.own_folder(path)
589                            && (value == absent || value == json!("directory")) =>
590                    {
591                        json!("folder")
592                    }
593                    value => value,
594                };
595                (path.clone(), value)
596            })
597            .collect();
598        Ok(json!({"reads": reads, "listings": listings, "existence": existence}))
599    }
600
601    /// A directory's entries by name and kind, without what the task writes.
602    fn listing(&self, root: &Path, directory: &str, unlisted: &Unlisted) -> Result<Value> {
603        let absolute = if directory == "." {
604            root.to_path_buf()
605        } else {
606            root.join(directory)
607        };
608        let entries = match std::fs::read_dir(&absolute) {
609            Ok(entries) => entries,
610            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
611                return Ok(json!("absent"));
612            }
613            Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
614                return Ok(json!(kind(&absolute)));
615            }
616            Err(error) => return Err(error.into()),
617        };
618        let mut listed = Map::new();
619        for entry in entries {
620            let entry = entry?;
621            let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
622                continue;
623            };
624            let path = if directory == "." {
625                name.clone()
626            } else {
627                format!("{directory}/{name}")
628            };
629            if self.written.contains(&path) || unlisted.matches(&path) {
630                continue;
631            }
632            listed.insert(name, json!(kind(&entry.path())));
633        }
634        Ok(Value::Object(listed))
635    }
636}
637
638/// The directory a workspace-relative path is in, `.` for the root.
639fn parent(path: &str) -> &str {
640    path.rsplit_once('/').map_or(".", |(parent, _)| parent)
641}
642
643/// Whether `path` lies below `directory`, both workspace-relative.
644fn under(path: &str, directory: &str) -> bool {
645    path.strip_prefix(directory)
646        .is_some_and(|rest| rest.starts_with('/'))
647}
648
649/// The path of `name` in `directory`, `.` being the workspace root.
650fn child(directory: &str, name: &str) -> String {
651    if directory == "." {
652        name.to_owned()
653    } else {
654        format!("{directory}/{name}")
655    }
656}
657
658/// The names and kinds of `directory`'s entries but those `skipped`; none
659/// when it, or any of its entries or their kinds, cannot be read.
660fn entries(
661    root: &Path,
662    directory: &str,
663    skipped: impl Fn(&str) -> bool,
664) -> Option<BTreeSet<(String, &'static str)>> {
665    let absolute = if directory == "." {
666        root.to_path_buf()
667    } else {
668        root.join(directory)
669    };
670    let mut found = BTreeSet::new();
671    for entry in std::fs::read_dir(&absolute).ok()? {
672        let entry = entry.ok()?;
673        let name = entry.file_name().to_string_lossy().into_owned();
674        if !skipped(&child(directory, &name)) {
675            found.insert((name, file_kind(entry.file_type().ok()?)));
676        }
677    }
678    Some(found)
679}
680
681/// What is at `path`, without following a link.
682fn kind(path: &Path) -> &'static str {
683    std::fs::symlink_metadata(path).map_or("absent", |metadata| file_kind(metadata.file_type()))
684}
685
686fn file_kind(file_type: std::fs::FileType) -> &'static str {
687    if file_type.is_symlink() {
688        "symlink"
689    } else if file_type.is_dir() {
690        "directory"
691    } else if file_type.is_file() {
692        "file"
693    } else {
694        "other"
695    }
696}
697
698/// A project's root as output paths name it: without a leading `./` or a
699/// trailing `/`, and empty for the workspace's root, which `""` and `"."`
700/// both name.
701fn relative_root(root: &str) -> &str {
702    let mut root = root.trim_end_matches('/');
703    while let Some(rest) = root.strip_prefix("./") {
704        root = rest;
705    }
706    if root == "." { "" } else { root }
707}
708
709/// A path whose recording changed while the task ran, and what changed in
710/// it, where that can be told.
711#[derive(Debug, PartialEq, Eq)]
712pub(crate) struct Changed<'a> {
713    pub path: &'a str,
714    pub how: Option<String>,
715}
716
717impl std::fmt::Display for Changed<'_> {
718    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719        match &self.how {
720            Some(how) => write!(f, "{} changed while it ran ({how})", self.path),
721            None => write!(f, "{} changed while it ran", self.path),
722        }
723    }
724}
725
726/// The declared outputs of every target in the workspace, and of each of
727/// their configurations, which can move an output with its options, by the
728/// workspace's configuration rather than what a run schedules. Indexed by
729/// their anchors, so a path is matched only against the outputs that can
730/// hold it.
731#[derive(Default)]
732pub(crate) struct WorkspaceOutputs {
733    outputs: Vec<(TargetOf, Outputs)>,
734    by_anchor: BTreeMap<String, Vec<usize>>,
735    /// The directories between each output's project root and its anchor,
736    /// such as `.cache` for `.cache/build`, which exist to hold it.
737    folders: Vec<Vec<String>>,
738    by_folder: BTreeMap<String, Vec<usize>>,
739    /// Each project's root, and each directory holding one.
740    projects: BTreeSet<String>,
741}
742
743impl WorkspaceOutputs {
744    /// Whether `path`, workspace-relative, is a declared output of any
745    /// target.
746    pub(crate) fn matches(&self, path: &str) -> bool {
747        self.outputs
748            .iter()
749            .any(|(_, outputs)| outputs.matches(path))
750    }
751}
752
753impl WorkspaceOutputs {
754    /// `roots` holds each project's root.
755    pub(crate) fn new(outputs: Vec<(TargetOf, Outputs)>, roots: &BTreeMap<String, String>) -> Self {
756        let mut by_anchor: BTreeMap<String, Vec<usize>> = BTreeMap::new();
757        let mut by_folder: BTreeMap<String, Vec<usize>> = BTreeMap::new();
758        let mut folders = Vec::new();
759        // No project's root, nor a directory holding one, is a folder an
760        // output owns.
761        let projects: BTreeSet<&str> = roots
762            .values()
763            .map(|root| relative_root(root))
764            .filter(|root| !root.is_empty())
765            .flat_map(|root| {
766                std::iter::once(root).chain(root.match_indices('/').map(|(at, _)| &root[..at]))
767            })
768            .collect();
769        for (index, ((project, _, _), declared)) in outputs.iter().enumerate() {
770            let root = roots.get(project).map_or("", |root| relative_root(root));
771            let mut held = Vec::new();
772            for anchor in declared.anchors() {
773                by_anchor.entry(anchor.to_owned()).or_default().push(index);
774                let rest = if root.is_empty() {
775                    Some(anchor)
776                } else {
777                    anchor
778                        .strip_prefix(root)
779                        .and_then(|rest| rest.strip_prefix('/'))
780                };
781                let Some(rest) = rest else {
782                    continue;
783                };
784                // Each directory below the root that holds the anchor.
785                for (at, _) in rest.match_indices('/') {
786                    let folder = if root.is_empty() {
787                        rest[..at].to_owned()
788                    } else {
789                        format!("{root}/{}", &rest[..at])
790                    };
791                    if projects.contains(folder.as_str()) {
792                        continue;
793                    }
794                    by_folder.entry(folder.clone()).or_default().push(index);
795                    held.push(folder);
796                }
797            }
798            folders.push(held);
799        }
800        let projects = projects.into_iter().map(str::to_owned).collect();
801        Self {
802            outputs,
803            by_anchor,
804            folders,
805            by_folder,
806            projects,
807        }
808    }
809
810    /// The directories that hold nothing a task keys but `own`, its outputs:
811    /// each directory between the workspace root and one of their anchors,
812    /// such as `dist` and `dist/apps` for `dist/apps/web`, but no project's
813    /// root, nor a directory holding one, nor one holding or held by an
814    /// output of `upstream`, the targets it depends on.
815    fn own_folders(&self, own: &[&Outputs], upstream: &BTreeSet<TargetOf>) -> BTreeSet<String> {
816        let inputs: Vec<&str> = self
817            .outputs
818            .iter()
819            .filter(|(target, _)| upstream.contains(target))
820            .flat_map(|(_, outputs)| outputs.anchors())
821            .collect();
822        let related = |a: &str, b: &str| a == b || under(a, b) || under(b, a);
823        own.iter()
824            .flat_map(|outputs| outputs.anchors())
825            .flat_map(|anchor| anchor.match_indices('/').map(move |(at, _)| &anchor[..at]))
826            .filter(|folder| !self.projects.contains(*folder))
827            .filter(|folder| !inputs.iter().any(|input| related(input, folder)))
828            .map(str::to_owned)
829            .collect()
830    }
831
832    /// The outputs whose anchor is `path` or a directory holding it.
833    fn holding<'a>(&'a self, path: &'a str) -> impl Iterator<Item = &'a (TargetOf, Outputs)> {
834        std::iter::once(path)
835            .chain(path.match_indices('/').map(|(at, _)| &path[..at]))
836            .filter_map(|anchor| self.by_anchor.get(anchor))
837            .flatten()
838            .map(|&index| &self.outputs[index])
839    }
840}
841
842/// A target by project, target and configuration, whose options can move
843/// its outputs.
844pub(crate) type TargetOf = (String, String, Option<String>);
845
846/// The outputs a recording's listings leave out: those of every target in
847/// the workspace, the task's own included, except the targets the task
848/// depends on, whose outputs are its inputs.
849pub(crate) struct Others<'a> {
850    pub outputs: &'a WorkspaceOutputs,
851    /// The targets the task depends on.
852    pub upstream: BTreeSet<TargetOf>,
853    /// The directories that only hold the task's own outputs, which come
854    /// and go with them as its project's folders do: see
855    /// [`WorkspaceOutputs::own_folders`].
856    pub own_folders: BTreeSet<String>,
857}
858
859impl<'a> Others<'a> {
860    /// `own` holds the task's outputs and warm paths.
861    pub(crate) fn new(
862        outputs: &'a WorkspaceOutputs,
863        upstream: BTreeSet<TargetOf>,
864        own: &[&Outputs],
865    ) -> Self {
866        let own_folders = outputs.own_folders(own, &upstream);
867        Self {
868            outputs,
869            upstream,
870            own_folders,
871        }
872    }
873
874    /// Whether `path` is one of the task's own folders.
875    pub(crate) fn own_folder(&self, path: &str) -> bool {
876        self.own_folders.contains(path)
877    }
878}
879
880impl Others<'_> {
881    /// Whether `path` is another target's output, and none of the
882    /// upstream's: an output both declare is the task's input.
883    ///
884    /// A directory that only holds another target's outputs, between its
885    /// project's root and the outputs, counts as one, as `.cache` does for
886    /// `.cache/build`: it comes and goes with them.
887    pub(crate) fn matches(&self, path: &str) -> bool {
888        let mut other = false;
889        let holding = self
890            .outputs
891            .holding(path)
892            .filter(|(_, outputs)| outputs.matches(path))
893            .map(|(target, _)| target);
894        let folders = self
895            .outputs
896            .by_folder
897            .get(path)
898            .into_iter()
899            .flatten()
900            .map(|&index| &self.outputs.outputs[index].0);
901        for target in holding.chain(folders) {
902            if self.upstream.contains(target) {
903                return false;
904            }
905            other = true;
906        }
907        other
908    }
909}
910
911/// What a recording's listings leave out of a directory's entries: what is
912/// there after a task runs whether or not it read anything, rather than what
913/// it read. The task's own outputs, the outputs of other targets, such as a
914/// build's output beside a lint's sources or another project's coverage, but
915/// not those of the targets it depends on, what its `ignoreReads` names, and
916/// in `node_modules`, what is installed rather than produced.
917/// Only listings leave them out: a file among them that the task read is
918/// still keyed by its content.
919pub(crate) struct Unlisted<'a> {
920    pub own: Vec<&'a Outputs>,
921    pub others: Option<Others<'a>>,
922    pub ignore: &'a crate::ultracache::IgnoredReads,
923    /// What is produced in `node_modules`, which alone is listed there.
924    pub produced: Option<&'a Produced>,
925}
926
927impl Unlisted<'_> {
928    fn matches(&self, path: &str) -> bool {
929        self.own(path)
930            || self
931                .others
932                .as_ref()
933                .is_some_and(|others| others.matches(path))
934            || self.ignore.is_match(path)
935            || inside_node_modules(path)
936                && self.produced.is_some_and(|produced| !produced.lists(path))
937    }
938
939    /// Whether `path` is the task's own output, or a directory that only
940    /// holds its outputs.
941    fn own(&self, path: &str) -> bool {
942        self.own.iter().any(|outputs| outputs.matches(path)) || self.own_folder(path)
943    }
944
945    /// Whether `path` is a directory that only holds the task's outputs.
946    fn own_folder(&self, path: &str) -> bool {
947        self.others
948            .as_ref()
949            .is_some_and(|others| others.own_folder(path))
950    }
951
952    /// Where other targets' outputs and ignored paths come and go while the
953    /// task runs: the directories holding those outputs, up to their
954    /// project's root, and their anchors, where an output a glob declares
955    /// comes and goes, and the root of the task's project, where a tool's
956    /// `ignoreReads` state usually sits.
957    pub(crate) fn directories(&self, project_root: &str) -> BTreeSet<String> {
958        let project_root = match relative_root(project_root) {
959            "" => ".",
960            root => root,
961        };
962        let mut directories = BTreeSet::from([project_root.to_owned()]);
963        if let Some(others) = &self.others {
964            let all = &others.outputs;
965            for (index, (target, outputs)) in all.outputs.iter().enumerate() {
966                if others.upstream.contains(target) {
967                    continue;
968                }
969                for anchor in outputs.anchors() {
970                    directories.insert(anchor.to_owned());
971                    directories.insert(parent(anchor).to_owned());
972                }
973                // Each folder, and the directory holding it, which can be
974                // another project's root.
975                for folder in &all.folders[index] {
976                    directories.insert(folder.clone());
977                    directories.insert(parent(folder).to_owned());
978                }
979            }
980            // Its own folders, whose other entries its listings keep.
981            directories.extend(others.own_folders.iter().cloned());
982        }
983        directories
984    }
985}
986
987/// The entries of directories before a task ran, but those its listings
988/// leave out, so a listing whose directory gained or lost something else as
989/// it ran is told apart from one where only those came and went.
990#[derive(Default)]
991pub(crate) struct Before(BTreeMap<String, BTreeSet<(String, &'static str)>>);
992
993impl Before {
994    pub(crate) fn take(root: &Path, directories: BTreeSet<String>, unlisted: &Unlisted) -> Self {
995        Self(
996            directories
997                .into_iter()
998                .filter_map(|directory| {
999                    let entries = entries(root, &directory, |path| unlisted.matches(path))?;
1000                    Some((directory, entries))
1001                })
1002                .collect(),
1003        )
1004    }
1005}
1006
1007/// The path sets kept for weak keys, in `pathsets/` beside the entries.
1008#[derive(Serialize, Deserialize)]
1009struct Stored {
1010    version: u32,
1011    weak: String,
1012    sets: Vec<PathSet>,
1013}
1014
1015fn location(cache: &Path, weak: &str) -> PathBuf {
1016    cache.join("pathsets").join(format!("{weak}.json"))
1017}
1018
1019/// The path sets kept for `weak`, newest first; none when there are none or
1020/// they cannot be read.
1021pub(crate) fn load(cache: &Path, weak: &str) -> Vec<PathSet> {
1022    std::fs::read(location(cache, weak))
1023        .ok()
1024        .and_then(|bytes| serde_json::from_slice::<Stored>(&bytes).ok())
1025        .filter(|stored| stored.version == 1 && stored.weak == weak)
1026        .map(|stored| stored.sets)
1027        .unwrap_or_default()
1028}
1029
1030/// Marks `weak`'s path sets as used, so eviction keeps them as it keeps the
1031/// entries they lead to.
1032pub(crate) fn touch(cache: &Path, weak: &str) {
1033    if let Ok(file) = std::fs::File::options()
1034        .append(true)
1035        .open(location(cache, weak))
1036    {
1037        let _ = file.set_modified(std::time::SystemTime::now());
1038    }
1039}
1040
1041/// Keeps `set` as the newest for `weak`, and at most [`KEPT`] in all.
1042pub(crate) fn save(cache: &Path, weak: &str, set: PathSet) -> Result<()> {
1043    let mut sets = load(cache, weak);
1044    sets.retain(|kept| kept != &set);
1045    sets.insert(0, set);
1046    keep(cache, weak, sets)
1047}
1048
1049/// The record of `weak`'s path sets as the local cache keeps them, for the
1050/// remote store.
1051pub(crate) fn record(cache: &Path, weak: &str) -> Option<Vec<u8>> {
1052    std::fs::read(location(cache, weak)).ok()
1053}
1054
1055/// `record`, a record of path sets, in at most `largest` bytes: with its
1056/// oldest sets left out until it fits, or `None` when its newest set alone
1057/// does not.
1058pub(crate) fn within(record: &[u8], largest: usize) -> Result<Option<Vec<u8>>> {
1059    if record.len() <= largest {
1060        return Ok(Some(record.to_vec()));
1061    }
1062    let mut stored: Stored = serde_json::from_slice(record)?;
1063    while stored.sets.len() > 1 {
1064        stored.sets.pop();
1065        let shorter = serde_json::to_vec(&stored)?;
1066        if shorter.len() <= largest {
1067            return Ok(Some(shorter));
1068        }
1069    }
1070    Ok(None)
1071}
1072
1073/// Adds the path sets in a record the remote store held for `weak` behind
1074/// those kept locally, returning the ones that are new here. A record for
1075/// another weak key or another version adds nothing, and neither does a set
1076/// naming a path outside the workspace.
1077pub(crate) fn merge(cache: &Path, weak: &str, record: &[u8]) -> Result<Vec<PathSet>> {
1078    let Some(fetched) = serde_json::from_slice::<Stored>(record)
1079        .ok()
1080        .filter(|stored| stored.version == 1 && stored.weak == weak)
1081    else {
1082        return Ok(Vec::new());
1083    };
1084    let mut sets = load(cache, weak);
1085    let mut added = Vec::new();
1086    for set in fetched.sets {
1087        if sets.len() + added.len() < KEPT
1088            && set.is_relative()
1089            && !set.is_empty()
1090            && !sets.contains(&set)
1091            && !added.contains(&set)
1092        {
1093            added.push(set);
1094        }
1095    }
1096    if !added.is_empty() {
1097        sets.extend(added.iter().cloned());
1098        keep(cache, weak, sets)?;
1099    }
1100    Ok(added)
1101}
1102
1103fn keep(cache: &Path, weak: &str, mut sets: Vec<PathSet>) -> Result<()> {
1104    sets.truncate(KEPT);
1105    std::fs::create_dir_all(cache.join("pathsets"))?;
1106    crate::record::publish(
1107        cache,
1108        &location(cache, weak),
1109        &Stored {
1110            version: 1,
1111            weak: weak.to_owned(),
1112            sets,
1113        },
1114    )
1115}
1116
1117/// The key of a result for `set` under `weak`, from how its paths are now.
1118pub(crate) fn strong_key(
1119    weak: &str,
1120    set: &PathSet,
1121    snapshot: &Snapshot,
1122    root: &Path,
1123    unlisted: &Unlisted,
1124) -> Result<String> {
1125    keyed(weak, set, &set.values(snapshot, root, unlisted)?)
1126}
1127
1128fn keyed(weak: &str, set: &PathSet, values: &Value) -> Result<String> {
1129    key(&json!({
1130        "schema": "qk-recorded-v1",
1131        "weak": weak,
1132        "set": set.digest()?,
1133        "values": values,
1134    }))
1135}
1136
1137/// `set`'s key under `weak`, from how its paths are now, and `set` with
1138/// how they are kept for telling later which changed.
1139pub(crate) fn with_values(
1140    weak: &str,
1141    mut set: PathSet,
1142    snapshot: &Snapshot,
1143    root: &Path,
1144    unlisted: &Unlisted,
1145) -> Result<(String, PathSet)> {
1146    let values = set.values(snapshot, root, unlisted)?;
1147    let strong = keyed(weak, &set, &values)?;
1148    set.recorded = Some(Values::of(&values)?);
1149    Ok((strong, set))
1150}
1151
1152/// Now, as the file system in `directory` stamps a file it writes. The kernel
1153/// stamps files from a clock coarser than the system's, so a file written
1154/// just after a [`SystemTime::now`] can bear an earlier time; this one is
1155/// never later than the stamp of a file written after it.
1156pub(crate) fn now(directory: &Path) -> SystemTime {
1157    tempfile::NamedTempFile::new_in(directory)
1158        .and_then(|file| file.as_file().metadata())
1159        .map(|metadata| changed(&metadata))
1160        .unwrap_or_else(|_| SystemTime::now())
1161}
1162
1163/// When a path's content or metadata last changed: its ctime where there is
1164/// one, which a tool resetting the modification time does not hide.
1165fn changed(metadata: &std::fs::Metadata) -> SystemTime {
1166    let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
1167    #[cfg(unix)]
1168    {
1169        use std::os::unix::fs::MetadataExt;
1170        let ctime = u64::try_from(metadata.ctime()).ok().map(|seconds| {
1171            SystemTime::UNIX_EPOCH
1172                + std::time::Duration::new(
1173                    seconds,
1174                    u32::try_from(metadata.ctime_nsec()).unwrap_or(0),
1175                )
1176        });
1177        if let Some(ctime) = ctime {
1178            return modified.max(ctime);
1179        }
1180    }
1181    modified
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186    use super::*;
1187    use crate::ultracache::IgnoredReads;
1188
1189    static IGNORE_NOTHING: IgnoredReads = IgnoredReads::none();
1190
1191    /// Nothing left out of listings.
1192    const NOTHING: Unlisted<'static> = Unlisted {
1193        own: Vec::new(),
1194        others: None,
1195        ignore: &IGNORE_NOTHING,
1196        produced: None,
1197    };
1198    use qk_input_analysis::Category;
1199
1200    fn access(path: &str, operation: Operation, result: &str) -> Access {
1201        Access {
1202            process: 1,
1203            path: path.into(),
1204            resolved_path: None,
1205            keyed_path: None,
1206            package: None,
1207            operation,
1208            result: result.into(),
1209            category: Category::Uncovered,
1210        }
1211    }
1212
1213    #[test]
1214    fn keeps_what_the_task_read_listed_and_looked_up() {
1215        let temp = tempfile::tempdir().unwrap();
1216        let root = temp.path().canonicalize().unwrap();
1217        for directory in ["src", "icons", "dist"] {
1218            std::fs::create_dir(root.join(directory)).unwrap();
1219        }
1220        let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
1221        let outputs = Outputs::from_paths(&["dist".into()]).unwrap();
1222        let exclusions = Exclusions {
1223            roots: [&root, &root],
1224            outputs: &[&outputs],
1225            ignore: IgnoredReads::of_globs(&["src/*.log".to_owned()]).unwrap(),
1226            state: vec![".qk".into()],
1227            produced: &Produced::default(),
1228        };
1229        let accesses = [
1230            access(&path("src/a.ts"), Operation::OpenRead, "success"),
1231            access(&path("src"), Operation::OpenRead, "success"),
1232            access(&path("icons"), Operation::ListDirectory, "success"),
1233            access(&path("config.local"), Operation::Metadata, "missing"),
1234            access(&path("src/b.ts"), Operation::Metadata, "success"),
1235            // A file it writes and then reads is its own.
1236            access(&path("icons/sprite.svg"), Operation::Write, "success"),
1237            access(&path("icons/sprite.svg"), Operation::OpenRead, "success"),
1238            // Left out wholesale.
1239            access("/usr/lib/libc.so", Operation::OpenRead, "success"),
1240            access(
1241                &path("node_modules/x/index.js"),
1242                Operation::OpenRead,
1243                "success",
1244            ),
1245            access(&path(".git/HEAD"), Operation::OpenRead, "success"),
1246            access(&path(".qk/state.json"), Operation::OpenRead, "success"),
1247            access(&path("dist/out.js"), Operation::OpenRead, "success"),
1248            access(&path("src/debug.log"), Operation::OpenRead, "success"),
1249            // A file read is not also only present.
1250            access(&path("src/a.ts"), Operation::Metadata, "success"),
1251        ];
1252        let set = PathSet::from_accesses(&accesses, &exclusions, &root);
1253        assert_eq!(
1254            set,
1255            PathSet {
1256                reads: ["src/a.ts".into()].into(),
1257                listings: ["icons".into()].into(),
1258                present: ["src/b.ts".into()].into(),
1259                absent: ["config.local".into()].into(),
1260                written: ["icons/sprite.svg".into()].into(),
1261                recorded: None,
1262            }
1263        );
1264    }
1265
1266    /// What upstream tasks write into `node_modules`, and what an
1267    /// `includeIgnored` fileset reads there, is no installed package: the
1268    /// lockfile does not key it, so the path set does.
1269    #[test]
1270    fn keeps_what_is_produced_in_node_modules() {
1271        let temp = tempfile::tempdir().unwrap();
1272        let root = temp.path().canonicalize().unwrap();
1273        let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
1274        let produced = Produced::new(
1275            vec![
1276                Outputs::from_paths(&["node_modules/.gen/app".into()]).unwrap(),
1277                Outputs::from_paths(&["dist/lib".into()]).unwrap(),
1278                // A glob from a project root names no directory in
1279                // node_modules: only what it matches is produced.
1280                Outputs::from_paths(&["libs/types/**/*.d.ts".into()]).unwrap(),
1281            ],
1282            vec![
1283                "libs/e2e/node_modules/.cache/fixtures".into(),
1284                // Reading a project's own directory does not make its
1285                // installed packages produced.
1286                "libs/e2e".into(),
1287            ],
1288        );
1289        assert_eq!(
1290            produced.roots,
1291            [
1292                "libs/e2e/node_modules/.cache/fixtures",
1293                "node_modules/.gen/app"
1294            ]
1295        );
1296        let exclusions = Exclusions {
1297            roots: [&root, &root],
1298            outputs: &[],
1299            ignore: IgnoredReads::none(),
1300            state: Vec::new(),
1301            produced: &produced,
1302        };
1303        let read = |relative: &str| access(&path(relative), Operation::OpenRead, "success");
1304        let accesses = [
1305            read("node_modules/.gen/app/index.js"),
1306            access(
1307                &path("node_modules/.gen/app"),
1308                Operation::ListDirectory,
1309                "success",
1310            ),
1311            read("node_modules/.gen/other/index.js"),
1312            read("node_modules/foo/index.js"),
1313            read("dist/lib/node_modules/dep/index.js"),
1314            read("libs/types/node_modules/@types/x/index.d.ts"),
1315            read("libs/types/node_modules/x/index.js"),
1316            read("libs/e2e/node_modules/.cache/fixtures/a.json"),
1317            read("libs/e2e/node_modules/foo/index.js"),
1318            // Where it looks for what was produced, but not among what is
1319            // installed.
1320            access(
1321                &path("node_modules/.gen"),
1322                Operation::ListDirectory,
1323                "success",
1324            ),
1325            access(
1326                &path("libs/e2e/node_modules/.cache"),
1327                Operation::ListDirectory,
1328                "success",
1329            ),
1330            access(&path("node_modules"), Operation::ListDirectory, "success"),
1331            // Where an upstream glob may come to match.
1332            access(
1333                &path("libs/types/node_modules/@types"),
1334                Operation::ListDirectory,
1335                "success",
1336            ),
1337            access(
1338                &path("node_modules/foo"),
1339                Operation::ListDirectory,
1340                "success",
1341            ),
1342            access(
1343                &path("node_modules/.gen/app/index.d.ts"),
1344                Operation::Metadata,
1345                "success",
1346            ),
1347            access(
1348                &path("node_modules/.gen/app/extra.js"),
1349                Operation::Metadata,
1350                "missing",
1351            ),
1352            access(
1353                &path("node_modules/foo/package.json"),
1354                Operation::Metadata,
1355                "success",
1356            ),
1357            access(
1358                &path("node_modules/foo/extra.js"),
1359                Operation::Metadata,
1360                "missing",
1361            ),
1362        ];
1363        let set = PathSet::from_accesses(&accesses, &exclusions, &root);
1364        assert_eq!(
1365            set.reads,
1366            [
1367                "dist/lib/node_modules/dep/index.js",
1368                "libs/e2e/node_modules/.cache/fixtures/a.json",
1369                "libs/types/node_modules/@types/x/index.d.ts",
1370                "node_modules/.gen/app/index.js",
1371            ]
1372            .map(String::from)
1373            .into()
1374        );
1375        assert_eq!(
1376            set.listings,
1377            [
1378                "libs/e2e/node_modules/.cache",
1379                "libs/types/node_modules/@types",
1380                "node_modules",
1381                "node_modules/.gen",
1382                "node_modules/.gen/app",
1383            ]
1384            .map(String::from)
1385            .into()
1386        );
1387        assert_eq!(
1388            set.present,
1389            ["node_modules/.gen/app/index.d.ts".to_owned()].into()
1390        );
1391        assert_eq!(
1392            set.absent,
1393            ["node_modules/.gen/app/extra.js".to_owned()].into()
1394        );
1395        // And what it looked up there keys it.
1396        std::fs::write(root.join("nx.json"), "{}").unwrap();
1397        std::fs::write(
1398            root.join("project.json"),
1399            r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1400        )
1401        .unwrap();
1402        let workspace = qk_config::Workspace::load(&root).unwrap();
1403        let graph = qk_taskgraph::TaskGraph::build(
1404            &workspace,
1405            &[qk_taskgraph::Request::parse("app:build").unwrap()],
1406        )
1407        .unwrap();
1408        let cache = tempfile::tempdir().unwrap();
1409        let snapshot = Snapshot::new(&workspace, &graph, cache.path()).unwrap();
1410        std::fs::create_dir_all(root.join("node_modules/.gen/app")).unwrap();
1411        std::fs::write(root.join("node_modules/.gen/app/index.d.ts"), "").unwrap();
1412        let unlisted = Unlisted {
1413            produced: Some(&produced),
1414            ..NOTHING
1415        };
1416        let key = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
1417        let generated = key();
1418        // A package installed beside what was produced is the lockfile's.
1419        std::fs::create_dir_all(root.join("node_modules/bar")).unwrap();
1420        assert_eq!(key(), generated);
1421        // What was produced coming and going is not.
1422        std::fs::rename(root.join("node_modules/.gen"), root.join(".gen")).unwrap();
1423        assert_ne!(key(), generated);
1424        std::fs::rename(root.join(".gen"), root.join("node_modules/.gen")).unwrap();
1425        assert_eq!(key(), generated);
1426        // The listing of node_modules alone keys what was produced in it.
1427        let listing = PathSet {
1428            listings: ["node_modules".to_owned()].into(),
1429            ..PathSet::default()
1430        };
1431        let listed = || strong_key("weak", &listing, &snapshot, &root, &unlisted).unwrap();
1432        let before = listed();
1433        std::fs::create_dir_all(root.join("node_modules/baz")).unwrap();
1434        assert_eq!(listed(), before);
1435        std::fs::rename(root.join("node_modules/.gen"), root.join(".gen")).unwrap();
1436        assert_ne!(listed(), before);
1437        std::fs::rename(root.join(".gen"), root.join("node_modules/.gen")).unwrap();
1438        assert_eq!(listed(), before);
1439        // What it found and what it found missing there each key it alone.
1440        let keyed = |set: &PathSet| strong_key("weak", set, &snapshot, &root, &unlisted).unwrap();
1441        let found = PathSet {
1442            present: set.present.clone(),
1443            ..PathSet::default()
1444        };
1445        let before = keyed(&found);
1446        std::fs::remove_file(root.join("node_modules/.gen/app/index.d.ts")).unwrap();
1447        assert_ne!(keyed(&found), before);
1448        std::fs::write(root.join("node_modules/.gen/app/index.d.ts"), "").unwrap();
1449        assert_eq!(keyed(&found), before);
1450        let missing = PathSet {
1451            absent: set.absent.clone(),
1452            ..PathSet::default()
1453        };
1454        let before = keyed(&missing);
1455        std::fs::write(root.join("node_modules/.gen/app/extra.js"), "").unwrap();
1456        assert_ne!(keyed(&missing), before);
1457        std::fs::remove_file(root.join("node_modules/.gen/app/extra.js")).unwrap();
1458        assert_eq!(keyed(&missing), before);
1459        std::fs::remove_file(root.join("node_modules/.gen/app/index.d.ts")).unwrap();
1460        assert_ne!(key(), generated);
1461        std::fs::write(root.join("node_modules/.gen/app/index.d.ts"), "").unwrap();
1462        assert_eq!(key(), generated);
1463        std::fs::write(root.join("node_modules/.gen/app/extra.js"), "").unwrap();
1464        assert_ne!(key(), generated);
1465    }
1466
1467    /// Below where an upstream glob starts, only listings and writes are read
1468    /// from a trace, so an installed package's reads there never count
1469    /// toward its limits.
1470    #[test]
1471    fn a_trace_keeps_in_node_modules_only_what_a_path_set_may() {
1472        let produced = Produced::new(
1473            vec![
1474                Outputs::from_paths(&["node_modules/.gen/app".into()]).unwrap(),
1475                Outputs::from_paths(&["libs/types/**/*.d.ts".into()]).unwrap(),
1476            ],
1477            Vec::new(),
1478        );
1479        let traced = |path: &str, operation: Operation| produced.traced(path, &operation);
1480        // What is produced, however it is accessed.
1481        assert!(traced(
1482            "node_modules/.gen/app/index.js",
1483            Operation::OpenRead
1484        ));
1485        assert!(traced(
1486            "libs/types/node_modules/@types/x/index.d.ts",
1487            Operation::OpenRead
1488        ));
1489        // Where only listings are kept, listings and writes alone.
1490        assert!(traced("node_modules", Operation::ListDirectory));
1491        assert!(traced(
1492            "libs/types/node_modules/@types",
1493            Operation::ListDirectory
1494        ));
1495        assert!(traced(
1496            "libs/types/node_modules/x/cache.json",
1497            Operation::Write
1498        ));
1499        assert!(!traced(
1500            "libs/types/node_modules/x/index.js",
1501            Operation::OpenRead
1502        ));
1503        assert!(!traced("libs/types/node_modules/x", Operation::Metadata));
1504        // An installed package elsewhere, not at all.
1505        assert!(!traced("node_modules/foo/index.js", Operation::OpenRead));
1506        assert!(!traced("node_modules/foo", Operation::ListDirectory));
1507    }
1508
1509    /// What decides which paths in node_modules a recording keeps is keyed
1510    /// however the dependencies come, and not at all where nothing does.
1511    #[test]
1512    fn what_keeps_paths_in_node_modules_is_keyed_once() {
1513        assert_eq!(Produced::default().weak(), None);
1514        let produced = |declared: &[&[&str]]| {
1515            Produced::default()
1516                .declared(
1517                    declared
1518                        .iter()
1519                        .map(|outputs| outputs.iter().map(|output| (*output).to_owned()).collect())
1520                        .collect(),
1521                )
1522                .weak()
1523        };
1524        let both = produced(&[&["dist/lib"], &["node_modules/.gen/app"]]);
1525        assert!(both.is_some());
1526        assert_eq!(produced(&[&["node_modules/.gen/app"], &["dist/lib"]]), both);
1527        assert_ne!(produced(&[&["dist/lib"]]), both);
1528    }
1529
1530    /// A file read through a linked directory cannot be keyed by its
1531    /// content, so a recording naming one keys nothing.
1532    #[cfg(unix)]
1533    #[test]
1534    fn a_file_read_through_a_linked_directory_is_unkeyable() {
1535        let temp = tempfile::tempdir().unwrap();
1536        let root = temp.path().canonicalize().unwrap();
1537        std::fs::write(root.join("nx.json"), "{}").unwrap();
1538        std::fs::write(
1539            root.join("project.json"),
1540            r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1541        )
1542        .unwrap();
1543        std::fs::create_dir(root.join("package")).unwrap();
1544        std::fs::write(root.join("package/index.js"), "").unwrap();
1545        std::os::unix::fs::symlink("package", root.join("linked")).unwrap();
1546        let workspace = qk_config::Workspace::load(&root).unwrap();
1547        let graph = qk_taskgraph::TaskGraph::build(
1548            &workspace,
1549            &[qk_taskgraph::Request::parse("app:build").unwrap()],
1550        )
1551        .unwrap();
1552        let cache = tempfile::tempdir().unwrap();
1553        let snapshot = Snapshot::new(&workspace, &graph, cache.path()).unwrap();
1554        let reading = |path: &str| PathSet {
1555            reads: [path.to_owned()].into(),
1556            ..PathSet::default()
1557        };
1558        assert_eq!(
1559            reading("package/index.js").unkeyable(&snapshot, &root),
1560            None
1561        );
1562        let linked = reading("linked/index.js").unkeyable(&snapshot, &root);
1563        assert!(
1564            linked
1565                .as_deref()
1566                .is_some_and(|reason| reason.starts_with("linked/index.js cannot be keyed")),
1567            "{linked:?}"
1568        );
1569        // A path it found absent is keyed as such.
1570        assert_eq!(
1571            reading("linked/missing.js").unkeyable(&snapshot, &root),
1572            None
1573        );
1574    }
1575
1576    #[test]
1577    fn values_change_with_contents_listings_and_existence() {
1578        let temp = tempfile::tempdir().unwrap();
1579        let root = temp.path().canonicalize().unwrap();
1580        std::fs::create_dir_all(root.join("icons")).unwrap();
1581        std::fs::write(root.join("a.txt"), "one").unwrap();
1582        std::fs::write(root.join("icons/a.svg"), "").unwrap();
1583        std::fs::write(root.join("nx.json"), "{}").unwrap();
1584        std::fs::write(
1585            root.join("project.json"),
1586            r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1587        )
1588        .unwrap();
1589        let workspace = qk_config::Workspace::load(&root).unwrap();
1590        let graph = qk_taskgraph::TaskGraph::build(
1591            &workspace,
1592            &[qk_taskgraph::Request::parse("app:build").unwrap()],
1593        )
1594        .unwrap();
1595        let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
1596        std::fs::write(root.join("checked.txt"), "one").unwrap();
1597        let set = PathSet {
1598            reads: ["a.txt".into()].into(),
1599            listings: ["icons".into()].into(),
1600            present: ["checked.txt".into()].into(),
1601            absent: ["optional.txt".into()].into(),
1602            written: ["icons/sprite.svg".into()].into(),
1603            recorded: None,
1604        };
1605        let outputs = Outputs::from_paths(&["icons/out".into()]).unwrap();
1606        let unlisted = Unlisted {
1607            own: vec![&outputs],
1608            others: None,
1609            ignore: &IGNORE_NOTHING,
1610            produced: None,
1611        };
1612        let strong = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
1613        let first = strong();
1614        // Kept with how its paths were, the set has the same key, and names
1615        // what changes.
1616        let (kept_key, kept) =
1617            with_values("weak", set.clone(), &snapshot, &root, &unlisted).unwrap();
1618        assert_eq!(kept_key, first);
1619        assert_eq!(kept, set);
1620        assert_eq!(kept.digest().unwrap(), set.digest().unwrap());
1621        assert!(
1622            kept.changed(&snapshot, &root, &unlisted)
1623                .unwrap()
1624                .is_empty()
1625        );
1626        std::fs::write(root.join("a.txt"), "changed").unwrap();
1627        std::fs::write(root.join("optional.txt"), "").unwrap();
1628        assert_eq!(
1629            kept.changed(&snapshot, &root, &unlisted).unwrap(),
1630            [("a.txt", "read"), ("optional.txt", "looked for")]
1631        );
1632        assert!(set.changed(&snapshot, &root, &unlisted).unwrap().is_empty());
1633        std::fs::write(root.join("a.txt"), "one").unwrap();
1634        std::fs::remove_file(root.join("optional.txt")).unwrap();
1635        // What the task writes in a listed directory, or its outputs there,
1636        // and files it never read change nothing.
1637        std::fs::write(root.join("icons/sprite.svg"), "").unwrap();
1638        std::fs::create_dir(root.join("icons/out")).unwrap();
1639        std::fs::write(root.join("b.txt"), "unread").unwrap();
1640        assert_eq!(strong(), first);
1641        for change in [
1642            |root: &Path| std::fs::write(root.join("icons/b.svg"), "").unwrap(),
1643            |root: &Path| std::fs::write(root.join("optional.txt"), "").unwrap(),
1644            |root: &Path| std::fs::write(root.join("a.txt"), "two").unwrap(),
1645            // A file it only looked at, as by its timestamp.
1646            |root: &Path| std::fs::write(root.join("checked.txt"), "two").unwrap(),
1647        ] {
1648            let before = strong();
1649            change(&root);
1650            assert_ne!(strong(), before);
1651        }
1652    }
1653
1654    #[test]
1655    fn keeps_the_newest_sets_first() {
1656        let temp = tempfile::tempdir().unwrap();
1657        std::fs::create_dir(temp.path().join("tmp")).unwrap();
1658        let set = |read: &str| PathSet {
1659            reads: [read.to_owned()].into(),
1660            ..PathSet::default()
1661        };
1662        for index in 0..10 {
1663            save(temp.path(), "weak", set(&format!("{index}"))).unwrap();
1664        }
1665        save(temp.path(), "weak", set("5")).unwrap();
1666        let kept: Vec<String> = load(temp.path(), "weak")
1667            .into_iter()
1668            .map(|set| set.reads.into_iter().next().unwrap())
1669            .collect();
1670        assert_eq!(kept, ["5", "9", "8", "7", "6", "4", "3", "2"]);
1671        assert!(load(temp.path(), "other").is_empty());
1672    }
1673
1674    #[test]
1675    fn merges_the_sets_another_machine_recorded_behind_its_own() {
1676        let temp = tempfile::tempdir().unwrap();
1677        std::fs::create_dir(temp.path().join("tmp")).unwrap();
1678        let set = |read: &str| PathSet {
1679            reads: [read.to_owned()].into(),
1680            ..PathSet::default()
1681        };
1682        save(temp.path(), "weak", set("a")).unwrap();
1683        let record = |weak: &str, sets: Vec<PathSet>| {
1684            serde_json::to_vec(&Stored {
1685                version: 1,
1686                weak: weak.into(),
1687                sets,
1688            })
1689            .unwrap()
1690        };
1691        let fetched = record(
1692            "weak",
1693            vec![set("b"), set("a"), set("../escape"), set("/abs")],
1694        );
1695        assert_eq!(merge(temp.path(), "weak", &fetched).unwrap(), [set("b")]);
1696        assert_eq!(load(temp.path(), "weak"), [set("a"), set("b")]);
1697        assert!(merge(temp.path(), "weak", &fetched).unwrap().is_empty());
1698        assert!(
1699            merge(temp.path(), "weak", &record("other", vec![set("c")]))
1700                .unwrap()
1701                .is_empty()
1702        );
1703        let many = (0..10).map(|index| set(&format!("{index}"))).collect();
1704        assert_eq!(
1705            merge(temp.path(), "weak", &record("weak", many))
1706                .unwrap()
1707                .len(),
1708            6
1709        );
1710        assert_eq!(load(temp.path(), "weak").len(), KEPT);
1711    }
1712
1713    #[test]
1714    fn a_record_too_large_leaves_out_its_oldest_sets() {
1715        let set = |read: &str| PathSet {
1716            reads: [read.to_owned()].into(),
1717            ..PathSet::default()
1718        };
1719        let record = |sets: Vec<PathSet>| {
1720            serde_json::to_vec(&Stored {
1721                version: 1,
1722                weak: "weak".into(),
1723                sets,
1724            })
1725            .unwrap()
1726        };
1727        let newest = "n".repeat(100);
1728        let full = record(vec![
1729            set(&newest),
1730            set(&"o".repeat(100)),
1731            set(&"p".repeat(100)),
1732        ]);
1733        assert_eq!(within(&full, full.len()).unwrap(), Some(full.clone()));
1734        let one = record(vec![set(&newest)]);
1735        assert_eq!(within(&full, one.len() + 50).unwrap(), Some(one.clone()));
1736        assert_eq!(within(&full, one.len() - 1).unwrap(), None);
1737    }
1738
1739    #[test]
1740    fn finds_what_changed_while_the_task_ran() {
1741        let temp = tempfile::tempdir().unwrap();
1742        for path in ["read.txt", "found.txt", "listed.txt", "before.txt"] {
1743            std::fs::write(temp.path().join(path), "before").unwrap();
1744        }
1745        for directory in ["listed", "written"] {
1746            std::fs::create_dir(temp.path().join(directory)).unwrap();
1747            std::fs::write(temp.path().join(directory).join("kept.txt"), "").unwrap();
1748        }
1749        std::fs::create_dir(temp.path().join("listed/sub")).unwrap();
1750        let set = PathSet {
1751            reads: ["read.txt".to_owned(), "gone.txt".to_owned()].into(),
1752            present: ["found.txt".to_owned(), ".".to_owned()].into(),
1753            listings: ["listed".to_owned(), "written".to_owned()].into(),
1754            written: ["written/new.txt".to_owned()].into(),
1755            ..PathSet::default()
1756        };
1757        std::thread::sleep(std::time::Duration::from_millis(20));
1758        let started = now(temp.path());
1759        let changed = |set: &PathSet| {
1760            set.changed_since(temp.path(), started, &NOTHING, &Before::default())
1761                .map(|changed| changed.path.to_owned())
1762        };
1763        assert_eq!(changed(&set), None);
1764        // What the task wrote, and content in a listed directory, keys nothing.
1765        std::fs::write(temp.path().join("written/new.txt"), "").unwrap();
1766        std::fs::write(temp.path().join("listed/kept.txt"), "after").unwrap();
1767        std::fs::write(temp.path().join("listed.txt"), "after").unwrap();
1768        // Content in a listed directory is not part of the listing.
1769        let unlisted = PathSet {
1770            listings: ["written".to_owned()].into(),
1771            ..set.clone()
1772        };
1773        assert_eq!(changed(&unlisted), None);
1774        std::fs::write(temp.path().join("found.txt"), "after").unwrap();
1775        assert_eq!(changed(&set), Some("found.txt".into()));
1776        let listings = PathSet {
1777            reads: [].into(),
1778            present: [].into(),
1779            ..set.clone()
1780        };
1781        // Another task writing inside an entry, or rewriting one in place,
1782        // leaves the listing as it was.
1783        std::fs::write(temp.path().join("listed/sub/made.txt"), "").unwrap();
1784        assert_eq!(changed(&listings), None);
1785        // An entry another process adds where the task wrote too.
1786        std::fs::write(temp.path().join("written/other.txt"), "").unwrap();
1787        if std::fs::metadata(temp.path().join("written/other.txt"))
1788            .and_then(|metadata| metadata.created())
1789            .is_ok()
1790        {
1791            assert_eq!(changed(&listings), Some("written".into()));
1792        }
1793        std::fs::remove_file(temp.path().join("written/other.txt")).unwrap();
1794        // A file that was there before, unchanged, moved in where the task
1795        // wrote too: a move updates its change time, which Unix keeps.
1796        #[cfg(unix)]
1797        {
1798            std::fs::rename(
1799                temp.path().join("before.txt"),
1800                temp.path().join("written/moved.txt"),
1801            )
1802            .unwrap();
1803            assert_eq!(changed(&listings), Some("written".into()));
1804            std::fs::rename(
1805                temp.path().join("written/moved.txt"),
1806                temp.path().join("before.txt"),
1807            )
1808            .unwrap();
1809        }
1810        // An entry added where the task wrote nothing.
1811        std::fs::write(temp.path().join("listed/new.txt"), "").unwrap();
1812        assert_eq!(changed(&listings), Some("listed".into()));
1813        // A removal, where the task wrote nothing.
1814        let removed = tempfile::tempdir().unwrap();
1815        std::fs::write(removed.path().join("gone.txt"), "").unwrap();
1816        std::thread::sleep(std::time::Duration::from_millis(20));
1817        // Taken elsewhere, as qk takes it in its cache: taking it changes the
1818        // directory it is taken in.
1819        let started = now(temp.path());
1820        let root = PathSet {
1821            listings: [".".to_owned()].into(),
1822            ..PathSet::default()
1823        };
1824        assert_eq!(
1825            root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1826                .map(|changed| changed.path),
1827            None
1828        );
1829        std::fs::remove_file(removed.path().join("gone.txt")).unwrap();
1830        assert_eq!(
1831            root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1832                .map(|changed| changed.path),
1833            Some(".")
1834        );
1835    }
1836
1837    /// The directories that only hold the task's own outputs are there once
1838    /// they are, or when a sandbox made them before it ran, and not before:
1839    /// a lookup on a fresh checkout keys them as a run after the task does.
1840    #[test]
1841    fn directories_only_holding_its_own_outputs_key_the_same_made_or_not() {
1842        let roots = [
1843            ("web".to_owned(), "apps/web".to_owned()),
1844            ("ui".to_owned(), "libs/ui".to_owned()),
1845        ]
1846        .into();
1847        let declared = |upstream: Option<&str>| {
1848            let mut outputs = vec![(
1849                ("web".into(), "build".into(), None),
1850                Outputs::from_patterns(&["dist/apps/web"]),
1851            )];
1852            outputs.extend(upstream.map(|anchor| {
1853                (
1854                    ("ui".into(), "build".into(), None),
1855                    Outputs::from_patterns(&[anchor]),
1856                )
1857            }));
1858            WorkspaceOutputs::new(outputs, &roots)
1859        };
1860        let build = Outputs::from_patterns(&["dist/apps/web"]);
1861        let coverage = Outputs::from_patterns(&["apps/web/coverage/unit"]);
1862        let upstream: BTreeSet<TargetOf> = [("ui".into(), "build".into(), None)].into();
1863        let folders = |outputs: &WorkspaceOutputs| {
1864            outputs
1865                .own_folders(&[&build, &coverage], &upstream)
1866                .into_iter()
1867                .collect::<Vec<_>>()
1868        };
1869        // Outside its project's root and inside it, but never the root, nor
1870        // the directory holding it.
1871        assert_eq!(
1872            folders(&declared(None)),
1873            ["apps/web/coverage", "dist", "dist/apps"]
1874        );
1875        // A directory also holding an output it depends on, or held by one,
1876        // holds its input.
1877        assert_eq!(
1878            folders(&declared(Some("dist/libs/ui"))),
1879            ["apps/web/coverage", "dist/apps"]
1880        );
1881        assert_eq!(folders(&declared(Some("dist"))), ["apps/web/coverage"]);
1882        let outputs = declared(None);
1883
1884        let temp = tempfile::tempdir().unwrap();
1885        let root = &temp.path().canonicalize().unwrap();
1886        std::fs::write(root.join("nx.json"), "{}").unwrap();
1887        std::fs::write(
1888            root.join("project.json"),
1889            r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1890        )
1891        .unwrap();
1892        let workspace = qk_config::Workspace::load(root).unwrap();
1893        let graph = qk_taskgraph::TaskGraph::build(
1894            &workspace,
1895            &[qk_taskgraph::Request::parse("app:build").unwrap()],
1896        )
1897        .unwrap();
1898        // Its cache apart, as the root is listed.
1899        let cache = tempfile::tempdir().unwrap();
1900        let snapshot = Snapshot::new(&workspace, &graph, cache.path()).unwrap();
1901        let own = [&build, &coverage];
1902        let unlisted = Unlisted {
1903            own: own.to_vec(),
1904            others: Some(Others::new(&outputs, BTreeSet::new(), &own)),
1905            ignore: &IGNORE_NOTHING,
1906            produced: None,
1907        };
1908        let set = PathSet {
1909            listings: [".", "dist", "apps/web/coverage"].map(String::from).into(),
1910            present: ["dist/apps".to_owned()].into(),
1911            ..PathSet::default()
1912        };
1913        let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
1914        // Made before it ran, as the sandbox makes them, or not.
1915        std::fs::create_dir_all(root.join("dist/apps")).unwrap();
1916        std::fs::create_dir_all(root.join("apps/web/coverage")).unwrap();
1917        let made = key();
1918        std::fs::remove_dir_all(root.join("dist")).unwrap();
1919        std::fs::remove_dir(root.join("apps/web/coverage")).unwrap();
1920        assert_eq!(key(), made);
1921        // What else such a directory holds is still listed.
1922        std::fs::create_dir(root.join("apps/web/coverage")).unwrap();
1923        assert_eq!(key(), made);
1924        std::fs::write(root.join("apps/web/coverage/README.md"), "").unwrap();
1925        assert_ne!(key(), made);
1926        std::fs::remove_file(root.join("apps/web/coverage/README.md")).unwrap();
1927        // And another directory appearing beside them changes it.
1928        std::fs::create_dir(root.join("build")).unwrap();
1929        assert_ne!(key(), made);
1930        // A source added beside its outputs while it ran is told, as in any
1931        // directory it listed.
1932        std::fs::create_dir_all(root.join("dist/apps")).unwrap();
1933        let directories = unlisted.directories(".");
1934        assert!(directories.contains("apps/web/coverage"));
1935        let taken = Before::take(root, directories, &unlisted);
1936        std::thread::sleep(std::time::Duration::from_millis(20));
1937        let started = now(root);
1938        std::fs::write(root.join("apps/web/coverage/added.md"), "").unwrap();
1939        assert_eq!(
1940            set.changed_since(root, started, &unlisted, &taken)
1941                .map(|changed| changed.path),
1942            Some("apps/web/coverage")
1943        );
1944    }
1945
1946    #[test]
1947    fn listings_leave_out_other_targets_outputs_and_ignored_reads() {
1948        let temp = tempfile::tempdir().unwrap();
1949        let root = &temp.path().canonicalize().unwrap();
1950        std::fs::create_dir_all(root.join("app/src")).unwrap();
1951        std::fs::write(root.join("app/src/main.ts"), "").unwrap();
1952        std::fs::write(root.join("nx.json"), "{}").unwrap();
1953        std::fs::write(
1954            root.join("project.json"),
1955            r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1956        )
1957        .unwrap();
1958        let workspace = qk_config::Workspace::load(root).unwrap();
1959        let graph = qk_taskgraph::TaskGraph::build(
1960            &workspace,
1961            &[qk_taskgraph::Request::parse("app:build").unwrap()],
1962        )
1963        .unwrap();
1964        let set = PathSet {
1965            listings: ["app".to_owned()].into(),
1966            ..PathSet::default()
1967        };
1968        std::fs::write(root.join("app/kept.txt"), "").unwrap();
1969        std::fs::create_dir(root.join("app/out")).unwrap();
1970        let roots = [("app".to_owned(), "app".to_owned())].into();
1971        let outputs = WorkspaceOutputs::new(
1972            vec![
1973                (
1974                    ("app".into(), "tsc".into(), None),
1975                    Outputs::from_patterns(&["app/.tsc"]),
1976                ),
1977                (
1978                    ("app".into(), "codegen".into(), None),
1979                    Outputs::from_patterns(&["app/generated"]),
1980                ),
1981                // An output a glob declares, inside a directory the task lists.
1982                (
1983                    ("app".into(), "bundle".into(), None),
1984                    Outputs::from_patterns(&["app/out/*.js"]),
1985                ),
1986                // Another target declaring the dependency's output too.
1987                (
1988                    ("app".into(), "package".into(), None),
1989                    Outputs::from_patterns(&["app/generated"]),
1990                ),
1991                // An output inside a directory that only exists to hold it.
1992                (
1993                    ("app".into(), "deploy".into(), None),
1994                    Outputs::from_patterns(&["app/.cache/deploy"]),
1995                ),
1996            ],
1997            &roots,
1998        );
1999        let ignore = IgnoredReads::of_globs(&["**/.fallow".to_owned()]).unwrap();
2000        let unlisted = Unlisted {
2001            own: Vec::new(),
2002            others: Some(Others::new(
2003                &outputs,
2004                // It depends on codegen, whose output is its input.
2005                [
2006                    ("app".into(), "build".into(), None),
2007                    ("app".into(), "codegen".into(), None),
2008                ]
2009                .into(),
2010                &[],
2011            )),
2012            ignore: &ignore,
2013            produced: None,
2014        };
2015        assert_eq!(
2016            unlisted.directories("app"),
2017            [
2018                "app".to_owned(),
2019                "app/.cache".to_owned(),
2020                "app/.cache/deploy".to_owned(),
2021                "app/.tsc".to_owned(),
2022                "app/generated".to_owned(),
2023                "app/out".to_owned()
2024            ]
2025            .into()
2026        );
2027        let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
2028        let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
2029        let before = key();
2030        std::thread::sleep(std::time::Duration::from_millis(20));
2031        let taken = Before::take(root, unlisted.directories("app"), &unlisted);
2032        let started = now(root);
2033        let changed = |taken: &Before| {
2034            set.changed_since(root, started, &unlisted, taken)
2035                .map(|changed| changed.path)
2036        };
2037        // Another target's output, the directory holding another's, and an
2038        // ignored path appear as it runs.
2039        std::fs::create_dir(root.join("app/.tsc")).unwrap();
2040        std::fs::create_dir_all(root.join("app/.cache/deploy")).unwrap();
2041        std::fs::create_dir(root.join("app/.fallow")).unwrap();
2042        assert_eq!(key(), before);
2043        assert_eq!(changed(&taken), None);
2044        // Another target's output listed itself has nothing to key, though
2045        // nothing listed it before.
2046        std::fs::write(root.join("app/.tsc/index.js"), "").unwrap();
2047        let output = PathSet {
2048            listings: ["app/.tsc".to_owned()].into(),
2049            ..PathSet::default()
2050        };
2051        assert_eq!(
2052            output.changed_since(root, started, &unlisted, &Before::default()),
2053            None
2054        );
2055        // The project's root is no folder holding its outputs.
2056        assert!(!unlisted.matches("app"));
2057        // A listed directory removed as it ran, with no listing from before.
2058        std::fs::create_dir(root.join("app/gone")).unwrap();
2059        let gone = PathSet {
2060            listings: ["app/gone".to_owned()].into(),
2061            ..PathSet::default()
2062        };
2063        std::fs::remove_dir(root.join("app/gone")).unwrap();
2064        assert_eq!(
2065            gone.changed_since(root, started, &unlisted, &Before::default())
2066                .map(|changed| changed.path),
2067            Some("app/gone")
2068        );
2069        // An output appearing in a listed directory a glob declares it in.
2070        std::fs::write(root.join("app/out/new.js"), "").unwrap();
2071        let out = PathSet {
2072            listings: ["app/out".to_owned()].into(),
2073            ..PathSet::default()
2074        };
2075        assert_eq!(out.changed_since(root, started, &unlisted, &taken), None);
2076        assert_eq!(
2077            out.changed_since(root, started, &unlisted, &Before::default())
2078                .map(|changed| changed.path),
2079            Some("app/out")
2080        );
2081        // A dependency's output is the task's input, and stays listed.
2082        assert!(!unlisted.matches("app/generated"));
2083        assert!(unlisted.matches("app/.tsc/index.js"));
2084        // Without the entries from before, the directory's time is all there
2085        // is, and it moved.
2086        assert_eq!(changed(&Before::default()), Some("app"));
2087        // A removal is still a change, though left-out entries are there.
2088        std::fs::remove_file(root.join("app/kept.txt")).unwrap();
2089        assert_ne!(key(), before);
2090        assert_eq!(
2091            set.changed_since(root, started, &unlisted, &taken)
2092                .and_then(|changed| changed.how),
2093            Some("kept.txt removed".to_owned())
2094        );
2095        std::fs::write(root.join("app/kept.txt"), "").unwrap();
2096        assert_eq!(changed(&taken), None);
2097        // So is anything else added, which the reason names.
2098        std::fs::write(root.join("app/README.md"), "").unwrap();
2099        assert_ne!(key(), before);
2100        assert_eq!(
2101            set.changed_since(root, started, &unlisted, &taken)
2102                .map(|changed| changed.to_string()),
2103            Some("app changed while it ran (README.md added)".to_owned())
2104        );
2105    }
2106}