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<&str> {
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        file.or_else(|| {
211            self.listings.iter().find(|directory| {
212                let absolute = if *directory == "." {
213                    root.to_path_buf()
214                } else {
215                    root.join(directory)
216                };
217                let skipped = |path: &str| self.written.contains(path) || unlisted.matches(path);
218                if let Some(earlier) = before.0.get(*directory) {
219                    let earlier: BTreeSet<_> = earlier
220                        .iter()
221                        .filter(|(name, _)| !self.written.contains(&child(directory, name)))
222                        .cloned()
223                        .collect();
224                    return entries(root, directory, skipped).is_none_or(|now| now != earlier);
225                }
226                let Ok(entries) = std::fs::read_dir(&absolute) else {
227                    return false;
228                };
229                // Each entry, whether listings leave it out, and whether the
230                // task wrote it or it is among the task's own outputs.
231                let entries: Vec<(std::fs::DirEntry, bool, bool)> = entries
232                    .flatten()
233                    .map(|entry| {
234                        let path = child(directory, &entry.file_name().to_string_lossy());
235                        let own = self.written.contains(&path) || unlisted.own(&path);
236                        (entry, skipped(&path), own)
237                    })
238                    .collect();
239                let own = self.written.iter().any(|path| parent(path) == *directory)
240                    || entries.iter().any(|(_, _, own)| *own);
241                if !own {
242                    return std::fs::symlink_metadata(&absolute)
243                        .and_then(|metadata| metadata.modified())
244                        .is_ok_and(|modified| modified >= started);
245                }
246                entries
247                    .iter()
248                    .filter(|(_, skipped, _)| !skipped)
249                    .any(|(entry, _, _)| {
250                        std::fs::symlink_metadata(entry.path()).is_ok_and(|metadata| {
251                            // A file's change time moves when it is moved in,
252                            // and with a rewrite, which counts too. A
253                            // subdirectory's moves with what is written inside
254                            // it, so it counts only once it was created.
255                            if metadata.is_dir() {
256                                metadata.created().is_ok_and(|born| born >= started)
257                            } else {
258                                changed(&metadata) >= started
259                            }
260                        })
261                    })
262            })
263        })
264        .map(String::as_str)
265    }
266
267    pub fn digest(&self) -> Result<String> {
268        key(&serde_json::to_value(self)?)
269    }
270
271    /// How the set's paths are now: each read file's content, each listed
272    /// directory's entries and each looked-up path's kind. `outputs` are the
273    /// task's own, left out of listings.
274    pub(crate) fn values(
275        &self,
276        snapshot: &Snapshot,
277        root: &Path,
278        unlisted: &Unlisted,
279    ) -> Result<Value> {
280        // A file by its content, anything else by what it is.
281        let value = |path: &str| match kind(&root.join(path)) {
282            "file" | "symlink" => snapshot
283                .recorded_value(root, path)
284                .unwrap_or_else(|_| json!({"unreadable": true})),
285            other => json!(other),
286        };
287        let reads: Map<String, Value> = self
288            .reads
289            .iter()
290            .map(|path| (path.clone(), value(path)))
291            .collect();
292        let mut listings = Map::new();
293        for directory in &self.listings {
294            listings.insert(directory.clone(), self.listing(root, directory, unlisted)?);
295        }
296        let existence: Map<String, Value> = self
297            .present
298            .iter()
299            .chain(&self.absent)
300            .map(|path| (path.clone(), value(path)))
301            .collect();
302        Ok(json!({"reads": reads, "listings": listings, "existence": existence}))
303    }
304
305    /// A directory's entries by name and kind, without what the task writes.
306    fn listing(&self, root: &Path, directory: &str, unlisted: &Unlisted) -> Result<Value> {
307        let absolute = if directory == "." {
308            root.to_path_buf()
309        } else {
310            root.join(directory)
311        };
312        let entries = match std::fs::read_dir(&absolute) {
313            Ok(entries) => entries,
314            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
315                return Ok(json!("absent"));
316            }
317            Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
318                return Ok(json!(kind(&absolute)));
319            }
320            Err(error) => return Err(error.into()),
321        };
322        let mut listed = Map::new();
323        for entry in entries {
324            let entry = entry?;
325            let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
326                continue;
327            };
328            let path = if directory == "." {
329                name.clone()
330            } else {
331                format!("{directory}/{name}")
332            };
333            if self.written.contains(&path) || unlisted.matches(&path) {
334                continue;
335            }
336            listed.insert(name, json!(kind(&entry.path())));
337        }
338        Ok(Value::Object(listed))
339    }
340}
341
342/// The directory a workspace-relative path is in, `.` for the root.
343fn parent(path: &str) -> &str {
344    path.rsplit_once('/').map_or(".", |(parent, _)| parent)
345}
346
347/// The path of `name` in `directory`, `.` being the workspace root.
348fn child(directory: &str, name: &str) -> String {
349    if directory == "." {
350        name.to_owned()
351    } else {
352        format!("{directory}/{name}")
353    }
354}
355
356/// The names and kinds of `directory`'s entries but those `skipped`; none
357/// when it, or any of its entries or their kinds, cannot be read.
358fn entries(
359    root: &Path,
360    directory: &str,
361    skipped: impl Fn(&str) -> bool,
362) -> Option<BTreeSet<(String, &'static str)>> {
363    let absolute = if directory == "." {
364        root.to_path_buf()
365    } else {
366        root.join(directory)
367    };
368    let mut found = BTreeSet::new();
369    for entry in std::fs::read_dir(&absolute).ok()? {
370        let entry = entry.ok()?;
371        let name = entry.file_name().to_string_lossy().into_owned();
372        if !skipped(&child(directory, &name)) {
373            found.insert((name, file_kind(entry.file_type().ok()?)));
374        }
375    }
376    Some(found)
377}
378
379/// What is at `path`, without following a link.
380fn kind(path: &Path) -> &'static str {
381    std::fs::symlink_metadata(path).map_or("absent", |metadata| file_kind(metadata.file_type()))
382}
383
384fn file_kind(file_type: std::fs::FileType) -> &'static str {
385    if file_type.is_symlink() {
386        "symlink"
387    } else if file_type.is_dir() {
388        "directory"
389    } else if file_type.is_file() {
390        "file"
391    } else {
392        "other"
393    }
394}
395
396/// The declared outputs of every target in the workspace, and of each of
397/// their configurations, which can move an output with its options, by the
398/// workspace's configuration rather than what a run schedules. Indexed by
399/// their anchors, so a path is matched only against the outputs that can
400/// hold it.
401#[derive(Default)]
402pub(crate) struct WorkspaceOutputs {
403    outputs: Vec<(TargetOf, Outputs)>,
404    by_anchor: BTreeMap<String, Vec<usize>>,
405}
406
407impl WorkspaceOutputs {
408    pub(crate) fn new(outputs: Vec<(TargetOf, Outputs)>) -> Self {
409        let mut by_anchor: BTreeMap<String, Vec<usize>> = BTreeMap::new();
410        for (index, (_, declared)) in outputs.iter().enumerate() {
411            for anchor in declared.anchors() {
412                by_anchor.entry(anchor.to_owned()).or_default().push(index);
413            }
414        }
415        Self { outputs, by_anchor }
416    }
417
418    /// The outputs whose anchor is `path` or a directory holding it.
419    fn holding<'a>(&'a self, path: &'a str) -> impl Iterator<Item = &'a (TargetOf, Outputs)> {
420        std::iter::once(path)
421            .chain(path.match_indices('/').map(|(at, _)| &path[..at]))
422            .filter_map(|anchor| self.by_anchor.get(anchor))
423            .flatten()
424            .map(|&index| &self.outputs[index])
425    }
426}
427
428/// A target by project, target and configuration, whose options can move
429/// its outputs.
430pub(crate) type TargetOf = (String, String, Option<String>);
431
432/// Other targets' outputs a recording's listings leave out: every target's
433/// in the workspace but the task's own and those it depends on, whose
434/// outputs are its inputs.
435pub(crate) struct Others<'a> {
436    pub outputs: &'a WorkspaceOutputs,
437    /// The task's target and its upstream.
438    pub upstream: BTreeSet<TargetOf>,
439}
440
441impl Others<'_> {
442    /// Whether `path` is another target's output, and none of the
443    /// upstream's: an output both declare is the task's input.
444    pub(crate) fn matches(&self, path: &str) -> bool {
445        let mut other = false;
446        for (target, outputs) in self.outputs.holding(path) {
447            if outputs.matches(path) {
448                if self.upstream.contains(target) {
449                    return false;
450                }
451                other = true;
452            }
453        }
454        other
455    }
456}
457
458/// What a recording's listings leave out of a directory's entries: what is
459/// there after a task runs whether or not it read anything, rather than what
460/// it read. The task's own outputs, the outputs of other targets, such as a
461/// build's output beside a lint's sources or another project's coverage, but
462/// not those of the targets it depends on, and what its `ignoreReads` names.
463/// Only listings leave them out: a file among them that the task read is
464/// still keyed by its content.
465pub(crate) struct Unlisted<'a> {
466    pub own: Vec<&'a Outputs>,
467    pub others: Option<Others<'a>>,
468    pub ignore: &'a [Pattern],
469}
470
471impl Unlisted<'_> {
472    fn matches(&self, path: &str) -> bool {
473        self.own(path)
474            || self
475                .others
476                .as_ref()
477                .is_some_and(|others| others.matches(path))
478            || self.ignore.iter().any(|pattern| pattern.is_match(path))
479    }
480
481    fn own(&self, path: &str) -> bool {
482        self.own.iter().any(|outputs| outputs.matches(path))
483    }
484
485    /// Where other targets' outputs and ignored paths come and go while the
486    /// task runs: the directories holding those outputs, and their anchors,
487    /// where an output a glob declares comes and goes, and the root of the
488    /// task's project, where a tool's `ignoreReads` state usually sits.
489    pub(crate) fn directories(&self, project_root: &str) -> BTreeSet<String> {
490        let project_root = if project_root.is_empty() {
491            "."
492        } else {
493            project_root
494        };
495        self.others
496            .iter()
497            .flat_map(|others| {
498                others
499                    .outputs
500                    .outputs
501                    .iter()
502                    .filter(|(target, _)| !others.upstream.contains(target))
503                    .flat_map(|(_, outputs)| outputs.anchors())
504            })
505            .flat_map(|anchor| [anchor.to_owned(), parent(anchor).to_owned()])
506            .chain([project_root.to_owned()])
507            .collect()
508    }
509}
510
511/// The entries of directories before a task ran, but those its listings
512/// leave out, so a listing whose directory gained or lost something else as
513/// it ran is told apart from one where only those came and went.
514#[derive(Default)]
515pub(crate) struct Before(BTreeMap<String, BTreeSet<(String, &'static str)>>);
516
517impl Before {
518    pub(crate) fn take(root: &Path, directories: BTreeSet<String>, unlisted: &Unlisted) -> Self {
519        Self(
520            directories
521                .into_iter()
522                .filter_map(|directory| {
523                    let entries = entries(root, &directory, |path| unlisted.matches(path))?;
524                    Some((directory, entries))
525                })
526                .collect(),
527        )
528    }
529}
530
531/// The path sets kept for weak keys, in `pathsets/` beside the entries.
532#[derive(Serialize, Deserialize)]
533struct Stored {
534    version: u32,
535    weak: String,
536    sets: Vec<PathSet>,
537}
538
539fn location(cache: &Path, weak: &str) -> PathBuf {
540    cache.join("pathsets").join(format!("{weak}.json"))
541}
542
543/// The path sets kept for `weak`, newest first; none when there are none or
544/// they cannot be read.
545pub(crate) fn load(cache: &Path, weak: &str) -> Vec<PathSet> {
546    std::fs::read(location(cache, weak))
547        .ok()
548        .and_then(|bytes| serde_json::from_slice::<Stored>(&bytes).ok())
549        .filter(|stored| stored.version == 1 && stored.weak == weak)
550        .map(|stored| stored.sets)
551        .unwrap_or_default()
552}
553
554/// Marks `weak`'s path sets as used, so eviction keeps them as it keeps the
555/// entries they lead to.
556pub(crate) fn touch(cache: &Path, weak: &str) {
557    if let Ok(file) = std::fs::File::options()
558        .append(true)
559        .open(location(cache, weak))
560    {
561        let _ = file.set_modified(std::time::SystemTime::now());
562    }
563}
564
565/// Keeps `set` as the newest for `weak`, and at most [`KEPT`] in all.
566pub(crate) fn save(cache: &Path, weak: &str, set: PathSet) -> Result<()> {
567    let mut sets = load(cache, weak);
568    sets.retain(|kept| kept != &set);
569    sets.insert(0, set);
570    keep(cache, weak, sets)
571}
572
573/// The record of `weak`'s path sets as the local cache keeps them, for the
574/// remote store.
575pub(crate) fn record(cache: &Path, weak: &str) -> Option<Vec<u8>> {
576    std::fs::read(location(cache, weak)).ok()
577}
578
579/// Adds the path sets in a record the remote store held for `weak` behind
580/// those kept locally, returning the ones that are new here. A record for
581/// another weak key or another version adds nothing, and neither does a set
582/// naming a path outside the workspace.
583pub(crate) fn merge(cache: &Path, weak: &str, record: &[u8]) -> Result<Vec<PathSet>> {
584    let Some(fetched) = serde_json::from_slice::<Stored>(record)
585        .ok()
586        .filter(|stored| stored.version == 1 && stored.weak == weak)
587    else {
588        return Ok(Vec::new());
589    };
590    let mut sets = load(cache, weak);
591    let mut added = Vec::new();
592    for set in fetched.sets {
593        if sets.len() + added.len() < KEPT
594            && set.is_relative()
595            && !set.is_empty()
596            && !sets.contains(&set)
597            && !added.contains(&set)
598        {
599            added.push(set);
600        }
601    }
602    if !added.is_empty() {
603        sets.extend(added.iter().cloned());
604        keep(cache, weak, sets)?;
605    }
606    Ok(added)
607}
608
609fn keep(cache: &Path, weak: &str, mut sets: Vec<PathSet>) -> Result<()> {
610    sets.truncate(KEPT);
611    std::fs::create_dir_all(cache.join("pathsets"))?;
612    crate::record::publish(
613        cache,
614        &location(cache, weak),
615        &Stored {
616            version: 1,
617            weak: weak.to_owned(),
618            sets,
619        },
620    )
621}
622
623/// The key of a result for `set` under `weak`, from how its paths are now.
624pub(crate) fn strong_key(
625    weak: &str,
626    set: &PathSet,
627    snapshot: &Snapshot,
628    root: &Path,
629    unlisted: &Unlisted,
630) -> Result<String> {
631    key(&json!({
632        "schema": "qk-recorded-v1",
633        "weak": weak,
634        "set": set.digest()?,
635        "values": set.values(snapshot, root, unlisted)?,
636    }))
637}
638
639/// Now, as the file system in `directory` stamps a file it writes. The kernel
640/// stamps files from a clock coarser than the system's, so a file written
641/// just after a [`SystemTime::now`] can bear an earlier time; this one is
642/// never later than the stamp of a file written after it.
643pub(crate) fn now(directory: &Path) -> SystemTime {
644    tempfile::NamedTempFile::new_in(directory)
645        .and_then(|file| file.as_file().metadata())
646        .map(|metadata| changed(&metadata))
647        .unwrap_or_else(|_| SystemTime::now())
648}
649
650/// When a path's content or metadata last changed: its ctime where there is
651/// one, which a tool resetting the modification time does not hide.
652fn changed(metadata: &std::fs::Metadata) -> SystemTime {
653    let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
654    #[cfg(unix)]
655    {
656        use std::os::unix::fs::MetadataExt;
657        let ctime = u64::try_from(metadata.ctime()).ok().map(|seconds| {
658            SystemTime::UNIX_EPOCH
659                + std::time::Duration::new(
660                    seconds,
661                    u32::try_from(metadata.ctime_nsec()).unwrap_or(0),
662                )
663        });
664        if let Some(ctime) = ctime {
665            return modified.max(ctime);
666        }
667    }
668    modified
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    /// Nothing left out of listings.
676    const NOTHING: Unlisted<'static> = Unlisted {
677        own: Vec::new(),
678        others: None,
679        ignore: &[],
680    };
681    use qk_input_analysis::Category;
682
683    fn access(path: &str, operation: Operation, result: &str) -> Access {
684        Access {
685            process: 1,
686            path: path.into(),
687            resolved_path: None,
688            keyed_path: None,
689            package: None,
690            operation,
691            result: result.into(),
692            category: Category::Uncovered,
693        }
694    }
695
696    #[test]
697    fn keeps_what_the_task_read_listed_and_looked_up() {
698        let temp = tempfile::tempdir().unwrap();
699        let root = temp.path().canonicalize().unwrap();
700        for directory in ["src", "icons", "dist"] {
701            std::fs::create_dir(root.join(directory)).unwrap();
702        }
703        let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
704        let outputs = Outputs::from_paths(&["dist".into()]).unwrap();
705        let exclusions = Exclusions {
706            roots: [&root, &root],
707            outputs: &[&outputs],
708            ignore: vec![Pattern::new("src/*.log", false).unwrap()],
709            state: vec![".qk".into()],
710        };
711        let accesses = [
712            access(&path("src/a.ts"), Operation::OpenRead, "success"),
713            access(&path("src"), Operation::OpenRead, "success"),
714            access(&path("icons"), Operation::ListDirectory, "success"),
715            access(&path("config.local"), Operation::Metadata, "missing"),
716            access(&path("src/b.ts"), Operation::Metadata, "success"),
717            // A file it writes and then reads is its own.
718            access(&path("icons/sprite.svg"), Operation::Write, "success"),
719            access(&path("icons/sprite.svg"), Operation::OpenRead, "success"),
720            // Left out wholesale.
721            access("/usr/lib/libc.so", Operation::OpenRead, "success"),
722            access(
723                &path("node_modules/x/index.js"),
724                Operation::OpenRead,
725                "success",
726            ),
727            access(&path(".git/HEAD"), Operation::OpenRead, "success"),
728            access(&path(".qk/state.json"), Operation::OpenRead, "success"),
729            access(&path("dist/out.js"), Operation::OpenRead, "success"),
730            access(&path("src/debug.log"), Operation::OpenRead, "success"),
731            // A file read is not also only present.
732            access(&path("src/a.ts"), Operation::Metadata, "success"),
733        ];
734        let set = PathSet::from_accesses(&accesses, &exclusions, &root);
735        assert_eq!(
736            set,
737            PathSet {
738                reads: ["src/a.ts".into()].into(),
739                listings: ["icons".into()].into(),
740                present: ["src/b.ts".into()].into(),
741                absent: ["config.local".into()].into(),
742                written: ["icons/sprite.svg".into()].into(),
743            }
744        );
745    }
746
747    #[test]
748    fn values_change_with_contents_listings_and_existence() {
749        let temp = tempfile::tempdir().unwrap();
750        let root = temp.path().canonicalize().unwrap();
751        std::fs::create_dir_all(root.join("icons")).unwrap();
752        std::fs::write(root.join("a.txt"), "one").unwrap();
753        std::fs::write(root.join("icons/a.svg"), "").unwrap();
754        std::fs::write(root.join("nx.json"), "{}").unwrap();
755        std::fs::write(
756            root.join("project.json"),
757            r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
758        )
759        .unwrap();
760        let workspace = qk_config::Workspace::load(&root).unwrap();
761        let graph = qk_taskgraph::TaskGraph::build(
762            &workspace,
763            &[qk_taskgraph::Request::parse("app:build").unwrap()],
764        )
765        .unwrap();
766        let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
767        std::fs::write(root.join("checked.txt"), "one").unwrap();
768        let set = PathSet {
769            reads: ["a.txt".into()].into(),
770            listings: ["icons".into()].into(),
771            present: ["checked.txt".into()].into(),
772            absent: ["optional.txt".into()].into(),
773            written: ["icons/sprite.svg".into()].into(),
774        };
775        let outputs = Outputs::from_paths(&["icons/out".into()]).unwrap();
776        let unlisted = Unlisted {
777            own: vec![&outputs],
778            others: None,
779            ignore: &[],
780        };
781        let strong = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
782        let first = strong();
783        // What the task writes in a listed directory, or its outputs there,
784        // and files it never read change nothing.
785        std::fs::write(root.join("icons/sprite.svg"), "").unwrap();
786        std::fs::create_dir(root.join("icons/out")).unwrap();
787        std::fs::write(root.join("b.txt"), "unread").unwrap();
788        assert_eq!(strong(), first);
789        for change in [
790            |root: &Path| std::fs::write(root.join("icons/b.svg"), "").unwrap(),
791            |root: &Path| std::fs::write(root.join("optional.txt"), "").unwrap(),
792            |root: &Path| std::fs::write(root.join("a.txt"), "two").unwrap(),
793            // A file it only looked at, as by its timestamp.
794            |root: &Path| std::fs::write(root.join("checked.txt"), "two").unwrap(),
795        ] {
796            let before = strong();
797            change(&root);
798            assert_ne!(strong(), before);
799        }
800    }
801
802    #[test]
803    fn keeps_the_newest_sets_first() {
804        let temp = tempfile::tempdir().unwrap();
805        std::fs::create_dir(temp.path().join("tmp")).unwrap();
806        let set = |read: &str| PathSet {
807            reads: [read.to_owned()].into(),
808            ..PathSet::default()
809        };
810        for index in 0..10 {
811            save(temp.path(), "weak", set(&format!("{index}"))).unwrap();
812        }
813        save(temp.path(), "weak", set("5")).unwrap();
814        let kept: Vec<String> = load(temp.path(), "weak")
815            .into_iter()
816            .map(|set| set.reads.into_iter().next().unwrap())
817            .collect();
818        assert_eq!(kept, ["5", "9", "8", "7", "6", "4", "3", "2"]);
819        assert!(load(temp.path(), "other").is_empty());
820    }
821
822    #[test]
823    fn merges_the_sets_another_machine_recorded_behind_its_own() {
824        let temp = tempfile::tempdir().unwrap();
825        std::fs::create_dir(temp.path().join("tmp")).unwrap();
826        let set = |read: &str| PathSet {
827            reads: [read.to_owned()].into(),
828            ..PathSet::default()
829        };
830        save(temp.path(), "weak", set("a")).unwrap();
831        let record = |weak: &str, sets: Vec<PathSet>| {
832            serde_json::to_vec(&Stored {
833                version: 1,
834                weak: weak.into(),
835                sets,
836            })
837            .unwrap()
838        };
839        let fetched = record(
840            "weak",
841            vec![set("b"), set("a"), set("../escape"), set("/abs")],
842        );
843        assert_eq!(merge(temp.path(), "weak", &fetched).unwrap(), [set("b")]);
844        assert_eq!(load(temp.path(), "weak"), [set("a"), set("b")]);
845        assert!(merge(temp.path(), "weak", &fetched).unwrap().is_empty());
846        assert!(
847            merge(temp.path(), "weak", &record("other", vec![set("c")]))
848                .unwrap()
849                .is_empty()
850        );
851        let many = (0..10).map(|index| set(&format!("{index}"))).collect();
852        assert_eq!(
853            merge(temp.path(), "weak", &record("weak", many))
854                .unwrap()
855                .len(),
856            6
857        );
858        assert_eq!(load(temp.path(), "weak").len(), KEPT);
859    }
860
861    #[test]
862    fn finds_what_changed_while_the_task_ran() {
863        let temp = tempfile::tempdir().unwrap();
864        for path in ["read.txt", "found.txt", "listed.txt", "before.txt"] {
865            std::fs::write(temp.path().join(path), "before").unwrap();
866        }
867        for directory in ["listed", "written"] {
868            std::fs::create_dir(temp.path().join(directory)).unwrap();
869            std::fs::write(temp.path().join(directory).join("kept.txt"), "").unwrap();
870        }
871        std::fs::create_dir(temp.path().join("listed/sub")).unwrap();
872        let set = PathSet {
873            reads: ["read.txt".to_owned(), "gone.txt".to_owned()].into(),
874            present: ["found.txt".to_owned(), ".".to_owned()].into(),
875            listings: ["listed".to_owned(), "written".to_owned()].into(),
876            written: ["written/new.txt".to_owned()].into(),
877            ..PathSet::default()
878        };
879        std::thread::sleep(std::time::Duration::from_millis(20));
880        let started = now(temp.path());
881        let changed = |set: &PathSet| {
882            set.changed_since(temp.path(), started, &NOTHING, &Before::default())
883                .map(str::to_owned)
884        };
885        assert_eq!(changed(&set), None);
886        // What the task wrote, and content in a listed directory, keys nothing.
887        std::fs::write(temp.path().join("written/new.txt"), "").unwrap();
888        std::fs::write(temp.path().join("listed/kept.txt"), "after").unwrap();
889        std::fs::write(temp.path().join("listed.txt"), "after").unwrap();
890        // Content in a listed directory is not part of the listing.
891        let unlisted = PathSet {
892            listings: ["written".to_owned()].into(),
893            ..set.clone()
894        };
895        assert_eq!(changed(&unlisted), None);
896        std::fs::write(temp.path().join("found.txt"), "after").unwrap();
897        assert_eq!(changed(&set), Some("found.txt".into()));
898        let listings = PathSet {
899            reads: [].into(),
900            present: [].into(),
901            ..set.clone()
902        };
903        // Another task writing inside an entry, or rewriting one in place,
904        // leaves the listing as it was.
905        std::fs::write(temp.path().join("listed/sub/made.txt"), "").unwrap();
906        assert_eq!(changed(&listings), None);
907        // An entry another process adds where the task wrote too.
908        std::fs::write(temp.path().join("written/other.txt"), "").unwrap();
909        if std::fs::metadata(temp.path().join("written/other.txt"))
910            .and_then(|metadata| metadata.created())
911            .is_ok()
912        {
913            assert_eq!(changed(&listings), Some("written".into()));
914        }
915        std::fs::remove_file(temp.path().join("written/other.txt")).unwrap();
916        // A file that was there before, unchanged, moved in where the task
917        // wrote too: a move updates its change time, which Unix keeps.
918        #[cfg(unix)]
919        {
920            std::fs::rename(
921                temp.path().join("before.txt"),
922                temp.path().join("written/moved.txt"),
923            )
924            .unwrap();
925            assert_eq!(changed(&listings), Some("written".into()));
926            std::fs::rename(
927                temp.path().join("written/moved.txt"),
928                temp.path().join("before.txt"),
929            )
930            .unwrap();
931        }
932        // An entry added where the task wrote nothing.
933        std::fs::write(temp.path().join("listed/new.txt"), "").unwrap();
934        assert_eq!(changed(&listings), Some("listed".into()));
935        // A removal, where the task wrote nothing.
936        let removed = tempfile::tempdir().unwrap();
937        std::fs::write(removed.path().join("gone.txt"), "").unwrap();
938        std::thread::sleep(std::time::Duration::from_millis(20));
939        // Taken elsewhere, as qk takes it in its cache: taking it changes the
940        // directory it is taken in.
941        let started = now(temp.path());
942        let root = PathSet {
943            listings: [".".to_owned()].into(),
944            ..PathSet::default()
945        };
946        assert_eq!(
947            root.changed_since(removed.path(), started, &NOTHING, &Before::default()),
948            None
949        );
950        std::fs::remove_file(removed.path().join("gone.txt")).unwrap();
951        assert_eq!(
952            root.changed_since(removed.path(), started, &NOTHING, &Before::default()),
953            Some(".")
954        );
955    }
956
957    #[test]
958    fn listings_leave_out_other_targets_outputs_and_ignored_reads() {
959        let temp = tempfile::tempdir().unwrap();
960        let root = &temp.path().canonicalize().unwrap();
961        std::fs::create_dir_all(root.join("app/src")).unwrap();
962        std::fs::write(root.join("app/src/main.ts"), "").unwrap();
963        std::fs::write(root.join("nx.json"), "{}").unwrap();
964        std::fs::write(
965            root.join("project.json"),
966            r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
967        )
968        .unwrap();
969        let workspace = qk_config::Workspace::load(root).unwrap();
970        let graph = qk_taskgraph::TaskGraph::build(
971            &workspace,
972            &[qk_taskgraph::Request::parse("app:build").unwrap()],
973        )
974        .unwrap();
975        let set = PathSet {
976            listings: ["app".to_owned()].into(),
977            ..PathSet::default()
978        };
979        std::fs::write(root.join("app/kept.txt"), "").unwrap();
980        std::fs::create_dir(root.join("app/out")).unwrap();
981        let outputs = WorkspaceOutputs::new(vec![
982            (
983                ("app".into(), "tsc".into(), None),
984                Outputs::from_patterns(&["app/.tsc"]),
985            ),
986            (
987                ("app".into(), "codegen".into(), None),
988                Outputs::from_patterns(&["app/generated"]),
989            ),
990            // An output a glob declares, inside a directory the task lists.
991            (
992                ("app".into(), "bundle".into(), None),
993                Outputs::from_patterns(&["app/out/*.js"]),
994            ),
995            // Another target declaring the dependency's output too.
996            (
997                ("app".into(), "package".into(), None),
998                Outputs::from_patterns(&["app/generated"]),
999            ),
1000        ]);
1001        let ignore = [Pattern::new("**/.fallow", false).unwrap()];
1002        let unlisted = Unlisted {
1003            own: Vec::new(),
1004            others: Some(Others {
1005                outputs: &outputs,
1006                // It depends on codegen, whose output is its input.
1007                upstream: [
1008                    ("app".into(), "build".into(), None),
1009                    ("app".into(), "codegen".into(), None),
1010                ]
1011                .into(),
1012            }),
1013            ignore: &ignore,
1014        };
1015        assert_eq!(
1016            unlisted.directories("app"),
1017            [
1018                "app".to_owned(),
1019                "app/.tsc".to_owned(),
1020                "app/generated".to_owned(),
1021                "app/out".to_owned()
1022            ]
1023            .into()
1024        );
1025        let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
1026        let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
1027        let before = key();
1028        std::thread::sleep(std::time::Duration::from_millis(20));
1029        let taken = Before::take(root, unlisted.directories("app"), &unlisted);
1030        let started = now(root);
1031        let changed = |taken: &Before| set.changed_since(root, started, &unlisted, taken);
1032        // Another target's output and an ignored path appear as it runs.
1033        std::fs::create_dir(root.join("app/.tsc")).unwrap();
1034        std::fs::create_dir(root.join("app/.fallow")).unwrap();
1035        assert_eq!(key(), before);
1036        assert_eq!(changed(&taken), None);
1037        // An output appearing in a listed directory a glob declares it in.
1038        std::fs::write(root.join("app/out/new.js"), "").unwrap();
1039        let out = PathSet {
1040            listings: ["app/out".to_owned()].into(),
1041            ..PathSet::default()
1042        };
1043        assert_eq!(out.changed_since(root, started, &unlisted, &taken), None);
1044        assert_eq!(
1045            out.changed_since(root, started, &unlisted, &Before::default()),
1046            Some("app/out")
1047        );
1048        // A dependency's output is the task's input, and stays listed.
1049        assert!(!unlisted.matches("app/generated"));
1050        assert!(unlisted.matches("app/.tsc/index.js"));
1051        // Without the entries from before, the directory's time is all there
1052        // is, and it moved.
1053        assert_eq!(changed(&Before::default()), Some("app"));
1054        // A removal is still a change, though left-out entries are there.
1055        std::fs::remove_file(root.join("app/kept.txt")).unwrap();
1056        assert_ne!(key(), before);
1057        assert_eq!(changed(&taken), Some("app"));
1058        std::fs::write(root.join("app/kept.txt"), "").unwrap();
1059        assert_eq!(changed(&taken), None);
1060        // So is anything else added.
1061        std::fs::write(root.join("app/README.md"), "").unwrap();
1062        assert_ne!(key(), before);
1063        assert_eq!(changed(&taken), Some("app"));
1064    }
1065}