1use std::collections::BTreeSet;
26use std::path::Path;
27
28use anyhow::{Context, Result, bail};
29use qk_config::Workspace;
30use qk_graph::ProjectGraph;
31use qk_taskgraph::Task;
32use serde_json::Value;
33
34use crate::glob::Pattern;
35
36pub const FIELDS: &[&str] = &["mode", "ignoreReads", "uncheckedOutputs"];
39
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct Settings {
42 pub mode: Mode,
43 pub ignore_reads: Vec<String>,
44 pub unchecked_outputs: Vec<String>,
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
48pub enum Mode {
49 #[default]
51 On,
52 Record,
54}
55
56impl Settings {
57 pub fn of(task: &Task) -> Result<Option<Self>> {
60 let Some(value) = task.definition.extra.get("qk:ultracache") else {
61 return Ok(None);
62 };
63 let Value::Object(object) = value else {
64 bail!("qk:ultracache must be an object");
65 };
66 let mode = match object.get("mode").map(Value::as_str) {
67 None | Some(Some("on")) => Mode::On,
68 Some(Some("record")) => Mode::Record,
69 Some(Some("off")) => return Ok(None),
70 Some(Some(mode @ ("warn" | "error"))) => {
71 bail!(
72 "qk:ultracache.mode {mode:?} is not supported yet; use \"on\", \"record\" or \"off\""
73 )
74 }
75 Some(_) => bail!("qk:ultracache.mode must be \"on\", \"record\" or \"off\""),
76 };
77 let ignore_reads = ignored_reads(task)?;
78 let unchecked_outputs: Vec<String> = match object.get("uncheckedOutputs") {
79 None => Vec::new(),
80 Some(value) => serde_json::from_value(value.clone())
81 .context("qk:ultracache.uncheckedOutputs must be an array of globs")?,
82 };
83 for glob in &unchecked_outputs {
84 Pattern::new(glob, false).with_context(|| {
85 format!("qk:ultracache.uncheckedOutputs has an invalid glob {glob:?}")
86 })?;
87 }
88 Ok(Some(Self {
89 mode,
90 ignore_reads,
91 unchecked_outputs,
92 }))
93 }
94
95 pub fn unchecked(&self) -> Result<impl Fn(&str) -> bool + use<>> {
97 let patterns = self
98 .unchecked_outputs
99 .iter()
100 .map(|glob| Pattern::new(glob, false))
101 .collect::<Result<Vec<_>>>()?;
102 Ok(move |path: &str| patterns.iter().any(|pattern| pattern.is_match(path)))
103 }
104}
105
106pub fn ignored_reads(task: &Task) -> Result<Vec<String>> {
110 let Some(Value::Object(object)) = task.definition.extra.get("qk:ultracache") else {
111 return Ok(Vec::new());
112 };
113 let ignore_reads: Vec<String> = match object.get("ignoreReads") {
114 None => Vec::new(),
115 Some(value) => serde_json::from_value(value.clone())
116 .context("qk:ultracache.ignoreReads must be an array of globs")?,
117 };
118 for glob in &ignore_reads {
120 Pattern::new(glob.strip_prefix('^').unwrap_or(glob), false)
121 .with_context(|| format!("qk:ultracache.ignoreReads has an invalid glob {glob:?}"))?;
122 }
123 Ok(ignore_reads)
124}
125
126#[derive(Clone, Default)]
137pub struct IgnoredReads {
138 plain: Vec<(String, Pattern)>,
139 dependency: Vec<(String, Pattern)>,
140 extra: Vec<Pattern>,
143 owners: Vec<(String, bool)>,
146}
147
148impl IgnoredReads {
149 pub fn new(
152 written: &[String],
153 workspace: &Workspace,
154 projects: Option<&ProjectGraph>,
155 project: &str,
156 ) -> Result<Self> {
157 let mut ignored = Self::of_globs(written)?;
158 if ignored.dependency.is_empty() {
159 return Ok(ignored);
160 }
161 let depended = projects
162 .map(|projects| depended(projects, project))
163 .unwrap_or_default();
164 ignored.owners = workspace
165 .projects
166 .iter()
167 .map(|(name, config)| (root_of(&config.root), depended.contains(name.as_str())))
168 .collect();
169 ignored.owners.sort_by(|a, b| {
173 b.0.len()
174 .cmp(&a.0.len())
175 .then_with(|| a.0.cmp(&b.0))
176 .then_with(|| a.1.cmp(&b.1))
177 });
178 Ok(ignored)
179 }
180
181 pub const fn none() -> Self {
183 Self {
184 plain: Vec::new(),
185 dependency: Vec::new(),
186 extra: Vec::new(),
187 owners: Vec::new(),
188 }
189 }
190
191 pub fn of_globs(written: &[String]) -> Result<Self> {
194 let mut ignored = Self::default();
195 for glob in written {
196 match glob.strip_prefix('^') {
197 Some(within) => ignored
198 .dependency
199 .push((within.to_owned(), Pattern::new(within, false)?)),
200 None => ignored
201 .plain
202 .push((glob.clone(), Pattern::new(glob, false)?)),
203 }
204 }
205 Ok(ignored)
206 }
207
208 pub fn with_patterns(mut self, extra: impl IntoIterator<Item = Pattern>) -> Self {
210 self.extra.extend(extra);
211 self
212 }
213
214 pub fn is_empty(&self) -> bool {
215 self.plain.is_empty() && self.dependency.is_empty() && self.extra.is_empty()
216 }
217
218 pub fn is_match(&self, path: impl AsRef<Path>) -> bool {
219 let path = path.as_ref();
220 if self.plain.iter().any(|(_, glob)| glob.is_match(path))
221 || self.extra.iter().any(|glob| glob.is_match(path))
222 {
223 return true;
224 }
225 if self.dependency.is_empty() {
226 return false;
227 }
228 let Some(text) = path.to_str() else {
229 return false;
230 };
231 match self.owner(text) {
232 Some((root, true)) => {
233 let within = within(root, text);
234 self.dependency
235 .iter()
236 .any(|(_, glob)| glob.is_match(within))
237 }
238 _ => false,
239 }
240 }
241
242 pub fn walks(&self) -> (Vec<String>, Vec<String>) {
246 let plain = self.plain.iter().map(|(glob, _)| glob.clone()).collect();
247 let mut dependency = Vec::new();
248 for (root, depended) in &self.owners {
249 if !depended {
250 continue;
251 }
252 for (glob, _) in &self.dependency {
253 dependency.push(if root.is_empty() {
254 glob.clone()
255 } else {
256 format!("{}/{glob}", crate::glob::escape(root))
257 });
258 }
259 }
260 (plain, dependency)
261 }
262
263 pub fn project_within(&self, directory: &str) -> bool {
268 self.owners.iter().any(|(root, _)| {
269 root.len() > directory.len()
270 && (directory.is_empty()
271 || root
272 .strip_prefix(directory)
273 .is_some_and(|rest| rest.starts_with('/')))
274 })
275 }
276
277 pub fn scope(&self) -> Vec<(&str, bool)> {
282 self.owners
283 .iter()
284 .filter(|(root, depended)| {
285 *depended
286 || self.owners.iter().any(|(outer, outer_depended)| {
287 *outer_depended
288 && (root == outer
289 || outer.is_empty()
290 || root
291 .strip_prefix(outer.as_str())
292 .is_some_and(|rest| rest.starts_with('/')))
293 })
294 })
295 .map(|(root, depended)| (root.as_str(), *depended))
296 .collect()
297 }
298
299 fn owner(&self, path: &str) -> Option<(&str, bool)> {
301 self.owners
302 .iter()
303 .find(|(root, _)| root.is_empty() || within(root, path) != path)
304 .map(|(root, depended)| (root.as_str(), *depended))
305 }
306}
307
308fn within<'a>(root: &str, path: &'a str) -> &'a str {
310 if root.is_empty() {
311 return path;
312 }
313 path.strip_prefix(root)
314 .and_then(|rest| rest.strip_prefix('/'))
315 .unwrap_or(path)
316}
317
318fn root_of(root: &str) -> String {
321 root.split('/')
322 .filter(|part| !part.is_empty() && *part != ".")
323 .collect::<Vec<_>>()
324 .join("/")
325}
326
327fn depended(projects: &ProjectGraph, project: &str) -> BTreeSet<String> {
329 let mut seen = BTreeSet::new();
330 let mut pending = vec![project];
331 while let Some(next) = pending.pop() {
332 for dependency in projects.dependencies.get(next).into_iter().flatten() {
333 if dependency.target != project && seen.insert(dependency.target.clone()) {
334 pending.push(&dependency.target);
335 }
336 }
337 }
338 seen
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 fn task(value: Value) -> Task {
346 let mut definition: qk_config::Target =
347 serde_json::from_value(serde_json::json!({})).unwrap();
348 definition.extra.insert("qk:ultracache".into(), value);
349 Task {
350 id: "app:build".into(),
351 project: "app".into(),
352 target: "build".into(),
353 configuration: None,
354 args: Vec::new(),
355 definition,
356 dependencies: Default::default(),
357 edges: Default::default(),
358 }
359 }
360
361 fn workspace() -> (tempfile::TempDir, Workspace, ProjectGraph) {
364 let temp = tempfile::tempdir().unwrap();
365 let root = temp.path();
366 std::fs::write(root.join("nx.json"), "{}").unwrap();
367 for (directory, name, depends) in [
368 ("apps/app", "app", vec!["lib", "outer", "inner"]),
369 ("apps", "outer", vec![]),
370 ("packages/lib", "lib", vec![]),
371 ("packages/lib/nested", "nested", vec![]),
372 ("packages/lib/src/inner", "inner", vec![]),
373 ("packages/other", "other", vec![]),
374 ] {
375 std::fs::create_dir_all(root.join(directory)).unwrap();
376 std::fs::write(
377 root.join(directory).join("project.json"),
378 serde_json::json!({"name": name, "implicitDependencies": depends}).to_string(),
379 )
380 .unwrap();
381 }
382 let workspace = Workspace::load(root).unwrap();
383 let projects = ProjectGraph::build(&workspace).unwrap();
384 (temp, workspace, projects)
385 }
386
387 #[test]
388 fn a_dependency_entry_matches_only_the_projects_depended_on() {
389 let (_temp, workspace, projects) = workspace();
390 let written = ["^**/*.ts".to_owned(), "tmp/**".to_owned()];
391 let ignored = IgnoredReads::new(&written, &workspace, Some(&projects), "app").unwrap();
392 for (path, matched) in [
393 ("packages/lib/src/a.ts", true),
394 ("packages/lib/src/a.json", false),
395 ("packages/lib/nested/src/a.ts", false),
397 ("packages/other/a.ts", false),
398 ("apps/app/src/a.ts", false),
400 ("apps/z.ts", true),
401 ("tmp/x", true),
402 ] {
403 assert_eq!(ignored.is_match(path), matched, "{path}");
404 }
405 let (plain, dependency) = ignored.walks();
406 assert_eq!(plain, ["tmp/**"]);
407 assert_eq!(
408 dependency,
409 [
410 "packages/lib/src/inner/**/*.ts",
411 "packages/lib/**/*.ts",
412 "apps/**/*.ts"
413 ]
414 );
415 assert!(ignored.project_within("packages/lib"));
416 assert!(ignored.project_within("packages/lib/src"));
419 assert!(!ignored.project_within("packages/lib/src/inner"));
420 assert!(!ignored.project_within("packages/lib/nested/src"));
421 assert!(ignored.project_within("apps"));
422 assert_eq!(
423 ignored.scope(),
424 [
425 ("packages/lib/src/inner", true),
426 ("packages/lib/nested", false),
427 ("packages/lib", true),
428 ("apps/app", false),
429 ("apps", true)
430 ]
431 );
432 let alone = IgnoredReads::new(&written, &workspace, None, "app").unwrap();
434 assert!(!alone.is_match("packages/lib/src/a.ts"));
435 assert!(alone.is_match("tmp/x"));
436 }
437
438 #[test]
439 fn reads_the_mode_and_ignored_reads() {
440 let on = Settings::of(&task(serde_json::json!({"ignoreReads": ["tmp/**"]})))
441 .unwrap()
442 .unwrap();
443 assert_eq!(on.ignore_reads, ["tmp/**"]);
444 assert_eq!(on.mode, Mode::On);
445 let record = Settings::of(&task(serde_json::json!({"mode": "record"})))
446 .unwrap()
447 .unwrap();
448 assert_eq!(record.mode, Mode::Record);
449 let unchecked = Settings::of(&task(serde_json::json!({
450 "uncheckedOutputs": ["**/coverage/**", "**/*.tsbuildinfo"]
451 })))
452 .unwrap()
453 .unwrap()
454 .unchecked()
455 .unwrap();
456 assert!(unchecked("app/coverage/lcov.info"));
457 assert!(unchecked("app/dist/.tsbuildinfo"));
458 assert!(!unchecked("app/dist/index.d.ts"));
459 assert!(
460 Settings::of(&task(serde_json::json!({"mode": "off"})))
461 .unwrap()
462 .is_none()
463 );
464 let off = task(serde_json::json!({"mode": "off", "ignoreReads": ["tmp/**"]}));
466 assert_eq!(ignored_reads(&off).unwrap(), ["tmp/**"]);
467 for invalid in [
468 serde_json::json!(true),
469 serde_json::json!({"mode": "warn"}),
470 serde_json::json!({"mode": 1}),
471 serde_json::json!({"ignoreReads": "tmp/**"}),
472 serde_json::json!({"ignoreReads": ["src/[*.log"]}),
473 serde_json::json!({"uncheckedOutputs": "coverage/**"}),
474 serde_json::json!({"uncheckedOutputs": ["src/[*.log"]}),
475 ] {
476 assert!(Settings::of(&task(invalid.clone())).is_err(), "{invalid}");
477 }
478 }
479}