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/// What a recording's listings leave out of a directory's entries: what is
397/// there after a task runs whether or not it read anything, rather than what
398/// it read. The task's own outputs, those of every other target of its
399/// project, such as a build's output beside a lint's sources, and what its
400/// `ignoreReads` names. Only listings leave them out: a file among them that
401/// the task read is still keyed by its content.
402pub(crate) struct Unlisted<'a> {
403    pub own: Vec<&'a Outputs>,
404    pub siblings: &'a [Outputs],
405    pub ignore: &'a [Pattern],
406}
407
408impl Unlisted<'_> {
409    fn matches(&self, path: &str) -> bool {
410        self.own(path)
411            || self.siblings.iter().any(|outputs| outputs.matches(path))
412            || self.ignore.iter().any(|pattern| pattern.is_match(path))
413    }
414
415    fn own(&self, path: &str) -> bool {
416        self.own.iter().any(|outputs| outputs.matches(path))
417    }
418
419    /// Where other targets' outputs and ignored paths come and go while the
420    /// task runs: the directories holding those outputs, and the root of the
421    /// task's project, where a tool's `ignoreReads` state usually sits.
422    pub(crate) fn directories(&self, project_root: &str) -> BTreeSet<String> {
423        let project_root = if project_root.is_empty() {
424            "."
425        } else {
426            project_root
427        };
428        self.siblings
429            .iter()
430            .flat_map(Outputs::anchors)
431            .map(|anchor| parent(anchor).to_owned())
432            .chain([project_root.to_owned()])
433            .collect()
434    }
435}
436
437/// The entries of directories before a task ran, but those its listings
438/// leave out, so a listing whose directory gained or lost something else as
439/// it ran is told apart from one where only those came and went.
440#[derive(Default)]
441pub(crate) struct Before(BTreeMap<String, BTreeSet<(String, &'static str)>>);
442
443impl Before {
444    pub(crate) fn take(root: &Path, directories: BTreeSet<String>, unlisted: &Unlisted) -> Self {
445        Self(
446            directories
447                .into_iter()
448                .filter_map(|directory| {
449                    let entries = entries(root, &directory, |path| unlisted.matches(path))?;
450                    Some((directory, entries))
451                })
452                .collect(),
453        )
454    }
455}
456
457/// The path sets kept for weak keys, in `pathsets/` beside the entries.
458#[derive(Serialize, Deserialize)]
459struct Stored {
460    version: u32,
461    weak: String,
462    sets: Vec<PathSet>,
463}
464
465fn location(cache: &Path, weak: &str) -> PathBuf {
466    cache.join("pathsets").join(format!("{weak}.json"))
467}
468
469/// The path sets kept for `weak`, newest first; none when there are none or
470/// they cannot be read.
471pub(crate) fn load(cache: &Path, weak: &str) -> Vec<PathSet> {
472    std::fs::read(location(cache, weak))
473        .ok()
474        .and_then(|bytes| serde_json::from_slice::<Stored>(&bytes).ok())
475        .filter(|stored| stored.version == 1 && stored.weak == weak)
476        .map(|stored| stored.sets)
477        .unwrap_or_default()
478}
479
480/// Marks `weak`'s path sets as used, so eviction keeps them as it keeps the
481/// entries they lead to.
482pub(crate) fn touch(cache: &Path, weak: &str) {
483    if let Ok(file) = std::fs::File::options()
484        .append(true)
485        .open(location(cache, weak))
486    {
487        let _ = file.set_modified(std::time::SystemTime::now());
488    }
489}
490
491/// Keeps `set` as the newest for `weak`, and at most [`KEPT`] in all.
492pub(crate) fn save(cache: &Path, weak: &str, set: PathSet) -> Result<()> {
493    let mut sets = load(cache, weak);
494    sets.retain(|kept| kept != &set);
495    sets.insert(0, set);
496    keep(cache, weak, sets)
497}
498
499/// The record of `weak`'s path sets as the local cache keeps them, for the
500/// remote store.
501pub(crate) fn record(cache: &Path, weak: &str) -> Option<Vec<u8>> {
502    std::fs::read(location(cache, weak)).ok()
503}
504
505/// Adds the path sets in a record the remote store held for `weak` behind
506/// those kept locally, returning the ones that are new here. A record for
507/// another weak key or another version adds nothing, and neither does a set
508/// naming a path outside the workspace.
509pub(crate) fn merge(cache: &Path, weak: &str, record: &[u8]) -> Result<Vec<PathSet>> {
510    let Some(fetched) = serde_json::from_slice::<Stored>(record)
511        .ok()
512        .filter(|stored| stored.version == 1 && stored.weak == weak)
513    else {
514        return Ok(Vec::new());
515    };
516    let mut sets = load(cache, weak);
517    let mut added = Vec::new();
518    for set in fetched.sets {
519        if sets.len() + added.len() < KEPT
520            && set.is_relative()
521            && !set.is_empty()
522            && !sets.contains(&set)
523            && !added.contains(&set)
524        {
525            added.push(set);
526        }
527    }
528    if !added.is_empty() {
529        sets.extend(added.iter().cloned());
530        keep(cache, weak, sets)?;
531    }
532    Ok(added)
533}
534
535fn keep(cache: &Path, weak: &str, mut sets: Vec<PathSet>) -> Result<()> {
536    sets.truncate(KEPT);
537    std::fs::create_dir_all(cache.join("pathsets"))?;
538    crate::record::publish(
539        cache,
540        &location(cache, weak),
541        &Stored {
542            version: 1,
543            weak: weak.to_owned(),
544            sets,
545        },
546    )
547}
548
549/// The key of a result for `set` under `weak`, from how its paths are now.
550pub(crate) fn strong_key(
551    weak: &str,
552    set: &PathSet,
553    snapshot: &Snapshot,
554    root: &Path,
555    unlisted: &Unlisted,
556) -> Result<String> {
557    key(&json!({
558        "schema": "qk-recorded-v1",
559        "weak": weak,
560        "set": set.digest()?,
561        "values": set.values(snapshot, root, unlisted)?,
562    }))
563}
564
565/// Now, as the file system in `directory` stamps a file it writes. The kernel
566/// stamps files from a clock coarser than the system's, so a file written
567/// just after a [`SystemTime::now`] can bear an earlier time; this one is
568/// never later than the stamp of a file written after it.
569pub(crate) fn now(directory: &Path) -> SystemTime {
570    tempfile::NamedTempFile::new_in(directory)
571        .and_then(|file| file.as_file().metadata())
572        .map(|metadata| changed(&metadata))
573        .unwrap_or_else(|_| SystemTime::now())
574}
575
576/// When a path's content or metadata last changed: its ctime where there is
577/// one, which a tool resetting the modification time does not hide.
578fn changed(metadata: &std::fs::Metadata) -> SystemTime {
579    let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
580    #[cfg(unix)]
581    {
582        use std::os::unix::fs::MetadataExt;
583        let ctime = u64::try_from(metadata.ctime()).ok().map(|seconds| {
584            SystemTime::UNIX_EPOCH
585                + std::time::Duration::new(
586                    seconds,
587                    u32::try_from(metadata.ctime_nsec()).unwrap_or(0),
588                )
589        });
590        if let Some(ctime) = ctime {
591            return modified.max(ctime);
592        }
593    }
594    modified
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    /// Nothing left out of listings.
602    const NOTHING: Unlisted<'static> = Unlisted {
603        own: Vec::new(),
604        siblings: &[],
605        ignore: &[],
606    };
607    use qk_input_analysis::Category;
608
609    fn access(path: &str, operation: Operation, result: &str) -> Access {
610        Access {
611            process: 1,
612            path: path.into(),
613            resolved_path: None,
614            keyed_path: None,
615            package: None,
616            operation,
617            result: result.into(),
618            category: Category::Uncovered,
619        }
620    }
621
622    #[test]
623    fn keeps_what_the_task_read_listed_and_looked_up() {
624        let temp = tempfile::tempdir().unwrap();
625        let root = temp.path().canonicalize().unwrap();
626        for directory in ["src", "icons", "dist"] {
627            std::fs::create_dir(root.join(directory)).unwrap();
628        }
629        let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
630        let outputs = Outputs::from_paths(&["dist".into()]).unwrap();
631        let exclusions = Exclusions {
632            roots: [&root, &root],
633            outputs: &[&outputs],
634            ignore: vec![Pattern::new("src/*.log", false).unwrap()],
635            state: vec![".qk".into()],
636        };
637        let accesses = [
638            access(&path("src/a.ts"), Operation::OpenRead, "success"),
639            access(&path("src"), Operation::OpenRead, "success"),
640            access(&path("icons"), Operation::ListDirectory, "success"),
641            access(&path("config.local"), Operation::Metadata, "missing"),
642            access(&path("src/b.ts"), Operation::Metadata, "success"),
643            // A file it writes and then reads is its own.
644            access(&path("icons/sprite.svg"), Operation::Write, "success"),
645            access(&path("icons/sprite.svg"), Operation::OpenRead, "success"),
646            // Left out wholesale.
647            access("/usr/lib/libc.so", Operation::OpenRead, "success"),
648            access(
649                &path("node_modules/x/index.js"),
650                Operation::OpenRead,
651                "success",
652            ),
653            access(&path(".git/HEAD"), Operation::OpenRead, "success"),
654            access(&path(".qk/state.json"), Operation::OpenRead, "success"),
655            access(&path("dist/out.js"), Operation::OpenRead, "success"),
656            access(&path("src/debug.log"), Operation::OpenRead, "success"),
657            // A file read is not also only present.
658            access(&path("src/a.ts"), Operation::Metadata, "success"),
659        ];
660        let set = PathSet::from_accesses(&accesses, &exclusions, &root);
661        assert_eq!(
662            set,
663            PathSet {
664                reads: ["src/a.ts".into()].into(),
665                listings: ["icons".into()].into(),
666                present: ["src/b.ts".into()].into(),
667                absent: ["config.local".into()].into(),
668                written: ["icons/sprite.svg".into()].into(),
669            }
670        );
671    }
672
673    #[test]
674    fn values_change_with_contents_listings_and_existence() {
675        let temp = tempfile::tempdir().unwrap();
676        let root = temp.path().canonicalize().unwrap();
677        std::fs::create_dir_all(root.join("icons")).unwrap();
678        std::fs::write(root.join("a.txt"), "one").unwrap();
679        std::fs::write(root.join("icons/a.svg"), "").unwrap();
680        std::fs::write(root.join("nx.json"), "{}").unwrap();
681        std::fs::write(
682            root.join("project.json"),
683            r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
684        )
685        .unwrap();
686        let workspace = qk_config::Workspace::load(&root).unwrap();
687        let graph = qk_taskgraph::TaskGraph::build(
688            &workspace,
689            &[qk_taskgraph::Request::parse("app:build").unwrap()],
690        )
691        .unwrap();
692        let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
693        std::fs::write(root.join("checked.txt"), "one").unwrap();
694        let set = PathSet {
695            reads: ["a.txt".into()].into(),
696            listings: ["icons".into()].into(),
697            present: ["checked.txt".into()].into(),
698            absent: ["optional.txt".into()].into(),
699            written: ["icons/sprite.svg".into()].into(),
700        };
701        let outputs = Outputs::from_paths(&["icons/out".into()]).unwrap();
702        let unlisted = Unlisted {
703            own: vec![&outputs],
704            siblings: &[],
705            ignore: &[],
706        };
707        let strong = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
708        let first = strong();
709        // What the task writes in a listed directory, or its outputs there,
710        // and files it never read change nothing.
711        std::fs::write(root.join("icons/sprite.svg"), "").unwrap();
712        std::fs::create_dir(root.join("icons/out")).unwrap();
713        std::fs::write(root.join("b.txt"), "unread").unwrap();
714        assert_eq!(strong(), first);
715        for change in [
716            |root: &Path| std::fs::write(root.join("icons/b.svg"), "").unwrap(),
717            |root: &Path| std::fs::write(root.join("optional.txt"), "").unwrap(),
718            |root: &Path| std::fs::write(root.join("a.txt"), "two").unwrap(),
719            // A file it only looked at, as by its timestamp.
720            |root: &Path| std::fs::write(root.join("checked.txt"), "two").unwrap(),
721        ] {
722            let before = strong();
723            change(&root);
724            assert_ne!(strong(), before);
725        }
726    }
727
728    #[test]
729    fn keeps_the_newest_sets_first() {
730        let temp = tempfile::tempdir().unwrap();
731        std::fs::create_dir(temp.path().join("tmp")).unwrap();
732        let set = |read: &str| PathSet {
733            reads: [read.to_owned()].into(),
734            ..PathSet::default()
735        };
736        for index in 0..10 {
737            save(temp.path(), "weak", set(&format!("{index}"))).unwrap();
738        }
739        save(temp.path(), "weak", set("5")).unwrap();
740        let kept: Vec<String> = load(temp.path(), "weak")
741            .into_iter()
742            .map(|set| set.reads.into_iter().next().unwrap())
743            .collect();
744        assert_eq!(kept, ["5", "9", "8", "7", "6", "4", "3", "2"]);
745        assert!(load(temp.path(), "other").is_empty());
746    }
747
748    #[test]
749    fn merges_the_sets_another_machine_recorded_behind_its_own() {
750        let temp = tempfile::tempdir().unwrap();
751        std::fs::create_dir(temp.path().join("tmp")).unwrap();
752        let set = |read: &str| PathSet {
753            reads: [read.to_owned()].into(),
754            ..PathSet::default()
755        };
756        save(temp.path(), "weak", set("a")).unwrap();
757        let record = |weak: &str, sets: Vec<PathSet>| {
758            serde_json::to_vec(&Stored {
759                version: 1,
760                weak: weak.into(),
761                sets,
762            })
763            .unwrap()
764        };
765        let fetched = record(
766            "weak",
767            vec![set("b"), set("a"), set("../escape"), set("/abs")],
768        );
769        assert_eq!(merge(temp.path(), "weak", &fetched).unwrap(), [set("b")]);
770        assert_eq!(load(temp.path(), "weak"), [set("a"), set("b")]);
771        assert!(merge(temp.path(), "weak", &fetched).unwrap().is_empty());
772        assert!(
773            merge(temp.path(), "weak", &record("other", vec![set("c")]))
774                .unwrap()
775                .is_empty()
776        );
777        let many = (0..10).map(|index| set(&format!("{index}"))).collect();
778        assert_eq!(
779            merge(temp.path(), "weak", &record("weak", many))
780                .unwrap()
781                .len(),
782            6
783        );
784        assert_eq!(load(temp.path(), "weak").len(), KEPT);
785    }
786
787    #[test]
788    fn finds_what_changed_while_the_task_ran() {
789        let temp = tempfile::tempdir().unwrap();
790        for path in ["read.txt", "found.txt", "listed.txt", "before.txt"] {
791            std::fs::write(temp.path().join(path), "before").unwrap();
792        }
793        for directory in ["listed", "written"] {
794            std::fs::create_dir(temp.path().join(directory)).unwrap();
795            std::fs::write(temp.path().join(directory).join("kept.txt"), "").unwrap();
796        }
797        std::fs::create_dir(temp.path().join("listed/sub")).unwrap();
798        let set = PathSet {
799            reads: ["read.txt".to_owned(), "gone.txt".to_owned()].into(),
800            present: ["found.txt".to_owned(), ".".to_owned()].into(),
801            listings: ["listed".to_owned(), "written".to_owned()].into(),
802            written: ["written/new.txt".to_owned()].into(),
803            ..PathSet::default()
804        };
805        std::thread::sleep(std::time::Duration::from_millis(20));
806        let started = now(temp.path());
807        let changed = |set: &PathSet| {
808            set.changed_since(temp.path(), started, &NOTHING, &Before::default())
809                .map(str::to_owned)
810        };
811        assert_eq!(changed(&set), None);
812        // What the task wrote, and content in a listed directory, keys nothing.
813        std::fs::write(temp.path().join("written/new.txt"), "").unwrap();
814        std::fs::write(temp.path().join("listed/kept.txt"), "after").unwrap();
815        std::fs::write(temp.path().join("listed.txt"), "after").unwrap();
816        // Content in a listed directory is not part of the listing.
817        let unlisted = PathSet {
818            listings: ["written".to_owned()].into(),
819            ..set.clone()
820        };
821        assert_eq!(changed(&unlisted), None);
822        std::fs::write(temp.path().join("found.txt"), "after").unwrap();
823        assert_eq!(changed(&set), Some("found.txt".into()));
824        let listings = PathSet {
825            reads: [].into(),
826            present: [].into(),
827            ..set.clone()
828        };
829        // Another task writing inside an entry, or rewriting one in place,
830        // leaves the listing as it was.
831        std::fs::write(temp.path().join("listed/sub/made.txt"), "").unwrap();
832        assert_eq!(changed(&listings), None);
833        // An entry another process adds where the task wrote too.
834        std::fs::write(temp.path().join("written/other.txt"), "").unwrap();
835        if std::fs::metadata(temp.path().join("written/other.txt"))
836            .and_then(|metadata| metadata.created())
837            .is_ok()
838        {
839            assert_eq!(changed(&listings), Some("written".into()));
840        }
841        std::fs::remove_file(temp.path().join("written/other.txt")).unwrap();
842        // A file that was there before, unchanged, moved in where the task
843        // wrote too: a move updates its change time, which Unix keeps.
844        #[cfg(unix)]
845        {
846            std::fs::rename(
847                temp.path().join("before.txt"),
848                temp.path().join("written/moved.txt"),
849            )
850            .unwrap();
851            assert_eq!(changed(&listings), Some("written".into()));
852            std::fs::rename(
853                temp.path().join("written/moved.txt"),
854                temp.path().join("before.txt"),
855            )
856            .unwrap();
857        }
858        // An entry added where the task wrote nothing.
859        std::fs::write(temp.path().join("listed/new.txt"), "").unwrap();
860        assert_eq!(changed(&listings), Some("listed".into()));
861        // A removal, where the task wrote nothing.
862        let removed = tempfile::tempdir().unwrap();
863        std::fs::write(removed.path().join("gone.txt"), "").unwrap();
864        std::thread::sleep(std::time::Duration::from_millis(20));
865        // Taken elsewhere, as qk takes it in its cache: taking it changes the
866        // directory it is taken in.
867        let started = now(temp.path());
868        let root = PathSet {
869            listings: [".".to_owned()].into(),
870            ..PathSet::default()
871        };
872        assert_eq!(
873            root.changed_since(removed.path(), started, &NOTHING, &Before::default()),
874            None
875        );
876        std::fs::remove_file(removed.path().join("gone.txt")).unwrap();
877        assert_eq!(
878            root.changed_since(removed.path(), started, &NOTHING, &Before::default()),
879            Some(".")
880        );
881    }
882
883    #[test]
884    fn listings_leave_out_other_targets_outputs_and_ignored_reads() {
885        let temp = tempfile::tempdir().unwrap();
886        let root = &temp.path().canonicalize().unwrap();
887        std::fs::create_dir_all(root.join("app/src")).unwrap();
888        std::fs::write(root.join("app/src/main.ts"), "").unwrap();
889        std::fs::write(root.join("nx.json"), "{}").unwrap();
890        std::fs::write(
891            root.join("project.json"),
892            r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
893        )
894        .unwrap();
895        let workspace = qk_config::Workspace::load(root).unwrap();
896        let graph = qk_taskgraph::TaskGraph::build(
897            &workspace,
898            &[qk_taskgraph::Request::parse("app:build").unwrap()],
899        )
900        .unwrap();
901        let set = PathSet {
902            listings: ["app".to_owned()].into(),
903            ..PathSet::default()
904        };
905        std::fs::write(root.join("app/kept.txt"), "").unwrap();
906        let build = [Outputs::from_patterns(&["app/.syb-ts"])];
907        let ignore = [Pattern::new("**/.fallow", false).unwrap()];
908        let unlisted = Unlisted {
909            own: Vec::new(),
910            siblings: &build,
911            ignore: &ignore,
912        };
913        assert_eq!(unlisted.directories("app"), ["app".to_owned()].into());
914        let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
915        let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
916        let before = key();
917        std::thread::sleep(std::time::Duration::from_millis(20));
918        let taken = Before::take(root, unlisted.directories("app"), &unlisted);
919        let started = now(root);
920        let changed = |taken: &Before| set.changed_since(root, started, &unlisted, taken);
921        // Another target's output and an ignored path appear as it runs.
922        std::fs::create_dir(root.join("app/.syb-ts")).unwrap();
923        std::fs::create_dir(root.join("app/.fallow")).unwrap();
924        assert_eq!(key(), before);
925        assert_eq!(changed(&taken), None);
926        // Without the entries from before, the directory's time is all there
927        // is, and it moved.
928        assert_eq!(changed(&Before::default()), Some("app"));
929        // A removal is still a change, though left-out entries are there.
930        std::fs::remove_file(root.join("app/kept.txt")).unwrap();
931        assert_ne!(key(), before);
932        assert_eq!(changed(&taken), Some("app"));
933        std::fs::write(root.join("app/kept.txt"), "").unwrap();
934        assert_eq!(changed(&taken), None);
935        // So is anything else added.
936        std::fs::write(root.join("app/README.md"), "").unwrap();
937        assert_ne!(key(), before);
938        assert_eq!(changed(&taken), Some("app"));
939    }
940}