Skip to main content

yapi_core/chest/
mod.rs

1//! chest, a project package manager for skills, subagents and extensions:
2//! the `chest.toml` manifest, the `chest.lock`, content hashes, the
3//! placement of installed items, and the engine that installs them. It has
4//! no pi counterpart.
5
6mod fetch;
7pub mod hash;
8pub mod lock;
9pub mod manifest;
10pub mod place;
11mod record;
12pub mod startup;
13pub mod wasm;
14
15use std::collections::{HashMap, HashSet};
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use tokio_util::sync::CancellationToken;
20use yapi_ai::auth::lock::{FileLock, LockError, write_atomic};
21
22use crate::packages::npm::{self, Npm, NpmError, Pinned};
23use crate::settings::Scope;
24use crate::tools::path::{home_dir, resolve_lexically};
25use hash::tree_hash;
26use lock::{Lock, Locked, fresh};
27use manifest::{Entry, Manifest, Section};
28use record::{Held, Record, content_hash};
29
30/// Why a chest operation failed.
31#[derive(Debug, thiserror::Error)]
32pub enum ChestError {
33    /// A manifest, lock or package tree that chest refuses.
34    #[error("{0}")]
35    Invalid(String),
36    /// A git command or another step of an install failed.
37    #[error("{0}")]
38    Failed(String),
39    /// An npm operation failed.
40    #[error(transparent)]
41    Npm(#[from] NpmError),
42    /// A file operation failed.
43    #[error("{path}: {source}")]
44    Io {
45        /// Where.
46        path: PathBuf,
47        /// Why.
48        source: std::io::Error,
49    },
50}
51
52fn invalid(message: impl Into<String>) -> ChestError {
53    ChestError::Invalid(message.into())
54}
55
56fn io(path: &Path) -> impl FnOnce(std::io::Error) -> ChestError + '_ {
57    move |source| ChestError::Io {
58        path: path.to_path_buf(),
59        source,
60    }
61}
62
63/// What [`Project::sync`] does.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum Mode {
66    /// `chest install`: keep what is locked, fresh and present, holding
67    /// what the lock pins as chest's record says, replay what is locked and
68    /// fresh but missing or different, resolve the rest, then write the
69    /// lock and the `.gitignore` block and remove what the manifest dropped.
70    Resolve,
71    /// `chest update`: [`Mode::Resolve`], resolving the named entries, or
72    /// all with no names, again even when their lock is fresh.
73    Update(Vec<String>),
74    /// `chest install --frozen`: fail on a missing or stale lock and on an
75    /// installed copy that no longer matches its hash. Writes nothing.
76    Frozen,
77    /// yapi startup: replay what is locked, fresh and missing, unless
78    /// `offline`, and skip the rest with one warning. Never writes the lock
79    /// or `.gitignore`.
80    Startup {
81        /// Whether to leave missing entries alone rather than download them.
82        offline: bool,
83    },
84}
85
86/// What happened to one entry.
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub enum Outcome {
89    /// Resolved, or copied from a path source, and put in place.
90    Installed,
91    /// Put in place again exactly as locked.
92    Replayed,
93    /// Already in place as locked.
94    Unchanged,
95    /// Left alone: stale, unlocked, offline, or not installed when frozen.
96    Skipped,
97    /// Removed, because the manifest no longer has it.
98    Removed,
99}
100
101/// What a sync did.
102#[derive(Clone, Debug, Default, PartialEq, Eq)]
103pub struct Report {
104    /// Each entry, as `section.name`, with what happened to it: manifest
105    /// entries in order, then removed ones.
106    pub entries: Vec<(String, Outcome)>,
107    /// Problems the user should act on.
108    pub warnings: Vec<String>,
109    /// Facts worth showing, such as a path source outside the project.
110    pub notices: Vec<String>,
111}
112
113/// A locked git or npm entry with the newest revision its source offers.
114#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct Outdated {
116    /// The entry, as `section.name`.
117    pub id: String,
118    /// The locked npm version or git commit.
119    pub locked: String,
120    /// The newest npm version in the entry's range, or the commit its `rev`
121    /// names now.
122    pub wanted: String,
123    /// npm's `latest` version, which may lie outside the range.
124    pub latest: Option<String>,
125}
126
127/// A package fetched to look at, kept until dropped.
128pub struct Fetched {
129    /// The package's folder: a checkout, an unpacked npm package, or a path
130    /// source.
131    pub root: PathBuf,
132    _work: Option<fetch::Work>,
133}
134
135/// One manifest entry and what the old lock says of it.
136struct Target<'a> {
137    id: String,
138    section: Section,
139    name: &'a str,
140    entry: &'a Entry,
141    patch: Option<&'a Entry>,
142    locked: Option<&'a Locked>,
143}
144
145/// An entry fetched, checked and ready to put in place.
146struct Staged {
147    id: String,
148    section: Section,
149    record: Option<Locked>,
150    content: Content,
151    outcome: Outcome,
152    /// Items of the package left out, with why.
153    warnings: Vec<String>,
154}
155
156enum Content {
157    /// Skills or agents: each placed name with the folder or file it copies
158    /// and its hash.
159    Items(Vec<(String, PathBuf, String)>),
160    /// An extension built in `staging`, beside its store folder `dir`, with
161    /// its hash.
162    Store {
163        staging: PathBuf,
164        dir: PathBuf,
165        hash: String,
166    },
167    /// Nothing to put in place.
168    Nothing,
169}
170
171/// Locks `file` as [`FileLock::acquire`] does, but waits for as long as
172/// another holder keeps the lock fresh, where `acquire` gives up after the
173/// stale period. A holder that stopped stops refreshing, so its lock goes
174/// stale and is taken over. `waiting` hears once that the lock is held,
175/// when it is not free within a second.
176pub async fn hold_lock(
177    file: &Path,
178    waiting: &mut (dyn FnMut() + Send),
179) -> Result<FileLock, ChestError> {
180    let cancel = CancellationToken::new();
181    let failed = |err: LockError| ChestError::Failed(format!("{}: {err}", file.display()));
182    let first = tokio::time::timeout(Duration::from_secs(1), FileLock::acquire(file, &cancel));
183    match first.await {
184        Ok(Ok(lock)) => return Ok(lock),
185        Ok(Err(LockError::Held)) | Err(_) => waiting(),
186        Ok(Err(err)) => return Err(failed(err)),
187    }
188    loop {
189        match FileLock::acquire(file, &cancel).await {
190            Err(LockError::Held) => {}
191            locked => return locked.map_err(failed),
192        }
193    }
194}
195
196/// Fails when `path` is a symbolic link. chest reads and writes a manifest
197/// and its lock only as plain files, since a cloned project could point a
198/// link anywhere.
199pub fn refuse_link(path: &Path) -> Result<(), ChestError> {
200    if path.symlink_metadata().is_ok_and(|meta| meta.is_symlink()) {
201        return Err(invalid(format!(
202            "{} is a symbolic link, which chest does not follow: replace it with the file itself",
203            path.display()
204        )));
205    }
206    Ok(())
207}
208
209/// Removes the extensions `staged` built and did not put in place.
210fn discard(staged: &[Staged]) {
211    for staged in staged {
212        if let Content::Store { staging, .. } = &staged.content {
213            let _ = std::fs::remove_dir_all(staging);
214        }
215    }
216}
217
218/// `path` with its deepest existing part resolved to its real path, links
219/// followed and, where the file system ignores case, as the disk spells it.
220fn real_path(path: &Path) -> PathBuf {
221    let mut rest = Vec::new();
222    let mut current = path;
223    loop {
224        if let Ok(real) = current.canonicalize() {
225            return rest.iter().rev().fold(real, |real, part| real.join(part));
226        }
227        match (current.parent(), current.file_name()) {
228            (Some(parent), Some(name)) => {
229                rest.push(name.to_owned());
230                current = parent;
231            }
232            _ => return path.to_path_buf(),
233        }
234    }
235}
236
237/// A `chest.toml` with its lock, and the folders its entries go to.
238#[derive(Clone, Debug)]
239pub struct Project {
240    /// The manifest.
241    pub manifest: Manifest,
242    /// Its lock, when there is one.
243    pub lock: Option<Lock>,
244    /// The manifest's folder, which its relative paths start from.
245    pub base: PathBuf,
246    /// Whether it is a project's manifest or the user's global one.
247    pub scope: Scope,
248    /// Where git clones are cached and work is staged: `~/.chest/cache`
249    /// unless set otherwise.
250    pub cache: PathBuf,
251    /// Where chest records what it placed on this machine, outside any
252    /// repository: `~/.chest/state` unless set otherwise.
253    pub state: PathBuf,
254    path: PathBuf,
255    /// The canonical home folder, which `~` names.
256    pub home: PathBuf,
257}
258
259/// What startup does with an entry, as its lock and folders stand.
260enum AtStartup {
261    /// Not locked, or locked for another source: skipped.
262    Stale,
263    /// In place, or patched: kept.
264    Present,
265    /// Missing, and offline: skipped.
266    Missing,
267    /// Missing: put back from the lock.
268    Replay,
269}
270
271impl Project {
272    /// The manifest at `path` and its lock, the file beside it named with
273    /// `.lock` for `.toml`: `chest.dev.toml` locks in `chest.dev.lock`.
274    ///
275    /// The manifest's folder is resolved to its canonical path once, so a
276    /// project opened through a link or another spelling of its path places,
277    /// records and removes the same paths. A manifest or lock that is itself
278    /// a link is refused, as [`refuse_link`] says.
279    pub fn open(path: &Path, scope: Scope) -> Result<Project, ChestError> {
280        let path = std::path::absolute(path).map_err(io(path))?;
281        refuse_link(&path)?;
282        let text = std::fs::read_to_string(&path).map_err(io(&path))?;
283        let manifest = Manifest::parse(&text, scope)
284            .map_err(|err| invalid(format!("{}: {err}", path.display())))?;
285        let folder = path.parent().unwrap_or(&path);
286        let base = folder.canonicalize().map_err(io(folder))?;
287        let home = home_dir();
288        let mut project = Project {
289            manifest,
290            lock: None,
291            path: base.join(path.file_name().unwrap_or_default()),
292            base,
293            scope,
294            cache: home.join(".chest/cache"),
295            state: home.join(".chest/state"),
296            home: home.canonicalize().unwrap_or(home),
297        };
298        project.lock = project.read_lock()?;
299        Ok(project)
300    }
301
302    /// The user's global manifest, `~/.chest/chest.toml`.
303    pub fn global() -> Result<Project, ChestError> {
304        Project::open(&home_dir().join(".chest/chest.toml"), Scope::Global)
305    }
306
307    /// Where the lock is.
308    pub fn lock_path(&self) -> PathBuf {
309        self.path.with_extension("lock")
310    }
311
312    /// The folder chest never writes outside of: the project's own for a
313    /// project manifest, home for the global one.
314    pub fn root(&self) -> PathBuf {
315        match self.scope {
316            Scope::Project => self.base.clone(),
317            Scope::Global => self.home.clone(),
318        }
319    }
320
321    /// The extension store: `[folders] extensions`, else `.yapi/chest` in a
322    /// project and `~/.chest/store` for the global manifest.
323    pub fn store(&self) -> PathBuf {
324        match (&self.manifest.folders.extensions, self.scope) {
325            (Some(store), _) => self.resolve(store),
326            (None, Scope::Project) => self.base.join(".yapi/chest"),
327            (None, Scope::Global) => self.home.join(".chest/store"),
328        }
329    }
330
331    /// `path` from the manifest, against its folder, with `~` the canonical
332    /// home folder.
333    fn resolve(&self, path: &str) -> PathBuf {
334        let path = match path.strip_prefix('~') {
335            Some("") => self.home.clone(),
336            Some(rest) if rest.starts_with('/') => self.home.join(&rest[1..]),
337            _ => PathBuf::from(path),
338        };
339        resolve_lexically(&self.base, &path)
340    }
341
342    /// Whether `folder`, an extension's module root, holds far more than one
343    /// package, so the extension could load any file there: it is, or holds,
344    /// the home folder, the agent folder, the filesystem's root, the
345    /// manifest's folder, which is the project's for a project manifest, the
346    /// store, which holds the other extensions, or a folder in `also`. A
347    /// held folder that does not exist yet counts by the path it will have.
348    pub fn too_wide(&self, folder: &Path, also: &[&Path]) -> bool {
349        let Ok(folder) = folder.canonicalize() else {
350            return false;
351        };
352        let held = [
353            self.home.clone(),
354            crate::config::agent_dir(),
355            PathBuf::from("/"),
356            self.base.clone(),
357            self.store(),
358        ];
359        held.into_iter()
360            .chain(also.iter().map(|path| path.to_path_buf()))
361            .any(|held| real_path(&held).starts_with(&folder))
362    }
363
364    /// Refuses an extension whose path source, or `[patch]`, is a folder
365    /// [`Project::too_wide`] for it.
366    fn refuse_wide(&self) -> Result<(), ChestError> {
367        for (name, entry) in &self.manifest.extensions {
368            let patch = self.manifest.patch.get(name);
369            let patch = patch.and_then(|patch| patch.path.as_deref());
370            let source = entry.path.as_deref();
371            let source = source.filter(|_| entry.git.is_none() && entry.npm.is_none());
372            let (id, path) = match (patch, source) {
373                (Some(path), _) => (format!("patch.{name}"), path),
374                (None, Some(path)) => (format!("extensions.{name}"), path),
375                (None, None) => continue,
376            };
377            let folder = self.resolve(path);
378            if self.too_wide(&folder, &[]) {
379                return Err(invalid(format!(
380                    "{id}: {} holds more than the extension, such as the project, the home or the agent folder, so chest does not install it",
381                    folder.display()
382                )));
383            }
384        }
385        Ok(())
386    }
387
388    /// The folders of skill or agent entries, the canonical one first, by
389    /// real path. A folder that links to an earlier one, or spells it
390    /// another way, is left out, so nothing is placed there twice.
391    fn folders(&self, section: Section) -> Result<Vec<PathBuf>, ChestError> {
392        let folders = match section {
393            Section::Skills => &self.manifest.folders.skills,
394            _ => &self.manifest.folders.agents,
395        };
396        if folders.is_empty() {
397            let key = section.key();
398            return Err(invalid(format!(
399                "Set `{key}` in [folders] to say where {key} go"
400            )));
401        }
402        let mut real: Vec<PathBuf> = Vec::new();
403        for folder in folders {
404            let path = real_path(&self.resolve(folder));
405            if !real.contains(&path) {
406                real.push(path);
407            }
408        }
409        Ok(real)
410    }
411
412    fn read_lock(&self) -> Result<Option<Lock>, ChestError> {
413        let path = self.lock_path();
414        refuse_link(&path)?;
415        match std::fs::read_to_string(&path) {
416            Ok(text) => Lock::parse(&text)
417                .map(Some)
418                .map_err(|err| invalid(format!("{}: {err}", path.display()))),
419            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
420            Err(err) => Err(io(&path)(err)),
421        }
422    }
423
424    fn file_name(&self) -> String {
425        self.path
426            .file_name()
427            .map_or_else(String::new, |name| name.to_string_lossy().into_owned())
428    }
429
430    /// The skill, agent and extension entries, in manifest order.
431    fn entries(&self) -> impl Iterator<Item = (Section, &String, &Entry)> {
432        self.manifest.entries()
433    }
434
435    /// The folders a section's items go in: its configured folders, or the
436    /// store for extensions.
437    fn homes(&self, section: Section) -> Vec<PathBuf> {
438        match section {
439            Section::Extensions => vec![self.store()],
440            _ => self.folders(section).unwrap_or_default(),
441        }
442    }
443
444    /// The paths `locked` occupies: each of its items in each of its
445    /// section's folders, or its store folder.
446    pub fn placed(&self, section: Section, locked: &Locked) -> Vec<PathBuf> {
447        let homes = self.homes(section);
448        let items = locked_items(section, locked);
449        items
450            .iter()
451            .flat_map(|(name, _)| homes.iter().map(move |home| home.join(name)))
452            .collect()
453    }
454
455    /// The hash the lock pins for each path its entries occupy.
456    fn pinned(&self, lock: &Lock) -> HashMap<PathBuf, String> {
457        let mut pinned = HashMap::new();
458        for section in [Section::Skills, Section::Agents, Section::Extensions] {
459            let homes = self.homes(section);
460            for locked in lock.section(section) {
461                for (name, hash) in locked_items(section, locked) {
462                    let Some(hash) = hash else { continue };
463                    for home in &homes {
464                        pinned.insert(home.join(&name), hash.clone());
465                    }
466                }
467            }
468        }
469        pinned
470    }
471
472    /// Whether `record` says each path `locked` occupies holds what the lock
473    /// pins, with no placement in progress. A link counts by the copy it
474    /// points to.
475    fn recorded(&self, section: Section, locked: &Locked, record: &Record) -> bool {
476        let homes = self.homes(section);
477        let items = locked_items(section, locked);
478        items.iter().all(|(name, hash)| {
479            let holds = |hash: &String| {
480                homes
481                    .iter()
482                    .all(|home| record.holds(&home.join(name), hash))
483            };
484            hash.as_ref().is_some_and(holds)
485        })
486    }
487
488    /// Whether everything `locked` occupies is there. A link in the first
489    /// folder, where the copy belongs, counts as missing, as after the
490    /// folders were reordered. An extension from a path loads where it is,
491    /// so it always is.
492    pub fn present(&self, section: Section, locked: &Locked) -> bool {
493        let placed = self.placed(section, locked);
494        let in_place = section == Section::Extensions && placed.is_empty();
495        let first = self.homes(section).into_iter().next();
496        let there = |path: &PathBuf| match path.symlink_metadata() {
497            Ok(metadata) => !metadata.is_symlink() || path.parent() != first.as_deref(),
498            Err(_) => false,
499        };
500        in_place || !placed.is_empty() && placed.iter().all(there)
501    }
502
503    /// Brings the installed entries in line with the manifest and lock as
504    /// `mode` says, holding the lock file's lock and the project's record
505    /// lock throughout. Every entry is fetched and checked before anything
506    /// is put in place, so a failed download changes nothing. Nothing is
507    /// installed outside [`Project::root`]. Only chest's cache and record,
508    /// under `~/.chest` unless set otherwise, are written elsewhere.
509    /// `progress` hears a line for each entry fetched, and one while another
510    /// chest command holds a lock.
511    pub async fn sync(
512        &mut self,
513        mode: Mode,
514        npm: &mut Npm,
515        progress: &mut (dyn FnMut(&str) + Send),
516    ) -> Result<Report, ChestError> {
517        let mut report = Report::default();
518        // yapi's startup skips such an entry on its own, with a warning.
519        if !matches!(mode, Mode::Startup { .. }) {
520            self.refuse_wide()?;
521        }
522        self.path_notices(&mut report);
523        let frozen = mode == Mode::Frozen;
524        // Frozen with everything in place writes nothing, not even the
525        // lock's lock.
526        if frozen {
527            self.lock = self.read_lock()?;
528            if !self.check_frozen(&mut report)? {
529                return Ok(report);
530            }
531        }
532        // Startup takes the lock's lock only to put something back.
533        if let Mode::Startup { offline } = mode
534            && self.startup_without_lock(offline, &mut report)?
535        {
536            return Ok(report);
537        }
538        let lock_path = self.lock_path();
539        let mut waiting = || progress(&waiting_for(&lock_path));
540        let _held = hold_lock(&lock_path, &mut waiting).await?;
541        self.lock = self.read_lock()?;
542        // Another install may have changed the lock or put things back meanwhile.
543        if frozen && !self.check_frozen(&mut report)? {
544            return Ok(report);
545        }
546        let startup = matches!(mode, Mode::Startup { .. });
547        let old = match (&self.lock, startup) {
548            (Some(lock), _) => lock.clone(),
549            (None, false) => Lock::default(),
550            (None, true) => {
551                report.warnings.push(format!(
552                    "{} has no lock, so nothing from it was installed: run `chest install`",
553                    self.file_name()
554                ));
555                return Ok(report);
556            }
557        };
558        // Every manifest of the project shares its record, so one install at
559        // a time places and removes.
560        let record_path = Record::path(&self.state, &self.root(), self.scope);
561        let state = record_path.parent().unwrap_or(&record_path);
562        std::fs::create_dir_all(state).map_err(io(state))?;
563        let mut waiting = || progress(&waiting_for(&record_path));
564        let _record_held = hold_lock(&record_path, &mut waiting).await?;
565        self.prepare_folders()?;
566        let mut record = Record::load(&record_path, &self.root(), &self.file_name());
567        // `_work` holds the fetched items until they are placed.
568        let (staged, _work) = self
569            .prepare(&mode, &old, &record, npm, progress, &mut report)
570            .await?;
571        let committed = self.commit(&staged, &old, &mut record, &record_path, &mut report);
572        let occupied = match committed {
573            Ok(occupied) => occupied,
574            Err(err) => {
575                discard(&staged);
576                return Err(err);
577            }
578        };
579        if startup {
580            return Ok(report);
581        }
582        let mut lock = Lock::default();
583        for staged in staged {
584            let Some(record) = staged.record else {
585                continue;
586            };
587            match staged.section {
588                Section::Skills => lock.skills.push(record),
589                Section::Agents => lock.agents.push(record),
590                _ => lock.extensions.push(record),
591            }
592        }
593        self.remove_dropped(&old, &occupied, &mut record, &record_path, &mut report)?;
594        let text = lock.to_toml()?;
595        // Frozen installs what the lock pins and never writes it.
596        if !frozen && std::fs::read_to_string(&lock_path).ok().as_deref() != Some(text.as_str()) {
597            write_atomic(&lock_path, text.as_bytes(), 0o666).map_err(io(&lock_path))?;
598        }
599        if self.scope == Scope::Project {
600            self.write_ignore(&occupied)?;
601        }
602        self.lock = Some(lock);
603        Ok(report)
604    }
605
606    /// Before anything is placed: makes the folders of sections with
607    /// entries, so that a folder linking to another resolves, refuses
608    /// folders whose real paths nest or that two sections share, and removes
609    /// the staging and previous copies a stopped install left in them or in
610    /// the store, when they are inside [`Project::root`].
611    fn prepare_folders(&self) -> Result<(), ChestError> {
612        let root = self.root();
613        let mut homes: Vec<PathBuf> = Vec::new();
614        for section in [Section::Skills, Section::Agents] {
615            let configured = match section {
616                Section::Skills => &self.manifest.folders.skills,
617                _ => &self.manifest.folders.agents,
618            };
619            if !self.manifest.section(section).is_empty() {
620                for folder in configured {
621                    let path = self.resolve(folder);
622                    // `within` judges an item by its folder.
623                    place::within(&root, &path.join("item"))?;
624                    // A folder that is a link stays one.
625                    if path.symlink_metadata().is_err() {
626                        std::fs::create_dir_all(&path).map_err(io(&path))?;
627                    }
628                }
629            }
630            let mut reals: Vec<PathBuf> = Vec::new();
631            for folder in configured {
632                let real = real_path(&self.resolve(folder));
633                if !reals.contains(&real) {
634                    reals.push(real);
635                }
636            }
637            homes.extend(reals);
638        }
639        homes.push(real_path(&self.store()));
640        for (index, first) in homes.iter().enumerate() {
641            for second in &homes[index + 1..] {
642                if first.starts_with(second) || second.starts_with(first) {
643                    return Err(invalid(format!(
644                        "folders: {} and {} overlap",
645                        first.display(),
646                        second.display()
647                    )));
648                }
649            }
650        }
651        for home in &homes {
652            // A folder of a section without entries may link out of the root.
653            if place::within(&root, &home.join("item")).is_err() {
654                continue;
655            }
656            for entry in std::fs::read_dir(home).into_iter().flatten().flatten() {
657                let name = entry.file_name().to_string_lossy().into_owned();
658                let stopped = name.ends_with("-staging") || name.ends_with("-old");
659                if name.starts_with('.') && stopped {
660                    let path = entry.path();
661                    npm::remove_path(&path).map_err(io(&path))?;
662                }
663            }
664        }
665        Ok(())
666    }
667
668    /// Fetches and stages each entry that needs it, and keeps the others as
669    /// they are, with the work folder that holds what it fetched. On
670    /// failure, removes what it staged.
671    async fn prepare(
672        &self,
673        mode: &Mode,
674        old: &Lock,
675        record: &Record,
676        npm: &mut Npm,
677        progress: &mut (dyn FnMut(&str) + Send),
678        report: &mut Report,
679    ) -> Result<(Vec<Staged>, fetch::Work), ChestError> {
680        if let Mode::Update(names) = mode
681            && let Some(unknown) = names
682                .iter()
683                .find(|name| self.entries().all(|(_, entry, _)| entry != *name))
684        {
685            return Err(invalid(format!("No entry is named {unknown}")));
686        }
687        let mut work = fetch::Work::new(&self.cache)?;
688        let mut staged: Vec<Staged> = Vec::new();
689        let mut stale = false;
690        for (section, name, entry) in self.entries() {
691            let locked = old
692                .section(section)
693                .iter()
694                .find(|locked| &locked.name == name);
695            let target = Target {
696                id: format!("{}.{name}", section.key()),
697                section,
698                name,
699                entry,
700                patch: self.manifest.patch.get(name),
701                locked,
702            };
703            let fresh = locked.is_some_and(|locked| fresh(entry, locked));
704            let present = locked.is_some_and(|locked| self.present(section, locked));
705            let recorded = locked.is_some_and(|locked| self.recorded(section, locked, record));
706            let local = target.patch.is_some() || entry.git.is_none() && entry.npm.is_none();
707            let named = matches!(mode, Mode::Update(names)
708                if names.is_empty() || names.iter().any(|n| n == name));
709            let keep = match mode {
710                Mode::Startup { offline } => {
711                    match self.at_startup(section, name, entry, locked, *offline) {
712                        AtStartup::Stale => {
713                            stale = true;
714                            Some(Outcome::Skipped)
715                        }
716                        AtStartup::Present => Some(Outcome::Unchanged),
717                        AtStartup::Missing => Some(Outcome::Skipped),
718                        AtStartup::Replay => None,
719                    }
720                }
721                Mode::Frozen if present => Some(Outcome::Unchanged),
722                _ if fresh && present && recorded && !local && !named => Some(Outcome::Unchanged),
723                _ => None,
724            };
725            if let Some(outcome) = keep {
726                staged.push(Staged {
727                    id: target.id,
728                    section,
729                    record: locked.cloned(),
730                    content: Content::Nothing,
731                    outcome,
732                    warnings: Vec::new(),
733                });
734                continue;
735            }
736            // Fresh and missing replays. Anything else resolves anew.
737            let resolve = !fresh || named;
738            progress(&format!("Installing {}...", target.id));
739            match self.stage(&target, resolve, npm, &mut work).await {
740                Ok(mut next) => {
741                    let id = &next.id;
742                    let warnings = next.warnings.drain(..);
743                    report
744                        .warnings
745                        .extend(warnings.map(|warning| format!("{id}: {warning}")));
746                    staged.push(next);
747                }
748                Err(err) => {
749                    discard(&staged);
750                    return Err(match err {
751                        ChestError::Invalid(message) => {
752                            invalid(format!("{}: {message}", target.id))
753                        }
754                        err => ChestError::Failed(format!("{}: {err}", target.id)),
755                    });
756                }
757            }
758        }
759        if stale {
760            report.warnings.push(self.stale_warning());
761        }
762        Ok((staged, work))
763    }
764
765    /// Gets one entry: from its `[patch]` or path source, or its git or npm
766    /// source, resolved anew or replayed as locked.
767    async fn stage(
768        &self,
769        target: &Target<'_>,
770        resolve: bool,
771        npm: &mut Npm,
772        work: &mut fetch::Work,
773    ) -> Result<Staged, ChestError> {
774        let Target {
775            section,
776            name,
777            entry,
778            locked,
779            ..
780        } = *target;
781        let staged = |record, content, outcome, warnings| Staged {
782            id: target.id.clone(),
783            section,
784            record,
785            content,
786            outcome,
787            warnings,
788        };
789        let hashed = |items: Vec<(String, PathBuf)>| -> Result<Vec<_>, ChestError> {
790            let hash = |(name, source): (String, PathBuf)| {
791                tree_hash(&source).map(|hash| (name, source, hash))
792            };
793            items.into_iter().map(hash).collect()
794        };
795        let path_source = match (&entry.git, &entry.npm) {
796            (None, None) => entry.path.as_deref(),
797            _ => None,
798        };
799        if let Some(path) = target
800            .patch
801            .and_then(|patch| patch.path.as_deref())
802            .or(path_source)
803        {
804            // A patch leaves the lock as it was, and a path entry locks its path.
805            let record = match target.patch {
806                Some(_) => locked.cloned(),
807                None => Some(Locked {
808                    name: name.to_owned(),
809                    path: entry.path.clone(),
810                    ..Locked::default()
811                }),
812            };
813            if section == Section::Extensions {
814                let outcome = match resolve {
815                    true => Outcome::Installed,
816                    false => Outcome::Unchanged,
817                };
818                return Ok(staged(record, Content::Nothing, outcome, Vec::new()));
819            }
820            let source = self.resolve(path);
821            // A patch for a package's items, an entry without `path`, is
822            // such a package too.
823            let patched_package =
824                target.patch.is_some() && path_source.is_none() && entry.path.is_none();
825            let (items, warnings) = if patched_package {
826                fetch::items(&source, section, None, name)?
827            } else {
828                fetch::check_item(section, &source)?;
829                let item = (fetch::placed_name(section, name), source);
830                (vec![item], Vec::new())
831            };
832            // An item holding a folder chest installs to would copy itself.
833            let sections = [Section::Skills, Section::Agents].into_iter();
834            let mut installs: Vec<PathBuf> = sections.flat_map(|s| self.homes(s)).collect();
835            installs.push(real_path(&self.store()));
836            for (_, item) in &items {
837                let real = real_path(item);
838                if let Some(home) = installs.iter().find(|home| home.starts_with(&real)) {
839                    return Err(invalid(format!(
840                        "{} holds {}, where chest installs, so it cannot be installed",
841                        item.display(),
842                        home.display()
843                    )));
844                }
845            }
846            let content = Content::Items(hashed(items)?);
847            return Ok(staged(record, content, Outcome::Installed, warnings));
848        }
849        let replay = locked.filter(|_| !resolve);
850        let mut record = Locked {
851            name: name.to_owned(),
852            git: entry.git.clone(),
853            rev: entry.rev.clone(),
854            npm: entry.npm.clone(),
855            path: entry.path.clone(),
856            ..Locked::default()
857        };
858        let package = work.path();
859        if let Some(repo) = &entry.git {
860            let commit = match replay {
861                Some(locked) => locked.commit.clone().unwrap_or_default(),
862                None => fetch::resolve(&self.cache, repo, entry.rev.as_deref()).await?,
863            };
864            fetch::checkout(&self.cache, repo, &commit, &package).await?;
865            record.commit = Some(commit);
866        }
867        let mut warnings = Vec::new();
868        let content = if section == Section::Extensions {
869            let dir = self.store().join(name);
870            place::within(&self.root(), &dir)?;
871            let staging = npm::stage(&dir)?;
872            let built = self
873                .build_extension(entry, replay, &package, &staging, npm, &mut record)
874                .await;
875            if let Err(err) = built {
876                let _ = std::fs::remove_dir_all(&staging);
877                return Err(err);
878            }
879            let hash = record.hash.clone().unwrap_or_default();
880            Content::Store { staging, dir, hash }
881        } else {
882            if let Some(npm_name) = &entry.npm {
883                let pin = match replay {
884                    Some(locked) => {
885                        let pin = Pinned {
886                            path: String::new(),
887                            name: npm_name.clone(),
888                            version: locked.version.clone().unwrap_or_default(),
889                            tarball: locked.tarball.clone().unwrap_or_default(),
890                            integrity: locked.integrity.clone().unwrap_or_default(),
891                        };
892                        npm::fetch_pinned(npm, &pin, &package).await?;
893                        pin
894                    }
895                    None => {
896                        let pin =
897                            npm::fetch(npm, npm_name, entry.version.as_deref(), &package).await?;
898                        // Refused now if replay would refuse it, not in every clone.
899                        npm.check_pin(&pin)?;
900                        pin
901                    }
902                };
903                record.version = Some(pin.version);
904                record.tarball = Some(pin.tarball);
905                record.integrity = Some(pin.integrity);
906            }
907            let (items, skipped) = fetch::items(&package, section, entry.path.as_deref(), name)?;
908            warnings = skipped;
909            let items = hashed(items)?;
910            if entry.path.is_some() {
911                record.hash = Some(items[0].2.clone());
912            } else {
913                for (item, _, hash) in &items {
914                    record.items.push(lock::Item {
915                        name: item.clone(),
916                        hash: hash.clone(),
917                    });
918                }
919            }
920            Content::Items(items)
921        };
922        let Some(locked) = replay else {
923            return Ok(staged(Some(record), content, Outcome::Installed, warnings));
924        };
925        if record.hash != locked.hash || record.items != locked.items {
926            if let Content::Store { staging, .. } = &content {
927                let _ = std::fs::remove_dir_all(staging);
928            }
929            return Err(invalid(
930                "what was fetched does not match the lock: run `chest update` to take it",
931            ));
932        }
933        Ok(staged(
934            Some(locked.clone()),
935            content,
936            Outcome::Replayed,
937            warnings,
938        ))
939    }
940
941    /// Builds extension `entry` in `staging`: an npm package installed into
942    /// it with its full tree, or the git checkout `package` with its
943    /// dependencies. Replays `replay`'s tree when given.
944    async fn build_extension(
945        &self,
946        entry: &Entry,
947        replay: Option<&Locked>,
948        package: &Path,
949        staging: &Path,
950        npm: &mut Npm,
951        record: &mut Locked,
952    ) -> Result<(), ChestError> {
953        match (&entry.npm, replay) {
954            (_, Some(locked)) => {
955                // The extension loads from the package's own folder, so the
956                // lock must pin the package itself there, at its version.
957                if let Some(npm_name) = &entry.npm {
958                    let root = format!("node_modules/{npm_name}");
959                    let version = locked.version.as_deref().unwrap_or_default();
960                    let pinned = locked.packages.iter().any(|pin| {
961                        pin.path == root && pin.name == *npm_name && pin.version == version
962                    });
963                    if !pinned {
964                        return Err(invalid(format!(
965                            "the lock does not pin {npm_name}@{version} at {root}"
966                        )));
967                    }
968                }
969                if entry.git.is_some() {
970                    copy_checkout(package, entry.path.as_deref(), staging)?;
971                }
972                // Replay reaches the package at the top of an npm extension's
973                // tree from a root package.json, as its install did.
974                let root = staging.join("package.json");
975                if let Some(npm_name) = &entry.npm {
976                    let version = locked.version.as_deref().unwrap_or_default();
977                    let manifest = serde_json::json!({"dependencies": {npm_name: version}});
978                    let text = yapi_types::json::to_string(&manifest).unwrap_or_default();
979                    std::fs::write(&root, text).map_err(io(&root))?;
980                }
981                npm::replay(npm, staging, &locked.packages).await?;
982                if entry.npm.is_some() {
983                    std::fs::remove_file(&root).map_err(io(&root))?;
984                }
985            }
986            (Some(npm_name), None) => {
987                let installed =
988                    npm::install(npm, staging, npm_name, entry.version.as_deref()).await?;
989                // Checked as replay will check it, while the root package.json
990                // still names the package at the top.
991                npm::check_installed(staging, &installed.tree)?;
992                // The store keeps only what replay rebuilds, `node_modules`.
993                for file in ["package.json", ".gitignore"] {
994                    let file = staging.join(file);
995                    std::fs::remove_file(&file).map_err(io(&file))?;
996                }
997                record.version = Some(installed.version);
998                record.packages = installed.tree;
999            }
1000            (None, None) => {
1001                copy_checkout(package, entry.path.as_deref(), staging)?;
1002                record.packages = npm::install_dependencies(npm, staging).await?;
1003                npm::check_installed(staging, &record.packages)?;
1004            }
1005        }
1006        // A tree that replay would refuse fails this install, not every later
1007        // one. A replayed tree records no pins: replay checked them.
1008        for pin in &record.packages {
1009            npm.check_pin(pin)?;
1010        }
1011        record.hash = Some(tree_hash(staging)?);
1012        Ok(())
1013    }
1014
1015    /// Puts staged entries in place and returns every path the entries
1016    /// occupy, kept ones included. Before writing anything, it checks each
1017    /// target: it must lie within the root, no other entry may occupy it, and
1018    /// what is there now must be chest's. That is a path the record lists and
1019    /// that still holds what chest put there, or an unrecorded path that
1020    /// holds exactly what the lock pins and what is about to replace it, with
1021    /// no `.git` inside, which chest then adopts. Each target is recorded
1022    /// before it is written, so a failure partway leaves it owned.
1023    fn commit(
1024        &self,
1025        staged: &[Staged],
1026        old: &Lock,
1027        record: &mut Record,
1028        record_path: &Path,
1029        report: &mut Report,
1030    ) -> Result<Vec<PathBuf>, ChestError> {
1031        let root = self.root();
1032        let pinned = self.pinned(old);
1033        let mut occupied: HashSet<PathBuf> = HashSet::new();
1034        for staged in staged {
1035            if let (Content::Nothing, Some(locked)) = (&staged.content, &staged.record) {
1036                occupied.extend(self.placed(staged.section, locked));
1037            }
1038        }
1039        // Each placement: what it copies, where to, and the hash it brings.
1040        let mut placements: Vec<Vec<(PathBuf, Vec<PathBuf>, String)>> = Vec::new();
1041        for staged in staged {
1042            let planned = match &staged.content {
1043                Content::Items(items) => {
1044                    let folders = self.folders(staged.section)?;
1045                    let plan = |(name, source, hash): &(String, PathBuf, String)| {
1046                        let targets = folders.iter().map(|folder| folder.join(name)).collect();
1047                        (source.clone(), targets, hash.clone())
1048                    };
1049                    items.iter().map(plan).collect()
1050                }
1051                Content::Store { staging, dir, hash } => {
1052                    vec![(staging.clone(), vec![dir.clone()], hash.clone())]
1053                }
1054                Content::Nothing => Vec::new(),
1055            };
1056            for (_, targets, hash) in &planned {
1057                for target in targets {
1058                    let refuse =
1059                        |why: &str| invalid(format!("{}: {why} {}", staged.id, target.display()));
1060                    place::within(&root, target)
1061                        .map_err(|err| invalid(format!("{}: {err}", staged.id)))?;
1062                    if !occupied.insert(target.clone()) {
1063                        return Err(refuse("another entry also installs"));
1064                    }
1065                    if target.symlink_metadata().is_err() {
1066                        continue;
1067                    }
1068                    match record.held(target) {
1069                        Held::Owned => {}
1070                        Held::Modified => {
1071                            return Err(invalid(format!(
1072                                "{}: {} was modified since chest installed it: remove it or restore it, then run chest again",
1073                                staged.id,
1074                                target.display()
1075                            )));
1076                        }
1077                        Held::Unrecorded => {
1078                            let lock_pins = pinned.get(target);
1079                            let adoptable = lock_pins == Some(hash)
1080                                && content_hash(target).as_ref() == lock_pins
1081                                && !has_git(target);
1082                            if !adoptable {
1083                                return Err(refuse(
1084                                    "refusing to replace what chest did not install at",
1085                                ));
1086                            }
1087                            record.adopt(target, hash);
1088                        }
1089                    }
1090                }
1091            }
1092            placements.push(planned);
1093        }
1094        record.save(record_path)?;
1095        for (staged, planned) in staged.iter().zip(placements) {
1096            for (source, targets, hash) in planned {
1097                let before: Vec<_> = targets.iter().map(|target| record.get(target)).collect();
1098                // Until it is noted, a target is chest's with the old or the new content.
1099                for target in &targets {
1100                    record.expect(target, &hash);
1101                }
1102                record.save(record_path)?;
1103                let placed = match &staged.content {
1104                    Content::Store { dir, .. } => npm::swap(&source, dir, Ok::<(), ChestError>(())),
1105                    _ => {
1106                        let folders = self.folders(staged.section)?;
1107                        let name = targets[0].file_name().unwrap_or_default().to_string_lossy();
1108                        place::place(&source, &root, &folders, &name).map(drop)
1109                    }
1110                };
1111                // Placed targets hold the new content, links by their target.
1112                // After a failure, what was not written goes back to its record.
1113                for (target, before) in targets.iter().zip(before) {
1114                    if placed.is_ok() || content_hash(target).as_ref() == Some(&hash) {
1115                        record.noted(target, &hash);
1116                    } else {
1117                        record.restore(target, before);
1118                    }
1119                }
1120                record.save(record_path)?;
1121                placed?;
1122            }
1123            report.entries.push((staged.id.clone(), staged.outcome));
1124        }
1125        Ok(occupied.into_iter().collect())
1126    }
1127
1128    /// Removes what the record lists for this manifest that none of its
1129    /// entries occupies any more, when it still holds what chest put there.
1130    /// A path another manifest of the project still occupies stays theirs,
1131    /// and so does one that is, by its real path, a path an entry occupies.
1132    /// A path changed since is left with a warning, and one chest cannot
1133    /// remove is forgotten with a warning. Reports the entries the manifest
1134    /// dropped.
1135    fn remove_dropped(
1136        &self,
1137        old: &Lock,
1138        occupied: &[PathBuf],
1139        record: &mut Record,
1140        record_path: &Path,
1141        report: &mut Report,
1142    ) -> Result<(), ChestError> {
1143        // The real path of a placed path: its folder resolved, itself not.
1144        let real = |path: &Path| match (path.parent(), path.file_name()) {
1145            (Some(parent), Some(name)) => real_path(parent).join(name),
1146            _ => path.to_path_buf(),
1147        };
1148        let really: HashSet<PathBuf> = occupied.iter().map(|path| real(path)).collect();
1149        let occupied: HashSet<&PathBuf> = occupied.iter().collect();
1150        for path in record.paths() {
1151            if occupied.contains(&path) {
1152                continue;
1153            }
1154            if record.shared(&path) || really.contains(&real(&path)) {
1155                record.release(&path);
1156                record.save(record_path)?;
1157                continue;
1158            }
1159            if path.symlink_metadata().is_ok() {
1160                match record.held(&path) {
1161                    Held::Modified => {
1162                        report.warnings.push(format!(
1163                            "{} was modified since chest installed it, so it stays: remove it or restore it",
1164                            path.display()
1165                        ));
1166                        continue;
1167                    }
1168                    _ => {
1169                        if let Err(err) = place::unplace(&self.root(), std::slice::from_ref(&path))
1170                        {
1171                            report.warnings.push(format!(
1172                                "chest could not remove {} and forgets it: {err}",
1173                                path.display()
1174                            ));
1175                        }
1176                    }
1177                }
1178            }
1179            record.forget(&path);
1180            record.save(record_path)?;
1181        }
1182        for section in [Section::Skills, Section::Agents, Section::Extensions] {
1183            for locked in old.section(section) {
1184                if !self.manifest.section(section).contains_key(&locked.name) {
1185                    let id = format!("{}.{}", section.key(), locked.name);
1186                    report.entries.push((id, Outcome::Removed));
1187                }
1188            }
1189        }
1190        Ok(())
1191    }
1192
1193    /// Lists every `occupied` path in the root's `.gitignore` block, with the
1194    /// staging and previous copies a stopped install can leave beside the
1195    /// copies it makes: in the first folder of each section and in the store.
1196    fn write_ignore(&self, occupied: &[PathBuf]) -> Result<(), ChestError> {
1197        let root = self.root();
1198        let relative = |path: &Path| -> Result<String, ChestError> {
1199            let relative = path.strip_prefix(&root).map_err(|_| {
1200                invalid(format!("{} is outside {}", path.display(), root.display()))
1201            })?;
1202            Ok(relative.to_string_lossy().replace('\\', "/"))
1203        };
1204        let mut entries = Vec::new();
1205        for path in occupied {
1206            entries.push(relative(path)?);
1207        }
1208        let first = |section| self.folders(section).ok()?.into_iter().next();
1209        let copies = [
1210            first(Section::Skills),
1211            first(Section::Agents),
1212            Some(self.store()),
1213        ];
1214        for home in copies.into_iter().flatten() {
1215            if occupied
1216                .iter()
1217                .any(|path| path.parent() == Some(home.as_path()))
1218            {
1219                let home = relative(&home)?;
1220                entries.push(format!("{home}/.*-staging"));
1221                entries.push(format!("{home}/.*-old"));
1222            }
1223        }
1224        place::write_ignore(&root, &self.file_name(), &entries)
1225    }
1226
1227    /// Notes each path source that resolves outside the project.
1228    fn path_notices(&self, report: &mut Report) {
1229        if self.scope != Scope::Project {
1230            return;
1231        }
1232        let entries = self
1233            .entries()
1234            .map(|(section, name, entry)| (format!("{}.{name}", section.key()), entry));
1235        let patches = self
1236            .manifest
1237            .patch
1238            .iter()
1239            .map(|(name, entry)| (format!("patch.{name}"), entry));
1240        for (id, entry) in entries.chain(patches) {
1241            let source = entry
1242                .path
1243                .as_deref()
1244                .filter(|_| entry.git.is_none() && entry.npm.is_none());
1245            let Some(path) = source else { continue };
1246            let resolved = self.resolve(path);
1247            if !resolved.starts_with(&self.base) {
1248                report.notices.push(format!(
1249                    "{id}: `{path}` is outside the project, at {}",
1250                    resolved.display()
1251                ));
1252            }
1253        }
1254    }
1255
1256    /// Fails on a missing or stale lock, or on an installed copy, copy
1257    /// fallback or link that does not match the lock. Whether something the
1258    /// lock pins is missing, to be put back. When nothing is, every entry is
1259    /// reported unchanged.
1260    fn check_frozen(&self, report: &mut Report) -> Result<bool, ChestError> {
1261        let name = self.file_name();
1262        let lock = self
1263            .lock
1264            .as_ref()
1265            .ok_or_else(|| invalid(format!("{name} has no lock: run `chest install`")))?;
1266        let drift = lock.drift(&self.manifest);
1267        if !drift.is_empty() {
1268            return Err(invalid(format!(
1269                "The lock of {name} is out of date for {}: run `chest install`",
1270                drift.join(", ")
1271            )));
1272        }
1273        let problems = self.problems(lock);
1274        let wrong: Vec<String> = problems
1275            .iter()
1276            .filter(|(_, _, problem)| !matches!(problem, Problem::Missing))
1277            .map(describe)
1278            .collect();
1279        if !wrong.is_empty() {
1280            return Err(invalid(wrong.join("\n")));
1281        }
1282        let missing = self.entries().any(|(section, entry_name, _)| {
1283            let locked = lock.section(section).iter();
1284            let mut found = locked.filter(|locked| &locked.name == entry_name);
1285            found.any(|locked| !self.present(section, locked))
1286        });
1287        if !missing {
1288            for (section, entry_name, _) in self.entries() {
1289                let id = format!("{}.{entry_name}", section.key());
1290                report.entries.push((id, Outcome::Unchanged));
1291            }
1292        }
1293        Ok(missing)
1294    }
1295
1296    /// Each installed copy of a locked git or npm entry that does not match
1297    /// the lock: canonical copies, copy fallbacks and store folders by their
1298    /// hash, links by what they point to. `[patch]` entries are not hashed.
1299    fn problems(&self, lock: &Lock) -> Vec<(String, PathBuf, Problem)> {
1300        let mut problems = Vec::new();
1301        for section in [Section::Skills, Section::Agents, Section::Extensions] {
1302            let homes = self.homes(section);
1303            for locked in lock.section(section) {
1304                if self.manifest.patch.contains_key(&locked.name) {
1305                    continue;
1306                }
1307                let id = format!("{}.{}", section.key(), locked.name);
1308                for (item, hash) in locked_items(section, locked) {
1309                    let Some(hash) = hash else { continue };
1310                    let canonical = homes.first().map(|home| home.join(&item));
1311                    for (index, home) in homes.iter().enumerate() {
1312                        let path = home.join(&item);
1313                        let problem = match path.symlink_metadata() {
1314                            Err(_) => Some(Problem::Missing),
1315                            Ok(metadata) if metadata.is_symlink() => {
1316                                let target = path.canonicalize().ok();
1317                                let canonical =
1318                                    canonical.as_ref().and_then(|c| c.canonicalize().ok());
1319                                (index == 0 || target != canonical)
1320                                    .then(|| Problem::Link(homes[0].join(&item)))
1321                            }
1322                            Ok(_) => (tree_hash(&path).ok().as_ref() != Some(&hash))
1323                                .then_some(Problem::Mismatch),
1324                        };
1325                        if let Some(problem) = problem {
1326                            problems.push((id.clone(), path, problem));
1327                        }
1328                    }
1329                }
1330            }
1331        }
1332        problems
1333    }
1334
1335    /// The folder installed extension `name` loads from: its `[patch]`
1336    /// path, its store folder, the npm package inside it, or its path
1337    /// source. `None` for an entry that is not an installed extension.
1338    pub fn installed_root(&self, name: &str) -> Option<PathBuf> {
1339        self.extension_roots(name)
1340            .map(|(root, _)| root)
1341            .filter(|root| root.exists())
1342    }
1343
1344    /// Where extension `name` loads from, as [`Project::installed_root`]
1345    /// says whether or not it exists, and the folder that holds the package
1346    /// and its own dependencies, which it may load modules from: the
1347    /// `[patch]` or path source, or `<store>/<name>` for git and npm.
1348    pub fn extension_roots(&self, name: &str) -> Option<(PathBuf, PathBuf)> {
1349        let entry = self.manifest.extensions.get(name)?;
1350        let patch = self
1351            .manifest
1352            .patch
1353            .get(name)
1354            .and_then(|patch| patch.path.as_deref());
1355        let store = self.store().join(name);
1356        Some(
1357            match (patch, &entry.npm, &entry.git, entry.path.as_deref()) {
1358                (Some(path), ..) | (None, None, None, Some(path)) => {
1359                    let root = self.resolve(path);
1360                    (root.clone(), root)
1361                }
1362                (None, Some(npm), _, _) => (store.join("node_modules").join(npm), store),
1363                (None, None, Some(_), _) => (store.clone(), store),
1364                (None, None, None, None) => return None,
1365            },
1366        )
1367    }
1368
1369    /// For [`Mode::Startup`]: when nothing must be put back, because every
1370    /// locked, current entry is present or `offline` leaves it missing, the
1371    /// report of what a sync would find, without taking the lock's lock.
1372    /// Whether it gave the report.
1373    fn startup_without_lock(
1374        &mut self,
1375        offline: bool,
1376        report: &mut Report,
1377    ) -> Result<bool, ChestError> {
1378        self.lock = self.read_lock()?;
1379        let Some(lock) = &self.lock else {
1380            report.warnings.push(format!(
1381                "{} has no lock, so nothing from it was installed: run `chest install`",
1382                self.file_name()
1383            ));
1384            return Ok(true);
1385        };
1386        let mut entries = Vec::new();
1387        let mut stale = false;
1388        let mut missing = Vec::new();
1389        for (section, name, entry) in self.entries() {
1390            let locked = lock
1391                .section(section)
1392                .iter()
1393                .find(|locked| &locked.name == name);
1394            let id = format!("{}.{name}", section.key());
1395            let outcome = match self.at_startup(section, name, entry, locked, offline) {
1396                AtStartup::Stale => {
1397                    stale = true;
1398                    Outcome::Skipped
1399                }
1400                AtStartup::Present => Outcome::Unchanged,
1401                AtStartup::Missing => {
1402                    missing.push(id.clone());
1403                    Outcome::Skipped
1404                }
1405                AtStartup::Replay => return Ok(false),
1406            };
1407            entries.push((id, outcome));
1408        }
1409        report.entries = entries;
1410        if stale {
1411            report.warnings.push(self.stale_warning());
1412        }
1413        if !missing.is_empty() {
1414            report.warnings.push(format!(
1415                "{}: {} could not be put back offline, so they were skipped",
1416                self.file_name(),
1417                missing.join(", ")
1418            ));
1419        }
1420        Ok(true)
1421    }
1422
1423    /// What startup does with entry `name` of `section`, locked as
1424    /// `locked`: a stale or unlocked entry is skipped, a present or patched
1425    /// one kept, and a missing one put back, unless `offline`.
1426    fn at_startup(
1427        &self,
1428        section: Section,
1429        name: &str,
1430        entry: &Entry,
1431        locked: Option<&Locked>,
1432        offline: bool,
1433    ) -> AtStartup {
1434        let patched = self.manifest.patch.contains_key(name);
1435        if !patched && !locked.is_some_and(|locked| fresh(entry, locked)) {
1436            return AtStartup::Stale;
1437        }
1438        // A patched entry loads from its patch, or where `chest install` put it.
1439        if patched || locked.is_some_and(|locked| self.present(section, locked)) {
1440            return AtStartup::Present;
1441        }
1442        if offline {
1443            AtStartup::Missing
1444        } else {
1445            AtStartup::Replay
1446        }
1447    }
1448
1449    fn stale_warning(&self) -> String {
1450        format!(
1451            "{}: entries that are not locked, or whose lock is stale, were skipped: run `chest install`",
1452            self.file_name()
1453        )
1454    }
1455
1456    /// Checks every installed copy of a locked git or npm entry: canonical
1457    /// copies, copy fallbacks and store folders by their hash, and links by
1458    /// what they point to. Returns one line per problem, so none means all
1459    /// match.
1460    pub fn verify(&self) -> Result<Vec<String>, ChestError> {
1461        let name = self.file_name();
1462        let lock = self
1463            .lock
1464            .as_ref()
1465            .ok_or_else(|| invalid(format!("{name} has no lock: run `chest install`")))?;
1466        Ok(self.problems(lock).iter().map(describe).collect())
1467    }
1468
1469    /// The newest revision each locked git or npm entry's source offers:
1470    /// the commit its `rev` names now, or the newest npm version in its
1471    /// range with npm's `latest`.
1472    pub async fn outdated(&self, npm: &mut Npm) -> Result<Vec<Outdated>, ChestError> {
1473        let Some(lock) = &self.lock else {
1474            return Ok(Vec::new());
1475        };
1476        let mut found = Vec::new();
1477        for (section, name, entry) in self.entries() {
1478            let Some(locked) = lock
1479                .section(section)
1480                .iter()
1481                .find(|locked| &locked.name == name)
1482            else {
1483                continue;
1484            };
1485            let id = format!("{}.{name}", section.key());
1486            let failed = |err: ChestError| ChestError::Failed(format!("{id}: {err}"));
1487            if let Some(repo) = &entry.git {
1488                let wanted = fetch::resolve(&self.cache, repo, entry.rev.as_deref())
1489                    .await
1490                    .map_err(failed)?;
1491                let locked = locked.commit.clone().unwrap_or_default();
1492                found.push(Outdated {
1493                    id,
1494                    locked,
1495                    wanted,
1496                    latest: None,
1497                });
1498            } else if let Some(package) = &entry.npm {
1499                let range = entry.version.as_deref().unwrap_or("latest");
1500                let wanted = npm
1501                    .latest(package, range)
1502                    .await
1503                    .map_err(|err| failed(err.into()))?;
1504                let latest = npm.latest(package, "latest").await.ok();
1505                let locked = locked.version.clone().unwrap_or_default();
1506                found.push(Outdated {
1507                    id,
1508                    locked,
1509                    wanted,
1510                    latest,
1511                });
1512            }
1513        }
1514        Ok(found)
1515    }
1516
1517    /// Fetches the package `entry` names, without its dependencies, to read
1518    /// what it provides and requests before it is added: a git source at the
1519    /// commit its `rev` names, an npm source at the newest version in its
1520    /// range, or a path source where it is.
1521    pub async fn fetch_package(&self, entry: &Entry, npm: &mut Npm) -> Result<Fetched, ChestError> {
1522        if entry.git.is_none() && entry.npm.is_none() {
1523            let path = entry
1524                .path
1525                .as_deref()
1526                .ok_or_else(|| invalid("needs `git`, `npm` or `path`"))?;
1527            return Ok(Fetched {
1528                root: self.resolve(path),
1529                _work: None,
1530            });
1531        }
1532        let mut work = fetch::Work::new(&self.cache)?;
1533        let root = work.path();
1534        if let Some(repo) = &entry.git {
1535            let commit = fetch::resolve(&self.cache, repo, entry.rev.as_deref()).await?;
1536            fetch::checkout(&self.cache, repo, &commit, &root).await?;
1537        } else if let Some(package) = &entry.npm {
1538            npm::fetch(npm, package, entry.version.as_deref(), &root).await?;
1539        }
1540        Ok(Fetched {
1541            root,
1542            _work: Some(work),
1543        })
1544    }
1545}
1546
1547/// What is wrong with one installed copy.
1548#[derive(Clone, Debug, PartialEq, Eq)]
1549enum Problem {
1550    /// Nothing is there.
1551    Missing,
1552    /// A copy whose hash differs from the lock's.
1553    Mismatch,
1554    /// A link that does not point at the canonical copy, given.
1555    Link(PathBuf),
1556}
1557
1558/// What `sync` says while another chest command holds the lock of `file`.
1559fn waiting_for(file: &Path) -> String {
1560    format!(
1561        "Waiting for another chest command, which holds the lock of {}...",
1562        file.display()
1563    )
1564}
1565
1566fn describe((id, path, problem): &(String, PathBuf, Problem)) -> String {
1567    let what = match problem {
1568        Problem::Missing => "is missing".to_owned(),
1569        Problem::Mismatch => "does not match the lock".to_owned(),
1570        Problem::Link(canonical) => format!("does not link to {}", canonical.display()),
1571    };
1572    format!("{id}: {} {what}", path.display())
1573}
1574
1575/// Each name `locked` occupies in its section's folders, with the hash the
1576/// lock pins for it. An extension from a path occupies none.
1577fn locked_items(section: Section, locked: &Locked) -> Vec<(String, Option<String>)> {
1578    if section == Section::Extensions {
1579        let stored = locked.git.is_some() || locked.npm.is_some();
1580        return stored
1581            .then(|| (locked.name.clone(), locked.hash.clone()))
1582            .into_iter()
1583            .collect();
1584    }
1585    if locked.items.is_empty() {
1586        return vec![(
1587            fetch::placed_name(section, &locked.name),
1588            locked.hash.clone(),
1589        )];
1590    }
1591    let items = locked.items.iter();
1592    items
1593        .map(|item| (item.name.clone(), Some(item.hash.clone())))
1594        .collect()
1595}
1596
1597/// Whether `path`, or what it links to, has a `.git` anywhere inside. Its
1598/// hash leaves `.git` out, so a matching hash says nothing about it.
1599fn has_git(path: &Path) -> bool {
1600    fn walk(dir: &Path) -> bool {
1601        let Ok(entries) = std::fs::read_dir(dir) else {
1602            return false;
1603        };
1604        entries.flatten().any(|entry| {
1605            entry.file_name() == ".git"
1606                || entry.file_type().is_ok_and(|kind| kind.is_dir()) && walk(&entry.path())
1607        })
1608    }
1609    let held = match path.symlink_metadata() {
1610        Ok(metadata) if metadata.is_symlink() => path.canonicalize().ok(),
1611        Ok(_) => Some(path.to_path_buf()),
1612        Err(_) => None,
1613    };
1614    held.is_some_and(|held| walk(&held))
1615}
1616
1617/// Copies the checkout `package`, or its `subpath`, into `staging`, without
1618/// a committed `node_modules`, which the dependency install or replay
1619/// rebuilds so that the hash does not depend on what the repository commits.
1620/// The subpath must be a folder: an extension is installed as a package.
1621fn copy_checkout(package: &Path, subpath: Option<&str>, staging: &Path) -> Result<(), ChestError> {
1622    let source = package.join(subpath.unwrap_or_default());
1623    fetch::inside(package, &source)?;
1624    if !source.is_dir() {
1625        return Err(invalid(format!(
1626            "`path` must name the folder that holds the extension, not a file: {}",
1627            subpath.unwrap_or_default()
1628        )));
1629    }
1630    place::copy(&source, staging)?;
1631    let modules = staging.join("node_modules");
1632    if modules.symlink_metadata().is_ok() {
1633        std::fs::remove_dir_all(&modules).map_err(io(&modules))?;
1634    }
1635    Ok(())
1636}