1use 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
31pub 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 #[default]
45 On,
46 Record,
48}
49
50impl Settings {
51 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
76pub 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 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#[derive(Clone, Default)]
107pub struct IgnoredReads {
108 plain: Vec<(String, Pattern)>,
109 dependency: Vec<(String, Pattern)>,
110 extra: Vec<Pattern>,
113 owners: Vec<(String, bool)>,
116}
117
118impl IgnoredReads {
119 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 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 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 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 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 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 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 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 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
278fn 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
288fn root_of(root: &str) -> String {
291 root.split('/')
292 .filter(|part| !part.is_empty() && *part != ".")
293 .collect::<Vec<_>>()
294 .join("/")
295}
296
297fn 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 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 ("packages/lib/nested/src/a.ts", false),
367 ("packages/other/a.ts", false),
368 ("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 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 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 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}