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