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