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