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