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
56/// `path` with `/` between its parts. Only the platform's separator turns
57/// into one: on Unix a `\` is part of a name.
58fn slashed(path: &Path) -> String {
59    let text = path.to_string_lossy();
60    text.replace(std::path::MAIN_SEPARATOR, "/")
61}
62
63fn io(path: &Path) -> impl FnOnce(std::io::Error) -> ChestError + '_ {
64    move |source| ChestError::Io {
65        path: path.to_path_buf(),
66        source,
67    }
68}
69
70/// What [`Project::sync`] does.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub enum Mode {
73    /// `chest install`: keep what is locked, fresh and present, holding
74    /// what the lock pins as chest's record says. Replay what is locked and
75    /// fresh but missing or different, resolve the rest, then write the
76    /// lock and the `.gitignore` block and remove what the manifest dropped.
77    /// A copy chest placed that changed since stays as it is, and its entry
78    /// is [`Outcome::Edited`].
79    Resolve,
80    /// `chest update`: [`Mode::Resolve`], resolving the named entries, or
81    /// all with no names, again even when their lock is fresh.
82    Update(Vec<String>),
83    /// `chest install --frozen`: fail on a missing or stale lock and on an
84    /// installed copy that no longer matches its hash, unless chest's
85    /// record holds it, placed for another lock and unchanged since. Put
86    /// back what is missing, and such copies, as the lock pins them. Never
87    /// writes the lock.
88    Frozen,
89    /// yapi startup: replay what is locked, fresh and missing, unless
90    /// `offline`, and skip the rest with one warning. Never writes the lock
91    /// or `.gitignore`.
92    Startup {
93        /// Whether to leave missing entries alone rather than download them.
94        offline: bool,
95    },
96}
97
98/// What happened to one entry.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum Outcome {
101    /// Resolved, or copied from a path source, and put in place.
102    Installed,
103    /// Put in place again exactly as locked.
104    Replayed,
105    /// Already in place as locked.
106    Unchanged,
107    /// Left as it is, wholly or in part: someone edited a copy chest placed.
108    Edited,
109    /// Left alone: stale, unlocked, offline, or not installed when frozen.
110    Skipped,
111    /// Removed, because the manifest no longer has it.
112    Removed,
113}
114
115/// What a sync did.
116#[derive(Clone, Debug, Default, PartialEq, Eq)]
117pub struct Report {
118    /// Each entry, as `section.name`, with what happened to it: manifest
119    /// entries in order, then removed ones.
120    pub entries: Vec<(String, Outcome)>,
121    /// Problems the user should act on.
122    pub warnings: Vec<String>,
123    /// Facts worth showing, such as a path source outside the project.
124    pub notices: Vec<String>,
125}
126
127/// A locked git or npm entry with the newest revision its source offers.
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub struct Outdated {
130    /// The entry, as `section.name`.
131    pub id: String,
132    /// The locked npm version or git commit.
133    pub locked: String,
134    /// The newest npm version in the entry's range, or the commit its `rev`
135    /// names now.
136    pub wanted: String,
137    /// npm's `latest` version, which may lie outside the range.
138    pub latest: Option<String>,
139}
140
141/// A package fetched to look at, kept until dropped.
142pub struct Fetched {
143    /// The package's folder: a checkout, an unpacked npm package, or a path
144    /// source.
145    pub root: PathBuf,
146    _work: Option<fetch::Work>,
147}
148
149/// One manifest entry and what the old lock says of it.
150struct Target<'a> {
151    id: String,
152    section: Section,
153    name: &'a str,
154    entry: &'a Entry,
155    patch: Option<&'a Entry>,
156    locked: Option<&'a Locked>,
157}
158
159/// An entry fetched, checked and ready to put in place.
160struct Staged {
161    id: String,
162    section: Section,
163    record: Option<Locked>,
164    content: Content,
165    outcome: Outcome,
166    /// Items of the package left out, with why.
167    warnings: Vec<String>,
168}
169
170enum Content {
171    /// Skills or agents: each placed name with the folder or file it copies
172    /// and its hash.
173    Items(Vec<(String, PathBuf, String)>),
174    /// An extension built in `staging`, beside its store folder `dir`, with
175    /// its hash.
176    Store {
177        staging: PathBuf,
178        dir: PathBuf,
179        hash: String,
180    },
181    /// Nothing to put in place.
182    Nothing,
183}
184
185/// Locks `file` as [`FileLock::acquire`] does, but waits for as long as
186/// another holder keeps the lock fresh, where `acquire` gives up after the
187/// stale period. A holder that stopped stops refreshing, so its lock goes
188/// stale and is taken over. `waiting` hears once that the lock is held,
189/// when it is not free within a second.
190pub async fn hold_lock(
191    file: &Path,
192    waiting: &mut (dyn FnMut() + Send),
193) -> Result<FileLock, ChestError> {
194    let cancel = CancellationToken::new();
195    let failed = |err: LockError| ChestError::Failed(format!("{}: {err}", file.display()));
196    let first = tokio::time::timeout(Duration::from_secs(1), FileLock::acquire(file, &cancel));
197    match first.await {
198        Ok(Ok(lock)) => return Ok(lock),
199        Ok(Err(LockError::Held)) | Err(_) => waiting(),
200        Ok(Err(err)) => return Err(failed(err)),
201    }
202    loop {
203        match FileLock::acquire(file, &cancel).await {
204            Err(LockError::Held) => {}
205            locked => return locked.map_err(failed),
206        }
207    }
208}
209
210/// Fails when `path` is a symbolic link. chest reads and writes a manifest
211/// and its lock only as plain files, since a cloned project could point a
212/// link anywhere.
213pub fn refuse_link(path: &Path) -> Result<(), ChestError> {
214    if path.symlink_metadata().is_ok_and(|meta| meta.is_symlink()) {
215        return Err(invalid(format!(
216            "{} is a symbolic link, which chest does not follow: replace it with the file itself",
217            path.display()
218        )));
219    }
220    Ok(())
221}
222
223/// Removes the extensions `staged` built and did not put in place.
224fn discard(staged: &[Staged]) {
225    for staged in staged {
226        if let Content::Store { staging, .. } = &staged.content {
227            let _ = std::fs::remove_dir_all(staging);
228        }
229    }
230}
231
232/// `path` with its deepest existing part resolved to its real path, links
233/// followed and, where the file system ignores case, as the disk spells it.
234fn real_path(path: &Path) -> PathBuf {
235    let mut rest = Vec::new();
236    let mut current = path;
237    loop {
238        if let Ok(real) = current.canonicalize() {
239            return rest.iter().rev().fold(real, |real, part| real.join(part));
240        }
241        match (current.parent(), current.file_name()) {
242            (Some(parent), Some(name)) => {
243                rest.push(name.to_owned());
244                current = parent;
245            }
246            _ => return path.to_path_buf(),
247        }
248    }
249}
250
251/// A `chest.toml` with its lock, and the folders its entries go to.
252#[derive(Clone, Debug)]
253pub struct Project {
254    /// The manifest.
255    pub manifest: Manifest,
256    /// Its lock, when there is one.
257    pub lock: Option<Lock>,
258    /// The manifest's folder, which its relative paths start from.
259    pub base: PathBuf,
260    /// Whether it is a project's manifest or the user's global one.
261    pub scope: Scope,
262    /// Where git clones are cached and work is staged: `~/.chest/cache`
263    /// unless set otherwise.
264    pub cache: PathBuf,
265    /// Where chest records what it placed on this machine, outside any
266    /// repository: `~/.chest/state` unless set otherwise.
267    pub state: PathBuf,
268    path: PathBuf,
269    /// The canonical home folder, which `~` names.
270    pub home: PathBuf,
271}
272
273/// What startup does with an entry, as its lock and folders stand.
274enum AtStartup {
275    /// Not locked, or locked for another source: skipped.
276    Stale,
277    /// In place, or patched: kept.
278    Present,
279    /// Missing or outdated, and offline: skipped.
280    Missing,
281    /// Missing, or in place as an older lock pinned it: put back from the
282    /// lock.
283    Replay,
284}
285
286/// How messages name manifest `path` of `scope`: its file name in a
287/// project, and its path from `~`, the folder `home`, for the global one,
288/// which no project folder holds.
289pub fn manifest_label(path: &Path, scope: Scope, home: &Path) -> String {
290    match (scope, path.strip_prefix(home)) {
291        (Scope::Global, Ok(path)) => format!("~/{}", path.display()),
292        (Scope::Global, Err(_)) => path.display().to_string(),
293        (Scope::Project, _) => path
294            .file_name()
295            .map_or_else(String::new, |name| name.to_string_lossy().into_owned()),
296    }
297}
298
299/// `chest` with the flag that selects manifest `path` of `scope`, for the
300/// commands messages name: `chest -g`, `chest`, or `chest --manifest` with
301/// the manifest's file name, for a run in its folder.
302pub fn command(path: &Path, scope: Scope) -> String {
303    let name = path.file_name().unwrap_or_default().to_string_lossy();
304    match (scope, &*name) {
305        (Scope::Global, _) => "chest -g".to_owned(),
306        (Scope::Project, "chest.toml") => "chest".to_owned(),
307        (Scope::Project, name) => format!("chest --manifest {name}"),
308    }
309}
310
311/// The command that installs the entries of manifest `path` of `scope`, for
312/// messages.
313pub fn install_command(path: &Path, scope: Scope) -> String {
314    format!("{} install", command(path, scope))
315}
316
317impl Project {
318    /// The manifest at `path` and its lock, the file beside it named with
319    /// `.lock` for `.toml`: `chest.dev.toml` locks in `chest.dev.lock`.
320    ///
321    /// The manifest's folder is resolved to its canonical path once, so a
322    /// project opened through a link or another spelling of its path places,
323    /// records and removes the same paths. A manifest or lock that is itself
324    /// a link is refused, as [`refuse_link`] says.
325    pub fn open(path: &Path, scope: Scope) -> Result<Project, ChestError> {
326        let path = std::path::absolute(path).map_err(io(path))?;
327        refuse_link(&path)?;
328        let text = std::fs::read_to_string(&path).map_err(io(&path))?;
329        let manifest = Manifest::parse(&text, scope)
330            .map_err(|err| invalid(format!("{}: {err}", path.display())))?;
331        let folder = path.parent().unwrap_or(&path);
332        let base = folder.canonicalize().map_err(io(folder))?;
333        let home = home_dir();
334        let mut project = Project {
335            manifest,
336            lock: None,
337            path: base.join(path.file_name().unwrap_or_default()),
338            base,
339            scope,
340            cache: home.join(".chest/cache"),
341            state: home.join(".chest/state"),
342            home: home.canonicalize().unwrap_or(home),
343        };
344        project.lock = project.read_lock()?;
345        Ok(project)
346    }
347
348    /// The user's global manifest, `~/.chest/chest.toml`.
349    pub fn global() -> Result<Project, ChestError> {
350        Project::open(&home_dir().join(".chest/chest.toml"), Scope::Global)
351    }
352
353    /// Where the lock is.
354    pub fn lock_path(&self) -> PathBuf {
355        self.path.with_extension("lock")
356    }
357
358    /// The folder chest never writes outside of: the project's own for a
359    /// project manifest, home for the global one.
360    pub fn root(&self) -> PathBuf {
361        match self.scope {
362            Scope::Project => self.base.clone(),
363            Scope::Global => self.home.clone(),
364        }
365    }
366
367    /// The extension store: `[folders] extensions`, else `.yapi/chest` in a
368    /// project and `~/.chest/store` for the global manifest.
369    pub fn store(&self) -> PathBuf {
370        match (&self.manifest.folders.extensions, self.scope) {
371            (Some(store), _) => self.resolve(store),
372            (None, Scope::Project) => self.base.join(".yapi/chest"),
373            (None, Scope::Global) => self.home.join(".chest/store"),
374        }
375    }
376
377    /// `path` from the manifest, against its folder, with `~` the canonical
378    /// home folder.
379    fn resolve(&self, path: &str) -> PathBuf {
380        let path = match path.strip_prefix('~') {
381            Some("") => self.home.clone(),
382            Some(rest) if rest.starts_with('/') => self.home.join(&rest[1..]),
383            _ => PathBuf::from(path),
384        };
385        resolve_lexically(&self.base, &path)
386    }
387
388    /// Whether `folder`, an extension's module root, holds far more than one
389    /// package, so the extension could load any file there: it is, or holds,
390    /// the home folder, the agent folder, the filesystem's root, the
391    /// manifest's folder, which is the project's for a project manifest, the
392    /// store, which holds the other extensions, or a folder in `also`. A
393    /// held folder that does not exist yet counts by the path it will have.
394    pub fn too_wide(&self, folder: &Path, also: &[&Path]) -> bool {
395        let Ok(folder) = folder.canonicalize() else {
396            return false;
397        };
398        let held = [
399            self.home.clone(),
400            crate::config::agent_dir(),
401            PathBuf::from("/"),
402            self.base.clone(),
403            self.store(),
404        ];
405        held.into_iter()
406            .chain(also.iter().map(|path| path.to_path_buf()))
407            .any(|held| real_path(&held).starts_with(&folder))
408    }
409
410    /// Refuses an extension whose path source, or `[patch]`, is a folder
411    /// [`Project::too_wide`] for it.
412    fn refuse_wide(&self) -> Result<(), ChestError> {
413        for (name, entry) in &self.manifest.extensions {
414            let patch = self.manifest.patch.get(name);
415            let patch = patch.and_then(|patch| patch.path.as_deref());
416            let source = entry.path.as_deref();
417            let source = source.filter(|_| entry.git.is_none() && entry.npm.is_none());
418            let (id, path) = match (patch, source) {
419                (Some(path), _) => (format!("patch.{name}"), path),
420                (None, Some(path)) => (format!("extensions.{name}"), path),
421                (None, None) => continue,
422            };
423            let folder = self.resolve(path);
424            if self.too_wide(&folder, &[]) {
425                return Err(invalid(format!(
426                    "{id}: {} holds more than the extension, such as the project, the home or the agent folder, so chest does not install it",
427                    folder.display()
428                )));
429            }
430        }
431        Ok(())
432    }
433
434    /// The folders of skill or agent entries, the canonical one first, by
435    /// real path. A folder that links to an earlier one, or spells it
436    /// another way, is left out, so nothing is placed there twice.
437    fn folders(&self, section: Section) -> Result<Vec<PathBuf>, ChestError> {
438        let folders = match section {
439            Section::Skills => &self.manifest.folders.skills,
440            _ => &self.manifest.folders.agents,
441        };
442        if folders.is_empty() {
443            let key = section.key();
444            return Err(invalid(format!(
445                "Set `{key}` in [folders] to say where {key} go"
446            )));
447        }
448        let mut real: Vec<PathBuf> = Vec::new();
449        for folder in folders {
450            let path = real_path(&self.resolve(folder));
451            if !real.contains(&path) {
452                real.push(path);
453            }
454        }
455        Ok(real)
456    }
457
458    fn read_lock(&self) -> Result<Option<Lock>, ChestError> {
459        let path = self.lock_path();
460        refuse_link(&path)?;
461        match std::fs::read_to_string(&path) {
462            Ok(text) => Lock::parse(&text).map(Some).map_err(|err| {
463                let label = manifest_label(&path, self.scope, &self.home);
464                invalid(format!("{label}: {err}"))
465            }),
466            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
467            Err(err) => Err(io(&path)(err)),
468        }
469    }
470
471    fn file_name(&self) -> String {
472        self.path
473            .file_name()
474            .map_or_else(String::new, |name| name.to_string_lossy().into_owned())
475    }
476
477    /// How messages name the manifest, as [`manifest_label`] does.
478    fn label(&self) -> String {
479        manifest_label(&self.path, self.scope, &self.home)
480    }
481
482    /// `chest` with the flag that selects the manifest, as [`command`]
483    /// says.
484    fn command(&self) -> String {
485        command(&self.path, self.scope)
486    }
487
488    /// The command that installs the manifest's entries, for messages.
489    fn install_command(&self) -> String {
490        install_command(&self.path, self.scope)
491    }
492
493    /// The skill, agent and extension entries, in manifest order.
494    fn entries(&self) -> impl Iterator<Item = (Section, &String, &Entry)> {
495        self.manifest.entries()
496    }
497
498    /// The folders a section's items go in: its configured folders, or the
499    /// store for extensions.
500    fn homes(&self, section: Section) -> Vec<PathBuf> {
501        match section {
502            Section::Extensions => vec![self.store()],
503            _ => self.folders(section).unwrap_or_default(),
504        }
505    }
506
507    /// Each name `locked` occupies in its section's folders, with the hash
508    /// the lock pins for it. An extension from a path occupies none. A path
509    /// entry that names a package occupies each item the package provides
510    /// now, unhashed, as path entries are never hashed.
511    pub(crate) fn locked_items(
512        &self,
513        section: Section,
514        locked: &Locked,
515    ) -> Vec<(String, Option<String>)> {
516        if section == Section::Extensions {
517            let stored = locked.git.is_some() || locked.npm.is_some();
518            return stored
519                .then(|| (locked.name.clone(), locked.hash.clone()))
520                .into_iter()
521                .collect();
522        }
523        let path_source = match (&locked.git, &locked.npm) {
524            (None, None) => locked.path.as_deref().map(|path| self.resolve(path)),
525            _ => None,
526        };
527        if let Some(package) = path_source.filter(|source| fetch::is_package(section, source)) {
528            let items = fetch::items(&package, section, None, &locked.name);
529            let items = items.map(|(items, _)| items).unwrap_or_default();
530            return items.into_iter().map(|(name, _)| (name, None)).collect();
531        }
532        if locked.items.is_empty() {
533            return vec![(
534                fetch::placed_name(section, &locked.name),
535                locked.hash.clone(),
536            )];
537        }
538        let items = locked.items.iter();
539        items
540            .map(|item| (item.name.clone(), Some(item.hash.clone())))
541            .collect()
542    }
543
544    /// The paths `locked` occupies: each of its items in each of its
545    /// section's folders, or its store folder.
546    pub fn placed(&self, section: Section, locked: &Locked) -> Vec<PathBuf> {
547        let homes = self.homes(section);
548        let items = self.locked_items(section, locked);
549        items
550            .iter()
551            .flat_map(|(name, _)| homes.iter().map(move |home| home.join(name)))
552            .collect()
553    }
554
555    /// The hash the lock pins for each path its entries occupy.
556    fn pinned(&self, lock: &Lock) -> HashMap<PathBuf, String> {
557        let mut pinned = HashMap::new();
558        for section in [Section::Skills, Section::Agents, Section::Extensions] {
559            let homes = self.homes(section);
560            for locked in lock.section(section) {
561                for (name, hash) in self.locked_items(section, locked) {
562                    let Some(hash) = hash else { continue };
563                    for home in &homes {
564                        pinned.insert(home.join(&name), hash.clone());
565                    }
566                }
567            }
568        }
569        pinned
570    }
571
572    /// Whether `record` says each path `locked` occupies holds what the lock
573    /// pins, with no placement in progress. A link counts by the copy it
574    /// points to.
575    fn recorded(&self, section: Section, locked: &Locked, record: &Record) -> bool {
576        let homes = self.homes(section);
577        let items = self.locked_items(section, locked);
578        items.iter().all(|(name, hash)| {
579            let holds = |hash: &String| {
580                homes
581                    .iter()
582                    .all(|home| record.holds(&home.join(name), hash))
583            };
584            hash.as_ref().is_some_and(holds)
585        })
586    }
587
588    /// The record of what chest placed for this manifest's project, read
589    /// without its lock: for startup, which only reads it.
590    fn record(&self) -> Record {
591        let path = Record::path(&self.state, &self.root(), self.scope);
592        Record::load(&path, &self.root(), &self.file_name())
593    }
594
595    /// Whether the copies of `locked` that [`Project::present`] finds are
596    /// the ones the lock pins: by `record` for a git or npm entry, and
597    /// always for one from a path, which has no hash. A copy that an older
598    /// lock pinned is present but not pinned.
599    fn holds_locked(
600        &self,
601        section: Section,
602        entry: &Entry,
603        locked: &Locked,
604        record: &Record,
605    ) -> bool {
606        entry.git.is_none() && entry.npm.is_none() || self.recorded(section, locked, record)
607    }
608
609    /// Whether `record` says each item `content` stages is in every folder
610    /// of `section` with the hash it brings, unchanged since. An extension
611    /// from a path stages nothing, so it always is.
612    fn holds_staged(&self, section: Section, content: &Content, record: &Record) -> bool {
613        let items = match content {
614            Content::Items(items) => items,
615            Content::Nothing => return true,
616            Content::Store { .. } => return false,
617        };
618        let homes = self.homes(section);
619        items.iter().all(|(item, _, hash)| {
620            homes.iter().all(|home| {
621                let path = home.join(item);
622                record.holds(&path, hash) && record.held(&path) == Held::Owned
623            })
624        })
625    }
626
627    /// The copies `locked` occupies that `record` says changed since chest
628    /// placed them: [`Problem::Modified`] when still there, else
629    /// [`Problem::Missing`]. A patched extension loads from its patch, so its
630    /// store folder may go.
631    fn changed(
632        &self,
633        section: Section,
634        locked: &Locked,
635        record: &Record,
636    ) -> Vec<(PathBuf, Problem)> {
637        let placed = self.placed(section, locked).into_iter();
638        let changed = placed.filter(|path| record.held(path) == Held::Modified);
639        changed
640            .filter_map(|path| match path.symlink_metadata() {
641                Ok(_) => Some((path, Problem::Modified)),
642                Err(_) => (section != Section::Extensions).then_some((path, Problem::Missing)),
643            })
644            .collect()
645    }
646
647    /// The state of entry `name` of `section` that yapi's startup acts on,
648    /// as `chest list` shows it: under `[patch]`, `missing` when a copy chest
649    /// placed is gone, `edited` when one changed since, else `patched`.
650    /// Otherwise, `no source` for a path entry whose source is gone, `stale`
651    /// when not locked or locked for another source, `missing` when not in
652    /// place, `edited` when a copy chest placed changed since, whatever lock
653    /// pinned it, `outdated` when in place as an older lock pinned it, else
654    /// `installed`. Startup hashes no copies, so it loads an `edited` entry
655    /// that the lock still pins, or that is patched, as it would unedited.
656    pub fn state(&self, section: Section, name: &str, entry: &Entry) -> &'static str {
657        if self.no_source(name, entry) {
658            return "no source";
659        }
660        let patched = self.manifest.patch.contains_key(name);
661        let locked = self.lock.as_ref().and_then(|lock| {
662            lock.section(section)
663                .iter()
664                .find(|locked| locked.name == name)
665                .filter(|locked| fresh(entry, locked))
666        });
667        let record = self.record();
668        let changed = |locked: &Locked, problem: Problem| {
669            let changed = self.changed(section, locked, &record);
670            changed.iter().any(|(_, found)| *found == problem)
671        };
672        match locked {
673            Some(locked) if patched && changed(locked, Problem::Missing) => "missing",
674            Some(locked) if patched && changed(locked, Problem::Modified) => "edited",
675            _ if patched => "patched",
676            None => "stale",
677            Some(locked) if !self.present(section, locked) => "missing",
678            Some(locked) if changed(locked, Problem::Modified) => "edited",
679            Some(locked) if !self.holds_locked(section, entry, locked, &record) => "outdated",
680            Some(_) => "installed",
681        }
682    }
683
684    /// Whether entry `name` is a path entry, without a `[patch]`, whose
685    /// source is gone.
686    fn no_source(&self, name: &str, entry: &Entry) -> bool {
687        let path_source = match (&entry.git, &entry.npm) {
688            (None, None) => entry.path.as_deref(),
689            _ => None,
690        };
691        let patched = self.manifest.patch.contains_key(name);
692        !patched && path_source.is_some_and(|path| !self.resolve(path).exists())
693    }
694
695    /// Whether everything `locked` occupies is there. A link in the first
696    /// folder, where the copy belongs, counts as missing, as after the
697    /// folders were reordered. An extension from a path loads where it is,
698    /// so it always is.
699    pub fn present(&self, section: Section, locked: &Locked) -> bool {
700        let placed = self.placed(section, locked);
701        let in_place = section == Section::Extensions && placed.is_empty();
702        let first = self.homes(section).into_iter().next();
703        let there = |path: &PathBuf| match path.symlink_metadata() {
704            Ok(metadata) => !metadata.is_symlink() || path.parent() != first.as_deref(),
705            Err(_) => false,
706        };
707        in_place || !placed.is_empty() && placed.iter().all(there)
708    }
709
710    /// Brings the installed entries in line with the manifest and lock as
711    /// `mode` says, holding the lock file's lock and the project's record
712    /// lock throughout. Every entry is fetched and checked before anything
713    /// is put in place, so a failed download changes nothing. Nothing is
714    /// installed outside [`Project::root`]. Only chest's cache and record,
715    /// under `~/.chest` unless set otherwise, are written elsewhere.
716    /// `progress` hears a line for each entry fetched, and one while another
717    /// chest command holds a lock.
718    pub async fn sync(
719        &mut self,
720        mode: Mode,
721        npm: &mut Npm,
722        progress: &mut (dyn FnMut(&str) + Send),
723    ) -> Result<Report, ChestError> {
724        let mut report = Report::default();
725        // yapi's startup skips such an entry on its own, with a warning, and
726        // leaves the notes to installs.
727        if !matches!(mode, Mode::Startup { .. }) {
728            self.refuse_wide()?;
729            self.path_notices(&mut report);
730        }
731        let frozen = mode == Mode::Frozen;
732        // Frozen with everything in place writes nothing, not even the
733        // lock's lock.
734        if frozen {
735            self.lock = self.read_lock()?;
736            if !self.check_frozen(&mut report)? {
737                return Ok(report);
738            }
739        }
740        // Startup takes the lock's lock only to put something back.
741        if let Mode::Startup { offline } = mode
742            && self.startup_without_lock(offline, &mut report)?
743        {
744            return Ok(report);
745        }
746        let lock_path = self.lock_path();
747        let mut waiting = || progress(&waiting_for(&lock_path));
748        let _held = hold_lock(&lock_path, &mut waiting).await?;
749        self.lock = self.read_lock()?;
750        // Another install may have changed the lock or put things back meanwhile.
751        if frozen && !self.check_frozen(&mut report)? {
752            return Ok(report);
753        }
754        let startup = matches!(mode, Mode::Startup { .. });
755        let old = match (&self.lock, startup) {
756            (Some(lock), _) => lock.clone(),
757            (None, false) => Lock::default(),
758            (None, true) => {
759                report.warnings.push(format!(
760                    "{} has no lock, so nothing from it was installed: run `{}`",
761                    self.label(),
762                    self.install_command()
763                ));
764                return Ok(report);
765            }
766        };
767        // Every manifest of the project shares its record, so one install at
768        // a time places and removes.
769        let record_path = Record::path(&self.state, &self.root(), self.scope);
770        let state = record_path.parent().unwrap_or(&record_path);
771        std::fs::create_dir_all(state).map_err(io(state))?;
772        let mut waiting = || progress(&waiting_for(&record_path));
773        let _record_held = hold_lock(&record_path, &mut waiting).await?;
774        self.prepare_folders()?;
775        let mut record = Record::load(&record_path, &self.root(), &self.file_name());
776        // `_work` holds the fetched items until they are placed.
777        let (staged, _work) = self
778            .prepare(&mode, &old, &record, npm, progress, &mut report)
779            .await?;
780        let committed = self.commit(
781            &staged,
782            &old,
783            frozen,
784            &mut record,
785            &record_path,
786            &mut report,
787        );
788        let occupied = match committed {
789            Ok(occupied) => occupied,
790            Err(err) => {
791                discard(&staged);
792                return Err(err);
793            }
794        };
795        if startup {
796            return Ok(report);
797        }
798        let mut lock = Lock::default();
799        for staged in staged {
800            let Some(record) = staged.record else {
801                continue;
802            };
803            match staged.section {
804                Section::Skills => lock.skills.push(record),
805                Section::Agents => lock.agents.push(record),
806                _ => lock.extensions.push(record),
807            }
808        }
809        let kept = self.held_back(&old, &occupied, &record, &mut report);
810        self.remove_dropped(&old, &kept, &mut record, &record_path, &mut report)?;
811        let text = lock.to_toml()?;
812        // Frozen installs what the lock pins and never writes it.
813        if !frozen && std::fs::read_to_string(&lock_path).ok().as_deref() != Some(text.as_str()) {
814            write_atomic(&lock_path, text.as_bytes(), 0o666).map_err(io(&lock_path))?;
815        }
816        if self.scope == Scope::Project {
817            self.write_ignore(&occupied)?;
818        }
819        self.lock = Some(lock);
820        Ok(report)
821    }
822
823    /// Before anything is placed: makes the folders of sections with
824    /// entries, so that a folder linking to another resolves, refuses
825    /// folders whose real paths nest or that two sections share, and removes
826    /// the staging and previous copies a stopped install left in them or in
827    /// the store, when they are inside [`Project::root`].
828    fn prepare_folders(&self) -> Result<(), ChestError> {
829        let root = self.root();
830        let mut homes: Vec<PathBuf> = Vec::new();
831        for section in [Section::Skills, Section::Agents] {
832            let configured = match section {
833                Section::Skills => &self.manifest.folders.skills,
834                _ => &self.manifest.folders.agents,
835            };
836            if !self.manifest.section(section).is_empty() {
837                for folder in configured {
838                    let path = self.resolve(folder);
839                    // `within` judges an item by its folder.
840                    place::within(&root, &path.join("item"))?;
841                    // A folder that is a link stays one.
842                    if path.symlink_metadata().is_err() {
843                        std::fs::create_dir_all(&path).map_err(io(&path))?;
844                    }
845                }
846            }
847            let mut reals: Vec<PathBuf> = Vec::new();
848            for folder in configured {
849                let real = real_path(&self.resolve(folder));
850                if !reals.contains(&real) {
851                    reals.push(real);
852                }
853            }
854            homes.extend(reals);
855        }
856        homes.push(real_path(&self.store()));
857        for (index, first) in homes.iter().enumerate() {
858            for second in &homes[index + 1..] {
859                if first.starts_with(second) || second.starts_with(first) {
860                    return Err(invalid(format!(
861                        "folders: {} and {} overlap",
862                        first.display(),
863                        second.display()
864                    )));
865                }
866            }
867        }
868        for home in &homes {
869            // A folder of a section without entries may link out of the root.
870            if place::within(&root, &home.join("item")).is_err() {
871                continue;
872            }
873            for entry in std::fs::read_dir(home).into_iter().flatten().flatten() {
874                let name = entry.file_name().to_string_lossy().into_owned();
875                let stopped = name.ends_with("-staging") || name.ends_with("-old");
876                if name.starts_with('.') && stopped {
877                    let path = entry.path();
878                    npm::remove_path(&path).map_err(io(&path))?;
879                }
880            }
881        }
882        Ok(())
883    }
884
885    /// Fetches and stages each entry that needs it, and keeps the others as
886    /// they are, with the work folder that holds what it fetched. On
887    /// failure, removes what it staged.
888    async fn prepare(
889        &self,
890        mode: &Mode,
891        old: &Lock,
892        record: &Record,
893        npm: &mut Npm,
894        progress: &mut (dyn FnMut(&str) + Send),
895        report: &mut Report,
896    ) -> Result<(Vec<Staged>, fetch::Work), ChestError> {
897        if let Mode::Update(names) = mode
898            && let Some(unknown) = names
899                .iter()
900                .find(|name| self.entries().all(|(_, entry, _)| entry != *name))
901        {
902            return Err(invalid(format!("No entry is named {unknown}")));
903        }
904        let mut work = fetch::Work::new(&self.cache)?;
905        let mut staged: Vec<Staged> = Vec::new();
906        let mut stale = Vec::new();
907        for (section, name, entry) in self.entries() {
908            let locked = old
909                .section(section)
910                .iter()
911                .find(|locked| &locked.name == name);
912            let target = Target {
913                id: format!("{}.{name}", section.key()),
914                section,
915                name,
916                entry,
917                patch: self.manifest.patch.get(name),
918                locked,
919            };
920            let fresh = locked.is_some_and(|locked| fresh(entry, locked));
921            let present = locked.is_some_and(|locked| self.present(section, locked));
922            let recorded = locked.is_some_and(|locked| self.recorded(section, locked, record));
923            let local = target.patch.is_some() || entry.git.is_none() && entry.npm.is_none();
924            let named = matches!(mode, Mode::Update(names)
925                if names.is_empty() || names.iter().any(|n| n == name));
926            // Past `check_frozen`, a copy that differs from the lock is one
927            // chest placed for another lock, which goes back as this one pins.
928            let matches = |locked: &Locked| self.copy_problems(section, locked).is_empty();
929            let keep = match mode {
930                Mode::Startup { offline } => {
931                    match self.at_startup(section, name, entry, locked, record, *offline) {
932                        AtStartup::Stale => {
933                            stale.push(target.id.clone());
934                            Some(Outcome::Skipped)
935                        }
936                        AtStartup::Present => Some(Outcome::Unchanged),
937                        AtStartup::Missing => Some(Outcome::Skipped),
938                        AtStartup::Replay => None,
939                    }
940                }
941                Mode::Frozen
942                    if present
943                        && (recorded || locked.is_some_and(matches))
944                        && !self.no_source(name, entry) =>
945                {
946                    Some(Outcome::Unchanged)
947                }
948                // Frozen fails on it as it stages, and the rest still install.
949                mode if *mode != Mode::Frozen && self.no_source(name, entry) => {
950                    let path = entry.path.as_deref().unwrap_or_default();
951                    report.warnings.push(format!(
952                        "{}: {} does not exist, so chest skipped it: restore it or remove the entry",
953                        target.id,
954                        self.resolve(path).display()
955                    ));
956                    Some(Outcome::Skipped)
957                }
958                _ if fresh && present && recorded && !local && !named => {
959                    let problems =
960                        locked.map_or_else(Vec::new, |locked| self.copy_problems(section, locked));
961                    for problem in &problems {
962                        report.warnings.push(format!(
963                            "{}, so chest left it as it is: delete it and run `{}` to put back the locked copy",
964                            describe(problem),
965                            self.install_command()
966                        ));
967                    }
968                    Some(match problems.is_empty() {
969                        true => Outcome::Unchanged,
970                        false => Outcome::Edited,
971                    })
972                }
973                _ => None,
974            };
975            if let Some(outcome) = keep {
976                staged.push(Staged {
977                    id: target.id,
978                    section,
979                    record: locked.cloned(),
980                    content: Content::Nothing,
981                    outcome,
982                    warnings: Vec::new(),
983                });
984                continue;
985            }
986            // Fresh and missing replays. Anything else resolves anew.
987            let resolve = !fresh || named;
988            progress(&format!("Installing {}...", target.id));
989            match self.stage(&target, resolve, npm, &mut work).await {
990                Ok(mut next) => {
991                    let id = &next.id;
992                    let warnings = next.warnings.drain(..);
993                    report
994                        .warnings
995                        .extend(warnings.map(|warning| format!("{id}: {warning}")));
996                    // What resolves to what the lock pins, in place as chest
997                    // put it, changes nothing: by the lock's hashes for git and
998                    // npm, by the staged ones for a path entry. A patch always
999                    // places, as the lock doesn't list the items it brings.
1000                    let same = next.record.as_ref() == locked && present && target.patch.is_none();
1001                    let held = match local {
1002                        true => self.holds_staged(section, &next.content, record),
1003                        false => recorded && locked.is_some_and(matches),
1004                    };
1005                    if same && held {
1006                        discard(std::slice::from_ref(&next));
1007                        next.content = Content::Nothing;
1008                        next.outcome = Outcome::Unchanged;
1009                    }
1010                    staged.push(next);
1011                }
1012                Err(err) => {
1013                    discard(&staged);
1014                    return Err(match err {
1015                        ChestError::Invalid(message) => {
1016                            invalid(format!("{}: {message}", target.id))
1017                        }
1018                        err => ChestError::Failed(format!("{}: {err}", target.id)),
1019                    });
1020                }
1021            }
1022        }
1023        if !stale.is_empty() {
1024            report.warnings.push(self.stale_warning(&stale));
1025        }
1026        Ok((staged, work))
1027    }
1028
1029    /// Gets one entry: from its `[patch]` or path source, or its git or npm
1030    /// source, resolved anew or replayed as locked.
1031    async fn stage(
1032        &self,
1033        target: &Target<'_>,
1034        resolve: bool,
1035        npm: &mut Npm,
1036        work: &mut fetch::Work,
1037    ) -> Result<Staged, ChestError> {
1038        let Target {
1039            section,
1040            name,
1041            entry,
1042            locked,
1043            ..
1044        } = *target;
1045        let staged = |record, content, outcome, warnings| Staged {
1046            id: target.id.clone(),
1047            section,
1048            record,
1049            content,
1050            outcome,
1051            warnings,
1052        };
1053        let hashed = |items: Vec<(String, PathBuf)>| -> Result<Vec<_>, ChestError> {
1054            let hash = |(name, source): (String, PathBuf)| {
1055                tree_hash(&source).map(|hash| (name, source, hash))
1056            };
1057            items.into_iter().map(hash).collect()
1058        };
1059        let path_source = match (&entry.git, &entry.npm) {
1060            (None, None) => entry.path.as_deref(),
1061            _ => None,
1062        };
1063        if let Some(path) = target
1064            .patch
1065            .and_then(|patch| patch.path.as_deref())
1066            .or(path_source)
1067        {
1068            // A patch leaves the lock as it was, and a path entry locks its path.
1069            let record = match target.patch {
1070                Some(_) => locked.cloned(),
1071                None => Some(Locked {
1072                    name: name.to_owned(),
1073                    path: entry.path.clone(),
1074                    ..Locked::default()
1075                }),
1076            };
1077            let source = self.resolve(path);
1078            if !source.exists() {
1079                return Err(invalid(format!("{} does not exist", source.display())));
1080            }
1081            if section == Section::Extensions {
1082                let outcome = match resolve {
1083                    true => Outcome::Installed,
1084                    false => Outcome::Unchanged,
1085                };
1086                return Ok(staged(record, Content::Nothing, outcome, Vec::new()));
1087            }
1088            // A path that is not one item, and a patch for a package's
1089            // items, an entry without `path`, are such a package too.
1090            let patched_package =
1091                target.patch.is_some() && path_source.is_none() && entry.path.is_none();
1092            let path_package = target.patch.is_none() && fetch::is_package(section, &source);
1093            let (items, warnings) = if patched_package || path_package {
1094                fetch::items(&source, section, None, name)?
1095            } else {
1096                fetch::check_item(section, &source, &source)?;
1097                let item = (fetch::placed_name(section, name), source);
1098                (vec![item], Vec::new())
1099            };
1100            // An item holding a folder chest installs to would copy itself.
1101            let sections = [Section::Skills, Section::Agents].into_iter();
1102            let mut installs: Vec<PathBuf> = sections.flat_map(|s| self.homes(s)).collect();
1103            installs.push(real_path(&self.store()));
1104            for (_, item) in &items {
1105                let real = real_path(item);
1106                if let Some(home) = installs.iter().find(|home| home.starts_with(&real)) {
1107                    return Err(invalid(format!(
1108                        "{} holds {}, where chest installs, so it cannot be installed",
1109                        item.display(),
1110                        home.display()
1111                    )));
1112                }
1113            }
1114            let content = Content::Items(hashed(items)?);
1115            return Ok(staged(record, content, Outcome::Installed, warnings));
1116        }
1117        let replay = locked.filter(|_| !resolve);
1118        let mut record = Locked {
1119            name: name.to_owned(),
1120            git: entry.git.clone(),
1121            rev: entry.rev.clone(),
1122            npm: entry.npm.clone(),
1123            path: entry.path.clone(),
1124            ..Locked::default()
1125        };
1126        let package = work.path();
1127        if let Some(repo) = &entry.git {
1128            let commit = match replay {
1129                Some(locked) => locked.commit.clone().unwrap_or_default(),
1130                None => fetch::resolve(&self.cache, repo, entry.rev.as_deref()).await?,
1131            };
1132            fetch::checkout(&self.cache, repo, &commit, &package).await?;
1133            record.commit = Some(commit);
1134        }
1135        let mut warnings = Vec::new();
1136        let content = if section == Section::Extensions {
1137            let dir = self.store().join(name);
1138            place::within(&self.root(), &dir)?;
1139            let staging = npm::stage(&dir)?;
1140            let built = self
1141                .build_extension(entry, replay, &package, &staging, npm, &mut record)
1142                .await;
1143            if let Err(err) = built {
1144                let _ = std::fs::remove_dir_all(&staging);
1145                return Err(err);
1146            }
1147            let hash = record.hash.clone().unwrap_or_default();
1148            Content::Store { staging, dir, hash }
1149        } else {
1150            if let Some(npm_name) = &entry.npm {
1151                let pin = match replay {
1152                    Some(locked) => {
1153                        let pin = Pinned {
1154                            path: String::new(),
1155                            name: npm_name.clone(),
1156                            version: locked.version.clone().unwrap_or_default(),
1157                            tarball: locked.tarball.clone().unwrap_or_default(),
1158                            integrity: locked.integrity.clone().unwrap_or_default(),
1159                        };
1160                        npm::fetch_pinned(npm, &pin, &package).await?;
1161                        pin
1162                    }
1163                    None => {
1164                        let pin =
1165                            npm::fetch(npm, npm_name, entry.version.as_deref(), &package).await?;
1166                        // Refused now if replay would refuse it, not in every clone.
1167                        npm.check_pin(&pin)?;
1168                        pin
1169                    }
1170                };
1171                record.version = Some(pin.version);
1172                record.tarball = Some(pin.tarball);
1173                record.integrity = Some(pin.integrity);
1174            }
1175            let (items, skipped) = fetch::items(&package, section, entry.path.as_deref(), name)?;
1176            warnings = skipped;
1177            let items = hashed(items)?;
1178            if entry.path.is_some() {
1179                record.hash = Some(items[0].2.clone());
1180            } else {
1181                for (item, _, hash) in &items {
1182                    record.items.push(lock::Item {
1183                        name: item.clone(),
1184                        hash: hash.clone(),
1185                    });
1186                }
1187            }
1188            Content::Items(items)
1189        };
1190        let Some(locked) = replay else {
1191            return Ok(staged(Some(record), content, Outcome::Installed, warnings));
1192        };
1193        if record.hash != locked.hash || record.items != locked.items {
1194            if let Content::Store { staging, .. } = &content {
1195                let _ = std::fs::remove_dir_all(staging);
1196            }
1197            return Err(invalid(format!(
1198                "what was fetched does not match the lock: run `{} update` to take it",
1199                self.command()
1200            )));
1201        }
1202        Ok(staged(
1203            Some(locked.clone()),
1204            content,
1205            Outcome::Replayed,
1206            warnings,
1207        ))
1208    }
1209
1210    /// Builds extension `entry` in `staging`: an npm package installed into
1211    /// it with its full tree, or the git checkout `package` with its
1212    /// dependencies. Replays `replay`'s tree when given.
1213    async fn build_extension(
1214        &self,
1215        entry: &Entry,
1216        replay: Option<&Locked>,
1217        package: &Path,
1218        staging: &Path,
1219        npm: &mut Npm,
1220        record: &mut Locked,
1221    ) -> Result<(), ChestError> {
1222        match (&entry.npm, replay) {
1223            (_, Some(locked)) => {
1224                // The extension loads from the package's own folder, so the
1225                // lock must pin the package itself there, at its version.
1226                if let Some(npm_name) = &entry.npm {
1227                    let root = format!("node_modules/{npm_name}");
1228                    let version = locked.version.as_deref().unwrap_or_default();
1229                    let pinned = locked.packages.iter().any(|pin| {
1230                        pin.path == root && pin.name == *npm_name && pin.version == version
1231                    });
1232                    if !pinned {
1233                        return Err(invalid(format!(
1234                            "the lock does not pin {npm_name}@{version} at {root}"
1235                        )));
1236                    }
1237                }
1238                if entry.git.is_some() {
1239                    copy_checkout(package, entry.path.as_deref(), staging)?;
1240                }
1241                // Replay reaches the package at the top of an npm extension's
1242                // tree from a root package.json, as its install did.
1243                let root = staging.join("package.json");
1244                if let Some(npm_name) = &entry.npm {
1245                    let version = locked.version.as_deref().unwrap_or_default();
1246                    let manifest = serde_json::json!({"dependencies": {npm_name: version}});
1247                    let text = yapi_types::json::to_string(&manifest).unwrap_or_default();
1248                    std::fs::write(&root, text).map_err(io(&root))?;
1249                }
1250                npm::replay(npm, staging, &locked.packages).await?;
1251                if entry.npm.is_some() {
1252                    std::fs::remove_file(&root).map_err(io(&root))?;
1253                }
1254            }
1255            (Some(npm_name), None) => {
1256                let installed =
1257                    npm::install(npm, staging, npm_name, entry.version.as_deref()).await?;
1258                // Checked as replay will check it, while the root package.json
1259                // still names the package at the top.
1260                npm::check_installed(staging, &installed.tree)?;
1261                // The store keeps only what replay rebuilds, `node_modules`.
1262                for file in ["package.json", ".gitignore"] {
1263                    let file = staging.join(file);
1264                    std::fs::remove_file(&file).map_err(io(&file))?;
1265                }
1266                record.version = Some(installed.version);
1267                record.packages = installed.tree;
1268            }
1269            (None, None) => {
1270                copy_checkout(package, entry.path.as_deref(), staging)?;
1271                record.packages = npm::install_dependencies(npm, staging).await?;
1272                npm::check_installed(staging, &record.packages)?;
1273            }
1274        }
1275        // A tree that replay would refuse fails this install, not every later
1276        // one. A replayed tree records no pins: replay checked them.
1277        for pin in &record.packages {
1278            npm.check_pin(pin)?;
1279        }
1280        record.hash = Some(tree_hash(staging)?);
1281        Ok(())
1282    }
1283
1284    /// Puts staged entries in place and returns every path the entries
1285    /// occupy, kept ones included. Before writing anything, it checks each
1286    /// target: it must lie within the root, no other entry may occupy it, and
1287    /// what is there now must be chest's. That is a path the record lists and
1288    /// that still holds what chest put there, or an unrecorded path that
1289    /// holds exactly what the lock pins and what is about to replace it, with
1290    /// no `.git` inside, which chest then adopts. A recorded path that
1291    /// changed since fails a `frozen` sync. Otherwise its item stays as it
1292    /// is, with a warning, and its entry is [`Outcome::Edited`]. Each target
1293    /// is recorded before it is written, so a failure partway leaves it
1294    /// owned.
1295    fn commit(
1296        &self,
1297        staged: &[Staged],
1298        old: &Lock,
1299        frozen: bool,
1300        record: &mut Record,
1301        record_path: &Path,
1302        report: &mut Report,
1303    ) -> Result<Vec<PathBuf>, ChestError> {
1304        let root = self.root();
1305        let pinned = self.pinned(old);
1306        let mut occupied: HashSet<PathBuf> = HashSet::new();
1307        for staged in staged {
1308            if let (Content::Nothing, Some(locked)) = (&staged.content, &staged.record) {
1309                occupied.extend(self.placed(staged.section, locked));
1310            }
1311        }
1312        // Each placement: what it copies, where to, and the hash it brings.
1313        let mut placements: Vec<Vec<(PathBuf, Vec<PathBuf>, String)>> = Vec::new();
1314        let mut outcomes = Vec::new();
1315        for staged in staged {
1316            let planned = match &staged.content {
1317                Content::Items(items) => {
1318                    let folders = self.folders(staged.section)?;
1319                    let plan = |(name, source, hash): &(String, PathBuf, String)| {
1320                        let targets = folders.iter().map(|folder| folder.join(name)).collect();
1321                        (source.clone(), targets, hash.clone())
1322                    };
1323                    items.iter().map(plan).collect()
1324                }
1325                Content::Store { staging, dir, hash } => {
1326                    vec![(staging.clone(), vec![dir.clone()], hash.clone())]
1327                }
1328                Content::Nothing => Vec::new(),
1329            };
1330            let mut placing = Vec::new();
1331            let mut left = false;
1332            for (source, targets, hash) in planned {
1333                let mut edited = false;
1334                for target in &targets {
1335                    let refuse =
1336                        |why: &str| invalid(format!("{}: {why} {}", staged.id, target.display()));
1337                    place::within(&root, target)
1338                        .map_err(|err| invalid(format!("{}: {err}", staged.id)))?;
1339                    if !occupied.insert(target.clone()) {
1340                        return Err(refuse("another entry also installs"));
1341                    }
1342                    if target.symlink_metadata().is_err() {
1343                        continue;
1344                    }
1345                    match record.held(target) {
1346                        Held::Owned => {}
1347                        Held::Modified => {
1348                            let edit = format!(
1349                                "{}: {} was modified since chest installed it",
1350                                staged.id,
1351                                target.display()
1352                            );
1353                            let how = format!(
1354                                "delete it and run `{}` to put back the locked copy",
1355                                self.install_command()
1356                            );
1357                            if frozen {
1358                                return Err(invalid(format!("{edit}: {how}")));
1359                            }
1360                            let mut warning = format!("{edit}, so chest left it as it is: {how}");
1361                            // yapi loads an extension only where the record holds what the lock pins.
1362                            if staged.section == Section::Extensions && !record.holds(target, &hash)
1363                            {
1364                                warning.push_str(", and until then yapi does not load it");
1365                            }
1366                            report.warnings.push(warning);
1367                            edited = true;
1368                        }
1369                        Held::Unrecorded => {
1370                            let lock_pins = pinned.get(target);
1371                            let adoptable = lock_pins == Some(&hash)
1372                                && content_hash(target).as_ref() == lock_pins
1373                                && !has_git(target);
1374                            if !adoptable {
1375                                return Err(refuse(
1376                                    "refusing to replace what chest did not install at",
1377                                ));
1378                            }
1379                            record.adopt(target, &hash);
1380                        }
1381                    }
1382                }
1383                // An edited item stays whole, its links included.
1384                if edited {
1385                    left = true;
1386                } else {
1387                    placing.push((source, targets, hash));
1388                }
1389            }
1390            let outcome = match left {
1391                true => Outcome::Edited,
1392                false => staged.outcome,
1393            };
1394            if left {
1395                discard(std::slice::from_ref(staged));
1396            }
1397            outcomes.push(outcome);
1398            placements.push(placing);
1399        }
1400        record.save(record_path)?;
1401        let entries = staged.iter().zip(placements).zip(outcomes);
1402        for ((staged, planned), outcome) in entries {
1403            for (source, targets, hash) in planned {
1404                let before: Vec<_> = targets.iter().map(|target| record.get(target)).collect();
1405                // Until it is noted, a target is chest's with the old or the new content.
1406                for target in &targets {
1407                    record.expect(target, &hash);
1408                }
1409                record.save(record_path)?;
1410                let placed = match &staged.content {
1411                    Content::Store { dir, .. } => npm::swap(&source, dir, Ok::<(), ChestError>(())),
1412                    _ => {
1413                        let folders = self.folders(staged.section)?;
1414                        let name = targets[0].file_name().unwrap_or_default().to_string_lossy();
1415                        place::place(&source, &root, &folders, &name).map(drop)
1416                    }
1417                };
1418                // Placed targets hold the new content, links by their target.
1419                // After a failure, what was not written goes back to its record.
1420                for (target, before) in targets.iter().zip(before) {
1421                    if placed.is_ok() || content_hash(target).as_ref() == Some(&hash) {
1422                        record.noted(target, &hash);
1423                    } else {
1424                        record.restore(target, before);
1425                    }
1426                }
1427                record.save(record_path)?;
1428                placed?;
1429            }
1430            report.entries.push((staged.id.clone(), outcome));
1431        }
1432        Ok(occupied.into_iter().collect())
1433    }
1434
1435    /// `occupied`, with the copies kept for a skipped entry whose folder is
1436    /// gone and held several items. Without the folder, chest can't name
1437    /// that entry's copies, so every copy in its section that would be
1438    /// removed stays, with a warning that lists them. A missing extension
1439    /// places nothing, and a missing single item occupies its own name.
1440    fn held_back(
1441        &self,
1442        old: &Lock,
1443        occupied: &[PathBuf],
1444        record: &Record,
1445        report: &mut Report,
1446    ) -> Vec<PathBuf> {
1447        let recorded = record.paths();
1448        let mut kept = occupied.to_vec();
1449        for (section, name, entry) in self.entries() {
1450            if section == Section::Extensions || !self.no_source(name, entry) {
1451                continue;
1452            }
1453            let locked = old
1454                .section(section)
1455                .iter()
1456                .find(|locked| &locked.name == name);
1457            let Some(locked) = locked else { continue };
1458            // A single item is recorded under the name it occupies.
1459            if self
1460                .placed(section, locked)
1461                .iter()
1462                .any(|path| recorded.contains(path))
1463            {
1464                continue;
1465            }
1466            let homes = self.homes(section);
1467            let held: Vec<&PathBuf> = recorded
1468                .iter()
1469                .filter(|path| {
1470                    path.parent()
1471                        .is_some_and(|parent| homes.iter().any(|home| home == parent))
1472                })
1473                .filter(|path| !kept.contains(path))
1474                .collect();
1475            if held.is_empty() {
1476                continue;
1477            }
1478            let list: Vec<String> = held.iter().map(|path| path.display().to_string()).collect();
1479            report.warnings.push(format!(
1480                "{}.{name}'s folder is missing and held several items, so chest keeps {}, which it would remove, as it can't tell that entry's copies apart: restore the folder or remove the entry",
1481                section.key(),
1482                list.join(", ")
1483            ));
1484            kept.extend(held.into_iter().cloned());
1485        }
1486        kept
1487    }
1488
1489    /// Removes what the record lists for this manifest that none of its
1490    /// entries occupies any more, when it still holds what chest put there.
1491    /// A path another manifest of the project still occupies stays theirs,
1492    /// and so does one that is, by its real path, a path an entry occupies.
1493    /// A path changed since is left with a warning, and one chest cannot
1494    /// remove is forgotten with a warning. Reports the entries the manifest
1495    /// dropped.
1496    fn remove_dropped(
1497        &self,
1498        old: &Lock,
1499        occupied: &[PathBuf],
1500        record: &mut Record,
1501        record_path: &Path,
1502        report: &mut Report,
1503    ) -> Result<(), ChestError> {
1504        // The real path of a placed path: its folder resolved, itself not.
1505        let real = |path: &Path| match (path.parent(), path.file_name()) {
1506            (Some(parent), Some(name)) => real_path(parent).join(name),
1507            _ => path.to_path_buf(),
1508        };
1509        let really: HashSet<PathBuf> = occupied.iter().map(|path| real(path)).collect();
1510        let occupied: HashSet<&PathBuf> = occupied.iter().collect();
1511        for path in record.paths() {
1512            if occupied.contains(&path) {
1513                continue;
1514            }
1515            if record.shared(&path) || really.contains(&real(&path)) {
1516                record.release(&path);
1517                record.save(record_path)?;
1518                continue;
1519            }
1520            if path.symlink_metadata().is_ok() {
1521                match record.held(&path) {
1522                    Held::Modified => {
1523                        report.warnings.push(format!(
1524                            "{} was modified since chest installed it, so chest left it as it is: delete it, or undo the edit and run `{}` to remove it",
1525                            path.display(),
1526                            self.install_command()
1527                        ));
1528                        continue;
1529                    }
1530                    _ => {
1531                        if let Err(err) = place::unplace(&self.root(), std::slice::from_ref(&path))
1532                        {
1533                            report.warnings.push(format!(
1534                                "chest could not remove {} and forgets it: {err}",
1535                                path.display()
1536                            ));
1537                        }
1538                    }
1539                }
1540            }
1541            record.forget(&path);
1542            record.save(record_path)?;
1543        }
1544        for section in [Section::Skills, Section::Agents, Section::Extensions] {
1545            for locked in old.section(section) {
1546                if !self.manifest.section(section).contains_key(&locked.name) {
1547                    let id = format!("{}.{}", section.key(), locked.name);
1548                    report.entries.push((id, Outcome::Removed));
1549                }
1550            }
1551        }
1552        Ok(())
1553    }
1554
1555    /// Lists every `occupied` path in the root's `.gitignore` block, with the
1556    /// staging and previous copies a stopped install can leave beside the
1557    /// copies it makes: in the first folder of each section and in the store.
1558    fn write_ignore(&self, occupied: &[PathBuf]) -> Result<(), ChestError> {
1559        let root = self.root();
1560        let relative = |path: &Path| -> Result<String, ChestError> {
1561            let relative = path.strip_prefix(&root).map_err(|_| {
1562                invalid(format!("{} is outside {}", path.display(), root.display()))
1563            })?;
1564            Ok(slashed(relative))
1565        };
1566        let mut entries = Vec::new();
1567        for path in occupied {
1568            entries.push(relative(path)?);
1569        }
1570        let first = |section| self.folders(section).ok()?.into_iter().next();
1571        let copies = [
1572            first(Section::Skills),
1573            first(Section::Agents),
1574            Some(self.store()),
1575        ];
1576        for home in copies.into_iter().flatten() {
1577            if occupied
1578                .iter()
1579                .any(|path| path.parent() == Some(home.as_path()))
1580            {
1581                let home = relative(&home)?;
1582                entries.push(format!("{home}/.*-staging"));
1583                entries.push(format!("{home}/.*-old"));
1584            }
1585        }
1586        place::write_ignore(&root, &self.file_name(), &entries)
1587    }
1588
1589    /// Notes each path source that resolves outside the project.
1590    fn path_notices(&self, report: &mut Report) {
1591        if self.scope != Scope::Project {
1592            return;
1593        }
1594        let entries = self
1595            .entries()
1596            .map(|(section, name, entry)| (format!("{}.{name}", section.key()), entry));
1597        let patches = self
1598            .manifest
1599            .patch
1600            .iter()
1601            .map(|(name, entry)| (format!("patch.{name}"), entry));
1602        for (id, entry) in entries.chain(patches) {
1603            let source = entry
1604                .path
1605                .as_deref()
1606                .filter(|_| entry.git.is_none() && entry.npm.is_none());
1607            let Some(path) = source else { continue };
1608            let resolved = self.resolve(path);
1609            if !resolved.starts_with(&self.base) {
1610                report.notices.push(format!(
1611                    "{id}: `{path}` is outside the project, at {}",
1612                    resolved.display()
1613                ));
1614            }
1615        }
1616    }
1617
1618    /// Fails on a missing or stale lock, or on an installed copy, copy
1619    /// fallback or link that does not match the lock, unless it is a copy
1620    /// chest's record holds, placed for another lock and unchanged since.
1621    /// Whether something is to be put back: such a copy, or something the
1622    /// lock pins that is missing. When nothing is, every entry is reported
1623    /// unchanged.
1624    fn check_frozen(&self, report: &mut Report) -> Result<bool, ChestError> {
1625        let (name, install) = (self.label(), self.install_command());
1626        let lock = self
1627            .lock
1628            .as_ref()
1629            .ok_or_else(|| invalid(format!("{name} has no lock: run `{install}`")))?;
1630        let drift = lock.drift(&self.manifest);
1631        if !drift.is_empty() {
1632            return Err(invalid(format!(
1633                "The lock of {name} is out of date for {}: run `{install}`",
1634                drift.join(", ")
1635            )));
1636        }
1637        let problems = self.problems(lock);
1638        let record = self.record();
1639        let (outdated, wrong): (Vec<_>, Vec<_>) = problems
1640            .iter()
1641            .filter(|(_, _, problem)| !matches!(problem, Problem::Missing | Problem::NoSource))
1642            .partition(|(_, path, problem)| {
1643                *problem == Problem::Mismatch && record.held(path) == Held::Owned
1644            });
1645        if !wrong.is_empty() {
1646            let wrong: Vec<String> = wrong.into_iter().map(describe).collect();
1647            return Err(invalid(wrong.join("\n")));
1648        }
1649        // A path entry whose source is gone fails as it is staged.
1650        let missing = !outdated.is_empty()
1651            || self.entries().any(|(section, entry_name, entry)| {
1652                let locked = lock.section(section).iter();
1653                let mut found = locked.filter(|locked| &locked.name == entry_name);
1654                self.no_source(entry_name, entry)
1655                    || found.any(|locked| !self.present(section, locked))
1656            });
1657        if !missing {
1658            for (section, entry_name, _) in self.entries() {
1659                let id = format!("{}.{entry_name}", section.key());
1660                report.entries.push((id, Outcome::Unchanged));
1661            }
1662        }
1663        Ok(missing)
1664    }
1665
1666    /// Each installed copy of a locked entry that does not match the lock,
1667    /// as [`Project::copy_problems`] finds them, each other copy of a path
1668    /// entry, which has no hash, or of a `[patch]` entry, which is not
1669    /// hashed, that chest's record says changed or is gone, and each path
1670    /// entry's source that is gone.
1671    fn problems(&self, lock: &Lock) -> Vec<(String, PathBuf, Problem)> {
1672        let record = self.record();
1673        let mut problems = Vec::new();
1674        for section in [Section::Skills, Section::Agents, Section::Extensions] {
1675            for locked in lock.section(section) {
1676                let copies = self.copy_problems(section, locked);
1677                let path_entry = locked.git.is_none() && locked.npm.is_none();
1678                let (mut edited, mut gone) = (Vec::new(), None);
1679                let patched = self.manifest.patch.contains_key(&locked.name);
1680                if path_entry || patched {
1681                    let id = format!("{}.{}", section.key(), locked.name);
1682                    // A patch's source is the patch, not the entry's `path`.
1683                    let source = locked.path.as_deref().filter(|_| path_entry && !patched);
1684                    gone = source
1685                        .map(|path| self.resolve(path))
1686                        .filter(|source| !source.exists())
1687                        .map(|source| (id.clone(), source, Problem::NoSource));
1688                    // A link that points elsewhere is reported as that, once.
1689                    let reported = |path: &PathBuf| copies.iter().any(|(_, at, _)| at == path);
1690                    edited = self
1691                        .changed(section, locked, &record)
1692                        .into_iter()
1693                        .filter(|(path, _)| !reported(path))
1694                        .map(|(path, problem)| (id.clone(), path, problem))
1695                        .collect();
1696                }
1697                // Without its source, a path entry's copies can't be named.
1698                if gone.is_none() {
1699                    problems.extend(copies);
1700                }
1701                problems.extend(gone);
1702                problems.extend(edited);
1703            }
1704        }
1705        problems
1706    }
1707
1708    /// Each installed copy of `locked`, an entry of `section`, that does not
1709    /// match the lock: canonical copies, copy fallbacks and store folders by
1710    /// their hash, links by what they point to. A path entry has no hash, so
1711    /// only its links are checked, and `[patch]` entries are not checked.
1712    fn copy_problems(&self, section: Section, locked: &Locked) -> Vec<(String, PathBuf, Problem)> {
1713        let mut problems = Vec::new();
1714        if self.manifest.patch.contains_key(&locked.name) {
1715            return problems;
1716        }
1717        let homes = self.homes(section);
1718        let id = format!("{}.{}", section.key(), locked.name);
1719        for (item, hash) in self.locked_items(section, locked) {
1720            let canonical = homes.first().map(|home| home.join(&item));
1721            for (index, home) in homes.iter().enumerate() {
1722                let path = home.join(&item);
1723                let problem = match path.symlink_metadata() {
1724                    Err(_) => Some(Problem::Missing),
1725                    Ok(metadata) if metadata.is_symlink() && index == 0 => Some(Problem::Linked),
1726                    Ok(metadata) if metadata.is_symlink() => {
1727                        let target = path.canonicalize().ok();
1728                        let canonical = canonical.as_ref().and_then(|c| c.canonicalize().ok());
1729                        (target != canonical).then(|| Problem::Link(homes[0].join(&item)))
1730                    }
1731                    Ok(_) => hash
1732                        .as_ref()
1733                        .filter(|hash| tree_hash(&path).ok().as_ref() != Some(*hash))
1734                        .map(|_| Problem::Mismatch),
1735                };
1736                if let Some(problem) = problem {
1737                    problems.push((id.clone(), path, problem));
1738                }
1739            }
1740        }
1741        problems
1742    }
1743
1744    /// The folder installed extension `name` loads from: its `[patch]`
1745    /// path, its store folder, the npm package inside it, or its path
1746    /// source. `None` for an entry that is not an installed extension.
1747    pub fn installed_root(&self, name: &str) -> Option<PathBuf> {
1748        self.extension_roots(name)
1749            .map(|(root, _)| root)
1750            .filter(|root| root.exists())
1751    }
1752
1753    /// Where extension `name` loads from, as [`Project::installed_root`]
1754    /// says whether or not it exists, and the folder that holds the package
1755    /// and its own dependencies, which it may load modules from: the
1756    /// `[patch]` or path source, or `<store>/<name>` for git and npm.
1757    pub fn extension_roots(&self, name: &str) -> Option<(PathBuf, PathBuf)> {
1758        let entry = self.manifest.extensions.get(name)?;
1759        let patch = self
1760            .manifest
1761            .patch
1762            .get(name)
1763            .and_then(|patch| patch.path.as_deref());
1764        let store = self.store().join(name);
1765        Some(
1766            match (patch, &entry.npm, &entry.git, entry.path.as_deref()) {
1767                (Some(path), ..) | (None, None, None, Some(path)) => {
1768                    let root = self.resolve(path);
1769                    (root.clone(), root)
1770                }
1771                (None, Some(npm), _, _) => (store.join("node_modules").join(npm), store),
1772                (None, None, Some(_), _) => (store.clone(), store),
1773                (None, None, None, None) => return None,
1774            },
1775        )
1776    }
1777
1778    /// For [`Mode::Startup`]: when nothing must be put back, because every
1779    /// locked, current entry is present or `offline` leaves it missing, the
1780    /// report of what a sync would find, without taking the lock's lock.
1781    /// Whether it gave the report.
1782    fn startup_without_lock(
1783        &mut self,
1784        offline: bool,
1785        report: &mut Report,
1786    ) -> Result<bool, ChestError> {
1787        self.lock = self.read_lock()?;
1788        let Some(lock) = &self.lock else {
1789            report.warnings.push(format!(
1790                "{} has no lock, so nothing from it was installed: run `{}`",
1791                self.label(),
1792                self.install_command()
1793            ));
1794            return Ok(true);
1795        };
1796        let record = self.record();
1797        let mut entries = Vec::new();
1798        let mut stale = Vec::new();
1799        let (mut missing, mut loading) = (Vec::new(), Vec::new());
1800        for (section, name, entry) in self.entries() {
1801            let locked = lock
1802                .section(section)
1803                .iter()
1804                .find(|locked| &locked.name == name);
1805            let id = format!("{}.{name}", section.key());
1806            let outcome = match self.at_startup(section, name, entry, locked, &record, offline) {
1807                AtStartup::Stale => {
1808                    stale.push(id.clone());
1809                    Outcome::Skipped
1810                }
1811                AtStartup::Present => Outcome::Unchanged,
1812                AtStartup::Missing => {
1813                    match locked.is_some_and(|locked| self.read_by_yapi(section, locked)) {
1814                        true => loading.push(id.clone()),
1815                        false => missing.push(id.clone()),
1816                    }
1817                    Outcome::Skipped
1818                }
1819                AtStartup::Replay => return Ok(false),
1820            };
1821            entries.push((id, outcome));
1822        }
1823        report.entries = entries;
1824        if !stale.is_empty() {
1825            report.warnings.push(self.stale_warning(&stale));
1826        }
1827        let label = self.label();
1828        if !missing.is_empty() {
1829            let skipped = if missing.len() == 1 {
1830                "it was"
1831            } else {
1832                "they were"
1833            };
1834            report.warnings.push(format!(
1835                "{label}: {} could not be put back offline, so {skipped} left out",
1836                missing.join(", ")
1837            ));
1838        }
1839        if !loading.is_empty() {
1840            let copies = match loading.len() {
1841                1 => "its copy in a folder yapi reads on its own keeps",
1842                _ => "their copies in folders yapi reads on its own keep",
1843            };
1844            report.warnings.push(format!(
1845                "{label}: {} could not be put back offline, so {copies} loading",
1846                loading.join(", ")
1847            ));
1848        }
1849        Ok(true)
1850    }
1851
1852    /// Whether yapi loads a copy of skill entry `locked` on its own, beside
1853    /// what chest gives it: one of its items is in a skills folder of
1854    /// [`Project::homes`] that yapi reads anyway, such as `.agents/skills`.
1855    fn read_by_yapi(&self, section: Section, locked: &Locked) -> bool {
1856        if section != Section::Skills {
1857            return false;
1858        }
1859        let own = match self.scope {
1860            Scope::Project => [".agents/skills", ".yapi/skills"].map(|dir| self.base.join(dir)),
1861            Scope::Global => [
1862                self.home.join(".agents/skills"),
1863                crate::config::agent_dir().join("skills"),
1864            ],
1865        };
1866        let own = own.map(|folder| real_path(&folder));
1867        let homes = self.homes(section);
1868        let items = self.locked_items(section, locked);
1869        let read = homes.iter().filter(|home| own.contains(home));
1870        read.flat_map(|home| items.iter().map(move |(item, _)| home.join(item)))
1871            .any(|copy| copy.exists())
1872    }
1873
1874    /// What startup does with entry `name` of `section`, locked as
1875    /// `locked`: a stale or unlocked entry is skipped, a patched one or one
1876    /// in place as the lock pins it, by `record`, kept, and a missing or
1877    /// outdated one put back, unless `offline`.
1878    fn at_startup(
1879        &self,
1880        section: Section,
1881        name: &str,
1882        entry: &Entry,
1883        locked: Option<&Locked>,
1884        record: &Record,
1885        offline: bool,
1886    ) -> AtStartup {
1887        let patched = self.manifest.patch.contains_key(name);
1888        if !patched && !locked.is_some_and(|locked| fresh(entry, locked)) {
1889            return AtStartup::Stale;
1890        }
1891        // A patched entry loads from its patch, or where `chest install` put it.
1892        let pinned = |locked: &Locked| {
1893            self.present(section, locked) && self.holds_locked(section, entry, locked, record)
1894        };
1895        if patched || locked.is_some_and(pinned) {
1896            return AtStartup::Present;
1897        }
1898        if offline {
1899            AtStartup::Missing
1900        } else {
1901            AtStartup::Replay
1902        }
1903    }
1904
1905    /// The warning for entries `ids` that are not locked, or whose lock is
1906    /// stale.
1907    fn stale_warning(&self, ids: &[String]) -> String {
1908        let (verb, lock, skipped) = match ids.len() {
1909            1 => ("is", "its", "it was"),
1910            _ => ("are", "their", "they were"),
1911        };
1912        format!(
1913            "{}: {} {verb} not locked, or {lock} lock is stale, so {skipped} skipped: run `{}`",
1914            self.label(),
1915            ids.join(", "),
1916            self.install_command()
1917        )
1918    }
1919
1920    /// Checks every installed copy of a locked git or npm entry: canonical
1921    /// copies, copy fallbacks and store folders by their hash, and links by
1922    /// what they point to. Copies of path and `[patch]` entries are checked
1923    /// by chest's record of what it placed, and each path entry's source
1924    /// must be there. Returns one line per problem, so none means all match.
1925    pub fn verify(&self) -> Result<Vec<String>, ChestError> {
1926        let (name, install) = (self.label(), self.install_command());
1927        let lock = self
1928            .lock
1929            .as_ref()
1930            .ok_or_else(|| invalid(format!("{name} has no lock: run `{install}`")))?;
1931        Ok(self.problems(lock).iter().map(describe).collect())
1932    }
1933
1934    /// The newest revision each locked git or npm entry's source offers:
1935    /// the commit its `rev` names now, or the newest npm version in its
1936    /// range with npm's `latest`.
1937    pub async fn outdated(&self, npm: &mut Npm) -> Result<Vec<Outdated>, ChestError> {
1938        let Some(lock) = &self.lock else {
1939            return Ok(Vec::new());
1940        };
1941        let mut found = Vec::new();
1942        for (section, name, entry) in self.entries() {
1943            let Some(locked) = lock
1944                .section(section)
1945                .iter()
1946                .find(|locked| &locked.name == name)
1947            else {
1948                continue;
1949            };
1950            let id = format!("{}.{name}", section.key());
1951            let failed = |err: ChestError| ChestError::Failed(format!("{id}: {err}"));
1952            if let Some(repo) = &entry.git {
1953                let wanted = fetch::resolve(&self.cache, repo, entry.rev.as_deref())
1954                    .await
1955                    .map_err(failed)?;
1956                let locked = locked.commit.clone().unwrap_or_default();
1957                found.push(Outdated {
1958                    id,
1959                    locked,
1960                    wanted,
1961                    latest: None,
1962                });
1963            } else if let Some(package) = &entry.npm {
1964                let range = entry.version.as_deref().unwrap_or("latest");
1965                let wanted = npm
1966                    .latest(package, range)
1967                    .await
1968                    .map_err(|err| failed(err.into()))?;
1969                let latest = npm.latest(package, "latest").await.ok();
1970                let locked = locked.version.clone().unwrap_or_default();
1971                found.push(Outdated {
1972                    id,
1973                    locked,
1974                    wanted,
1975                    latest,
1976                });
1977            }
1978        }
1979        Ok(found)
1980    }
1981
1982    /// Fetches the package `entry` names, without its dependencies, to read
1983    /// what it provides and requests before it is added: a git source at the
1984    /// commit its `rev` names, an npm source at the newest version in its
1985    /// range, or a path source where it is.
1986    pub async fn fetch_package(&self, entry: &Entry, npm: &mut Npm) -> Result<Fetched, ChestError> {
1987        if entry.git.is_none() && entry.npm.is_none() {
1988            let path = entry
1989                .path
1990                .as_deref()
1991                .ok_or_else(|| invalid("needs `git`, `npm` or `path`"))?;
1992            return Ok(Fetched {
1993                root: self.resolve(path),
1994                _work: None,
1995            });
1996        }
1997        let mut work = fetch::Work::new(&self.cache)?;
1998        let root = work.path();
1999        if let Some(repo) = &entry.git {
2000            let commit = fetch::resolve(&self.cache, repo, entry.rev.as_deref()).await?;
2001            fetch::checkout(&self.cache, repo, &commit, &root).await?;
2002        } else if let Some(package) = &entry.npm {
2003            npm::fetch(npm, package, entry.version.as_deref(), &root).await?;
2004        }
2005        Ok(Fetched {
2006            root,
2007            _work: Some(work),
2008        })
2009    }
2010}
2011
2012/// What is wrong with one installed copy.
2013#[derive(Clone, Debug, PartialEq, Eq)]
2014enum Problem {
2015    /// Nothing is there.
2016    Missing,
2017    /// A copy whose hash differs from the lock's.
2018    Mismatch,
2019    /// A copy of a path or `[patch]` entry that changed since chest placed it.
2020    Modified,
2021    /// A link that does not point at the canonical copy, given.
2022    Link(PathBuf),
2023    /// A link in the first folder, where chest places the canonical copy.
2024    Linked,
2025    /// A path entry's source, which is gone.
2026    NoSource,
2027}
2028
2029/// What `sync` says while another chest command holds the lock of `file`.
2030fn waiting_for(file: &Path) -> String {
2031    format!(
2032        "Waiting for another chest command, which holds the lock of {}...",
2033        file.display()
2034    )
2035}
2036
2037fn describe((id, path, problem): &(String, PathBuf, Problem)) -> String {
2038    let what = match problem {
2039        Problem::NoSource => return format!("{id}: its source {} is missing", path.display()),
2040        Problem::Missing => "is missing".to_owned(),
2041        Problem::Mismatch => "does not match the lock".to_owned(),
2042        Problem::Modified => "was modified since chest installed it".to_owned(),
2043        Problem::Link(canonical) => format!("does not link to {}", canonical.display()),
2044        Problem::Linked => "is a link where chest places the copy".to_owned(),
2045    };
2046    format!("{id}: {} {what}", path.display())
2047}
2048
2049/// Whether `path`, or what it links to, has a `.git` anywhere inside. Its
2050/// hash leaves `.git` out, so a matching hash says nothing about it.
2051fn has_git(path: &Path) -> bool {
2052    fn walk(dir: &Path) -> bool {
2053        let Ok(entries) = std::fs::read_dir(dir) else {
2054            return false;
2055        };
2056        entries.flatten().any(|entry| {
2057            entry.file_name() == ".git"
2058                || entry.file_type().is_ok_and(|kind| kind.is_dir()) && walk(&entry.path())
2059        })
2060    }
2061    let held = match path.symlink_metadata() {
2062        Ok(metadata) if metadata.is_symlink() => path.canonicalize().ok(),
2063        Ok(_) => Some(path.to_path_buf()),
2064        Err(_) => None,
2065    };
2066    held.is_some_and(|held| walk(&held))
2067}
2068
2069/// Copies the checkout `package`, or its `subpath`, into `staging`, without
2070/// a committed `node_modules`, which the dependency install or replay
2071/// rebuilds so that the hash does not depend on what the repository commits.
2072/// The subpath must be a folder: an extension is installed as a package.
2073fn copy_checkout(package: &Path, subpath: Option<&str>, staging: &Path) -> Result<(), ChestError> {
2074    let source = package.join(subpath.unwrap_or_default());
2075    fetch::inside(package, &source)?;
2076    if !source.is_dir() {
2077        return Err(invalid(format!(
2078            "`path` must name the folder that holds the extension, not a file: {}",
2079            subpath.unwrap_or_default()
2080        )));
2081    }
2082    place::copy(&source, staging)?;
2083    let modules = staging.join("node_modules");
2084    if modules.symlink_metadata().is_ok() {
2085        std::fs::remove_dir_all(&modules).map_err(io(&modules))?;
2086    }
2087    Ok(())
2088}