1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Component, Path, PathBuf};
3use std::process::{Command, Output};
4
5use anyhow::{Context, Result, bail};
6use qk_config::Workspace;
7use qk_taskgraph::Task;
8
9pub fn git(root: &Path, args: &[&str]) -> std::io::Result<Output> {
10 Command::new("git")
11 .arg("-C")
12 .arg(root)
13 .args(args)
14 .env_remove("GIT_DIR")
15 .env_remove("GIT_WORK_TREE")
16 .env_remove("GIT_INDEX_FILE")
17 .env_remove("GIT_COMMON_DIR")
18 .output()
19}
20
21pub fn git_path(root: &Path, flag: &str) -> Option<PathBuf> {
22 let result = git(root, &["rev-parse", "--path-format=absolute", flag]).ok()?;
23 if !result.status.success() {
24 return None;
25 }
26 let path = String::from_utf8(result.stdout).ok()?;
27 Some(PathBuf::from(path.trim_end_matches(['\r', '\n'])))
28}
29
30fn dot_git(root: &Path) -> Option<(PathBuf, PathBuf)> {
36 let (parent, entry) = root
37 .ancestors()
38 .map(|dir| (dir, dir.join(".git")))
39 .find(|(_, entry)| entry.exists())?;
40 let git_dir = if entry.is_dir() {
41 entry
42 } else {
43 let content = std::fs::read_to_string(&entry).ok()?;
44 let named = content.strip_prefix("gitdir:")?.trim();
45 parent.join(named)
46 };
47 let common = match std::fs::read_to_string(git_dir.join("commondir")) {
48 Ok(named) => git_dir.join(named.trim()),
49 Err(_) => git_dir.clone(),
50 };
51 let absolute = |path: PathBuf| std::fs::canonicalize(&path).unwrap_or(path);
52 Some((absolute(git_dir), absolute(common)))
53}
54
55pub fn worktree_state(root: &Path) -> PathBuf {
60 static KNOWN: std::sync::OnceLock<
61 std::sync::Mutex<std::collections::HashMap<PathBuf, PathBuf>>,
62 > = std::sync::OnceLock::new();
63 let known = KNOWN.get_or_init(Default::default);
64 if let Some(state) = known.lock().unwrap().get(root) {
65 return state.clone();
66 }
67 let state = git_path(root, "--git-dir")
68 .or_else(|| dot_git(root).map(|(git_dir, _)| git_dir))
69 .map(|dir| dir.join("qk"))
70 .unwrap_or_else(|| root.join(".qk"));
71 known.lock().unwrap().insert(root.to_owned(), state.clone());
72 state
73}
74
75const WORKTREE_ENTRIES: &[&str] = &[
79 "digests.json",
80 "outputs",
81 "restore",
82 "sandbox",
83 "terminal-outputs",
84 "warm",
85];
86
87pub fn clear_worktree_state(root: &Path) -> Result<()> {
91 let state = worktree_state(root);
92 for entry in WORKTREE_ENTRIES {
93 let path = state.join(entry);
94 let removed = if path.is_dir() {
95 std::fs::remove_dir_all(&path)
96 } else {
97 std::fs::remove_file(&path)
98 };
99 match removed {
100 Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
101 return Err(error).with_context(|| format!("cannot remove {}", path.display()));
102 }
103 _ => {}
104 }
105 }
106 Ok(())
107}
108
109pub fn cache_location(workspace: &Workspace) -> PathBuf {
116 let configured = std::env::var_os("NX_CACHE_DIRECTORY")
117 .filter(|value| !value.is_empty())
118 .map(PathBuf::from)
119 .or_else(|| {
120 let extra = &workspace.config.extra;
121 extra
122 .get("cacheDirectory")
123 .or_else(|| {
124 extra
125 .get("tasksRunnerOptions")?
126 .pointer("/default/options/cacheDirectory")
127 })
128 .and_then(serde_json::Value::as_str)
129 .filter(|directory| !directory.is_empty())
130 .map(PathBuf::from)
131 });
132 match configured {
133 Some(directory) => workspace.root.join(directory).join("qk/v1"),
134 None => cache_directory(&workspace.root),
135 }
136}
137
138pub fn cache_directory(root: &Path) -> PathBuf {
140 git_path(root, "--git-common-dir")
141 .or_else(|| dot_git(root).map(|(_, common)| common))
142 .map(|dir| dir.join("qk/cache/v1"))
143 .unwrap_or_else(|| root.join(".qk/cache/v1"))
144}
145
146pub fn relative(root: &Path, path: &Path) -> Result<String> {
147 let parts = path
148 .strip_prefix(root)?
149 .components()
150 .map(|part| {
151 part.as_os_str()
152 .to_str()
153 .map(str::to_owned)
154 .context("cache paths must be UTF-8")
155 })
156 .collect::<Result<Vec<_>>>()?;
157 Ok(parts.join("/"))
158}
159
160pub fn validate_path(path: &str) -> Result<()> {
161 if path.is_empty()
162 || path.contains(['\\', '\0'])
163 || (cfg!(windows) && path.contains(':'))
164 || Path::new(path).is_absolute()
165 || Path::new(path)
166 .components()
167 .any(|part| !matches!(part, Component::Normal(_)))
168 || path
169 .split('/')
170 .any(|part| matches!(part, ".git" | ".qk" | ".." | "." | ""))
171 {
172 bail!("unsafe cache artifact path {path:?}");
173 }
174 Ok(())
175}
176
177fn validate_pattern(pattern: &str) -> Result<()> {
180 let mut path = String::new();
181 let mut chars = pattern.chars();
182 while let Some(c) = chars.next() {
183 path.push(if c == '\\' {
184 chars
185 .next()
186 .with_context(|| format!("unsafe cache artifact path {pattern:?}"))?
187 } else {
188 c
189 });
190 }
191 validate_path(&path)
192}
193
194pub fn safe_parents(root: &Path, path: &str) -> Result<()> {
195 validate_path(path)?;
196 let mut current = root.to_owned();
197 for part in Path::new(path)
198 .parent()
199 .into_iter()
200 .flat_map(Path::components)
201 {
202 current.push(part);
203 match std::fs::symlink_metadata(¤t) {
204 Ok(metadata) if metadata.file_type().is_symlink() => {
205 bail!("cache artifact parent is a symlink: {}", current.display())
206 }
207 Ok(metadata) if !metadata.is_dir() => bail!(
208 "cache artifact parent is not a directory: {}",
209 current.display()
210 ),
211 Ok(_) => {}
212 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
213 Err(error) => return Err(error.into()),
214 }
215 }
216 Ok(())
217}
218
219pub use crate::glob::Pattern;
220
221pub fn expand(workspace: &Workspace, project: &str, text: &str) -> Result<String> {
226 let root = &workspace.projects[project].root;
227 expand_with(text, root, project)
228}
229
230pub fn expand_glob(workspace: &Workspace, project: &str, text: &str) -> Result<String> {
234 let root = &workspace.projects[project].root;
235 let root = if root == "." {
236 root.clone()
237 } else {
238 crate::glob::escape(root)
239 };
240 expand_with(text, &root, &crate::glob::escape(project))
241}
242
243fn expand_with(text: &str, project_root: &str, project_name: &str) -> Result<String> {
244 let prefix = if project_root == "." {
245 String::new()
246 } else {
247 format!("{project_root}/")
248 };
249 let text = match text.strip_prefix("{workspaceRoot}") {
250 Some(rest) => rest.strip_prefix('/').unwrap_or(match rest {
251 "" => ".",
252 rest => rest,
253 }),
254 None => text,
255 };
256 let result = text
257 .replace("{projectRoot}/", &prefix)
258 .replace("{projectRoot}", project_root)
259 .replace("{projectName}", project_name);
260 normalize(&result)
261}
262
263fn normalize(path: &str) -> Result<String> {
265 let mut parts: Vec<&str> = Vec::new();
266 for part in path.split('/') {
267 match part {
268 "." => {}
269 ".." => match parts.pop() {
270 Some(parent) if crate::glob::names_a_path(parent) => {}
271 _ => bail!("cache path {path:?} leaves the workspace or follows a glob with `..`"),
272 },
273 part => parts.push(part),
274 }
275 }
276 Ok(if parts.is_empty() {
277 ".".to_owned()
278 } else {
279 parts.join("/")
280 })
281}
282
283type OutputEntries<'a> = Box<dyn Iterator<Item = Result<(String, std::fs::Metadata)>> + 'a>;
284
285#[derive(Clone)]
286pub struct Outputs {
287 patterns: Vec<Pattern>,
288 globs: Vec<String>,
290 negations: Vec<Pattern>,
292 anchors: BTreeSet<String>,
295 complete: BTreeSet<String>,
298 explicit: bool,
301}
302
303impl Outputs {
304 pub fn from_paths(patterns: &[String]) -> Result<Self> {
308 let mut compiled = Vec::new();
309 let mut globs = Vec::new();
310 let mut negations = Vec::new();
311 let mut anchors = BTreeSet::new();
312 let mut complete = BTreeSet::new();
313 for pattern in patterns {
314 if let Some(excluded) = pattern.strip_prefix('!') {
315 validate_pattern(excluded)?;
316 negations.push(Pattern::new(excluded, false)?);
317 continue;
318 }
319 validate_pattern(pattern)?;
320 let (anchor, glob) = crate::ignored::partition(pattern);
321 if anchor.is_empty() {
322 bail!("{pattern:?} needs a fixed directory prefix");
323 }
324 if glob.is_none() {
325 complete.insert(anchor.clone());
326 }
327 compiled.push(Pattern::new(pattern, false)?);
328 globs.push(pattern.clone());
329 anchors.insert(anchor);
330 }
331 Ok(Self {
332 patterns: compiled,
333 globs,
334 negations,
335 anchors,
336 complete,
337 explicit: true,
338 })
339 }
340
341 #[cfg(test)]
343 pub fn from_patterns(patterns: &[&str]) -> Self {
344 Self {
345 patterns: patterns
346 .iter()
347 .map(|pattern| Pattern::new(pattern, false).unwrap())
348 .collect(),
349 globs: patterns
350 .iter()
351 .map(|pattern| (*pattern).to_owned())
352 .collect(),
353 anchors: patterns
354 .iter()
355 .map(|pattern| (*pattern).to_owned())
356 .collect(),
357 complete: patterns
358 .iter()
359 .filter(|pattern| pattern.split('/').all(crate::glob::is_literal))
360 .map(|pattern| (*pattern).to_owned())
361 .collect(),
362 negations: Vec::new(),
363 explicit: true,
364 }
365 }
366
367 pub fn new(workspace: &Workspace, task: &Task) -> Result<Self> {
369 let (resolved, explicit) = resolved_outputs(workspace, task)?;
370 let mut patterns = Vec::new();
371 let mut globs = Vec::new();
372 let mut negations = Vec::new();
373 let mut anchors = BTreeSet::new();
374 let mut complete = BTreeSet::new();
375 for output in resolved {
376 let (negated, pattern) = match output.strip_prefix('!') {
377 Some(rest) => (true, rest.to_owned()),
378 None => (false, output),
379 };
380 validate_pattern(&pattern)?;
381 if negated {
382 negations.push(Pattern::new(&pattern, false)?);
383 continue;
384 }
385 let (anchor, glob) = crate::ignored::partition(&pattern);
386 if anchor.is_empty()
389 || glob.is_none()
390 && workspace.projects.values().any(|project| {
391 project.root == anchor
392 || project
393 .root
394 .strip_prefix(anchor.as_str())
395 .is_some_and(|rest| rest.starts_with('/'))
396 })
397 {
398 bail!(
399 "cache outputs must have a fixed directory prefix and cannot replace a project root"
400 );
401 }
402 if glob.is_none() {
403 complete.insert(anchor.clone());
404 }
405 patterns.push(Pattern::new(&pattern, false)?);
406 globs.push(pattern);
407 anchors.insert(anchor);
408 }
409 Ok(Self {
410 patterns,
411 globs,
412 negations,
413 anchors,
414 complete,
415 explicit,
416 })
417 }
418
419 pub fn is_explicit(&self) -> bool {
421 self.explicit
422 }
423
424 pub fn declared(&self) -> bool {
428 self.explicit && !self.patterns.is_empty()
429 }
430
431 pub(crate) fn globs(&self) -> &[String] {
433 &self.globs
434 }
435
436 pub fn anchors(&self) -> impl Iterator<Item = &str> {
438 self.anchors.iter().map(String::as_str)
439 }
440
441 pub(crate) fn complete_roots(&self) -> Vec<&str> {
444 if !self.explicit || !self.negations.is_empty() {
445 return Vec::new();
446 }
447 let mut roots: Vec<&str> = Vec::new();
448 for root in &self.complete {
449 if !roots.iter().any(|parent| {
450 root.strip_prefix(parent)
451 .is_some_and(|suffix| suffix.starts_with('/'))
452 }) {
453 roots.push(root);
454 }
455 }
456 roots
457 }
458
459 pub fn matches(&self, path: &str) -> bool {
460 let any = |patterns: &[Pattern]| {
461 Path::new(path)
462 .ancestors()
463 .any(|ancestor| patterns.iter().any(|pattern| pattern.is_match(ancestor)))
464 };
465 (self.complete.iter().any(|root| {
466 path == root
467 || path
468 .strip_prefix(root)
469 .is_some_and(|suffix| suffix.starts_with('/'))
470 }) || any(&self.patterns))
471 && !any(&self.negations)
472 }
473
474 pub fn paths(&self, root: &Path) -> Result<BTreeSet<String>> {
475 self.listing(root).map(|(paths, _)| paths)
476 }
477
478 pub(crate) fn entries<'a>(&self, root: &'a Path) -> Result<OutputEntries<'a>> {
479 if self.explicit && self.negations.is_empty() && self.complete.len() == self.globs.len() {
480 let mut walks = Vec::new();
481 let mut symlink = false;
482 for anchor in &self.anchors {
483 safe_parents(root, anchor)?;
484 match std::fs::symlink_metadata(root.join(anchor)) {
485 Ok(metadata) => symlink |= metadata.file_type().is_symlink(),
486 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
487 Err(error) => return Err(error.into()),
488 }
489 }
490 for anchor in self.complete_roots() {
491 let absolute = root.join(anchor);
492 match std::fs::symlink_metadata(&absolute) {
493 Ok(_) => walks.push(walkdir::WalkDir::new(absolute).follow_links(false)),
494 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
495 Err(error) => return Err(error.into()),
496 }
497 }
498 if !symlink {
499 return Ok(Box::new(walks.into_iter().flatten().map(move |entry| {
500 let entry = entry?;
501 let path = relative(root, entry.path())?;
502 validate_path(&path)?;
503 Ok((path, entry.metadata()?))
504 })));
505 }
506 }
507 let (paths, mut traversal) = self.listing(root)?;
508 Ok(Box::new(paths.into_iter().map(move |path| {
509 let absolute = root.join(&path);
510 let metadata = match traversal.metadata.remove(&absolute) {
511 Some(metadata) => metadata,
512 None => std::fs::symlink_metadata(&absolute)?,
513 };
514 Ok((path, metadata))
515 })))
516 }
517
518 fn listing(&self, root: &Path) -> Result<(BTreeSet<String>, Traversal)> {
519 if let Some(segments) = self.simple_segments()? {
520 for anchor in &self.anchors {
521 safe_parents(root, anchor)?;
522 match std::fs::symlink_metadata(root.join(anchor)) {
523 Ok(metadata) if metadata.file_type().is_symlink() => {
524 return self
525 .walk_paths(root)
526 .map(|paths| (paths, Traversal::default()));
527 }
528 Ok(_) => {}
529 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
530 Err(error) => return Err(error.into()),
531 }
532 }
533 let mut traversal = Traversal::default();
534 let mut candidates = BTreeSet::new();
535 for (glob, pattern) in self.globs.iter().zip(&segments) {
536 let fixed = pattern
537 .iter()
538 .take_while(|segment| matches!(segment, Segment::Literal(_)))
539 .count();
540 let anchor = glob.split('/').take(fixed).collect::<Vec<_>>().join("/");
541 traversal.expand(root, &root.join(anchor), &pattern[fixed..], &mut candidates)?;
542 }
543 return candidates
544 .into_iter()
545 .filter(|path| self.matches(path))
546 .map(|path| {
547 validate_path(&path)?;
548 Ok(path)
549 })
550 .collect::<Result<_>>()
551 .map(|paths| (paths, traversal));
552 }
553 self.walk_paths(root)
554 .map(|paths| (paths, Traversal::default()))
555 }
556
557 fn simple_segments(&self) -> Result<Option<Vec<Vec<Segment>>>> {
558 if self.globs.iter().any(|glob| {
559 glob.contains("**")
560 || glob.contains(['!', '@', '+', '|', ',', '{', '}', '[', ']', '(', ')'])
561 }) {
562 return Ok(None);
563 }
564 let mut matchers: BTreeMap<String, Pattern> = BTreeMap::new();
565 let mut patterns = Vec::new();
566 for glob in &self.globs {
567 let mut segments = Vec::new();
568 for part in glob.split('/') {
569 segments.push(if crate::glob::is_literal(part) {
570 Segment::Literal(part.to_owned())
571 } else {
572 let matcher = match matchers.entry(part.to_owned()) {
573 std::collections::btree_map::Entry::Occupied(entry) => entry.get().clone(),
574 std::collections::btree_map::Entry::Vacant(entry) => {
575 entry.insert(Pattern::new(part, false)?).clone()
576 }
577 };
578 Segment::Glob(matcher)
579 });
580 }
581 patterns.push(segments);
582 }
583 Ok(Some(patterns))
584 }
585
586 fn walk_paths(&self, root: &Path) -> Result<BTreeSet<String>> {
587 let mut paths = BTreeSet::new();
588 for anchor in &self.anchors {
589 safe_parents(root, anchor)?;
590 let path = root.join(anchor);
591 match std::fs::symlink_metadata(&path) {
592 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
593 Err(error) => return Err(error.into()),
594 Ok(_) => {}
595 }
596 let walk = walkdir::WalkDir::new(path)
597 .follow_links(false)
598 .into_iter()
599 .filter_entry(|entry| {
600 entry.depth() == 0
601 || !entry.file_type().is_dir()
602 || relative(root, entry.path()).map_or(true, |directory| {
603 self.globs
604 .iter()
605 .any(|glob| may_match_below(glob, &directory))
606 })
607 });
608 for entry in walk {
609 let entry = entry?;
610 let path = relative(root, entry.path())?;
611 if self.matches(&path) {
612 validate_path(&path)?;
613 paths.insert(path);
614 }
615 }
616 }
617 Ok(paths)
618 }
619}
620
621#[derive(Clone)]
622enum Segment {
623 Literal(String),
624 Glob(Pattern),
625}
626
627#[derive(Default)]
628struct Traversal {
629 metadata: BTreeMap<PathBuf, std::fs::Metadata>,
630 directories: BTreeMap<PathBuf, Vec<PathBuf>>,
631}
632
633impl Traversal {
634 fn metadata(&mut self, path: &Path) -> Result<Option<std::fs::Metadata>> {
635 if let Some(metadata) = self.metadata.get(path) {
636 return Ok(Some(metadata.clone()));
637 }
638 match std::fs::symlink_metadata(path) {
639 Ok(metadata) => {
640 self.metadata.insert(path.to_owned(), metadata.clone());
641 Ok(Some(metadata))
642 }
643 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
644 Err(error) => Err(error.into()),
645 }
646 }
647
648 fn children(&mut self, path: &Path) -> Result<&Vec<PathBuf>> {
649 if !self.directories.contains_key(path) {
650 let children = std::fs::read_dir(path)?
651 .map(|entry| entry.map(|entry| entry.path()))
652 .collect::<std::io::Result<Vec<_>>>()?;
653 self.directories.insert(path.to_owned(), children);
654 }
655 Ok(&self.directories[path])
656 }
657
658 fn expand(
659 &mut self,
660 root: &Path,
661 path: &Path,
662 segments: &[Segment],
663 paths: &mut BTreeSet<String>,
664 ) -> Result<()> {
665 let Some(metadata) = self.metadata(path)? else {
666 return Ok(());
667 };
668 let Some((segment, rest)) = segments.split_first() else {
669 for entry in walkdir::WalkDir::new(path)
670 .follow_links(false)
671 .follow_root_links(false)
672 {
673 paths.insert(relative(root, entry?.path())?);
674 }
675 return Ok(());
676 };
677 if !metadata.is_dir() {
678 return Ok(());
679 }
680 match segment {
681 Segment::Literal(name) => {
682 let candidate = path.join(name);
683 if self.metadata(&candidate)?.is_none() {
684 return Ok(());
685 }
686 let child = self
688 .children(path)?
689 .iter()
690 .find(|child| child.file_name() == Some(std::ffi::OsStr::new(name)))
691 .cloned();
692 if let Some(child) = child {
693 self.expand(root, &child, rest, paths)?;
694 }
695 Ok(())
696 }
697 Segment::Glob(matcher) => {
698 let children: Vec<_> = self
699 .children(path)?
700 .iter()
701 .filter(|child| matcher.is_match(child.file_name().unwrap()))
702 .cloned()
703 .collect();
704 for child in children {
705 self.expand(root, &child, rest, paths)?;
706 }
707 Ok(())
708 }
709 }
710 }
711}
712
713fn may_match_below(glob: &str, directory: &str) -> bool {
718 if glob.contains(['{', '}', '(', ')', '[', ']']) {
719 return true;
720 }
721 for (part, name) in glob.split('/').zip(directory.split('/')) {
722 if part == "**" {
723 return true;
724 }
725 if crate::glob::is_literal(part) && part != name {
726 return false;
727 }
728 }
729 true
730}
731
732pub fn resolved_outputs(workspace: &Workspace, task: &Task) -> Result<(Vec<String>, bool)> {
739 let root = &workspace.projects[&task.project].root;
740 let options = task_options(task);
741 let mut resolved = Vec::new();
742 let Some(templates) = &task.definition.outputs else {
743 let defaults: Vec<String> = match options.get("outputPath") {
747 Some(path @ serde_json::Value::String(text)) if !text.is_empty() => {
748 vec![option_tokens(workspace, &task.project, path)?]
749 }
750 Some(serde_json::Value::Array(paths)) => paths
751 .iter()
752 .filter(|path| path.as_str().is_some_and(|text| !text.is_empty()))
753 .map(|path| option_tokens(workspace, &task.project, path))
754 .collect::<Result<_>>()?,
755 _ if matches!(task.target.as_str(), "build" | "prepare") => {
756 let root = crate::glob::escape(root);
758 [
759 format!("dist/{root}"),
760 format!("{root}/dist"),
761 format!("{root}/build"),
762 format!("{root}/public"),
763 ]
764 .into()
765 }
766 _ => Vec::new(),
767 };
768 for output in defaults {
769 let (negated, path) = match output.strip_prefix('!') {
770 Some(rest) => (true, rest),
771 None => (false, output.as_str()),
772 };
773 let path = workspace_relative(workspace, path)?;
774 resolved.push(if negated { format!("!{path}") } else { path });
775 }
776 return Ok((resolved, false));
777 };
778 qk_taskgraph::validate_outputs(templates)?;
779 let data = OutputData {
780 workspace,
781 project: &task.project,
782 options: &options,
783 };
784 for template in templates {
785 let (negated, template) = match template.strip_prefix('!') {
786 Some(rest) => (true, rest),
787 None => (false, template.as_str()),
788 };
789 let Some(output) = interpolate_output(template, &data)? else {
790 continue;
791 };
792 let path = workspace_relative(workspace, &output)?;
793 let path = if negated { format!("!{path}") } else { path };
794 if !resolved.contains(&path) {
796 resolved.push(path);
797 }
798 }
799 Ok((resolved, true))
800}
801
802fn verbatim_free(path: &Path) -> PathBuf {
804 match path.to_str().and_then(|text| text.strip_prefix(r"\\?\")) {
805 Some(rest) if !rest.starts_with("UNC\\") => PathBuf::from(rest),
806 _ => path.to_path_buf(),
807 }
808}
809
810fn workspace_relative(workspace: &Workspace, output: &str) -> Result<String> {
814 let owned;
815 let relative = if Path::new(output).is_absolute() || output.starts_with('/') {
816 let inside = verbatim_free(Path::new(output))
819 .strip_prefix(verbatim_free(&workspace.root))
820 .ok()
821 .map(|rest| {
822 rest.components()
823 .map(|part| part.as_os_str().to_string_lossy())
824 .collect::<Vec<_>>()
825 .join("/")
826 });
827 match inside {
828 Some(rest) => {
829 owned = rest;
830 owned.as_str()
831 }
832 None => bail!("cache output is outside the workspace: {output}"),
833 }
834 } else {
835 output
836 };
837 match normalize(relative) {
838 Ok(path) => Ok(path.trim_end_matches('/').to_owned()),
839 Err(_) => bail!("cache output is outside the workspace: {output}"),
840 }
841}
842
843fn task_options(task: &Task) -> serde_json::Map<String, serde_json::Value> {
846 let mut options = task.definition.options.clone();
847 let mut args = task.args.iter().peekable();
848 while let Some(arg) = args.next() {
849 let Some(flag) = arg.strip_prefix("--") else {
850 continue;
851 };
852 let (key, value) = match flag.split_once('=') {
853 Some((key, value)) => (key.to_owned(), value.to_owned()),
854 None if args.peek().is_some_and(|next| !next.starts_with('-')) => {
855 (flag.to_owned(), args.next().unwrap().clone())
856 }
857 None => (flag.to_owned(), "true".to_owned()),
858 };
859 options.insert(key, serde_json::Value::String(value));
860 }
861 options
862}
863
864struct OutputData<'a> {
867 workspace: &'a Workspace,
868 project: &'a str,
869 options: &'a serde_json::Map<String, serde_json::Value>,
870}
871
872impl OutputData<'_> {
873 fn is_token(name: &str) -> bool {
874 matches!(name, "projectRoot" | "projectName" | "project" | "options")
875 }
876
877 fn read(&self, name: &str, path: Option<&str>) -> Result<Option<serde_json::Value>> {
878 let value = match name {
879 "projectRoot" => {
880 serde_json::Value::String(self.workspace.projects[self.project].root.clone())
881 }
882 "projectName" => serde_json::Value::String(self.project.to_owned()),
883 "project" => {
884 let mut project = serde_json::to_value(&self.workspace.projects[self.project])?;
885 project["name"] = serde_json::Value::String(self.project.to_owned());
886 project
887 }
888 _ => serde_json::Value::Object(self.options.clone()),
889 };
890 let Some(found) = read_path(&value, path) else {
891 return Ok(None);
892 };
893 Ok(Some(if name == "options" {
900 resolve_option_tokens(self.workspace, self.project, &found)?
901 } else {
902 escape_strings(&found)
903 }))
904 }
905}
906
907fn escape_strings(value: &serde_json::Value) -> serde_json::Value {
909 match value {
910 serde_json::Value::String(text) => serde_json::Value::String(crate::glob::escape(text)),
911 serde_json::Value::Array(items) => items.iter().map(escape_strings).collect(),
912 serde_json::Value::Object(object) => serde_json::Value::Object(
913 object
914 .iter()
915 .map(|(key, item)| (key.clone(), escape_strings(item)))
916 .collect(),
917 ),
918 other => other.clone(),
919 }
920}
921
922fn read_path(value: &serde_json::Value, path: Option<&str>) -> Option<serde_json::Value> {
926 let Some(path) = path else {
927 return Some(value.clone());
928 };
929 let index = |key: &str| {
930 (key == "0" || (!key.starts_with('0') && key.bytes().all(|b| b.is_ascii_digit())))
931 .then(|| key.parse::<usize>().ok())
932 .flatten()
933 };
934 let mut value = value.clone();
935 for key in path.split('.') {
936 value = match &value {
937 serde_json::Value::Object(object) => object.get(key)?.clone(),
938 serde_json::Value::Array(items) if key == "length" => items.len().into(),
939 serde_json::Value::Array(items) => items.get(index(key)?)?.clone(),
940 serde_json::Value::String(text) => {
942 let units: Vec<u16> = text.encode_utf16().collect();
943 if key == "length" {
944 units.len().into()
945 } else {
946 String::from_utf16_lossy(&[*units.get(index(key)?)?]).into()
947 }
948 }
949 _ => return None,
950 };
951 }
952 Some(value)
953}
954
955fn option_tokens(
958 workspace: &Workspace,
959 project: &str,
960 value: &serde_json::Value,
961) -> Result<String> {
962 Ok(text(&resolve_option_tokens(workspace, project, value)?).unwrap_or_default())
963}
964
965fn resolve_option_tokens(
969 workspace: &Workspace,
970 project: &str,
971 value: &serde_json::Value,
972) -> Result<serde_json::Value> {
973 Ok(match value {
974 serde_json::Value::String(text) => {
975 let text = text
976 .strip_prefix("{workspaceRoot}/")
977 .or_else(|| text.strip_prefix("{workspaceRoot}"))
978 .unwrap_or(text);
979 if text.contains("{workspaceRoot}") {
980 bail!(
981 "the {{workspaceRoot}} token is only valid at the beginning of an option ({project})"
982 );
983 }
984 serde_json::Value::String(
985 text.replace("{projectRoot}", &workspace.projects[project].root)
986 .replace("{projectName}", project),
987 )
988 }
989 serde_json::Value::Array(items) => serde_json::Value::Array(
990 items
991 .iter()
992 .map(|item| resolve_option_tokens(workspace, project, item))
993 .collect::<Result<_>>()?,
994 ),
995 serde_json::Value::Object(object) => serde_json::Value::Object(
996 object
997 .iter()
998 .map(|(key, item)| {
999 Ok((
1000 key.clone(),
1001 resolve_option_tokens(workspace, project, item)?,
1002 ))
1003 })
1004 .collect::<Result<_>>()?,
1005 ),
1006 other => other.clone(),
1007 })
1008}
1009
1010fn text(value: &serde_json::Value) -> Option<String> {
1013 let text = match value {
1014 serde_json::Value::Null => return None,
1015 serde_json::Value::String(text) => text.clone(),
1016 serde_json::Value::Bool(flag) => flag.to_string(),
1017 serde_json::Value::Number(number) => match number.as_f64() {
1018 Some(float) if number.is_f64() && float.fract() == 0.0 && float.abs() < 1e21 => {
1019 format!("{float:.0}")
1020 }
1021 _ => number.to_string(),
1022 },
1023 serde_json::Value::Array(items) => items
1024 .iter()
1025 .map(|item| text(item).unwrap_or_default())
1026 .collect::<Vec<_>>()
1027 .join(","),
1028 serde_json::Value::Object(_) => "[object Object]".to_owned(),
1029 };
1030 (!text.is_empty()).then_some(text)
1031}
1032
1033enum Node<'t> {
1035 Text(String),
1036 Token { raw: &'t str, parts: Vec<Part<'t>> },
1037}
1038
1039enum Part<'t> {
1040 Reference {
1041 name: &'t str,
1042 path: Option<&'t str>,
1043 },
1044 Literal(Vec<Node<'t>>),
1045}
1046
1047fn parse_tokens(template: &str) -> Result<Vec<Node<'_>>> {
1050 let mut nodes = Vec::new();
1051 let mut text = String::new();
1052 let mut index = 0;
1053 while let Some(offset) = template[index..].find('{') {
1054 let open = index + offset;
1055 let token = match qk_executor::tokens::closing_brace(template, open) {
1056 Some(close) => parse_token(&template[open..=close])?.map(|token| (token, close)),
1057 None => None,
1058 };
1059 let Some((token, close)) = token else {
1060 text.push_str(&template[index..=open]);
1061 index = open + 1;
1062 continue;
1063 };
1064 text.push_str(&template[index..open]);
1065 if !text.is_empty() {
1066 nodes.push(Node::Text(std::mem::take(&mut text)));
1067 }
1068 nodes.push(token);
1069 index = close + 1;
1070 }
1071 text.push_str(&template[index..]);
1072 if !text.is_empty() {
1073 nodes.push(Node::Text(text));
1074 }
1075 Ok(nodes)
1076}
1077
1078fn parse_token(raw: &str) -> Result<Option<Node<'_>>> {
1082 let sources = qk_executor::tokens::fallbacks(&raw[1..raw.len() - 1]);
1083 fn reference(source: &str) -> Option<(&str, Option<&str>)> {
1084 let (name, path) = match source.split_once('.') {
1085 Some((name, path)) => (name, Some(path)),
1086 None => (source, None),
1087 };
1088 OutputData::is_token(name).then_some((name, path))
1089 }
1090 if reference(sources[0]).is_none() {
1091 return Ok(None);
1092 }
1093 let mut parts = Vec::new();
1094 for (index, source) in sources.iter().enumerate() {
1095 parts.push(match reference(source) {
1096 Some((name, path)) => Part::Reference { name, path },
1097 None if index + 1 == sources.len() => Part::Literal(parse_tokens(source)?),
1098 None => {
1099 bail!("{raw:?} has a fallback after the literal {source:?} that can never be used")
1100 }
1101 });
1102 }
1103 Ok(Some(Node::Token { raw, parts }))
1104}
1105
1106fn workspace_root_only_at_start(nodes: &[Node], at_start: bool) -> bool {
1110 nodes.iter().enumerate().all(|(index, node)| {
1111 let node_at_start = at_start && index == 0;
1112 match node {
1113 Node::Text(text) => {
1114 let skip = usize::from(node_at_start).min(text.len());
1115 !text[skip..].contains("{workspaceRoot}")
1116 }
1117 Node::Token { parts, .. } => parts.iter().all(|part| match part {
1118 Part::Literal(nodes) => workspace_root_only_at_start(nodes, node_at_start),
1119 Part::Reference { .. } => true,
1120 }),
1121 }
1122 })
1123}
1124
1125fn interpolate_output(template: &str, data: &OutputData) -> Result<Option<String>> {
1133 let output = if template.starts_with('/') || !has_braces(template) {
1134 template.to_owned()
1135 } else {
1136 let nodes = parse_tokens(template)?;
1137 if !workspace_root_only_at_start(&nodes, true) {
1138 bail!(
1139 "output {template:?} is invalid: {{workspaceRoot}} can only be used at the beginning of the expression"
1140 );
1141 }
1142 let mut segments = vec![String::new()];
1143 render(&nodes, data, false, &mut segments)?;
1144 segments.retain(|segment| !segment.is_empty());
1146 join_path(&segments.join("/")).replacen("{workspaceRoot}/", "", 1)
1147 };
1148 let unresolved = ["{projectRoot}", "{workspaceRoot}"]
1149 .iter()
1150 .any(|token| output.contains(token))
1151 || output
1152 .find("{options")
1153 .is_some_and(|start| output[start..].contains('}'));
1154 Ok((!output.is_empty() && !unresolved).then_some(output))
1155}
1156
1157fn has_braces(template: &str) -> bool {
1159 template.find('{').is_some_and(|open| {
1160 template
1161 .get(open + 2..)
1162 .is_some_and(|rest| rest.contains('}'))
1163 })
1164}
1165
1166fn render(
1171 nodes: &[Node],
1172 data: &OutputData,
1173 literal: bool,
1174 segments: &mut Vec<String>,
1175) -> Result<()> {
1176 for (index, node) in nodes.iter().enumerate() {
1177 match node {
1178 Node::Text(text) => {
1179 let root = if literal && index == 0 && text.starts_with('/') {
1180 "/"
1181 } else {
1182 ""
1183 };
1184 let mut parts = text[root.len()..].split('/');
1185 let last = segments.last_mut().expect("segments start with one");
1186 last.push_str(root);
1187 last.push_str(parts.next().unwrap_or_default());
1188 segments.extend(parts.map(str::to_owned));
1189 }
1190 Node::Token { raw, parts } => {
1191 if *raw == "{projectRoot}" && data.workspace.projects[data.project].root == "." {
1193 continue;
1194 }
1195 let mut value = Some(*raw);
1196 let mut found = None;
1197 for part in parts {
1198 match part {
1199 Part::Literal(nodes) => {
1200 render(nodes, data, true, segments)?;
1201 value = None;
1202 break;
1203 }
1204 Part::Reference { name, path } => {
1205 found = data.read(name, *path)?.as_ref().and_then(text);
1206 if found.is_some() {
1207 break;
1208 }
1209 }
1210 }
1211 }
1212 let value = found.as_deref().or(value).unwrap_or_default();
1214 segments
1215 .last_mut()
1216 .expect("segments start with one")
1217 .push_str(value);
1218 }
1219 }
1220 }
1221 Ok(())
1222}
1223
1224fn join_path(path: &str) -> String {
1227 let absolute = path.starts_with('/');
1228 let mut parts: Vec<&str> = Vec::new();
1229 for part in path.split('/') {
1230 match part {
1231 "" | "." => {}
1232 ".." => match parts.last() {
1233 Some(&last) if last != ".." => {
1234 parts.pop();
1235 }
1236 _ if absolute => {}
1237 _ => parts.push(".."),
1238 },
1239 part => parts.push(part),
1240 }
1241 }
1242 let mut joined = parts.join("/");
1243 if absolute {
1244 joined.insert(0, '/');
1245 }
1246 if joined.is_empty() {
1247 joined.push('.');
1248 }
1249 if path.ends_with('/') && !joined.ends_with('/') {
1250 joined.push('/');
1251 }
1252 joined
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::{Outputs, Pattern, dot_git, normalize, resolved_outputs, verbatim_free};
1258 use qk_config::Workspace;
1259 use qk_taskgraph::{Request, TaskGraph};
1260 use serde_json::{Value, json};
1261 use std::path::{Path, PathBuf};
1262
1263 fn outputs_of(target: Value, args: &[&str]) -> anyhow::Result<Vec<String>> {
1266 let root = tempfile::tempdir().unwrap();
1267 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1268 std::fs::write(
1269 root.path().join("project.json"),
1270 json!({"name": "root"}).to_string(),
1271 )
1272 .unwrap();
1273 std::fs::create_dir_all(root.path().join("libs/lib")).unwrap();
1274 std::fs::write(
1275 root.path().join("libs/lib/project.json"),
1276 json!({"name": "lib", "sourceRoot": "libs/lib/src", "tags": ["a", "b"],
1277 "targets": {"build": target}})
1278 .to_string(),
1279 )
1280 .unwrap();
1281 let workspace = Workspace::load(root.path()).unwrap();
1282 let mut request = Request::parse("lib:build").unwrap();
1283 request.args = args.iter().map(|arg| (*arg).to_owned()).collect();
1284 let graph = TaskGraph::build(&workspace, &[request])?;
1285 resolved_outputs(&workspace, &graph.tasks["lib:build"]).map(|(outputs, _)| outputs)
1286 }
1287
1288 fn outputs(outputs: Value, options: Value) -> anyhow::Result<Vec<String>> {
1289 outputs_of(
1290 json!({"command": "true", "outputs": outputs, "options": options}),
1291 &[],
1292 )
1293 }
1294
1295 #[test]
1296 fn braces_that_are_no_token_stay_as_written() {
1297 assert_eq!(
1298 outputs(
1299 json!([
1300 "{projectRoot}/dist/**/*.{js,map}",
1301 "{workspaceRoot}/x/{a,{projectName}}"
1302 ]),
1303 json!({})
1304 )
1305 .unwrap(),
1306 ["libs/lib/dist/**/*.{js,map}", "x/{a,lib}"]
1307 );
1308 }
1309
1310 #[test]
1311 fn placeholders_fall_back_left_to_right() {
1312 let resolved = outputs(
1313 json!([
1314 "{options.a ?? options.b ?? dist/{projectName}}",
1315 "{options.missing ?? options.b}/x",
1316 "{options.missing ?? {workspaceRoot}/fallback}",
1317 "{workspaceRoot}/x/{env.HOME}",
1318 "{options.missing}",
1319 "{options.missing ?? options.other}",
1320 ]),
1321 json!({"b": "from-b"}),
1322 )
1323 .unwrap();
1324 assert_eq!(resolved, ["from-b", "from-b/x", "fallback", "x/{env.HOME}"]);
1327 assert!(outputs(json!(["{options.a ?? literal ?? options.b}"]), json!({})).is_err());
1328 }
1329
1330 #[test]
1331 fn option_values_read_as_javascript_writes_them() {
1332 assert_eq!(
1333 outputs(
1334 json!([
1335 "{workspaceRoot}/dist/{options.flag}",
1336 "{workspaceRoot}/dist/{options.zero}",
1337 "{workspaceRoot}/dist/{options.list}",
1338 "{workspaceRoot}/dist/{options.float}",
1339 "{workspaceRoot}/dist/{options.nested.deep}",
1340 "{workspaceRoot}/dist/{options.list.1}",
1341 "{workspaceRoot}/dist/{options.empty}",
1342 "{workspaceRoot}/dist/{options.none}",
1343 ]),
1344 json!({"flag": false, "zero": 0, "list": ["a", "b"], "float": 2.0,
1345 "nested": {"deep": "d"}, "empty": "", "none": null}),
1346 )
1347 .unwrap(),
1348 [
1349 "dist/false",
1350 "dist/0",
1351 "dist/a,b",
1352 "dist/2",
1353 "dist/d",
1354 "dist/b"
1355 ]
1356 );
1357 }
1358
1359 #[test]
1360 fn option_values_have_their_project_tokens_resolved() {
1361 assert_eq!(
1362 outputs(
1363 json!(["{options.outputPath}", "{options.list}"]),
1364 json!({"outputPath": "{workspaceRoot}/dist/{projectName}/{projectRoot}",
1365 "list": ["{projectName}"]}),
1366 )
1367 .unwrap(),
1368 ["dist/lib/libs/lib", "lib"]
1369 );
1370 assert_eq!(
1374 outputs_of(
1375 json!({"command": "true", "outputs": ["{options.out}"], "options": {"out": "a"}}),
1376 &["--out=b"],
1377 )
1378 .unwrap(),
1379 ["b"]
1380 );
1381 assert_eq!(
1384 outputs(
1385 json!([
1386 "{options.list.length}",
1387 "{options.list.1}",
1388 "{options.list.01}"
1389 ]),
1390 json!({"list": ["a", "b"]}),
1391 )
1392 .unwrap(),
1393 ["2", "b"]
1394 );
1395 assert_eq!(
1398 outputs_of(
1399 json!({"command": "true", "options": {"outputPath": ""}}),
1400 &[],
1401 )
1402 .unwrap(),
1403 [
1404 "dist/libs/lib",
1405 "libs/lib/dist",
1406 "libs/lib/build",
1407 "libs/lib/public"
1408 ]
1409 );
1410 assert_eq!(
1411 outputs_of(
1412 json!({"command": "true", "options": {"outputPath": ["", "dist"]}}),
1413 &[],
1414 )
1415 .unwrap(),
1416 ["dist"]
1417 );
1418 assert_eq!(
1420 outputs_of(
1421 json!({"command": "true", "options": {"outputPath": "dist/{projectName}"}}),
1422 &[],
1423 )
1424 .unwrap(),
1425 ["dist/lib"]
1426 );
1427 }
1428
1429 #[test]
1430 fn legacy_project_paths_read_the_project_configuration() {
1431 assert_eq!(
1432 outputs(
1433 json!([
1434 "{project.sourceRoot}/gen",
1435 "{workspaceRoot}/dist/{project.name}/{project.tags}",
1436 "{project.missing}/x"
1437 ]),
1438 json!({})
1439 )
1440 .unwrap(),
1441 ["libs/lib/src/gen", "dist/lib/a,b", "{project.missing}/x"]
1442 );
1443 }
1444
1445 #[test]
1446 fn workspace_root_outputs_name_a_directory() {
1447 for output in ["{workspaceRoot}/**", "{workspaceRoot}/*.js"] {
1448 assert!(outputs(json!([output]), json!({})).is_err(), "{output}");
1449 }
1450 assert_eq!(
1451 outputs(json!(["{workspaceRoot}/@scope/**"]), json!({})).unwrap(),
1452 ["@scope/**"]
1453 );
1454 }
1455
1456 #[test]
1457 fn workspace_root_is_allowed_only_at_the_start() {
1458 for output in ["{projectRoot}/{workspaceRoot}/dist", "dist/{workspaceRoot}"] {
1459 assert!(outputs(json!([output]), json!({})).is_err(), "{output}");
1460 }
1461 assert_eq!(
1462 outputs(
1463 json!([
1464 "!{workspaceRoot}/dist/x",
1465 "{workspaceRoot}/dist",
1466 "{workspaceRoot}"
1467 ]),
1468 json!({})
1469 )
1470 .unwrap(),
1471 ["!dist/x", "dist"]
1472 );
1473 }
1474
1475 #[test]
1476 fn a_root_projects_root_is_no_segment() {
1477 let root = tempfile::tempdir().unwrap();
1478 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1479 std::fs::write(
1480 root.path().join("project.json"),
1481 json!({"name": "app", "targets": {"build": {"command": "true",
1482 "outputs": ["{projectRoot}/dist", "{options.out ?? {projectRoot}/out}"]}}})
1483 .to_string(),
1484 )
1485 .unwrap();
1486 let workspace = Workspace::load(root.path()).unwrap();
1487 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1488 assert_eq!(
1489 resolved_outputs(&workspace, &graph.tasks["app:build"])
1490 .unwrap()
1491 .0,
1492 ["dist", "out"]
1493 );
1494 }
1495
1496 #[test]
1497 fn verbatim_prefixes_are_left_out_of_drive_paths_only() {
1498 assert_eq!(
1499 verbatim_free(Path::new(r"\\?\C:\repo\dist")),
1500 PathBuf::from(r"C:\repo\dist")
1501 );
1502 let unc = Path::new(r"\\?\UNC\server\share");
1503 assert_eq!(verbatim_free(unc), unc);
1504 assert_eq!(
1505 verbatim_free(Path::new("/repo/dist")),
1506 Path::new("/repo/dist")
1507 );
1508 }
1509
1510 #[test]
1511 fn absolute_outputs_must_lie_in_the_workspace() {
1512 let root = tempfile::tempdir().unwrap();
1513 let inside = std::fs::canonicalize(root.path()).unwrap();
1514 std::fs::write(inside.join("nx.json"), "{}").unwrap();
1515 std::fs::write(
1516 inside.join("project.json"),
1517 json!({"name": "app", "targets": {"build": {"command": "true",
1518 "outputs": ["{options.out}"], "options": {"out": inside.join("dist/app")}},
1519 "outside": {"command": "true", "outputs": ["{options.out}"],
1520 "options": {"out": "/elsewhere/dist"}}}})
1521 .to_string(),
1522 )
1523 .unwrap();
1524 let workspace = Workspace::load(&inside).unwrap();
1525 let graph = TaskGraph::build(
1526 &workspace,
1527 &[
1528 Request::parse("app:build").unwrap(),
1529 Request::parse("app:outside").unwrap(),
1530 ],
1531 )
1532 .unwrap();
1533 assert_eq!(
1534 resolved_outputs(&workspace, &graph.tasks["app:build"])
1535 .unwrap()
1536 .0,
1537 ["dist/app"]
1538 );
1539 assert!(resolved_outputs(&workspace, &graph.tasks["app:outside"]).is_err());
1540 }
1541
1542 #[test]
1543 fn dot_git_finds_a_linked_worktrees_directories_without_git() {
1544 let scratch = tempfile::tempdir().unwrap();
1545 let base = std::fs::canonicalize(scratch.path()).unwrap();
1546 let common = base.join("main/.git");
1547 let git_dir = common.join("worktrees/linked");
1548 std::fs::create_dir_all(&git_dir).unwrap();
1549 std::fs::write(git_dir.join("commondir"), "../..\n").unwrap();
1550 std::fs::create_dir_all(base.join("linked/apps/web")).unwrap();
1551 std::fs::write(
1552 base.join("linked/.git"),
1553 "gitdir: ../main/.git/worktrees/linked\n",
1554 )
1555 .unwrap();
1556 assert_eq!(
1557 dot_git(&base.join("linked/apps/web")),
1558 Some((git_dir, common.clone()))
1559 );
1560 assert_eq!(dot_git(&base.join("main")), Some((common.clone(), common)));
1561 assert_eq!(dot_git(&base.join("elsewhere")), None);
1562 }
1563
1564 #[test]
1565 fn literal_entries_match_listing_for_nested_and_missing_roots() {
1566 let workspace = tempfile::tempdir().unwrap();
1567 let root = workspace.path();
1568 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1569 std::fs::write(root.join("dist/nested/value"), "contents").unwrap();
1570 std::fs::write(root.join("single"), "one").unwrap();
1571 #[cfg(unix)]
1572 std::os::unix::fs::symlink(root.join("single"), root.join("dist/link")).unwrap();
1573 let outputs = Outputs::from_paths(&[
1574 "dist".into(),
1575 "dist/nested".into(),
1576 "missing".into(),
1577 "single".into(),
1578 ])
1579 .unwrap();
1580 let entries = outputs
1581 .entries(root)
1582 .unwrap()
1583 .collect::<anyhow::Result<Vec<_>>>()
1584 .unwrap();
1585 let paths: std::collections::BTreeSet<_> =
1586 entries.iter().map(|(path, _)| path.clone()).collect();
1587 assert_eq!(entries.len(), paths.len());
1588 assert_eq!(paths, outputs.paths(root).unwrap());
1589 for (path, metadata) in entries {
1590 let expected = std::fs::symlink_metadata(root.join(path)).unwrap();
1591 assert_eq!(metadata.file_type(), expected.file_type());
1592 assert_eq!(metadata.len(), expected.len());
1593 assert_eq!(metadata.modified().unwrap(), expected.modified().unwrap());
1594 }
1595 }
1596
1597 #[cfg(unix)]
1598 #[test]
1599 fn literal_entries_reject_nested_roots_below_a_symlink() {
1600 let workspace = tempfile::tempdir().unwrap();
1601 let root = workspace.path();
1602 std::fs::create_dir_all(root.join("dist")).unwrap();
1603 std::fs::create_dir_all(root.join("outside/nested")).unwrap();
1604 std::os::unix::fs::symlink(root.join("outside"), root.join("dist/link")).unwrap();
1605 let outputs = Outputs::from_paths(&["dist".into(), "dist/link/nested".into()]).unwrap();
1606 assert!(outputs.entries(root).is_err());
1607 }
1608
1609 #[test]
1612 fn outputs_escape_the_project_root_and_name() {
1613 let root = tempfile::tempdir().unwrap();
1614 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1615 std::fs::create_dir_all(root.path().join("apps/(group)/dist")).unwrap();
1616 std::fs::write(root.path().join("apps/(group)/dist/a.js"), "").unwrap();
1617 std::fs::write(
1618 root.path().join("apps/(group)/project.json"),
1619 json!({"name": "a+b", "sourceRoot": "apps/(group)/src", "targets": {
1620 "build": {"command": "true"},
1621 "emit": {"command": "true", "options": {"out": "out/*.js"}, "outputs": [
1622 "{projectRoot}/dist",
1623 "{workspaceRoot}/dist/{projectName}",
1624 "{project.sourceRoot}/gen",
1625 "{options.out}",
1626 ]},
1627 }})
1628 .to_string(),
1629 )
1630 .unwrap();
1631 let workspace = Workspace::load(root.path()).unwrap();
1632 let graph = TaskGraph::build(
1633 &workspace,
1634 &[
1635 Request::parse("a+b:emit").unwrap(),
1636 Request::parse("a+b:build").unwrap(),
1637 ],
1638 )
1639 .unwrap();
1640 assert_eq!(
1641 resolved_outputs(&workspace, &graph.tasks["a+b:emit"])
1642 .unwrap()
1643 .0,
1644 [
1645 r"apps/\(group\)/dist",
1646 r"dist/a\+b",
1647 r"apps/\(group\)/src/gen",
1648 "out/*.js"
1649 ]
1650 );
1651 assert_eq!(
1652 resolved_outputs(&workspace, &graph.tasks["a+b:build"])
1653 .unwrap()
1654 .0,
1655 [
1656 r"dist/apps/\(group\)",
1657 r"apps/\(group\)/dist",
1658 r"apps/\(group\)/build",
1659 r"apps/\(group\)/public"
1660 ]
1661 );
1662 let outputs = Outputs::new(&workspace, &graph.tasks["a+b:emit"]).unwrap();
1663 assert!(
1664 outputs
1665 .paths(root.path())
1666 .unwrap()
1667 .contains("apps/(group)/dist/a.js")
1668 );
1669 }
1670
1671 #[test]
1674 fn outputs_cannot_name_a_project_root() {
1675 let root = tempfile::tempdir().unwrap();
1676 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1677 std::fs::create_dir_all(root.path().join("apps/(group)")).unwrap();
1678 std::fs::write(
1679 root.path().join("apps/(group)/project.json"),
1680 json!({"name": "group", "targets": {
1681 "own": {"command": "true", "outputs": ["{projectRoot}"]},
1682 "written": {"command": "true", "outputs": ["{workspaceRoot}/apps/\\(group\\)"]},
1683 "parent": {"command": "true", "outputs": ["{workspaceRoot}/apps"]},
1684 "sibling": {"command": "true", "outputs": ["{workspaceRoot}/apps/\\(gr"]},
1685 }})
1686 .to_string(),
1687 )
1688 .unwrap();
1689 let workspace = Workspace::load(root.path()).unwrap();
1690 for (target, refused) in [
1691 ("own", true),
1692 ("written", true),
1693 ("parent", true),
1694 ("sibling", false),
1695 ] {
1696 let request = Request::parse(&format!("group:{target}")).unwrap();
1697 let graph = TaskGraph::build(&workspace, &[request]).unwrap();
1698 assert_eq!(
1699 Outputs::new(&workspace, &graph.tasks[&format!("group:{target}")]).is_err(),
1700 refused,
1701 "{target}"
1702 );
1703 }
1704 }
1705
1706 #[test]
1707 fn output_patterns_escape_glob_characters() {
1708 let workspace = tempfile::tempdir().unwrap();
1709 for file in [
1710 "app/(group)/page.js",
1711 "app/group/page.js",
1712 "dist/[id].js",
1713 "dist/i.js",
1714 ] {
1715 let path = workspace.path().join(file);
1716 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1717 std::fs::write(path, "").unwrap();
1718 }
1719 let outputs =
1720 Outputs::from_paths(&[r"app/\(group\)".into(), r"dist/\[id\].js".into()]).unwrap();
1721 assert_eq!(
1722 outputs
1723 .paths(workspace.path())
1724 .unwrap()
1725 .into_iter()
1726 .collect::<Vec<_>>(),
1727 ["app/(group)", "app/(group)/page.js", "dist/[id].js"]
1728 );
1729 let outputs = Outputs::from_paths(&[r"\(group\)/dist".into()]).unwrap();
1731 std::fs::create_dir_all(workspace.path().join("(group)/dist")).unwrap();
1732 std::fs::write(workspace.path().join("(group)/dist/a.js"), "").unwrap();
1733 assert_eq!(outputs.anchors().collect::<Vec<_>>(), ["(group)/dist"]);
1734 assert_eq!(outputs.complete_roots(), ["(group)/dist"]);
1735 assert_eq!(
1736 outputs
1737 .paths(workspace.path())
1738 .unwrap()
1739 .into_iter()
1740 .collect::<Vec<_>>(),
1741 ["(group)/dist", "(group)/dist/a.js"]
1742 );
1743 for unsafe_pattern in [r"dist/\.\./x", r"dist\"] {
1744 assert!(
1745 Outputs::from_paths(&[unsafe_pattern.into()]).is_err(),
1746 "{unsafe_pattern}"
1747 );
1748 }
1749 }
1750
1751 #[test]
1752 fn literal_roots_preserve_path_boundaries_and_exclusions() {
1753 let outputs =
1754 Outputs::from_paths(&["dist".into(), "build/*.js".into(), "!dist/private".into()])
1755 .unwrap();
1756 for path in ["dist", "dist/nested/file.txt", "build/app.js"] {
1757 assert!(outputs.matches(path), "{path}");
1758 }
1759 for path in [
1760 "dist2/file.txt",
1761 "dist/private",
1762 "dist/private/file.txt",
1763 "build/app.ts",
1764 ] {
1765 assert!(!outputs.matches(path), "{path}");
1766 }
1767 }
1768
1769 #[test]
1771 fn listing_skips_only_directories_no_glob_reaches() {
1772 let workspace = tempfile::tempdir().unwrap();
1773 let root = workspace.path();
1774 for file in [
1775 "apps/web/graphql/manifest.json",
1776 "apps/web/src/graphql/manifest.json",
1777 "apps/web/node_modules/pkg/graphql/manifest.json",
1778 "apps/client/app/graphql/manifest.json",
1779 "apps/client/app/graphql/nested/extra.json",
1780 "packages/a/deep/x/gen/out.ts",
1781 "packages/b/types/index.d.ts",
1782 "packages/c/lib/one.js",
1783 ] {
1784 let path = root.join(file);
1785 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1786 std::fs::write(path, "").unwrap();
1787 }
1788 let outputs = Outputs::from_paths(&[
1789 "apps/*/graphql/manifest.json".into(),
1790 "apps/*/app/graphql".into(),
1791 "packages/a/**/gen/*.ts".into(),
1792 "packages/b/types".into(),
1793 "packages/{c,d}/lib/*.js".into(),
1794 ])
1795 .unwrap();
1796 assert_eq!(
1797 outputs.paths(root).unwrap().into_iter().collect::<Vec<_>>(),
1798 [
1799 "apps/client/app/graphql",
1800 "apps/client/app/graphql/manifest.json",
1801 "apps/client/app/graphql/nested",
1802 "apps/client/app/graphql/nested/extra.json",
1803 "apps/web/graphql/manifest.json",
1804 "packages/a/deep/x/gen/out.ts",
1805 "packages/b/types",
1806 "packages/b/types/index.d.ts",
1807 "packages/c/lib/one.js",
1808 ]
1809 );
1810 }
1811
1812 #[test]
1814 fn character_classes_with_slashes_keep_nested_artifacts() {
1815 let workspace = tempfile::tempdir().unwrap();
1816 for name in ["a", "b", "c"] {
1817 let directory = workspace.path().join(format!("apps/{name}/graphql/nested"));
1818 std::fs::create_dir_all(&directory).unwrap();
1819 std::fs::write(directory.join("output.txt"), "value").unwrap();
1820 }
1821 let outputs = Outputs::from_paths(&["apps/[a/b]/graphql".into()]).unwrap();
1822 assert_eq!(
1823 outputs
1824 .paths(workspace.path())
1825 .unwrap()
1826 .into_iter()
1827 .collect::<Vec<_>>(),
1828 [
1829 "apps/a/graphql",
1830 "apps/a/graphql/nested",
1831 "apps/a/graphql/nested/output.txt",
1832 "apps/b/graphql",
1833 "apps/b/graphql/nested",
1834 "apps/b/graphql/nested/output.txt",
1835 ]
1836 );
1837 }
1838
1839 #[test]
1840 fn segment_traversal_preserves_output_sets() {
1841 let workspace = tempfile::tempdir().unwrap();
1842 for file in [
1843 "apps/a/graphql/manifest.json",
1844 "apps/a/graphql/nested/out.ts",
1845 "apps/a/src/other.ts",
1846 "apps/b/src/graphql/manifest.json",
1847 "apps/.hidden/graphql/manifest.json",
1848 "apps/a/file",
1849 "dist/a.ts",
1850 "dist/deep/b.ts",
1851 ] {
1852 let path = workspace.path().join(file);
1853 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1854 std::fs::write(path, "value").unwrap();
1855 }
1856 for patterns in [
1857 vec![
1858 "apps/*/graphql/manifest.json",
1859 "apps/*/*/graphql/manifest.json",
1860 ],
1861 vec!["apps/?/graphql", "!apps/a/graphql/nested"],
1862 vec!["apps/a/graphql", "apps/*/graphql/*"],
1863 vec!["apps/a/file/nested", "missing/*/file", "dist/*.ts"],
1864 vec!["dist"],
1865 vec!["apps/**/manifest.json"],
1866 vec!["apps/[a/b]/graphql"],
1867 ] {
1868 let outputs =
1869 Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
1870 .unwrap();
1871 match (
1872 outputs.paths(workspace.path()),
1873 outputs.walk_paths(workspace.path()),
1874 ) {
1875 (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
1876 (Err(actual), Err(expected)) => {
1877 assert_eq!(actual.to_string(), expected.to_string())
1878 }
1879 results => panic!("different results for {patterns:?}: {results:?}"),
1880 }
1881 }
1882 }
1883
1884 #[cfg(unix)]
1885 #[test]
1886 fn segment_traversal_preserves_symlink_boundaries() {
1887 let workspace = tempfile::tempdir().unwrap();
1888 let root = workspace.path();
1889 std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
1890 std::fs::write(root.join("apps/a/graphql/manifest.json"), "value").unwrap();
1891 std::os::unix::fs::symlink("a", root.join("apps/link")).unwrap();
1892 std::os::unix::fs::symlink("absent", root.join("apps/dangling")).unwrap();
1893 for patterns in [
1894 vec!["apps/*/graphql/manifest.json"],
1895 vec!["apps/*"],
1896 vec!["apps/link"],
1897 vec!["apps/dangling"],
1898 ] {
1899 let outputs =
1900 Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
1901 .unwrap();
1902 match (outputs.paths(root), outputs.walk_paths(root)) {
1903 (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
1904 (Err(actual), Err(expected)) => {
1905 assert_eq!(actual.to_string(), expected.to_string())
1906 }
1907 results => panic!("different results for {patterns:?}: {results:?}"),
1908 }
1909 }
1910 }
1911
1912 #[test]
1913 fn listings_recheck_metadata_and_directory_contents() {
1914 let workspace = tempfile::tempdir().unwrap();
1915 let root = workspace.path();
1916 std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
1917 std::fs::write(root.join("apps/a/graphql/manifest.json"), "one").unwrap();
1918 let outputs = Outputs::from_paths(&["apps/*/graphql/manifest.json".into()]).unwrap();
1919 assert_eq!(
1920 outputs
1921 .entries(root)
1922 .unwrap()
1923 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
1924 .unwrap()["apps/a/graphql/manifest.json"]
1925 .len(),
1926 3
1927 );
1928 std::fs::write(root.join("apps/a/graphql/manifest.json"), "different").unwrap();
1929 std::fs::create_dir_all(root.join("apps/b/graphql")).unwrap();
1930 std::fs::write(root.join("apps/b/graphql/manifest.json"), "two").unwrap();
1931 let entries = outputs
1932 .entries(root)
1933 .unwrap()
1934 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
1935 .unwrap();
1936 assert_eq!(entries.len(), 2);
1937 assert_eq!(entries["apps/a/graphql/manifest.json"].len(), 9);
1938 std::fs::remove_dir_all(root.join("apps/a")).unwrap();
1939 assert_eq!(
1940 outputs
1941 .entries(root)
1942 .unwrap()
1943 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
1944 .unwrap()
1945 .len(),
1946 1
1947 );
1948 }
1949
1950 #[test]
1951 fn literal_segments_after_wildcards_remain_case_sensitive() {
1952 let workspace = tempfile::tempdir().unwrap();
1953 let root = workspace.path();
1954 std::fs::create_dir_all(root.join("apps/a/GraphQL")).unwrap();
1955 std::fs::write(root.join("apps/a/GraphQL/Manifest.json"), "one").unwrap();
1956 for glob in [
1957 "apps/*/graphql/Manifest.json",
1958 "apps/*/GraphQL/manifest.json",
1959 "apps/*/GraphQL/Manifest.json",
1960 "apps/a/GraphQL",
1961 ] {
1962 let outputs = Outputs::from_paths(&[glob.into()]).unwrap();
1963 assert_eq!(
1964 outputs.paths(root).unwrap(),
1965 outputs.walk_paths(root).unwrap(),
1966 "{glob}"
1967 );
1968 }
1969 }
1970
1971 #[test]
1972 fn simple_glob_combinations_match_the_walker() {
1973 let workspace = tempfile::tempdir().unwrap();
1974 let root = workspace.path();
1975 for project in ["a", "aa", "Ab", ".hidden", "café"] {
1976 for directory in ["graphql", "GraphQL", "src"] {
1977 let path = root.join(format!("apps/{project}/{directory}/nested"));
1978 std::fs::create_dir_all(&path).unwrap();
1979 std::fs::write(path.join("manifest.json"), "value").unwrap();
1980 std::fs::write(path.parent().unwrap().join("manifest.json"), "value").unwrap();
1981 }
1982 }
1983 for project in ["*", "?", "a*", "*a", "*?", "A*"] {
1984 for directory in ["graphql", "GraphQL", "src", "missing"] {
1985 for suffix in ["", "/manifest.json", "/*.json", "/?anifest.json", "/*"] {
1986 let glob = format!("apps/{project}/{directory}{suffix}");
1987 let outputs =
1988 Outputs::from_paths(&[glob.clone(), "!apps/aa/graphql/nested".into()])
1989 .unwrap();
1990 assert_eq!(
1991 outputs.paths(root).unwrap(),
1992 outputs.walk_paths(root).unwrap(),
1993 "{glob}"
1994 );
1995 }
1996 }
1997 }
1998 }
1999
2000 #[test]
2002 fn complete_roots_do_not_widen_globs_exclusions_or_defaults() {
2003 let mut outputs = Outputs::from_paths(&[
2004 "dist".into(),
2005 "dist/nested".into(),
2006 "dist2".into(),
2007 "partial/**/*.js".into(),
2008 ])
2009 .unwrap();
2010 assert_eq!(outputs.complete_roots(), ["dist", "dist2"]);
2011 outputs
2012 .negations
2013 .push(Pattern::new("dist/private", false).unwrap());
2014 assert!(outputs.complete_roots().is_empty());
2015 outputs.negations.clear();
2016 outputs.explicit = false;
2017 assert!(outputs.complete_roots().is_empty());
2018 }
2019
2020 #[test]
2021 fn normalizes_relative_segments() {
2022 assert_eq!(
2023 normalize("apps/client/tests/../build/*/bundle.bin").unwrap(),
2024 "apps/client/build/*/bundle.bin"
2025 );
2026 assert_eq!(normalize("./dist/./a").unwrap(), "dist/a");
2027 assert_eq!(normalize(".").unwrap(), ".");
2028 assert!(normalize("app/../../outside").is_err());
2029 assert!(normalize("app/**/../x").is_err());
2030 assert_eq!(normalize(r"apps/\(group\)/../x").unwrap(), "apps/x");
2031 }
2032}