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