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