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.
236fn 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        if self.globs.iter().any(|glob| {
616            glob.contains("**")
617                || glob.contains(['!', '@', '+', '|', ',', '{', '}', '[', ']', '(', ')'])
618        }) {
619            return Ok(None);
620        }
621        let mut matchers: BTreeMap<String, Pattern> = BTreeMap::new();
622        let mut patterns = Vec::new();
623        for glob in &self.globs {
624            let mut segments = Vec::new();
625            for part in glob.split('/') {
626                segments.push(if crate::glob::is_literal(part) {
627                    Segment::Literal(part.to_owned())
628                } else {
629                    let matcher = match matchers.entry(part.to_owned()) {
630                        std::collections::btree_map::Entry::Occupied(entry) => entry.get().clone(),
631                        std::collections::btree_map::Entry::Vacant(entry) => {
632                            entry.insert(Pattern::new(part, false)?).clone()
633                        }
634                    };
635                    Segment::Glob(matcher)
636                });
637            }
638            patterns.push(segments);
639        }
640        Ok(Some(patterns))
641    }
642
643    fn walk_paths(&self, root: &Path) -> Result<BTreeSet<String>> {
644        let mut paths = BTreeSet::new();
645        for anchor in &self.anchors {
646            safe_parents(root, anchor)?;
647            let path = root.join(anchor);
648            match std::fs::symlink_metadata(&path) {
649                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
650                Err(error) => return Err(error.into()),
651                Ok(_) => {}
652            }
653            let walk = walkdir::WalkDir::new(path)
654                .follow_links(false)
655                .into_iter()
656                .filter_entry(|entry| {
657                    entry.depth() == 0
658                        || !entry.file_type().is_dir()
659                        || relative(root, entry.path()).map_or(true, |directory| {
660                            self.globs
661                                .iter()
662                                .any(|glob| may_match_below(glob, &directory))
663                        })
664                });
665            for entry in walk {
666                let entry = entry?;
667                let path = relative(root, entry.path())?;
668                if self.matches(&path) {
669                    validate_path(&path)?;
670                    paths.insert(path);
671                }
672            }
673        }
674        Ok(paths)
675    }
676}
677
678#[derive(Clone)]
679enum Segment {
680    Literal(String),
681    Glob(Pattern),
682}
683
684#[derive(Default)]
685struct Traversal {
686    metadata: BTreeMap<PathBuf, std::fs::Metadata>,
687    directories: BTreeMap<PathBuf, Vec<PathBuf>>,
688}
689
690impl Traversal {
691    fn metadata(&mut self, path: &Path) -> Result<Option<std::fs::Metadata>> {
692        if let Some(metadata) = self.metadata.get(path) {
693            return Ok(Some(metadata.clone()));
694        }
695        match std::fs::symlink_metadata(path) {
696            Ok(metadata) => {
697                self.metadata.insert(path.to_owned(), metadata.clone());
698                Ok(Some(metadata))
699            }
700            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
701            Err(error) => Err(error.into()),
702        }
703    }
704
705    fn children(&mut self, path: &Path) -> Result<&Vec<PathBuf>> {
706        if !self.directories.contains_key(path) {
707            let children = std::fs::read_dir(path)?
708                .map(|entry| entry.map(|entry| entry.path()))
709                .collect::<std::io::Result<Vec<_>>>()?;
710            self.directories.insert(path.to_owned(), children);
711        }
712        Ok(&self.directories[path])
713    }
714
715    fn expand(
716        &mut self,
717        root: &Path,
718        path: &Path,
719        segments: &[Segment],
720        paths: &mut BTreeSet<String>,
721    ) -> Result<()> {
722        let Some(metadata) = self.metadata(path)? else {
723            return Ok(());
724        };
725        let Some((segment, rest)) = segments.split_first() else {
726            for entry in walkdir::WalkDir::new(path)
727                .follow_links(false)
728                .follow_root_links(false)
729            {
730                paths.insert(relative(root, entry?.path())?);
731            }
732            return Ok(());
733        };
734        if !metadata.is_dir() {
735            return Ok(());
736        }
737        match segment {
738            Segment::Literal(name) => {
739                let candidate = path.join(name);
740                if self.metadata(&candidate)?.is_none() {
741                    return Ok(());
742                }
743                // A successful lookup may differ in case on the host filesystem.
744                let child = self
745                    .children(path)?
746                    .iter()
747                    .find(|child| child.file_name() == Some(std::ffi::OsStr::new(name)))
748                    .cloned();
749                if let Some(child) = child {
750                    self.expand(root, &child, rest, paths)?;
751                }
752                Ok(())
753            }
754            Segment::Glob(matcher) => {
755                let children: Vec<_> = self
756                    .children(path)?
757                    .iter()
758                    .filter(|child| matcher.is_match(child.file_name().unwrap()))
759                    .cloned()
760                    .collect();
761                for child in children {
762                    self.expand(root, &child, rest, paths)?;
763                }
764                Ok(())
765            }
766        }
767    }
768}
769
770/// Whether `glob` can match `directory` or a path below it. A path matches
771/// when it or an ancestor does, so a directory deeper than the glob counts.
772/// Groups and character classes can contain slashes, so a glob with one is
773/// not ruled out by comparing slash-separated segments.
774fn may_match_below(glob: &str, directory: &str) -> bool {
775    if glob.contains(['{', '}', '(', ')', '[', ']']) {
776        return true;
777    }
778    for (part, name) in glob.split('/').zip(directory.split('/')) {
779        if part == "**" {
780            return true;
781        }
782        if crate::glob::is_literal(part) && part != name {
783            return false;
784        }
785    }
786    true
787}
788
789/// A task's outputs as Nx lists them: workspace-relative paths and globs,
790/// `!` for a negated one, with their tokens read as [`interpolate_output`]
791/// reads them; an output naming what has no value is left out. Without
792/// `outputs`, Nx's defaults: `options.outputPath`, and for `build` and
793/// `prepare` targets `dist/<root>`, `<root>/dist`, `<root>/build` and
794/// `<root>/public`. The flag tells whether the target declared them.
795pub fn resolved_outputs(workspace: &Workspace, task: &Task) -> Result<(Vec<String>, bool)> {
796    let root = &workspace.projects[&task.project].root;
797    let options = task_options(task);
798    let mut resolved = Vec::new();
799    let Some(templates) = &task.definition.outputs else {
800        // As in Nx, an `outputPath` counts when it is truthy, so an empty
801        // string falls back to the defaults; an empty path in a list names
802        // nothing.
803        let defaults: Vec<String> = match options.get("outputPath") {
804            Some(path @ serde_json::Value::String(text)) if !text.is_empty() => {
805                vec![option_tokens(workspace, &task.project, path)?]
806            }
807            Some(serde_json::Value::Array(paths)) => paths
808                .iter()
809                .filter(|path| path.as_str().is_some_and(|text| !text.is_empty()))
810                .map(|path| option_tokens(workspace, &task.project, path))
811                .collect::<Result<_>>()?,
812            _ if matches!(task.target.as_str(), "build" | "prepare") => {
813                // The root goes in escaped, as in a configured output.
814                let root = crate::glob::escape(root);
815                [
816                    format!("dist/{root}"),
817                    format!("{root}/dist"),
818                    format!("{root}/build"),
819                    format!("{root}/public"),
820                ]
821                .into()
822            }
823            _ => Vec::new(),
824        };
825        for output in defaults {
826            let (negated, path) = match output.strip_prefix('!') {
827                Some(rest) => (true, rest),
828                None => (false, output.as_str()),
829            };
830            let path = workspace_relative(workspace, path)?;
831            resolved.push(if negated { format!("!{path}") } else { path });
832        }
833        return Ok((resolved, false));
834    };
835    qk_taskgraph::validate_outputs(templates)?;
836    let data = OutputData {
837        workspace,
838        project: &task.project,
839        options: &options,
840    };
841    for template in templates {
842        let (negated, template) = match template.strip_prefix('!') {
843            Some(rest) => (true, rest),
844            None => (false, template.as_str()),
845        };
846        let Some(output) = interpolate_output(template, &data)? else {
847            continue;
848        };
849        let path = workspace_relative(workspace, &output)?;
850        let path = if negated { format!("!{path}") } else { path };
851        // As in Nx, outputs are a set.
852        if !resolved.contains(&path) {
853            resolved.push(path);
854        }
855    }
856    Ok((resolved, true))
857}
858
859/// `path` without Windows' verbatim `\\?\` prefix on a drive path.
860fn verbatim_free(path: &Path) -> PathBuf {
861    match path.to_str().and_then(|text| text.strip_prefix(r"\\?\")) {
862        Some(rest) if !rest.starts_with("UNC\\") => PathBuf::from(rest),
863        _ => path.to_path_buf(),
864    }
865}
866
867/// An output made workspace-relative, as Nx's cache reads one: an absolute
868/// path inside the workspace is relativized, and one outside it, or a path
869/// that climbs out with `..`, is an error.
870fn workspace_relative(workspace: &Workspace, output: &str) -> Result<String> {
871    let owned;
872    let relative = if Path::new(output).is_absolute() || output.starts_with('/') {
873        // Compared by components, without Windows' `\\?\` prefix, which
874        // one of the two paths may carry and the other not.
875        let inside = verbatim_free(Path::new(output))
876            .strip_prefix(verbatim_free(&workspace.root))
877            .ok()
878            .map(|rest| {
879                rest.components()
880                    .map(|part| part.as_os_str().to_string_lossy())
881                    .collect::<Vec<_>>()
882                    .join("/")
883            });
884        match inside {
885            Some(rest) => {
886                owned = rest;
887                owned.as_str()
888            }
889            None => bail!("cache output is outside the workspace: {output}"),
890        }
891    } else {
892        output
893    };
894    match normalize(relative) {
895        Ok(path) => Ok(path.trim_end_matches('/').to_owned()),
896        Err(_) => bail!("cache output is outside the workspace: {output}"),
897    }
898}
899
900/// The target's options with the task's `--name=value` arguments applied,
901/// as Nx's overrides are.
902fn task_options(task: &Task) -> serde_json::Map<String, serde_json::Value> {
903    let mut options = task.definition.options.clone();
904    let mut args = task.args.iter().peekable();
905    while let Some(arg) = args.next() {
906        let Some(flag) = arg.strip_prefix("--") else {
907            continue;
908        };
909        let (key, value) = match flag.split_once('=') {
910            Some((key, value)) => (key.to_owned(), value.to_owned()),
911            None if args.peek().is_some_and(|next| !next.starts_with('-')) => {
912                (flag.to_owned(), args.next().unwrap().clone())
913            }
914            None => (flag.to_owned(), "true".to_owned()),
915        };
916        options.insert(key, serde_json::Value::String(value));
917    }
918    options
919}
920
921/// What an output's tokens read: as in Nx, `{projectRoot}`, `{projectName}`,
922/// `{options.*}` and the legacy `{project.*}`, the project's configuration.
923struct OutputData<'a> {
924    workspace: &'a Workspace,
925    project: &'a str,
926    options: &'a serde_json::Map<String, serde_json::Value>,
927}
928
929impl OutputData<'_> {
930    fn is_token(name: &str) -> bool {
931        matches!(name, "projectRoot" | "projectName" | "project" | "options")
932    }
933
934    fn read(&self, name: &str, path: Option<&str>) -> Result<Option<serde_json::Value>> {
935        let value = match name {
936            "projectRoot" => {
937                serde_json::Value::String(self.workspace.projects[self.project].root.clone())
938            }
939            "projectName" => serde_json::Value::String(self.project.to_owned()),
940            "project" => {
941                let mut project = serde_json::to_value(&self.workspace.projects[self.project])?;
942                project["name"] = serde_json::Value::String(self.project.to_owned());
943                project
944            }
945            _ => serde_json::Value::Object(self.options.clone()),
946        };
947        let Some(found) = read_path(&value, path) else {
948            return Ok(None);
949        };
950        // Nx resolves the project tokens in every option when it builds the
951        // project graph, so an option's value already has them replaced. The
952        // value goes in as written, a pattern if it holds one, as in Nx. The
953        // project's own paths go in with their glob characters escaped, so a
954        // root such as `apps/(group)` names that directory; Nx inserts them
955        // as they are.
956        Ok(Some(if name == "options" {
957            resolve_option_tokens(self.workspace, self.project, &found)?
958        } else {
959            escape_strings(&found)
960        }))
961    }
962}
963
964/// `value` with every string in it escaped by [`crate::glob::escape`].
965fn escape_strings(value: &serde_json::Value) -> serde_json::Value {
966    match value {
967        serde_json::Value::String(text) => serde_json::Value::String(crate::glob::escape(text)),
968        serde_json::Value::Array(items) => items.iter().map(escape_strings).collect(),
969        serde_json::Value::Object(object) => serde_json::Value::Object(
970            object
971                .iter()
972                .map(|(key, item)| (key.clone(), escape_strings(item)))
973                .collect(),
974        ),
975        other => other.clone(),
976    }
977}
978
979/// Walks a dotted path as Nx's `readPath` does: each step must be an own
980/// property, so an object's key, or an array's or string's canonical index
981/// (`0`, `12`, not `01` or `+1`) or `length`.
982fn read_path(value: &serde_json::Value, path: Option<&str>) -> Option<serde_json::Value> {
983    let Some(path) = path else {
984        return Some(value.clone());
985    };
986    let index = |key: &str| {
987        (key == "0" || (!key.starts_with('0') && key.bytes().all(|b| b.is_ascii_digit())))
988            .then(|| key.parse::<usize>().ok())
989            .flatten()
990    };
991    let mut value = value.clone();
992    for key in path.split('.') {
993        value = match &value {
994            serde_json::Value::Object(object) => object.get(key)?.clone(),
995            serde_json::Value::Array(items) if key == "length" => items.len().into(),
996            serde_json::Value::Array(items) => items.get(index(key)?)?.clone(),
997            // A JavaScript string's length and indexes count UTF-16 units.
998            serde_json::Value::String(text) => {
999                let units: Vec<u16> = text.encode_utf16().collect();
1000                if key == "length" {
1001                    units.len().into()
1002                } else {
1003                    String::from_utf16_lossy(&[*units.get(index(key)?)?]).into()
1004                }
1005            }
1006            _ => return None,
1007        };
1008    }
1009    Some(value)
1010}
1011
1012/// An option value as Nx's graph holds it, from [`resolve_option_tokens`],
1013/// as the text it stands for.
1014fn option_tokens(
1015    workspace: &Workspace,
1016    project: &str,
1017    value: &serde_json::Value,
1018) -> Result<String> {
1019    Ok(text(&resolve_option_tokens(workspace, project, value)?).unwrap_or_default())
1020}
1021
1022/// `{projectRoot}`, `{projectName}` and a leading `{workspaceRoot}` replaced
1023/// in every string of an option value, as Nx's `resolveNxTokensInOptions`:
1024/// `{workspaceRoot}` anywhere but the start is an error.
1025fn resolve_option_tokens(
1026    workspace: &Workspace,
1027    project: &str,
1028    value: &serde_json::Value,
1029) -> Result<serde_json::Value> {
1030    Ok(match value {
1031        serde_json::Value::String(text) => {
1032            let text = text
1033                .strip_prefix("{workspaceRoot}/")
1034                .or_else(|| text.strip_prefix("{workspaceRoot}"))
1035                .unwrap_or(text);
1036            if text.contains("{workspaceRoot}") {
1037                bail!(
1038                    "the {{workspaceRoot}} token is only valid at the beginning of an option ({project})"
1039                );
1040            }
1041            serde_json::Value::String(
1042                text.replace("{projectRoot}", &workspace.projects[project].root)
1043                    .replace("{projectName}", project),
1044            )
1045        }
1046        serde_json::Value::Array(items) => serde_json::Value::Array(
1047            items
1048                .iter()
1049                .map(|item| resolve_option_tokens(workspace, project, item))
1050                .collect::<Result<_>>()?,
1051        ),
1052        serde_json::Value::Object(object) => serde_json::Value::Object(
1053            object
1054                .iter()
1055                .map(|(key, item)| {
1056                    Ok((
1057                        key.clone(),
1058                        resolve_option_tokens(workspace, project, item)?,
1059                    ))
1060                })
1061                .collect::<Result<_>>()?,
1062        ),
1063        other => other.clone(),
1064    })
1065}
1066
1067/// A value as JavaScript's `String()` writes it, or `None` for one Nx counts
1068/// as unset: missing, `null` or `''`.
1069fn text(value: &serde_json::Value) -> Option<String> {
1070    let text = match value {
1071        serde_json::Value::Null => return None,
1072        serde_json::Value::String(text) => text.clone(),
1073        serde_json::Value::Bool(flag) => flag.to_string(),
1074        serde_json::Value::Number(number) => match number.as_f64() {
1075            Some(float) if number.is_f64() && float.fract() == 0.0 && float.abs() < 1e21 => {
1076                format!("{float:.0}")
1077            }
1078            _ => number.to_string(),
1079        },
1080        serde_json::Value::Array(items) => items
1081            .iter()
1082            .map(|item| text(item).unwrap_or_default())
1083            .collect::<Vec<_>>()
1084            .join(","),
1085        serde_json::Value::Object(_) => "[object Object]".to_owned(),
1086    };
1087    (!text.is_empty()).then_some(text)
1088}
1089
1090/// One piece of a template: text, or a `{token}` with its `??` fallbacks.
1091enum Node<'t> {
1092    Text(String),
1093    Token { raw: &'t str, parts: Vec<Part<'t>> },
1094}
1095
1096enum Part<'t> {
1097    Reference {
1098        name: &'t str,
1099        path: Option<&'t str>,
1100    },
1101    Literal(Vec<Node<'t>>),
1102}
1103
1104/// A template's pieces, as Nx's `parseTokens`: braces that do not start
1105/// with a token are text, and a token can still sit inside them.
1106fn parse_tokens(template: &str) -> Result<Vec<Node<'_>>> {
1107    let mut nodes = Vec::new();
1108    let mut text = String::new();
1109    let mut index = 0;
1110    while let Some(offset) = template[index..].find('{') {
1111        let open = index + offset;
1112        let token = match qk_executor::tokens::closing_brace(template, open) {
1113            Some(close) => parse_token(&template[open..=close])?.map(|token| (token, close)),
1114            None => None,
1115        };
1116        let Some((token, close)) = token else {
1117            text.push_str(&template[index..=open]);
1118            index = open + 1;
1119            continue;
1120        };
1121        text.push_str(&template[index..open]);
1122        if !text.is_empty() {
1123            nodes.push(Node::Text(std::mem::take(&mut text)));
1124        }
1125        nodes.push(token);
1126        index = close + 1;
1127    }
1128    text.push_str(&template[index..]);
1129    if !text.is_empty() {
1130        nodes.push(Node::Text(text));
1131    }
1132    Ok(nodes)
1133}
1134
1135/// A `{...}` as a token, or `None` when its first part names no token. Only
1136/// the last fallback may be a literal, which can hold tokens itself. `env.`
1137/// is not a token in an output, so it is text there.
1138fn parse_token(raw: &str) -> Result<Option<Node<'_>>> {
1139    let sources = qk_executor::tokens::fallbacks(&raw[1..raw.len() - 1]);
1140    fn reference(source: &str) -> Option<(&str, Option<&str>)> {
1141        let (name, path) = match source.split_once('.') {
1142            Some((name, path)) => (name, Some(path)),
1143            None => (source, None),
1144        };
1145        OutputData::is_token(name).then_some((name, path))
1146    }
1147    if reference(sources[0]).is_none() {
1148        return Ok(None);
1149    }
1150    let mut parts = Vec::new();
1151    for (index, source) in sources.iter().enumerate() {
1152        parts.push(match reference(source) {
1153            Some((name, path)) => Part::Reference { name, path },
1154            None if index + 1 == sources.len() => Part::Literal(parse_tokens(source)?),
1155            None => {
1156                bail!("{raw:?} has a fallback after the literal {source:?} that can never be used")
1157            }
1158        });
1159    }
1160    Ok(Some(Node::Token { raw, parts }))
1161}
1162
1163/// Whether `{workspaceRoot}` appears only at the very start, which is the
1164/// only place Nx allows it in an output. A fallback's literal replaces its
1165/// whole token, so a token at the start may fall back to one.
1166fn workspace_root_only_at_start(nodes: &[Node], at_start: bool) -> bool {
1167    nodes.iter().enumerate().all(|(index, node)| {
1168        let node_at_start = at_start && index == 0;
1169        match node {
1170            Node::Text(text) => {
1171                let skip = usize::from(node_at_start).min(text.len());
1172                !text[skip..].contains("{workspaceRoot}")
1173            }
1174            Node::Token { parts, .. } => parts.iter().all(|part| match part {
1175                Part::Literal(nodes) => workspace_root_only_at_start(nodes, node_at_start),
1176                Part::Reference { .. } => true,
1177            }),
1178        }
1179    })
1180}
1181
1182/// What an output names once its tokens are read, as Nx 24's
1183/// `interpolateOutput`, or `None` when Nx leaves it out: it is empty, or a
1184/// `{projectRoot}`, `{workspaceRoot}` or `{options...}` placeholder was left
1185/// without a value. A token's value is its first part that is set; braces
1186/// that start no token, such as `*.{js,map}`, stay as written. The result is
1187/// joined as a path, and a leading `{workspaceRoot}/` removed. An output
1188/// starting with `/` is taken as written.
1189fn interpolate_output(template: &str, data: &OutputData) -> Result<Option<String>> {
1190    let output = if template.starts_with('/') || !has_braces(template) {
1191        template.to_owned()
1192    } else {
1193        let nodes = parse_tokens(template)?;
1194        if !workspace_root_only_at_start(&nodes, true) {
1195            bail!(
1196                "output {template:?} is invalid: {{workspaceRoot}} can only be used at the beginning of the expression"
1197            );
1198        }
1199        let mut segments = vec![String::new()];
1200        render(&nodes, data, false, &mut segments)?;
1201        // `path.join` skips empty segments.
1202        segments.retain(|segment| !segment.is_empty());
1203        join_path(&segments.join("/")).replacen("{workspaceRoot}/", "", 1)
1204    };
1205    let unresolved = ["{projectRoot}", "{workspaceRoot}"]
1206        .iter()
1207        .any(|token| output.contains(token))
1208        || output
1209            .find("{options")
1210            .is_some_and(|start| output[start..].contains('}'));
1211    Ok((!output.is_empty() && !unresolved).then_some(output))
1212}
1213
1214/// Whether `template` holds a `{` with something before a later `}`.
1215fn has_braces(template: &str) -> bool {
1216    template.find('{').is_some_and(|open| {
1217        template
1218            .get(open + 2..)
1219            .is_some_and(|rest| rest.contains('}'))
1220    })
1221}
1222
1223/// The template's `/`-separated segments with its tokens read, as Nx's
1224/// `joinSegments` gathers them for `path.join`. Text splits segments and a
1225/// value joins the segment it lands in, so only a value or a fallback's
1226/// literal starting a path can make it absolute.
1227fn render(
1228    nodes: &[Node],
1229    data: &OutputData,
1230    literal: bool,
1231    segments: &mut Vec<String>,
1232) -> Result<()> {
1233    for (index, node) in nodes.iter().enumerate() {
1234        match node {
1235            Node::Text(text) => {
1236                let root = if literal && index == 0 && text.starts_with('/') {
1237                    "/"
1238                } else {
1239                    ""
1240                };
1241                let mut parts = text[root.len()..].split('/');
1242                let last = segments.last_mut().expect("segments start with one");
1243                last.push_str(root);
1244                last.push_str(parts.next().unwrap_or_default());
1245                segments.extend(parts.map(str::to_owned));
1246            }
1247            Node::Token { raw, parts } => {
1248                // A root project's `{projectRoot}` is no path segment at all.
1249                if *raw == "{projectRoot}" && data.workspace.projects[data.project].root == "." {
1250                    continue;
1251                }
1252                let mut value = Some(*raw);
1253                let mut found = None;
1254                for part in parts {
1255                    match part {
1256                        Part::Literal(nodes) => {
1257                            render(nodes, data, true, segments)?;
1258                            value = None;
1259                            break;
1260                        }
1261                        Part::Reference { name, path } => {
1262                            found = data.read(name, *path)?.as_ref().and_then(text);
1263                            if found.is_some() {
1264                                break;
1265                            }
1266                        }
1267                    }
1268                }
1269                // A token with no value stays as written.
1270                let value = found.as_deref().or(value).unwrap_or_default();
1271                segments
1272                    .last_mut()
1273                    .expect("segments start with one")
1274                    .push_str(value);
1275            }
1276        }
1277    }
1278    Ok(())
1279}
1280
1281/// `path` joined as Node's `path.posix.join` joins it: empty and `.`
1282/// segments dropped, `..` applied where it can be, a trailing slash kept.
1283fn join_path(path: &str) -> String {
1284    let absolute = path.starts_with('/');
1285    let mut parts: Vec<&str> = Vec::new();
1286    for part in path.split('/') {
1287        match part {
1288            "" | "." => {}
1289            ".." => match parts.last() {
1290                Some(&last) if last != ".." => {
1291                    parts.pop();
1292                }
1293                _ if absolute => {}
1294                _ => parts.push(".."),
1295            },
1296            part => parts.push(part),
1297        }
1298    }
1299    let mut joined = parts.join("/");
1300    if absolute {
1301        joined.insert(0, '/');
1302    }
1303    if joined.is_empty() {
1304        joined.push('.');
1305    }
1306    if path.ends_with('/') && !joined.ends_with('/') {
1307        joined.push('/');
1308    }
1309    joined
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314    use super::{Outputs, Pattern, dot_git, normalize, resolved_outputs, verbatim_free};
1315
1316    #[test]
1317    fn a_cache_location_has_its_dot_segments_resolved() {
1318        use std::path::{Path, PathBuf};
1319        assert_eq!(
1320            super::resolved(Path::new("/work/repo/elsewhere/../qk-cache/./v1")),
1321            PathBuf::from("/work/repo/qk-cache/v1")
1322        );
1323        // Above where it starts there is nothing to go back to; `..` is kept.
1324        assert_eq!(
1325            super::resolved(Path::new("../cache")),
1326            PathBuf::from("../cache")
1327        );
1328    }
1329    use qk_config::Workspace;
1330    use qk_taskgraph::{Request, TaskGraph};
1331    use serde_json::{Value, json};
1332    use std::path::{Path, PathBuf};
1333
1334    /// The outputs of `lib:build` in a workspace with a root project and
1335    /// `lib` at `libs/lib`, the target configured by `target`.
1336    fn outputs_of(target: Value, args: &[&str]) -> anyhow::Result<Vec<String>> {
1337        let root = tempfile::tempdir().unwrap();
1338        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1339        std::fs::write(
1340            root.path().join("project.json"),
1341            json!({"name": "root"}).to_string(),
1342        )
1343        .unwrap();
1344        std::fs::create_dir_all(root.path().join("libs/lib")).unwrap();
1345        std::fs::write(
1346            root.path().join("libs/lib/project.json"),
1347            json!({"name": "lib", "sourceRoot": "libs/lib/src", "tags": ["a", "b"],
1348                "targets": {"build": target}})
1349            .to_string(),
1350        )
1351        .unwrap();
1352        let workspace = Workspace::load(root.path()).unwrap();
1353        let mut request = Request::parse("lib:build").unwrap();
1354        request.args = args.iter().map(|arg| (*arg).to_owned()).collect();
1355        let graph = TaskGraph::build(&workspace, &[request])?;
1356        resolved_outputs(&workspace, &graph.tasks["lib:build"]).map(|(outputs, _)| outputs)
1357    }
1358
1359    fn outputs(outputs: Value, options: Value) -> anyhow::Result<Vec<String>> {
1360        outputs_of(
1361            json!({"command": "true", "outputs": outputs, "options": options}),
1362            &[],
1363        )
1364    }
1365
1366    #[test]
1367    fn braces_that_are_no_token_stay_as_written() {
1368        assert_eq!(
1369            outputs(
1370                json!([
1371                    "{projectRoot}/dist/**/*.{js,map}",
1372                    "{workspaceRoot}/x/{a,{projectName}}"
1373                ]),
1374                json!({})
1375            )
1376            .unwrap(),
1377            ["libs/lib/dist/**/*.{js,map}", "x/{a,lib}"]
1378        );
1379    }
1380
1381    #[test]
1382    fn placeholders_fall_back_left_to_right() {
1383        let resolved = outputs(
1384            json!([
1385                "{options.a ?? options.b ?? dist/{projectName}}",
1386                "{options.missing ?? options.b}/x",
1387                "{options.missing ?? {workspaceRoot}/fallback}",
1388                "{workspaceRoot}/x/{env.HOME}",
1389                "{options.missing}",
1390                "{options.missing ?? options.other}",
1391            ]),
1392            json!({"b": "from-b"}),
1393        )
1394        .unwrap();
1395        // `env.` is no token in an output, and an output with an unresolved
1396        // placeholder is left out.
1397        assert_eq!(resolved, ["from-b", "from-b/x", "fallback", "x/{env.HOME}"]);
1398        assert!(outputs(json!(["{options.a ?? literal ?? options.b}"]), json!({})).is_err());
1399    }
1400
1401    #[test]
1402    fn option_values_read_as_javascript_writes_them() {
1403        assert_eq!(
1404            outputs(
1405                json!([
1406                    "{workspaceRoot}/dist/{options.flag}",
1407                    "{workspaceRoot}/dist/{options.zero}",
1408                    "{workspaceRoot}/dist/{options.list}",
1409                    "{workspaceRoot}/dist/{options.float}",
1410                    "{workspaceRoot}/dist/{options.nested.deep}",
1411                    "{workspaceRoot}/dist/{options.list.1}",
1412                    "{workspaceRoot}/dist/{options.empty}",
1413                    "{workspaceRoot}/dist/{options.none}",
1414                ]),
1415                json!({"flag": false, "zero": 0, "list": ["a", "b"], "float": 2.0,
1416                    "nested": {"deep": "d"}, "empty": "", "none": null}),
1417            )
1418            .unwrap(),
1419            [
1420                "dist/false",
1421                "dist/0",
1422                "dist/a,b",
1423                "dist/2",
1424                "dist/d",
1425                "dist/b"
1426            ]
1427        );
1428    }
1429
1430    #[test]
1431    fn option_values_have_their_project_tokens_resolved() {
1432        assert_eq!(
1433            outputs(
1434                json!(["{options.outputPath}", "{options.list}"]),
1435                json!({"outputPath": "{workspaceRoot}/dist/{projectName}/{projectRoot}",
1436                    "list": ["{projectName}"]}),
1437            )
1438            .unwrap(),
1439            ["dist/lib/libs/lib", "lib"]
1440        );
1441        // `{workspaceRoot}` elsewhere in an option fails when the workspace
1442        // loads, as in Nx; see qk-config's workspace tests.
1443        // Arguments override options and are taken as they are given.
1444        assert_eq!(
1445            outputs_of(
1446                json!({"command": "true", "outputs": ["{options.out}"], "options": {"out": "a"}}),
1447                &["--out=b"],
1448            )
1449            .unwrap(),
1450            ["b"]
1451        );
1452        // Paths step through own properties as Nx's readPath does: an array's
1453        // length and canonical indexes only.
1454        assert_eq!(
1455            outputs(
1456                json!([
1457                    "{options.list.length}",
1458                    "{options.list.1}",
1459                    "{options.list.01}"
1460                ]),
1461                json!({"list": ["a", "b"]}),
1462            )
1463            .unwrap(),
1464            ["2", "b"]
1465        );
1466        // An empty `outputPath` falls back to a build target's defaults, as
1467        // Nx's truthiness check does.
1468        assert_eq!(
1469            outputs_of(
1470                json!({"command": "true", "options": {"outputPath": ""}}),
1471                &[],
1472            )
1473            .unwrap(),
1474            [
1475                "dist/libs/lib",
1476                "libs/lib/dist",
1477                "libs/lib/build",
1478                "libs/lib/public"
1479            ]
1480        );
1481        assert_eq!(
1482            outputs_of(
1483                json!({"command": "true", "options": {"outputPath": ["", "dist"]}}),
1484                &[],
1485            )
1486            .unwrap(),
1487            ["dist"]
1488        );
1489        // Without `outputs`, `outputPath` is the output, its tokens resolved.
1490        assert_eq!(
1491            outputs_of(
1492                json!({"command": "true", "options": {"outputPath": "dist/{projectName}"}}),
1493                &[],
1494            )
1495            .unwrap(),
1496            ["dist/lib"]
1497        );
1498    }
1499
1500    #[test]
1501    fn legacy_project_paths_read_the_project_configuration() {
1502        assert_eq!(
1503            outputs(
1504                json!([
1505                    "{project.sourceRoot}/gen",
1506                    "{workspaceRoot}/dist/{project.name}/{project.tags}",
1507                    "{project.missing}/x"
1508                ]),
1509                json!({})
1510            )
1511            .unwrap(),
1512            ["libs/lib/src/gen", "dist/lib/a,b", "{project.missing}/x"]
1513        );
1514    }
1515
1516    #[test]
1517    fn workspace_root_outputs_name_a_directory() {
1518        for output in ["{workspaceRoot}/**", "{workspaceRoot}/*.js"] {
1519            assert!(outputs(json!([output]), json!({})).is_err(), "{output}");
1520        }
1521        assert_eq!(
1522            outputs(json!(["{workspaceRoot}/@scope/**"]), json!({})).unwrap(),
1523            ["@scope/**"]
1524        );
1525    }
1526
1527    #[test]
1528    fn workspace_root_is_allowed_only_at_the_start() {
1529        for output in ["{projectRoot}/{workspaceRoot}/dist", "dist/{workspaceRoot}"] {
1530            assert!(outputs(json!([output]), json!({})).is_err(), "{output}");
1531        }
1532        assert_eq!(
1533            outputs(
1534                json!([
1535                    "!{workspaceRoot}/dist/x",
1536                    "{workspaceRoot}/dist",
1537                    "{workspaceRoot}"
1538                ]),
1539                json!({})
1540            )
1541            .unwrap(),
1542            ["!dist/x", "dist"]
1543        );
1544    }
1545
1546    #[test]
1547    fn a_root_projects_root_is_no_segment() {
1548        let root = tempfile::tempdir().unwrap();
1549        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1550        std::fs::write(
1551            root.path().join("project.json"),
1552            json!({"name": "app", "targets": {"build": {"command": "true",
1553                "outputs": ["{projectRoot}/dist", "{options.out ?? {projectRoot}/out}"]}}})
1554            .to_string(),
1555        )
1556        .unwrap();
1557        let workspace = Workspace::load(root.path()).unwrap();
1558        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1559        assert_eq!(
1560            resolved_outputs(&workspace, &graph.tasks["app:build"])
1561                .unwrap()
1562                .0,
1563            ["dist", "out"]
1564        );
1565    }
1566
1567    #[test]
1568    fn verbatim_prefixes_are_left_out_of_drive_paths_only() {
1569        assert_eq!(
1570            verbatim_free(Path::new(r"\\?\C:\repo\dist")),
1571            PathBuf::from(r"C:\repo\dist")
1572        );
1573        let unc = Path::new(r"\\?\UNC\server\share");
1574        assert_eq!(verbatim_free(unc), unc);
1575        assert_eq!(
1576            verbatim_free(Path::new("/repo/dist")),
1577            Path::new("/repo/dist")
1578        );
1579    }
1580
1581    #[test]
1582    fn absolute_outputs_must_lie_in_the_workspace() {
1583        let root = tempfile::tempdir().unwrap();
1584        let inside = std::fs::canonicalize(root.path()).unwrap();
1585        std::fs::write(inside.join("nx.json"), "{}").unwrap();
1586        std::fs::write(
1587            inside.join("project.json"),
1588            json!({"name": "app", "targets": {"build": {"command": "true",
1589                "outputs": ["{options.out}"], "options": {"out": inside.join("dist/app")}},
1590                "outside": {"command": "true", "outputs": ["{options.out}"],
1591                    "options": {"out": "/elsewhere/dist"}}}})
1592            .to_string(),
1593        )
1594        .unwrap();
1595        let workspace = Workspace::load(&inside).unwrap();
1596        let graph = TaskGraph::build(
1597            &workspace,
1598            &[
1599                Request::parse("app:build").unwrap(),
1600                Request::parse("app:outside").unwrap(),
1601            ],
1602        )
1603        .unwrap();
1604        assert_eq!(
1605            resolved_outputs(&workspace, &graph.tasks["app:build"])
1606                .unwrap()
1607                .0,
1608            ["dist/app"]
1609        );
1610        assert!(resolved_outputs(&workspace, &graph.tasks["app:outside"]).is_err());
1611    }
1612
1613    #[test]
1614    fn dot_git_finds_a_linked_worktrees_directories_without_git() {
1615        let scratch = tempfile::tempdir().unwrap();
1616        let base = std::fs::canonicalize(scratch.path()).unwrap();
1617        let common = base.join("main/.git");
1618        let git_dir = common.join("worktrees/linked");
1619        std::fs::create_dir_all(&git_dir).unwrap();
1620        std::fs::write(git_dir.join("commondir"), "../..\n").unwrap();
1621        std::fs::create_dir_all(base.join("linked/apps/web")).unwrap();
1622        std::fs::write(
1623            base.join("linked/.git"),
1624            "gitdir: ../main/.git/worktrees/linked\n",
1625        )
1626        .unwrap();
1627        assert_eq!(
1628            dot_git(&base.join("linked/apps/web")),
1629            Some((git_dir, common.clone()))
1630        );
1631        assert_eq!(dot_git(&base.join("main")), Some((common.clone(), common)));
1632        assert_eq!(dot_git(&base.join("elsewhere")), None);
1633    }
1634
1635    #[test]
1636    fn literal_entries_match_listing_for_nested_and_missing_roots() {
1637        let workspace = tempfile::tempdir().unwrap();
1638        let root = workspace.path();
1639        std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1640        std::fs::write(root.join("dist/nested/value"), "contents").unwrap();
1641        std::fs::write(root.join("single"), "one").unwrap();
1642        #[cfg(unix)]
1643        std::os::unix::fs::symlink(root.join("single"), root.join("dist/link")).unwrap();
1644        let outputs = Outputs::from_paths(&[
1645            "dist".into(),
1646            "dist/nested".into(),
1647            "missing".into(),
1648            "single".into(),
1649        ])
1650        .unwrap();
1651        let entries = outputs
1652            .entries(root)
1653            .unwrap()
1654            .collect::<anyhow::Result<Vec<_>>>()
1655            .unwrap();
1656        let paths: std::collections::BTreeSet<_> =
1657            entries.iter().map(|(path, _)| path.clone()).collect();
1658        assert_eq!(entries.len(), paths.len());
1659        assert_eq!(paths, outputs.paths(root).unwrap());
1660        for (path, metadata) in entries {
1661            let expected = std::fs::symlink_metadata(root.join(path)).unwrap();
1662            assert_eq!(metadata.file_type(), expected.file_type());
1663            assert_eq!(metadata.len(), expected.len());
1664            assert_eq!(metadata.modified().unwrap(), expected.modified().unwrap());
1665        }
1666    }
1667
1668    #[cfg(unix)]
1669    #[test]
1670    fn literal_entries_reject_nested_roots_below_a_symlink() {
1671        let workspace = tempfile::tempdir().unwrap();
1672        let root = workspace.path();
1673        std::fs::create_dir_all(root.join("dist")).unwrap();
1674        std::fs::create_dir_all(root.join("outside/nested")).unwrap();
1675        std::os::unix::fs::symlink(root.join("outside"), root.join("dist/link")).unwrap();
1676        let outputs = Outputs::from_paths(&["dist".into(), "dist/link/nested".into()]).unwrap();
1677        assert!(outputs.entries(root).is_err());
1678    }
1679
1680    /// A project's root and name go into its outputs as the paths they are;
1681    /// an option's value goes in as written.
1682    #[test]
1683    fn outputs_escape_the_project_root_and_name() {
1684        let root = tempfile::tempdir().unwrap();
1685        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1686        std::fs::create_dir_all(root.path().join("apps/(group)/dist")).unwrap();
1687        std::fs::write(root.path().join("apps/(group)/dist/a.js"), "").unwrap();
1688        std::fs::write(
1689            root.path().join("apps/(group)/project.json"),
1690            json!({"name": "a+b", "sourceRoot": "apps/(group)/src", "targets": {
1691                "build": {"command": "true"},
1692                "emit": {"command": "true", "options": {"out": "out/*.js"}, "outputs": [
1693                    "{projectRoot}/dist",
1694                    "{workspaceRoot}/dist/{projectName}",
1695                    "{project.sourceRoot}/gen",
1696                    "{options.out}",
1697                ]},
1698            }})
1699            .to_string(),
1700        )
1701        .unwrap();
1702        let workspace = Workspace::load(root.path()).unwrap();
1703        let graph = TaskGraph::build(
1704            &workspace,
1705            &[
1706                Request::parse("a+b:emit").unwrap(),
1707                Request::parse("a+b:build").unwrap(),
1708            ],
1709        )
1710        .unwrap();
1711        assert_eq!(
1712            resolved_outputs(&workspace, &graph.tasks["a+b:emit"])
1713                .unwrap()
1714                .0,
1715            [
1716                r"apps/\(group\)/dist",
1717                r"dist/a\+b",
1718                r"apps/\(group\)/src/gen",
1719                "out/*.js"
1720            ]
1721        );
1722        assert_eq!(
1723            resolved_outputs(&workspace, &graph.tasks["a+b:build"])
1724                .unwrap()
1725                .0,
1726            [
1727                r"dist/apps/\(group\)",
1728                r"apps/\(group\)/dist",
1729                r"apps/\(group\)/build",
1730                r"apps/\(group\)/public"
1731            ]
1732        );
1733        let outputs = Outputs::new(&workspace, &graph.tasks["a+b:emit"]).unwrap();
1734        assert!(
1735            outputs
1736                .paths(root.path())
1737                .unwrap()
1738                .contains("apps/(group)/dist/a.js")
1739        );
1740    }
1741
1742    /// An output naming a project's root or a directory holding one, escaped
1743    /// or not, is refused: a restore would replace the project.
1744    #[test]
1745    fn outputs_cannot_name_a_project_root() {
1746        let root = tempfile::tempdir().unwrap();
1747        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1748        std::fs::create_dir_all(root.path().join("apps/(group)")).unwrap();
1749        std::fs::write(
1750            root.path().join("apps/(group)/project.json"),
1751            json!({"name": "group", "targets": {
1752                "own": {"command": "true", "outputs": ["{projectRoot}"]},
1753                "written": {"command": "true", "outputs": ["{workspaceRoot}/apps/\\(group\\)"]},
1754                "parent": {"command": "true", "outputs": ["{workspaceRoot}/apps"]},
1755                "sibling": {"command": "true", "outputs": ["{workspaceRoot}/apps/\\(gr"]},
1756            }})
1757            .to_string(),
1758        )
1759        .unwrap();
1760        let workspace = Workspace::load(root.path()).unwrap();
1761        for (target, refused) in [
1762            ("own", true),
1763            ("written", true),
1764            ("parent", true),
1765            ("sibling", false),
1766        ] {
1767            let request = Request::parse(&format!("group:{target}")).unwrap();
1768            let graph = TaskGraph::build(&workspace, &[request]).unwrap();
1769            assert_eq!(
1770                Outputs::new(&workspace, &graph.tasks[&format!("group:{target}")]).is_err(),
1771                refused,
1772                "{target}"
1773            );
1774        }
1775    }
1776
1777    #[test]
1778    fn output_patterns_escape_glob_characters() {
1779        let workspace = tempfile::tempdir().unwrap();
1780        for file in [
1781            "app/(group)/page.js",
1782            "app/group/page.js",
1783            "dist/[id].js",
1784            "dist/i.js",
1785        ] {
1786            let path = workspace.path().join(file);
1787            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1788            std::fs::write(path, "").unwrap();
1789        }
1790        let outputs =
1791            Outputs::from_paths(&[r"app/\(group\)".into(), r"dist/\[id\].js".into()]).unwrap();
1792        assert_eq!(
1793            outputs
1794                .paths(workspace.path())
1795                .unwrap()
1796                .into_iter()
1797                .collect::<Vec<_>>(),
1798            ["app/(group)", "app/(group)/page.js", "dist/[id].js"]
1799        );
1800        // An escaped first segment is the directory the outputs lie in.
1801        let outputs = Outputs::from_paths(&[r"\(group\)/dist".into()]).unwrap();
1802        std::fs::create_dir_all(workspace.path().join("(group)/dist")).unwrap();
1803        std::fs::write(workspace.path().join("(group)/dist/a.js"), "").unwrap();
1804        assert_eq!(outputs.anchors().collect::<Vec<_>>(), ["(group)/dist"]);
1805        assert_eq!(outputs.complete_roots(), ["(group)/dist"]);
1806        assert_eq!(
1807            outputs
1808                .paths(workspace.path())
1809                .unwrap()
1810                .into_iter()
1811                .collect::<Vec<_>>(),
1812            ["(group)/dist", "(group)/dist/a.js"]
1813        );
1814        for unsafe_pattern in [r"dist/\.\./x", r"dist\"] {
1815            assert!(
1816                Outputs::from_paths(&[unsafe_pattern.into()]).is_err(),
1817                "{unsafe_pattern}"
1818            );
1819        }
1820    }
1821
1822    #[test]
1823    fn literal_roots_preserve_path_boundaries_and_exclusions() {
1824        let outputs =
1825            Outputs::from_paths(&["dist".into(), "build/*.js".into(), "!dist/private".into()])
1826                .unwrap();
1827        for path in ["dist", "dist/nested/file.txt", "build/app.js"] {
1828            assert!(outputs.matches(path), "{path}");
1829        }
1830        for path in [
1831            "dist2/file.txt",
1832            "dist/private",
1833            "dist/private/file.txt",
1834            "build/app.ts",
1835        ] {
1836            assert!(!outputs.matches(path), "{path}");
1837        }
1838    }
1839
1840    /// Directories no glob can match are skipped without losing a match.
1841    #[test]
1842    fn listing_skips_only_directories_no_glob_reaches() {
1843        let workspace = tempfile::tempdir().unwrap();
1844        let root = workspace.path();
1845        for file in [
1846            "apps/web/graphql/manifest.json",
1847            "apps/web/src/graphql/manifest.json",
1848            "apps/web/node_modules/pkg/graphql/manifest.json",
1849            "apps/client/app/graphql/manifest.json",
1850            "apps/client/app/graphql/nested/extra.json",
1851            "packages/a/deep/x/gen/out.ts",
1852            "packages/b/types/index.d.ts",
1853            "packages/c/lib/one.js",
1854        ] {
1855            let path = root.join(file);
1856            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1857            std::fs::write(path, "").unwrap();
1858        }
1859        let outputs = Outputs::from_paths(&[
1860            "apps/*/graphql/manifest.json".into(),
1861            "apps/*/app/graphql".into(),
1862            "packages/a/**/gen/*.ts".into(),
1863            "packages/b/types".into(),
1864            "packages/{c,d}/lib/*.js".into(),
1865        ])
1866        .unwrap();
1867        assert_eq!(
1868            outputs.paths(root).unwrap().into_iter().collect::<Vec<_>>(),
1869            [
1870                "apps/client/app/graphql",
1871                "apps/client/app/graphql/manifest.json",
1872                "apps/client/app/graphql/nested",
1873                "apps/client/app/graphql/nested/extra.json",
1874                "apps/web/graphql/manifest.json",
1875                "packages/a/deep/x/gen/out.ts",
1876                "packages/b/types",
1877                "packages/b/types/index.d.ts",
1878                "packages/c/lib/one.js",
1879            ]
1880        );
1881    }
1882
1883    /// A slash inside a character class does not separate path segments.
1884    #[test]
1885    fn character_classes_with_slashes_keep_nested_artifacts() {
1886        let workspace = tempfile::tempdir().unwrap();
1887        for name in ["a", "b", "c"] {
1888            let directory = workspace.path().join(format!("apps/{name}/graphql/nested"));
1889            std::fs::create_dir_all(&directory).unwrap();
1890            std::fs::write(directory.join("output.txt"), "value").unwrap();
1891        }
1892        let outputs = Outputs::from_paths(&["apps/[a/b]/graphql".into()]).unwrap();
1893        assert_eq!(
1894            outputs
1895                .paths(workspace.path())
1896                .unwrap()
1897                .into_iter()
1898                .collect::<Vec<_>>(),
1899            [
1900                "apps/a/graphql",
1901                "apps/a/graphql/nested",
1902                "apps/a/graphql/nested/output.txt",
1903                "apps/b/graphql",
1904                "apps/b/graphql/nested",
1905                "apps/b/graphql/nested/output.txt",
1906            ]
1907        );
1908    }
1909
1910    #[test]
1911    fn segment_traversal_preserves_output_sets() {
1912        let workspace = tempfile::tempdir().unwrap();
1913        for file in [
1914            "apps/a/graphql/manifest.json",
1915            "apps/a/graphql/nested/out.ts",
1916            "apps/a/src/other.ts",
1917            "apps/b/src/graphql/manifest.json",
1918            "apps/.hidden/graphql/manifest.json",
1919            "apps/a/file",
1920            "dist/a.ts",
1921            "dist/deep/b.ts",
1922        ] {
1923            let path = workspace.path().join(file);
1924            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1925            std::fs::write(path, "value").unwrap();
1926        }
1927        for patterns in [
1928            vec![
1929                "apps/*/graphql/manifest.json",
1930                "apps/*/*/graphql/manifest.json",
1931            ],
1932            vec!["apps/?/graphql", "!apps/a/graphql/nested"],
1933            vec!["apps/a/graphql", "apps/*/graphql/*"],
1934            vec!["apps/a/file/nested", "missing/*/file", "dist/*.ts"],
1935            vec!["dist"],
1936            vec!["apps/**/manifest.json"],
1937            vec!["apps/[a/b]/graphql"],
1938        ] {
1939            let outputs =
1940                Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
1941                    .unwrap();
1942            match (
1943                outputs.paths(workspace.path()),
1944                outputs.walk_paths(workspace.path()),
1945            ) {
1946                (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
1947                (Err(actual), Err(expected)) => {
1948                    assert_eq!(actual.to_string(), expected.to_string())
1949                }
1950                results => panic!("different results for {patterns:?}: {results:?}"),
1951            }
1952        }
1953    }
1954
1955    #[cfg(unix)]
1956    #[test]
1957    fn segment_traversal_preserves_symlink_boundaries() {
1958        let workspace = tempfile::tempdir().unwrap();
1959        let root = workspace.path();
1960        std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
1961        std::fs::write(root.join("apps/a/graphql/manifest.json"), "value").unwrap();
1962        std::os::unix::fs::symlink("a", root.join("apps/link")).unwrap();
1963        std::os::unix::fs::symlink("absent", root.join("apps/dangling")).unwrap();
1964        for patterns in [
1965            vec!["apps/*/graphql/manifest.json"],
1966            vec!["apps/*"],
1967            vec!["apps/link"],
1968            vec!["apps/dangling"],
1969        ] {
1970            let outputs =
1971                Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
1972                    .unwrap();
1973            match (outputs.paths(root), outputs.walk_paths(root)) {
1974                (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
1975                (Err(actual), Err(expected)) => {
1976                    assert_eq!(actual.to_string(), expected.to_string())
1977                }
1978                results => panic!("different results for {patterns:?}: {results:?}"),
1979            }
1980        }
1981    }
1982
1983    #[test]
1984    fn listings_recheck_metadata_and_directory_contents() {
1985        let workspace = tempfile::tempdir().unwrap();
1986        let root = workspace.path();
1987        std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
1988        std::fs::write(root.join("apps/a/graphql/manifest.json"), "one").unwrap();
1989        let outputs = Outputs::from_paths(&["apps/*/graphql/manifest.json".into()]).unwrap();
1990        assert_eq!(
1991            outputs
1992                .entries(root)
1993                .unwrap()
1994                .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
1995                .unwrap()["apps/a/graphql/manifest.json"]
1996                .len(),
1997            3
1998        );
1999        std::fs::write(root.join("apps/a/graphql/manifest.json"), "different").unwrap();
2000        std::fs::create_dir_all(root.join("apps/b/graphql")).unwrap();
2001        std::fs::write(root.join("apps/b/graphql/manifest.json"), "two").unwrap();
2002        let entries = outputs
2003            .entries(root)
2004            .unwrap()
2005            .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
2006            .unwrap();
2007        assert_eq!(entries.len(), 2);
2008        assert_eq!(entries["apps/a/graphql/manifest.json"].len(), 9);
2009        std::fs::remove_dir_all(root.join("apps/a")).unwrap();
2010        assert_eq!(
2011            outputs
2012                .entries(root)
2013                .unwrap()
2014                .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
2015                .unwrap()
2016                .len(),
2017            1
2018        );
2019    }
2020
2021    #[test]
2022    fn literal_segments_after_wildcards_remain_case_sensitive() {
2023        let workspace = tempfile::tempdir().unwrap();
2024        let root = workspace.path();
2025        std::fs::create_dir_all(root.join("apps/a/GraphQL")).unwrap();
2026        std::fs::write(root.join("apps/a/GraphQL/Manifest.json"), "one").unwrap();
2027        for glob in [
2028            "apps/*/graphql/Manifest.json",
2029            "apps/*/GraphQL/manifest.json",
2030            "apps/*/GraphQL/Manifest.json",
2031            "apps/a/GraphQL",
2032        ] {
2033            let outputs = Outputs::from_paths(&[glob.into()]).unwrap();
2034            assert_eq!(
2035                outputs.paths(root).unwrap(),
2036                outputs.walk_paths(root).unwrap(),
2037                "{glob}"
2038            );
2039        }
2040    }
2041
2042    #[test]
2043    fn simple_glob_combinations_match_the_walker() {
2044        let workspace = tempfile::tempdir().unwrap();
2045        let root = workspace.path();
2046        for project in ["a", "aa", "Ab", ".hidden", "café"] {
2047            for directory in ["graphql", "GraphQL", "src"] {
2048                let path = root.join(format!("apps/{project}/{directory}/nested"));
2049                std::fs::create_dir_all(&path).unwrap();
2050                std::fs::write(path.join("manifest.json"), "value").unwrap();
2051                std::fs::write(path.parent().unwrap().join("manifest.json"), "value").unwrap();
2052            }
2053        }
2054        for project in ["*", "?", "a*", "*a", "*?", "A*"] {
2055            for directory in ["graphql", "GraphQL", "src", "missing"] {
2056                for suffix in ["", "/manifest.json", "/*.json", "/?anifest.json", "/*"] {
2057                    let glob = format!("apps/{project}/{directory}{suffix}");
2058                    let outputs =
2059                        Outputs::from_paths(&[glob.clone(), "!apps/aa/graphql/nested".into()])
2060                            .unwrap();
2061                    assert_eq!(
2062                        outputs.paths(root).unwrap(),
2063                        outputs.walk_paths(root).unwrap(),
2064                        "{glob}"
2065                    );
2066                }
2067            }
2068        }
2069    }
2070
2071    /// Only unconditional literal output roots may move as complete trees.
2072    #[test]
2073    fn complete_roots_do_not_widen_globs_exclusions_or_defaults() {
2074        let mut outputs = Outputs::from_paths(&[
2075            "dist".into(),
2076            "dist/nested".into(),
2077            "dist2".into(),
2078            "partial/**/*.js".into(),
2079        ])
2080        .unwrap();
2081        assert_eq!(outputs.complete_roots(), ["dist", "dist2"]);
2082        outputs
2083            .negations
2084            .push(Pattern::new("dist/private", false).unwrap());
2085        assert!(outputs.complete_roots().is_empty());
2086        outputs.negations.clear();
2087        outputs.explicit = false;
2088        assert!(outputs.complete_roots().is_empty());
2089    }
2090
2091    #[test]
2092    fn normalizes_relative_segments() {
2093        assert_eq!(
2094            normalize("apps/client/tests/../build/*/bundle.bin").unwrap(),
2095            "apps/client/build/*/bundle.bin"
2096        );
2097        assert_eq!(normalize("./dist/./a").unwrap(), "dist/a");
2098        assert_eq!(normalize(".").unwrap(), ".");
2099        assert!(normalize("app/../../outside").is_err());
2100        assert!(normalize("app/**/../x").is_err());
2101        assert_eq!(normalize(r"apps/\(group\)/../x").unwrap(), "apps/x");
2102    }
2103}