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