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| {
618 let unescaped = unescaped(glob);
619 unescaped.contains("**")
620 || unescaped.contains(['!', '@', '+', '|', ',', '{', '}', '[', ']', '(', ')'])
621 }) {
622 return Ok(None);
623 }
624 let mut matchers: BTreeMap<String, Pattern> = BTreeMap::new();
625 let mut patterns = Vec::new();
626 for glob in &self.globs {
627 let mut segments = Vec::new();
628 for part in glob.split('/') {
629 segments.push(if crate::glob::is_literal(part) {
630 Segment::Literal(part.to_owned())
631 } else {
632 let matcher = match matchers.entry(part.to_owned()) {
633 std::collections::btree_map::Entry::Occupied(entry) => entry.get().clone(),
634 std::collections::btree_map::Entry::Vacant(entry) => {
635 entry.insert(Pattern::new(part, false)?).clone()
636 }
637 };
638 Segment::Glob(matcher)
639 });
640 }
641 patterns.push(segments);
642 }
643 Ok(Some(patterns))
644 }
645
646 fn walk_paths(&self, root: &Path) -> Result<BTreeSet<String>> {
647 let mut paths = BTreeSet::new();
648 for anchor in &self.anchors {
649 safe_parents(root, anchor)?;
650 let path = root.join(anchor);
651 match std::fs::symlink_metadata(&path) {
652 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
653 Err(error) => return Err(error.into()),
654 Ok(_) => {}
655 }
656 let walk = walkdir::WalkDir::new(path)
657 .follow_links(false)
658 .into_iter()
659 .filter_entry(|entry| {
660 entry.depth() == 0
661 || !entry.file_type().is_dir()
662 || relative(root, entry.path()).map_or(true, |directory| {
663 self.globs
664 .iter()
665 .any(|glob| may_match_below(glob, &directory))
666 })
667 });
668 for entry in walk {
669 let entry = entry?;
670 let path = relative(root, entry.path())?;
671 if self.matches(&path) {
672 validate_path(&path)?;
673 paths.insert(path);
674 }
675 }
676 }
677 Ok(paths)
678 }
679}
680
681fn unescaped(glob: &str) -> String {
685 let mut kept = String::with_capacity(glob.len());
686 let mut chars = glob.chars();
687 while let Some(c) = chars.next() {
688 if c == '\\' {
689 chars.next();
690 kept.push('_');
691 } else {
692 kept.push(c);
693 }
694 }
695 kept
696}
697
698#[derive(Clone)]
699enum Segment {
700 Literal(String),
701 Glob(Pattern),
702}
703
704#[derive(Default)]
705struct Traversal {
706 metadata: BTreeMap<PathBuf, std::fs::Metadata>,
707 directories: BTreeMap<PathBuf, Vec<PathBuf>>,
708}
709
710impl Traversal {
711 fn metadata(&mut self, path: &Path) -> Result<Option<std::fs::Metadata>> {
712 if let Some(metadata) = self.metadata.get(path) {
713 return Ok(Some(metadata.clone()));
714 }
715 match std::fs::symlink_metadata(path) {
716 Ok(metadata) => {
717 self.metadata.insert(path.to_owned(), metadata.clone());
718 Ok(Some(metadata))
719 }
720 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
721 Err(error) => Err(error.into()),
722 }
723 }
724
725 fn children(&mut self, path: &Path) -> Result<&Vec<PathBuf>> {
726 if !self.directories.contains_key(path) {
727 let children = std::fs::read_dir(path)?
728 .map(|entry| entry.map(|entry| entry.path()))
729 .collect::<std::io::Result<Vec<_>>>()?;
730 self.directories.insert(path.to_owned(), children);
731 }
732 Ok(&self.directories[path])
733 }
734
735 fn expand(
736 &mut self,
737 root: &Path,
738 path: &Path,
739 segments: &[Segment],
740 paths: &mut BTreeSet<String>,
741 ) -> Result<()> {
742 let Some(metadata) = self.metadata(path)? else {
743 return Ok(());
744 };
745 let Some((segment, rest)) = segments.split_first() else {
746 for entry in walkdir::WalkDir::new(path)
747 .follow_links(false)
748 .follow_root_links(false)
749 {
750 paths.insert(relative(root, entry?.path())?);
751 }
752 return Ok(());
753 };
754 if !metadata.is_dir() {
755 return Ok(());
756 }
757 match segment {
758 Segment::Literal(name) => {
759 let candidate = path.join(name);
760 if self.metadata(&candidate)?.is_none() {
761 return Ok(());
762 }
763 let child = self
765 .children(path)?
766 .iter()
767 .find(|child| child.file_name() == Some(std::ffi::OsStr::new(name)))
768 .cloned();
769 if let Some(child) = child {
770 self.expand(root, &child, rest, paths)?;
771 }
772 Ok(())
773 }
774 Segment::Glob(matcher) => {
775 let children: Vec<_> = self
776 .children(path)?
777 .iter()
778 .filter(|child| matcher.is_match(child.file_name().unwrap()))
779 .cloned()
780 .collect();
781 for child in children {
782 self.expand(root, &child, rest, paths)?;
783 }
784 Ok(())
785 }
786 }
787 }
788}
789
790fn may_match_below(glob: &str, directory: &str) -> bool {
795 if glob.contains(['{', '}', '(', ')', '[', ']']) {
796 return true;
797 }
798 for (part, name) in glob.split('/').zip(directory.split('/')) {
799 if part == "**" {
800 return true;
801 }
802 if crate::glob::is_literal(part) && part != name {
803 return false;
804 }
805 }
806 true
807}
808
809pub fn resolved_outputs(workspace: &Workspace, task: &Task) -> Result<(Vec<String>, bool)> {
816 let root = &workspace.projects[&task.project].root;
817 let options = task_options(task);
818 let mut resolved = Vec::new();
819 let Some(templates) = &task.definition.outputs else {
820 let defaults: Vec<String> = match options.get("outputPath") {
824 Some(path @ serde_json::Value::String(text)) if !text.is_empty() => {
825 vec![option_tokens(workspace, &task.project, path)?]
826 }
827 Some(serde_json::Value::Array(paths)) => paths
828 .iter()
829 .filter(|path| path.as_str().is_some_and(|text| !text.is_empty()))
830 .map(|path| option_tokens(workspace, &task.project, path))
831 .collect::<Result<_>>()?,
832 _ if matches!(task.target.as_str(), "build" | "prepare") => {
833 let root = crate::glob::escape(root);
835 [
836 format!("dist/{root}"),
837 format!("{root}/dist"),
838 format!("{root}/build"),
839 format!("{root}/public"),
840 ]
841 .into()
842 }
843 _ => Vec::new(),
844 };
845 for output in defaults {
846 let (negated, path) = match output.strip_prefix('!') {
847 Some(rest) => (true, rest),
848 None => (false, output.as_str()),
849 };
850 let path = workspace_relative(workspace, path)?;
851 resolved.push(if negated { format!("!{path}") } else { path });
852 }
853 return Ok((resolved, false));
854 };
855 qk_taskgraph::validate_outputs(templates)?;
856 let data = OutputData {
857 workspace,
858 project: &task.project,
859 options: &options,
860 };
861 for template in templates {
862 let (negated, template) = match template.strip_prefix('!') {
863 Some(rest) => (true, rest),
864 None => (false, template.as_str()),
865 };
866 let Some(output) = interpolate_output(template, &data)? else {
867 continue;
868 };
869 let path = workspace_relative(workspace, &output)?;
870 let path = if negated { format!("!{path}") } else { path };
871 if !resolved.contains(&path) {
873 resolved.push(path);
874 }
875 }
876 Ok((resolved, true))
877}
878
879fn verbatim_free(path: &Path) -> PathBuf {
881 match path.to_str().and_then(|text| text.strip_prefix(r"\\?\")) {
882 Some(rest) if !rest.starts_with("UNC\\") => PathBuf::from(rest),
883 _ => path.to_path_buf(),
884 }
885}
886
887fn workspace_relative(workspace: &Workspace, output: &str) -> Result<String> {
891 let owned;
892 let relative = if Path::new(output).is_absolute() || output.starts_with('/') {
893 let inside = verbatim_free(Path::new(output))
896 .strip_prefix(verbatim_free(&workspace.root))
897 .ok()
898 .map(|rest| {
899 rest.components()
900 .map(|part| part.as_os_str().to_string_lossy())
901 .collect::<Vec<_>>()
902 .join("/")
903 });
904 match inside {
905 Some(rest) => {
906 owned = rest;
907 owned.as_str()
908 }
909 None => bail!("cache output is outside the workspace: {output}"),
910 }
911 } else {
912 output
913 };
914 match normalize(relative) {
915 Ok(path) => Ok(path.trim_end_matches('/').to_owned()),
916 Err(_) => bail!("cache output is outside the workspace: {output}"),
917 }
918}
919
920fn task_options(task: &Task) -> serde_json::Map<String, serde_json::Value> {
923 let mut options = task.definition.options.clone();
924 let mut args = task.args.iter().peekable();
925 while let Some(arg) = args.next() {
926 let Some(flag) = arg.strip_prefix("--") else {
927 continue;
928 };
929 let (key, value) = match flag.split_once('=') {
930 Some((key, value)) => (key.to_owned(), value.to_owned()),
931 None if args.peek().is_some_and(|next| !next.starts_with('-')) => {
932 (flag.to_owned(), args.next().unwrap().clone())
933 }
934 None => (flag.to_owned(), "true".to_owned()),
935 };
936 options.insert(key, serde_json::Value::String(value));
937 }
938 options
939}
940
941struct OutputData<'a> {
944 workspace: &'a Workspace,
945 project: &'a str,
946 options: &'a serde_json::Map<String, serde_json::Value>,
947}
948
949impl OutputData<'_> {
950 fn is_token(name: &str) -> bool {
951 matches!(name, "projectRoot" | "projectName" | "project" | "options")
952 }
953
954 fn read(&self, name: &str, path: Option<&str>) -> Result<Option<serde_json::Value>> {
955 let value = match name {
956 "projectRoot" => {
957 serde_json::Value::String(self.workspace.projects[self.project].root.clone())
958 }
959 "projectName" => serde_json::Value::String(self.project.to_owned()),
960 "project" => {
961 let mut project = serde_json::to_value(&self.workspace.projects[self.project])?;
962 project["name"] = serde_json::Value::String(self.project.to_owned());
963 project
964 }
965 _ => serde_json::Value::Object(self.options.clone()),
966 };
967 let Some(found) = read_path(&value, path) else {
968 return Ok(None);
969 };
970 Ok(Some(if name == "options" {
977 resolve_option_tokens(self.workspace, self.project, &found)?
978 } else {
979 escape_strings(&found)
980 }))
981 }
982}
983
984fn escape_strings(value: &serde_json::Value) -> serde_json::Value {
986 match value {
987 serde_json::Value::String(text) => serde_json::Value::String(crate::glob::escape(text)),
988 serde_json::Value::Array(items) => items.iter().map(escape_strings).collect(),
989 serde_json::Value::Object(object) => serde_json::Value::Object(
990 object
991 .iter()
992 .map(|(key, item)| (key.clone(), escape_strings(item)))
993 .collect(),
994 ),
995 other => other.clone(),
996 }
997}
998
999fn read_path(value: &serde_json::Value, path: Option<&str>) -> Option<serde_json::Value> {
1003 let Some(path) = path else {
1004 return Some(value.clone());
1005 };
1006 let index = |key: &str| {
1007 (key == "0" || (!key.starts_with('0') && key.bytes().all(|b| b.is_ascii_digit())))
1008 .then(|| key.parse::<usize>().ok())
1009 .flatten()
1010 };
1011 let mut value = value.clone();
1012 for key in path.split('.') {
1013 value = match &value {
1014 serde_json::Value::Object(object) => object.get(key)?.clone(),
1015 serde_json::Value::Array(items) if key == "length" => items.len().into(),
1016 serde_json::Value::Array(items) => items.get(index(key)?)?.clone(),
1017 serde_json::Value::String(text) => {
1019 let units: Vec<u16> = text.encode_utf16().collect();
1020 if key == "length" {
1021 units.len().into()
1022 } else {
1023 String::from_utf16_lossy(&[*units.get(index(key)?)?]).into()
1024 }
1025 }
1026 _ => return None,
1027 };
1028 }
1029 Some(value)
1030}
1031
1032fn option_tokens(
1035 workspace: &Workspace,
1036 project: &str,
1037 value: &serde_json::Value,
1038) -> Result<String> {
1039 Ok(text(&resolve_option_tokens(workspace, project, value)?).unwrap_or_default())
1040}
1041
1042fn resolve_option_tokens(
1046 workspace: &Workspace,
1047 project: &str,
1048 value: &serde_json::Value,
1049) -> Result<serde_json::Value> {
1050 Ok(match value {
1051 serde_json::Value::String(text) => {
1052 let text = text
1053 .strip_prefix("{workspaceRoot}/")
1054 .or_else(|| text.strip_prefix("{workspaceRoot}"))
1055 .unwrap_or(text);
1056 if text.contains("{workspaceRoot}") {
1057 bail!(
1058 "the {{workspaceRoot}} token is only valid at the beginning of an option ({project})"
1059 );
1060 }
1061 serde_json::Value::String(
1062 text.replace("{projectRoot}", &workspace.projects[project].root)
1063 .replace("{projectName}", project),
1064 )
1065 }
1066 serde_json::Value::Array(items) => serde_json::Value::Array(
1067 items
1068 .iter()
1069 .map(|item| resolve_option_tokens(workspace, project, item))
1070 .collect::<Result<_>>()?,
1071 ),
1072 serde_json::Value::Object(object) => serde_json::Value::Object(
1073 object
1074 .iter()
1075 .map(|(key, item)| {
1076 Ok((
1077 key.clone(),
1078 resolve_option_tokens(workspace, project, item)?,
1079 ))
1080 })
1081 .collect::<Result<_>>()?,
1082 ),
1083 other => other.clone(),
1084 })
1085}
1086
1087fn text(value: &serde_json::Value) -> Option<String> {
1090 let text = match value {
1091 serde_json::Value::Null => return None,
1092 serde_json::Value::String(text) => text.clone(),
1093 serde_json::Value::Bool(flag) => flag.to_string(),
1094 serde_json::Value::Number(number) => match number.as_f64() {
1095 Some(float) if number.is_f64() && float.fract() == 0.0 && float.abs() < 1e21 => {
1096 format!("{float:.0}")
1097 }
1098 _ => number.to_string(),
1099 },
1100 serde_json::Value::Array(items) => items
1101 .iter()
1102 .map(|item| text(item).unwrap_or_default())
1103 .collect::<Vec<_>>()
1104 .join(","),
1105 serde_json::Value::Object(_) => "[object Object]".to_owned(),
1106 };
1107 (!text.is_empty()).then_some(text)
1108}
1109
1110enum Node<'t> {
1112 Text(String),
1113 Token { raw: &'t str, parts: Vec<Part<'t>> },
1114}
1115
1116enum Part<'t> {
1117 Reference {
1118 name: &'t str,
1119 path: Option<&'t str>,
1120 },
1121 Literal(Vec<Node<'t>>),
1122}
1123
1124fn parse_tokens(template: &str) -> Result<Vec<Node<'_>>> {
1127 let mut nodes = Vec::new();
1128 let mut text = String::new();
1129 let mut index = 0;
1130 while let Some(offset) = template[index..].find('{') {
1131 let open = index + offset;
1132 let token = match qk_executor::tokens::closing_brace(template, open) {
1133 Some(close) => parse_token(&template[open..=close])?.map(|token| (token, close)),
1134 None => None,
1135 };
1136 let Some((token, close)) = token else {
1137 text.push_str(&template[index..=open]);
1138 index = open + 1;
1139 continue;
1140 };
1141 text.push_str(&template[index..open]);
1142 if !text.is_empty() {
1143 nodes.push(Node::Text(std::mem::take(&mut text)));
1144 }
1145 nodes.push(token);
1146 index = close + 1;
1147 }
1148 text.push_str(&template[index..]);
1149 if !text.is_empty() {
1150 nodes.push(Node::Text(text));
1151 }
1152 Ok(nodes)
1153}
1154
1155fn parse_token(raw: &str) -> Result<Option<Node<'_>>> {
1159 let sources = qk_executor::tokens::fallbacks(&raw[1..raw.len() - 1]);
1160 fn reference(source: &str) -> Option<(&str, Option<&str>)> {
1161 let (name, path) = match source.split_once('.') {
1162 Some((name, path)) => (name, Some(path)),
1163 None => (source, None),
1164 };
1165 OutputData::is_token(name).then_some((name, path))
1166 }
1167 if reference(sources[0]).is_none() {
1168 return Ok(None);
1169 }
1170 let mut parts = Vec::new();
1171 for (index, source) in sources.iter().enumerate() {
1172 parts.push(match reference(source) {
1173 Some((name, path)) => Part::Reference { name, path },
1174 None if index + 1 == sources.len() => Part::Literal(parse_tokens(source)?),
1175 None => {
1176 bail!("{raw:?} has a fallback after the literal {source:?} that can never be used")
1177 }
1178 });
1179 }
1180 Ok(Some(Node::Token { raw, parts }))
1181}
1182
1183fn workspace_root_only_at_start(nodes: &[Node], at_start: bool) -> bool {
1187 nodes.iter().enumerate().all(|(index, node)| {
1188 let node_at_start = at_start && index == 0;
1189 match node {
1190 Node::Text(text) => {
1191 let skip = usize::from(node_at_start).min(text.len());
1192 !text[skip..].contains("{workspaceRoot}")
1193 }
1194 Node::Token { parts, .. } => parts.iter().all(|part| match part {
1195 Part::Literal(nodes) => workspace_root_only_at_start(nodes, node_at_start),
1196 Part::Reference { .. } => true,
1197 }),
1198 }
1199 })
1200}
1201
1202fn interpolate_output(template: &str, data: &OutputData) -> Result<Option<String>> {
1210 let output = if template.starts_with('/') || !has_braces(template) {
1211 template.to_owned()
1212 } else {
1213 let nodes = parse_tokens(template)?;
1214 if !workspace_root_only_at_start(&nodes, true) {
1215 bail!(
1216 "output {template:?} is invalid: {{workspaceRoot}} can only be used at the beginning of the expression"
1217 );
1218 }
1219 let mut segments = vec![String::new()];
1220 render(&nodes, data, false, &mut segments)?;
1221 segments.retain(|segment| !segment.is_empty());
1223 join_path(&segments.join("/")).replacen("{workspaceRoot}/", "", 1)
1224 };
1225 let unresolved = ["{projectRoot}", "{workspaceRoot}"]
1226 .iter()
1227 .any(|token| output.contains(token))
1228 || output
1229 .find("{options")
1230 .is_some_and(|start| output[start..].contains('}'));
1231 Ok((!output.is_empty() && !unresolved).then_some(output))
1232}
1233
1234fn has_braces(template: &str) -> bool {
1236 template.find('{').is_some_and(|open| {
1237 template
1238 .get(open + 2..)
1239 .is_some_and(|rest| rest.contains('}'))
1240 })
1241}
1242
1243fn render(
1248 nodes: &[Node],
1249 data: &OutputData,
1250 literal: bool,
1251 segments: &mut Vec<String>,
1252) -> Result<()> {
1253 for (index, node) in nodes.iter().enumerate() {
1254 match node {
1255 Node::Text(text) => {
1256 let root = if literal && index == 0 && text.starts_with('/') {
1257 "/"
1258 } else {
1259 ""
1260 };
1261 let mut parts = text[root.len()..].split('/');
1262 let last = segments.last_mut().expect("segments start with one");
1263 last.push_str(root);
1264 last.push_str(parts.next().unwrap_or_default());
1265 segments.extend(parts.map(str::to_owned));
1266 }
1267 Node::Token { raw, parts } => {
1268 if *raw == "{projectRoot}" && data.workspace.projects[data.project].root == "." {
1270 continue;
1271 }
1272 let mut value = Some(*raw);
1273 let mut found = None;
1274 for part in parts {
1275 match part {
1276 Part::Literal(nodes) => {
1277 render(nodes, data, true, segments)?;
1278 value = None;
1279 break;
1280 }
1281 Part::Reference { name, path } => {
1282 found = data.read(name, *path)?.as_ref().and_then(text);
1283 if found.is_some() {
1284 break;
1285 }
1286 }
1287 }
1288 }
1289 let value = found.as_deref().or(value).unwrap_or_default();
1291 segments
1292 .last_mut()
1293 .expect("segments start with one")
1294 .push_str(value);
1295 }
1296 }
1297 }
1298 Ok(())
1299}
1300
1301fn join_path(path: &str) -> String {
1304 let absolute = path.starts_with('/');
1305 let mut parts: Vec<&str> = Vec::new();
1306 for part in path.split('/') {
1307 match part {
1308 "" | "." => {}
1309 ".." => match parts.last() {
1310 Some(&last) if last != ".." => {
1311 parts.pop();
1312 }
1313 _ if absolute => {}
1314 _ => parts.push(".."),
1315 },
1316 part => parts.push(part),
1317 }
1318 }
1319 let mut joined = parts.join("/");
1320 if absolute {
1321 joined.insert(0, '/');
1322 }
1323 if joined.is_empty() {
1324 joined.push('.');
1325 }
1326 if path.ends_with('/') && !joined.ends_with('/') {
1327 joined.push('/');
1328 }
1329 joined
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334 use super::{Outputs, Pattern, dot_git, normalize, resolved_outputs, verbatim_free};
1335
1336 #[test]
1337 fn a_cache_location_has_its_dot_segments_resolved() {
1338 use std::path::{Path, PathBuf};
1339 assert_eq!(
1340 super::resolved(Path::new("/work/repo/elsewhere/../qk-cache/./v1")),
1341 PathBuf::from("/work/repo/qk-cache/v1")
1342 );
1343 assert_eq!(
1345 super::resolved(Path::new("../cache")),
1346 PathBuf::from("../cache")
1347 );
1348 }
1349 use qk_config::Workspace;
1350 use qk_taskgraph::{Request, TaskGraph};
1351 use serde_json::{Value, json};
1352 use std::path::{Path, PathBuf};
1353
1354 fn outputs_of(target: Value, args: &[&str]) -> anyhow::Result<Vec<String>> {
1357 let root = tempfile::tempdir().unwrap();
1358 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1359 std::fs::write(
1360 root.path().join("project.json"),
1361 json!({"name": "root"}).to_string(),
1362 )
1363 .unwrap();
1364 std::fs::create_dir_all(root.path().join("libs/lib")).unwrap();
1365 std::fs::write(
1366 root.path().join("libs/lib/project.json"),
1367 json!({"name": "lib", "sourceRoot": "libs/lib/src", "tags": ["a", "b"],
1368 "targets": {"build": target}})
1369 .to_string(),
1370 )
1371 .unwrap();
1372 let workspace = Workspace::load(root.path()).unwrap();
1373 let mut request = Request::parse("lib:build").unwrap();
1374 request.args = args.iter().map(|arg| (*arg).to_owned()).collect();
1375 let graph = TaskGraph::build(&workspace, &[request])?;
1376 resolved_outputs(&workspace, &graph.tasks["lib:build"]).map(|(outputs, _)| outputs)
1377 }
1378
1379 fn outputs(outputs: Value, options: Value) -> anyhow::Result<Vec<String>> {
1380 outputs_of(
1381 json!({"command": "true", "outputs": outputs, "options": options}),
1382 &[],
1383 )
1384 }
1385
1386 #[test]
1387 fn braces_that_are_no_token_stay_as_written() {
1388 assert_eq!(
1389 outputs(
1390 json!([
1391 "{projectRoot}/dist/**/*.{js,map}",
1392 "{workspaceRoot}/x/{a,{projectName}}"
1393 ]),
1394 json!({})
1395 )
1396 .unwrap(),
1397 ["libs/lib/dist/**/*.{js,map}", "x/{a,lib}"]
1398 );
1399 }
1400
1401 #[test]
1402 fn placeholders_fall_back_left_to_right() {
1403 let resolved = outputs(
1404 json!([
1405 "{options.a ?? options.b ?? dist/{projectName}}",
1406 "{options.missing ?? options.b}/x",
1407 "{options.missing ?? {workspaceRoot}/fallback}",
1408 "{workspaceRoot}/x/{env.HOME}",
1409 "{options.missing}",
1410 "{options.missing ?? options.other}",
1411 ]),
1412 json!({"b": "from-b"}),
1413 )
1414 .unwrap();
1415 assert_eq!(resolved, ["from-b", "from-b/x", "fallback", "x/{env.HOME}"]);
1418 assert!(outputs(json!(["{options.a ?? literal ?? options.b}"]), json!({})).is_err());
1419 }
1420
1421 #[test]
1422 fn option_values_read_as_javascript_writes_them() {
1423 assert_eq!(
1424 outputs(
1425 json!([
1426 "{workspaceRoot}/dist/{options.flag}",
1427 "{workspaceRoot}/dist/{options.zero}",
1428 "{workspaceRoot}/dist/{options.list}",
1429 "{workspaceRoot}/dist/{options.float}",
1430 "{workspaceRoot}/dist/{options.nested.deep}",
1431 "{workspaceRoot}/dist/{options.list.1}",
1432 "{workspaceRoot}/dist/{options.empty}",
1433 "{workspaceRoot}/dist/{options.none}",
1434 ]),
1435 json!({"flag": false, "zero": 0, "list": ["a", "b"], "float": 2.0,
1436 "nested": {"deep": "d"}, "empty": "", "none": null}),
1437 )
1438 .unwrap(),
1439 [
1440 "dist/false",
1441 "dist/0",
1442 "dist/a,b",
1443 "dist/2",
1444 "dist/d",
1445 "dist/b"
1446 ]
1447 );
1448 }
1449
1450 #[test]
1451 fn option_values_have_their_project_tokens_resolved() {
1452 assert_eq!(
1453 outputs(
1454 json!(["{options.outputPath}", "{options.list}"]),
1455 json!({"outputPath": "{workspaceRoot}/dist/{projectName}/{projectRoot}",
1456 "list": ["{projectName}"]}),
1457 )
1458 .unwrap(),
1459 ["dist/lib/libs/lib", "lib"]
1460 );
1461 assert_eq!(
1465 outputs_of(
1466 json!({"command": "true", "outputs": ["{options.out}"], "options": {"out": "a"}}),
1467 &["--out=b"],
1468 )
1469 .unwrap(),
1470 ["b"]
1471 );
1472 assert_eq!(
1475 outputs(
1476 json!([
1477 "{options.list.length}",
1478 "{options.list.1}",
1479 "{options.list.01}"
1480 ]),
1481 json!({"list": ["a", "b"]}),
1482 )
1483 .unwrap(),
1484 ["2", "b"]
1485 );
1486 assert_eq!(
1489 outputs_of(
1490 json!({"command": "true", "options": {"outputPath": ""}}),
1491 &[],
1492 )
1493 .unwrap(),
1494 [
1495 "dist/libs/lib",
1496 "libs/lib/dist",
1497 "libs/lib/build",
1498 "libs/lib/public"
1499 ]
1500 );
1501 assert_eq!(
1502 outputs_of(
1503 json!({"command": "true", "options": {"outputPath": ["", "dist"]}}),
1504 &[],
1505 )
1506 .unwrap(),
1507 ["dist"]
1508 );
1509 assert_eq!(
1511 outputs_of(
1512 json!({"command": "true", "options": {"outputPath": "dist/{projectName}"}}),
1513 &[],
1514 )
1515 .unwrap(),
1516 ["dist/lib"]
1517 );
1518 }
1519
1520 #[test]
1521 fn legacy_project_paths_read_the_project_configuration() {
1522 assert_eq!(
1523 outputs(
1524 json!([
1525 "{project.sourceRoot}/gen",
1526 "{workspaceRoot}/dist/{project.name}/{project.tags}",
1527 "{project.missing}/x"
1528 ]),
1529 json!({})
1530 )
1531 .unwrap(),
1532 ["libs/lib/src/gen", "dist/lib/a,b", "{project.missing}/x"]
1533 );
1534 }
1535
1536 #[test]
1537 fn workspace_root_outputs_name_a_directory() {
1538 for output in ["{workspaceRoot}/**", "{workspaceRoot}/*.js"] {
1539 assert!(outputs(json!([output]), json!({})).is_err(), "{output}");
1540 }
1541 assert_eq!(
1542 outputs(json!(["{workspaceRoot}/@scope/**"]), json!({})).unwrap(),
1543 ["@scope/**"]
1544 );
1545 }
1546
1547 #[test]
1548 fn workspace_root_is_allowed_only_at_the_start() {
1549 for output in ["{projectRoot}/{workspaceRoot}/dist", "dist/{workspaceRoot}"] {
1550 assert!(outputs(json!([output]), json!({})).is_err(), "{output}");
1551 }
1552 assert_eq!(
1553 outputs(
1554 json!([
1555 "!{workspaceRoot}/dist/x",
1556 "{workspaceRoot}/dist",
1557 "{workspaceRoot}"
1558 ]),
1559 json!({})
1560 )
1561 .unwrap(),
1562 ["!dist/x", "dist"]
1563 );
1564 }
1565
1566 #[test]
1567 fn a_root_projects_root_is_no_segment() {
1568 let root = tempfile::tempdir().unwrap();
1569 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1570 std::fs::write(
1571 root.path().join("project.json"),
1572 json!({"name": "app", "targets": {"build": {"command": "true",
1573 "outputs": ["{projectRoot}/dist", "{options.out ?? {projectRoot}/out}"]}}})
1574 .to_string(),
1575 )
1576 .unwrap();
1577 let workspace = Workspace::load(root.path()).unwrap();
1578 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1579 assert_eq!(
1580 resolved_outputs(&workspace, &graph.tasks["app:build"])
1581 .unwrap()
1582 .0,
1583 ["dist", "out"]
1584 );
1585 }
1586
1587 #[test]
1588 fn verbatim_prefixes_are_left_out_of_drive_paths_only() {
1589 assert_eq!(
1590 verbatim_free(Path::new(r"\\?\C:\repo\dist")),
1591 PathBuf::from(r"C:\repo\dist")
1592 );
1593 let unc = Path::new(r"\\?\UNC\server\share");
1594 assert_eq!(verbatim_free(unc), unc);
1595 assert_eq!(
1596 verbatim_free(Path::new("/repo/dist")),
1597 Path::new("/repo/dist")
1598 );
1599 }
1600
1601 #[test]
1602 fn absolute_outputs_must_lie_in_the_workspace() {
1603 let root = tempfile::tempdir().unwrap();
1604 let inside = std::fs::canonicalize(root.path()).unwrap();
1605 std::fs::write(inside.join("nx.json"), "{}").unwrap();
1606 std::fs::write(
1607 inside.join("project.json"),
1608 json!({"name": "app", "targets": {"build": {"command": "true",
1609 "outputs": ["{options.out}"], "options": {"out": inside.join("dist/app")}},
1610 "outside": {"command": "true", "outputs": ["{options.out}"],
1611 "options": {"out": "/elsewhere/dist"}}}})
1612 .to_string(),
1613 )
1614 .unwrap();
1615 let workspace = Workspace::load(&inside).unwrap();
1616 let graph = TaskGraph::build(
1617 &workspace,
1618 &[
1619 Request::parse("app:build").unwrap(),
1620 Request::parse("app:outside").unwrap(),
1621 ],
1622 )
1623 .unwrap();
1624 assert_eq!(
1625 resolved_outputs(&workspace, &graph.tasks["app:build"])
1626 .unwrap()
1627 .0,
1628 ["dist/app"]
1629 );
1630 assert!(resolved_outputs(&workspace, &graph.tasks["app:outside"]).is_err());
1631 }
1632
1633 #[test]
1634 fn dot_git_finds_a_linked_worktrees_directories_without_git() {
1635 let scratch = tempfile::tempdir().unwrap();
1636 let base = std::fs::canonicalize(scratch.path()).unwrap();
1637 let common = base.join("main/.git");
1638 let git_dir = common.join("worktrees/linked");
1639 std::fs::create_dir_all(&git_dir).unwrap();
1640 std::fs::write(git_dir.join("commondir"), "../..\n").unwrap();
1641 std::fs::create_dir_all(base.join("linked/apps/web")).unwrap();
1642 std::fs::write(
1643 base.join("linked/.git"),
1644 "gitdir: ../main/.git/worktrees/linked\n",
1645 )
1646 .unwrap();
1647 assert_eq!(
1648 dot_git(&base.join("linked/apps/web")),
1649 Some((git_dir, common.clone()))
1650 );
1651 assert_eq!(dot_git(&base.join("main")), Some((common.clone(), common)));
1652 assert_eq!(dot_git(&base.join("elsewhere")), None);
1653 }
1654
1655 #[test]
1656 fn literal_entries_match_listing_for_nested_and_missing_roots() {
1657 let workspace = tempfile::tempdir().unwrap();
1658 let root = workspace.path();
1659 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1660 std::fs::write(root.join("dist/nested/value"), "contents").unwrap();
1661 std::fs::write(root.join("single"), "one").unwrap();
1662 #[cfg(unix)]
1663 std::os::unix::fs::symlink(root.join("single"), root.join("dist/link")).unwrap();
1664 let outputs = Outputs::from_paths(&[
1665 "dist".into(),
1666 "dist/nested".into(),
1667 "missing".into(),
1668 "single".into(),
1669 ])
1670 .unwrap();
1671 let entries = outputs
1672 .entries(root)
1673 .unwrap()
1674 .collect::<anyhow::Result<Vec<_>>>()
1675 .unwrap();
1676 let paths: std::collections::BTreeSet<_> =
1677 entries.iter().map(|(path, _)| path.clone()).collect();
1678 assert_eq!(entries.len(), paths.len());
1679 assert_eq!(paths, outputs.paths(root).unwrap());
1680 for (path, metadata) in entries {
1681 let expected = std::fs::symlink_metadata(root.join(path)).unwrap();
1682 assert_eq!(metadata.file_type(), expected.file_type());
1683 assert_eq!(metadata.len(), expected.len());
1684 assert_eq!(metadata.modified().unwrap(), expected.modified().unwrap());
1685 }
1686 }
1687
1688 #[cfg(unix)]
1689 #[test]
1690 fn literal_entries_reject_nested_roots_below_a_symlink() {
1691 let workspace = tempfile::tempdir().unwrap();
1692 let root = workspace.path();
1693 std::fs::create_dir_all(root.join("dist")).unwrap();
1694 std::fs::create_dir_all(root.join("outside/nested")).unwrap();
1695 std::os::unix::fs::symlink(root.join("outside"), root.join("dist/link")).unwrap();
1696 let outputs = Outputs::from_paths(&["dist".into(), "dist/link/nested".into()]).unwrap();
1697 assert!(outputs.entries(root).is_err());
1698 }
1699
1700 #[test]
1703 fn outputs_escape_the_project_root_and_name() {
1704 let root = tempfile::tempdir().unwrap();
1705 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1706 std::fs::create_dir_all(root.path().join("apps/(group)/dist")).unwrap();
1707 std::fs::write(root.path().join("apps/(group)/dist/a.js"), "").unwrap();
1708 std::fs::write(
1709 root.path().join("apps/(group)/project.json"),
1710 json!({"name": "a+b", "sourceRoot": "apps/(group)/src", "targets": {
1711 "build": {"command": "true"},
1712 "emit": {"command": "true", "options": {"out": "out/*.js"}, "outputs": [
1713 "{projectRoot}/dist",
1714 "{workspaceRoot}/dist/{projectName}",
1715 "{project.sourceRoot}/gen",
1716 "{options.out}",
1717 ]},
1718 }})
1719 .to_string(),
1720 )
1721 .unwrap();
1722 let workspace = Workspace::load(root.path()).unwrap();
1723 let graph = TaskGraph::build(
1724 &workspace,
1725 &[
1726 Request::parse("a+b:emit").unwrap(),
1727 Request::parse("a+b:build").unwrap(),
1728 ],
1729 )
1730 .unwrap();
1731 assert_eq!(
1732 resolved_outputs(&workspace, &graph.tasks["a+b:emit"])
1733 .unwrap()
1734 .0,
1735 [
1736 r"apps/\(group\)/dist",
1737 r"dist/a\+b",
1738 r"apps/\(group\)/src/gen",
1739 "out/*.js"
1740 ]
1741 );
1742 assert_eq!(
1743 resolved_outputs(&workspace, &graph.tasks["a+b:build"])
1744 .unwrap()
1745 .0,
1746 [
1747 r"dist/apps/\(group\)",
1748 r"apps/\(group\)/dist",
1749 r"apps/\(group\)/build",
1750 r"apps/\(group\)/public"
1751 ]
1752 );
1753 let outputs = Outputs::new(&workspace, &graph.tasks["a+b:emit"]).unwrap();
1754 assert!(
1755 outputs
1756 .paths(root.path())
1757 .unwrap()
1758 .contains("apps/(group)/dist/a.js")
1759 );
1760 }
1761
1762 #[test]
1765 fn outputs_cannot_name_a_project_root() {
1766 let root = tempfile::tempdir().unwrap();
1767 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1768 std::fs::create_dir_all(root.path().join("apps/(group)")).unwrap();
1769 std::fs::write(
1770 root.path().join("apps/(group)/project.json"),
1771 json!({"name": "group", "targets": {
1772 "own": {"command": "true", "outputs": ["{projectRoot}"]},
1773 "written": {"command": "true", "outputs": ["{workspaceRoot}/apps/\\(group\\)"]},
1774 "parent": {"command": "true", "outputs": ["{workspaceRoot}/apps"]},
1775 "sibling": {"command": "true", "outputs": ["{workspaceRoot}/apps/\\(gr"]},
1776 }})
1777 .to_string(),
1778 )
1779 .unwrap();
1780 let workspace = Workspace::load(root.path()).unwrap();
1781 for (target, refused) in [
1782 ("own", true),
1783 ("written", true),
1784 ("parent", true),
1785 ("sibling", false),
1786 ] {
1787 let request = Request::parse(&format!("group:{target}")).unwrap();
1788 let graph = TaskGraph::build(&workspace, &[request]).unwrap();
1789 assert_eq!(
1790 Outputs::new(&workspace, &graph.tasks[&format!("group:{target}")]).is_err(),
1791 refused,
1792 "{target}"
1793 );
1794 }
1795 }
1796
1797 #[test]
1798 fn output_patterns_escape_glob_characters() {
1799 let workspace = tempfile::tempdir().unwrap();
1800 for file in [
1801 "app/(group)/page.js",
1802 "app/group/page.js",
1803 "dist/[id].js",
1804 "dist/i.js",
1805 ] {
1806 let path = workspace.path().join(file);
1807 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1808 std::fs::write(path, "").unwrap();
1809 }
1810 let outputs =
1811 Outputs::from_paths(&[r"app/\(group\)".into(), r"dist/\[id\].js".into()]).unwrap();
1812 assert_eq!(
1813 outputs
1814 .paths(workspace.path())
1815 .unwrap()
1816 .into_iter()
1817 .collect::<Vec<_>>(),
1818 ["app/(group)", "app/(group)/page.js", "dist/[id].js"]
1819 );
1820 let outputs = Outputs::from_paths(&[r"\(group\)/dist".into()]).unwrap();
1822 std::fs::create_dir_all(workspace.path().join("(group)/dist")).unwrap();
1823 std::fs::write(workspace.path().join("(group)/dist/a.js"), "").unwrap();
1824 assert_eq!(outputs.anchors().collect::<Vec<_>>(), ["(group)/dist"]);
1825 assert_eq!(outputs.complete_roots(), ["(group)/dist"]);
1826 assert_eq!(
1827 outputs
1828 .paths(workspace.path())
1829 .unwrap()
1830 .into_iter()
1831 .collect::<Vec<_>>(),
1832 ["(group)/dist", "(group)/dist/a.js"]
1833 );
1834 for unsafe_pattern in [r"dist/\.\./x", r"dist\"] {
1835 assert!(
1836 Outputs::from_paths(&[unsafe_pattern.into()]).is_err(),
1837 "{unsafe_pattern}"
1838 );
1839 }
1840 }
1841
1842 #[test]
1843 fn literal_roots_preserve_path_boundaries_and_exclusions() {
1844 let outputs =
1845 Outputs::from_paths(&["dist".into(), "build/*.js".into(), "!dist/private".into()])
1846 .unwrap();
1847 for path in ["dist", "dist/nested/file.txt", "build/app.js"] {
1848 assert!(outputs.matches(path), "{path}");
1849 }
1850 for path in [
1851 "dist2/file.txt",
1852 "dist/private",
1853 "dist/private/file.txt",
1854 "build/app.ts",
1855 ] {
1856 assert!(!outputs.matches(path), "{path}");
1857 }
1858 }
1859
1860 #[test]
1862 fn listing_skips_only_directories_no_glob_reaches() {
1863 let workspace = tempfile::tempdir().unwrap();
1864 let root = workspace.path();
1865 for file in [
1866 "apps/web/graphql/manifest.json",
1867 "apps/web/src/graphql/manifest.json",
1868 "apps/web/node_modules/pkg/graphql/manifest.json",
1869 "apps/client/app/graphql/manifest.json",
1870 "apps/client/app/graphql/nested/extra.json",
1871 "packages/a/deep/x/gen/out.ts",
1872 "packages/b/types/index.d.ts",
1873 "packages/c/lib/one.js",
1874 ] {
1875 let path = root.join(file);
1876 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1877 std::fs::write(path, "").unwrap();
1878 }
1879 let outputs = Outputs::from_paths(&[
1880 "apps/*/graphql/manifest.json".into(),
1881 "apps/*/app/graphql".into(),
1882 "packages/a/**/gen/*.ts".into(),
1883 "packages/b/types".into(),
1884 "packages/{c,d}/lib/*.js".into(),
1885 ])
1886 .unwrap();
1887 assert_eq!(
1888 outputs.paths(root).unwrap().into_iter().collect::<Vec<_>>(),
1889 [
1890 "apps/client/app/graphql",
1891 "apps/client/app/graphql/manifest.json",
1892 "apps/client/app/graphql/nested",
1893 "apps/client/app/graphql/nested/extra.json",
1894 "apps/web/graphql/manifest.json",
1895 "packages/a/deep/x/gen/out.ts",
1896 "packages/b/types",
1897 "packages/b/types/index.d.ts",
1898 "packages/c/lib/one.js",
1899 ]
1900 );
1901 }
1902
1903 #[test]
1907 fn escaped_names_are_expanded_segment_by_segment() {
1908 let workspace = tempfile::tempdir().unwrap();
1909 let root = workspace.path();
1910 for file in [
1911 "node_modules/.pnpm/react-native@1_patch/node_modules/react-native/.gradle/state",
1912 "node_modules/.pnpm/react-native-screens@2/node_modules/react-native/.gradle/state",
1913 "node_modules/.pnpm/@scope+plugin@3/node_modules/@scope/plugin/.gradle/state",
1914 "node_modules/.pnpm/scope+plugin@3/node_modules/@scope/plugin/.gradle/state",
1915 ] {
1916 let path = root.join(file);
1917 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1918 std::fs::write(path, "").unwrap();
1919 }
1920 let outputs = Outputs::from_paths(&[
1921 "node_modules/.pnpm/react-native\\@*/node_modules/react-native/.gradle".into(),
1922 "node_modules/.pnpm/\\@scope\\+plugin\\@*/node_modules/\\@scope/plugin/.gradle".into(),
1923 ])
1924 .unwrap();
1925 assert!(outputs.simple_segments().unwrap().is_some());
1926 assert_eq!(
1927 outputs.paths(root).unwrap().into_iter().collect::<Vec<_>>(),
1928 [
1929 "node_modules/.pnpm/@scope+plugin@3/node_modules/@scope/plugin/.gradle",
1930 "node_modules/.pnpm/@scope+plugin@3/node_modules/@scope/plugin/.gradle/state",
1931 "node_modules/.pnpm/react-native@1_patch/node_modules/react-native/.gradle",
1932 "node_modules/.pnpm/react-native@1_patch/node_modules/react-native/.gradle/state",
1933 ]
1934 );
1935 let unescaped = Outputs::from_paths(&["node_modules/.pnpm/react-native@*".into()]).unwrap();
1938 assert!(unescaped.simple_segments().unwrap().is_none());
1939 let apart = Outputs::from_paths(&["dist/*\\.*".into()]).unwrap();
1941 assert!(apart.simple_segments().unwrap().is_some());
1942 }
1943
1944 #[test]
1946 fn character_classes_with_slashes_keep_nested_artifacts() {
1947 let workspace = tempfile::tempdir().unwrap();
1948 for name in ["a", "b", "c"] {
1949 let directory = workspace.path().join(format!("apps/{name}/graphql/nested"));
1950 std::fs::create_dir_all(&directory).unwrap();
1951 std::fs::write(directory.join("output.txt"), "value").unwrap();
1952 }
1953 let outputs = Outputs::from_paths(&["apps/[a/b]/graphql".into()]).unwrap();
1954 assert_eq!(
1955 outputs
1956 .paths(workspace.path())
1957 .unwrap()
1958 .into_iter()
1959 .collect::<Vec<_>>(),
1960 [
1961 "apps/a/graphql",
1962 "apps/a/graphql/nested",
1963 "apps/a/graphql/nested/output.txt",
1964 "apps/b/graphql",
1965 "apps/b/graphql/nested",
1966 "apps/b/graphql/nested/output.txt",
1967 ]
1968 );
1969 }
1970
1971 #[test]
1972 fn segment_traversal_preserves_output_sets() {
1973 let workspace = tempfile::tempdir().unwrap();
1974 for file in [
1975 "apps/a/graphql/manifest.json",
1976 "apps/a/graphql/nested/out.ts",
1977 "apps/a/src/other.ts",
1978 "apps/b/src/graphql/manifest.json",
1979 "apps/.hidden/graphql/manifest.json",
1980 "apps/a/file",
1981 "dist/a.ts",
1982 "dist/deep/b.ts",
1983 ] {
1984 let path = workspace.path().join(file);
1985 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1986 std::fs::write(path, "value").unwrap();
1987 }
1988 for patterns in [
1989 vec![
1990 "apps/*/graphql/manifest.json",
1991 "apps/*/*/graphql/manifest.json",
1992 ],
1993 vec!["apps/?/graphql", "!apps/a/graphql/nested"],
1994 vec!["apps/a/graphql", "apps/*/graphql/*"],
1995 vec!["apps/a/file/nested", "missing/*/file", "dist/*.ts"],
1996 vec!["dist"],
1997 vec!["apps/**/manifest.json"],
1998 vec!["apps/[a/b]/graphql"],
1999 ] {
2000 let outputs =
2001 Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
2002 .unwrap();
2003 match (
2004 outputs.paths(workspace.path()),
2005 outputs.walk_paths(workspace.path()),
2006 ) {
2007 (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
2008 (Err(actual), Err(expected)) => {
2009 assert_eq!(actual.to_string(), expected.to_string())
2010 }
2011 results => panic!("different results for {patterns:?}: {results:?}"),
2012 }
2013 }
2014 }
2015
2016 #[cfg(unix)]
2017 #[test]
2018 fn segment_traversal_preserves_symlink_boundaries() {
2019 let workspace = tempfile::tempdir().unwrap();
2020 let root = workspace.path();
2021 std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
2022 std::fs::write(root.join("apps/a/graphql/manifest.json"), "value").unwrap();
2023 std::os::unix::fs::symlink("a", root.join("apps/link")).unwrap();
2024 std::os::unix::fs::symlink("absent", root.join("apps/dangling")).unwrap();
2025 for patterns in [
2026 vec!["apps/*/graphql/manifest.json"],
2027 vec!["apps/*"],
2028 vec!["apps/link"],
2029 vec!["apps/dangling"],
2030 ] {
2031 let outputs =
2032 Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
2033 .unwrap();
2034 match (outputs.paths(root), outputs.walk_paths(root)) {
2035 (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
2036 (Err(actual), Err(expected)) => {
2037 assert_eq!(actual.to_string(), expected.to_string())
2038 }
2039 results => panic!("different results for {patterns:?}: {results:?}"),
2040 }
2041 }
2042 }
2043
2044 #[test]
2045 fn listings_recheck_metadata_and_directory_contents() {
2046 let workspace = tempfile::tempdir().unwrap();
2047 let root = workspace.path();
2048 std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
2049 std::fs::write(root.join("apps/a/graphql/manifest.json"), "one").unwrap();
2050 let outputs = Outputs::from_paths(&["apps/*/graphql/manifest.json".into()]).unwrap();
2051 assert_eq!(
2052 outputs
2053 .entries(root)
2054 .unwrap()
2055 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
2056 .unwrap()["apps/a/graphql/manifest.json"]
2057 .len(),
2058 3
2059 );
2060 std::fs::write(root.join("apps/a/graphql/manifest.json"), "different").unwrap();
2061 std::fs::create_dir_all(root.join("apps/b/graphql")).unwrap();
2062 std::fs::write(root.join("apps/b/graphql/manifest.json"), "two").unwrap();
2063 let entries = outputs
2064 .entries(root)
2065 .unwrap()
2066 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
2067 .unwrap();
2068 assert_eq!(entries.len(), 2);
2069 assert_eq!(entries["apps/a/graphql/manifest.json"].len(), 9);
2070 std::fs::remove_dir_all(root.join("apps/a")).unwrap();
2071 assert_eq!(
2072 outputs
2073 .entries(root)
2074 .unwrap()
2075 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
2076 .unwrap()
2077 .len(),
2078 1
2079 );
2080 }
2081
2082 #[test]
2083 fn literal_segments_after_wildcards_remain_case_sensitive() {
2084 let workspace = tempfile::tempdir().unwrap();
2085 let root = workspace.path();
2086 std::fs::create_dir_all(root.join("apps/a/GraphQL")).unwrap();
2087 std::fs::write(root.join("apps/a/GraphQL/Manifest.json"), "one").unwrap();
2088 for glob in [
2089 "apps/*/graphql/Manifest.json",
2090 "apps/*/GraphQL/manifest.json",
2091 "apps/*/GraphQL/Manifest.json",
2092 "apps/a/GraphQL",
2093 ] {
2094 let outputs = Outputs::from_paths(&[glob.into()]).unwrap();
2095 assert_eq!(
2096 outputs.paths(root).unwrap(),
2097 outputs.walk_paths(root).unwrap(),
2098 "{glob}"
2099 );
2100 }
2101 }
2102
2103 #[test]
2104 fn simple_glob_combinations_match_the_walker() {
2105 let workspace = tempfile::tempdir().unwrap();
2106 let root = workspace.path();
2107 for project in ["a", "aa", "Ab", ".hidden", "café"] {
2108 for directory in ["graphql", "GraphQL", "src"] {
2109 let path = root.join(format!("apps/{project}/{directory}/nested"));
2110 std::fs::create_dir_all(&path).unwrap();
2111 std::fs::write(path.join("manifest.json"), "value").unwrap();
2112 std::fs::write(path.parent().unwrap().join("manifest.json"), "value").unwrap();
2113 }
2114 }
2115 for project in ["*", "?", "a*", "*a", "*?", "A*"] {
2116 for directory in ["graphql", "GraphQL", "src", "missing"] {
2117 for suffix in ["", "/manifest.json", "/*.json", "/?anifest.json", "/*"] {
2118 let glob = format!("apps/{project}/{directory}{suffix}");
2119 let outputs =
2120 Outputs::from_paths(&[glob.clone(), "!apps/aa/graphql/nested".into()])
2121 .unwrap();
2122 assert_eq!(
2123 outputs.paths(root).unwrap(),
2124 outputs.walk_paths(root).unwrap(),
2125 "{glob}"
2126 );
2127 }
2128 }
2129 }
2130 }
2131
2132 #[test]
2134 fn complete_roots_do_not_widen_globs_exclusions_or_defaults() {
2135 let mut outputs = Outputs::from_paths(&[
2136 "dist".into(),
2137 "dist/nested".into(),
2138 "dist2".into(),
2139 "partial/**/*.js".into(),
2140 ])
2141 .unwrap();
2142 assert_eq!(outputs.complete_roots(), ["dist", "dist2"]);
2143 outputs
2144 .negations
2145 .push(Pattern::new("dist/private", false).unwrap());
2146 assert!(outputs.complete_roots().is_empty());
2147 outputs.negations.clear();
2148 outputs.explicit = false;
2149 assert!(outputs.complete_roots().is_empty());
2150 }
2151
2152 #[test]
2153 fn normalizes_relative_segments() {
2154 assert_eq!(
2155 normalize("apps/client/tests/../build/*/bundle.bin").unwrap(),
2156 "apps/client/build/*/bundle.bin"
2157 );
2158 assert_eq!(normalize("./dist/./a").unwrap(), "dist/a");
2159 assert_eq!(normalize(".").unwrap(), ".");
2160 assert!(normalize("app/../../outside").is_err());
2161 assert!(normalize("app/**/../x").is_err());
2162 assert_eq!(normalize(r"apps/\(group\)/../x").unwrap(), "apps/x");
2163 }
2164}