Skip to main content

yapi_core/chest/
startup.rs

1//! What yapi loads from chest manifests: the installed extensions, with the
2//! grants, event policy and module root each runs with, and the placed
3//! skills.
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use serde::Deserialize as _;
9use yapi_types::rpc::SourceInfo;
10use yapi_types::settings::{DefaultProjectTrust, FilteredPackage};
11
12use super::fetch::placed_name;
13use super::lock::fresh;
14use super::manifest::{Entry, MANIFEST_ENV, Manifest, Section, from_env};
15use super::{Mode, Project};
16use crate::extensions::{EventPolicy, Grants};
17use crate::packages::npm::{Npm, escaped};
18use crate::packages::resolve::{
19    PackageInput, ProjectOnly, ResolvedPaths, ResourceType, package_resources,
20};
21use crate::settings::{Scope, SettingsManager};
22use crate::tools::path::home_dir;
23use crate::trust::{TrustStore, resolve_trusted};
24
25/// The project manifest of `project_root`: the file [`MANIFEST_ENV`] names
26/// relative to it, else `chest.toml`. `None` when the variable names a file
27/// outside the project, as chest refuses it too. Its lock is the file beside
28/// it named with `.lock`.
29pub fn project_manifest(project_root: &Path) -> Option<PathBuf> {
30    let name = from_env().ok()?;
31    Some(project_root.join(name.as_deref().unwrap_or("chest.toml")))
32}
33
34/// How messages name the chest entry that gives an extension file the
35/// resolver reports with `source` and `scope`: `extensions.<name> in
36/// <manifest>`, the manifest named as chest's own messages name it. `None`
37/// for a file chest doesn't give.
38pub fn entry_label(source: &str, scope: &str, project_root: &Path) -> Option<String> {
39    let name = source.strip_prefix("chest:")?;
40    let manifest = match scope {
41        "user" => "~/.chest/chest.toml".to_owned(),
42        _ => {
43            let manifest = project_manifest(project_root)?;
44            manifest.file_name()?.to_string_lossy().into_owned()
45        }
46    };
47    Some(format!("extensions.{name} in {manifest}"))
48}
49
50/// Whether `manifest` installs anything, beyond describing its folder as a
51/// package with `[package]`.
52fn consumes(manifest: &Manifest) -> bool {
53    [Section::Skills, Section::Agents, Section::Extensions]
54        .into_iter()
55        .any(|section| !manifest.section(section).is_empty())
56        || !manifest.patch.is_empty()
57}
58
59/// Whether the project manifest of `project_root` installs anything, so it
60/// needs the project's trust as other project files that run code do. One
61/// with only `[package]`, which describes the project as a package, does not.
62/// A manifest that does not parse needs trust too.
63pub fn needs_trust(project_root: &Path) -> bool {
64    let Some(Ok(text)) = project_manifest(project_root).map(std::fs::read_to_string) else {
65        return false;
66    };
67    Manifest::parse(&text, Scope::Project).map_or(true, |manifest| consumes(&manifest))
68}
69
70/// What an extension runs with: its grants, its `pi.events` policy, and the
71/// folder it loads modules from without file access. It has no default, so
72/// every file says which one it gets.
73#[derive(Clone, Debug, PartialEq)]
74pub struct Policy {
75    /// What it may reach.
76    pub grants: Grants,
77    /// The channels it may send to, and hear from, extensions whose grants
78    /// differ.
79    pub events: EventPolicy,
80    /// The folder that holds its package and the package's own
81    /// dependencies.
82    pub root: Option<PathBuf>,
83}
84
85impl Policy {
86    /// What a package from `yapi install` runs with, as in pi: every grant
87    /// and the shared event policy.
88    pub fn unrestricted() -> Policy {
89        Policy {
90            grants: Grants::default(),
91            events: EventPolicy::shared(),
92            root: None,
93        }
94    }
95
96    /// What a file chest cannot vouch for runs with: no grant, the
97    /// isolated event policy, and modules only from `root`, a folder or
98    /// the file itself.
99    pub fn closed(root: &Path) -> Policy {
100        Policy {
101            grants: Grants::none(),
102            events: EventPolicy::isolated(),
103            root: Some(root.to_path_buf()),
104        }
105    }
106
107    /// What both `self` and `other` allow: the grants and channels of both,
108    /// and the narrower of two nested module roots, or none.
109    fn intersection(&self, other: &Policy) -> Policy {
110        let canonical = |root: &PathBuf| root.canonicalize().unwrap_or_else(|_| root.clone());
111        let roots = (
112            self.root.as_ref().map(canonical),
113            other.root.as_ref().map(canonical),
114        );
115        let root = match roots {
116            (Some(left), Some(right)) if left.starts_with(&right) => Some(left),
117            (Some(left), Some(right)) if right.starts_with(&left) => Some(right),
118            _ => None,
119        };
120        Policy {
121            grants: self.grants.intersection(other.grants),
122            events: self.events.intersection(&other.events),
123            root,
124        }
125    }
126}
127
128/// The policies of chest's packages, by the scope and source the resolver
129/// gives their files, such as `("project", "chest:todo")`.
130/// [`installed_inputs`] builds the packages and their policies in one place,
131/// so they always match.
132pub type Policies = HashMap<(String, String), Policy>;
133
134/// The policies of extension files, so a policy applies whichever source of
135/// the same file loads it.
136#[derive(Clone, Debug, Default)]
137pub struct FilePolicies {
138    /// The policy of each file of chest's packages, by canonical path: what
139    /// every entry that gives it allows.
140    pub files: HashMap<PathBuf, Policy>,
141    /// The canonical folders and files of entries chest left out, whose
142    /// files run with no grant, whatever loads them.
143    pub closed: Vec<PathBuf>,
144}
145
146impl FilePolicies {
147    /// What extension file `file`, a canonical path, runs with: no grant
148    /// and no module but itself in an entry chest left out, else its
149    /// entries' policy, and for a file chest does not know, every grant, as
150    /// packages from `yapi install` keep pi's grants.
151    pub fn of(&self, file: &Path) -> Policy {
152        // Not the entry's folder as the root, which may hold far more.
153        if self.closes(file) {
154            return Policy::closed(file);
155        }
156        self.files
157            .get(file)
158            .cloned()
159            .unwrap_or_else(Policy::unrestricted)
160    }
161
162    /// Whether chest decides what `file` runs with.
163    pub fn manages(&self, file: &Path) -> bool {
164        self.files.contains_key(file) || self.closes(file)
165    }
166
167    /// Whether `file` belongs to an entry chest left out.
168    pub fn closes(&self, file: &Path) -> bool {
169        self.closed.iter().any(|root| file.starts_with(root))
170    }
171
172    /// The warning for extension file `file`, a canonical path, that
173    /// `source` names by `path` though chest decides what it runs with, as
174    /// the path and message to show, or `None` when chest does not. Both are
175    /// escaped, since a cloned project's file names can hold anything.
176    pub fn also_loaded(&self, file: &Path, path: &str, source: &str) -> Option<(String, String)> {
177        if !self.manages(file) {
178            return None;
179        }
180        let message = match self.closes(file) {
181            true => format!(
182                "{path} is also loaded from {source}, but its chest entry does not load, so it runs with no capabilities"
183            ),
184            false => format!("{path} is also loaded from {source}; chest's grants apply"),
185        };
186        Some((escaped(path), escaped(&message)))
187    }
188}
189
190/// The policy of each extension file the resolver gave a `chest:` source in
191/// `resolved`, loaded or shadowed, from its package's entry in
192/// `installed`, with the entries `installed` left out. A file that several
193/// entries give gets what all of them allow, and when that is less than one
194/// of them allows, adds a line to `problems` that names the entries, as
195/// found for the project in `project_root`. So does a file that an entry
196/// left out holds too, which runs with no grant. A chest file whose package
197/// has no entry runs with no grant and the isolated event policy, and adds
198/// a line to `problems` under its source.
199pub fn file_policies(
200    resolved: &ResolvedPaths,
201    installed: &Installed,
202    project_root: &Path,
203    problems: &mut Vec<(String, String)>,
204) -> FilePolicies {
205    let files = resolved.of(ResourceType::Extensions).iter();
206    let files = files.map(|file| &file.info).chain(&resolved.shadowed);
207    let mut found = FilePolicies {
208        files: HashMap::new(),
209        closed: installed
210            .closed
211            .iter()
212            .map(|closed| closed.root.clone())
213            .collect(),
214    };
215    // Each file as its first source names it, with the entries that give
216    // it, and whether one of them allows fewer grants, or fewer channels,
217    // than another.
218    let mut givers: Vec<(PathBuf, &SourceInfo, Vec<String>, [bool; 2])> = Vec::new();
219    for info in files.filter(|info| info.source.starts_with("chest:")) {
220        let key = (info.scope.clone(), info.source.clone());
221        let file = Path::new(&info.path);
222        let file = file.canonicalize().unwrap_or_else(|_| file.into());
223        let policy = installed.policies.get(&key).cloned().unwrap_or_else(|| {
224            let message = format!(
225                "{}: no chest entry grants this file, so it runs with no capabilities",
226                info.path
227            );
228            problems.push((escaped(&info.source), escaped(&message)));
229            Policy::closed(&file)
230        });
231        let label = entry_label(&info.source, &info.scope, project_root);
232        let label = label.unwrap_or_else(|| info.source.clone());
233        let policy = match found.files.get(&file) {
234            Some(other) => {
235                if let Some((.., labels, [grants, events])) =
236                    givers.iter_mut().find(|giver| giver.0 == file)
237                {
238                    if !labels.contains(&label) {
239                        labels.push(label);
240                    }
241                    *grants |= other.grants != policy.grants;
242                    *events |= other.events != policy.events;
243                }
244                other.intersection(&policy)
245            }
246            None => {
247                givers.push((file.clone(), info, vec![label], [false; 2]));
248                policy
249            }
250        };
251        found.files.insert(file, policy);
252    }
253    // What a file that `count` entries give, with `policy`, gets less of
254    // than one of them allows, by whether that is grants and event channels.
255    let only = |narrowed: [bool; 2], policy: &Policy, count: usize| {
256        let all = if count == 2 { "both" } else { "all" };
257        match narrowed {
258            [false, false] => None,
259            [true, false] => Some(format!("grants {all} give: {}", policy.grants)),
260            [false, true] => Some(format!("event channels {all} give")),
261            [true, true] => Some(format!(
262                "event channels and the grants {all} give: {}",
263                policy.grants
264            )),
265        }
266    };
267    for (file, info, labels, narrowed) in givers {
268        let (path, source) = (&info.path, &info.source);
269        let Some(policy) = found.files.get(&file) else {
270            continue;
271        };
272        let closer = installed
273            .closed
274            .iter()
275            .find(|closed| file.starts_with(&closed.root));
276        if let Some(Closed {
277            entry: closer,
278            remove,
279            install,
280            ..
281        }) = closer
282        {
283            // Once installed, the entry left out gives the file too.
284            let fix = match install {
285                Some((command, once)) => {
286                    let [grants, events] = narrowed;
287                    let narrowed = [
288                        grants || policy.grants != once.grants,
289                        events || policy.events != once.events,
290                    ];
291                    let after = policy.intersection(once);
292                    let gives = only(narrowed, &after, labels.len() + 1);
293                    let gives = gives.map(|gives| format!(", which gives it only the {gives}"));
294                    format!(
295                        "run `{command}`{}, or `{remove}`",
296                        gives.unwrap_or_default()
297                    )
298                }
299                None => format!("run `{remove}`"),
300            };
301            let message = format!(
302                "{path} is in {}, but {closer} holds it too and does not load, so it runs with no capabilities: {fix}",
303                labels.join(" and ")
304            );
305            problems.push((escaped(source), escaped(&message)));
306            continue;
307        }
308        let Some(narrowed) = only(narrowed, policy, labels.len()) else {
309            continue;
310        };
311        let message = format!(
312            "{path} is in {}, so it gets only the {narrowed}",
313            labels.join(" and ")
314        );
315        problems.push((escaped(source), escaped(&message)));
316    }
317    found
318}
319
320/// What the installed entries of chest manifests give yapi.
321#[derive(Clone, Debug, Default)]
322pub struct Installed {
323    /// Extension packages, each as source `chest:<name>`.
324    pub packages: Vec<PackageInput>,
325    /// The canonical copies of placed skills, each a skill folder, with the
326    /// source and scope of its entry.
327    pub skills: Vec<PackageInput>,
328    /// The packages' policies.
329    pub policies: Policies,
330    /// The folders and files of extension entries left out because their
331    /// lock is stale or missing, and the extension files of those whose
332    /// folder holds too much.
333    pub closed: Vec<Closed>,
334    /// The [`package_identity`] of each loaded npm or git extension entry,
335    /// such as `npm:@acme/todo`, with the entry's name, its manifest's path,
336    /// and whether that is the user's global manifest.
337    pub identities: Vec<(String, String, String, bool)>,
338}
339
340/// A canonical folder or file of an extension entry chest leaves out,
341/// which runs with no grant if something else loads it.
342#[derive(Clone, Debug)]
343pub struct Closed {
344    /// The folder or file.
345    pub root: PathBuf,
346    /// Its entry, as `extensions.<name> in <manifest>`.
347    pub entry: String,
348    /// The command that removes the entry.
349    pub remove: String,
350    /// For an entry that `chest install` would load, that command and the
351    /// policy the entry would then give.
352    pub install: Option<(String, Policy)>,
353}
354
355impl Installed {
356    fn extend(&mut self, other: Installed) {
357        self.packages.extend(other.packages);
358        self.skills.extend(other.skills);
359        self.policies.extend(other.policies);
360        self.closed.extend(other.closed);
361        self.identities.extend(other.identities);
362    }
363}
364
365/// Whether a run reads the user's global manifest: always, except in a
366/// project-only run whose `projectOnly` setting in `settings` can keep none
367/// of its entries, so nothing global is put back for nothing.
368pub fn reads_global(settings: &SettingsManager) -> bool {
369    !crate::config::project_only()
370        || crate::packages::resolve::ProjectOnly::from_settings(settings.document(Scope::Global))
371            .needs_chest()
372}
373
374/// One install of an npm or git package, as [`LeftOut`] compares them.
375#[derive(Clone, Debug, PartialEq)]
376enum Install {
377    /// A chest extension entry: its name, its manifest's path, and whether
378    /// that is the user's global manifest.
379    Chest {
380        name: String,
381        path: String,
382        global: bool,
383    },
384    /// A `packages` entry of a scope's settings, by its source.
385    Settings { scope: Scope, source: String },
386}
387
388/// The copies a run leaves out of each npm or git package that several
389/// sources install, so it loads once, from the first of: the project's
390/// manifest, the project's settings `packages`, the global manifest and the
391/// global settings `packages`. Of two entries of one manifest, the first
392/// loads. pi's own rules decide between two settings copies.
393#[derive(Clone, Debug, Default, PartialEq)]
394pub struct LeftOut(Vec<(Install, Install)>);
395
396impl LeftOut {
397    /// The copies left out of `installed` and the packages of `settings`,
398    /// each with the copy that loads instead. With `keep`, a project-only
399    /// run's keep list, a global copy that only a kind list keeps comes
400    /// after the copies the run keeps whole, and one it keeps no file of
401    /// doesn't count. Global paths are relative to `agent_dir`.
402    pub fn new(
403        installed: &Installed,
404        settings: &SettingsManager,
405        keep: Option<&ProjectOnly>,
406        agent_dir: &Path,
407    ) -> LeftOut {
408        let rank = |source: &str, global: bool| match keep {
409            Some(keep) if global && !keep.keeps_package(source, agent_dir) => {
410                keep.needs_package(source, agent_dir).then_some(1)
411            }
412            _ => Some(0),
413        };
414        let chest = |global: bool| {
415            let identities = installed.identities.iter();
416            identities
417                .filter(move |(_, _, _, other)| *other == global)
418                .filter_map(move |(id, name, path, global)| {
419                    let rank = rank(&format!("chest:{name}"), *global)?;
420                    let (name, path, global) = (name.clone(), path.clone(), *global);
421                    Some((rank, id.clone(), Install::Chest { name, path, global }))
422                })
423        };
424        let from_settings = |scope: Scope| {
425            let packages = crate::packages::settings_packages(settings, scope);
426            let sources = packages
427                .iter()
428                .filter_map(crate::packages::entry_source)
429                .filter_map(|source| {
430                    let rank = rank(source, scope == Scope::Global)?;
431                    let id = crate::packages::source::parse(source).identity(Path::new(""));
432                    let source = source.to_owned();
433                    Some((rank, id, Install::Settings { scope, source }))
434                });
435            sources.collect::<Vec<_>>()
436        };
437        let mut copies: Vec<(u8, String, Install)> = chest(false)
438            .chain(from_settings(Scope::Project))
439            .chain(chest(true))
440            .chain(from_settings(Scope::Global))
441            .collect();
442        // Two settings copies are pi's to decide.
443        let pair = |a: &Install, b: &Install| {
444            !matches!((a, b), (Install::Settings { .. }, Install::Settings { .. }))
445        };
446        if let Some(keep) = keep {
447            // A global copy's files are read only when it collides.
448            let home = home_dir();
449            let input = |copy: &Install| match copy {
450                Install::Chest { name, .. } => {
451                    let source = format!("chest:{name}");
452                    let mut inputs = installed.packages.iter();
453                    let found =
454                        inputs.find(|input| input.scope == "user" && input.source == source);
455                    found.cloned()
456                }
457                Install::Settings { source, .. } => {
458                    let global = crate::packages::settings_packages(settings, Scope::Global);
459                    let inputs = crate::packages::resolve::package_inputs(
460                        &[],
461                        &global,
462                        |_| agent_dir.to_path_buf(),
463                        |source, _| crate::packages::install_location(source, agent_dir),
464                    );
465                    inputs.into_iter().find(|input| input.source == *source)
466                }
467            };
468            let counts: Vec<bool> = copies
469                .iter()
470                .enumerate()
471                .map(|(index, (rank, id, copy))| {
472                    let mut others = copies.iter().enumerate();
473                    *rank == 0
474                        || !others.any(|(other_index, (_, other, install))| {
475                            other_index != index && same_package(id, other) && pair(copy, install)
476                        })
477                        || input(copy)
478                            .is_some_and(|input| keep.keeps_part(&input, agent_dir, &home))
479                })
480                .collect();
481            let copies_counts = copies.into_iter().zip(counts);
482            copies = copies_counts
483                .filter_map(|(copy, counts)| counts.then_some(copy))
484                .collect();
485        }
486        // Stable, so each rank keeps the order above.
487        copies.sort_by_key(|(rank, ..)| *rank);
488        let mut left_out = Vec::new();
489        // Only copies that load count, as a whole package overlaps two of
490        // its folders that don't overlap each other.
491        let mut loaded: Vec<(&str, &Install)> = Vec::new();
492        for (_, id, copy) in &copies {
493            let kept = loaded.iter().find(|(other, _)| same_package(other, id));
494            match kept {
495                Some((_, kept)) if pair(kept, copy) => {
496                    left_out.push((copy.clone(), (*kept).clone()));
497                }
498                _ => loaded.push((id, copy)),
499            }
500        }
501        LeftOut(left_out)
502    }
503
504    /// Whether chest's package `input` is left out.
505    pub fn chest(&self, input: &PackageInput) -> bool {
506        self.0.iter().any(|(copy, _)| match copy {
507            Install::Chest { name, global, .. } => {
508                input.source == format!("chest:{name}") && (input.scope == "user") == *global
509            }
510            Install::Settings { .. } => false,
511        })
512    }
513
514    /// Whether the `packages` entry of `scope`'s settings with `source` is
515    /// left out.
516    pub fn settings(&self, scope: Scope, source: &str) -> bool {
517        let copy = Install::Settings {
518            scope,
519            source: source.to_owned(),
520        };
521        self.0.iter().any(|(left_out, _)| *left_out == copy)
522    }
523
524    /// A warning for each copy left out, by the path of the chest manifest
525    /// involved, that says which copy loads and how to remove the other.
526    /// With `approve`, the command for a project's settings trusts the
527    /// project for that command.
528    pub fn problems(&self, approve: bool) -> Vec<(String, String)> {
529        let scope = |global: bool| {
530            if global {
531                Scope::Global
532            } else {
533                Scope::Project
534            }
535        };
536        let home = home_dir();
537        let home = home.canonicalize().unwrap_or(home);
538        let place = |copy: &Install| match copy {
539            Install::Chest { name, path, global } => {
540                let label = super::manifest_label(Path::new(path), scope(*global), &home);
541                format!("extensions.{name} in {label}")
542            }
543            Install::Settings { scope, source } => match scope {
544                Scope::Project => format!("{source} in the project's settings"),
545                Scope::Global => format!("{source} in the global settings"),
546            },
547        };
548        let local = match approve {
549            true => "yapi remove -l --approve",
550            false => "yapi remove -l",
551        };
552        let problems = self.0.iter().map(|(copy, kept)| {
553            let remove = match copy {
554                Install::Chest { name, path, global } => {
555                    let command = super::command(Path::new(path), scope(*global));
556                    format!("{command} remove {name}")
557                }
558                Install::Settings { scope, source } => match scope {
559                    Scope::Project => format!("{local} {source}"),
560                    Scope::Global => format!("yapi remove {source}"),
561                },
562            };
563            let message = format!(
564                "{} is left out, because {} installs the same package: remove it with `{remove}`",
565                place(copy),
566                place(kept),
567            );
568            // Two settings copies are pi's to decide, so one is chest's.
569            let path = match (copy, kept) {
570                (Install::Chest { path, .. }, _) | (_, Install::Chest { path, .. }) => path.clone(),
571                _ => String::new(),
572            };
573            (escaped(&path), escaped(&message))
574        });
575        problems.collect()
576    }
577}
578
579/// The warnings, by manifest path, for each copy of a package that a run
580/// leaves out because another source installs it too, as [`LeftOut`]
581/// decides. Their `yapi remove -l` commands take `--approve` unless the
582/// project of `settings` is trusted without it, by the decisions in
583/// `agent_dir` or the global `defaultProjectTrust`.
584pub fn duplicates(
585    installed: &Installed,
586    settings: &SettingsManager,
587    keep: Option<&ProjectOnly>,
588    agent_dir: &Path,
589) -> Vec<(String, String)> {
590    // As `yapi remove -l` decides it, without this run's `--approve`.
591    let default = settings
592        .document(Scope::Global)
593        .get("defaultProjectTrust")
594        .and_then(|value| DefaultProjectTrust::deserialize(value).ok());
595    let store = TrustStore::new(agent_dir);
596    let trusted = resolve_trusted(settings.project_dir(), &store, None, default);
597    LeftOut::new(installed, settings, keep, agent_dir).problems(!trusted.unwrap_or(false))
598}
599
600/// What chest gives one run of yapi, with the problems to show by manifest
601/// path: warnings and notices of the startup sync, entries left out, and
602/// manifests that fail to load.
603#[derive(Clone, Debug, Default)]
604pub struct Loaded {
605    /// The installed entries.
606    pub installed: Installed,
607    /// Each problem's manifest path and message.
608    pub problems: Vec<(String, String)>,
609}
610
611impl Loaded {
612    /// Adds problem `message` under manifest `path`, both with their
613    /// unprintable characters escaped: they quote files a cloned project
614    /// holds, and reach the terminal.
615    fn warn(&mut self, (path, message): (String, String)) {
616        self.problems.push((escaped(&path), escaped(&message)));
617    }
618}
619
620/// The package that npm or git extension entry `entry` installs, as
621/// [`LeftOut`] compares copies: `npm:<name>` or `git:<host>/<path>`, as
622/// settings packages are matched. An entry with `path` takes one folder of
623/// it, which `#<path>` names, without `.` or empty segments.
624pub fn package_identity(entry: &Entry) -> Option<String> {
625    let package = match (&entry.npm, &entry.git) {
626        (Some(npm), _) => format!("npm:{npm}"),
627        (None, Some(git)) => {
628            crate::packages::source::parse(&format!("git:{git}")).identity(Path::new(""))
629        }
630        (None, None) => return None,
631    };
632    let path = entry.path.iter().flat_map(|path| path.split('/'));
633    let path: Vec<&str> = path
634        .filter(|segment| !matches!(*segment, "" | "."))
635        .collect();
636    Some(match path.is_empty() {
637        true => package,
638        false => format!("{package}#{}", path.join("/")),
639    })
640}
641
642/// Whether two [`package_identity`] values, or settings identities, name
643/// copies of one package: the same package and `path`, or the whole package
644/// and one folder of it. Two `path`s of one package are two packages.
645pub fn same_package(a: &str, b: &str) -> bool {
646    let folder_of = |whole: &str, folder: &str| {
647        folder
648            .strip_prefix(whole)
649            .is_some_and(|rest| rest.starts_with('#'))
650    };
651    a == b || folder_of(a, b) || folder_of(b, a)
652}
653
654/// The extensions and skills `project` has installed, locked and up to
655/// date, or patched, for a run in `project_root`: a stale or unlocked entry
656/// gives nothing until `chest install`. Entries left out for a reason the
657/// user should see add a line to `problems`.
658pub fn installed_inputs(
659    project: &Project,
660    project_root: &Path,
661    problems: &mut Vec<String>,
662) -> Installed {
663    let scope = match project.scope {
664        Scope::Project => "project",
665        Scope::Global => "user",
666    };
667    let mut installed = Installed::default();
668    let lock = project.lock.clone().unwrap_or_default();
669    let record = project.record();
670    // A copy an older lock pinned stays out until it is put back.
671    let current = |section: Section, name: &str, entry: &Entry| {
672        lock.section(section)
673            .iter()
674            .find(|locked| locked.name == name)
675            .filter(|locked| fresh(entry, locked))
676            .filter(|locked| project.present(section, locked))
677            .filter(|locked| project.holds_locked(section, entry, locked, &record))
678            .cloned()
679    };
680    for (name, entry) in &project.manifest.extensions {
681        let patched = project.manifest.patch.contains_key(name);
682        let roots = project.extension_roots(name);
683        let label = format!("extensions.{name} in {}", project.label());
684        // Locked or not, it never loads. Only the files it would load run
685        // closed, as other files there belong to other sources.
686        if let Some((root, module_root)) = &roots
687            && project.too_wide(module_root, &[project_root])
688        {
689            problems.push(format!(
690                "extensions.{name}: {} holds more than the extension, such as the project, the home or the agent folder, so it does not load",
691                module_root.display()
692            ));
693            let local = crate::packages::source::is_local(&format!("chest:{name}"));
694            let files = package_resources(root, None, local).extensions;
695            let files = files.iter().map(|file| Path::new(&file.info.path));
696            let remove = format!("{} remove {name}", project.command());
697            let closed = files.filter_map(|file| file.canonicalize().ok());
698            let closed = closed.map(|root| Closed {
699                root,
700                entry: label.clone(),
701                remove: remove.clone(),
702                install: None,
703            });
704            installed.closed.extend(closed);
705            continue;
706        }
707        let policy = |root: PathBuf| Policy {
708            grants: project.manifest.grants(entry),
709            events: entry.events.clone().unwrap_or_default(),
710            root: Some(root),
711        };
712        if !patched && current(Section::Extensions, name, entry).is_none() {
713            // Its files may still load through pi's discovery or settings.
714            if let Some(root) = roots.and_then(|(_, root)| root.canonicalize().ok()) {
715                let install = Some((project.install_command(), policy(root.clone())));
716                installed.closed.push(Closed {
717                    root,
718                    entry: label,
719                    remove: format!("{} remove {name}", project.command()),
720                    install,
721                });
722            }
723            continue;
724        }
725        let Some((root, module_root)) = roots else {
726            continue;
727        };
728        if !root.exists() {
729            // A store folder that is missing is the sync's to put back, or
730            // to warn of.
731            if patched || entry.git.is_none() && entry.npm.is_none() {
732                problems.push(format!(
733                    "extensions.{name}: {} does not exist",
734                    root.display()
735                ));
736            }
737            continue;
738        }
739        let source = format!("chest:{name}");
740        let filter = [&entry.skills, &entry.prompts, &entry.themes]
741            .iter()
742            .any(|list| list.is_some())
743            .then(|| FilteredPackage {
744                source: source.clone(),
745                autoload: None,
746                extensions: None,
747                skills: entry.skills.clone(),
748                prompts: entry.prompts.clone(),
749                themes: entry.themes.clone(),
750            });
751        let key = (scope.to_owned(), source.clone());
752        installed.policies.insert(key, policy(module_root));
753        // A patched entry still installs its package, so its patch loads in
754        // place of other copies.
755        if let Some(identity) = package_identity(entry) {
756            let path = project.path.display().to_string();
757            let global = project.scope == Scope::Global;
758            installed
759                .identities
760                .push((identity, name.clone(), path, global));
761        }
762        installed.packages.push(PackageInput {
763            source,
764            scope: scope.to_owned(),
765            root,
766            filter,
767        });
768    }
769    // Skills load from their canonical copy, wherever the folders put it.
770    let Some(home) = project.homes(Section::Skills).into_iter().next() else {
771        return installed;
772    };
773    for (name, entry) in &project.manifest.skills {
774        let patch = project.manifest.patch.get(name);
775        let roots: Vec<PathBuf> = match (
776            patch.and_then(|patch| patch.path.as_deref()),
777            current(Section::Skills, name, entry),
778        ) {
779            // A patched skill loads where `chest install` placed it, else
780            // from its patch. A patch of a whole package, as `chest install`
781            // reads it, places each of its skills under its own name.
782            (Some(path), _) => {
783                let patch = project.resolve(path);
784                if !patch.exists() {
785                    problems.push(format!("skills.{name}: {} does not exist", patch.display()));
786                    continue;
787                }
788                let package = entry.path.is_none() && (entry.git.is_some() || entry.npm.is_some());
789                let items = if package {
790                    match super::fetch::items(&patch, Section::Skills, None, name) {
791                        Ok((items, _)) => items,
792                        Err(err) => {
793                            problems.push(format!("skills.{name}: {err}"));
794                            continue;
795                        }
796                    }
797                } else {
798                    vec![(placed_name(Section::Skills, name), patch)]
799                };
800                items
801                    .into_iter()
802                    .map(|(item, from)| {
803                        let placed = home.join(item);
804                        if placed.exists() { placed } else { from }
805                    })
806                    .collect()
807            }
808            (None, Some(locked)) => project
809                .locked_items(Section::Skills, &locked)
810                .into_iter()
811                .map(|(item, _)| home.join(item))
812                .collect(),
813            (None, None) => continue,
814        };
815        for root in roots {
816            installed.skills.push(PackageInput {
817                source: format!("chest:{name}"),
818                scope: scope.to_owned(),
819                root,
820                filter: None,
821            });
822        }
823    }
824    installed
825}
826
827/// The manifests yapi reads for `project_root` that install anything: the
828/// project's, only when `trusted`, then the user's global one, only when
829/// `global`, or why each cannot be read.
830fn manifests(
831    project_root: &Path,
832    trusted: bool,
833    global: bool,
834) -> Vec<Result<Project, (String, String)>> {
835    let mut found = Vec::new();
836    if trusted && let Err(problem) = from_env() {
837        let problem = format!("{problem}, so no project manifest loads");
838        found.push(Err((MANIFEST_ENV.to_owned(), problem)));
839    }
840    let project = trusted.then(|| project_manifest(project_root)).flatten();
841    let project = project.map(|path| (path, Scope::Project));
842    let global = global.then(|| (home_dir().join(".chest/chest.toml"), Scope::Global));
843    for (path, scope) in [project, global].into_iter().flatten() {
844        if !path.exists() {
845            continue;
846        }
847        match Project::open(&path, scope) {
848            Ok(project) if consumes(&project.manifest) => found.push(Ok(project)),
849            Ok(_) => {}
850            Err(err) => found.push(Err((path.display().to_string(), err.to_string()))),
851        }
852    }
853    found
854}
855
856/// Adds the inputs of `project` for a run in `project_root` to `loaded`,
857/// with its problems under its path.
858fn add(loaded: &mut Loaded, project: &Project, project_root: &Path) {
859    let mut problems = Vec::new();
860    loaded
861        .installed
862        .extend(installed_inputs(project, project_root, &mut problems));
863    let path = project.path.display().to_string();
864    // Print mode shows only the message, so it names its manifest.
865    for problem in problems {
866        loaded.warn((path.clone(), format!("{}: {problem}", project.label())));
867    }
868}
869
870/// What chest gives a run in `project_root`, as [`load`] would, without
871/// installing anything: for `yapi config`, which shows what a run loads.
872pub fn installed(project_root: &Path, trusted: bool, global: bool) -> Loaded {
873    let mut loaded = Loaded::default();
874    for project in manifests(project_root, trusted, global) {
875        match project {
876            Ok(project) => add(&mut loaded, &project, project_root),
877            Err(problem) => loaded.warn(problem),
878        }
879    }
880    loaded
881}
882
883/// What chest gives a run of yapi in `project_root`: the project manifest,
884/// only when `trusted`, and the user's global one, only when `global`, each
885/// when it installs anything. Each puts back what its lock pins and is
886/// missing, unless `offline`, and skips stale or unlocked entries with a
887/// warning. It never writes a lock or `.gitignore`.
888pub async fn load(project_root: &Path, trusted: bool, offline: bool, global: bool) -> Loaded {
889    let mut loaded = Loaded::default();
890    let mut npm = None;
891    for project in manifests(project_root, trusted, global) {
892        let mut project = match project {
893            Ok(project) => project,
894            Err(problem) => {
895                loaded.warn(problem);
896                continue;
897            }
898        };
899        let path = project.path.display().to_string();
900        let npm = npm.get_or_insert_with(|| Npm::new(crate::packages::npm::config()));
901        match project
902            .sync(Mode::Startup { offline }, npm, &mut |_| {})
903            .await
904        {
905            Ok(report) => {
906                for message in report.warnings.into_iter().chain(report.notices) {
907                    loaded.warn((path.clone(), message));
908                }
909            }
910            Err(err) => loaded.warn((path.clone(), err.to_string())),
911        }
912        add(&mut loaded, &project, project_root);
913    }
914    loaded
915}
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920    use crate::packages::resolve::Resolved;
921    use yapi_types::rpc::SourceInfo;
922
923    /// yapi's startup runs `load` in futures that move between threads.
924    #[test]
925    fn startup_load_send() {
926        fn send<T: Send>(_: T) {}
927        send(load(Path::new("."), false, true, true));
928    }
929
930    /// A chest file whose package has no policy runs with none, with a
931    /// warning, while other sources' files get no policy at all. Warnings
932    /// escape a file name's control characters. A file
933    /// that two entries give loads modules from the narrower root, and one
934    /// in an entry chest left out from itself alone, with a warning that
935    /// names that entry too.
936    #[test]
937    fn chest_files_fail_closed() {
938        let file = |path: &str, source: &str, scope: &str| SourceInfo {
939            path: path.into(),
940            source: source.into(),
941            scope: scope.into(),
942            origin: "package".into(),
943            base_dir: None,
944        };
945        let enabled = |info| Resolved {
946            info,
947            enabled: true,
948        };
949        // A cloned project's file name may hold a terminal escape.
950        let spoof = "/p/\x1b[2J.js";
951        let resolved = ResolvedPaths {
952            extensions: vec![
953                enabled(file("/p/a.js", "chest:a", "project")),
954                enabled(file("/p/b.js", "npm:b", "project")),
955                enabled(file(spoof, "chest:a", "project")),
956            ],
957            ..ResolvedPaths::default()
958        };
959        let mut problems = Vec::new();
960        let policies = file_policies(
961            &resolved,
962            &Installed::default(),
963            Path::new("/p"),
964            &mut problems,
965        );
966        let (file_a, file_spoof) = (PathBuf::from("/p/a.js"), PathBuf::from(spoof));
967        let files = HashMap::from([
968            (file_a.clone(), Policy::closed(&file_a)),
969            (file_spoof.clone(), Policy::closed(&file_spoof)),
970        ]);
971        assert_eq!(policies.files, files);
972        assert_eq!(problems.len(), 2, "{problems:?}");
973        let warned = policies.also_loaded(&file_spoof, spoof, "cli");
974        let warned = warned
975            .into_iter()
976            .flat_map(|(path, message)| [path, message]);
977        let texts: Vec<String> = problems
978            .iter()
979            .flat_map(|(source, message)| [source.clone(), message.clone()])
980            .chain(warned)
981            .collect();
982        assert!(
983            texts.len() == 6 && texts.iter().all(|text| !text.contains('\x1b')),
984            "{texts:?}"
985        );
986        let rooted = |root: &str| Policy {
987            root: Some(root.into()),
988            ..Policy::unrestricted()
989        };
990        let key = |scope: &str, name: &str| (scope.to_owned(), format!("chest:{name}"));
991        let filesystem = Grants {
992            filesystem: true,
993            ..Grants::none()
994        };
995        let network = Grants {
996            network: true,
997            ..Grants::none()
998        };
999        let narrow = |grants, events| Policy {
1000            grants,
1001            events,
1002            ..rooted("/p/sub")
1003        };
1004        let installed = Installed {
1005            policies: HashMap::from([
1006                (key("project", "s"), rooted("/p")),
1007                (key("user", "s"), narrow(filesystem, EventPolicy::shared())),
1008                // `e` narrows only the event channels, and `g` the grants too.
1009                (
1010                    key("project", "e"),
1011                    narrow(filesystem, EventPolicy::shared()),
1012                ),
1013                (
1014                    key("user", "e"),
1015                    narrow(filesystem, EventPolicy::isolated()),
1016                ),
1017                (key("project", "g"), rooted("/p")),
1018                (
1019                    key("user", "g"),
1020                    narrow(filesystem, EventPolicy::isolated()),
1021                ),
1022            ]),
1023            // `chest install` would load `x` with its own grants.
1024            closed: [("/q", None), ("/p/sub/c.js", Some(network))]
1025                .map(|(root, grants)| Closed {
1026                    root: root.into(),
1027                    entry: "extensions.x in chest.toml".into(),
1028                    remove: "chest remove x".into(),
1029                    install: grants.map(|grants| {
1030                        let policy = narrow(grants, EventPolicy::shared());
1031                        ("chest install".to_owned(), policy)
1032                    }),
1033                })
1034                .into(),
1035            ..Installed::default()
1036        };
1037        let resolved = ResolvedPaths {
1038            extensions: vec![
1039                enabled(file("/p/sub/a.js", "chest:s", "project")),
1040                enabled(file("/p/sub/b.js", "chest:s", "user")),
1041                enabled(file("/p/sub/c.js", "chest:s", "project")),
1042                enabled(file("/p/sub/e.js", "chest:e", "project")),
1043                enabled(file("/p/sub/g.js", "chest:g", "project")),
1044            ],
1045            shadowed: vec![
1046                file("/p/sub/a.js", "chest:s", "user"),
1047                file("/p/sub/b.js", "chest:s", "project"),
1048                file("/p/sub/c.js", "chest:s", "user"),
1049                file("/p/sub/e.js", "chest:e", "user"),
1050                file("/p/sub/g.js", "chest:g", "user"),
1051            ],
1052            ..ResolvedPaths::default()
1053        };
1054        problems.clear();
1055        let policies = file_policies(&resolved, &installed, Path::new("/p"), &mut problems);
1056        let root = |path: &str| policies.of(Path::new(path)).root;
1057        for file in ["/p/sub/a.js", "/p/sub/b.js"] {
1058            assert_eq!(root(file), Some("/p/sub".into()), "{file}");
1059        }
1060        // A file two entries give warns once, naming both, as it gets
1061        // only what both allow, or nothing when an entry left out holds it,
1062        // and then what installing that entry would leave it. The warning
1063        // says whether that narrows grants, event channels or both.
1064        let (project, user) = (
1065            "extensions.s in chest.toml",
1066            "extensions.s in ~/.chest/chest.toml",
1067        );
1068        let shared = |file: &str, first: &str, second: &str| {
1069            let message = format!(
1070                "{file} is in {first} and {second}, so it gets only the grants both give: fs"
1071            );
1072            ("chest:s".to_owned(), message)
1073        };
1074        let closed = format!(
1075            "/p/sub/c.js is in {project} and {user}, but extensions.x in chest.toml holds it too and does not load, so it runs with no capabilities: run `chest install`, which gives it only the grants all give: none, or `chest remove x`"
1076        );
1077        let narrowed = |name: &str, what: &str| {
1078            let message = format!(
1079                "/p/sub/{name}.js is in extensions.{name} in chest.toml and extensions.{name} in ~/.chest/chest.toml, so it gets only the {what}"
1080            );
1081            (format!("chest:{name}"), message)
1082        };
1083        let both = [
1084            shared("/p/sub/a.js", project, user),
1085            shared("/p/sub/b.js", user, project),
1086            ("chest:s".to_owned(), closed),
1087            narrowed("e", "event channels both give"),
1088            narrowed("g", "event channels and the grants both give: fs"),
1089        ];
1090        assert_eq!(problems, both);
1091        assert_eq!(root("/q/a.js"), Some("/q/a.js".into()));
1092        assert_eq!(root("/p/sub/c.js"), Some("/p/sub/c.js".into()));
1093    }
1094}