1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Component, Path, PathBuf};
3use std::process::{Command, Output};
4
5use anyhow::{Context, Result, bail};
6use qk_config::Workspace;
7use qk_taskgraph::Task;
8
9pub fn git(root: &Path, args: &[&str]) -> std::io::Result<Output> {
10 Command::new("git")
11 .arg("-C")
12 .arg(root)
13 .args(args)
14 .env_remove("GIT_DIR")
15 .env_remove("GIT_WORK_TREE")
16 .env_remove("GIT_INDEX_FILE")
17 .env_remove("GIT_COMMON_DIR")
18 .output()
19}
20
21pub fn git_path(root: &Path, flag: &str) -> Option<PathBuf> {
22 let result = git(root, &["rev-parse", "--path-format=absolute", flag]).ok()?;
23 if !result.status.success() {
24 return None;
25 }
26 let path = String::from_utf8(result.stdout).ok()?;
27 Some(PathBuf::from(path.trim_end_matches(['\r', '\n'])))
28}
29
30pub fn worktree_state(root: &Path) -> PathBuf {
35 static KNOWN: std::sync::OnceLock<
36 std::sync::Mutex<std::collections::HashMap<PathBuf, PathBuf>>,
37 > = std::sync::OnceLock::new();
38 let known = KNOWN.get_or_init(Default::default);
39 if let Some(state) = known.lock().unwrap().get(root) {
40 return state.clone();
41 }
42 let state = git_path(root, "--git-dir")
43 .map(|dir| dir.join("qk"))
44 .unwrap_or_else(|| root.join(".qk"));
45 known.lock().unwrap().insert(root.to_owned(), state.clone());
46 state
47}
48
49const WORKTREE_ENTRIES: &[&str] = &["digests.json", "outputs", "restore", "sandbox", "warm"];
53
54pub fn clear_worktree_state(root: &Path) -> Result<()> {
57 let state = worktree_state(root);
58 for entry in WORKTREE_ENTRIES {
59 let path = state.join(entry);
60 let removed = if path.is_dir() {
61 std::fs::remove_dir_all(&path)
62 } else {
63 std::fs::remove_file(&path)
64 };
65 match removed {
66 Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
67 return Err(error).with_context(|| format!("cannot remove {}", path.display()));
68 }
69 _ => {}
70 }
71 }
72 Ok(())
73}
74
75pub fn cache_location(workspace: &Workspace) -> PathBuf {
80 let configured = std::env::var_os("NX_CACHE_DIRECTORY")
81 .filter(|value| !value.is_empty())
82 .map(PathBuf::from)
83 .or_else(|| {
84 workspace
85 .config
86 .extra
87 .get("cacheDirectory")
88 .and_then(serde_json::Value::as_str)
89 .map(PathBuf::from)
90 });
91 match configured {
92 Some(directory) => workspace.root.join(directory).join("qk/v1"),
93 None => cache_directory(&workspace.root),
94 }
95}
96
97pub fn cache_directory(root: &Path) -> PathBuf {
99 git_path(root, "--git-common-dir")
100 .map(|dir| dir.join("qk/cache/v1"))
101 .unwrap_or_else(|| root.join(".qk/cache/v1"))
102}
103
104pub fn relative(root: &Path, path: &Path) -> Result<String> {
105 let parts = path
106 .strip_prefix(root)?
107 .components()
108 .map(|part| {
109 part.as_os_str()
110 .to_str()
111 .map(str::to_owned)
112 .context("cache paths must be UTF-8")
113 })
114 .collect::<Result<Vec<_>>>()?;
115 Ok(parts.join("/"))
116}
117
118pub fn validate_path(path: &str) -> Result<()> {
119 if path.is_empty()
120 || path.contains(['\\', ':', '\0'])
121 || Path::new(path).is_absolute()
122 || Path::new(path)
123 .components()
124 .any(|part| !matches!(part, Component::Normal(_)))
125 || path
126 .split('/')
127 .any(|part| matches!(part, ".git" | ".qk" | ".." | "." | ""))
128 {
129 bail!("unsafe cache artifact path {path:?}");
130 }
131 Ok(())
132}
133
134pub fn safe_parents(root: &Path, path: &str) -> Result<()> {
135 validate_path(path)?;
136 let mut current = root.to_owned();
137 for part in Path::new(path)
138 .parent()
139 .into_iter()
140 .flat_map(Path::components)
141 {
142 current.push(part);
143 match std::fs::symlink_metadata(¤t) {
144 Ok(metadata) if metadata.file_type().is_symlink() => {
145 bail!("cache artifact parent is a symlink: {}", current.display())
146 }
147 Ok(metadata) if !metadata.is_dir() => bail!(
148 "cache artifact parent is not a directory: {}",
149 current.display()
150 ),
151 Ok(_) => {}
152 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
153 Err(error) => return Err(error.into()),
154 }
155 }
156 Ok(())
157}
158
159pub use crate::glob::Pattern;
160
161pub fn expand(workspace: &Workspace, project: &str, text: &str) -> Result<String> {
162 let project_root = &workspace.projects[project].root;
163 let prefix = if project_root == "." {
164 String::new()
165 } else {
166 format!("{project_root}/")
167 };
168 let result = text
169 .replace("{workspaceRoot}/", "")
170 .replace("{projectRoot}/", &prefix)
171 .replace("{projectRoot}", project_root)
172 .replace("{workspaceRoot}", ".");
173 if result.contains("{options.") || result.contains("{args.") {
174 bail!("dynamic cache paths are not supported yet");
175 }
176 normalize(&result)
177}
178
179fn normalize(path: &str) -> Result<String> {
181 let mut parts: Vec<&str> = Vec::new();
182 for part in path.split('/') {
183 match part {
184 "." => {}
185 ".." => match parts.pop() {
186 Some(parent) if crate::glob::is_literal(parent) => {}
187 _ => bail!("cache path {path:?} leaves the workspace or follows a glob with `..`"),
188 },
189 part => parts.push(part),
190 }
191 }
192 Ok(if parts.is_empty() {
193 ".".to_owned()
194 } else {
195 parts.join("/")
196 })
197}
198
199#[derive(Clone)]
200pub struct Outputs {
201 patterns: Vec<Pattern>,
202 globs: Vec<String>,
204 negations: Vec<Pattern>,
206 anchors: BTreeSet<String>,
207 complete: BTreeSet<String>,
209 explicit: bool,
212}
213
214impl Outputs {
215 pub fn from_paths(patterns: &[String]) -> Result<Self> {
219 let mut compiled = Vec::new();
220 let mut globs = Vec::new();
221 let mut negations = Vec::new();
222 let mut anchors = BTreeSet::new();
223 let mut complete = BTreeSet::new();
224 for pattern in patterns {
225 if let Some(excluded) = pattern.strip_prefix('!') {
226 validate_path(excluded)?;
227 negations.push(Pattern::new(excluded, false)?);
228 continue;
229 }
230 validate_path(pattern)?;
231 let anchor = pattern
232 .split('/')
233 .take_while(|part| crate::glob::is_literal(part))
234 .collect::<Vec<_>>()
235 .join("/");
236 if anchor.is_empty() {
237 bail!("{pattern:?} needs a fixed directory prefix");
238 }
239 if pattern.split('/').all(crate::glob::is_literal) {
240 complete.insert(pattern.clone());
241 }
242 compiled.push(Pattern::new(pattern, false)?);
243 globs.push(pattern.clone());
244 anchors.insert(anchor);
245 }
246 Ok(Self {
247 patterns: compiled,
248 globs,
249 negations,
250 anchors,
251 complete,
252 explicit: true,
253 })
254 }
255
256 #[cfg(test)]
258 pub fn from_patterns(patterns: &[&str]) -> Self {
259 Self {
260 patterns: patterns
261 .iter()
262 .map(|pattern| Pattern::new(pattern, false).unwrap())
263 .collect(),
264 globs: patterns
265 .iter()
266 .map(|pattern| (*pattern).to_owned())
267 .collect(),
268 anchors: patterns
269 .iter()
270 .map(|pattern| (*pattern).to_owned())
271 .collect(),
272 complete: patterns
273 .iter()
274 .filter(|pattern| pattern.split('/').all(crate::glob::is_literal))
275 .map(|pattern| (*pattern).to_owned())
276 .collect(),
277 negations: Vec::new(),
278 explicit: true,
279 }
280 }
281
282 pub fn new(workspace: &Workspace, task: &Task) -> Result<Self> {
284 let (resolved, explicit) = resolved_outputs(workspace, task)?;
285 let mut patterns = Vec::new();
286 let mut globs = Vec::new();
287 let mut negations = Vec::new();
288 let mut anchors = BTreeSet::new();
289 let mut complete = BTreeSet::new();
290 for output in resolved {
291 let (negated, pattern) = match output.strip_prefix('!') {
292 Some(rest) => (true, rest.to_owned()),
293 None => (false, output),
294 };
295 validate_path(&pattern)?;
296 if negated {
297 negations.push(Pattern::new(&pattern, false)?);
298 continue;
299 }
300 let anchor = pattern
301 .split('/')
302 .take_while(|part| crate::glob::is_literal(part))
303 .collect::<Vec<_>>()
304 .join("/");
305 if anchor.is_empty()
306 || workspace
307 .projects
308 .values()
309 .any(|project| project.root == pattern)
310 {
311 bail!(
312 "cache outputs must have a fixed directory prefix and cannot replace a project root"
313 );
314 }
315 if pattern.split('/').all(crate::glob::is_literal) {
316 complete.insert(pattern.clone());
317 }
318 patterns.push(Pattern::new(&pattern, false)?);
319 globs.push(pattern);
320 anchors.insert(anchor);
321 }
322 Ok(Self {
323 patterns,
324 globs,
325 negations,
326 anchors,
327 complete,
328 explicit,
329 })
330 }
331
332 pub fn is_explicit(&self) -> bool {
334 self.explicit
335 }
336
337 pub fn declared(&self) -> bool {
341 self.explicit && !self.patterns.is_empty()
342 }
343
344 pub fn anchors(&self) -> impl Iterator<Item = &str> {
346 self.anchors.iter().map(String::as_str)
347 }
348
349 pub(crate) fn complete_roots(&self) -> Vec<&str> {
352 if !self.explicit || !self.negations.is_empty() {
353 return Vec::new();
354 }
355 let mut roots: Vec<&str> = Vec::new();
356 for root in &self.complete {
357 if !roots.iter().any(|parent| {
358 root.strip_prefix(parent)
359 .is_some_and(|suffix| suffix.starts_with('/'))
360 }) {
361 roots.push(root);
362 }
363 }
364 roots
365 }
366
367 pub fn matches(&self, path: &str) -> bool {
368 let any = |patterns: &[Pattern]| {
369 Path::new(path)
370 .ancestors()
371 .any(|ancestor| patterns.iter().any(|pattern| pattern.is_match(ancestor)))
372 };
373 any(&self.patterns) && !any(&self.negations)
374 }
375
376 pub fn paths(&self, root: &Path) -> Result<BTreeSet<String>> {
377 self.listing(root).map(|(paths, _)| paths)
378 }
379
380 pub(crate) fn entries<'a>(
381 &self,
382 root: &'a Path,
383 ) -> Result<impl Iterator<Item = Result<(String, std::fs::Metadata)>> + 'a> {
384 let (paths, mut traversal) = self.listing(root)?;
385 Ok(paths.into_iter().map(move |path| {
386 let absolute = root.join(&path);
387 let metadata = match traversal.metadata.remove(&absolute) {
388 Some(metadata) => metadata,
389 None => std::fs::symlink_metadata(&absolute)?,
390 };
391 Ok((path, metadata))
392 }))
393 }
394
395 fn listing(&self, root: &Path) -> Result<(BTreeSet<String>, Traversal)> {
396 if let Some(segments) = self.simple_segments()? {
397 for anchor in &self.anchors {
398 safe_parents(root, anchor)?;
399 match std::fs::symlink_metadata(root.join(anchor)) {
400 Ok(metadata) if metadata.file_type().is_symlink() => {
401 return self
402 .walk_paths(root)
403 .map(|paths| (paths, Traversal::default()));
404 }
405 Ok(_) => {}
406 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
407 Err(error) => return Err(error.into()),
408 }
409 }
410 let mut traversal = Traversal::default();
411 let mut candidates = BTreeSet::new();
412 for (glob, pattern) in self.globs.iter().zip(&segments) {
413 let fixed = pattern
414 .iter()
415 .take_while(|segment| matches!(segment, Segment::Literal(_)))
416 .count();
417 let anchor = glob.split('/').take(fixed).collect::<Vec<_>>().join("/");
418 traversal.expand(root, &root.join(anchor), &pattern[fixed..], &mut candidates)?;
419 }
420 return candidates
421 .into_iter()
422 .filter(|path| self.matches(path))
423 .map(|path| {
424 validate_path(&path)?;
425 Ok(path)
426 })
427 .collect::<Result<_>>()
428 .map(|paths| (paths, traversal));
429 }
430 self.walk_paths(root)
431 .map(|paths| (paths, Traversal::default()))
432 }
433
434 fn simple_segments(&self) -> Result<Option<Vec<Vec<Segment>>>> {
435 if self.globs.iter().any(|glob| {
436 glob.contains("**")
437 || glob.contains(['!', '@', '+', '|', ',', '{', '}', '[', ']', '(', ')'])
438 }) {
439 return Ok(None);
440 }
441 let mut matchers: BTreeMap<String, Pattern> = BTreeMap::new();
442 let mut patterns = Vec::new();
443 for glob in &self.globs {
444 let mut segments = Vec::new();
445 for part in glob.split('/') {
446 segments.push(if crate::glob::is_literal(part) {
447 Segment::Literal(part.to_owned())
448 } else {
449 let matcher = match matchers.entry(part.to_owned()) {
450 std::collections::btree_map::Entry::Occupied(entry) => entry.get().clone(),
451 std::collections::btree_map::Entry::Vacant(entry) => {
452 entry.insert(Pattern::new(part, false)?).clone()
453 }
454 };
455 Segment::Glob(matcher)
456 });
457 }
458 patterns.push(segments);
459 }
460 Ok(Some(patterns))
461 }
462
463 fn walk_paths(&self, root: &Path) -> Result<BTreeSet<String>> {
464 let mut paths = BTreeSet::new();
465 for anchor in &self.anchors {
466 safe_parents(root, anchor)?;
467 let path = root.join(anchor);
468 match std::fs::symlink_metadata(&path) {
469 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
470 Err(error) => return Err(error.into()),
471 Ok(_) => {}
472 }
473 let walk = walkdir::WalkDir::new(path)
474 .follow_links(false)
475 .into_iter()
476 .filter_entry(|entry| {
477 entry.depth() == 0
478 || !entry.file_type().is_dir()
479 || relative(root, entry.path()).map_or(true, |directory| {
480 self.globs
481 .iter()
482 .any(|glob| may_match_below(glob, &directory))
483 })
484 });
485 for entry in walk {
486 let entry = entry?;
487 let path = relative(root, entry.path())?;
488 if self.matches(&path) {
489 validate_path(&path)?;
490 paths.insert(path);
491 }
492 }
493 }
494 Ok(paths)
495 }
496}
497
498#[derive(Clone)]
499enum Segment {
500 Literal(String),
501 Glob(Pattern),
502}
503
504#[derive(Default)]
505struct Traversal {
506 metadata: BTreeMap<PathBuf, std::fs::Metadata>,
507 directories: BTreeMap<PathBuf, Vec<PathBuf>>,
508}
509
510impl Traversal {
511 fn metadata(&mut self, path: &Path) -> Result<Option<std::fs::Metadata>> {
512 if let Some(metadata) = self.metadata.get(path) {
513 return Ok(Some(metadata.clone()));
514 }
515 match std::fs::symlink_metadata(path) {
516 Ok(metadata) => {
517 self.metadata.insert(path.to_owned(), metadata.clone());
518 Ok(Some(metadata))
519 }
520 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
521 Err(error) => Err(error.into()),
522 }
523 }
524
525 fn children(&mut self, path: &Path) -> Result<&Vec<PathBuf>> {
526 if !self.directories.contains_key(path) {
527 let children = std::fs::read_dir(path)?
528 .map(|entry| entry.map(|entry| entry.path()))
529 .collect::<std::io::Result<Vec<_>>>()?;
530 self.directories.insert(path.to_owned(), children);
531 }
532 Ok(&self.directories[path])
533 }
534
535 fn expand(
536 &mut self,
537 root: &Path,
538 path: &Path,
539 segments: &[Segment],
540 paths: &mut BTreeSet<String>,
541 ) -> Result<()> {
542 let Some(metadata) = self.metadata(path)? else {
543 return Ok(());
544 };
545 let Some((segment, rest)) = segments.split_first() else {
546 for entry in walkdir::WalkDir::new(path)
547 .follow_links(false)
548 .follow_root_links(false)
549 {
550 paths.insert(relative(root, entry?.path())?);
551 }
552 return Ok(());
553 };
554 if !metadata.is_dir() {
555 return Ok(());
556 }
557 match segment {
558 Segment::Literal(name) => {
559 let candidate = path.join(name);
560 if self.metadata(&candidate)?.is_none() {
561 return Ok(());
562 }
563 let child = self
565 .children(path)?
566 .iter()
567 .find(|child| child.file_name() == Some(std::ffi::OsStr::new(name)))
568 .cloned();
569 if let Some(child) = child {
570 self.expand(root, &child, rest, paths)?;
571 }
572 Ok(())
573 }
574 Segment::Glob(matcher) => {
575 let children: Vec<_> = self
576 .children(path)?
577 .iter()
578 .filter(|child| matcher.is_match(child.file_name().unwrap()))
579 .cloned()
580 .collect();
581 for child in children {
582 self.expand(root, &child, rest, paths)?;
583 }
584 Ok(())
585 }
586 }
587 }
588}
589
590fn may_match_below(glob: &str, directory: &str) -> bool {
595 if glob.contains(['{', '}', '(', ')', '[', ']']) {
596 return true;
597 }
598 for (part, name) in glob.split('/').zip(directory.split('/')) {
599 if part == "**" {
600 return true;
601 }
602 if crate::glob::is_literal(part) && part != name {
603 return false;
604 }
605 }
606 true
607}
608
609pub fn resolved_outputs(workspace: &Workspace, task: &Task) -> Result<(Vec<String>, bool)> {
616 let root = &workspace.projects[&task.project].root;
617 let options = task_options(task);
618 let (templates, explicit) = match &task.definition.outputs {
619 Some(outputs) => (outputs.clone(), true),
620 None => match options.get("outputPath") {
621 Some(serde_json::Value::String(path)) => (vec![path.clone()], false),
622 Some(serde_json::Value::Array(paths)) => (
623 paths
624 .iter()
625 .filter_map(|path| path.as_str().map(str::to_owned))
626 .collect(),
627 false,
628 ),
629 _ if matches!(task.target.as_str(), "build" | "prepare") => (
630 [
631 format!("dist/{root}"),
632 format!("{root}/dist"),
633 format!("{root}/build"),
634 format!("{root}/public"),
635 ]
636 .into(),
637 false,
638 ),
639 _ => (Vec::new(), false),
640 },
641 };
642 let mut resolved = Vec::new();
643 for template in templates {
644 let (negated, template) = match template.strip_prefix('!') {
645 Some(rest) => (true, rest.to_owned()),
646 None => (false, template),
647 };
648 let Some(output) = resolve_output(task, &options, &template) else {
649 continue;
650 };
651 let path = expand(workspace, &task.project, &output)?
652 .trim_end_matches('/')
653 .to_owned();
654 resolved.push(if negated { format!("!{path}") } else { path });
655 }
656 Ok((resolved, explicit))
657}
658
659fn task_options(task: &Task) -> serde_json::Map<String, serde_json::Value> {
662 let mut options = task.definition.options.clone();
663 let mut args = task.args.iter().peekable();
664 while let Some(arg) = args.next() {
665 let Some(flag) = arg.strip_prefix("--") else {
666 continue;
667 };
668 let (key, value) = match flag.split_once('=') {
669 Some((key, value)) => (key.to_owned(), value.to_owned()),
670 None if args.peek().is_some_and(|next| !next.starts_with('-')) => {
671 (flag.to_owned(), args.next().unwrap().clone())
672 }
673 None => (flag.to_owned(), "true".to_owned()),
674 };
675 options.insert(key, serde_json::Value::String(value));
676 }
677 options
678}
679
680fn resolve_output(
684 task: &Task,
685 options: &serde_json::Map<String, serde_json::Value>,
686 template: &str,
687) -> Option<String> {
688 let mut result = String::new();
689 let mut rest = template;
690 while let Some(start) = rest.find('{') {
691 result.push_str(&rest[..start]);
692 let end = rest[start..].find('}')? + start;
693 let token = rest[start + 1..end].trim();
694 let value = if let Some(path) = token.strip_prefix("options.") {
695 let mut value = options.get(path.split('.').next()?)?;
696 for part in path.split('.').skip(1) {
697 value = value.get(part)?;
698 }
699 match value {
700 serde_json::Value::String(text) if !text.is_empty() => text.clone(),
701 serde_json::Value::Number(number) => number.to_string(),
702 serde_json::Value::Bool(true) => "true".into(),
703 _ => return None,
704 }
705 } else {
706 match token {
707 "projectName" | "project.name" => task.project.clone(),
708 "project.root" => "{projectRoot}".into(),
709 "projectRoot" | "workspaceRoot" => format!("{{{token}}}"),
710 _ => return None,
711 }
712 };
713 result.push_str(&value);
714 rest = &rest[end + 1..];
715 }
716 result.push_str(rest);
717 Some(result)
718}
719
720#[cfg(test)]
721mod tests {
722 use super::{Outputs, Pattern, normalize};
723
724 #[test]
726 fn listing_skips_only_directories_no_glob_reaches() {
727 let workspace = tempfile::tempdir().unwrap();
728 let root = workspace.path();
729 for file in [
730 "apps/web/graphql/manifest.json",
731 "apps/web/src/graphql/manifest.json",
732 "apps/web/node_modules/pkg/graphql/manifest.json",
733 "apps/mobile/app/graphql/manifest.json",
734 "apps/mobile/app/graphql/nested/extra.json",
735 "packages/a/deep/x/gen/out.ts",
736 "packages/b/types/index.d.ts",
737 "packages/c/lib/one.js",
738 ] {
739 let path = root.join(file);
740 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
741 std::fs::write(path, "").unwrap();
742 }
743 let outputs = Outputs::from_paths(&[
744 "apps/*/graphql/manifest.json".into(),
745 "apps/*/app/graphql".into(),
746 "packages/a/**/gen/*.ts".into(),
747 "packages/b/types".into(),
748 "packages/{c,d}/lib/*.js".into(),
749 ])
750 .unwrap();
751 assert_eq!(
752 outputs.paths(root).unwrap().into_iter().collect::<Vec<_>>(),
753 [
754 "apps/mobile/app/graphql",
755 "apps/mobile/app/graphql/manifest.json",
756 "apps/mobile/app/graphql/nested",
757 "apps/mobile/app/graphql/nested/extra.json",
758 "apps/web/graphql/manifest.json",
759 "packages/a/deep/x/gen/out.ts",
760 "packages/b/types",
761 "packages/b/types/index.d.ts",
762 "packages/c/lib/one.js",
763 ]
764 );
765 }
766
767 #[test]
769 fn character_classes_with_slashes_keep_nested_artifacts() {
770 let workspace = tempfile::tempdir().unwrap();
771 for name in ["a", "b", "c"] {
772 let directory = workspace.path().join(format!("apps/{name}/graphql/nested"));
773 std::fs::create_dir_all(&directory).unwrap();
774 std::fs::write(directory.join("output.txt"), "value").unwrap();
775 }
776 let outputs = Outputs::from_paths(&["apps/[a/b]/graphql".into()]).unwrap();
777 assert_eq!(
778 outputs
779 .paths(workspace.path())
780 .unwrap()
781 .into_iter()
782 .collect::<Vec<_>>(),
783 [
784 "apps/a/graphql",
785 "apps/a/graphql/nested",
786 "apps/a/graphql/nested/output.txt",
787 "apps/b/graphql",
788 "apps/b/graphql/nested",
789 "apps/b/graphql/nested/output.txt",
790 ]
791 );
792 }
793
794 #[test]
795 fn segment_traversal_preserves_output_sets() {
796 let workspace = tempfile::tempdir().unwrap();
797 for file in [
798 "apps/a/graphql/manifest.json",
799 "apps/a/graphql/nested/out.ts",
800 "apps/a/src/other.ts",
801 "apps/b/src/graphql/manifest.json",
802 "apps/.hidden/graphql/manifest.json",
803 "apps/a/file",
804 "dist/a.ts",
805 "dist/deep/b.ts",
806 ] {
807 let path = workspace.path().join(file);
808 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
809 std::fs::write(path, "value").unwrap();
810 }
811 for patterns in [
812 vec![
813 "apps/*/graphql/manifest.json",
814 "apps/*/*/graphql/manifest.json",
815 ],
816 vec!["apps/?/graphql", "!apps/a/graphql/nested"],
817 vec!["apps/a/graphql", "apps/*/graphql/*"],
818 vec!["apps/a/file/nested", "missing/*/file", "dist/*.ts"],
819 vec!["dist"],
820 vec!["apps/**/manifest.json"],
821 vec!["apps/[a/b]/graphql"],
822 ] {
823 let outputs =
824 Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
825 .unwrap();
826 match (
827 outputs.paths(workspace.path()),
828 outputs.walk_paths(workspace.path()),
829 ) {
830 (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
831 (Err(actual), Err(expected)) => {
832 assert_eq!(actual.to_string(), expected.to_string())
833 }
834 results => panic!("different results for {patterns:?}: {results:?}"),
835 }
836 }
837 }
838
839 #[cfg(unix)]
840 #[test]
841 fn segment_traversal_preserves_symlink_boundaries() {
842 let workspace = tempfile::tempdir().unwrap();
843 let root = workspace.path();
844 std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
845 std::fs::write(root.join("apps/a/graphql/manifest.json"), "value").unwrap();
846 std::os::unix::fs::symlink("a", root.join("apps/link")).unwrap();
847 std::os::unix::fs::symlink("absent", root.join("apps/dangling")).unwrap();
848 for patterns in [
849 vec!["apps/*/graphql/manifest.json"],
850 vec!["apps/*"],
851 vec!["apps/link"],
852 vec!["apps/dangling"],
853 ] {
854 let outputs =
855 Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
856 .unwrap();
857 match (outputs.paths(root), outputs.walk_paths(root)) {
858 (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
859 (Err(actual), Err(expected)) => {
860 assert_eq!(actual.to_string(), expected.to_string())
861 }
862 results => panic!("different results for {patterns:?}: {results:?}"),
863 }
864 }
865 }
866
867 #[test]
868 fn listings_recheck_metadata_and_directory_contents() {
869 let workspace = tempfile::tempdir().unwrap();
870 let root = workspace.path();
871 std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
872 std::fs::write(root.join("apps/a/graphql/manifest.json"), "one").unwrap();
873 let outputs = Outputs::from_paths(&["apps/*/graphql/manifest.json".into()]).unwrap();
874 assert_eq!(
875 outputs
876 .entries(root)
877 .unwrap()
878 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
879 .unwrap()["apps/a/graphql/manifest.json"]
880 .len(),
881 3
882 );
883 std::fs::write(root.join("apps/a/graphql/manifest.json"), "different").unwrap();
884 std::fs::create_dir_all(root.join("apps/b/graphql")).unwrap();
885 std::fs::write(root.join("apps/b/graphql/manifest.json"), "two").unwrap();
886 let entries = outputs
887 .entries(root)
888 .unwrap()
889 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
890 .unwrap();
891 assert_eq!(entries.len(), 2);
892 assert_eq!(entries["apps/a/graphql/manifest.json"].len(), 9);
893 std::fs::remove_dir_all(root.join("apps/a")).unwrap();
894 assert_eq!(
895 outputs
896 .entries(root)
897 .unwrap()
898 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
899 .unwrap()
900 .len(),
901 1
902 );
903 }
904
905 #[test]
906 fn literal_segments_after_wildcards_remain_case_sensitive() {
907 let workspace = tempfile::tempdir().unwrap();
908 let root = workspace.path();
909 std::fs::create_dir_all(root.join("apps/a/GraphQL")).unwrap();
910 std::fs::write(root.join("apps/a/GraphQL/Manifest.json"), "one").unwrap();
911 for glob in [
912 "apps/*/graphql/Manifest.json",
913 "apps/*/GraphQL/manifest.json",
914 "apps/*/GraphQL/Manifest.json",
915 "apps/a/GraphQL",
916 ] {
917 let outputs = Outputs::from_paths(&[glob.into()]).unwrap();
918 assert_eq!(
919 outputs.paths(root).unwrap(),
920 outputs.walk_paths(root).unwrap(),
921 "{glob}"
922 );
923 }
924 }
925
926 #[test]
927 fn simple_glob_combinations_match_the_walker() {
928 let workspace = tempfile::tempdir().unwrap();
929 let root = workspace.path();
930 for project in ["a", "aa", "Ab", ".hidden", "café"] {
931 for directory in ["graphql", "GraphQL", "src"] {
932 let path = root.join(format!("apps/{project}/{directory}/nested"));
933 std::fs::create_dir_all(&path).unwrap();
934 std::fs::write(path.join("manifest.json"), "value").unwrap();
935 std::fs::write(path.parent().unwrap().join("manifest.json"), "value").unwrap();
936 }
937 }
938 for project in ["*", "?", "a*", "*a", "*?", "A*"] {
939 for directory in ["graphql", "GraphQL", "src", "missing"] {
940 for suffix in ["", "/manifest.json", "/*.json", "/?anifest.json", "/*"] {
941 let glob = format!("apps/{project}/{directory}{suffix}");
942 let outputs =
943 Outputs::from_paths(&[glob.clone(), "!apps/aa/graphql/nested".into()])
944 .unwrap();
945 assert_eq!(
946 outputs.paths(root).unwrap(),
947 outputs.walk_paths(root).unwrap(),
948 "{glob}"
949 );
950 }
951 }
952 }
953 }
954
955 #[test]
957 fn complete_roots_do_not_widen_globs_exclusions_or_defaults() {
958 let mut outputs = Outputs::from_paths(&[
959 "dist".into(),
960 "dist/nested".into(),
961 "dist2".into(),
962 "partial/**/*.js".into(),
963 ])
964 .unwrap();
965 assert_eq!(outputs.complete_roots(), ["dist", "dist2"]);
966 outputs
967 .negations
968 .push(Pattern::new("dist/private", false).unwrap());
969 assert!(outputs.complete_roots().is_empty());
970 outputs.negations.clear();
971 outputs.explicit = false;
972 assert!(outputs.complete_roots().is_empty());
973 }
974
975 #[test]
976 fn normalizes_relative_segments() {
977 assert_eq!(
978 normalize("apps/mobile/test-e2e/../ios/build/*/App.app").unwrap(),
979 "apps/mobile/ios/build/*/App.app"
980 );
981 assert_eq!(normalize("./dist/./a").unwrap(), "dist/a");
982 assert_eq!(normalize(".").unwrap(), ".");
983 assert!(normalize("app/../../outside").is_err());
984 assert!(normalize("app/**/../x").is_err());
985 }
986}