Skip to main content

qk_cache/
paths.rs

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