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