Skip to main content

yapi_core/packages/
resolve.rs

1//! Every resource that settings, packages and the agent and project
2//! directories provide, each enabled or not, in load precedence order.
3//!
4//! Port of `DefaultPackageManager.resolve` and its pattern rules in
5//! `core/package-manager.ts` in pi `v1.0.0`. yapi also loads `.wasm` native
6//! extensions wherever pi loads `.ts` and `.js` ones.
7
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10
11use yapi_types::rpc::SourceInfo;
12use yapi_types::settings::FilteredPackage;
13
14use crate::chest::ChestError;
15use crate::chest::manifest::read_package_meta;
16use crate::glob;
17use crate::tools::path::{relative, resolve_lexically};
18
19/// A kind of resource.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
21pub enum ResourceType {
22    /// Extension entry files.
23    Extensions,
24    /// Skill files.
25    Skills,
26    /// Prompt template files.
27    Prompts,
28    /// Theme files.
29    Themes,
30}
31
32impl ResourceType {
33    /// Every kind, in pi's order.
34    pub const ALL: [ResourceType; 4] = [
35        ResourceType::Extensions,
36        ResourceType::Skills,
37        ResourceType::Prompts,
38        ResourceType::Themes,
39    ];
40
41    /// The settings and manifest key.
42    pub fn key(self) -> &'static str {
43        match self {
44            ResourceType::Extensions => "extensions",
45            ResourceType::Skills => "skills",
46            ResourceType::Prompts => "prompts",
47            ResourceType::Themes => "themes",
48        }
49    }
50
51    fn matches_file(self, name: &str) -> bool {
52        match self {
53            ResourceType::Extensions => is_extension_file(name),
54            ResourceType::Skills | ResourceType::Prompts => name.ends_with(".md"),
55            ResourceType::Themes => name.ends_with(".json"),
56        }
57    }
58}
59
60/// Whether file `name` is an extension yapi loads: pi's extension files,
61/// and yapi's native extensions.
62pub fn is_extension_file(name: &str) -> bool {
63    name.ends_with(".ts") || name.ends_with(".js") || name.ends_with(".wasm")
64}
65
66/// A resource and whether it loads.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct Resolved {
69    /// Its path and where it comes from.
70    pub info: SourceInfo,
71    /// Whether settings leave it on.
72    pub enabled: bool,
73}
74
75/// pi's `ResolvedPaths`: resolved resources by kind.
76#[derive(Clone, Debug, Default, PartialEq, Eq)]
77pub struct ResolvedPaths {
78    /// Extensions.
79    pub extensions: Vec<Resolved>,
80    /// Skills.
81    pub skills: Vec<Resolved>,
82    /// Prompt templates.
83    pub prompts: Vec<Resolved>,
84    /// Themes.
85    pub themes: Vec<Resolved>,
86    /// Extensions another source named again, each under that source,
87    /// which does not load them: by the same path, or by another path to
88    /// the same file.
89    pub shadowed: Vec<SourceInfo>,
90    /// Packages whose `chest.toml` does not parse, each by its folder with
91    /// why. Their resources resolve as if it were absent.
92    pub problems: Vec<(String, String)>,
93    /// What a project-only run keeps of the user's resources, while
94    /// [`resolve`] collects them.
95    pub(crate) keep: Option<Keep>,
96    /// The canonical paths of chest's resources so far, by kind, with
97    /// their place in the list, while [`resolve`] collects them.
98    pub(crate) chest_files: Vec<(ResourceType, PathBuf, usize)>,
99}
100
101/// [`ProjectOnly`] with the folders its patterns resolve against.
102#[derive(Clone, Debug, Default, PartialEq, Eq)]
103pub(crate) struct Keep {
104    only: ProjectOnly,
105    agent_dir: PathBuf,
106    home: PathBuf,
107}
108
109impl ResolvedPaths {
110    /// The resources of `kind`.
111    pub fn of(&self, kind: ResourceType) -> &[Resolved] {
112        match kind {
113            ResourceType::Extensions => &self.extensions,
114            ResourceType::Skills => &self.skills,
115            ResourceType::Prompts => &self.prompts,
116            ResourceType::Themes => &self.themes,
117        }
118    }
119
120    fn of_mut(&mut self, kind: ResourceType) -> &mut Vec<Resolved> {
121        match kind {
122            ResourceType::Extensions => &mut self.extensions,
123            ResourceType::Skills => &mut self.skills,
124            ResourceType::Prompts => &mut self.prompts,
125            ResourceType::Themes => &mut self.themes,
126        }
127    }
128
129    /// Adds the resource at `path` with `metadata` unless it is listed.
130    fn add(&mut self, kind: ResourceType, path: &Path, metadata: &SourceInfo, enabled: bool) {
131        let path = path.to_string_lossy().into_owned();
132        if path.is_empty() {
133            return;
134        }
135        let info = SourceInfo {
136            path,
137            ..metadata.clone()
138        };
139        // Before anything else, so a user's copy left out never shadows a
140        // project's copy of the same file.
141        if let Some(keep) = &self.keep
142            && info.scope == "user"
143            && info.source != "builtin"
144            && !keep.only.keeps(&info, kind, &keep.agent_dir, &keep.home)
145        {
146            return;
147        }
148        // A chest entry's file also takes the other spellings of its path,
149        // such as discovery's through a linked folder. One lookup a resource.
150        let chest = info.source.starts_with("chest:");
151        let canonical = (chest || self.chest_files.iter().any(|(other, ..)| *other == kind))
152            .then(|| std::fs::canonicalize(&info.path).ok())
153            .flatten();
154        let same_file = canonical.as_ref().and_then(|file| {
155            let mut files = self.chest_files.iter();
156            let found = files.find(|(other, path, _)| *other == kind && path == file);
157            found.map(|(_, _, index)| *index)
158        });
159        let list = self.of_mut(kind);
160        let listed = list
161            .iter()
162            .position(|resource| resource.info.path == info.path)
163            .or(same_file);
164        if let Some(index) = listed {
165            let listed = &list[index].info;
166            let other = (&listed.source, &listed.scope) != (&info.source, &info.scope);
167            if kind == ResourceType::Extensions && other {
168                self.shadowed.push(info);
169            }
170            return;
171        }
172        let index = list.len();
173        list.push(Resolved { info, enabled });
174        if let (true, Some(file)) = (chest, canonical) {
175            self.chest_files.push((kind, file, index));
176        }
177    }
178
179    /// The enabled resources of `kind`, in order.
180    pub fn enabled(&self, kind: ResourceType) -> impl Iterator<Item = &SourceInfo> {
181        self.of(kind)
182            .iter()
183            .filter(|resource| resource.enabled)
184            .map(|resource| &resource.info)
185    }
186}
187
188/// A package to collect from, after pi's deduplication.
189#[derive(Clone, Debug, PartialEq)]
190pub struct PackageInput {
191    /// The source as written in settings.
192    pub source: String,
193    /// `user`, `project` or `temporary`.
194    pub scope: String,
195    /// Where it is installed: a directory, or a file for a local file.
196    pub root: PathBuf,
197    /// The settings entry's filters, when it is an object.
198    pub filter: Option<FilteredPackage>,
199}
200
201/// What [`resolve`] reads.
202#[derive(Clone, Debug, Default)]
203pub struct ResolveInput<'a> {
204    /// The project's directory, whose ancestors' `.agents/skills` are
205    /// project skills: the working directory unless the run names another.
206    pub cwd: PathBuf,
207    /// The agent directory.
208    pub agent_dir: PathBuf,
209    /// The project's directory of yapi files, `<cwd>/.yapi`.
210    pub project_dir: PathBuf,
211    /// The home directory, for `~/.agents/skills`.
212    pub home: PathBuf,
213    /// Whether the project's own resources load.
214    pub project_trusted: bool,
215    /// Installed packages, project entries first.
216    pub packages: Vec<PackageInput>,
217    /// Skill folders a package manager placed, each one skill of the
218    /// package its source and scope name.
219    pub skills: Vec<PackageInput>,
220    /// The user's `extensions`, `skills`, `prompts` and `themes` settings.
221    pub user: [&'a [String]; 4],
222    /// The project's.
223    pub project: [&'a [String]; 4],
224    /// Built-in extension names.
225    pub builtins: &'a [&'a str],
226    /// In a project-only run, what it keeps of the user's resources. The
227    /// user's others are left out.
228    pub project_only: Option<ProjectOnly>,
229}
230
231/// The path prefix naming a built-in extension.
232pub const BUILTIN_PREFIX: &str = "builtin:";
233
234fn to_posix(path: &str) -> String {
235    path.replace('\\', "/")
236}
237
238fn base_name(path: &Path) -> String {
239    path.file_name()
240        .map(|name| name.to_string_lossy().into_owned())
241        .unwrap_or_default()
242}
243
244/// pi's `matchesAnyPattern`: a glob against the path relative to `base`,
245/// the file name or the whole path, and for `SKILL.md` its directory too.
246fn matches_any_pattern(file: &Path, patterns: &[String], base: &Path) -> bool {
247    let rel = relative(base, file);
248    let name = base_name(file);
249    let whole = to_posix(&file.to_string_lossy());
250    let parent = (name == "SKILL.md").then(|| file.parent()).flatten();
251    patterns.iter().any(|pattern| {
252        let pattern = to_posix(pattern);
253        // minimatch keeps a leading `./`, which no relative path, name or
254        // absolute path has.
255        if pattern.starts_with("./") {
256            return false;
257        }
258        if glob::matches(&pattern, &rel)
259            || glob::matches(&pattern, &name)
260            || glob::matches(&pattern, &whole)
261        {
262            return true;
263        }
264        parent.is_some_and(|parent| {
265            glob::matches(&pattern, &relative(base, parent))
266                || glob::matches(&pattern, &base_name(parent))
267                || glob::matches(&pattern, &to_posix(&parent.to_string_lossy()))
268        })
269    })
270}
271
272/// pi's `matchesAnyExactPattern`: the path relative to `base` or the whole
273/// path, without a leading `./`, and for `SKILL.md` its directory too.
274fn matches_any_exact_pattern(file: &Path, patterns: &[String], base: &Path) -> bool {
275    if patterns.is_empty() {
276        return false;
277    }
278    let rel = relative(base, file);
279    let whole = to_posix(&file.to_string_lossy());
280    let parent = (base_name(file) == "SKILL.md")
281        .then(|| file.parent())
282        .flatten();
283    patterns.iter().any(|pattern| {
284        let pattern = pattern
285            .strip_prefix("./")
286            .or_else(|| pattern.strip_prefix(".\\"))
287            .unwrap_or(pattern);
288        let pattern = to_posix(pattern);
289        if pattern == rel || pattern == whole {
290            return true;
291        }
292        parent.is_some_and(|parent| {
293            pattern == relative(base, parent) || pattern == to_posix(&parent.to_string_lossy())
294        })
295    })
296}
297
298fn is_override(entry: &str) -> bool {
299    entry.starts_with(['!', '+', '-'])
300}
301
302fn is_pattern(entry: &str) -> bool {
303    is_override(entry) || entry.contains(['*', '?'])
304}
305
306fn has_glob(entry: &str) -> bool {
307    entry.contains(['*', '?'])
308}
309
310fn stripped<'a>(entries: impl Iterator<Item = &'a String>, sign: char) -> Vec<String> {
311    entries
312        .filter_map(|entry| entry.strip_prefix(sign).map(str::to_owned))
313        .collect()
314}
315
316/// pi's `isEnabledByOverrides`: `!` globs disable, then `+` paths enable,
317/// then `-` paths disable.
318pub fn is_enabled_by_overrides(file: &Path, entries: &[String], base: &Path) -> bool {
319    let excludes = stripped(entries.iter(), '!');
320    let force_includes = stripped(entries.iter(), '+');
321    let force_excludes = stripped(entries.iter(), '-');
322    let mut enabled = true;
323    if !excludes.is_empty() && matches_any_pattern(file, &excludes, base) {
324        enabled = false;
325    }
326    if matches_any_exact_pattern(file, &force_includes, base) {
327        enabled = true;
328    }
329    if matches_any_exact_pattern(file, &force_excludes, base) {
330        enabled = false;
331    }
332    enabled
333}
334
335/// pi's `applyPatterns`: plain globs select (all when there are none), `!`
336/// globs remove, `+` paths add back and `-` paths remove.
337pub fn apply_patterns(all: &[PathBuf], patterns: &[String], base: &Path) -> HashSet<PathBuf> {
338    let includes: Vec<String> = patterns
339        .iter()
340        .filter(|pattern| !is_override(pattern))
341        .cloned()
342        .collect();
343    let excludes = stripped(patterns.iter(), '!');
344    let force_includes = stripped(patterns.iter(), '+');
345    let force_excludes = stripped(patterns.iter(), '-');
346    let mut result: Vec<PathBuf> = if includes.is_empty() {
347        all.to_vec()
348    } else {
349        all.iter()
350            .filter(|file| matches_any_pattern(file, &includes, base))
351            .cloned()
352            .collect()
353    };
354    if !excludes.is_empty() {
355        result.retain(|file| !matches_any_pattern(file, &excludes, base));
356    }
357    for file in all {
358        if !result.contains(file) && matches_any_exact_pattern(file, &force_includes, base) {
359            result.push(file.clone());
360        }
361    }
362    if !force_excludes.is_empty() {
363        result.retain(|file| !matches_any_exact_pattern(file, &force_excludes, base));
364    }
365    result.into_iter().collect()
366}
367
368/// pi's `applyAutoloadDisabledPatterns`: each pattern turns the files it
369/// names on (`+`, plain) or off (`-`, `!`), the last one deciding.
370fn apply_autoload_disabled_patterns(
371    all: &[PathBuf],
372    patterns: &[String],
373    base: &Path,
374) -> Vec<(PathBuf, bool)> {
375    let mut result: Vec<(PathBuf, bool)> = Vec::new();
376    for pattern in patterns {
377        let target = if is_override(pattern) {
378            pattern[1..].to_owned()
379        } else {
380            pattern.clone()
381        };
382        let enabled = !pattern.starts_with(['-', '!']);
383        let exact = pattern.starts_with(['+', '-']);
384        for file in all {
385            let found = if exact {
386                matches_any_exact_pattern(file, std::slice::from_ref(&target), base)
387            } else {
388                matches_any_pattern(file, std::slice::from_ref(&target), base)
389            };
390            if found {
391                match result.iter_mut().find(|(path, _)| path == file) {
392                    Some(entry) => entry.1 = enabled,
393                    None => result.push((file.clone(), enabled)),
394                }
395            }
396        }
397    }
398    result
399}
400
401/// The rules of `.gitignore`, `.ignore` and `.fdignore` files met while
402/// walking a directory, as a subset of the `ignore` package pi uses.
403#[derive(Clone, Debug, Default)]
404struct Ignore {
405    rules: Vec<(String, bool)>,
406}
407
408impl Ignore {
409    /// pi's `addIgnoreRules`: the rules in `dir`, prefixed with its path
410    /// relative to `root`.
411    fn add_dir(&mut self, dir: &Path, root: &Path) {
412        let rel = relative(root, dir);
413        let prefix = if rel.is_empty() {
414            String::new()
415        } else {
416            format!("{rel}/")
417        };
418        for name in [".gitignore", ".ignore", ".fdignore"] {
419            let Ok(text) = std::fs::read_to_string(dir.join(name)) else {
420                continue;
421            };
422            for line in text.lines() {
423                let trimmed = line.trim();
424                if trimmed.is_empty() || (trimmed.starts_with('#') && !trimmed.starts_with("\\#")) {
425                    continue;
426                }
427                let (mut pattern, negated) = match line.strip_prefix('!') {
428                    Some(rest) => (rest, true),
429                    None => (
430                        line.strip_prefix('\\')
431                            .filter(|rest| rest.starts_with('!'))
432                            .unwrap_or(line),
433                        false,
434                    ),
435                };
436                pattern = pattern.strip_prefix('/').unwrap_or(pattern);
437                self.rules
438                    .push((format!("{prefix}{}", pattern.trim_end()), negated));
439            }
440        }
441    }
442
443    /// Whether `path`, relative to the walk's root and ending in `/` for a
444    /// directory, is ignored.
445    fn ignores(&self, path: &str) -> bool {
446        let dir = path.ends_with('/');
447        let path = path.trim_end_matches('/');
448        let mut ignored = false;
449        for (pattern, negated) in &self.rules {
450            if rule_matches(pattern, path, dir) {
451                ignored = !negated;
452            }
453        }
454        ignored
455    }
456}
457
458/// One rule against a relative path. A rule with a `/` is anchored to the
459/// root, and one without matches a name at any depth. Walks skip dot entries
460/// before asking, so wildcards need not match them.
461fn rule_matches(pattern: &str, path: &str, dir: bool) -> bool {
462    let (pattern, dir_only) = match pattern.strip_suffix('/') {
463        Some(pattern) => (pattern, true),
464        None => (pattern, false),
465    };
466    if dir_only && !dir {
467        return false;
468    }
469    if pattern.contains('/') {
470        glob::matches(pattern, path)
471    } else {
472        glob::matches(pattern, path.rsplit('/').next().unwrap_or(path))
473    }
474}
475
476struct Entry {
477    path: PathBuf,
478    is_dir: bool,
479    is_file: bool,
480    name: String,
481}
482
483/// A directory's entries, sorted by name, following symbolic links.
484fn read_dir(dir: &Path) -> Vec<Entry> {
485    let Ok(entries) = std::fs::read_dir(dir) else {
486        return Vec::new();
487    };
488    let mut out: Vec<Entry> = entries
489        .flatten()
490        .filter_map(|entry| {
491            let path = entry.path();
492            let metadata = std::fs::metadata(&path).ok()?;
493            Some(Entry {
494                name: entry.file_name().to_string_lossy().into_owned(),
495                is_dir: metadata.is_dir(),
496                is_file: metadata.is_file(),
497                path,
498            })
499        })
500        .collect();
501    out.sort_by(|a, b| a.name.cmp(&b.name));
502    out
503}
504
505/// pi's `collectFiles`: files of `kind` under `dir`, recursively, skipping
506/// dot entries, `node_modules` and ignored paths.
507fn collect_files(dir: &Path, kind: ResourceType, ignore: &mut Ignore, root: &Path) -> Vec<PathBuf> {
508    let mut files = Vec::new();
509    if !dir.exists() {
510        return files;
511    }
512    ignore.add_dir(dir, root);
513    for entry in read_dir(dir) {
514        if entry.name.starts_with('.') || entry.name == "node_modules" {
515            continue;
516        }
517        let rel = relative(root, &entry.path);
518        let probe = if entry.is_dir { format!("{rel}/") } else { rel };
519        if ignore.ignores(&probe) {
520            continue;
521        }
522        if entry.is_dir {
523            files.extend(collect_files(&entry.path, kind, ignore, root));
524        } else if entry.is_file && kind.matches_file(&entry.name) {
525            files.push(entry.path);
526        }
527    }
528    files
529}
530
531/// Where skills are discovered: pi's own directories take `.md` files at the
532/// top, `.agents` directories only below it.
533#[derive(Clone, Copy, PartialEq, Eq)]
534enum SkillMode {
535    Pi,
536    Agents,
537}
538
539/// pi's `collectSkillEntries`: a directory with a `SKILL.md` is one skill.
540/// Otherwise, its `.md` files (by mode) and the skills of its directories.
541fn collect_skill_entries(
542    dir: &Path,
543    mode: SkillMode,
544    ignore: &mut Ignore,
545    root: &Path,
546) -> Vec<PathBuf> {
547    let mut entries = Vec::new();
548    if !dir.exists() {
549        return entries;
550    }
551    ignore.add_dir(dir, root);
552    let listing = read_dir(dir);
553    if let Some(skill) = listing.iter().find(|entry| entry.name == "SKILL.md")
554        && skill.is_file
555        && !ignore.ignores(&relative(root, &skill.path))
556    {
557        entries.push(skill.path.clone());
558        return entries;
559    }
560    for entry in listing {
561        if entry.name.starts_with('.') || entry.name == "node_modules" {
562            continue;
563        }
564        let rel = relative(root, &entry.path);
565        let markdown = entry.is_file
566            && entry.name.ends_with(".md")
567            && !ignore.ignores(&rel)
568            && ((mode == SkillMode::Pi && dir == root)
569                || (mode == SkillMode::Agents && dir != root));
570        if markdown {
571            entries.push(entry.path);
572            continue;
573        }
574        if !entry.is_dir || ignore.ignores(&format!("{rel}/")) {
575            continue;
576        }
577        entries.extend(collect_skill_entries(&entry.path, mode, ignore, root));
578    }
579    entries
580}
581
582/// pi's `collectAutoPromptEntries` and `collectAutoThemeEntries`: the files
583/// of `kind` directly in `dir`.
584fn collect_top_files(dir: &Path, kind: ResourceType) -> Vec<PathBuf> {
585    if !dir.exists() {
586        return Vec::new();
587    }
588    let mut ignore = Ignore::default();
589    ignore.add_dir(dir, dir);
590    read_dir(dir)
591        .into_iter()
592        .filter(|entry| !entry.name.starts_with('.') && entry.name != "node_modules")
593        .filter(|entry| !ignore.ignores(&relative(dir, &entry.path)))
594        .filter(|entry| entry.is_file && kind.matches_file(&entry.name))
595        .map(|entry| entry.path)
596        .collect()
597}
598
599/// pi's `collectAutoExtensionEntries`: a directory's own entry points, else
600/// its extension files and the entry points of its directories.
601fn collect_auto_extension_entries(dir: &Path) -> Vec<PathBuf> {
602    if !dir.exists() {
603        return Vec::new();
604    }
605    if let Some(entries) = crate::extensions::discovery::entries(dir) {
606        return entries;
607    }
608    let mut ignore = Ignore::default();
609    ignore.add_dir(dir, dir);
610    let mut entries = Vec::new();
611    for entry in read_dir(dir) {
612        if entry.name.starts_with('.') || entry.name == "node_modules" {
613            continue;
614        }
615        let rel = relative(dir, &entry.path);
616        let probe = if entry.is_dir { format!("{rel}/") } else { rel };
617        if ignore.ignores(&probe) {
618            continue;
619        }
620        if entry.is_file && is_extension_file(&entry.name) {
621            entries.push(entry.path);
622        } else if entry.is_dir {
623            entries.extend(crate::extensions::discovery::entries(&entry.path).unwrap_or_default());
624        }
625    }
626    entries
627}
628
629/// pi's `collectResourceFiles`.
630fn collect_resource_files(dir: &Path, kind: ResourceType) -> Vec<PathBuf> {
631    match kind {
632        ResourceType::Skills => {
633            collect_skill_entries(dir, SkillMode::Pi, &mut Ignore::default(), dir)
634        }
635        ResourceType::Extensions => collect_auto_extension_entries(dir),
636        _ => collect_files(dir, kind, &mut Ignore::default(), dir),
637    }
638}
639
640/// pi's `collectFilesFromPaths`: files as they are, directories collected.
641fn collect_files_from_paths(paths: &[PathBuf], kind: ResourceType) -> Vec<PathBuf> {
642    let mut files = Vec::new();
643    for path in paths {
644        let Ok(metadata) = std::fs::metadata(path) else {
645            continue;
646        };
647        if metadata.is_file() {
648            files.push(path.clone());
649        } else if metadata.is_dir() {
650            files.extend(collect_resource_files(path, kind));
651        }
652    }
653    files
654}
655
656/// pi's `readPiManifest`, extended kind by kind: each resource list comes
657/// from the package's `chest.toml` `[package]`, else its `package.json`
658/// `yapi` key, else its `pi` key, as [`read_package_meta`] reads them. A list is
659/// `None` when no source lists the kind.
660#[derive(Default)]
661pub(crate) struct Manifest {
662    lists: [Option<Vec<String>>; 4],
663}
664
665impl Manifest {
666    /// The manifest of the package at `root`, or `None` when no source
667    /// decides its resources, so the conventional folders apply. With it, why
668    /// its `chest.toml` was left out, as [`read_package_meta`] says.
669    pub(crate) fn read(root: &Path) -> (Option<Manifest>, Option<ChestError>) {
670        let (meta, problem) = read_package_meta(root);
671        let lists = ResourceType::ALL.map(|kind| meta.resources.get(kind.key()).cloned());
672        (meta.manifest.then_some(Manifest { lists }), problem)
673    }
674
675    /// The entries of `kind`, when listed.
676    pub(crate) fn get(&self, kind: ResourceType) -> Option<&Vec<String>> {
677        self.lists[kind as usize].as_ref()
678    }
679}
680
681/// pi's `expandPackageGlob`: matches under `root` with no dot segment,
682/// sorted.
683fn expand_package_glob(pattern: &str, root: &Path) -> Vec<PathBuf> {
684    fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
685        for entry in read_dir(dir) {
686            let rel = relative(root, &entry.path);
687            out.push(rel);
688            if entry.is_dir && entry.name != "node_modules" && !entry.name.starts_with('.') {
689                walk(root, &entry.path, out);
690            }
691        }
692    }
693    let mut all = Vec::new();
694    walk(root, root, &mut all);
695    let mut found: Vec<PathBuf> = all
696        .into_iter()
697        .filter(|rel| rel.split('/').all(|segment| !segment.starts_with('.')))
698        .filter(|rel| glob::matches(pattern, rel))
699        .map(|rel| root.join(rel))
700        .collect();
701    found.sort();
702    found
703}
704
705/// pi's `collectFilesFromManifestEntries`.
706fn manifest_files(entries: &[String], root: &Path, kind: ResourceType) -> Vec<PathBuf> {
707    let resolved: Vec<PathBuf> = entries
708        .iter()
709        .filter(|entry| !is_override(entry))
710        .flat_map(|entry| {
711            if has_glob(entry) {
712                expand_package_glob(entry, root)
713            } else {
714                vec![resolve_lexically(root, Path::new(entry))]
715            }
716        })
717        .collect();
718    collect_files_from_paths(&resolved, kind)
719}
720
721fn metadata(source: &str, scope: &str, origin: &str, base_dir: Option<&Path>) -> SourceInfo {
722    SourceInfo {
723        path: String::new(),
724        source: source.to_owned(),
725        scope: scope.to_owned(),
726        origin: origin.to_owned(),
727        base_dir: base_dir.map(|dir| dir.to_string_lossy().into_owned()),
728    }
729}
730
731/// pi's `collectPackageResources`: whether the package provided anything.
732fn collect_package_resources(
733    root: &Path,
734    acc: &mut ResolvedPaths,
735    filter: Option<&FilteredPackage>,
736    meta: &SourceInfo,
737) -> bool {
738    let (manifest, problem) = Manifest::read(root);
739    if let Some(err) = problem {
740        let folder = root.to_string_lossy().into_owned();
741        acc.problems.push((folder, err.to_string()));
742    }
743    if let Some(filter) = filter {
744        for kind in ResourceType::ALL {
745            let patterns = match kind {
746                ResourceType::Extensions => &filter.extensions,
747                ResourceType::Skills => &filter.skills,
748                ResourceType::Prompts => &filter.prompts,
749                ResourceType::Themes => &filter.themes,
750            };
751            if filter.autoload == Some(false) {
752                let patterns = patterns.clone().unwrap_or_default();
753                if patterns.is_empty() {
754                    continue;
755                }
756                let all = package_files(root, manifest.as_ref(), kind);
757                for (file, enabled) in apply_autoload_disabled_patterns(&all, &patterns, root) {
758                    acc.add(kind, &file, meta, enabled);
759                }
760            } else if let Some(patterns) = patterns {
761                let all = package_files(root, manifest.as_ref(), kind);
762                let enabled = if patterns.is_empty() {
763                    HashSet::new()
764                } else {
765                    apply_patterns(&all, patterns, root)
766                };
767                for file in &all {
768                    acc.add(kind, file, meta, enabled.contains(file));
769                }
770            } else {
771                default_resources(root, manifest.as_ref(), kind, acc, meta);
772            }
773        }
774        return true;
775    }
776    if let Some(manifest) = &manifest {
777        for kind in ResourceType::ALL {
778            if let Some(entries) = manifest.get(kind) {
779                manifest_entries(entries, root, kind, acc, meta);
780            }
781        }
782        return true;
783    }
784    let mut any = false;
785    for kind in ResourceType::ALL {
786        let dir = root.join(kind.key());
787        if dir.exists() {
788            for file in collect_resource_files(&dir, kind) {
789                acc.add(kind, &file, meta, true);
790            }
791            any = true;
792        }
793    }
794    any
795}
796
797/// pi's `collectDefaultResources`.
798fn default_resources(
799    root: &Path,
800    manifest: Option<&Manifest>,
801    kind: ResourceType,
802    acc: &mut ResolvedPaths,
803    meta: &SourceInfo,
804) {
805    if let Some(entries) = manifest.and_then(|manifest| manifest.get(kind)) {
806        manifest_entries(entries, root, kind, acc, meta);
807        return;
808    }
809    let dir = root.join(kind.key());
810    if dir.exists() {
811        for file in collect_resource_files(&dir, kind) {
812            acc.add(kind, &file, meta, true);
813        }
814    }
815}
816
817/// pi's `collectManifestFiles`: the files the manifest's entries select,
818/// or the conventional directory's.
819fn package_files(root: &Path, manifest: Option<&Manifest>, kind: ResourceType) -> Vec<PathBuf> {
820    let entries = manifest.and_then(|manifest| manifest.get(kind));
821    selected_files(root, entries.map(Vec::as_slice), kind.key(), kind)
822}
823
824/// The files of `kind` that the package at `root` provides: those its
825/// manifest `entries` select, with their globs and `!` patterns, else
826/// those in its folder `folder`.
827pub(crate) fn selected_files(
828    root: &Path,
829    entries: Option<&[String]>,
830    folder: &str,
831    kind: ResourceType,
832) -> Vec<PathBuf> {
833    if let Some(entries) = entries.filter(|entries| !entries.is_empty()) {
834        let all = manifest_files(entries, root, kind);
835        let patterns: Vec<String> = entries
836            .iter()
837            .filter(|entry| is_override(entry))
838            .cloned()
839            .collect();
840        if patterns.is_empty() {
841            return all;
842        }
843        let enabled = apply_patterns(&all, &patterns, root);
844        return all
845            .into_iter()
846            .filter(|file| enabled.contains(file))
847            .collect();
848    }
849    let dir = root.join(folder);
850    if dir.exists() {
851        collect_resource_files(&dir, kind)
852    } else {
853        Vec::new()
854    }
855}
856
857/// pi's `addManifestEntries`: the enabled files only.
858fn manifest_entries(
859    entries: &[String],
860    root: &Path,
861    kind: ResourceType,
862    acc: &mut ResolvedPaths,
863    meta: &SourceInfo,
864) {
865    let all = manifest_files(entries, root, kind);
866    let patterns: Vec<String> = entries
867        .iter()
868        .filter(|entry| is_override(entry))
869        .cloned()
870        .collect();
871    let enabled = apply_patterns(&all, &patterns, root);
872    for file in &all {
873        if enabled.contains(file) {
874            acc.add(kind, file, meta, true);
875        }
876    }
877}
878
879/// pi's `resolveLocalEntries`: settings entries as paths, with their
880/// patterns deciding which files are on.
881fn local_entries(
882    entries: &[String],
883    kind: ResourceType,
884    acc: &mut ResolvedPaths,
885    meta: &SourceInfo,
886    base: &Path,
887) {
888    if entries.is_empty() {
889        return;
890    }
891    let (patterns, plain): (Vec<String>, Vec<String>) =
892        entries.iter().cloned().partition(|entry| is_pattern(entry));
893    let resolved: Vec<PathBuf> = plain
894        .iter()
895        .map(|entry| resolve_lexically(base, Path::new(&crate::tools::path::expand(entry.trim()))))
896        .collect();
897    let all = collect_files_from_paths(&resolved, kind);
898    let enabled = apply_patterns(&all, &patterns, base);
899    for file in &all {
900        acc.add(kind, file, meta, enabled.contains(file));
901    }
902}
903
904/// pi's `collectAncestorAgentsSkillDirs`: `.agents/skills` in `start` and
905/// each parent up to the repository root, or the filesystem root.
906fn ancestor_agents_skill_dirs(start: &Path) -> Vec<PathBuf> {
907    let repo = start
908        .ancestors()
909        .find(|dir| dir.join(".git").exists())
910        .map(Path::to_path_buf);
911    let mut dirs = Vec::new();
912    for dir in start.ancestors() {
913        dirs.push(dir.join(".agents").join("skills"));
914        if repo.as_deref() == Some(dir) {
915            break;
916        }
917    }
918    dirs
919}
920
921/// pi's `addAutoDiscoveredResources`: the project's resources when it is
922/// trusted, then the user's, each scope with its extensions, skills,
923/// `.agents` skills, prompts and themes.
924fn auto_discovered(input: &ResolveInput<'_>, acc: &mut ResolvedPaths) {
925    let user_agents = input.home.join(".agents").join("skills");
926    let project_agents: Vec<PathBuf> = if input.project_trusted {
927        ancestor_agents_skill_dirs(&input.cwd)
928            .into_iter()
929            .filter(|dir| *dir != user_agents)
930            .collect()
931    } else {
932        Vec::new()
933    };
934    let scopes = [
935        (
936            "project",
937            &input.project_dir,
938            input.project,
939            input.project_trusted,
940            project_agents,
941        ),
942        (
943            "user",
944            &input.agent_dir,
945            input.user,
946            true,
947            vec![user_agents],
948        ),
949    ];
950    for (scope, base, overrides, own, agents) in scopes {
951        let mut add = |kind: ResourceType, paths: Vec<PathBuf>, base: &Path| {
952            let meta = metadata("auto", scope, "top-level", Some(base));
953            for path in paths {
954                let enabled = is_enabled_by_overrides(&path, overrides[kind as usize], base);
955                acc.add(kind, &path, &meta, enabled);
956            }
957        };
958        for kind in ResourceType::ALL {
959            let dir = base.join(kind.key());
960            if own {
961                let paths = match kind {
962                    ResourceType::Extensions => collect_auto_extension_entries(&dir),
963                    ResourceType::Skills => {
964                        collect_skill_entries(&dir, SkillMode::Pi, &mut Ignore::default(), &dir)
965                    }
966                    _ => collect_top_files(&dir, kind),
967                };
968                add(kind, paths, base);
969            }
970            if kind == ResourceType::Skills {
971                for dir in &agents {
972                    let paths =
973                        collect_skill_entries(dir, SkillMode::Agents, &mut Ignore::default(), dir);
974                    add(kind, paths, dir.parent().unwrap_or(dir));
975                }
976            }
977        }
978    }
979}
980
981/// pi's `resourcePrecedenceRank`, with chest's entries ranked as their
982/// scope's discovered folders, which hold their placed skills: collected
983/// first, they win the names they share there.
984fn precedence(info: &SourceInfo) -> u8 {
985    if info.source == "builtin" {
986        return 5;
987    }
988    if info.origin == "package" && !info.source.starts_with("chest:") {
989        return 4;
990    }
991    let scope = if info.scope == "project" { 0 } else { 2 };
992    scope + u8::from(info.source != "local")
993}
994
995/// One package's resources, installed at `root`: a file is one extension,
996/// and a folder gives what its manifest or resource folders list. pi takes a
997/// folder with neither as one extension only when it is a `local` path.
998fn collect_package(
999    acc: &mut ResolvedPaths,
1000    root: &Path,
1001    filter: Option<&FilteredPackage>,
1002    local: bool,
1003    mut meta: SourceInfo,
1004) {
1005    if root.is_file() {
1006        meta.base_dir = root.parent().map(|dir| dir.to_string_lossy().into_owned());
1007        acc.add(ResourceType::Extensions, root, &meta, true);
1008        return;
1009    }
1010    if !root.is_dir() {
1011        return;
1012    }
1013    meta.base_dir = Some(root.to_string_lossy().into_owned());
1014    if !collect_package_resources(root, acc, filter, &meta) && local {
1015        for entry in crate::extensions::discovery::entries(root).unwrap_or_default() {
1016            acc.add(ResourceType::Extensions, &entry, &meta, true);
1017        }
1018    }
1019}
1020
1021/// The resources of the package installed at `root`, as [`resolve`] finds
1022/// them for a settings entry with `filter`, each enabled or not. `local`
1023/// says whether its source is a local path.
1024pub fn package_resources(
1025    root: &Path,
1026    filter: Option<&FilteredPackage>,
1027    local: bool,
1028) -> ResolvedPaths {
1029    let mut acc = ResolvedPaths::default();
1030    collect_package(
1031        &mut acc,
1032        root,
1033        filter,
1034        local,
1035        metadata("local", "user", "package", None),
1036    );
1037    acc
1038}
1039
1040/// pi's `resolve`: packages, then settings entries, then discovered files,
1041/// then built-in extensions, ordered by precedence without repeats.
1042pub fn resolve(input: &ResolveInput<'_>) -> ResolvedPaths {
1043    let mut acc = ResolvedPaths {
1044        keep: input.project_only.clone().map(|only| Keep {
1045            only,
1046            agent_dir: input.agent_dir.clone(),
1047            home: input.home.clone(),
1048        }),
1049        ..ResolvedPaths::default()
1050    };
1051    // The project's packages and skills come before the user's, whichever
1052    // manager installed them. Settings list the project's first already.
1053    for project in [true, false] {
1054        let ours = |input: &&PackageInput| (input.scope == "project") == project;
1055        for package in input.packages.iter().filter(ours) {
1056            collect_package(
1057                &mut acc,
1058                &package.root,
1059                package.filter.as_ref(),
1060                super::source::is_local(&package.source),
1061                metadata(&package.source, &package.scope, "package", None),
1062            );
1063        }
1064        for skill in input.skills.iter().filter(ours) {
1065            let meta = metadata(&skill.source, &skill.scope, "package", None);
1066            // By its file, as discovery names a skill folder.
1067            let file = skill.root.join("SKILL.md");
1068            let path = if file.is_file() { &file } else { &skill.root };
1069            acc.add(ResourceType::Skills, path, &meta, true);
1070        }
1071    }
1072    for (index, kind) in ResourceType::ALL.into_iter().enumerate() {
1073        let project_meta = metadata("local", "project", "top-level", None);
1074        let user_meta = metadata("local", "user", "top-level", None);
1075        local_entries(
1076            input.project[index],
1077            kind,
1078            &mut acc,
1079            &project_meta,
1080            &input.project_dir,
1081        );
1082        local_entries(
1083            input.user[index],
1084            kind,
1085            &mut acc,
1086            &user_meta,
1087            &input.agent_dir,
1088        );
1089    }
1090    auto_discovered(input, &mut acc);
1091    let [user_ext, ..] = input.user;
1092    let [project_ext, ..] = input.project;
1093    for name in input.builtins {
1094        let path = PathBuf::from(format!("{BUILTIN_PREFIX}{name}"));
1095        let overrides: Vec<String> = project_ext
1096            .iter()
1097            .filter(|entry| is_override(entry))
1098            .cloned()
1099            .collect();
1100        let project = apply_autoload_disabled_patterns(
1101            std::slice::from_ref(&path),
1102            &overrides,
1103            &input.project_dir,
1104        )
1105        .into_iter()
1106        .next()
1107        .map(|(_, enabled)| enabled);
1108        let meta = metadata(
1109            "builtin",
1110            if project.is_some() { "project" } else { "user" },
1111            "top-level",
1112            None,
1113        );
1114        let enabled =
1115            project.unwrap_or_else(|| is_enabled_by_overrides(&path, user_ext, &input.agent_dir));
1116        acc.add(ResourceType::Extensions, &path, &meta, enabled);
1117    }
1118    let mut shadowed = Vec::new();
1119    for kind in ResourceType::ALL {
1120        let list = acc.of_mut(kind);
1121        list.sort_by_key(|resource| precedence(&resource.info));
1122        let mut seen = HashSet::new();
1123        list.retain(|resource| {
1124            let canonical = std::fs::canonicalize(&resource.info.path)
1125                .unwrap_or_else(|_| PathBuf::from(&resource.info.path));
1126            let first = seen.insert(canonical);
1127            if !first && kind == ResourceType::Extensions {
1128                shadowed.push(resource.info.clone());
1129            }
1130            first
1131        });
1132    }
1133    acc.shadowed.extend(shadowed);
1134    acc.keep = None;
1135    acc.chest_files.clear();
1136    acc
1137}
1138
1139/// The global items a run under `--project-only` still loads: the
1140/// `projectOnly` setting of the user's settings.
1141#[derive(Clone, Debug, Default, PartialEq, Eq)]
1142pub struct ProjectOnly {
1143    /// Global packages kept whole, by source, `chest:<name>` or `*`.
1144    pub packages: Vec<String>,
1145    /// Patterns of global resources kept, by kind, in [`ResourceType::ALL`]
1146    /// order.
1147    pub kinds: [Vec<String>; 4],
1148    /// Global MCP servers kept, by name pattern.
1149    pub mcp_servers: Vec<String>,
1150}
1151
1152impl ProjectOnly {
1153    /// The `projectOnly` object of the user's settings `document`.
1154    pub fn from_settings(document: &serde_json::Map<String, serde_json::Value>) -> ProjectOnly {
1155        let empty = serde_json::Map::new();
1156        let object = document
1157            .get("projectOnly")
1158            .and_then(serde_json::Value::as_object)
1159            .unwrap_or(&empty);
1160        let list = |key: &str| string_list(object, key);
1161        // A folder written with a trailing `/` names the folder.
1162        let patterns = |kind: ResourceType| {
1163            let list = list(kind.key()).into_iter();
1164            list.map(|pattern| pattern.trim_end_matches('/').to_owned())
1165                .collect()
1166        };
1167        ProjectOnly {
1168            packages: list("packages"),
1169            kinds: ResourceType::ALL.map(patterns),
1170            mcp_servers: list("mcpServers"),
1171        }
1172    }
1173
1174    /// Whether a run keeps the global resource `info` of `kind`: its package
1175    /// is kept, or its kind's patterns match its path, with `~` as `home`
1176    /// and other paths relative to `agent_dir`.
1177    pub fn keeps(
1178        &self,
1179        info: &SourceInfo,
1180        kind: ResourceType,
1181        agent_dir: &Path,
1182        home: &Path,
1183    ) -> bool {
1184        let package = info.origin == "package" && self.keeps_package(&info.source, agent_dir);
1185        package || self.keeps_path(Path::new(&info.path), kind, agent_dir, home)
1186    }
1187
1188    /// Whether `kind`'s patterns match the global file `path`, as for
1189    /// [`keeps`](Self::keeps).
1190    fn keeps_path(&self, path: &Path, kind: ResourceType, agent_dir: &Path, home: &Path) -> bool {
1191        let patterns: Vec<String> = self.kinds[kind as usize]
1192            .iter()
1193            .map(|pattern| match pattern.strip_prefix("~/") {
1194                Some(rest) => home.join(rest).to_string_lossy().into_owned(),
1195                None if pattern == "~" => home.to_string_lossy().into_owned(),
1196                None => pattern.clone(),
1197            })
1198            .collect();
1199        matches_any_pattern(path, &patterns, agent_dir)
1200    }
1201
1202    /// Whether a kind list keeps a resource that the user's package `input`
1203    /// loads, as [`resolve`] lists them, with paths as for
1204    /// [`keeps`](Self::keeps).
1205    pub fn keeps_part(&self, input: &PackageInput, agent_dir: &Path, home: &Path) -> bool {
1206        let local = super::source::is_local(&input.source);
1207        let resources = package_resources(&input.root, input.filter.as_ref(), local);
1208        ResourceType::ALL.into_iter().any(|kind| {
1209            let mut files = resources.enabled(kind);
1210            files.any(|info| self.keeps_path(Path::new(&info.path), kind, agent_dir, home))
1211        })
1212    }
1213
1214    /// Whether a project-only run needs the user's package `source` on
1215    /// disk: `packages` keeps it, or a kind list could match a file inside
1216    /// it. chest's entries are `chest:<name>`.
1217    pub fn needs_package(&self, source: &str, agent_dir: &Path) -> bool {
1218        self.kinds.iter().any(|list| !list.is_empty()) || self.keeps_package(source, agent_dir)
1219    }
1220
1221    /// Whether `packages` keeps the user's package `source` whole.
1222    pub fn keeps_package(&self, source: &str, agent_dir: &Path) -> bool {
1223        self.packages
1224            .iter()
1225            .any(|entry| entry == "*" || same_package(entry, source, agent_dir))
1226    }
1227
1228    /// Whether a project-only run needs the user's chest manifest: a kind
1229    /// list could match a file of its entries, or `packages` names one.
1230    pub fn needs_chest(&self) -> bool {
1231        self.kinds.iter().any(|list| !list.is_empty())
1232            || self
1233                .packages
1234                .iter()
1235                .any(|entry| entry == "*" || entry.starts_with("chest:"))
1236    }
1237}
1238
1239/// Whether `entry` of `projectOnly.packages` names the package of `source`,
1240/// as a project entry overrides a user's: by identity, so versions and refs
1241/// don't count. chest's sources compare by name.
1242fn same_package(entry: &str, source: &str, agent_dir: &Path) -> bool {
1243    use super::source::parse;
1244    match (entry.strip_prefix("chest:"), source.strip_prefix("chest:")) {
1245        (Some(entry), Some(source)) => entry == source,
1246        (None, None) => parse(entry).identity(agent_dir) == parse(source).identity(agent_dir),
1247        _ => false,
1248    }
1249}
1250
1251/// The `extensions`, `skills`, `prompts` and `themes` lists of a settings
1252/// document.
1253pub fn settings_lists(document: &serde_json::Map<String, serde_json::Value>) -> [Vec<String>; 4] {
1254    ResourceType::ALL.map(|kind| string_list(document, kind.key()))
1255}
1256
1257/// The strings in list `key` of a settings document or package entry,
1258/// skipping entries of other types.
1259pub fn string_list(
1260    document: &serde_json::Map<String, serde_json::Value>,
1261    key: &str,
1262) -> Vec<String> {
1263    document
1264        .get(key)
1265        .and_then(serde_json::Value::as_array)
1266        .into_iter()
1267        .flatten()
1268        .filter_map(|entry| entry.as_str().map(str::to_owned))
1269        .collect()
1270}
1271
1272/// pi's `dedupePackages`: the `packages` entries of the project, then the
1273/// user, with their scopes, `project` or `user`. A project entry wins over
1274/// the user's for the same package, as `identity` names it for a source and
1275/// scope, unless it sets `autoload: false`: then both stay.
1276pub fn dedupe_packages(
1277    project: &[serde_json::Value],
1278    user: &[serde_json::Value],
1279    identity: impl Fn(&str, &str) -> String,
1280) -> Vec<(serde_json::Value, &'static str)> {
1281    let mut result: Vec<(serde_json::Value, &'static str)> = Vec::new();
1282    let mut seen: Vec<(String, usize)> = Vec::new();
1283    let entries = project
1284        .iter()
1285        .map(|entry| (entry, "project"))
1286        .chain(user.iter().map(|entry| (entry, "user")));
1287    for (entry, scope) in entries {
1288        let Some(source) = super::entry_source(entry) else {
1289            continue;
1290        };
1291        let id = identity(source, scope);
1292        match seen
1293            .iter()
1294            .find(|(seen_id, _)| *seen_id == id)
1295            .map(|(_, index)| *index)
1296        {
1297            None => {
1298                seen.push((id, result.len()));
1299                result.push((entry.clone(), scope));
1300            }
1301            Some(index) => {
1302                let (existing, existing_scope) = &result[index];
1303                if *existing_scope == "project" && scope == "user" {
1304                    if is_delta(existing) {
1305                        result.push((entry.clone(), scope));
1306                    }
1307                } else if scope == "project" {
1308                    result[index] = (entry.clone(), scope);
1309                }
1310            }
1311        }
1312    }
1313    result
1314}
1315
1316/// Whether a project's `packages` entry only adjusts the user's entry for
1317/// the same package: it sets `autoload: false`.
1318fn is_delta(entry: &serde_json::Value) -> bool {
1319    entry.is_object() && entry["autoload"] == serde_json::Value::Bool(false)
1320}
1321
1322/// pi's `dedupePackages` with `findAutoloadDeltaBase`: each entry that
1323/// [`dedupe_packages`] keeps, with its scope, then the source and scope its
1324/// package is installed under, pi's `resolvedSource` and `resolvedScope`. A
1325/// project entry with `autoload: false` takes the user's entry for the same
1326/// package, when there is one.
1327pub fn locate_packages(
1328    project: &[serde_json::Value],
1329    user: &[serde_json::Value],
1330    identity: impl Fn(&str, &str) -> String,
1331) -> Vec<(serde_json::Value, &'static str, String, &'static str)> {
1332    let packages = dedupe_packages(project, user, &identity);
1333    packages
1334        .iter()
1335        .map(|(entry, scope)| {
1336            // `dedupe_packages` keeps only entries with a source.
1337            let source = super::entry_source(entry).unwrap_or_default();
1338            let base = (*scope == "project" && is_delta(entry))
1339                .then(|| {
1340                    let id = identity(source, scope);
1341                    packages
1342                        .iter()
1343                        .filter(|(_, other)| *other == "user")
1344                        .filter_map(|(other, _)| super::entry_source(other))
1345                        .find(|other| identity(other, "user") == id)
1346                })
1347                .flatten();
1348            let (located, located_scope) = base.map_or((source, *scope), |base| (base, "user"));
1349            (entry.clone(), *scope, located.to_owned(), located_scope)
1350        })
1351        .collect()
1352}
1353
1354/// The packages of [`locate_packages`] that are installed, as `install_path`
1355/// places a source written in a scope's settings. A project entry with
1356/// `autoload: false` only overrides the resources it names, on top of the
1357/// user's entry.
1358pub fn package_inputs(
1359    project: &[serde_json::Value],
1360    user: &[serde_json::Value],
1361    base: impl Fn(&str) -> PathBuf,
1362    install_path: impl Fn(&super::source::Source, &str) -> PathBuf,
1363) -> Vec<PackageInput> {
1364    use super::source::parse;
1365    let identity = |source: &str, scope: &str| parse(source).identity(&base(scope));
1366    let mut inputs = Vec::new();
1367    for (entry, scope, located, located_scope) in locate_packages(project, user, identity) {
1368        let Some(source) = super::entry_source(&entry) else {
1369            continue;
1370        };
1371        // Only chest names its packages `chest:`, so no settings entry
1372        // passes for one.
1373        if source.starts_with("chest:") {
1374            continue;
1375        }
1376        let filter: Option<FilteredPackage> = entry
1377            .is_object()
1378            .then(|| serde_json::from_value(entry.clone()).ok())
1379            .flatten();
1380        let root = install_path(&parse(&located), located_scope);
1381        if root.exists() {
1382            inputs.push(PackageInput {
1383                source: source.to_owned(),
1384                scope: scope.to_owned(),
1385                root,
1386                filter,
1387            });
1388        }
1389    }
1390    inputs
1391}