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