1use 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
33pub use crate::domain::paths::{AGENTS_ROOT, CLAUDE_ROOT, LEGACY_SHARED_ROOT};
37
38pub 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#[derive(Debug, Clone)]
51pub struct Layout {
52 pub roots: Vec<Utf8PathBuf>,
54 pub state_root: Utf8PathBuf,
56 pub receipt: Utf8PathBuf,
58 pub legacy_receipt: Utf8PathBuf,
60 pub legacy_shared: Utf8PathBuf,
62}
63
64impl Layout {
65 #[must_use]
67 pub fn lock_path(&self) -> Utf8PathBuf {
68 self.state_root.join(crate::domain::paths::SKILL_LOCK_FILE)
69 }
70
71 #[must_use]
73 pub fn journal_path(&self) -> Utf8PathBuf {
74 self.state_root
75 .join(crate::domain::paths::SKILL_JOURNAL_FILE)
76 }
77
78 #[must_use]
80 pub fn backups(&self) -> Utf8PathBuf {
81 self.state_root.join(crate::domain::paths::BACKUPS_DIR)
82 }
83
84 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#[derive(Debug, Clone)]
94struct Planned {
95 destination: Utf8PathBuf,
96 bytes: &'static [u8],
97 digest: Sha256,
98}
99
100#[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
113enum 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
140struct Interrupt {
141 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
153fn 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
172fn 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(¤t) {
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(¤t) else {
200 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
230fn 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
240fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258enum Standing {
259 Absent,
261 Current,
263 Recorded,
265 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 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
289fn 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
314fn 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
333fn 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 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
359fn load_receipt(layout: &Layout) -> SkillRecord {
361 SkillRecord::load_with_fallback(&layout.receipt, &layout.legacy_receipt)
362}
363
364pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<String>, AppError> {
374 settle(install_with(layout, apply, force, Interrupt::default()))
375}
376
377pub 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
396fn 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
419fn 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
518fn uninstall_with(
520 layout: &Layout,
521 apply: bool,
522 interrupt: Interrupt,
523) -> Result<Vec<String>, Failure> {
524 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 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 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
611fn 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 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 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 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
771fn 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
788fn 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 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 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 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 #[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 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 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 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 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 #[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 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 break;
1189 };
1190 boundaries = after;
1191
1192 journal::recover(&layout.journal_path()).unwrap();
1198 assert!(!layout.journal_path().exists(), "after {after}");
1199 assert_eq!(tree(&dir), before, "after {after}");
1200
1201 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 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 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 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}