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 || (cfg!(windows) && path.contains(':'))
122 || Path::new(path).is_absolute()
123 || Path::new(path)
124 .components()
125 .any(|part| !matches!(part, Component::Normal(_)))
126 || path
127 .split('/')
128 .any(|part| matches!(part, ".git" | ".qk" | ".." | "." | ""))
129 {
130 bail!("unsafe cache artifact path {path:?}");
131 }
132 Ok(())
133}
134
135pub fn safe_parents(root: &Path, path: &str) -> Result<()> {
136 validate_path(path)?;
137 let mut current = root.to_owned();
138 for part in Path::new(path)
139 .parent()
140 .into_iter()
141 .flat_map(Path::components)
142 {
143 current.push(part);
144 match std::fs::symlink_metadata(¤t) {
145 Ok(metadata) if metadata.file_type().is_symlink() => {
146 bail!("cache artifact parent is a symlink: {}", current.display())
147 }
148 Ok(metadata) if !metadata.is_dir() => bail!(
149 "cache artifact parent is not a directory: {}",
150 current.display()
151 ),
152 Ok(_) => {}
153 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
154 Err(error) => return Err(error.into()),
155 }
156 }
157 Ok(())
158}
159
160pub use crate::glob::Pattern;
161
162pub fn expand(workspace: &Workspace, project: &str, text: &str) -> Result<String> {
163 let project_root = &workspace.projects[project].root;
164 let prefix = if project_root == "." {
165 String::new()
166 } else {
167 format!("{project_root}/")
168 };
169 let result = text
170 .replace("{workspaceRoot}/", "")
171 .replace("{projectRoot}/", &prefix)
172 .replace("{projectRoot}", project_root)
173 .replace("{workspaceRoot}", ".");
174 if result.contains("{options.") || result.contains("{args.") {
175 bail!("dynamic cache paths are not supported yet");
176 }
177 normalize(&result)
178}
179
180fn normalize(path: &str) -> Result<String> {
182 let mut parts: Vec<&str> = Vec::new();
183 for part in path.split('/') {
184 match part {
185 "." => {}
186 ".." => match parts.pop() {
187 Some(parent) if crate::glob::is_literal(parent) => {}
188 _ => bail!("cache path {path:?} leaves the workspace or follows a glob with `..`"),
189 },
190 part => parts.push(part),
191 }
192 }
193 Ok(if parts.is_empty() {
194 ".".to_owned()
195 } else {
196 parts.join("/")
197 })
198}
199
200type OutputEntries<'a> = Box<dyn Iterator<Item = Result<(String, std::fs::Metadata)>> + 'a>;
201
202#[derive(Clone)]
203pub struct Outputs {
204 patterns: Vec<Pattern>,
205 globs: Vec<String>,
207 negations: Vec<Pattern>,
209 anchors: BTreeSet<String>,
210 complete: BTreeSet<String>,
212 explicit: bool,
215}
216
217impl Outputs {
218 pub fn from_paths(patterns: &[String]) -> Result<Self> {
222 let mut compiled = Vec::new();
223 let mut globs = Vec::new();
224 let mut negations = Vec::new();
225 let mut anchors = BTreeSet::new();
226 let mut complete = BTreeSet::new();
227 for pattern in patterns {
228 if let Some(excluded) = pattern.strip_prefix('!') {
229 validate_path(excluded)?;
230 negations.push(Pattern::new(excluded, false)?);
231 continue;
232 }
233 validate_path(pattern)?;
234 let anchor = pattern
235 .split('/')
236 .take_while(|part| crate::glob::is_literal(part))
237 .collect::<Vec<_>>()
238 .join("/");
239 if anchor.is_empty() {
240 bail!("{pattern:?} needs a fixed directory prefix");
241 }
242 if pattern.split('/').all(crate::glob::is_literal) {
243 complete.insert(pattern.clone());
244 }
245 compiled.push(Pattern::new(pattern, false)?);
246 globs.push(pattern.clone());
247 anchors.insert(anchor);
248 }
249 Ok(Self {
250 patterns: compiled,
251 globs,
252 negations,
253 anchors,
254 complete,
255 explicit: true,
256 })
257 }
258
259 #[cfg(test)]
261 pub fn from_patterns(patterns: &[&str]) -> Self {
262 Self {
263 patterns: patterns
264 .iter()
265 .map(|pattern| Pattern::new(pattern, false).unwrap())
266 .collect(),
267 globs: patterns
268 .iter()
269 .map(|pattern| (*pattern).to_owned())
270 .collect(),
271 anchors: patterns
272 .iter()
273 .map(|pattern| (*pattern).to_owned())
274 .collect(),
275 complete: patterns
276 .iter()
277 .filter(|pattern| pattern.split('/').all(crate::glob::is_literal))
278 .map(|pattern| (*pattern).to_owned())
279 .collect(),
280 negations: Vec::new(),
281 explicit: true,
282 }
283 }
284
285 pub fn new(workspace: &Workspace, task: &Task) -> Result<Self> {
287 let (resolved, explicit) = resolved_outputs(workspace, task)?;
288 let mut patterns = Vec::new();
289 let mut globs = Vec::new();
290 let mut negations = Vec::new();
291 let mut anchors = BTreeSet::new();
292 let mut complete = BTreeSet::new();
293 for output in resolved {
294 let (negated, pattern) = match output.strip_prefix('!') {
295 Some(rest) => (true, rest.to_owned()),
296 None => (false, output),
297 };
298 validate_path(&pattern)?;
299 if negated {
300 negations.push(Pattern::new(&pattern, false)?);
301 continue;
302 }
303 let anchor = pattern
304 .split('/')
305 .take_while(|part| crate::glob::is_literal(part))
306 .collect::<Vec<_>>()
307 .join("/");
308 if anchor.is_empty()
309 || workspace
310 .projects
311 .values()
312 .any(|project| project.root == pattern)
313 {
314 bail!(
315 "cache outputs must have a fixed directory prefix and cannot replace a project root"
316 );
317 }
318 if pattern.split('/').all(crate::glob::is_literal) {
319 complete.insert(pattern.clone());
320 }
321 patterns.push(Pattern::new(&pattern, false)?);
322 globs.push(pattern);
323 anchors.insert(anchor);
324 }
325 Ok(Self {
326 patterns,
327 globs,
328 negations,
329 anchors,
330 complete,
331 explicit,
332 })
333 }
334
335 pub fn is_explicit(&self) -> bool {
337 self.explicit
338 }
339
340 pub fn declared(&self) -> bool {
344 self.explicit && !self.patterns.is_empty()
345 }
346
347 pub fn anchors(&self) -> impl Iterator<Item = &str> {
349 self.anchors.iter().map(String::as_str)
350 }
351
352 pub(crate) fn complete_roots(&self) -> Vec<&str> {
355 if !self.explicit || !self.negations.is_empty() {
356 return Vec::new();
357 }
358 let mut roots: Vec<&str> = Vec::new();
359 for root in &self.complete {
360 if !roots.iter().any(|parent| {
361 root.strip_prefix(parent)
362 .is_some_and(|suffix| suffix.starts_with('/'))
363 }) {
364 roots.push(root);
365 }
366 }
367 roots
368 }
369
370 pub fn matches(&self, path: &str) -> bool {
371 let any = |patterns: &[Pattern]| {
372 Path::new(path)
373 .ancestors()
374 .any(|ancestor| patterns.iter().any(|pattern| pattern.is_match(ancestor)))
375 };
376 (self.complete.iter().any(|root| {
377 path == root
378 || path
379 .strip_prefix(root)
380 .is_some_and(|suffix| suffix.starts_with('/'))
381 }) || any(&self.patterns))
382 && !any(&self.negations)
383 }
384
385 pub fn paths(&self, root: &Path) -> Result<BTreeSet<String>> {
386 self.listing(root).map(|(paths, _)| paths)
387 }
388
389 pub(crate) fn entries<'a>(&self, root: &'a Path) -> Result<OutputEntries<'a>> {
390 if self.explicit && self.negations.is_empty() && self.complete.len() == self.globs.len() {
391 let mut walks = Vec::new();
392 let mut symlink = false;
393 for anchor in &self.anchors {
394 safe_parents(root, anchor)?;
395 match std::fs::symlink_metadata(root.join(anchor)) {
396 Ok(metadata) => symlink |= metadata.file_type().is_symlink(),
397 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
398 Err(error) => return Err(error.into()),
399 }
400 }
401 for anchor in self.complete_roots() {
402 let absolute = root.join(anchor);
403 match std::fs::symlink_metadata(&absolute) {
404 Ok(_) => walks.push(walkdir::WalkDir::new(absolute).follow_links(false)),
405 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
406 Err(error) => return Err(error.into()),
407 }
408 }
409 if !symlink {
410 return Ok(Box::new(walks.into_iter().flatten().map(move |entry| {
411 let entry = entry?;
412 let path = relative(root, entry.path())?;
413 validate_path(&path)?;
414 Ok((path, entry.metadata()?))
415 })));
416 }
417 }
418 let (paths, mut traversal) = self.listing(root)?;
419 Ok(Box::new(paths.into_iter().map(move |path| {
420 let absolute = root.join(&path);
421 let metadata = match traversal.metadata.remove(&absolute) {
422 Some(metadata) => metadata,
423 None => std::fs::symlink_metadata(&absolute)?,
424 };
425 Ok((path, metadata))
426 })))
427 }
428
429 fn listing(&self, root: &Path) -> Result<(BTreeSet<String>, Traversal)> {
430 if let Some(segments) = self.simple_segments()? {
431 for anchor in &self.anchors {
432 safe_parents(root, anchor)?;
433 match std::fs::symlink_metadata(root.join(anchor)) {
434 Ok(metadata) if metadata.file_type().is_symlink() => {
435 return self
436 .walk_paths(root)
437 .map(|paths| (paths, Traversal::default()));
438 }
439 Ok(_) => {}
440 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
441 Err(error) => return Err(error.into()),
442 }
443 }
444 let mut traversal = Traversal::default();
445 let mut candidates = BTreeSet::new();
446 for (glob, pattern) in self.globs.iter().zip(&segments) {
447 let fixed = pattern
448 .iter()
449 .take_while(|segment| matches!(segment, Segment::Literal(_)))
450 .count();
451 let anchor = glob.split('/').take(fixed).collect::<Vec<_>>().join("/");
452 traversal.expand(root, &root.join(anchor), &pattern[fixed..], &mut candidates)?;
453 }
454 return candidates
455 .into_iter()
456 .filter(|path| self.matches(path))
457 .map(|path| {
458 validate_path(&path)?;
459 Ok(path)
460 })
461 .collect::<Result<_>>()
462 .map(|paths| (paths, traversal));
463 }
464 self.walk_paths(root)
465 .map(|paths| (paths, Traversal::default()))
466 }
467
468 fn simple_segments(&self) -> Result<Option<Vec<Vec<Segment>>>> {
469 if self.globs.iter().any(|glob| {
470 glob.contains("**")
471 || glob.contains(['!', '@', '+', '|', ',', '{', '}', '[', ']', '(', ')'])
472 }) {
473 return Ok(None);
474 }
475 let mut matchers: BTreeMap<String, Pattern> = BTreeMap::new();
476 let mut patterns = Vec::new();
477 for glob in &self.globs {
478 let mut segments = Vec::new();
479 for part in glob.split('/') {
480 segments.push(if crate::glob::is_literal(part) {
481 Segment::Literal(part.to_owned())
482 } else {
483 let matcher = match matchers.entry(part.to_owned()) {
484 std::collections::btree_map::Entry::Occupied(entry) => entry.get().clone(),
485 std::collections::btree_map::Entry::Vacant(entry) => {
486 entry.insert(Pattern::new(part, false)?).clone()
487 }
488 };
489 Segment::Glob(matcher)
490 });
491 }
492 patterns.push(segments);
493 }
494 Ok(Some(patterns))
495 }
496
497 fn walk_paths(&self, root: &Path) -> Result<BTreeSet<String>> {
498 let mut paths = BTreeSet::new();
499 for anchor in &self.anchors {
500 safe_parents(root, anchor)?;
501 let path = root.join(anchor);
502 match std::fs::symlink_metadata(&path) {
503 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
504 Err(error) => return Err(error.into()),
505 Ok(_) => {}
506 }
507 let walk = walkdir::WalkDir::new(path)
508 .follow_links(false)
509 .into_iter()
510 .filter_entry(|entry| {
511 entry.depth() == 0
512 || !entry.file_type().is_dir()
513 || relative(root, entry.path()).map_or(true, |directory| {
514 self.globs
515 .iter()
516 .any(|glob| may_match_below(glob, &directory))
517 })
518 });
519 for entry in walk {
520 let entry = entry?;
521 let path = relative(root, entry.path())?;
522 if self.matches(&path) {
523 validate_path(&path)?;
524 paths.insert(path);
525 }
526 }
527 }
528 Ok(paths)
529 }
530}
531
532#[derive(Clone)]
533enum Segment {
534 Literal(String),
535 Glob(Pattern),
536}
537
538#[derive(Default)]
539struct Traversal {
540 metadata: BTreeMap<PathBuf, std::fs::Metadata>,
541 directories: BTreeMap<PathBuf, Vec<PathBuf>>,
542}
543
544impl Traversal {
545 fn metadata(&mut self, path: &Path) -> Result<Option<std::fs::Metadata>> {
546 if let Some(metadata) = self.metadata.get(path) {
547 return Ok(Some(metadata.clone()));
548 }
549 match std::fs::symlink_metadata(path) {
550 Ok(metadata) => {
551 self.metadata.insert(path.to_owned(), metadata.clone());
552 Ok(Some(metadata))
553 }
554 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
555 Err(error) => Err(error.into()),
556 }
557 }
558
559 fn children(&mut self, path: &Path) -> Result<&Vec<PathBuf>> {
560 if !self.directories.contains_key(path) {
561 let children = std::fs::read_dir(path)?
562 .map(|entry| entry.map(|entry| entry.path()))
563 .collect::<std::io::Result<Vec<_>>>()?;
564 self.directories.insert(path.to_owned(), children);
565 }
566 Ok(&self.directories[path])
567 }
568
569 fn expand(
570 &mut self,
571 root: &Path,
572 path: &Path,
573 segments: &[Segment],
574 paths: &mut BTreeSet<String>,
575 ) -> Result<()> {
576 let Some(metadata) = self.metadata(path)? else {
577 return Ok(());
578 };
579 let Some((segment, rest)) = segments.split_first() else {
580 for entry in walkdir::WalkDir::new(path)
581 .follow_links(false)
582 .follow_root_links(false)
583 {
584 paths.insert(relative(root, entry?.path())?);
585 }
586 return Ok(());
587 };
588 if !metadata.is_dir() {
589 return Ok(());
590 }
591 match segment {
592 Segment::Literal(name) => {
593 let candidate = path.join(name);
594 if self.metadata(&candidate)?.is_none() {
595 return Ok(());
596 }
597 let child = self
599 .children(path)?
600 .iter()
601 .find(|child| child.file_name() == Some(std::ffi::OsStr::new(name)))
602 .cloned();
603 if let Some(child) = child {
604 self.expand(root, &child, rest, paths)?;
605 }
606 Ok(())
607 }
608 Segment::Glob(matcher) => {
609 let children: Vec<_> = self
610 .children(path)?
611 .iter()
612 .filter(|child| matcher.is_match(child.file_name().unwrap()))
613 .cloned()
614 .collect();
615 for child in children {
616 self.expand(root, &child, rest, paths)?;
617 }
618 Ok(())
619 }
620 }
621 }
622}
623
624fn may_match_below(glob: &str, directory: &str) -> bool {
629 if glob.contains(['{', '}', '(', ')', '[', ']']) {
630 return true;
631 }
632 for (part, name) in glob.split('/').zip(directory.split('/')) {
633 if part == "**" {
634 return true;
635 }
636 if crate::glob::is_literal(part) && part != name {
637 return false;
638 }
639 }
640 true
641}
642
643pub fn resolved_outputs(workspace: &Workspace, task: &Task) -> Result<(Vec<String>, bool)> {
650 let root = &workspace.projects[&task.project].root;
651 let options = task_options(task);
652 let (templates, explicit) = match &task.definition.outputs {
653 Some(outputs) => (outputs.clone(), true),
654 None => match options.get("outputPath") {
655 Some(serde_json::Value::String(path)) => (vec![path.clone()], false),
656 Some(serde_json::Value::Array(paths)) => (
657 paths
658 .iter()
659 .filter_map(|path| path.as_str().map(str::to_owned))
660 .collect(),
661 false,
662 ),
663 _ if matches!(task.target.as_str(), "build" | "prepare") => (
664 [
665 format!("dist/{root}"),
666 format!("{root}/dist"),
667 format!("{root}/build"),
668 format!("{root}/public"),
669 ]
670 .into(),
671 false,
672 ),
673 _ => (Vec::new(), false),
674 },
675 };
676 let mut resolved = Vec::new();
677 for template in templates {
678 let (negated, template) = match template.strip_prefix('!') {
679 Some(rest) => (true, rest.to_owned()),
680 None => (false, template),
681 };
682 let Some(output) = resolve_output(task, &options, &template) else {
683 continue;
684 };
685 let path = expand(workspace, &task.project, &output)?
686 .trim_end_matches('/')
687 .to_owned();
688 resolved.push(if negated { format!("!{path}") } else { path });
689 }
690 Ok((resolved, explicit))
691}
692
693fn task_options(task: &Task) -> serde_json::Map<String, serde_json::Value> {
696 let mut options = task.definition.options.clone();
697 let mut args = task.args.iter().peekable();
698 while let Some(arg) = args.next() {
699 let Some(flag) = arg.strip_prefix("--") else {
700 continue;
701 };
702 let (key, value) = match flag.split_once('=') {
703 Some((key, value)) => (key.to_owned(), value.to_owned()),
704 None if args.peek().is_some_and(|next| !next.starts_with('-')) => {
705 (flag.to_owned(), args.next().unwrap().clone())
706 }
707 None => (flag.to_owned(), "true".to_owned()),
708 };
709 options.insert(key, serde_json::Value::String(value));
710 }
711 options
712}
713
714fn resolve_output(
718 task: &Task,
719 options: &serde_json::Map<String, serde_json::Value>,
720 template: &str,
721) -> Option<String> {
722 let mut result = String::new();
723 let mut rest = template;
724 while let Some(start) = rest.find('{') {
725 result.push_str(&rest[..start]);
726 let end = rest[start..].find('}')? + start;
727 let token = rest[start + 1..end].trim();
728 let value = if let Some(path) = token.strip_prefix("options.") {
729 let mut value = options.get(path.split('.').next()?)?;
730 for part in path.split('.').skip(1) {
731 value = value.get(part)?;
732 }
733 match value {
734 serde_json::Value::String(text) if !text.is_empty() => text.clone(),
735 serde_json::Value::Number(number) => number.to_string(),
736 serde_json::Value::Bool(true) => "true".into(),
737 _ => return None,
738 }
739 } else {
740 match token {
741 "projectName" | "project.name" => task.project.clone(),
742 "project.root" => "{projectRoot}".into(),
743 "projectRoot" | "workspaceRoot" => format!("{{{token}}}"),
744 _ => return None,
745 }
746 };
747 result.push_str(&value);
748 rest = &rest[end + 1..];
749 }
750 result.push_str(rest);
751 Some(result)
752}
753
754#[cfg(test)]
755mod tests {
756 use super::{Outputs, Pattern, normalize};
757
758 #[test]
759 fn literal_entries_match_listing_for_nested_and_missing_roots() {
760 let workspace = tempfile::tempdir().unwrap();
761 let root = workspace.path();
762 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
763 std::fs::write(root.join("dist/nested/value"), "contents").unwrap();
764 std::fs::write(root.join("single"), "one").unwrap();
765 #[cfg(unix)]
766 std::os::unix::fs::symlink(root.join("single"), root.join("dist/link")).unwrap();
767 let outputs = Outputs::from_paths(&[
768 "dist".into(),
769 "dist/nested".into(),
770 "missing".into(),
771 "single".into(),
772 ])
773 .unwrap();
774 let entries = outputs
775 .entries(root)
776 .unwrap()
777 .collect::<anyhow::Result<Vec<_>>>()
778 .unwrap();
779 let paths: std::collections::BTreeSet<_> =
780 entries.iter().map(|(path, _)| path.clone()).collect();
781 assert_eq!(entries.len(), paths.len());
782 assert_eq!(paths, outputs.paths(root).unwrap());
783 for (path, metadata) in entries {
784 let expected = std::fs::symlink_metadata(root.join(path)).unwrap();
785 assert_eq!(metadata.file_type(), expected.file_type());
786 assert_eq!(metadata.len(), expected.len());
787 assert_eq!(metadata.modified().unwrap(), expected.modified().unwrap());
788 }
789 }
790
791 #[cfg(unix)]
792 #[test]
793 fn literal_entries_reject_nested_roots_below_a_symlink() {
794 let workspace = tempfile::tempdir().unwrap();
795 let root = workspace.path();
796 std::fs::create_dir_all(root.join("dist")).unwrap();
797 std::fs::create_dir_all(root.join("outside/nested")).unwrap();
798 std::os::unix::fs::symlink(root.join("outside"), root.join("dist/link")).unwrap();
799 let outputs = Outputs::from_paths(&["dist".into(), "dist/link/nested".into()]).unwrap();
800 assert!(outputs.entries(root).is_err());
801 }
802
803 #[test]
804 fn literal_roots_preserve_path_boundaries_and_exclusions() {
805 let outputs =
806 Outputs::from_paths(&["dist".into(), "build/*.js".into(), "!dist/private".into()])
807 .unwrap();
808 for path in ["dist", "dist/nested/file.txt", "build/app.js"] {
809 assert!(outputs.matches(path), "{path}");
810 }
811 for path in [
812 "dist2/file.txt",
813 "dist/private",
814 "dist/private/file.txt",
815 "build/app.ts",
816 ] {
817 assert!(!outputs.matches(path), "{path}");
818 }
819 }
820
821 #[test]
823 fn listing_skips_only_directories_no_glob_reaches() {
824 let workspace = tempfile::tempdir().unwrap();
825 let root = workspace.path();
826 for file in [
827 "apps/web/graphql/manifest.json",
828 "apps/web/src/graphql/manifest.json",
829 "apps/web/node_modules/pkg/graphql/manifest.json",
830 "apps/client/app/graphql/manifest.json",
831 "apps/client/app/graphql/nested/extra.json",
832 "packages/a/deep/x/gen/out.ts",
833 "packages/b/types/index.d.ts",
834 "packages/c/lib/one.js",
835 ] {
836 let path = root.join(file);
837 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
838 std::fs::write(path, "").unwrap();
839 }
840 let outputs = Outputs::from_paths(&[
841 "apps/*/graphql/manifest.json".into(),
842 "apps/*/app/graphql".into(),
843 "packages/a/**/gen/*.ts".into(),
844 "packages/b/types".into(),
845 "packages/{c,d}/lib/*.js".into(),
846 ])
847 .unwrap();
848 assert_eq!(
849 outputs.paths(root).unwrap().into_iter().collect::<Vec<_>>(),
850 [
851 "apps/client/app/graphql",
852 "apps/client/app/graphql/manifest.json",
853 "apps/client/app/graphql/nested",
854 "apps/client/app/graphql/nested/extra.json",
855 "apps/web/graphql/manifest.json",
856 "packages/a/deep/x/gen/out.ts",
857 "packages/b/types",
858 "packages/b/types/index.d.ts",
859 "packages/c/lib/one.js",
860 ]
861 );
862 }
863
864 #[test]
866 fn character_classes_with_slashes_keep_nested_artifacts() {
867 let workspace = tempfile::tempdir().unwrap();
868 for name in ["a", "b", "c"] {
869 let directory = workspace.path().join(format!("apps/{name}/graphql/nested"));
870 std::fs::create_dir_all(&directory).unwrap();
871 std::fs::write(directory.join("output.txt"), "value").unwrap();
872 }
873 let outputs = Outputs::from_paths(&["apps/[a/b]/graphql".into()]).unwrap();
874 assert_eq!(
875 outputs
876 .paths(workspace.path())
877 .unwrap()
878 .into_iter()
879 .collect::<Vec<_>>(),
880 [
881 "apps/a/graphql",
882 "apps/a/graphql/nested",
883 "apps/a/graphql/nested/output.txt",
884 "apps/b/graphql",
885 "apps/b/graphql/nested",
886 "apps/b/graphql/nested/output.txt",
887 ]
888 );
889 }
890
891 #[test]
892 fn segment_traversal_preserves_output_sets() {
893 let workspace = tempfile::tempdir().unwrap();
894 for file in [
895 "apps/a/graphql/manifest.json",
896 "apps/a/graphql/nested/out.ts",
897 "apps/a/src/other.ts",
898 "apps/b/src/graphql/manifest.json",
899 "apps/.hidden/graphql/manifest.json",
900 "apps/a/file",
901 "dist/a.ts",
902 "dist/deep/b.ts",
903 ] {
904 let path = workspace.path().join(file);
905 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
906 std::fs::write(path, "value").unwrap();
907 }
908 for patterns in [
909 vec![
910 "apps/*/graphql/manifest.json",
911 "apps/*/*/graphql/manifest.json",
912 ],
913 vec!["apps/?/graphql", "!apps/a/graphql/nested"],
914 vec!["apps/a/graphql", "apps/*/graphql/*"],
915 vec!["apps/a/file/nested", "missing/*/file", "dist/*.ts"],
916 vec!["dist"],
917 vec!["apps/**/manifest.json"],
918 vec!["apps/[a/b]/graphql"],
919 ] {
920 let outputs =
921 Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
922 .unwrap();
923 match (
924 outputs.paths(workspace.path()),
925 outputs.walk_paths(workspace.path()),
926 ) {
927 (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
928 (Err(actual), Err(expected)) => {
929 assert_eq!(actual.to_string(), expected.to_string())
930 }
931 results => panic!("different results for {patterns:?}: {results:?}"),
932 }
933 }
934 }
935
936 #[cfg(unix)]
937 #[test]
938 fn segment_traversal_preserves_symlink_boundaries() {
939 let workspace = tempfile::tempdir().unwrap();
940 let root = workspace.path();
941 std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
942 std::fs::write(root.join("apps/a/graphql/manifest.json"), "value").unwrap();
943 std::os::unix::fs::symlink("a", root.join("apps/link")).unwrap();
944 std::os::unix::fs::symlink("absent", root.join("apps/dangling")).unwrap();
945 for patterns in [
946 vec!["apps/*/graphql/manifest.json"],
947 vec!["apps/*"],
948 vec!["apps/link"],
949 vec!["apps/dangling"],
950 ] {
951 let outputs =
952 Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
953 .unwrap();
954 match (outputs.paths(root), outputs.walk_paths(root)) {
955 (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
956 (Err(actual), Err(expected)) => {
957 assert_eq!(actual.to_string(), expected.to_string())
958 }
959 results => panic!("different results for {patterns:?}: {results:?}"),
960 }
961 }
962 }
963
964 #[test]
965 fn listings_recheck_metadata_and_directory_contents() {
966 let workspace = tempfile::tempdir().unwrap();
967 let root = workspace.path();
968 std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
969 std::fs::write(root.join("apps/a/graphql/manifest.json"), "one").unwrap();
970 let outputs = Outputs::from_paths(&["apps/*/graphql/manifest.json".into()]).unwrap();
971 assert_eq!(
972 outputs
973 .entries(root)
974 .unwrap()
975 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
976 .unwrap()["apps/a/graphql/manifest.json"]
977 .len(),
978 3
979 );
980 std::fs::write(root.join("apps/a/graphql/manifest.json"), "different").unwrap();
981 std::fs::create_dir_all(root.join("apps/b/graphql")).unwrap();
982 std::fs::write(root.join("apps/b/graphql/manifest.json"), "two").unwrap();
983 let entries = outputs
984 .entries(root)
985 .unwrap()
986 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
987 .unwrap();
988 assert_eq!(entries.len(), 2);
989 assert_eq!(entries["apps/a/graphql/manifest.json"].len(), 9);
990 std::fs::remove_dir_all(root.join("apps/a")).unwrap();
991 assert_eq!(
992 outputs
993 .entries(root)
994 .unwrap()
995 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
996 .unwrap()
997 .len(),
998 1
999 );
1000 }
1001
1002 #[test]
1003 fn literal_segments_after_wildcards_remain_case_sensitive() {
1004 let workspace = tempfile::tempdir().unwrap();
1005 let root = workspace.path();
1006 std::fs::create_dir_all(root.join("apps/a/GraphQL")).unwrap();
1007 std::fs::write(root.join("apps/a/GraphQL/Manifest.json"), "one").unwrap();
1008 for glob in [
1009 "apps/*/graphql/Manifest.json",
1010 "apps/*/GraphQL/manifest.json",
1011 "apps/*/GraphQL/Manifest.json",
1012 "apps/a/GraphQL",
1013 ] {
1014 let outputs = Outputs::from_paths(&[glob.into()]).unwrap();
1015 assert_eq!(
1016 outputs.paths(root).unwrap(),
1017 outputs.walk_paths(root).unwrap(),
1018 "{glob}"
1019 );
1020 }
1021 }
1022
1023 #[test]
1024 fn simple_glob_combinations_match_the_walker() {
1025 let workspace = tempfile::tempdir().unwrap();
1026 let root = workspace.path();
1027 for project in ["a", "aa", "Ab", ".hidden", "café"] {
1028 for directory in ["graphql", "GraphQL", "src"] {
1029 let path = root.join(format!("apps/{project}/{directory}/nested"));
1030 std::fs::create_dir_all(&path).unwrap();
1031 std::fs::write(path.join("manifest.json"), "value").unwrap();
1032 std::fs::write(path.parent().unwrap().join("manifest.json"), "value").unwrap();
1033 }
1034 }
1035 for project in ["*", "?", "a*", "*a", "*?", "A*"] {
1036 for directory in ["graphql", "GraphQL", "src", "missing"] {
1037 for suffix in ["", "/manifest.json", "/*.json", "/?anifest.json", "/*"] {
1038 let glob = format!("apps/{project}/{directory}{suffix}");
1039 let outputs =
1040 Outputs::from_paths(&[glob.clone(), "!apps/aa/graphql/nested".into()])
1041 .unwrap();
1042 assert_eq!(
1043 outputs.paths(root).unwrap(),
1044 outputs.walk_paths(root).unwrap(),
1045 "{glob}"
1046 );
1047 }
1048 }
1049 }
1050 }
1051
1052 #[test]
1054 fn complete_roots_do_not_widen_globs_exclusions_or_defaults() {
1055 let mut outputs = Outputs::from_paths(&[
1056 "dist".into(),
1057 "dist/nested".into(),
1058 "dist2".into(),
1059 "partial/**/*.js".into(),
1060 ])
1061 .unwrap();
1062 assert_eq!(outputs.complete_roots(), ["dist", "dist2"]);
1063 outputs
1064 .negations
1065 .push(Pattern::new("dist/private", false).unwrap());
1066 assert!(outputs.complete_roots().is_empty());
1067 outputs.negations.clear();
1068 outputs.explicit = false;
1069 assert!(outputs.complete_roots().is_empty());
1070 }
1071
1072 #[test]
1073 fn normalizes_relative_segments() {
1074 assert_eq!(
1075 normalize("apps/client/tests/../build/*/bundle.bin").unwrap(),
1076 "apps/client/build/*/bundle.bin"
1077 );
1078 assert_eq!(normalize("./dist/./a").unwrap(), "dist/a");
1079 assert_eq!(normalize(".").unwrap(), ".");
1080 assert!(normalize("app/../../outside").is_err());
1081 assert!(normalize("app/**/../x").is_err());
1082 }
1083}