Skip to main content

qk_cache/
paths.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Component, Path, PathBuf};
3use std::process::{Command, Output};
4
5use anyhow::{Context, Result, bail};
6use qk_config::Workspace;
7use qk_taskgraph::Task;
8
9pub fn git(root: &Path, args: &[&str]) -> std::io::Result<Output> {
10    Command::new("git")
11        .arg("-C")
12        .arg(root)
13        .args(args)
14        .env_remove("GIT_DIR")
15        .env_remove("GIT_WORK_TREE")
16        .env_remove("GIT_INDEX_FILE")
17        .env_remove("GIT_COMMON_DIR")
18        .output()
19}
20
21pub fn git_path(root: &Path, flag: &str) -> Option<PathBuf> {
22    let result = git(root, &["rev-parse", "--path-format=absolute", flag]).ok()?;
23    if !result.status.success() {
24        return None;
25    }
26    let path = String::from_utf8(result.stdout).ok()?;
27    Some(PathBuf::from(path.trim_end_matches(['\r', '\n'])))
28}
29
30/// Where qk keeps what belongs to one worktree: digests of its files, records
31/// of the outputs it holds, and restores in progress. Inside Git that is the
32/// worktree's own git directory, so nothing appears in the working tree;
33/// outside Git it is `.qk` at the workspace root.
34pub fn worktree_state(root: &Path) -> PathBuf {
35    static KNOWN: std::sync::OnceLock<
36        std::sync::Mutex<std::collections::HashMap<PathBuf, PathBuf>>,
37    > = std::sync::OnceLock::new();
38    let known = KNOWN.get_or_init(Default::default);
39    if let Some(state) = known.lock().unwrap().get(root) {
40        return state.clone();
41    }
42    let state = git_path(root, "--git-dir")
43        .map(|dir| dir.join("qk"))
44        .unwrap_or_else(|| root.join(".qk"));
45    known.lock().unwrap().insert(root.to_owned(), state.clone());
46    state
47}
48
49/// What [`worktree_state`] holds. Inside Git, the state directory of the main
50/// worktree also holds the shared cache and run history, so these entries are
51/// removed one by one rather than the directory as a whole.
52const WORKTREE_ENTRIES: &[&str] = &["digests.json", "outputs", "restore", "sandbox", "warm"];
53
54/// Removes a worktree's state: its file digests, the outputs it records
55/// holding, its warm directories and any restores or sandboxes left behind.
56pub fn clear_worktree_state(root: &Path) -> Result<()> {
57    let state = worktree_state(root);
58    for entry in WORKTREE_ENTRIES {
59        let path = state.join(entry);
60        let removed = if path.is_dir() {
61            std::fs::remove_dir_all(&path)
62        } else {
63            std::fs::remove_file(&path)
64        };
65        match removed {
66            Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
67                return Err(error).with_context(|| format!("cannot remove {}", path.display()));
68            }
69            _ => {}
70        }
71    }
72    Ok(())
73}
74
75/// Where the cache is: as in Nx, `NX_CACHE_DIRECTORY`, then nx.json
76/// `cacheDirectory`, relative to the workspace root, with qk's entries in
77/// `qk/v1` inside so they never mix with Nx's; otherwise
78/// [`cache_directory`], shared by the repository's worktrees.
79pub fn cache_location(workspace: &Workspace) -> PathBuf {
80    let configured = std::env::var_os("NX_CACHE_DIRECTORY")
81        .filter(|value| !value.is_empty())
82        .map(PathBuf::from)
83        .or_else(|| {
84            workspace
85                .config
86                .extra
87                .get("cacheDirectory")
88                .and_then(serde_json::Value::as_str)
89                .map(PathBuf::from)
90        });
91    match configured {
92        Some(directory) => workspace.root.join(directory).join("qk/v1"),
93        None => cache_directory(&workspace.root),
94    }
95}
96
97/// The cache's default place, in the repository's common git directory.
98pub fn cache_directory(root: &Path) -> PathBuf {
99    git_path(root, "--git-common-dir")
100        .map(|dir| dir.join("qk/cache/v1"))
101        .unwrap_or_else(|| root.join(".qk/cache/v1"))
102}
103
104pub fn relative(root: &Path, path: &Path) -> Result<String> {
105    let parts = path
106        .strip_prefix(root)?
107        .components()
108        .map(|part| {
109            part.as_os_str()
110                .to_str()
111                .map(str::to_owned)
112                .context("cache paths must be UTF-8")
113        })
114        .collect::<Result<Vec<_>>>()?;
115    Ok(parts.join("/"))
116}
117
118pub fn validate_path(path: &str) -> Result<()> {
119    if path.is_empty()
120        || path.contains(['\\', ':', '\0'])
121        || Path::new(path).is_absolute()
122        || Path::new(path)
123            .components()
124            .any(|part| !matches!(part, Component::Normal(_)))
125        || path
126            .split('/')
127            .any(|part| matches!(part, ".git" | ".qk" | ".." | "." | ""))
128    {
129        bail!("unsafe cache artifact path {path:?}");
130    }
131    Ok(())
132}
133
134pub fn safe_parents(root: &Path, path: &str) -> Result<()> {
135    validate_path(path)?;
136    let mut current = root.to_owned();
137    for part in Path::new(path)
138        .parent()
139        .into_iter()
140        .flat_map(Path::components)
141    {
142        current.push(part);
143        match std::fs::symlink_metadata(&current) {
144            Ok(metadata) if metadata.file_type().is_symlink() => {
145                bail!("cache artifact parent is a symlink: {}", current.display())
146            }
147            Ok(metadata) if !metadata.is_dir() => bail!(
148                "cache artifact parent is not a directory: {}",
149                current.display()
150            ),
151            Ok(_) => {}
152            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
153            Err(error) => return Err(error.into()),
154        }
155    }
156    Ok(())
157}
158
159pub use crate::glob::Pattern;
160
161pub fn expand(workspace: &Workspace, project: &str, text: &str) -> Result<String> {
162    let project_root = &workspace.projects[project].root;
163    let prefix = if project_root == "." {
164        String::new()
165    } else {
166        format!("{project_root}/")
167    };
168    let result = text
169        .replace("{workspaceRoot}/", "")
170        .replace("{projectRoot}/", &prefix)
171        .replace("{projectRoot}", project_root)
172        .replace("{workspaceRoot}", ".");
173    if result.contains("{options.") || result.contains("{args.") {
174        bail!("dynamic cache paths are not supported yet");
175    }
176    normalize(&result)
177}
178
179/// Resolves `.` and `..` segments lexically, e.g. `app/e2e/../ios` to `app/ios`.
180fn normalize(path: &str) -> Result<String> {
181    let mut parts: Vec<&str> = Vec::new();
182    for part in path.split('/') {
183        match part {
184            "." => {}
185            ".." => match parts.pop() {
186                Some(parent) if crate::glob::is_literal(parent) => {}
187                _ => bail!("cache path {path:?} leaves the workspace or follows a glob with `..`"),
188            },
189            part => parts.push(part),
190        }
191    }
192    Ok(if parts.is_empty() {
193        ".".to_owned()
194    } else {
195        parts.join("/")
196    })
197}
198
199#[derive(Clone)]
200pub struct Outputs {
201    patterns: Vec<Pattern>,
202    /// The patterns as written, for skipping directories none can match.
203    globs: Vec<String>,
204    /// Negated patterns: what they match is never an output.
205    negations: Vec<Pattern>,
206    anchors: BTreeSet<String>,
207    /// Literal output roots which include their entire subtree.
208    complete: BTreeSet<String>,
209    /// Whether the target declares its outputs, rather than taking Nx's
210    /// defaults for a target without them.
211    explicit: bool,
212}
213
214impl Outputs {
215    /// Paths from already-expanded workspace-relative patterns, each with a
216    /// fixed directory prefix, as outputs require. A pattern starting with `!`
217    /// excludes what it matches.
218    pub fn from_paths(patterns: &[String]) -> Result<Self> {
219        let mut compiled = Vec::new();
220        let mut globs = Vec::new();
221        let mut negations = Vec::new();
222        let mut anchors = BTreeSet::new();
223        let mut complete = BTreeSet::new();
224        for pattern in patterns {
225            if let Some(excluded) = pattern.strip_prefix('!') {
226                validate_path(excluded)?;
227                negations.push(Pattern::new(excluded, false)?);
228                continue;
229            }
230            validate_path(pattern)?;
231            let anchor = pattern
232                .split('/')
233                .take_while(|part| crate::glob::is_literal(part))
234                .collect::<Vec<_>>()
235                .join("/");
236            if anchor.is_empty() {
237                bail!("{pattern:?} needs a fixed directory prefix");
238            }
239            if pattern.split('/').all(crate::glob::is_literal) {
240                complete.insert(pattern.clone());
241            }
242            compiled.push(Pattern::new(pattern, false)?);
243            globs.push(pattern.clone());
244            anchors.insert(anchor);
245        }
246        Ok(Self {
247            patterns: compiled,
248            globs,
249            negations,
250            anchors,
251            complete,
252            explicit: true,
253        })
254    }
255
256    /// Outputs from already-expanded patterns, for tests without a workspace.
257    #[cfg(test)]
258    pub fn from_patterns(patterns: &[&str]) -> Self {
259        Self {
260            patterns: patterns
261                .iter()
262                .map(|pattern| Pattern::new(pattern, false).unwrap())
263                .collect(),
264            globs: patterns
265                .iter()
266                .map(|pattern| (*pattern).to_owned())
267                .collect(),
268            anchors: patterns
269                .iter()
270                .map(|pattern| (*pattern).to_owned())
271                .collect(),
272            complete: patterns
273                .iter()
274                .filter(|pattern| pattern.split('/').all(crate::glob::is_literal))
275                .map(|pattern| (*pattern).to_owned())
276                .collect(),
277            negations: Vec::new(),
278            explicit: true,
279        }
280    }
281
282    /// The task's outputs, from [`resolved_outputs`].
283    pub fn new(workspace: &Workspace, task: &Task) -> Result<Self> {
284        let (resolved, explicit) = resolved_outputs(workspace, task)?;
285        let mut patterns = Vec::new();
286        let mut globs = Vec::new();
287        let mut negations = Vec::new();
288        let mut anchors = BTreeSet::new();
289        let mut complete = BTreeSet::new();
290        for output in resolved {
291            let (negated, pattern) = match output.strip_prefix('!') {
292                Some(rest) => (true, rest.to_owned()),
293                None => (false, output),
294            };
295            validate_path(&pattern)?;
296            if negated {
297                negations.push(Pattern::new(&pattern, false)?);
298                continue;
299            }
300            let anchor = pattern
301                .split('/')
302                .take_while(|part| crate::glob::is_literal(part))
303                .collect::<Vec<_>>()
304                .join("/");
305            if anchor.is_empty()
306                || workspace
307                    .projects
308                    .values()
309                    .any(|project| project.root == pattern)
310            {
311                bail!(
312                    "cache outputs must have a fixed directory prefix and cannot replace a project root"
313                );
314            }
315            if pattern.split('/').all(crate::glob::is_literal) {
316                complete.insert(pattern.clone());
317            }
318            patterns.push(Pattern::new(&pattern, false)?);
319            globs.push(pattern);
320            anchors.insert(anchor);
321        }
322        Ok(Self {
323            patterns,
324            globs,
325            negations,
326            anchors,
327            complete,
328            explicit,
329        })
330    }
331
332    /// Whether the outputs come from the target's `outputs`.
333    pub fn is_explicit(&self) -> bool {
334        self.explicit
335    }
336
337    /// Whether the task declares outputs that hold everything it produces,
338    /// so dependents may be keyed by their content alone. Nx's defaults for a
339    /// target without `outputs` are only guesses, and do not count.
340    pub fn declared(&self) -> bool {
341        self.explicit && !self.patterns.is_empty()
342    }
343
344    /// Fixed directory prefixes; every matching path lies below one of them.
345    pub fn anchors(&self) -> impl Iterator<Item = &str> {
346        self.anchors.iter().map(String::as_str)
347    }
348
349    /// Complete literal roots only: globs, implicit defaults and any exclusion
350    /// retain per-artifact restoration. Nested roots are handled by their parent.
351    pub(crate) fn complete_roots(&self) -> Vec<&str> {
352        if !self.explicit || !self.negations.is_empty() {
353            return Vec::new();
354        }
355        let mut roots: Vec<&str> = Vec::new();
356        for root in &self.complete {
357            if !roots.iter().any(|parent| {
358                root.strip_prefix(parent)
359                    .is_some_and(|suffix| suffix.starts_with('/'))
360            }) {
361                roots.push(root);
362            }
363        }
364        roots
365    }
366
367    pub fn matches(&self, path: &str) -> bool {
368        let any = |patterns: &[Pattern]| {
369            Path::new(path)
370                .ancestors()
371                .any(|ancestor| patterns.iter().any(|pattern| pattern.is_match(ancestor)))
372        };
373        any(&self.patterns) && !any(&self.negations)
374    }
375
376    pub fn paths(&self, root: &Path) -> Result<BTreeSet<String>> {
377        self.listing(root).map(|(paths, _)| paths)
378    }
379
380    pub(crate) fn entries<'a>(
381        &self,
382        root: &'a Path,
383    ) -> Result<impl Iterator<Item = Result<(String, std::fs::Metadata)>> + 'a> {
384        let (paths, mut traversal) = self.listing(root)?;
385        Ok(paths.into_iter().map(move |path| {
386            let absolute = root.join(&path);
387            let metadata = match traversal.metadata.remove(&absolute) {
388                Some(metadata) => metadata,
389                None => std::fs::symlink_metadata(&absolute)?,
390            };
391            Ok((path, metadata))
392        }))
393    }
394
395    fn listing(&self, root: &Path) -> Result<(BTreeSet<String>, Traversal)> {
396        if let Some(segments) = self.simple_segments()? {
397            for anchor in &self.anchors {
398                safe_parents(root, anchor)?;
399                match std::fs::symlink_metadata(root.join(anchor)) {
400                    Ok(metadata) if metadata.file_type().is_symlink() => {
401                        return self
402                            .walk_paths(root)
403                            .map(|paths| (paths, Traversal::default()));
404                    }
405                    Ok(_) => {}
406                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
407                    Err(error) => return Err(error.into()),
408                }
409            }
410            let mut traversal = Traversal::default();
411            let mut candidates = BTreeSet::new();
412            for (glob, pattern) in self.globs.iter().zip(&segments) {
413                let fixed = pattern
414                    .iter()
415                    .take_while(|segment| matches!(segment, Segment::Literal(_)))
416                    .count();
417                let anchor = glob.split('/').take(fixed).collect::<Vec<_>>().join("/");
418                traversal.expand(root, &root.join(anchor), &pattern[fixed..], &mut candidates)?;
419            }
420            return candidates
421                .into_iter()
422                .filter(|path| self.matches(path))
423                .map(|path| {
424                    validate_path(&path)?;
425                    Ok(path)
426                })
427                .collect::<Result<_>>()
428                .map(|paths| (paths, traversal));
429        }
430        self.walk_paths(root)
431            .map(|paths| (paths, Traversal::default()))
432    }
433
434    fn simple_segments(&self) -> Result<Option<Vec<Vec<Segment>>>> {
435        if self.globs.iter().any(|glob| {
436            glob.contains("**")
437                || glob.contains(['!', '@', '+', '|', ',', '{', '}', '[', ']', '(', ')'])
438        }) {
439            return Ok(None);
440        }
441        let mut matchers: BTreeMap<String, Pattern> = BTreeMap::new();
442        let mut patterns = Vec::new();
443        for glob in &self.globs {
444            let mut segments = Vec::new();
445            for part in glob.split('/') {
446                segments.push(if crate::glob::is_literal(part) {
447                    Segment::Literal(part.to_owned())
448                } else {
449                    let matcher = match matchers.entry(part.to_owned()) {
450                        std::collections::btree_map::Entry::Occupied(entry) => entry.get().clone(),
451                        std::collections::btree_map::Entry::Vacant(entry) => {
452                            entry.insert(Pattern::new(part, false)?).clone()
453                        }
454                    };
455                    Segment::Glob(matcher)
456                });
457            }
458            patterns.push(segments);
459        }
460        Ok(Some(patterns))
461    }
462
463    fn walk_paths(&self, root: &Path) -> Result<BTreeSet<String>> {
464        let mut paths = BTreeSet::new();
465        for anchor in &self.anchors {
466            safe_parents(root, anchor)?;
467            let path = root.join(anchor);
468            match std::fs::symlink_metadata(&path) {
469                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
470                Err(error) => return Err(error.into()),
471                Ok(_) => {}
472            }
473            let walk = walkdir::WalkDir::new(path)
474                .follow_links(false)
475                .into_iter()
476                .filter_entry(|entry| {
477                    entry.depth() == 0
478                        || !entry.file_type().is_dir()
479                        || relative(root, entry.path()).map_or(true, |directory| {
480                            self.globs
481                                .iter()
482                                .any(|glob| may_match_below(glob, &directory))
483                        })
484                });
485            for entry in walk {
486                let entry = entry?;
487                let path = relative(root, entry.path())?;
488                if self.matches(&path) {
489                    validate_path(&path)?;
490                    paths.insert(path);
491                }
492            }
493        }
494        Ok(paths)
495    }
496}
497
498#[derive(Clone)]
499enum Segment {
500    Literal(String),
501    Glob(Pattern),
502}
503
504#[derive(Default)]
505struct Traversal {
506    metadata: BTreeMap<PathBuf, std::fs::Metadata>,
507    directories: BTreeMap<PathBuf, Vec<PathBuf>>,
508}
509
510impl Traversal {
511    fn metadata(&mut self, path: &Path) -> Result<Option<std::fs::Metadata>> {
512        if let Some(metadata) = self.metadata.get(path) {
513            return Ok(Some(metadata.clone()));
514        }
515        match std::fs::symlink_metadata(path) {
516            Ok(metadata) => {
517                self.metadata.insert(path.to_owned(), metadata.clone());
518                Ok(Some(metadata))
519            }
520            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
521            Err(error) => Err(error.into()),
522        }
523    }
524
525    fn children(&mut self, path: &Path) -> Result<&Vec<PathBuf>> {
526        if !self.directories.contains_key(path) {
527            let children = std::fs::read_dir(path)?
528                .map(|entry| entry.map(|entry| entry.path()))
529                .collect::<std::io::Result<Vec<_>>>()?;
530            self.directories.insert(path.to_owned(), children);
531        }
532        Ok(&self.directories[path])
533    }
534
535    fn expand(
536        &mut self,
537        root: &Path,
538        path: &Path,
539        segments: &[Segment],
540        paths: &mut BTreeSet<String>,
541    ) -> Result<()> {
542        let Some(metadata) = self.metadata(path)? else {
543            return Ok(());
544        };
545        let Some((segment, rest)) = segments.split_first() else {
546            for entry in walkdir::WalkDir::new(path)
547                .follow_links(false)
548                .follow_root_links(false)
549            {
550                paths.insert(relative(root, entry?.path())?);
551            }
552            return Ok(());
553        };
554        if !metadata.is_dir() {
555            return Ok(());
556        }
557        match segment {
558            Segment::Literal(name) => {
559                let candidate = path.join(name);
560                if self.metadata(&candidate)?.is_none() {
561                    return Ok(());
562                }
563                // A successful lookup may differ in case on the host filesystem.
564                let child = self
565                    .children(path)?
566                    .iter()
567                    .find(|child| child.file_name() == Some(std::ffi::OsStr::new(name)))
568                    .cloned();
569                if let Some(child) = child {
570                    self.expand(root, &child, rest, paths)?;
571                }
572                Ok(())
573            }
574            Segment::Glob(matcher) => {
575                let children: Vec<_> = self
576                    .children(path)?
577                    .iter()
578                    .filter(|child| matcher.is_match(child.file_name().unwrap()))
579                    .cloned()
580                    .collect();
581                for child in children {
582                    self.expand(root, &child, rest, paths)?;
583                }
584                Ok(())
585            }
586        }
587    }
588}
589
590/// Whether `glob` can match `directory` or a path below it. A path matches
591/// when it or an ancestor does, so a directory deeper than the glob counts.
592/// Groups and character classes can contain slashes, so a glob with one is
593/// not ruled out by comparing slash-separated segments.
594fn may_match_below(glob: &str, directory: &str) -> bool {
595    if glob.contains(['{', '}', '(', ')', '[', ']']) {
596        return true;
597    }
598    for (part, name) in glob.split('/').zip(directory.split('/')) {
599        if part == "**" {
600            return true;
601        }
602        if crate::glob::is_literal(part) && part != name {
603            return false;
604        }
605    }
606    true
607}
608
609/// A task's outputs as Nx lists them: workspace-relative paths and globs,
610/// `!` for a negated one, with `{options.name}`, `{projectName}` and the
611/// project tokens replaced; an output naming what has no value is left out.
612/// Without `outputs`, Nx's defaults: `options.outputPath`, and for `build`
613/// and `prepare` targets `dist/<root>`, `<root>/dist`, `<root>/build` and
614/// `<root>/public`. The flag tells whether the target declared them.
615pub fn resolved_outputs(workspace: &Workspace, task: &Task) -> Result<(Vec<String>, bool)> {
616    let root = &workspace.projects[&task.project].root;
617    let options = task_options(task);
618    let (templates, explicit) = match &task.definition.outputs {
619        Some(outputs) => (outputs.clone(), true),
620        None => match options.get("outputPath") {
621            Some(serde_json::Value::String(path)) => (vec![path.clone()], false),
622            Some(serde_json::Value::Array(paths)) => (
623                paths
624                    .iter()
625                    .filter_map(|path| path.as_str().map(str::to_owned))
626                    .collect(),
627                false,
628            ),
629            _ if matches!(task.target.as_str(), "build" | "prepare") => (
630                [
631                    format!("dist/{root}"),
632                    format!("{root}/dist"),
633                    format!("{root}/build"),
634                    format!("{root}/public"),
635                ]
636                .into(),
637                false,
638            ),
639            _ => (Vec::new(), false),
640        },
641    };
642    let mut resolved = Vec::new();
643    for template in templates {
644        let (negated, template) = match template.strip_prefix('!') {
645            Some(rest) => (true, rest.to_owned()),
646            None => (false, template),
647        };
648        let Some(output) = resolve_output(task, &options, &template) else {
649            continue;
650        };
651        let path = expand(workspace, &task.project, &output)?
652            .trim_end_matches('/')
653            .to_owned();
654        resolved.push(if negated { format!("!{path}") } else { path });
655    }
656    Ok((resolved, explicit))
657}
658
659/// The target's options with the task's `--name=value` arguments applied,
660/// as Nx's overrides are.
661fn task_options(task: &Task) -> serde_json::Map<String, serde_json::Value> {
662    let mut options = task.definition.options.clone();
663    let mut args = task.args.iter().peekable();
664    while let Some(arg) = args.next() {
665        let Some(flag) = arg.strip_prefix("--") else {
666            continue;
667        };
668        let (key, value) = match flag.split_once('=') {
669            Some((key, value)) => (key.to_owned(), value.to_owned()),
670            None if args.peek().is_some_and(|next| !next.starts_with('-')) => {
671                (flag.to_owned(), args.next().unwrap().clone())
672            }
673            None => (flag.to_owned(), "true".to_owned()),
674        };
675        options.insert(key, serde_json::Value::String(value));
676    }
677    options
678}
679
680/// An output with `{options.*}`, `{projectName}` and the legacy
681/// `{project.name}` and `{project.root}` replaced, or `None` when one of
682/// them has no value, which Nx leaves the output out for.
683fn resolve_output(
684    task: &Task,
685    options: &serde_json::Map<String, serde_json::Value>,
686    template: &str,
687) -> Option<String> {
688    let mut result = String::new();
689    let mut rest = template;
690    while let Some(start) = rest.find('{') {
691        result.push_str(&rest[..start]);
692        let end = rest[start..].find('}')? + start;
693        let token = rest[start + 1..end].trim();
694        let value = if let Some(path) = token.strip_prefix("options.") {
695            let mut value = options.get(path.split('.').next()?)?;
696            for part in path.split('.').skip(1) {
697                value = value.get(part)?;
698            }
699            match value {
700                serde_json::Value::String(text) if !text.is_empty() => text.clone(),
701                serde_json::Value::Number(number) => number.to_string(),
702                serde_json::Value::Bool(true) => "true".into(),
703                _ => return None,
704            }
705        } else {
706            match token {
707                "projectName" | "project.name" => task.project.clone(),
708                "project.root" => "{projectRoot}".into(),
709                "projectRoot" | "workspaceRoot" => format!("{{{token}}}"),
710                _ => return None,
711            }
712        };
713        result.push_str(&value);
714        rest = &rest[end + 1..];
715    }
716    result.push_str(rest);
717    Some(result)
718}
719
720#[cfg(test)]
721mod tests {
722    use super::{Outputs, Pattern, normalize};
723
724    /// Directories no glob can match are skipped without losing a match.
725    #[test]
726    fn listing_skips_only_directories_no_glob_reaches() {
727        let workspace = tempfile::tempdir().unwrap();
728        let root = workspace.path();
729        for file in [
730            "apps/web/graphql/manifest.json",
731            "apps/web/src/graphql/manifest.json",
732            "apps/web/node_modules/pkg/graphql/manifest.json",
733            "apps/mobile/app/graphql/manifest.json",
734            "apps/mobile/app/graphql/nested/extra.json",
735            "packages/a/deep/x/gen/out.ts",
736            "packages/b/types/index.d.ts",
737            "packages/c/lib/one.js",
738        ] {
739            let path = root.join(file);
740            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
741            std::fs::write(path, "").unwrap();
742        }
743        let outputs = Outputs::from_paths(&[
744            "apps/*/graphql/manifest.json".into(),
745            "apps/*/app/graphql".into(),
746            "packages/a/**/gen/*.ts".into(),
747            "packages/b/types".into(),
748            "packages/{c,d}/lib/*.js".into(),
749        ])
750        .unwrap();
751        assert_eq!(
752            outputs.paths(root).unwrap().into_iter().collect::<Vec<_>>(),
753            [
754                "apps/mobile/app/graphql",
755                "apps/mobile/app/graphql/manifest.json",
756                "apps/mobile/app/graphql/nested",
757                "apps/mobile/app/graphql/nested/extra.json",
758                "apps/web/graphql/manifest.json",
759                "packages/a/deep/x/gen/out.ts",
760                "packages/b/types",
761                "packages/b/types/index.d.ts",
762                "packages/c/lib/one.js",
763            ]
764        );
765    }
766
767    /// A slash inside a character class does not separate path segments.
768    #[test]
769    fn character_classes_with_slashes_keep_nested_artifacts() {
770        let workspace = tempfile::tempdir().unwrap();
771        for name in ["a", "b", "c"] {
772            let directory = workspace.path().join(format!("apps/{name}/graphql/nested"));
773            std::fs::create_dir_all(&directory).unwrap();
774            std::fs::write(directory.join("output.txt"), "value").unwrap();
775        }
776        let outputs = Outputs::from_paths(&["apps/[a/b]/graphql".into()]).unwrap();
777        assert_eq!(
778            outputs
779                .paths(workspace.path())
780                .unwrap()
781                .into_iter()
782                .collect::<Vec<_>>(),
783            [
784                "apps/a/graphql",
785                "apps/a/graphql/nested",
786                "apps/a/graphql/nested/output.txt",
787                "apps/b/graphql",
788                "apps/b/graphql/nested",
789                "apps/b/graphql/nested/output.txt",
790            ]
791        );
792    }
793
794    #[test]
795    fn segment_traversal_preserves_output_sets() {
796        let workspace = tempfile::tempdir().unwrap();
797        for file in [
798            "apps/a/graphql/manifest.json",
799            "apps/a/graphql/nested/out.ts",
800            "apps/a/src/other.ts",
801            "apps/b/src/graphql/manifest.json",
802            "apps/.hidden/graphql/manifest.json",
803            "apps/a/file",
804            "dist/a.ts",
805            "dist/deep/b.ts",
806        ] {
807            let path = workspace.path().join(file);
808            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
809            std::fs::write(path, "value").unwrap();
810        }
811        for patterns in [
812            vec![
813                "apps/*/graphql/manifest.json",
814                "apps/*/*/graphql/manifest.json",
815            ],
816            vec!["apps/?/graphql", "!apps/a/graphql/nested"],
817            vec!["apps/a/graphql", "apps/*/graphql/*"],
818            vec!["apps/a/file/nested", "missing/*/file", "dist/*.ts"],
819            vec!["dist"],
820            vec!["apps/**/manifest.json"],
821            vec!["apps/[a/b]/graphql"],
822        ] {
823            let outputs =
824                Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
825                    .unwrap();
826            match (
827                outputs.paths(workspace.path()),
828                outputs.walk_paths(workspace.path()),
829            ) {
830                (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
831                (Err(actual), Err(expected)) => {
832                    assert_eq!(actual.to_string(), expected.to_string())
833                }
834                results => panic!("different results for {patterns:?}: {results:?}"),
835            }
836        }
837    }
838
839    #[cfg(unix)]
840    #[test]
841    fn segment_traversal_preserves_symlink_boundaries() {
842        let workspace = tempfile::tempdir().unwrap();
843        let root = workspace.path();
844        std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
845        std::fs::write(root.join("apps/a/graphql/manifest.json"), "value").unwrap();
846        std::os::unix::fs::symlink("a", root.join("apps/link")).unwrap();
847        std::os::unix::fs::symlink("absent", root.join("apps/dangling")).unwrap();
848        for patterns in [
849            vec!["apps/*/graphql/manifest.json"],
850            vec!["apps/*"],
851            vec!["apps/link"],
852            vec!["apps/dangling"],
853        ] {
854            let outputs =
855                Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
856                    .unwrap();
857            match (outputs.paths(root), outputs.walk_paths(root)) {
858                (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
859                (Err(actual), Err(expected)) => {
860                    assert_eq!(actual.to_string(), expected.to_string())
861                }
862                results => panic!("different results for {patterns:?}: {results:?}"),
863            }
864        }
865    }
866
867    #[test]
868    fn listings_recheck_metadata_and_directory_contents() {
869        let workspace = tempfile::tempdir().unwrap();
870        let root = workspace.path();
871        std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
872        std::fs::write(root.join("apps/a/graphql/manifest.json"), "one").unwrap();
873        let outputs = Outputs::from_paths(&["apps/*/graphql/manifest.json".into()]).unwrap();
874        assert_eq!(
875            outputs
876                .entries(root)
877                .unwrap()
878                .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
879                .unwrap()["apps/a/graphql/manifest.json"]
880                .len(),
881            3
882        );
883        std::fs::write(root.join("apps/a/graphql/manifest.json"), "different").unwrap();
884        std::fs::create_dir_all(root.join("apps/b/graphql")).unwrap();
885        std::fs::write(root.join("apps/b/graphql/manifest.json"), "two").unwrap();
886        let entries = outputs
887            .entries(root)
888            .unwrap()
889            .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
890            .unwrap();
891        assert_eq!(entries.len(), 2);
892        assert_eq!(entries["apps/a/graphql/manifest.json"].len(), 9);
893        std::fs::remove_dir_all(root.join("apps/a")).unwrap();
894        assert_eq!(
895            outputs
896                .entries(root)
897                .unwrap()
898                .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
899                .unwrap()
900                .len(),
901            1
902        );
903    }
904
905    #[test]
906    fn literal_segments_after_wildcards_remain_case_sensitive() {
907        let workspace = tempfile::tempdir().unwrap();
908        let root = workspace.path();
909        std::fs::create_dir_all(root.join("apps/a/GraphQL")).unwrap();
910        std::fs::write(root.join("apps/a/GraphQL/Manifest.json"), "one").unwrap();
911        for glob in [
912            "apps/*/graphql/Manifest.json",
913            "apps/*/GraphQL/manifest.json",
914            "apps/*/GraphQL/Manifest.json",
915            "apps/a/GraphQL",
916        ] {
917            let outputs = Outputs::from_paths(&[glob.into()]).unwrap();
918            assert_eq!(
919                outputs.paths(root).unwrap(),
920                outputs.walk_paths(root).unwrap(),
921                "{glob}"
922            );
923        }
924    }
925
926    #[test]
927    fn simple_glob_combinations_match_the_walker() {
928        let workspace = tempfile::tempdir().unwrap();
929        let root = workspace.path();
930        for project in ["a", "aa", "Ab", ".hidden", "café"] {
931            for directory in ["graphql", "GraphQL", "src"] {
932                let path = root.join(format!("apps/{project}/{directory}/nested"));
933                std::fs::create_dir_all(&path).unwrap();
934                std::fs::write(path.join("manifest.json"), "value").unwrap();
935                std::fs::write(path.parent().unwrap().join("manifest.json"), "value").unwrap();
936            }
937        }
938        for project in ["*", "?", "a*", "*a", "*?", "A*"] {
939            for directory in ["graphql", "GraphQL", "src", "missing"] {
940                for suffix in ["", "/manifest.json", "/*.json", "/?anifest.json", "/*"] {
941                    let glob = format!("apps/{project}/{directory}{suffix}");
942                    let outputs =
943                        Outputs::from_paths(&[glob.clone(), "!apps/aa/graphql/nested".into()])
944                            .unwrap();
945                    assert_eq!(
946                        outputs.paths(root).unwrap(),
947                        outputs.walk_paths(root).unwrap(),
948                        "{glob}"
949                    );
950                }
951            }
952        }
953    }
954
955    /// Only unconditional literal output roots may move as complete trees.
956    #[test]
957    fn complete_roots_do_not_widen_globs_exclusions_or_defaults() {
958        let mut outputs = Outputs::from_paths(&[
959            "dist".into(),
960            "dist/nested".into(),
961            "dist2".into(),
962            "partial/**/*.js".into(),
963        ])
964        .unwrap();
965        assert_eq!(outputs.complete_roots(), ["dist", "dist2"]);
966        outputs
967            .negations
968            .push(Pattern::new("dist/private", false).unwrap());
969        assert!(outputs.complete_roots().is_empty());
970        outputs.negations.clear();
971        outputs.explicit = false;
972        assert!(outputs.complete_roots().is_empty());
973    }
974
975    #[test]
976    fn normalizes_relative_segments() {
977        assert_eq!(
978            normalize("apps/mobile/test-e2e/../ios/build/*/App.app").unwrap(),
979            "apps/mobile/ios/build/*/App.app"
980        );
981        assert_eq!(normalize("./dist/./a").unwrap(), "dist/a");
982        assert_eq!(normalize(".").unwrap(), ".");
983        assert!(normalize("app/../../outside").is_err());
984        assert!(normalize("app/**/../x").is_err());
985    }
986}