1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::fs::File;
3use std::io::{Read, Write};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::AtomicBool;
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, SystemTime};
8
9use anyhow::{Context, Result, bail};
10use qk_config::Workspace;
11use qk_executor::{Capture, Display, Outcome, PreparedTask, execute_captured, read_capture};
12use qk_graph::ProjectGraph;
13use qk_lockfile::Lockfile;
14use qk_taskgraph::{Task, TaskGraph};
15use serde_json::{Value, json};
16
17use crate::glob::is_literal;
18use crate::paths::{self, Outputs, Pattern};
19
20pub fn digest_file(path: &Path) -> Result<String> {
21 let mut hasher = blake3::Hasher::new();
22 let mut reader = File::open(path)?;
23 let mut buffer = [0; 64 * 1024];
24 loop {
25 let count = reader.read(&mut buffer)?;
26 if count == 0 {
27 break;
28 }
29 hasher.update(&buffer[..count]);
30 }
31 Ok(hasher.finalize().to_hex().to_string())
32}
33
34#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
37struct Stamp(Vec<i64>);
38
39impl Stamp {
40 fn new(metadata: &std::fs::Metadata) -> Self {
41 let modified = metadata
42 .modified()
43 .ok()
44 .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
45 .map_or(-1, |duration| duration.as_nanos() as i64);
46 #[allow(unused_mut)]
47 let mut values = vec![metadata.len() as i64, modified];
48 #[cfg(unix)]
49 {
50 use std::os::unix::fs::MetadataExt;
51 values.extend([
52 metadata.ctime(),
53 metadata.ctime_nsec(),
54 metadata.ino() as i64,
55 i64::from(metadata.mode()),
56 ]);
57 }
58 Self(values)
59 }
60
61 fn settled(&self) -> bool {
64 let modified = SystemTime::UNIX_EPOCH + Duration::from_nanos(self.0[1].max(0) as u64);
65 self.0[1] >= 0
66 && SystemTime::now()
67 .duration_since(modified)
68 .is_ok_and(|age| age > Duration::from_secs(2))
69 }
70}
71
72fn digests_path(root: &Path) -> PathBuf {
76 paths::worktree_state(root).join("digests.json")
77}
78
79pub struct Snapshot {
83 pub(crate) files: BTreeSet<String>,
84 extra_candidates: BTreeSet<String>,
85 projects: ProjectGraph,
86 generated_tasks: Vec<(Task, Outputs)>,
87 task_dependencies: BTreeMap<String, BTreeSet<String>>,
88 source_ignore: SourceIgnore,
89 canonical_root: PathBuf,
90 workspace_prefix: Option<PathBuf>,
91 patterns: Mutex<HashMap<(String, bool), Arc<Pattern>>>,
92 digests: Mutex<HashMap<String, (Stamp, String)>>,
93 digests_changed: std::sync::atomic::AtomicBool,
95 directories: Mutex<std::collections::HashSet<PathBuf>>,
97 runtime: Mutex<HashMap<String, Value>>,
99 lockfile: Mutex<Option<Arc<Installed>>>,
101 dependency_outputs: Mutex<HashMap<String, Arc<DependencyOutputs>>>,
104 outputs_written: std::sync::atomic::AtomicU64,
106}
107
108struct DependencyOutputs {
111 anchors: Vec<String>,
112 files: Vec<String>,
113 values: Mutex<HashMap<String, Value>>,
114}
115
116struct Installed {
119 content: String,
120 lockfile: Option<Lockfile>,
122 digests: Mutex<HashMap<String, String>>,
123}
124
125impl Installed {
126 fn digest(&self, key: &str, fingerprints: impl FnOnce() -> BTreeSet<String>) -> String {
127 if let Some(digest) = self.digests.lock().unwrap().get(key) {
128 return digest.clone();
129 }
130 let mut hasher = blake3::Hasher::new();
131 for fingerprint in fingerprints() {
132 hasher.update(fingerprint.as_bytes());
133 hasher.update(b"\n");
134 }
135 let digest = hasher.finalize().to_hex().to_string();
136 self.digests
137 .lock()
138 .unwrap()
139 .insert(key.to_owned(), digest.clone());
140 digest
141 }
142}
143
144impl Snapshot {
145 pub fn new(workspace: &Workspace, graph: &TaskGraph, cache_path: &Path) -> Result<Self> {
146 let (files, lockfile) = std::thread::scope(|scope| {
149 let lockfile = scope.spawn(|| parse_lockfile(&workspace.root.join("pnpm-lock.yaml")));
150 let files = source_files(&workspace.root);
151 (files, lockfile.join().expect("lockfile thread panicked"))
152 });
153 let mut files = files?;
154 if let Ok(cache_relative) = cache_path.strip_prefix(&workspace.root) {
155 files.retain(|path| !Path::new(path).starts_with(cache_relative));
156 }
157 let mut generated = BTreeSet::new();
161 for outputs in graph
162 .tasks
163 .values()
164 .filter_map(|task| Outputs::new(workspace, task).ok())
165 .filter(Outputs::is_explicit)
166 {
167 for anchor in outputs.anchors() {
168 generated.extend(
169 under(&files, anchor)
170 .filter(|path| outputs.matches(path))
171 .cloned(),
172 );
173 }
174 }
175 for task in graph.tasks.values() {
177 let Ok(Some(warm)) = crate::warm::config(workspace, task) else {
178 continue;
179 };
180 if let Ok(paths) = Outputs::from_paths(&warm.kept_paths()) {
181 for anchor in paths.anchors() {
182 generated.extend(
183 under(&files, anchor)
184 .filter(|path| paths.matches(path))
185 .cloned(),
186 );
187 }
188 }
189 }
190 files.retain(|path| !generated.contains(path));
191 let canonical_root = workspace.root.canonicalize()?;
192 let workspace_prefix = paths::git_path(&workspace.root, "--show-toplevel")
193 .and_then(|root| root.canonicalize().ok())
194 .and_then(|root| canonical_root.strip_prefix(root).ok().map(PathBuf::from));
195 Ok(Self {
196 files,
197 extra_candidates: BTreeSet::new(),
198 projects: ProjectGraph::build(workspace)?,
199 source_ignore: SourceIgnore::new(&workspace.root)?,
200 generated_tasks: graph
201 .tasks
202 .values()
203 .filter_map(|task| {
204 let outputs = Outputs::new(workspace, task).ok()?;
205 outputs.is_explicit().then(|| (task.clone(), outputs))
206 })
207 .collect(),
208 task_dependencies: graph
209 .tasks
210 .iter()
211 .map(|(id, task)| (id.clone(), task.dependencies.clone()))
212 .collect(),
213 canonical_root,
214 workspace_prefix,
215 patterns: Mutex::default(),
216 digests: Mutex::new(load_digests(&workspace.root)),
217 digests_changed: Default::default(),
218 directories: Mutex::default(),
219 runtime: Mutex::default(),
220 lockfile: Mutex::new(lockfile),
221 dependency_outputs: Mutex::default(),
222 outputs_written: Default::default(),
223 })
224 }
225
226 pub fn save_digests(&self, root: &Path) {
228 if !self
229 .digests_changed
230 .load(std::sync::atomic::Ordering::Relaxed)
231 {
232 return;
233 }
234 let digests = self.digests.lock().unwrap();
235 let entries: BTreeMap<&String, (&Stamp, &String)> = digests
236 .iter()
237 .filter(|(path, _)| self.files.contains(*path))
238 .map(|(path, (stamp, digest))| (path, (stamp, digest)))
239 .collect();
240 let path = digests_path(root);
241 let saved = (|| -> Result<()> {
242 let directory = path.parent().context("digests have a directory")?;
243 std::fs::create_dir_all(directory)?;
244 let mut file = tempfile::NamedTempFile::new_in(directory)?;
245 {
246 let mut writer = std::io::BufWriter::new(file.as_file_mut());
247 serde_json::to_writer(&mut writer, &json!({"version": 1, "entries": entries}))?;
248 writer.flush()?;
249 }
250 file.persist(&path)?;
251 Ok(())
252 })();
253 if let Err(error) = saved {
254 qk_executor::status!("qk: could not save file digests: {error:#}");
255 }
256 }
257
258 fn safe_parents(&self, root: &Path, path: &str) -> Result<()> {
260 paths::validate_path(path)?;
261 let mut current = root.to_owned();
262 for part in Path::new(path)
263 .parent()
264 .into_iter()
265 .flat_map(Path::components)
266 {
267 current.push(part);
268 if self.directories.lock().unwrap().contains(¤t) {
269 continue;
270 }
271 match std::fs::symlink_metadata(¤t) {
272 Ok(metadata) if metadata.file_type().is_symlink() => {
273 bail!("input parent is a symlink: {}", current.display())
274 }
275 Ok(metadata) if !metadata.is_dir() => {
276 bail!("input parent is not a directory: {}", current.display())
277 }
278 Ok(_) => {
279 self.directories.lock().unwrap().insert(current.clone());
280 }
281 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
282 Err(error) => return Err(error.into()),
283 }
284 }
285 Ok(())
286 }
287
288 fn installed(&self, root: &Path) -> Result<Option<Arc<Installed>>> {
289 let absolute = root.join("pnpm-lock.yaml");
290 let Ok(metadata) = std::fs::symlink_metadata(&absolute) else {
291 return Ok(None);
292 };
293 if !metadata.is_file() {
294 return Ok(None);
295 }
296 let content = self.digest("pnpm-lock.yaml", &absolute, &metadata)?;
297 let mut current = self.lockfile.lock().unwrap();
298 if let Some(installed) = &*current
299 && installed.content == content
300 {
301 return Ok(Some(installed.clone()));
302 }
303 let Some(installed) = parse_lockfile(&absolute) else {
304 return Ok(None);
305 };
306 *current = Some(installed.clone());
307 Ok(Some(installed))
308 }
309
310 pub fn with_candidates(mut self, paths: &[String]) -> Self {
313 self.files.extend(
314 paths
315 .iter()
316 .filter(|path| !self.source_ignore.matches(path))
317 .cloned(),
318 );
319 self.extra_candidates.extend(paths.iter().cloned());
320 self
321 }
322
323 fn pattern(&self, pattern: &str, negated: bool) -> Result<Arc<Pattern>> {
324 let key = (pattern.to_owned(), negated);
325 if let Some(pattern) = self.patterns.lock().unwrap().get(&key) {
326 return Ok(pattern.clone());
327 }
328 let compiled = Arc::new(Pattern::new(pattern, negated)?);
329 self.patterns.lock().unwrap().insert(key, compiled.clone());
330 Ok(compiled)
331 }
332
333 fn digest(&self, path: &str, absolute: &Path, metadata: &std::fs::Metadata) -> Result<String> {
334 let stamp = Stamp::new(metadata);
335 if let Some((known, digest)) = self.digests.lock().unwrap().get(path)
336 && *known == stamp
337 {
338 return Ok(digest.clone());
339 }
340 let digest = digest_file(absolute)?;
341 let after = Stamp::new(&std::fs::symlink_metadata(absolute)?);
343 if after == stamp && stamp.settled() {
344 self.digests
345 .lock()
346 .unwrap()
347 .insert(path.to_owned(), (stamp, digest.clone()));
348 self.digests_changed
349 .store(true, std::sync::atomic::Ordering::Relaxed);
350 }
351 Ok(digest)
352 }
353
354 fn dependency_outputs(
357 &self,
358 workspace: &Workspace,
359 task: &Task,
360 ) -> Result<Arc<DependencyOutputs>> {
361 if let Some(outputs) = self.dependency_outputs.lock().unwrap().get(&task.id) {
362 return Ok(outputs.clone());
363 }
364 let written = self
365 .outputs_written
366 .load(std::sync::atomic::Ordering::SeqCst);
367 let outputs = Arc::new(self.fresh_dependency_outputs(workspace, task)?);
368 let mut listed = self.dependency_outputs.lock().unwrap();
369 if self
370 .outputs_written
371 .load(std::sync::atomic::Ordering::SeqCst)
372 != written
373 {
374 return Ok(outputs);
375 }
376 Ok(listed.entry(task.id.clone()).or_insert(outputs).clone())
377 }
378
379 fn fresh_dependency_outputs(
381 &self,
382 workspace: &Workspace,
383 task: &Task,
384 ) -> Result<DependencyOutputs> {
385 let declared = Outputs::new(workspace, task)?;
386 let mut files = Vec::new();
387 for entry in declared.entries(&workspace.root)? {
388 let (path, metadata) = entry?;
389 if !metadata.is_dir() {
390 files.push(path);
391 }
392 }
393 Ok(DependencyOutputs {
394 anchors: declared.anchors().map(str::to_owned).collect(),
395 files,
396 values: Mutex::default(),
397 })
398 }
399
400 pub(crate) fn outputs_written(&self, workspace: &Workspace, task: &Task) {
403 let anchors: Option<Vec<String>> = Outputs::new(workspace, task)
404 .ok()
405 .filter(Outputs::is_explicit)
406 .map(|outputs| outputs.anchors().map(str::to_owned).collect());
407 let overlap = |a: &str, b: &str| {
408 a == b
409 || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
410 || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
411 };
412 let mut listed = self.dependency_outputs.lock().unwrap();
413 self.outputs_written
414 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
415 listed.retain(|_, outputs| {
416 anchors.as_ref().is_some_and(|written| {
417 !written
418 .iter()
419 .any(|a| outputs.anchors.iter().any(|b| overlap(a, b)))
420 })
421 });
422 }
423
424 fn file_value(&self, root: &Path, path: &str) -> Result<Value> {
425 self.file_value_with_links(root, path, &mut BTreeSet::new())
426 }
427
428 fn file_value_with_links(
429 &self,
430 root: &Path,
431 path: &str,
432 visiting: &mut BTreeSet<PathBuf>,
433 ) -> Result<Value> {
434 self.safe_parents(root, path)?;
435 let absolute = root.join(path);
436 let metadata = std::fs::symlink_metadata(&absolute)?;
437 if metadata.file_type().is_symlink() {
438 let target = std::fs::read_link(&absolute)?;
439 let Ok(resolved) = absolute.canonicalize() else {
441 return Ok(json!({"link":target, "dangling":true}));
442 };
443 let Ok(relative) = resolved.strip_prefix(&self.canonical_root) else {
444 bail!("input symlink resolves outside the workspace: {path}");
445 };
446 if resolved.is_file() {
447 let metadata = std::fs::metadata(&resolved)?;
448 let key = paths::relative(Path::new(""), relative)?;
449 return Ok(
450 json!({"link":target, "content":self.digest(&key, &resolved, &metadata)?,
451 "mode":crate::store::mode(&metadata) & 0o111 != 0}),
452 );
453 }
454 let relative = paths::relative(Path::new(""), relative)?;
455 if relative.is_empty() {
456 bail!("input symlink resolves to the workspace root: {path}");
457 }
458 if !visiting.insert(resolved.clone()) {
459 bail!("directory input symlink cycle: {path}");
460 }
461 let mut contents = BTreeMap::new();
462 for entry in walkdir::WalkDir::new(&resolved).follow_links(false) {
465 let entry = entry?;
466 if entry.file_type().is_dir() {
467 continue;
468 }
469 let file = paths::relative(&self.canonical_root, entry.path())?;
470 let value = self.file_value_with_links(&self.canonical_root, &file, visiting)?;
471 contents.insert(
472 file.strip_prefix(&relative).unwrap_or(&file).to_owned(),
473 value,
474 );
475 }
476 visiting.remove(&resolved);
477 return Ok(json!({"link":target, "directory":contents}));
478 }
479 if !metadata.is_file() {
480 bail!("input is not a regular file: {path}");
481 }
482 let mode = crate::store::mode(&metadata) & 0o111 != 0;
485 Ok(json!({"content":self.digest(path, &absolute, &metadata)?, "mode":mode}))
486 }
487}
488
489fn parse_lockfile(path: &Path) -> Option<Arc<Installed>> {
492 let text = std::fs::read_to_string(path).ok()?;
493 let lockfile = match Lockfile::parse(&text) {
494 Ok(lockfile) => Some(lockfile),
495 Err(error) => {
496 qk_executor::status!("qk: pnpm-lock.yaml: keying tasks by the whole file ({error:#})");
497 None
498 }
499 };
500 Some(Arc::new(Installed {
501 content: blake3::hash(text.as_bytes()).to_hex().to_string(),
502 lockfile,
503 digests: Mutex::default(),
504 }))
505}
506
507fn load_digests(root: &Path) -> HashMap<String, (Stamp, String)> {
508 #[derive(serde::Deserialize)]
509 struct Saved {
510 version: u32,
511 entries: HashMap<String, (Stamp, String)>,
512 }
513 std::fs::read(digests_path(root))
514 .ok()
515 .and_then(|bytes| serde_json::from_slice::<Saved>(&bytes).ok())
516 .filter(|saved| saved.version == 1)
517 .map(|saved| saved.entries)
518 .unwrap_or_default()
519}
520
521fn under<'a>(files: &'a BTreeSet<String>, prefix: &'a str) -> impl Iterator<Item = &'a String> {
523 files
524 .range::<str, _>((
525 std::ops::Bound::Included(prefix),
526 std::ops::Bound::Unbounded,
527 ))
528 .take_while(move |path| path.starts_with(prefix))
529 .filter(move |path| path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/')
530}
531
532pub struct SourceIgnore(ignore::gitignore::Gitignore);
534
535impl SourceIgnore {
536 pub fn new(root: &Path) -> Result<Self> {
538 let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
539 for name in [".gitignore", ".nxignore"] {
540 let path = root.join(name);
541 if path.is_file()
542 && let Some(error) = builder.add(path)
543 {
544 return Err(error.into());
545 }
546 }
547 Ok(Self(builder.build()?))
548 }
549
550 fn matches_directory(&self, path: &str) -> bool {
551 self.matches(path) || self.0.matched(path, true).is_ignore()
552 }
553
554 pub fn matches(&self, path: &str) -> bool {
556 Path::new(path)
558 .ancestors()
559 .skip(1)
560 .take_while(|parent| !parent.as_os_str().is_empty())
561 .any(|parent| self.0.matched(parent, true).is_ignore())
562 || self.0.matched(path, false).is_ignore()
563 }
564}
565
566pub fn source_files(root: &Path) -> Result<BTreeSet<String>> {
568 let lines =
569 |output: std::io::Result<std::process::Output>| -> Option<Result<BTreeSet<String>>> {
570 let output = output.ok().filter(|output| output.status.success())?;
571 Some(
572 output
573 .stdout
574 .split(|byte| *byte == 0)
575 .filter(|path| !path.is_empty())
576 .map(|path| {
577 String::from_utf8(path.to_vec()).context("input paths must be UTF-8")
578 })
579 .collect(),
580 )
581 };
582 let (listed, deleted) = if root.join(".nxignore").is_file() {
587 (None, None)
590 } else {
591 std::thread::scope(|scope| {
592 let deleted =
593 scope.spawn(|| paths::git(root, &["ls-files", "--deleted", "-z", "--", "."]));
594 let listed = paths::git(
595 root,
596 &[
597 "ls-files",
598 "-v",
599 "--cached",
600 "--others",
601 "--exclude-standard",
602 "-z",
603 "--",
604 ".",
605 ],
606 );
607 (
608 lines(listed),
609 lines(deleted.join().expect("git listing thread panicked")),
610 )
611 })
612 };
613 let candidates = match (listed, deleted) {
614 (Some(listed), Some(deleted)) => {
615 let deleted = deleted?;
616 let mut files = BTreeSet::new();
617 for entry in listed? {
618 let Some((tag, path)) = entry.split_once(' ') else {
619 bail!("unexpected git ls-files entry {entry:?}");
620 };
621 let absent =
622 matches!(tag, "S" | "s") && std::fs::symlink_metadata(root.join(path)).is_err();
623 if !absent && !deleted.contains(path) {
624 files.insert(path.to_owned());
625 }
626 }
627 files
628 }
629 _ => {
630 let mut files = BTreeSet::new();
631 for entry in ignore::WalkBuilder::new(root)
632 .hidden(false)
633 .parents(false)
634 .git_global(false)
635 .require_git(false)
636 .ignore(false)
637 .add_custom_ignore_filename(".nxignore")
638 .follow_links(false)
639 .filter_entry(|entry| {
640 !matches!(
641 entry.file_name().to_str(),
642 Some(".git" | ".qk" | "node_modules")
643 )
644 })
645 .build()
646 {
647 let entry = entry?;
648 if entry.file_type().is_some_and(|kind| !kind.is_dir()) {
649 files.insert(paths::relative(root, entry.path())?);
650 }
651 }
652 files
653 }
654 };
655 let ignore = SourceIgnore::new(root)?;
656 Ok(candidates
657 .into_iter()
658 .filter(|path| {
659 !ignore.matches(path)
660 && !path
661 .split('/')
662 .any(|part| matches!(part, ".git" | ".qk" | "node_modules"))
663 })
664 .collect())
665}
666
667fn json_path<'v>(value: &'v Value, path: &str) -> Option<&'v Value> {
668 path.split('.')
669 .try_fold(value, |value, part| value.get(part))
670}
671
672fn set_json_path(target: &mut Value, path: &str, value: Value) {
673 let mut parts = path.split('.').peekable();
674 let mut current = target;
675 while let Some(part) = parts.next() {
676 let Value::Object(object) = current else {
677 return;
678 };
679 if parts.peek().is_none() {
680 object.insert(part.to_owned(), value);
681 return;
682 }
683 current = object.entry(part).or_insert_with(|| json!({}));
684 }
685}
686
687fn remove_json_path(target: &mut Value, path: &str) {
688 let (parent, last) = match path.rsplit_once('.') {
689 Some((parent, last)) => (Some(parent), last),
690 None => (None, path),
691 };
692 let parent = match parent {
693 Some(parent) => parent
694 .split('.')
695 .try_fold(&mut *target, |value, part| value.get_mut(part)),
696 None => Some(target),
697 };
698 if let Some(Value::Object(object)) = parent {
699 object.remove(last);
700 }
701}
702
703struct FilePattern {
704 matcher: Arc<Pattern>,
705 prefix: String,
706 excluded: bool,
707}
708
709#[derive(Default)]
710struct FileSelection {
711 patterns: Vec<FilePattern>,
712 included: BTreeSet<String>,
713 excluded: BTreeSet<String>,
714}
715
716#[derive(PartialEq, Eq, PartialOrd, Ord)]
717enum FileScope {
718 Workspace,
719 Project(String),
720}
721
722struct Resolver<'a> {
723 workspace: &'a Workspace,
724 snapshot: &'a Snapshot,
725 selected: BTreeSet<String>,
726 scopes: BTreeMap<FileScope, FileSelection>,
727 values: BTreeMap<String, Value>,
728 named_stack: Vec<(String, String)>,
729 expanded: BTreeSet<(String, String)>,
731 external: BTreeSet<String>,
732 prepared: Option<&'a PreparedTask>,
735 cancelled: &'a AtomicBool,
736}
737
738impl Resolver<'_> {
739 fn input(&mut self, project: &str, input: &Value) -> Result<()> {
740 match input {
741 Value::String(name) if name.starts_with('^') => {
744 self.dependencies_named(project, &name[1..])?;
745 }
746 Value::Object(object)
748 if object.contains_key("input") && !object.contains_key("fileset") =>
749 {
750 let name = object["input"]
751 .as_str()
752 .context("input must name a named input")?;
753 let projects = object.get("projects");
754 let dependencies = object.get("dependencies") == Some(&json!(true))
755 || projects == Some(&json!("dependencies"));
756 let listed = projects.filter(|projects| {
757 **projects != json!("self") && **projects != json!("dependencies")
758 });
759 if (dependencies || listed.is_some()) && !self.named_stack.is_empty() {
760 bail!("named inputs can only refer to named inputs of their own project");
761 }
762 if dependencies {
763 self.dependencies_named(project, name)?;
764 } else if let Some(listed) = listed {
765 let patterns: Vec<String> = match listed {
766 Value::String(pattern) => vec![pattern.clone()],
767 other => serde_json::from_value(other.clone())
768 .context("input projects must be a string or an array")?,
769 };
770 for selected in
771 qk_graph::select_projects(&self.workspace.projects, &patterns, &[])?
772 {
773 if self.expanded.insert((selected.clone(), name.to_owned())) {
774 self.named(&selected, name)?;
775 }
776 }
777 } else {
778 self.named(project, name)?;
779 }
780 }
781 Value::Object(object)
782 if object.contains_key("fileset")
783 && object.get("dependencies") == Some(&json!(true)) =>
784 {
785 if !self.named_stack.is_empty() {
786 bail!("named inputs can only refer to named inputs of their own project");
787 }
788 let pattern = object["fileset"]
789 .as_str()
790 .context("fileset input must be a glob")?
791 .to_owned();
792 for dependency in self.dependency_closure(project) {
793 self.fileset(&dependency, &pattern)?;
794 }
795 }
796 Value::Object(object)
797 if object.len() == 1 && object.contains_key("workingDirectory") =>
798 {
799 let mode = object["workingDirectory"].as_str().unwrap_or_default();
800 let directory = std::env::current_dir()?;
801 let value = match mode {
802 "absolute" => directory.to_string_lossy().into_owned(),
803 "relative" => {
804 let root = self.workspace.root.canonicalize()?;
805 let directory = directory.canonicalize()?;
806 paths::relative(&root, &directory)
807 .unwrap_or_else(|_| directory.to_string_lossy().into_owned())
808 }
809 _ => bail!("workingDirectory must be \"relative\" or \"absolute\""),
810 };
811 self.values
812 .insert(format!("workingDirectory:{mode}"), json!(value));
813 }
814 Value::Object(object) if object.contains_key("json") => {
815 self.json(project, object)?;
816 }
817 Value::String(name)
818 if !name.contains("{projectRoot}") && !name.contains("{workspaceRoot}") =>
819 {
820 self.named(project, name)?
821 }
822 Value::Object(object) if object.len() == 1 && object.contains_key("fileset") => {
823 let pattern = object["fileset"]
824 .as_str()
825 .context("fileset input must be a glob")?;
826 self.fileset(project, pattern)?;
827 }
828 Value::String(pattern) => self.fileset(project, pattern)?,
829 Value::Object(object) if object.len() == 1 && object.contains_key("env") => {
830 let name = object["env"]
831 .as_str()
832 .context("env input must name a variable")?;
833 let Some(prepared) = self.prepared else {
834 self.values.insert(format!("env:{name}"), Value::Null);
835 return Ok(());
836 };
837 let value = prepared
838 .env
839 .get(std::ffi::OsStr::new(name))
840 .map(|value| {
841 value
842 .to_str()
843 .map(str::to_owned)
844 .context("declared env input must be UTF-8")
845 })
846 .transpose()?;
847 self.values.insert(format!("env:{name}"), json!(value));
848 }
849 Value::Object(object) if object.len() == 1 && object.contains_key("runtime") => {
850 let command = object["runtime"]
851 .as_str()
852 .context("runtime input must be a command")?;
853 let Some(prepared) = self.prepared else {
854 self.values
855 .insert(format!("runtime:{command}"), Value::Null);
856 return Ok(());
857 };
858 let memo = serde_json::to_string(&(
859 command,
860 prepared
861 .env
862 .iter()
863 .map(|(name, value)| (name.to_string_lossy(), value.to_string_lossy()))
864 .collect::<Vec<_>>(),
865 ))?;
866 if let Some(value) = self.snapshot.runtime.lock().unwrap().get(&memo) {
867 self.values
868 .insert(format!("runtime:{command}"), value.clone());
869 return Ok(());
870 }
871 let mut prepared = prepared.clone();
872 prepared
873 .execution
874 .remove(std::ffi::OsStr::new("FORCE_COLOR"));
875 prepared.commands = vec![command.into()];
876 prepared.cwd = self.workspace.root.clone();
877 prepared.parallel = false;
878 let log = tempfile::NamedTempFile::new()?;
879 let capture = Capture::new(Some(log.as_file().try_clone()?), Display::Hidden);
880 if execute_captured(&prepared, self.cancelled, Some(&capture))? != Outcome::Success
881 {
882 bail!("runtime input did not succeed");
883 }
884 let mut stdout = blake3::Hasher::new();
885 let mut stderr = blake3::Hasher::new();
886 read_capture(File::open(log.path())?, |error, bytes| {
887 if error {
888 stderr.update(bytes);
889 } else {
890 stdout.update(bytes);
891 }
892 Ok(())
893 })?;
894 let value = json!([
895 stdout.finalize().to_hex().to_string(),
896 stderr.finalize().to_hex().to_string()
897 ]);
898 self.snapshot
899 .runtime
900 .lock()
901 .unwrap()
902 .insert(memo, value.clone());
903 self.values.insert(format!("runtime:{command}"), value);
904 }
905 Value::Object(object) if object.contains_key("dependentTasksOutputFiles") => {
906 if object
907 .keys()
908 .any(|key| !matches!(key.as_str(), "dependentTasksOutputFiles" | "transitive"))
909 {
910 bail!("unsupported output input field");
911 }
912 let pattern = object["dependentTasksOutputFiles"]
913 .as_str()
914 .context("dependentTasksOutputFiles must be a glob")?;
915 self.snapshot.pattern(pattern, false)?;
916 let transitive = object
917 .get("transitive")
918 .map(|value| value.as_bool().context("transitive must be boolean"))
919 .transpose()?
920 .unwrap_or(false);
921 let value = self
925 .values
926 .entry(format!("dependentTasksOutputFiles:{pattern}"))
927 .or_insert(json!(false));
928 if transitive {
929 *value = json!(true);
930 }
931 }
932 Value::Object(object)
933 if object.len() == 1 && object.contains_key("externalDependencies") =>
934 {
935 let names: Vec<String> =
936 serde_json::from_value(object["externalDependencies"].clone())?;
937 self.external.extend(names);
938 }
939 _ => bail!("unsupported task input declaration"),
940 }
941 Ok(())
942 }
943
944 fn fileset(&mut self, project: &str, pattern: &str) -> Result<()> {
945 let (exclude, pattern) = pattern
946 .strip_prefix('!')
947 .map(|pattern| (true, pattern))
948 .unwrap_or((false, pattern));
949 let scope = if pattern.contains("{workspaceRoot}") {
951 FileScope::Workspace
952 } else {
953 FileScope::Project(project.to_owned())
954 };
955 let pattern = paths::expand(self.workspace, project, pattern)?;
956 let matcher = self.snapshot.pattern(&pattern, exclude)?;
957 let prefix = pattern
958 .split('/')
959 .take_while(|part| is_literal(part))
960 .collect::<Vec<_>>()
961 .join("/");
962 let candidates: Box<dyn Iterator<Item = &String>> = if prefix.is_empty() {
963 Box::new(self.snapshot.files.iter())
964 } else {
965 Box::new(under(&self.snapshot.files, &prefix))
966 };
967 let selection = self.scopes.entry(scope).or_default();
968 let selected = if exclude {
969 &mut selection.excluded
970 } else {
971 &mut selection.included
972 };
973 selected.extend(candidates.filter(|file| matcher.is_match(file)).cloned());
974 selection.patterns.push(FilePattern {
975 matcher,
976 prefix,
977 excluded: exclude,
978 });
979 Ok(())
980 }
981
982 fn dependency_closure(&self, project: &str) -> BTreeSet<String> {
984 let mut closure = BTreeSet::new();
985 let mut pending = vec![project.to_owned()];
986 while let Some(current) = pending.pop() {
987 for edge in &self.snapshot.projects.dependencies[¤t] {
988 if edge.target != project && closure.insert(edge.target.clone()) {
989 pending.push(edge.target.clone());
990 }
991 }
992 }
993 closure
994 }
995
996 fn dependencies_named(&mut self, project: &str, name: &str) -> Result<()> {
998 for dependency in self.dependency_closure(project) {
999 if self.expanded.insert((dependency.clone(), name.to_owned())) {
1000 self.named(&dependency, name)?;
1001 }
1002 }
1003 Ok(())
1004 }
1005
1006 fn json(&mut self, project: &str, object: &serde_json::Map<String, Value>) -> Result<()> {
1010 for key in object.keys() {
1011 if !matches!(key.as_str(), "json" | "fields" | "excludeFields") {
1012 bail!("unsupported json input field {key:?}");
1013 }
1014 }
1015 let path = object["json"]
1016 .as_str()
1017 .context("json input must name a file")?;
1018 let path = paths::expand(self.workspace, project, path)?;
1019 paths::validate_path(&path)?;
1020 let list = |name: &str| -> Result<Option<Vec<String>>> {
1021 object
1022 .get(name)
1023 .map(|value| {
1024 serde_json::from_value(value.clone())
1025 .with_context(|| format!("json input {name} must be an array of strings"))
1026 })
1027 .transpose()
1028 };
1029 let fields = list("fields")?;
1030 let exclude = list("excludeFields")?.unwrap_or_default();
1031 let value = match std::fs::read_to_string(self.workspace.root.join(&path)) {
1032 Ok(text) => {
1033 let mut value: Value =
1034 jsonc_parser::parse_to_serde_value(&text, &Default::default())
1035 .with_context(|| format!("cannot parse json input {path}"))?;
1036 if let Some(fields) = &fields {
1037 let mut kept = json!({});
1038 for field in fields {
1039 if let Some(found) = json_path(&value, field) {
1040 set_json_path(&mut kept, field, found.clone());
1041 }
1042 }
1043 value = kept;
1044 }
1045 for field in &exclude {
1046 remove_json_path(&mut value, field);
1047 }
1048 value
1049 }
1050 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Null,
1051 Err(error) => return Err(error.into()),
1052 };
1053 self.values.insert(format!("json:{path}"), value);
1054 Ok(())
1055 }
1056
1057 fn named(&mut self, project: &str, name: &str) -> Result<()> {
1058 let identity = (project.to_owned(), name.to_owned());
1059 if self.named_stack.contains(&identity) {
1060 bail!("named input cycle at {project}:{name}");
1061 }
1062 self.named_stack.push(identity);
1063 let inputs = self.workspace.projects[project]
1064 .named_inputs
1065 .get(name)
1066 .cloned()
1067 .or_else(|| (name == "default").then(|| vec![json!("{projectRoot}/**/*")]))
1068 .with_context(|| format!("unknown named input {name:?}"))?;
1069 for input in &inputs {
1070 self.input(project, input)?;
1071 }
1072 self.named_stack.pop();
1073 Ok(())
1074 }
1075}
1076
1077pub struct Resolved {
1081 pub files: BTreeSet<String>,
1083 pub mandatory: BTreeSet<String>,
1085 pub values: BTreeMap<String, Value>,
1088 pub lockfile: Option<(BTreeSet<String>, BTreeSet<String>)>,
1091 pub workspace_file: bool,
1094 pub external: BTreeSet<String>,
1096}
1097
1098pub fn resolve(
1099 snapshot: &Snapshot,
1100 workspace: &Workspace,
1101 task: &Task,
1102 prepared: Option<&PreparedTask>,
1103 cancelled: &AtomicBool,
1104) -> Result<Resolved> {
1105 let mut resolver = Resolver {
1106 workspace,
1107 snapshot,
1108 selected: BTreeSet::new(),
1109 scopes: BTreeMap::new(),
1110 values: BTreeMap::new(),
1111 named_stack: Vec::new(),
1112 expanded: BTreeSet::new(),
1113 external: BTreeSet::new(),
1114 prepared,
1115 cancelled,
1116 };
1117 let default = vec![json!("default"), json!("^default")];
1118 for input in task.definition.inputs.as_ref().unwrap_or(&default) {
1119 resolver.input(&task.project, input)?;
1120 }
1121 let mut upstream = BTreeSet::new();
1124 let mut pending: Vec<_> = task.dependencies.iter().cloned().collect();
1125 while let Some(id) = pending.pop() {
1126 if upstream.insert(id.clone())
1127 && let Some(dependencies) = snapshot.task_dependencies.get(&id)
1128 {
1129 pending.extend(dependencies.iter().cloned());
1130 }
1131 }
1132 let own_outputs = Outputs::new(workspace, task)
1133 .ok()
1134 .filter(Outputs::is_explicit);
1135 for (producer, declared) in &snapshot.generated_tasks {
1136 if producer.id == task.id || !upstream.contains(&producer.id) {
1137 continue;
1138 }
1139 let overlaps = |a: &str, b: &str| {
1140 a.is_empty()
1141 || a == b
1142 || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
1143 || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
1144 };
1145 let relevant = resolver
1146 .scopes
1147 .values()
1148 .flat_map(|scope| &scope.patterns)
1149 .filter(|pattern| !pattern.excluded)
1150 .any(|pattern| {
1151 declared.anchors().any(|anchor| {
1152 overlaps(&pattern.prefix, anchor)
1153 && (!snapshot.source_ignore.matches_directory(anchor)
1154 || !workspace.root.join(anchor).is_dir())
1155 })
1156 });
1157 if !relevant {
1158 continue;
1159 }
1160 let outputs = snapshot.dependency_outputs(workspace, producer)?;
1161 for path in &outputs.files {
1162 if snapshot.source_ignore.matches(path)
1163 || own_outputs.as_ref().is_some_and(|own| own.matches(path))
1164 {
1165 continue;
1166 }
1167 for selection in resolver.scopes.values_mut() {
1168 for pattern in &selection.patterns {
1169 if pattern.matcher.is_match(path) {
1170 if pattern.excluded {
1171 selection.excluded.insert(path.clone());
1172 } else {
1173 selection.included.insert(path.clone());
1174 }
1175 }
1176 }
1177 }
1178 }
1179 }
1180 for selection in resolver.scopes.values() {
1181 resolver.selected.extend(
1182 selection
1183 .included
1184 .difference(&selection.excluded)
1185 .filter(|path| own_outputs.as_ref().is_none_or(|own| !own.matches(path)))
1186 .cloned(),
1187 );
1188 }
1189 let readable = snapshot
1190 .installed(&workspace.root)?
1191 .is_some_and(|installed| installed.lockfile.is_some());
1192 let tsconfig = ["tsconfig.base.json", "tsconfig.json"]
1198 .into_iter()
1199 .find(|name| {
1200 workspace.root.join(name).is_file() || snapshot.extra_candidates.contains(*name)
1201 });
1202 let mut mandatory = BTreeSet::new();
1203 for path in tsconfig.into_iter().chain([
1204 "nx.json",
1205 ".gitignore",
1206 ".nxignore",
1207 qk_config::LOCAL_WORKSPACE,
1208 "package.json",
1209 "pnpm-workspace.yaml",
1210 "pnpm-lock.yaml",
1211 "package-lock.json",
1212 "yarn.lock",
1213 "bun.lock",
1214 ]) {
1215 if (workspace.root.join(path).is_file() || snapshot.extra_candidates.contains(path))
1216 && !(path == "pnpm-lock.yaml" && readable)
1217 {
1218 resolver.selected.insert(path.into());
1219 mandatory.insert(path.into());
1220 }
1221 }
1222 if let Some(extended) = &workspace.extended {
1223 resolver.selected.insert(extended.clone());
1224 mandatory.insert(extended.clone());
1225 }
1226 let mut packages = BTreeSet::from([task.project.clone()]);
1228 let mut pending = packages.clone();
1229 while let Some(project) = pending.pop_first() {
1230 for edge in &snapshot.projects.dependencies[&project] {
1231 if packages.insert(edge.target.clone()) {
1232 pending.insert(edge.target.clone());
1233 }
1234 }
1235 }
1236 for project in &packages {
1237 for name in ["project.json", qk_config::LOCAL_OVERRIDES, "package.json"] {
1238 let path = Path::new(&workspace.projects[project].root).join(name);
1239 let path = path.strip_prefix(".").unwrap_or(&path).to_owned();
1240 if workspace.root.join(&path).is_file() {
1241 let path = path
1242 .to_str()
1243 .context("project path must be UTF-8")?
1244 .replace('\\', "/");
1245 resolver.selected.insert(path.clone());
1246 mandatory.insert(path);
1247 }
1248 }
1249 }
1250 let workspace_file = readable && resolver.selected.remove("pnpm-workspace.yaml");
1253 if workspace_file {
1254 mandatory.remove("pnpm-workspace.yaml");
1255 }
1256 let lockfile = readable.then(|| {
1257 let importers = std::iter::once(".".to_owned())
1260 .chain(
1261 packages
1262 .iter()
1263 .map(|project| workspace.projects[project].root.clone()),
1264 )
1265 .collect();
1266 (importers, resolver.external.clone())
1267 });
1268 Ok(Resolved {
1269 files: resolver.selected,
1270 mandatory,
1271 values: resolver.values,
1272 lockfile,
1273 workspace_file,
1274 external: resolver.external,
1275 })
1276}
1277
1278pub fn inputs(
1281 snapshot: &Snapshot,
1282 workspace: &Workspace,
1283 task: &Task,
1284 prepared: &PreparedTask,
1285 dependencies: &BTreeMap<String, String>,
1286 cancelled: &AtomicBool,
1287) -> Result<Value> {
1288 let Resolved {
1289 files,
1290 mut values,
1291 lockfile,
1292 workspace_file,
1293 ..
1294 } = resolve(snapshot, workspace, task, Some(prepared), cancelled)?;
1295 let installed = snapshot.installed(&workspace.root)?;
1296 if let (Some((importers, external)), Some(installed)) = (lockfile, &installed)
1297 && let Some(lockfile) = &installed.lockfile
1298 {
1299 let importers: BTreeMap<_, _> = importers
1300 .iter()
1301 .filter(|importer| lockfile.has_importer(importer))
1302 .map(|importer| {
1303 let digest = installed.digest(&format!("importer:{importer}"), || {
1304 lockfile.installed(importer).unwrap_or_default()
1305 });
1306 (importer.clone(), digest)
1307 })
1308 .collect();
1309 let external: BTreeMap<_, _> = external
1310 .iter()
1311 .map(|name| {
1312 let digest =
1313 installed.digest(&format!("package:{name}"), || lockfile.package(name));
1314 (name.clone(), digest)
1315 })
1316 .collect();
1317 values.insert(
1318 "lockfile".into(),
1319 json!({
1320 "global": installed.digest("global", || BTreeSet::from([lockfile.global().to_string()])),
1321 "importers": importers,
1322 "external": external,
1323 }),
1324 );
1325 }
1326 let mut files = files
1327 .iter()
1328 .map(|path| {
1329 let value = snapshot
1330 .file_value(&workspace.root, path)
1331 .with_context(|| format!("cannot read input {path}"))?;
1332 Ok((path.clone(), value))
1333 })
1334 .collect::<Result<BTreeMap<_, _>>>()?;
1335 if workspace_file {
1336 match workspace_without_resolution(&workspace.root) {
1337 Some(value) => {
1338 values.insert("pnpm-workspace".into(), value);
1339 }
1340 None => {
1341 files.insert(
1342 "pnpm-workspace.yaml".into(),
1343 snapshot.file_value(&workspace.root, "pnpm-workspace.yaml")?,
1344 );
1345 }
1346 }
1347 }
1348 let mut definition = task.definition.clone();
1350 definition.extra.remove("qk:warm");
1351 definition.extra.remove("qk:threads");
1352 let hash = json!({
1353 "schema":"qk-local-v2", "qk":env!("CARGO_PKG_VERSION"),
1354 "platform":[std::env::consts::OS, std::env::consts::ARCH], "workspace":snapshot.workspace_prefix,
1355 "id":task.id, "args":task.args, "definition":definition, "packageManager":workspace.package_manager,
1356 "files":files, "values":values, "dependencies":dependencies,
1357 });
1358 Ok(hash)
1359}
1360
1361pub fn workspace_without_resolution(root: &Path) -> Option<Value> {
1364 let text = std::fs::read_to_string(root.join("pnpm-workspace.yaml")).ok()?;
1365 without_resolution(&text)
1366}
1367
1368pub fn without_resolution(text: &str) -> Option<Value> {
1370 let mut value: Value = serde_yaml_ng::from_str(text).ok()?;
1371 if let Some(object) = value.as_object_mut() {
1372 object.retain(|key, _| !qk_lockfile::RESOLUTION_KEYS.contains(&key.as_str()));
1373 }
1374 Some(value)
1375}
1376
1377pub fn key(inputs: &Value) -> Result<String> {
1379 Ok(blake3::hash(&serde_json::to_vec(inputs)?)
1380 .to_hex()
1381 .to_string())
1382}
1383
1384pub fn fingerprint(
1385 snapshot: &Snapshot,
1386 workspace: &Workspace,
1387 task: &Task,
1388 prepared: &PreparedTask,
1389 dependencies: &BTreeMap<String, String>,
1390 cancelled: &AtomicBool,
1391) -> Result<String> {
1392 key(&inputs(
1393 snapshot,
1394 workspace,
1395 task,
1396 prepared,
1397 dependencies,
1398 cancelled,
1399 )?)
1400}
1401
1402pub(crate) fn dependency_keys(
1406 snapshot: &Snapshot,
1407 workspace: &Workspace,
1408 graph: &TaskGraph,
1409 task: &Task,
1410 dependencies: &BTreeMap<String, String>,
1411 cancelled: &AtomicBool,
1412) -> Result<BTreeMap<String, String>> {
1413 dependency_keys_impl(
1414 snapshot,
1415 workspace,
1416 graph,
1417 task,
1418 dependencies,
1419 cancelled,
1420 false,
1421 )
1422}
1423
1424pub(crate) fn recheck_dependency_keys(
1426 snapshot: &Snapshot,
1427 workspace: &Workspace,
1428 graph: &TaskGraph,
1429 task: &Task,
1430 dependencies: &BTreeMap<String, String>,
1431 cancelled: &AtomicBool,
1432) -> Result<BTreeMap<String, String>> {
1433 dependency_keys_impl(
1434 snapshot,
1435 workspace,
1436 graph,
1437 task,
1438 dependencies,
1439 cancelled,
1440 true,
1441 )
1442}
1443
1444fn dependency_keys_impl(
1445 snapshot: &Snapshot,
1446 workspace: &Workspace,
1447 graph: &TaskGraph,
1448 task: &Task,
1449 dependencies: &BTreeMap<String, String>,
1450 cancelled: &AtomicBool,
1451 fresh: bool,
1452) -> Result<BTreeMap<String, String>> {
1453 let resolved = resolve(snapshot, workspace, task, None, cancelled)?;
1454 let selections: Vec<_> = resolved
1455 .values
1456 .iter()
1457 .filter_map(|(name, transitive)| {
1458 name.strip_prefix("dependentTasksOutputFiles:")
1459 .map(|pattern| (pattern, transitive == &json!(true)))
1460 })
1461 .collect();
1462 if selections.is_empty() {
1463 if fresh {
1464 return dependencies
1465 .iter()
1466 .map(|(id, key)| {
1467 let outputs = Outputs::new(workspace, &graph.tasks[id])?;
1468 let key = if outputs.declared() {
1469 crate::output_fingerprint(&workspace.root, &outputs, "")?
1470 } else {
1471 key.clone()
1472 };
1473 Ok((id.clone(), key))
1474 })
1475 .collect();
1476 }
1477 return Ok(dependencies.clone());
1478 }
1479 let mut keys = BTreeMap::new();
1480 let mut fresh_outputs = HashMap::new();
1481 for (pattern, transitive) in selections {
1482 let matcher = snapshot.pattern(pattern, false)?;
1483 let mut selected = task.dependencies.clone();
1484 if transitive {
1485 let mut pending = selected.clone();
1486 while let Some(id) = pending.pop_first() {
1487 for dependency in &graph.tasks[&id].dependencies {
1488 if selected.insert(dependency.clone()) {
1489 pending.insert(dependency.clone());
1490 }
1491 }
1492 }
1493 }
1494 for id in selected {
1495 let outputs = if fresh {
1496 if !fresh_outputs.contains_key(&id) {
1497 fresh_outputs.insert(
1498 id.clone(),
1499 Arc::new(snapshot.fresh_dependency_outputs(workspace, &graph.tasks[&id])?),
1500 );
1501 }
1502 fresh_outputs[&id].clone()
1503 } else {
1504 snapshot.dependency_outputs(workspace, &graph.tasks[&id])?
1505 };
1506 let mut files = BTreeMap::new();
1507 for path in outputs.files.iter().filter(|path| matcher.is_match(path)) {
1508 let known = outputs.values.lock().unwrap().get(path).cloned();
1509 let value = match known {
1510 Some(value) => value,
1511 None => {
1512 let value = snapshot.file_value(&workspace.root, path)?;
1513 if value.get("link").is_none() {
1515 outputs
1516 .values
1517 .lock()
1518 .unwrap()
1519 .insert(path.clone(), value.clone());
1520 }
1521 value
1522 }
1523 };
1524 files.insert(path.clone(), value);
1525 }
1526 keys.insert(format!("{id}:{pattern}"), key(&json!(files))?);
1527 }
1528 }
1529 Ok(keys)
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534 use super::*;
1535 use qk_taskgraph::Request;
1536
1537 fn fixture(outputs: Option<Value>) -> (tempfile::TempDir, Workspace, TaskGraph) {
1538 let root = tempfile::tempdir().unwrap();
1539 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1540 let mut post = json!({"command": "true"});
1541 if let Some(outputs) = outputs {
1542 post["outputs"] = outputs;
1543 }
1544 std::fs::write(root.path().join("project.json"), json!({"name":"app", "targets": {
1545 "gen": {"command":"true", "outputs":["generated"]},
1546 "post": post,
1547 "build": {"command":"true", "dependsOn":["gen", "post"], "inputs":[{"dependentTasksOutputFiles":"**/*"}]}
1548 }}).to_string()).unwrap();
1549 std::fs::create_dir(root.path().join("generated")).unwrap();
1550 std::fs::write(root.path().join("generated/value"), "one").unwrap();
1551 let workspace = Workspace::load(root.path()).unwrap();
1552 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1553 (root, workspace, graph)
1554 }
1555
1556 #[test]
1557 fn undeclared_writes_invalidate_dependency_values() {
1558 for outputs in [None, Some(json!(["generated"]))] {
1559 let (root, workspace, graph) = fixture(outputs);
1560 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
1561 let keys = || {
1562 dependency_keys(
1563 &snapshot,
1564 &workspace,
1565 &graph,
1566 &graph.tasks["app:build"],
1567 &BTreeMap::new(),
1568 &AtomicBool::new(false),
1569 )
1570 .unwrap()
1571 };
1572 let before = keys();
1573 std::fs::write(root.path().join("generated/value"), "different").unwrap();
1574 snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
1575 assert_ne!(before, keys());
1576 }
1577 }
1578
1579 #[cfg(unix)]
1580 #[test]
1581 fn dependency_symlinks_recheck_targets_outside_output_roots() {
1582 let (root, mut workspace, _) = fixture(Some(json!(["generated"])));
1583 workspace
1584 .projects
1585 .get_mut("app")
1586 .unwrap()
1587 .targets
1588 .get_mut("gen")
1589 .unwrap()
1590 .outputs = Some(vec!["links".into()]);
1591 std::fs::create_dir(root.path().join("links")).unwrap();
1592 std::os::unix::fs::symlink("../generated/value", root.path().join("links/value")).unwrap();
1593 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1594 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
1595 let keys = || {
1596 dependency_keys(
1597 &snapshot,
1598 &workspace,
1599 &graph,
1600 &graph.tasks["app:build"],
1601 &BTreeMap::new(),
1602 &AtomicBool::new(false),
1603 )
1604 .unwrap()
1605 };
1606 let before = keys();
1607 std::fs::write(root.path().join("generated/value"), "different").unwrap();
1608 snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
1609 assert_ne!(before["app:gen:**/*"], keys()["app:gen:**/*"]);
1610 }
1611}