Skip to main content

qk_cache/
ultracache.rs

1//! `qk:ultracache`: key a task by what it read when it last ran, rather
2//! than by its declared file inputs.
3//!
4//! A run with `--record` traces each opted-in task it executes and keeps a
5//! path set: the files the task read, the directories it listed and the
6//! paths it looked up. A later run keys the task by how those paths are now,
7//! so a change to a file the task never read is still a hit, and a new file
8//! in a directory it listed is a miss. See [`crate::recorded`].
9//!
10//! - `mode`: `"on"`, the default once the field is set, `"record"` or
11//!   `"off"`. With `"record"`, a task is recorded and its recordings kept and
12//!   shared as with `"on"`, but it is keyed by its declared inputs, and the
13//!   run reports what its recordings would have done. Nx's `"warn"` and
14//!   `"error"` are not supported yet.
15//! - `ignoreReads`: workspace globs left out of the path set, for files whose
16//!   changes cannot change the result. One starting `^` names files in the
17//!   projects the task's project depends on, directly or not: see
18//!   [`IgnoredReads`].
19
20use std::collections::BTreeSet;
21use std::path::Path;
22
23use anyhow::{Context, Result, bail};
24use qk_config::Workspace;
25use qk_graph::ProjectGraph;
26use qk_taskgraph::Task;
27use serde_json::Value;
28
29use crate::glob::Pattern;
30
31/// The fields a `qk:ultracache` object can set. Others are ignored, as Nx
32/// ignores keys it does not know; `qk doctor` reports them.
33pub const FIELDS: &[&str] = &["mode", "ignoreReads"];
34
35#[derive(Clone, Debug, Default, PartialEq, Eq)]
36pub struct Settings {
37    pub mode: Mode,
38    pub ignore_reads: Vec<String>,
39}
40
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42pub enum Mode {
43    /// Keyed by its recordings once it has any.
44    #[default]
45    On,
46    /// Recorded, but keyed by its declared inputs.
47    Record,
48}
49
50impl Settings {
51    /// The target's settings when it is recorded, `None` when it is not:
52    /// without the field, or with `mode` `"off"`.
53    pub fn of(task: &Task) -> Result<Option<Self>> {
54        let Some(value) = task.definition.extra.get("qk:ultracache") else {
55            return Ok(None);
56        };
57        let Value::Object(object) = value else {
58            bail!("qk:ultracache must be an object");
59        };
60        let mode = match object.get("mode").map(Value::as_str) {
61            None | Some(Some("on")) => Mode::On,
62            Some(Some("record")) => Mode::Record,
63            Some(Some("off")) => return Ok(None),
64            Some(Some(mode @ ("warn" | "error"))) => {
65                bail!(
66                    "qk:ultracache.mode {mode:?} is not supported yet; use \"on\", \"record\" or \"off\""
67                )
68            }
69            Some(_) => bail!("qk:ultracache.mode must be \"on\", \"record\" or \"off\""),
70        };
71        let ignore_reads = ignored_reads(task)?;
72        Ok(Some(Self { mode, ignore_reads }))
73    }
74}
75
76/// The target's `qk:ultracache.ignoreReads`, whatever its mode: the reads
77/// that change nothing the task does, which the sandbox leaves out of its
78/// findings and allows whether or not the task is recorded.
79pub fn ignored_reads(task: &Task) -> Result<Vec<String>> {
80    let Some(Value::Object(object)) = task.definition.extra.get("qk:ultracache") else {
81        return Ok(Vec::new());
82    };
83    let ignore_reads: Vec<String> = match object.get("ignoreReads") {
84        None => Vec::new(),
85        Some(value) => serde_json::from_value(value.clone())
86            .context("qk:ultracache.ignoreReads must be an array of globs")?,
87    };
88    // Checked before the task runs, rather than after it was traced.
89    for glob in &ignore_reads {
90        Pattern::new(glob.strip_prefix('^').unwrap_or(glob), false)
91            .with_context(|| format!("qk:ultracache.ignoreReads has an invalid glob {glob:?}"))?;
92    }
93    Ok(ignore_reads)
94}
95
96/// What a task's `ignoreReads` leaves out, matched against workspace-relative
97/// paths written with `/`.
98///
99/// An entry is a workspace glob, or, starting `^`, a glob within the projects
100/// the task's project depends on, directly or not: it matches a path whose
101/// innermost project is one of them, the glob matched against the path from
102/// that project's root. A file of the task's own project, or of a project
103/// nested in a dependency that it does not depend on, is never matched so.
104/// What a tool reads following imports into a dependency, as an import cycle
105/// rule does, is named that way without naming the task's own files.
106#[derive(Clone, Default)]
107pub struct IgnoredReads {
108    plain: Vec<(String, Pattern)>,
109    dependency: Vec<(String, Pattern)>,
110    /// Matched as plain entries, but not walked: the task's scratch
111    /// directories, which its recordings leave out too.
112    extra: Vec<Pattern>,
113    /// Every project's root, the deepest first, and whether the task's
114    /// project depends on it; only kept with a `^` entry.
115    owners: Vec<(String, bool)>,
116}
117
118impl IgnoredReads {
119    /// `written` as [`ignored_reads`] returns it, for a task of `project`.
120    /// Without `projects`, `^` entries match nothing.
121    pub fn new(
122        written: &[String],
123        workspace: &Workspace,
124        projects: Option<&ProjectGraph>,
125        project: &str,
126    ) -> Result<Self> {
127        let mut ignored = Self::of_globs(written)?;
128        if ignored.dependency.is_empty() {
129            return Ok(ignored);
130        }
131        let depended = projects
132            .map(|projects| depended(projects, project))
133            .unwrap_or_default();
134        ignored.owners = workspace
135            .projects
136            .iter()
137            .map(|(name, config)| (root_of(&config.root), depended.contains(name.as_str())))
138            .collect();
139        // Deepest first; of two projects written with the same root, as `a`
140        // and `./a`, the one not depended on, so neither is ignored, as the
141        // sandbox's report counts neither as a dependency's.
142        ignored.owners.sort_by(|a, b| {
143            b.0.len()
144                .cmp(&a.0.len())
145                .then_with(|| a.0.cmp(&b.0))
146                .then_with(|| a.1.cmp(&b.1))
147        });
148        Ok(ignored)
149    }
150
151    /// Nothing left out.
152    pub const fn none() -> Self {
153        Self {
154            plain: Vec::new(),
155            dependency: Vec::new(),
156            extra: Vec::new(),
157            owners: Vec::new(),
158        }
159    }
160
161    /// Workspace globs alone, as a task outside any project graph has them:
162    /// `^` entries match nothing.
163    pub fn of_globs(written: &[String]) -> Result<Self> {
164        let mut ignored = Self::default();
165        for glob in written {
166            match glob.strip_prefix('^') {
167                Some(within) => ignored
168                    .dependency
169                    .push((within.to_owned(), Pattern::new(within, false)?)),
170                None => ignored
171                    .plain
172                    .push((glob.clone(), Pattern::new(glob, false)?)),
173            }
174        }
175        Ok(ignored)
176    }
177
178    /// These, and `extra` matched as plain entries are.
179    pub fn with_patterns(mut self, extra: impl IntoIterator<Item = Pattern>) -> Self {
180        self.extra.extend(extra);
181        self
182    }
183
184    pub fn is_empty(&self) -> bool {
185        self.plain.is_empty() && self.dependency.is_empty() && self.extra.is_empty()
186    }
187
188    pub fn is_match(&self, path: impl AsRef<Path>) -> bool {
189        let path = path.as_ref();
190        if self.plain.iter().any(|(_, glob)| glob.is_match(path))
191            || self.extra.iter().any(|glob| glob.is_match(path))
192        {
193            return true;
194        }
195        if self.dependency.is_empty() {
196            return false;
197        }
198        let Some(text) = path.to_str() else {
199            return false;
200        };
201        match self.owner(text) {
202            Some((root, true)) => {
203                let within = within(root, text);
204                self.dependency
205                    .iter()
206                    .any(|(_, glob)| glob.is_match(within))
207            }
208            _ => false,
209        }
210    }
211
212    /// The globs a walk for what this matches starts from, as workspace
213    /// globs: the plain entries, and each `^` entry under the root of each
214    /// project it can match in.
215    pub fn walks(&self) -> (Vec<String>, Vec<String>) {
216        let plain = self.plain.iter().map(|(glob, _)| glob.clone()).collect();
217        let mut dependency = Vec::new();
218        for (root, depended) in &self.owners {
219            if !depended {
220                continue;
221            }
222            for (glob, _) in &self.dependency {
223                dependency.push(if root.is_empty() {
224                    glob.clone()
225                } else {
226                    format!("{}/{glob}", crate::glob::escape(root))
227                });
228            }
229        }
230        (plain, dependency)
231    }
232
233    /// Whether a project has its root strictly inside `directory`: a `^`
234    /// entry then does not match all that `directory` holds, since it
235    /// matches nothing in a project not depended on and matches from its own
236    /// root in one that is.
237    pub fn project_within(&self, directory: &str) -> bool {
238        self.owners.iter().any(|(root, _)| {
239            root.len() > directory.len()
240                && (directory.is_empty()
241                    || root
242                        .strip_prefix(directory)
243                        .is_some_and(|rest| rest.starts_with('/')))
244        })
245    }
246
247    /// The roots of the projects `^` entries match in, and of those nested
248    /// in them, or written with the same root, that they do not, each with
249    /// whether it is depended on, for keys that change with what the entries
250    /// match.
251    pub fn scope(&self) -> Vec<(&str, bool)> {
252        self.owners
253            .iter()
254            .filter(|(root, depended)| {
255                *depended
256                    || self.owners.iter().any(|(outer, outer_depended)| {
257                        *outer_depended
258                            && (root == outer
259                                || outer.is_empty()
260                                || root
261                                    .strip_prefix(outer.as_str())
262                                    .is_some_and(|rest| rest.starts_with('/')))
263                    })
264            })
265            .map(|(root, depended)| (root.as_str(), *depended))
266            .collect()
267    }
268
269    /// The innermost project holding `path`, with whether it is depended on.
270    fn owner(&self, path: &str) -> Option<(&str, bool)> {
271        self.owners
272            .iter()
273            .find(|(root, _)| root.is_empty() || within(root, path) != path)
274            .map(|(root, depended)| (root.as_str(), *depended))
275    }
276}
277
278/// `path` from `root`, or `path` itself when `root` does not hold it.
279fn within<'a>(root: &str, path: &'a str) -> &'a str {
280    if root.is_empty() {
281        return path;
282    }
283    path.strip_prefix(root)
284        .and_then(|rest| rest.strip_prefix('/'))
285        .unwrap_or(path)
286}
287
288/// A project root as paths are named: `./libs/lib/` is `libs/lib`, and the
289/// workspace's root project is empty.
290fn root_of(root: &str) -> String {
291    root.split('/')
292        .filter(|part| !part.is_empty() && *part != ".")
293        .collect::<Vec<_>>()
294        .join("/")
295}
296
297/// The projects `project` depends on, directly or not, itself left out.
298fn depended(projects: &ProjectGraph, project: &str) -> BTreeSet<String> {
299    let mut seen = BTreeSet::new();
300    let mut pending = vec![project];
301    while let Some(next) = pending.pop() {
302        for dependency in projects.dependencies.get(next).into_iter().flatten() {
303            if dependency.target != project && seen.insert(dependency.target.clone()) {
304                pending.push(&dependency.target);
305            }
306        }
307    }
308    seen
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn task(value: Value) -> Task {
316        let mut definition: qk_config::Target =
317            serde_json::from_value(serde_json::json!({})).unwrap();
318        definition.extra.insert("qk:ultracache".into(), value);
319        Task {
320            id: "app:build".into(),
321            project: "app".into(),
322            target: "build".into(),
323            configuration: None,
324            args: Vec::new(),
325            definition,
326            dependencies: Default::default(),
327            edges: Default::default(),
328        }
329    }
330
331    /// `app` at `apps/app` depends on `lib`, which holds `nested`, which it
332    /// does not, and on `outer`, whose root holds `app` itself.
333    fn workspace() -> (tempfile::TempDir, Workspace, ProjectGraph) {
334        let temp = tempfile::tempdir().unwrap();
335        let root = temp.path();
336        std::fs::write(root.join("nx.json"), "{}").unwrap();
337        for (directory, name, depends) in [
338            ("apps/app", "app", vec!["lib", "outer", "inner"]),
339            ("apps", "outer", vec![]),
340            ("packages/lib", "lib", vec![]),
341            ("packages/lib/nested", "nested", vec![]),
342            ("packages/lib/src/inner", "inner", vec![]),
343            ("packages/other", "other", vec![]),
344        ] {
345            std::fs::create_dir_all(root.join(directory)).unwrap();
346            std::fs::write(
347                root.join(directory).join("project.json"),
348                serde_json::json!({"name": name, "implicitDependencies": depends}).to_string(),
349            )
350            .unwrap();
351        }
352        let workspace = Workspace::load(root).unwrap();
353        let projects = ProjectGraph::build(&workspace).unwrap();
354        (temp, workspace, projects)
355    }
356
357    #[test]
358    fn a_dependency_entry_matches_only_the_projects_depended_on() {
359        let (_temp, workspace, projects) = workspace();
360        let written = ["^**/*.ts".to_owned(), "tmp/**".to_owned()];
361        let ignored = IgnoredReads::new(&written, &workspace, Some(&projects), "app").unwrap();
362        for (path, matched) in [
363            ("packages/lib/src/a.ts", true),
364            ("packages/lib/src/a.json", false),
365            // A project nested in a dependency that is not one itself
366            ("packages/lib/nested/src/a.ts", false),
367            ("packages/other/a.ts", false),
368            // Its own files, though a dependency's root holds them
369            ("apps/app/src/a.ts", false),
370            ("apps/z.ts", true),
371            ("tmp/x", true),
372        ] {
373            assert_eq!(ignored.is_match(path), matched, "{path}");
374        }
375        let (plain, dependency) = ignored.walks();
376        assert_eq!(plain, ["tmp/**"]);
377        assert_eq!(
378            dependency,
379            [
380                "packages/lib/src/inner/**/*.ts",
381                "packages/lib/**/*.ts",
382                "apps/**/*.ts"
383            ]
384        );
385        assert!(ignored.project_within("packages/lib"));
386        // A dependency nested in one, whose files `^` entries match from its
387        // own root
388        assert!(ignored.project_within("packages/lib/src"));
389        assert!(!ignored.project_within("packages/lib/src/inner"));
390        assert!(!ignored.project_within("packages/lib/nested/src"));
391        assert!(ignored.project_within("apps"));
392        assert_eq!(
393            ignored.scope(),
394            [
395                ("packages/lib/src/inner", true),
396                ("packages/lib/nested", false),
397                ("packages/lib", true),
398                ("apps/app", false),
399                ("apps", true)
400            ]
401        );
402        // Without the graph, or without a `^` entry, nothing depends on it.
403        let alone = IgnoredReads::new(&written, &workspace, None, "app").unwrap();
404        assert!(!alone.is_match("packages/lib/src/a.ts"));
405        assert!(alone.is_match("tmp/x"));
406    }
407
408    #[test]
409    fn reads_the_mode_and_ignored_reads() {
410        let on = Settings::of(&task(serde_json::json!({"ignoreReads": ["tmp/**"]})))
411            .unwrap()
412            .unwrap();
413        assert_eq!(on.ignore_reads, ["tmp/**"]);
414        assert_eq!(on.mode, Mode::On);
415        let record = Settings::of(&task(serde_json::json!({"mode": "record"})))
416            .unwrap()
417            .unwrap();
418        assert_eq!(record.mode, Mode::Record);
419        assert!(
420            Settings::of(&task(serde_json::json!({"mode": "off"})))
421                .unwrap()
422                .is_none()
423        );
424        // Unrecorded, its ignored reads still hold for the sandbox.
425        let off = task(serde_json::json!({"mode": "off", "ignoreReads": ["tmp/**"]}));
426        assert_eq!(ignored_reads(&off).unwrap(), ["tmp/**"]);
427        for invalid in [
428            serde_json::json!(true),
429            serde_json::json!({"mode": "warn"}),
430            serde_json::json!({"mode": 1}),
431            serde_json::json!({"ignoreReads": "tmp/**"}),
432            serde_json::json!({"ignoreReads": ["src/[*.log"]}),
433        ] {
434            assert!(Settings::of(&task(invalid.clone())).is_err(), "{invalid}");
435        }
436    }
437}