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