Skip to main content

spec_driven_docs/services/
skill_installer.rs

1//! Install the embedded skills into agent skill directories, as packages.
2//!
3//! The destinations live outside any instance — under the invoking user's
4//! home — so nothing here touches an instance manifest. The unit is the
5//! package: one directory per skill, holding `SKILL.md` and every shared
6//! artifact under `references/`, which is what the Agent Skills format and
7//! every documented host resolve against.
8//!
9//! Three references decide every destination: the payload, the receipt, and
10//! the disk. Bytes matching the payload are current. Bytes the receipt
11//! vouches for are this tool's and may be replaced or taken back. Every
12//! other byte is the user's: an install refuses it without `--force`, and
13//! an uninstall leaves it and names it.
14//!
15//! Every apply runs as one transaction. It holds the user-scope lock for
16//! its whole run, recovers an unfinished run before it plans new work,
17//! stages each write beside its destination, journals before the first
18//! replacement, and writes the receipt last. A run the process does not
19//! finish is rolled back by the next invocation.
20
21use std::collections::BTreeSet;
22
23use camino::{Utf8Path, Utf8PathBuf};
24
25use crate::domain::ownership::Sha256;
26use crate::domain::paths::HOME_VAR;
27use crate::domain::skill_record::SkillRecord;
28use crate::error::AppError;
29use crate::transaction::journal::{self, Entry, Journal};
30use crate::transaction::lock::Lock;
31use crate::transaction::stage::Stage;
32
33// The roots are declared in `domain::paths` and reach their callers from
34// here, so that what a destination is stays one statement and what an
35// install does with it stays another.
36pub use crate::domain::paths::{AGENTS_ROOT, CLAUDE_ROOT, LEGACY_SHARED_ROOT};
37
38/// The invoking user's home directory.
39///
40/// # Errors
41///
42/// [`AppError::Usage`] when `HOME` is unset or empty.
43pub fn home() -> Result<Utf8PathBuf, AppError> {
44    crate::domain::paths::UserEnv::from_process()
45        .home
46        .ok_or_else(|| AppError::Usage(format!("{HOME_VAR} is not set")))
47}
48
49/// Where one run writes: the agent roots, the state root, and the receipt.
50#[derive(Debug, Clone)]
51pub struct Layout {
52    /// The agent skill roots this run was asked to touch, deduplicated.
53    pub roots: Vec<Utf8PathBuf>,
54    /// Where this tool keeps state that outlives a command.
55    pub state_root: Utf8PathBuf,
56    /// The receipt vouching for every user-scope file this tool wrote.
57    pub receipt: Utf8PathBuf,
58    /// The receipt at the home-relative path, read once as a fallback.
59    pub legacy_receipt: Utf8PathBuf,
60    /// The retired shared root, swept rather than written.
61    pub legacy_shared: Utf8PathBuf,
62}
63
64impl Layout {
65    /// The lock every apply holds for its whole run.
66    #[must_use]
67    pub fn lock_path(&self) -> Utf8PathBuf {
68        self.state_root.join(crate::domain::paths::SKILL_LOCK_FILE)
69    }
70
71    /// The journal an unfinished run leaves.
72    #[must_use]
73    pub fn journal_path(&self) -> Utf8PathBuf {
74        self.state_root
75            .join(crate::domain::paths::SKILL_JOURNAL_FILE)
76    }
77
78    /// Where a run copies what it is about to replace.
79    #[must_use]
80    pub fn backups(&self) -> Utf8PathBuf {
81        self.state_root.join(crate::domain::paths::BACKUPS_DIR)
82    }
83
84    /// Every location a sweep reads: the selected roots and the retired one.
85    fn scanned(&self) -> Vec<Utf8PathBuf> {
86        let mut scanned = self.roots.clone();
87        scanned.push(self.legacy_shared.clone());
88        scanned
89    }
90}
91
92/// One planned file: where, which bytes, and their digest.
93#[derive(Debug, Clone)]
94struct Planned {
95    destination: Utf8PathBuf,
96    bytes: &'static [u8],
97    digest: Sha256,
98}
99
100/// A destination this tool refuses to write through, whatever `--force` says.
101#[derive(Debug, Clone, PartialEq, Eq)]
102struct Blocked {
103    path: Utf8PathBuf,
104    kind: &'static str,
105}
106
107impl std::fmt::Display for Blocked {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        write!(f, "{} is {}", self.path, self.kind)
110    }
111}
112
113/// How a run ends when a test interrupts it.
114///
115/// `Abandoned` stands in for the process dying: the caller does not roll
116/// back, so the journal survives and the next invocation is what recovers.
117enum Failure {
118    Error(AppError),
119    Abandoned,
120}
121
122impl From<AppError> for Failure {
123    fn from(error: AppError) -> Self {
124        Self::Error(error)
125    }
126}
127
128impl From<std::io::Error> for Failure {
129    fn from(error: std::io::Error) -> Self {
130        Self::Error(AppError::Io(error))
131    }
132}
133
134/// Where a run stops, counted in boundaries of the persistence order.
135///
136/// Production never sets it. The recovery tests walk it from one upward and
137/// assert that the next invocation puts every destination back before it
138/// writes anything new.
139#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
140struct Interrupt {
141    /// Stop after this many boundaries, without rolling back.
142    after: Option<usize>,
143}
144
145fn reached(passed: &mut usize, interrupt: Interrupt) -> Result<(), Failure> {
146    *passed += 1;
147    if interrupt.after == Some(*passed) {
148        return Err(Failure::Abandoned);
149    }
150    Ok(())
151}
152
153/// Every file of every package under `roots`, root by root and skill by skill.
154fn plan(roots: &[Utf8PathBuf]) -> Result<Vec<Planned>, AppError> {
155    let mut planned = Vec::new();
156    for root in roots {
157        for name in crate::embedded::skill_names() {
158            let package = crate::embedded::skill_package(name)
159                .ok_or_else(|| anyhow::anyhow!("payload skill missing: {name}"))?;
160            for (relative, bytes) in package {
161                planned.push(Planned {
162                    destination: root.join(name).join(relative),
163                    bytes,
164                    digest: Sha256::of(bytes),
165                });
166            }
167        }
168    }
169    Ok(planned)
170}
171
172/// The first component of `destination` under `root` this tool will not
173/// write through.
174///
175/// Every component is inspected without following links, from the agent
176/// root down. Directories above the root are the user's layout and stay
177/// unjudged: this tool did not create them and does not police them.
178fn blocked_by(root: &Utf8Path, destination: &Utf8Path) -> Option<Blocked> {
179    let relative = destination.strip_prefix(root).ok()?;
180    let mut current = root.to_owned();
181    if let Ok(meta) = std::fs::symlink_metadata(&current) {
182        if meta.file_type().is_symlink() {
183            return Some(Blocked {
184                path: current,
185                kind: "a symlink",
186            });
187        }
188        if !meta.is_dir() {
189            return Some(Blocked {
190                path: current,
191                kind: "a file where a directory is needed",
192            });
193        }
194    }
195    let components: Vec<&str> = relative.as_str().split('/').collect();
196    let last = components.len().saturating_sub(1);
197    for (index, part) in components.iter().enumerate() {
198        current = current.join(part);
199        let Ok(meta) = std::fs::symlink_metadata(&current) else {
200            // Absent, so nothing below it exists either.
201            return None;
202        };
203        if meta.file_type().is_symlink() {
204            return Some(Blocked {
205                path: current,
206                kind: "a symlink",
207            });
208        }
209        if index == last {
210            if !meta.is_file() {
211                return Some(Blocked {
212                    path: current,
213                    kind: if meta.is_dir() {
214                        "a directory"
215                    } else {
216                        "not a regular file"
217                    },
218                });
219            }
220        } else if !meta.is_dir() {
221            return Some(Blocked {
222                path: current,
223                kind: "a file where a directory is needed",
224            });
225        }
226    }
227    None
228}
229
230/// Which root a destination sits under, for the chain check.
231fn owning_root<'a>(layout: &'a Layout, destination: &Utf8Path) -> Option<&'a Utf8Path> {
232    layout
233        .roots
234        .iter()
235        .chain(std::iter::once(&layout.state_root))
236        .map(Utf8PathBuf::as_path)
237        .find(|root| destination.starts_with(root))
238}
239
240/// Every destination this run will not write through.
241fn blocked(layout: &Layout, destinations: &[Utf8PathBuf]) -> Vec<Blocked> {
242    let mut found = Vec::new();
243    for destination in destinations {
244        let Some(root) = owning_root(layout, destination) else {
245            continue;
246        };
247        if let Some(one) = blocked_by(root, destination)
248            && !found.contains(&one)
249        {
250            found.push(one);
251        }
252    }
253    found
254}
255
256/// What a destination currently holds, judged against the two references.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258enum Standing {
259    /// Nothing is there.
260    Absent,
261    /// The payload's own bytes.
262    Current,
263    /// Bytes the receipt vouches for, from an earlier release.
264    Recorded,
265    /// Bytes neither reference accounts for.
266    Foreign,
267}
268
269fn standing(
270    destination: &Utf8Path,
271    intended: &Sha256,
272    record: &SkillRecord,
273) -> Result<Standing, AppError> {
274    if !destination.is_file() {
275        return Ok(Standing::Absent);
276    }
277    // An unreadable destination raises instead of passing as clean: a
278    // comparison that cannot run must never license an overwrite.
279    let found = Sha256::of(&std::fs::read(destination)?);
280    if &found == intended {
281        return Ok(Standing::Current);
282    }
283    if record.wrote(destination, &found) {
284        return Ok(Standing::Recorded);
285    }
286    Ok(Standing::Foreign)
287}
288
289/// Recorded destinations under a scanned root that this run no longer plans.
290///
291/// Only bytes the receipt still vouches for are swept: a leftover the user
292/// edited is theirs. This is also what sweeps the retired shared root, whose
293/// two files an earlier release wrote and recorded.
294fn leftovers(
295    scanned: &[Utf8PathBuf],
296    record: &SkillRecord,
297    keep: &BTreeSet<Utf8PathBuf>,
298) -> Vec<(Utf8PathBuf, Sha256, bool)> {
299    record
300        .written
301        .iter()
302        .filter(|(destination, _)| {
303            !keep.contains(*destination) && scanned.iter().any(|root| destination.starts_with(root))
304        })
305        .map(|(destination, digest)| {
306            let ours = !destination.is_symlink()
307                && destination.is_file()
308                && std::fs::read(destination).is_ok_and(|found| Sha256::of(&found) == *digest);
309            (destination.clone(), digest.clone(), ours)
310        })
311        .collect()
312}
313
314/// Remove the directories a removal emptied, deepest first, up to `stop`.
315fn prune_empty(directories: &BTreeSet<Utf8PathBuf>, stop: &[Utf8PathBuf], lines: &mut Vec<String>) {
316    let mut deepest: Vec<&Utf8PathBuf> = directories.iter().collect();
317    deepest.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
318    for directory in deepest {
319        if stop.iter().any(|root| root == directory) {
320            continue;
321        }
322        let Ok(mut entries) = std::fs::read_dir(directory) else {
323            continue;
324        };
325        if entries.next().is_none() {
326            let _ = std::fs::remove_dir(directory);
327        } else {
328            lines.push(format!("kept (not empty): {directory}"));
329        }
330    }
331}
332
333/// The receipt this run intends to leave.
334fn next_receipt(record: &SkillRecord, written: &[Planned], removed: &[Utf8PathBuf]) -> SkillRecord {
335    let mut next = record.clone();
336    next.schema_version = crate::domain::skill_record::SCHEMA_VERSION;
337    next.engine_version = env!("CARGO_PKG_VERSION").to_string();
338    for destination in removed {
339        next.written.remove(destination);
340    }
341    for entry in written {
342        next.written
343            .insert(entry.destination.clone(), entry.digest.clone());
344    }
345    // A run that changed nothing leaves the receipt alone, timestamp
346    // included. A record rewritten on every invocation would make a second
347    // install write a file, and idempotence is what tells an operator that
348    // the home is already current.
349    if next.written == record.written
350        && next.engine_version == record.engine_version
351        && record.schema_version == crate::domain::skill_record::SCHEMA_VERSION
352    {
353        return record.clone();
354    }
355    next.installed_at = jiff::Timestamp::now().to_string();
356    next
357}
358
359/// Read the receipt, falling back to the home-relative path once.
360fn load_receipt(layout: &Layout) -> SkillRecord {
361    SkillRecord::load_with_fallback(&layout.receipt, &layout.legacy_receipt)
362}
363
364/// Install every embedded skill package under each root, previewing by default.
365///
366/// # Errors
367///
368/// [`AppError::Refused`] when a destination is reached through a link or
369/// holds bytes neither reference accounts for without `--force`,
370/// [`AppError::Busy`] when another process holds the lock,
371/// [`AppError::Unrecovered`] when an unfinished run cannot be put back, and
372/// [`AppError::Receipt`] when the apply cannot record what it wrote.
373pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<String>, AppError> {
374    settle(install_with(layout, apply, force, Interrupt::default()))
375}
376
377/// Remove every installed skill package under each root, previewing by default.
378///
379/// # Errors
380///
381/// As [`install`], except that nothing here refuses on foreign bytes: a
382/// file the receipt cannot vouch for stays and is named.
383pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<String>, AppError> {
384    settle(uninstall_with(layout, apply, Interrupt::default()))
385}
386
387fn settle(result: Result<Vec<String>, Failure>) -> Result<Vec<String>, AppError> {
388    result.map_err(|failure| match failure {
389        Failure::Error(error) => error,
390        Failure::Abandoned => AppError::Other(anyhow::anyhow!(
391            "the run was interrupted; the next invocation recovers it"
392        )),
393    })
394}
395
396/// The lock an applied run holds for the whole of its own reasoning.
397///
398/// A preview takes none: it writes nothing, and a preview that refused
399/// because somebody else was installing would fail for a reason the
400/// operator cannot act on. An applied run takes it before it reads a
401/// receipt or scans a destination, so every decision it makes is one the
402/// world still agrees with when it executes. A run that derived its
403/// removals before the lock could remove files a newer install had just
404/// written.
405///
406/// # Errors
407///
408/// [`AppError::Busy`] when another process holds it, and whatever
409/// recovery refuses.
410fn held(layout: &Layout, apply: bool, purpose: &str) -> Result<Option<Lock>, Failure> {
411    if !apply {
412        return Ok(None);
413    }
414    let lock = Lock::exclusive(&layout.lock_path(), purpose)?;
415    journal::recover(&layout.journal_path())?;
416    Ok(Some(lock))
417}
418
419/// The install, with the recovery tests' interruption point.
420fn install_with(
421    layout: &Layout,
422    apply: bool,
423    force: bool,
424    interrupt: Interrupt,
425) -> Result<Vec<String>, Failure> {
426    let _lock = held(layout, apply, "skill install")?;
427    let planned = plan(&layout.roots)?;
428    let mut lines: Vec<String> = planned
429        .iter()
430        .map(|entry| entry.destination.to_string())
431        .collect();
432
433    let record = load_receipt(layout);
434    let kept: BTreeSet<Utf8PathBuf> = planned
435        .iter()
436        .map(|entry| entry.destination.clone())
437        .collect();
438    let stale = leftovers(&layout.scanned(), &record, &kept);
439    for (destination, _, ours) in &stale {
440        if *ours {
441            lines.push(format!("sweep (no longer in the payload): {destination}"));
442        } else {
443            lines.push(format!("kept (edited): {destination}"));
444        }
445    }
446
447    let mut destinations: Vec<Utf8PathBuf> = planned
448        .iter()
449        .map(|entry| entry.destination.clone())
450        .collect();
451    destinations.push(layout.receipt.clone());
452    let refused = blocked(layout, &destinations);
453    for one in &refused {
454        lines.push(format!("conflict: {one}"));
455    }
456
457    let mut foreign: Vec<Utf8PathBuf> = Vec::new();
458    let mut writes: Vec<Planned> = Vec::new();
459    for entry in &planned {
460        if refused
461            .iter()
462            .any(|one| entry.destination.starts_with(&one.path))
463        {
464            continue;
465        }
466        match standing(&entry.destination, &entry.digest, &record)? {
467            Standing::Current => {}
468            Standing::Foreign => {
469                foreign.push(entry.destination.clone());
470                writes.push(entry.clone());
471            }
472            Standing::Absent | Standing::Recorded => writes.push(entry.clone()),
473        }
474    }
475    for destination in &foreign {
476        lines.push(format!(
477            "conflict: {destination} holds bytes this tool did not write"
478        ));
479    }
480
481    if !apply {
482        lines.push("DRY RUN: no files written".to_string());
483        return Ok(lines);
484    }
485    if !refused.is_empty() {
486        return Err(refuse_blocked(&refused).into());
487    }
488    if !force && !foreign.is_empty() {
489        let paths: Vec<&str> = foreign.iter().map(|path| path.as_str()).collect();
490        return Err(AppError::Refused(format!(
491            "destinations hold bytes this tool did not write: {}; re-run with --force to overwrite",
492            paths.join(", ")
493        ))
494        .into());
495    }
496
497    let swept: Vec<Utf8PathBuf> = stale
498        .iter()
499        .filter(|(_, _, ours)| *ours)
500        .map(|(destination, _, _)| destination.clone())
501        .collect();
502    let receipt = next_receipt(&record, &writes, &swept);
503    run_transaction(
504        layout,
505        &writes,
506        &stale
507            .into_iter()
508            .filter(|(_, _, ours)| *ours)
509            .map(|(destination, digest, _)| (destination, digest))
510            .collect::<Vec<_>>(),
511        &receipt,
512        &mut lines,
513        interrupt,
514    )?;
515    Ok(lines)
516}
517
518/// The uninstall, with the recovery tests' interruption point.
519fn uninstall_with(
520    layout: &Layout,
521    apply: bool,
522    interrupt: Interrupt,
523) -> Result<Vec<String>, Failure> {
524    // A home with no receipt has nothing this tool wrote, so an uninstall
525    // there removes nothing and takes no lock: creating the lock file
526    // would itself be the write that home was promised it would not get.
527    let _lock = held(layout, apply && layout.receipt.exists(), "skill uninstall")?;
528    let planned = plan(&layout.roots)?;
529    let record = load_receipt(layout);
530    let mut lines: Vec<String> = Vec::new();
531    let mut removals: Vec<(Utf8PathBuf, Sha256)> = Vec::new();
532
533    let refused = blocked(
534        layout,
535        &planned
536            .iter()
537            .map(|entry| entry.destination.clone())
538            .collect::<Vec<_>>(),
539    );
540    for one in &refused {
541        lines.push(format!("conflict: {one}"));
542    }
543
544    for entry in &planned {
545        if refused
546            .iter()
547            .any(|one| entry.destination.starts_with(&one.path))
548        {
549            continue;
550        }
551        if !entry.destination.is_file() {
552            continue;
553        }
554        let found = Sha256::of(&std::fs::read(&entry.destination)?);
555        if record.wrote(&entry.destination, &found) {
556            lines.push(entry.destination.to_string());
557            removals.push((entry.destination.clone(), found));
558        } else if found == entry.digest {
559            // The payload's own bytes with no receipt behind them: this
560            // tool cannot tell its copy from one the user placed there.
561            lines.push(format!("kept (not this tool's): {}", entry.destination));
562        } else {
563            lines.push(format!("kept (edited): {}", entry.destination));
564        }
565    }
566
567    let keep: BTreeSet<Utf8PathBuf> = removals
568        .iter()
569        .map(|(destination, _)| destination.clone())
570        .collect();
571    for (destination, digest, ours) in leftovers(&layout.scanned(), &record, &keep) {
572        if ours {
573            lines.push(format!("sweep (no longer in the payload): {destination}"));
574            removals.push((destination, digest));
575        } else if destination.exists() {
576            lines.push(format!("kept (edited): {destination}"));
577        }
578    }
579
580    if !apply {
581        lines.push("DRY RUN: no files removed".to_string());
582        return Ok(lines);
583    }
584    if !refused.is_empty() {
585        return Err(refuse_blocked(&refused).into());
586    }
587    // A home this tool never wrote into has nothing to take back, and an
588    // uninstall there must leave it exactly as it found it — no state root,
589    // no lock file, nothing.
590    if removals.is_empty() && !layout.receipt.exists() {
591        return Ok(lines);
592    }
593
594    let gone: Vec<Utf8PathBuf> = removals
595        .iter()
596        .map(|(destination, _)| destination.clone())
597        .collect();
598    let receipt = next_receipt(&record, &[], &gone);
599    run_transaction(layout, &[], &removals, &receipt, &mut lines, interrupt)?;
600    Ok(lines)
601}
602
603fn refuse_blocked(refused: &[Blocked]) -> AppError {
604    let named: Vec<String> = refused.iter().map(Blocked::to_string).collect();
605    AppError::Refused(format!(
606        "destinations cannot be written through: {}; move them aside and run this again",
607        named.join("; ")
608    ))
609}
610
611/// Stage, journal, replace, remove, and record — or put everything back.
612fn run_transaction(
613    layout: &Layout,
614    writes: &[Planned],
615    removals: &[(Utf8PathBuf, Sha256)],
616    receipt: &SkillRecord,
617    lines: &mut Vec<String>,
618    interrupt: Interrupt,
619) -> Result<(), Failure> {
620    // A receipt that vouches for nothing is removed rather than written:
621    // an empty record is a file that says the tool wrote something here,
622    // and it did not.
623    let empty = receipt.written.is_empty();
624    let receipt_bytes = receipt.to_json().into_bytes();
625    let receipt_current = if empty {
626        !layout.receipt.exists()
627    } else {
628        std::fs::read(&layout.receipt).is_ok_and(|held| held == receipt_bytes)
629    };
630    if writes.is_empty() && removals.is_empty() && receipt_current {
631        return Ok(());
632    }
633
634    let stage = Stage::new(&layout.backups())?;
635    let mut entries: Vec<Entry> = Vec::new();
636    let mut passed = 0usize;
637    for entry in writes {
638        let before = stage.back_up(&entry.destination)?;
639        entries.push(Entry::write(
640            entry.destination.clone(),
641            before,
642            entry.digest.clone(),
643        ));
644        reached(&mut passed, interrupt)?;
645    }
646    for (destination, _) in removals {
647        let Some(before) = stage.back_up(destination)? else {
648            continue;
649        };
650        entries.push(Entry::remove(destination.clone(), before));
651        reached(&mut passed, interrupt)?;
652    }
653    let receipt_before = stage.back_up(&layout.receipt)?;
654    if empty {
655        if let Some(before) = receipt_before {
656            entries.push(Entry::remove(layout.receipt.clone(), before));
657        }
658    } else {
659        entries.push(Entry::write(
660            layout.receipt.clone(),
661            receipt_before,
662            Sha256::of(&receipt_bytes),
663        ));
664    }
665    reached(&mut passed, interrupt)?;
666
667    let mut journal = Journal::begin(&layout.journal_path(), &layout.backups(), entries)?;
668    reached(&mut passed, interrupt)?;
669
670    let result = execute(
671        layout,
672        writes,
673        removals,
674        if empty {
675            None
676        } else {
677            Some(receipt_bytes.as_slice())
678        },
679        &mut journal,
680        &mut passed,
681        interrupt,
682    );
683    match result {
684        Ok(()) => {}
685        Err(Failure::Abandoned) => return Err(Failure::Abandoned),
686        Err(Failure::Error(cause)) => {
687            let restored = journal.roll_back();
688            return Err(Failure::Error(abort(&cause, restored.err().as_ref())));
689        }
690    }
691
692    let mut directories: BTreeSet<Utf8PathBuf> = BTreeSet::new();
693    for (destination, _) in removals {
694        let mut parent = destination.parent();
695        while let Some(directory) = parent {
696            if !layout.roots.iter().any(|root| directory.starts_with(root))
697                && !directory.starts_with(&layout.state_root)
698            {
699                break;
700            }
701            directories.insert(directory.to_owned());
702            parent = directory.parent();
703        }
704    }
705    let mut stop = layout.roots.clone();
706    stop.push(layout.state_root.clone());
707    prune_empty(&directories, &stop, lines);
708
709    journal.finish()?;
710    // The journal is gone, so nothing can ask for a copy of what this run
711    // replaced. Keeping them would grow the state root by one file per
712    // release for the life of the home.
713    let _ = std::fs::remove_dir_all(layout.backups());
714    Ok(())
715}
716
717fn execute(
718    layout: &Layout,
719    writes: &[Planned],
720    removals: &[(Utf8PathBuf, Sha256)],
721    receipt_bytes: Option<&[u8]>,
722    journal: &mut Journal,
723    passed: &mut usize,
724    interrupt: Interrupt,
725) -> Result<(), Failure> {
726    for entry in writes {
727        replace_one(layout, &entry.destination, entry.bytes)?;
728        reached(passed, interrupt)?;
729        journal.mark_done(&entry.destination)?;
730        reached(passed, interrupt)?;
731    }
732    for (destination, _) in removals {
733        match std::fs::remove_file(destination) {
734            Ok(()) => crate::transaction::sync_parent(destination)?,
735            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
736            Err(source) => return Err(Failure::Error(AppError::Io(source))),
737        }
738        reached(passed, interrupt)?;
739        journal.mark_done(destination)?;
740        reached(passed, interrupt)?;
741    }
742    // The receipt is last, and a receipt this run cannot write is a run
743    // that rolls back: a landing this tool cannot vouch for is a landing it
744    // would refuse to take back.
745    match receipt_bytes {
746        Some(bytes) => {
747            replace_one(layout, &layout.receipt, bytes).map_err(|failure| match failure {
748                Failure::Error(cause) => {
749                    Failure::Error(AppError::Receipt(format!("{}: {cause}", layout.receipt)))
750                }
751                abandoned @ Failure::Abandoned => abandoned,
752            })?;
753        }
754        None => match std::fs::remove_file(&layout.receipt) {
755            Ok(()) => crate::transaction::sync_parent(&layout.receipt)?,
756            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
757            Err(source) => {
758                return Err(Failure::Error(AppError::Receipt(format!(
759                    "{}: {source}",
760                    layout.receipt
761                ))));
762            }
763        },
764    }
765    reached(passed, interrupt)?;
766    journal.mark_done(&layout.receipt)?;
767    reached(passed, interrupt)?;
768    Ok(())
769}
770
771/// Stage one destination and rename it into place.
772///
773/// The chain is inspected again immediately before the rename, so a link
774/// substituted between the plan and the write is refused rather than
775/// followed.
776fn replace_one(layout: &Layout, destination: &Utf8Path, bytes: &[u8]) -> Result<(), Failure> {
777    let scratch = Stage::write(destination, bytes)?;
778    if let Some(root) = owning_root(layout, destination)
779        && let Some(one) = blocked_by(root, destination)
780    {
781        Stage::discard(&scratch);
782        return Err(Failure::Error(refuse_blocked(std::slice::from_ref(&one))));
783    }
784    Stage::replace(&scratch, destination)?;
785    Ok(())
786}
787
788/// The refusal a failed apply carries, naming the cause and what it restored.
789fn abort(cause: &AppError, unrestored: Option<&AppError>) -> AppError {
790    unrestored.map_or_else(
791        || {
792            AppError::Refused(format!(
793                "skill install aborted; the destinations were restored: {cause}"
794            ))
795        },
796        |failure| {
797            AppError::Unrecovered(format!(
798                "skill install aborted and restoration is incomplete; verify by hand: {failure}: {cause}"
799            ))
800        },
801    )
802}
803
804#[cfg(test)]
805mod tests {
806    #![allow(
807        clippy::unwrap_used,
808        reason = "a test panics as its failure signal, not as control flow"
809    )]
810
811    use std::collections::BTreeMap;
812
813    use super::*;
814
815    fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
816        Utf8PathBuf::from(dir.path().to_str().unwrap())
817    }
818
819    /// A digest of every file a run is answerable for, under one scratch
820    /// home.
821    ///
822    /// The lock, the journal, the holder note, and the backup store are the
823    /// transaction's own workings rather than destinations, so a comparison
824    /// that counted them would report a run as having changed the home when
825    /// it changed only its own scaffolding.
826    fn tree(dir: &tempfile::TempDir) -> BTreeMap<String, Sha256> {
827        walkdir::WalkDir::new(dir.path())
828            .into_iter()
829            .filter_map(Result::ok)
830            .filter(|entry| entry.file_type().is_file())
831            .filter_map(|entry| {
832                let path = entry.path().to_str()?.to_string();
833                let bytes = std::fs::read(entry.path()).ok()?;
834                Some((path, Sha256::of(&bytes)))
835            })
836            .filter(|(path, _)| {
837                !path.contains("/backups/")
838                    && !["journal", "lock", "holder"].iter().any(|suffix| {
839                        std::path::Path::new(path)
840                            .extension()
841                            .is_some_and(|found| found == *suffix)
842                    })
843            })
844            .collect()
845    }
846
847    /// The layout a home directory implies, selecting both agent roots.
848    fn home(dir: &tempfile::TempDir) -> Layout {
849        let home = root(dir);
850        let state = home.join(crate::domain::paths::STATE_ROOT);
851        Layout {
852            roots: vec![home.join(AGENTS_ROOT), home.join(CLAUDE_ROOT)],
853            receipt: state.join(crate::domain::paths::SKILL_RECEIPT_FILE),
854            legacy_receipt: home.join(crate::domain::paths::LEGACY_SKILL_RECEIPT_PATH),
855            legacy_shared: home.join(LEGACY_SHARED_ROOT),
856            state_root: state,
857        }
858    }
859
860    /// The same layout narrowed to one selected root.
861    fn select(layout: &Layout, index: usize) -> Layout {
862        Layout {
863            roots: vec![layout.roots[index].clone()],
864            ..layout.clone()
865        }
866    }
867
868    fn package_files(root: &Utf8Path, name: &str) -> Vec<Utf8PathBuf> {
869        crate::embedded::skill_package(name)
870            .unwrap()
871            .into_iter()
872            .map(|(relative, _)| root.join(name).join(relative))
873            .collect()
874    }
875
876    #[test]
877    fn a_package_is_skill_md_plus_every_shared_artifact_under_references() {
878        let package = crate::embedded::skill_package("sdd-setup").unwrap();
879        let names: Vec<&str> = package.iter().map(|(path, _)| path.as_str()).collect();
880        assert!(names.contains(&"SKILL.md"));
881        assert!(names.contains(&"references/plan-gate.md"));
882        assert!(names.contains(&"references/pre-flight-gate.md"));
883        assert_eq!(package.len(), 1 + crate::embedded::shared_artifacts().len());
884        assert!(crate::embedded::skill_package("no-such-skill").is_none());
885    }
886
887    #[test]
888    fn an_install_lands_every_package_file_and_records_each_digest() {
889        let dir = tempfile::tempdir().unwrap();
890        let layout = home(&dir);
891        install(&layout, true, false).unwrap();
892        let record = SkillRecord::load(&layout.receipt);
893        assert_eq!(record.schema_version, 2);
894        assert_eq!(record.engine_version, env!("CARGO_PKG_VERSION"));
895        for root in &layout.roots {
896            for name in crate::embedded::skill_names() {
897                for path in package_files(root, name) {
898                    assert!(path.is_file(), "{path} did not land");
899                    let digest = Sha256::of(&std::fs::read(&path).unwrap());
900                    assert!(record.wrote(&path, &digest), "{path} was not recorded");
901                }
902            }
903        }
904    }
905
906    #[test]
907    fn a_preview_lists_every_destination_and_writes_nothing() {
908        let dir = tempfile::tempdir().unwrap();
909        let layout = home(&dir);
910        let lines = install(&layout, false, false).unwrap();
911        assert_eq!(lines.last().unwrap(), "DRY RUN: no files written");
912        let package = crate::embedded::skill_package("sdd-setup").unwrap().len();
913        assert_eq!(
914            lines.len(),
915            crate::embedded::skill_names().len() * package * 2 + 1
916        );
917        assert!(!layout.roots[0].exists());
918        assert!(!layout.receipt.exists());
919    }
920
921    #[test]
922    fn a_second_install_is_idempotent_and_writes_nothing() {
923        let dir = tempfile::tempdir().unwrap();
924        let layout = home(&dir);
925        install(&layout, true, false).unwrap();
926        let before = std::fs::metadata(layout.roots[0].join("sdd-setup/SKILL.md"))
927            .unwrap()
928            .modified()
929            .unwrap();
930        let receipt_before = std::fs::read(&layout.receipt).unwrap();
931        install(&layout, true, false).unwrap();
932        assert_eq!(
933            std::fs::metadata(layout.roots[0].join("sdd-setup/SKILL.md"))
934                .unwrap()
935                .modified()
936                .unwrap(),
937            before
938        );
939        assert_eq!(std::fs::read(&layout.receipt).unwrap(), receipt_before);
940        assert!(!layout.journal_path().exists());
941    }
942
943    #[test]
944    fn a_stale_package_file_the_receipt_vouches_for_is_replaced_without_force() {
945        let dir = tempfile::tempdir().unwrap();
946        let layout = home(&dir);
947        install(&layout, true, false).unwrap();
948
949        let mut stale = SkillRecord::load(&layout.receipt);
950        let destination = layout.roots[0].join("sdd-setup/references/plan-gate.md");
951        std::fs::write(&destination, "older canon bytes\n").unwrap();
952        stale
953            .written
954            .insert(destination.clone(), Sha256::of(b"older canon bytes\n"));
955        crate::adapters::fs::write_file(&layout.receipt, stale.to_json().as_bytes()).unwrap();
956
957        install(&layout, true, false).unwrap();
958        assert!(
959            std::fs::read_to_string(&destination)
960                .unwrap()
961                .contains("# The plan gate")
962        );
963    }
964
965    #[test]
966    fn an_edited_reference_refuses_the_install_naming_every_conflict() {
967        let dir = tempfile::tempdir().unwrap();
968        let layout = home(&dir);
969        install(&layout, true, false).unwrap();
970        let edited = layout.roots[1].join("sdd-setup/references/plan-gate.md");
971        std::fs::write(&edited, "mine\n").unwrap();
972        let message = install(&layout, true, false).unwrap_err().to_string();
973        assert!(message.contains(edited.as_str()), "{message}");
974        assert_eq!(std::fs::read_to_string(&edited).unwrap(), "mine\n");
975    }
976
977    #[test]
978    fn force_replaces_an_edited_file_and_records_the_new_digest() {
979        let dir = tempfile::tempdir().unwrap();
980        let layout = home(&dir);
981        install(&layout, true, false).unwrap();
982        let edited = layout.roots[1].join("sdd-setup/SKILL.md");
983        std::fs::write(&edited, "mine\n").unwrap();
984        install(&layout, true, true).unwrap();
985        let held = std::fs::read(&edited).unwrap();
986        assert!(String::from_utf8_lossy(&held).contains("name: sdd-setup"));
987        assert!(SkillRecord::load(&layout.receipt).wrote(&edited, &Sha256::of(&held)));
988    }
989
990    /// The defect this phase closes: an uninstall that consulted the payload
991    /// and not the receipt deleted a skill the operator had edited.
992    #[test]
993    fn an_edited_skill_md_survives_uninstall_and_is_named_as_kept() {
994        let dir = tempfile::tempdir().unwrap();
995        let layout = home(&dir);
996        install(&layout, true, false).unwrap();
997        let edited = layout.roots[0].join("sdd-setup/SKILL.md");
998        std::fs::write(&edited, "mine\n").unwrap();
999
1000        let lines = uninstall(&layout, true).unwrap();
1001        assert_eq!(std::fs::read_to_string(&edited).unwrap(), "mine\n");
1002        assert!(
1003            lines
1004                .iter()
1005                .any(|line| line == &format!("kept (edited): {edited}")),
1006            "{lines:?}"
1007        );
1008    }
1009
1010    #[test]
1011    fn an_edited_reference_survives_uninstall_and_its_directory_stays() {
1012        let dir = tempfile::tempdir().unwrap();
1013        let layout = home(&dir);
1014        install(&layout, true, false).unwrap();
1015        let edited = layout.roots[0].join("sdd-setup/references/plan-gate.md");
1016        std::fs::write(&edited, "mine\n").unwrap();
1017        uninstall(&layout, true).unwrap();
1018        assert_eq!(std::fs::read_to_string(&edited).unwrap(), "mine\n");
1019        assert!(layout.roots[0].join("sdd-setup/references").is_dir());
1020    }
1021
1022    #[test]
1023    fn an_uninstall_removes_a_directory_only_when_nothing_recorded_or_foreign_remains() {
1024        let dir = tempfile::tempdir().unwrap();
1025        let layout = home(&dir);
1026        install(&layout, true, false).unwrap();
1027        let mine = layout.roots[1].join("sdd-setup/notes.md");
1028        std::fs::write(&mine, "mine").unwrap();
1029
1030        let lines = uninstall(&layout, true).unwrap();
1031        assert!(!layout.roots[1].join("sdd-write-docs").exists());
1032        assert!(!layout.roots[1].join("sdd-setup/SKILL.md").exists());
1033        assert!(!layout.roots[1].join("sdd-setup/references").exists());
1034        assert_eq!(std::fs::read_to_string(&mine).unwrap(), "mine");
1035        assert!(
1036            lines
1037                .iter()
1038                .any(|line| line.starts_with("kept (not empty):"))
1039        );
1040        // A re-run on the emptied roots is a no-op, not an error.
1041        uninstall(&layout, true).unwrap();
1042    }
1043
1044    #[test]
1045    fn the_retired_shared_root_is_swept_when_the_receipt_vouches_for_it() {
1046        let dir = tempfile::tempdir().unwrap();
1047        let layout = home(&dir);
1048        // Stand in for a 0.8.0 home: two gate files under the retired root,
1049        // each vouched for by the receipt that release wrote.
1050        let mut older = SkillRecord::new();
1051        for (name, bytes) in crate::embedded::shared_artifacts() {
1052            let destination = layout.legacy_shared.join(&name);
1053            crate::adapters::fs::write_file(&destination, bytes).unwrap();
1054            older.written.insert(destination, Sha256::of(bytes));
1055        }
1056        crate::adapters::fs::write_file(&layout.receipt, older.to_json().as_bytes()).unwrap();
1057
1058        install(&layout, true, false).unwrap();
1059        assert!(
1060            !layout.legacy_shared.exists(),
1061            "the retired shared root survived"
1062        );
1063        assert!(
1064            layout.roots[0]
1065                .join("sdd-setup/references/plan-gate.md")
1066                .is_file()
1067        );
1068    }
1069
1070    #[test]
1071    fn an_unrecorded_file_at_the_retired_shared_root_is_kept() {
1072        let dir = tempfile::tempdir().unwrap();
1073        let layout = home(&dir);
1074        let leftover = layout.legacy_shared.join("plan-gate.md");
1075        crate::adapters::fs::write_file(&leftover, b"mine\n").unwrap();
1076        install(&layout, true, false).unwrap();
1077        assert_eq!(std::fs::read(&leftover).unwrap(), b"mine\n");
1078    }
1079
1080    #[test]
1081    fn a_symlinked_package_directory_skill_md_reference_or_parent_is_a_typed_conflict() {
1082        for linked in ["sdd-setup", "sdd-setup/SKILL.md", "sdd-setup/references"] {
1083            let dir = tempfile::tempdir().unwrap();
1084            let layout = home(&dir);
1085            let elsewhere = root(&dir).join("elsewhere");
1086            std::fs::create_dir_all(&elsewhere).unwrap();
1087            let target = layout.roots[0].join(linked);
1088            std::fs::create_dir_all(target.parent().unwrap()).unwrap();
1089            std::os::unix::fs::symlink(&elsewhere, target.as_std_path()).unwrap();
1090
1091            for force in [false, true] {
1092                let message = install(&layout, true, force).unwrap_err().to_string();
1093                assert!(
1094                    message.contains("symlink"),
1095                    "{linked} with force {force}: {message}"
1096                );
1097                assert!(message.contains(target.as_str()), "{message}");
1098            }
1099            assert!(!elsewhere.join("SKILL.md").exists());
1100            assert!(target.is_symlink(), "the link itself was replaced");
1101        }
1102    }
1103
1104    #[test]
1105    fn a_symlink_whose_target_matches_the_recorded_digest_is_still_a_conflict() {
1106        let dir = tempfile::tempdir().unwrap();
1107        let layout = home(&dir);
1108        install(&layout, true, false).unwrap();
1109        let destination = layout.roots[0].join("sdd-setup/SKILL.md");
1110        let elsewhere = root(&dir).join("copy.md");
1111        std::fs::copy(&destination, &elsewhere).unwrap();
1112        std::fs::remove_file(&destination).unwrap();
1113        std::os::unix::fs::symlink(&elsewhere, destination.as_std_path()).unwrap();
1114
1115        let message = install(&layout, true, true).unwrap_err().to_string();
1116        assert!(message.contains("symlink"), "{message}");
1117        let message = uninstall(&layout, true).unwrap_err().to_string();
1118        assert!(message.contains("symlink"), "{message}");
1119        assert!(destination.is_symlink());
1120    }
1121
1122    #[test]
1123    fn two_roots_resolving_to_one_path_are_written_once() {
1124        let dir = tempfile::tempdir().unwrap();
1125        let mut layout = home(&dir);
1126        layout.roots = vec![layout.roots[0].clone()];
1127        let lines = install(&layout, false, false).unwrap();
1128        let landed = lines
1129            .iter()
1130            .filter(|line| line.ends_with("sdd-setup/SKILL.md"))
1131            .count();
1132        assert_eq!(landed, 1);
1133    }
1134
1135    #[test]
1136    fn a_second_installer_refuses_while_the_lock_is_held_naming_the_holder() {
1137        let dir = tempfile::tempdir().unwrap();
1138        let layout = home(&dir);
1139        // The home holds packages first, so the uninstall below has work to
1140        // do and reaches the lock rather than returning early.
1141        install(&layout, true, false).unwrap();
1142        age(&layout);
1143        let _held = Lock::exclusive(&layout.lock_path(), "skill install").unwrap();
1144        let error = install(&layout, true, false).unwrap_err();
1145        assert_eq!(error.kind(), "Busy");
1146        assert_eq!(error.exit_code(), 73);
1147        assert!(error.to_string().contains("skill install"));
1148        let error = uninstall(&layout, true).unwrap_err();
1149        assert_eq!(error.kind(), "Busy");
1150    }
1151
1152    /// A run the process did not finish is put back before the next one
1153    /// writes anything, at every boundary of the persistence order.
1154    /// Stand in for a home an older release left: every package file holds
1155    /// its bytes, and the receipt vouches for each, so the next install has
1156    /// a replacement at every boundary.
1157    fn age(layout: &Layout) {
1158        let mut older = SkillRecord::new();
1159        for root in &layout.roots {
1160            for name in crate::embedded::skill_names() {
1161                for path in package_files(root, name) {
1162                    crate::adapters::fs::write_file(&path, b"older\n").unwrap();
1163                    older.written.insert(path, Sha256::of(b"older\n"));
1164                }
1165            }
1166        }
1167        crate::adapters::fs::write_file(&layout.receipt, older.to_json().as_bytes()).unwrap();
1168    }
1169
1170    /// A run the process did not finish is put back before the next one
1171    /// writes anything, at every boundary of the persistence order.
1172    #[test]
1173    fn an_interrupted_install_is_rolled_back_by_the_next_invocation() {
1174        let mut boundaries = 0usize;
1175        for after in 1..200 {
1176            let dir = tempfile::tempdir().unwrap();
1177            // One root, because the matrix walks every boundary and the
1178            // second root doubles the walk without adding a kind of
1179            // boundary to it.
1180            let layout = select(&home(&dir), 0);
1181            age(&layout);
1182            let before = tree(&dir);
1183
1184            let outcome = install_with(&layout, true, false, Interrupt { after: Some(after) });
1185            let Err(Failure::Abandoned) = outcome else {
1186                // The run finished before this boundary, so every earlier
1187                // boundary has already been walked.
1188                break;
1189            };
1190            boundaries = after;
1191
1192            // The next invocation recovers before it plans new work, so the
1193            // home is exactly what it was before the interrupted run.
1194            // Before the journal exists there is nothing to recover, and
1195            // nothing has been replaced either, so both boundaries hold the
1196            // same promise: the home reads back exactly as it was.
1197            journal::recover(&layout.journal_path()).unwrap();
1198            assert!(!layout.journal_path().exists(), "after {after}");
1199            assert_eq!(tree(&dir), before, "after {after}");
1200
1201            // Every few boundaries, take the recovered home all the way, so
1202            // the matrix proves a recovery leaves a home an install can
1203            // still land into and not only one that reads back the same.
1204            if after % 7 == 0 {
1205                install(&layout, true, false).unwrap();
1206                let record = SkillRecord::load(&layout.receipt);
1207                for path in package_files(&layout.roots[0], "sdd-setup") {
1208                    let digest = Sha256::of(&std::fs::read(&path).unwrap());
1209                    assert!(record.wrote(&path, &digest), "after {after}: {path}");
1210                }
1211            }
1212        }
1213        assert!(boundaries > 10, "only {boundaries} boundaries were walked");
1214    }
1215
1216    #[test]
1217    fn recovery_is_idempotent_across_a_second_interruption() {
1218        let dir = tempfile::tempdir().unwrap();
1219        let layout = home(&dir);
1220        age(&layout);
1221        let before = tree(&dir);
1222        let Err(Failure::Abandoned) =
1223            install_with(&layout, true, false, Interrupt { after: Some(20) })
1224        else {
1225            panic!("the run was not interrupted");
1226        };
1227        journal::recover(&layout.journal_path()).unwrap();
1228        assert!(!journal::recover(&layout.journal_path()).unwrap());
1229        assert_eq!(tree(&dir), before);
1230    }
1231
1232    #[test]
1233    fn a_receipt_write_failure_fails_the_apply_and_rolls_back() {
1234        let dir = tempfile::tempdir().unwrap();
1235        let layout = home(&dir);
1236        age(&layout);
1237        let before = tree(&dir);
1238
1239        // A directory sits where the receipt stages, so the receipt is the
1240        // one write that cannot land, and it is the last one the run makes.
1241        std::fs::create_dir_all(
1242            crate::transaction::stage::scratch_for(&layout.receipt).as_std_path(),
1243        )
1244        .unwrap();
1245        let error = install(&layout, true, false).unwrap_err();
1246        std::fs::remove_dir(crate::transaction::stage::scratch_for(&layout.receipt).as_std_path())
1247            .unwrap();
1248
1249        let message = error.to_string();
1250        assert!(message.contains(layout.receipt.as_str()), "{message}");
1251        assert_eq!(tree(&dir), before);
1252    }
1253
1254    #[test]
1255    fn an_unreadable_journal_refuses_the_next_run() {
1256        let dir = tempfile::tempdir().unwrap();
1257        let layout = home(&dir);
1258        crate::adapters::fs::write_file(&layout.journal_path(), b"{not json").unwrap();
1259        let error = install(&layout, true, false).unwrap_err();
1260        assert_eq!(error.kind(), "Unrecovered");
1261        assert!(!layout.roots[0].exists(), "the run wrote past the journal");
1262    }
1263
1264    #[test]
1265    fn a_schema_one_receipt_reads_through_the_adapter() {
1266        let dir = tempfile::tempdir().unwrap();
1267        let layout = home(&dir);
1268        let destination = layout.roots[0].join("sdd-setup/SKILL.md");
1269        crate::adapters::fs::write_file(&destination, b"older\n").unwrap();
1270        crate::adapters::fs::write_file(
1271            &layout.receipt,
1272            format!(
1273                "{{\"schema_version\":1,\"written\":{{\"{destination}\":\"{}\"}}}}",
1274                Sha256::of(b"older\n")
1275            )
1276            .as_bytes(),
1277        )
1278        .unwrap();
1279        // The stale copy is the tool's, so the install replaces it without
1280        // asking, which only happens if the adapter read the old shape.
1281        install(&layout, true, false).unwrap();
1282        assert!(
1283            String::from_utf8_lossy(&std::fs::read(&destination).unwrap())
1284                .contains("name: sdd-setup")
1285        );
1286        assert_eq!(SkillRecord::load(&layout.receipt).schema_version, 2);
1287    }
1288
1289    #[test]
1290    fn a_receipt_at_the_legacy_path_is_read_once_and_rewritten_at_the_resolved_one() {
1291        let dir = tempfile::tempdir().unwrap();
1292        let mut layout = home(&dir);
1293        layout.state_root = root(&dir).join("xdg/spec-driven-docs");
1294        layout.receipt = layout
1295            .state_root
1296            .join(crate::domain::paths::SKILL_RECEIPT_FILE);
1297
1298        let destination = layout.roots[0].join("sdd-setup/SKILL.md");
1299        crate::adapters::fs::write_file(&destination, b"older\n").unwrap();
1300        let mut legacy = SkillRecord::new();
1301        legacy
1302            .written
1303            .insert(destination.clone(), Sha256::of(b"older\n"));
1304        crate::adapters::fs::write_file(&layout.legacy_receipt, legacy.to_json().as_bytes())
1305            .unwrap();
1306
1307        install(&layout, true, false).unwrap();
1308        assert!(layout.receipt.is_file());
1309        assert!(
1310            String::from_utf8_lossy(&std::fs::read(&destination).unwrap())
1311                .contains("name: sdd-setup")
1312        );
1313    }
1314
1315    #[test]
1316    fn a_write_that_fails_partway_restores_every_destination() {
1317        let dir = tempfile::tempdir().unwrap();
1318        let layout = home(&dir);
1319        install(&layout, true, false).unwrap();
1320
1321        // Every destination carries recognisable bytes, then one package
1322        // directory in the second root denies writes, so the failure lands
1323        // after the first root is already rewritten.
1324        for root in &layout.roots {
1325            for name in crate::embedded::skill_names() {
1326                for path in package_files(root, name) {
1327                    std::fs::write(&path, format!("previous {name}\n")).unwrap();
1328                }
1329            }
1330        }
1331        let before = tree(&dir);
1332        let blocked = layout.roots[1].join("sdd-write-docs");
1333        let mut permissions = std::fs::metadata(&blocked).unwrap().permissions();
1334        std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o500);
1335        std::fs::set_permissions(&blocked, permissions.clone()).unwrap();
1336
1337        let message = install(&layout, true, true).unwrap_err().to_string();
1338
1339        std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
1340        std::fs::set_permissions(&blocked, permissions).unwrap();
1341
1342        assert!(message.contains("skill install aborted"), "{message}");
1343        assert!(
1344            message.contains("the destinations were restored"),
1345            "{message}"
1346        );
1347        assert_eq!(tree(&dir), before);
1348        assert!(!layout.journal_path().exists());
1349    }
1350
1351    #[test]
1352    fn an_uninstall_of_one_root_keeps_the_others_entries() {
1353        let dir = tempfile::tempdir().unwrap();
1354        let layout = home(&dir);
1355        install(&layout, true, false).unwrap();
1356        uninstall(&select(&layout, 1), true).unwrap();
1357        let kept = SkillRecord::load(&layout.receipt);
1358        assert!(
1359            kept.written
1360                .keys()
1361                .all(|path| path.starts_with(&layout.roots[0]))
1362        );
1363        assert!(!kept.written.is_empty());
1364        assert!(
1365            layout.roots[0]
1366                .join("sdd-setup/references/plan-gate.md")
1367                .is_file()
1368        );
1369    }
1370
1371    #[test]
1372    fn either_agent_alone_lands_a_whole_package() {
1373        for index in 0..2 {
1374            let dir = tempfile::tempdir().unwrap();
1375            let layout = home(&dir);
1376            let narrowed = select(&layout, index);
1377            install(&narrowed, true, false).unwrap();
1378            for path in package_files(&layout.roots[index], "sdd-setup") {
1379                assert!(path.is_file(), "{path} did not land");
1380            }
1381            assert!(!layout.roots[1 - index].exists());
1382        }
1383    }
1384
1385    #[test]
1386    fn the_last_uninstall_takes_the_receipt_with_it() {
1387        let dir = tempfile::tempdir().unwrap();
1388        let layout = home(&dir);
1389        install(&layout, true, false).unwrap();
1390        uninstall(&layout, true).unwrap();
1391        assert!(
1392            !layout.receipt.exists(),
1393            "the receipt outlived every file it vouched for"
1394        );
1395        for root in &layout.roots {
1396            assert!(!root.join("sdd-setup").exists());
1397        }
1398    }
1399
1400    #[test]
1401    fn an_uninstall_on_a_home_this_tool_never_wrote_into_touches_nothing() {
1402        let dir = tempfile::tempdir().unwrap();
1403        let layout = home(&dir);
1404        let before = tree(&dir);
1405        uninstall(&layout, true).unwrap();
1406        assert_eq!(tree(&dir), before);
1407        assert!(!layout.state_root.exists());
1408    }
1409}