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.
17
18use anyhow::{Context, Result, bail};
19use qk_taskgraph::Task;
20use serde_json::Value;
21
22/// The fields a `qk:ultracache` object can set. Others are ignored, as Nx
23/// ignores keys it does not know; `qk doctor` reports them.
24pub const FIELDS: &[&str] = &["mode", "ignoreReads"];
25
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
27pub struct Settings {
28    pub mode: Mode,
29    pub ignore_reads: Vec<String>,
30}
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub enum Mode {
34    /// Keyed by its recordings once it has any.
35    #[default]
36    On,
37    /// Recorded, but keyed by its declared inputs.
38    Record,
39}
40
41impl Settings {
42    /// The target's settings when it is recorded, `None` when it is not:
43    /// without the field, or with `mode` `"off"`.
44    pub fn of(task: &Task) -> Result<Option<Self>> {
45        let Some(value) = task.definition.extra.get("qk:ultracache") else {
46            return Ok(None);
47        };
48        let Value::Object(object) = value else {
49            bail!("qk:ultracache must be an object");
50        };
51        let mode = match object.get("mode").map(Value::as_str) {
52            None | Some(Some("on")) => Mode::On,
53            Some(Some("record")) => Mode::Record,
54            Some(Some("off")) => return Ok(None),
55            Some(Some(mode @ ("warn" | "error"))) => {
56                bail!(
57                    "qk:ultracache.mode {mode:?} is not supported yet; use \"on\", \"record\" or \"off\""
58                )
59            }
60            Some(_) => bail!("qk:ultracache.mode must be \"on\", \"record\" or \"off\""),
61        };
62        let ignore_reads: Vec<String> = match object.get("ignoreReads") {
63            None => Vec::new(),
64            Some(value) => serde_json::from_value(value.clone())
65                .context("qk:ultracache.ignoreReads must be an array of globs")?,
66        };
67        // Checked before the task runs, rather than after it was traced.
68        for glob in &ignore_reads {
69            crate::glob::Pattern::new(glob, false).with_context(|| {
70                format!("qk:ultracache.ignoreReads has an invalid glob {glob:?}")
71            })?;
72        }
73        Ok(Some(Self { mode, ignore_reads }))
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    fn task(value: Value) -> Task {
82        let mut definition: qk_config::Target =
83            serde_json::from_value(serde_json::json!({})).unwrap();
84        definition.extra.insert("qk:ultracache".into(), value);
85        Task {
86            id: "app:build".into(),
87            project: "app".into(),
88            target: "build".into(),
89            configuration: None,
90            args: Vec::new(),
91            definition,
92            dependencies: Default::default(),
93            edges: Default::default(),
94        }
95    }
96
97    #[test]
98    fn reads_the_mode_and_ignored_reads() {
99        let on = Settings::of(&task(serde_json::json!({"ignoreReads": ["tmp/**"]})))
100            .unwrap()
101            .unwrap();
102        assert_eq!(on.ignore_reads, ["tmp/**"]);
103        assert_eq!(on.mode, Mode::On);
104        let record = Settings::of(&task(serde_json::json!({"mode": "record"})))
105            .unwrap()
106            .unwrap();
107        assert_eq!(record.mode, Mode::Record);
108        assert!(
109            Settings::of(&task(serde_json::json!({"mode": "off"})))
110                .unwrap()
111                .is_none()
112        );
113        for invalid in [
114            serde_json::json!(true),
115            serde_json::json!({"mode": "warn"}),
116            serde_json::json!({"mode": 1}),
117            serde_json::json!({"ignoreReads": "tmp/**"}),
118            serde_json::json!({"ignoreReads": ["src/[*.log"]}),
119        ] {
120            assert!(Settings::of(&task(invalid.clone())).is_err(), "{invalid}");
121        }
122    }
123}