1use std::collections::BTreeSet;
21
22use camino::{Utf8Path, Utf8PathBuf};
23
24use crate::domain::ownership::Sha256;
25use crate::domain::paths::HOME_VAR;
26use crate::domain::skill_record::SkillRecord;
27use crate::error::AppError;
28use crate::transaction::lock::Lock;
29use crate::transaction::stage::Stage;
30
31pub use crate::domain::paths::{AGENTS_ROOT, CLAUDE_ROOT, LEGACY_SHARED_ROOT};
35
36pub fn home() -> Result<Utf8PathBuf, AppError> {
42 crate::domain::paths::UserEnv::from_process()
43 .home
44 .ok_or_else(|| AppError::Usage(format!("{HOME_VAR} is not set")))
45}
46
47#[derive(Debug, Clone)]
49pub struct Layout {
50 pub roots: Vec<Utf8PathBuf>,
52 pub state_root: Utf8PathBuf,
54 pub receipt: Utf8PathBuf,
56 pub legacy_receipt: Utf8PathBuf,
58 pub legacy_shared: Utf8PathBuf,
60}
61
62impl Layout {
63 #[must_use]
65 pub fn lock_path(&self) -> Utf8PathBuf {
66 self.state_root.join(crate::domain::paths::SKILL_LOCK_FILE)
67 }
68
69 fn scanned(&self) -> Vec<Utf8PathBuf> {
71 let mut scanned = self.roots.clone();
72 scanned.push(self.legacy_shared.clone());
73 scanned
74 }
75}
76
77#[derive(Debug, Clone)]
79struct Planned {
80 destination: Utf8PathBuf,
81 bytes: &'static [u8],
82 digest: Sha256,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87struct Blocked {
88 path: Utf8PathBuf,
89 kind: &'static str,
90}
91
92impl std::fmt::Display for Blocked {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 write!(f, "{} is {}", self.path, self.kind)
95 }
96}
97
98enum Failure {
103 Error(AppError),
104 Abandoned,
105}
106
107impl From<AppError> for Failure {
108 fn from(error: AppError) -> Self {
109 Self::Error(error)
110 }
111}
112
113impl From<std::io::Error> for Failure {
114 fn from(error: std::io::Error) -> Self {
115 Self::Error(AppError::Io(error))
116 }
117}
118
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125struct Interrupt {
126 after: Option<usize>,
128}
129
130fn reached(passed: &mut usize, interrupt: Interrupt) -> Result<(), Failure> {
131 *passed += 1;
132 if interrupt.after == Some(*passed) {
133 return Err(Failure::Abandoned);
134 }
135 Ok(())
136}
137
138fn plan(roots: &[Utf8PathBuf]) -> Result<Vec<Planned>, AppError> {
140 let mut planned = Vec::new();
141 for root in roots {
142 for name in crate::embedded::skill_names() {
143 let package = crate::embedded::skill_package(name)
144 .ok_or_else(|| anyhow::anyhow!("payload skill missing: {name}"))?;
145 for (relative, bytes) in package {
146 planned.push(Planned {
147 destination: root.join(name).join(relative),
148 bytes,
149 digest: Sha256::of(bytes),
150 });
151 }
152 }
153 }
154 Ok(planned)
155}
156
157fn blocked_by(root: &Utf8Path, destination: &Utf8Path) -> Option<Blocked> {
164 let relative = destination.strip_prefix(root).ok()?;
165 let mut current = root.to_owned();
166 if let Ok(meta) = std::fs::symlink_metadata(¤t) {
167 if meta.file_type().is_symlink() {
168 return Some(Blocked {
169 path: current,
170 kind: "a symlink",
171 });
172 }
173 if !meta.is_dir() {
174 return Some(Blocked {
175 path: current,
176 kind: "a file where a directory is needed",
177 });
178 }
179 }
180 let components: Vec<&str> = relative.as_str().split('/').collect();
181 let last = components.len().saturating_sub(1);
182 for (index, part) in components.iter().enumerate() {
183 current = current.join(part);
184 let Ok(meta) = std::fs::symlink_metadata(¤t) else {
185 return None;
187 };
188 if meta.file_type().is_symlink() {
189 return Some(Blocked {
190 path: current,
191 kind: "a symlink",
192 });
193 }
194 if index == last {
195 if !meta.is_file() {
196 return Some(Blocked {
197 path: current,
198 kind: if meta.is_dir() {
199 "a directory"
200 } else {
201 "not a regular file"
202 },
203 });
204 }
205 } else if !meta.is_dir() {
206 return Some(Blocked {
207 path: current,
208 kind: "a file where a directory is needed",
209 });
210 }
211 }
212 None
213}
214
215fn owning_root<'a>(layout: &'a Layout, destination: &Utf8Path) -> Option<&'a Utf8Path> {
217 layout
218 .roots
219 .iter()
220 .chain(std::iter::once(&layout.state_root))
221 .chain(std::iter::once(&layout.legacy_shared))
225 .map(Utf8PathBuf::as_path)
226 .find(|root| destination.starts_with(root))
227}
228
229fn blocked(layout: &Layout, destinations: &[Utf8PathBuf]) -> Vec<Blocked> {
231 let mut found = Vec::new();
232 for destination in destinations {
233 let Some(root) = owning_root(layout, destination) else {
234 continue;
235 };
236 if let Some(one) = blocked_by(root, destination)
237 && !found.contains(&one)
238 {
239 found.push(one);
240 }
241 }
242 found
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247enum Standing {
248 Absent,
250 Current,
252 Recorded,
254 Foreign,
256}
257
258fn standing(
259 destination: &Utf8Path,
260 intended: &Sha256,
261 record: &SkillRecord,
262) -> Result<Standing, AppError> {
263 if !destination.is_file() {
264 return Ok(Standing::Absent);
265 }
266 let found = Sha256::of(&std::fs::read(destination)?);
269 if &found == intended {
270 return Ok(Standing::Current);
271 }
272 if record.wrote(destination, &found) {
273 return Ok(Standing::Recorded);
274 }
275 Ok(Standing::Foreign)
276}
277
278fn leftovers(
284 scanned: &[Utf8PathBuf],
285 record: &SkillRecord,
286 keep: &BTreeSet<Utf8PathBuf>,
287) -> Vec<(Utf8PathBuf, Sha256, bool)> {
288 record
289 .written
290 .iter()
291 .filter(|(destination, _)| {
292 !keep.contains(*destination) && scanned.iter().any(|root| destination.starts_with(root))
293 })
294 .map(|(destination, digest)| {
295 let ours = !destination.is_symlink()
296 && destination.is_file()
297 && std::fs::read(destination).is_ok_and(|found| Sha256::of(&found) == *digest);
298 (destination.clone(), digest.clone(), ours)
299 })
300 .collect()
301}
302
303fn prune_empty(directories: &BTreeSet<Utf8PathBuf>, stop: &[Utf8PathBuf], lines: &mut Vec<String>) {
305 let mut deepest: Vec<&Utf8PathBuf> = directories.iter().collect();
306 deepest.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
307 for directory in deepest {
308 if stop.iter().any(|root| root == directory) {
309 continue;
310 }
311 let Ok(mut entries) = std::fs::read_dir(directory) else {
312 continue;
313 };
314 if entries.next().is_none() {
315 let _ = std::fs::remove_dir(directory);
316 } else {
317 lines.push(format!("kept (not empty): {directory}"));
318 }
319 }
320}
321
322fn next_receipt(record: &SkillRecord, written: &[Planned], removed: &[Utf8PathBuf]) -> SkillRecord {
324 let mut next = record.clone();
325 next.schema_version = crate::domain::skill_record::SCHEMA_VERSION;
326 next.engine_version = env!("CARGO_PKG_VERSION").to_string();
327 for destination in removed {
328 next.written.remove(destination);
329 }
330 for entry in written {
331 next.written
332 .insert(entry.destination.clone(), entry.digest.clone());
333 }
334 if next.written == record.written
339 && next.engine_version == record.engine_version
340 && record.schema_version == crate::domain::skill_record::SCHEMA_VERSION
341 {
342 return record.clone();
343 }
344 next.installed_at = jiff::Timestamp::now().to_string();
345 next
346}
347
348fn load_receipt(layout: &Layout) -> SkillRecord {
350 SkillRecord::load_with_fallback(&layout.receipt, &layout.legacy_receipt)
351}
352
353pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<String>, AppError> {
363 settle(install_with(layout, apply, force, Interrupt::default()))
364}
365
366pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<String>, AppError> {
373 settle(uninstall_with(layout, apply, Interrupt::default()))
374}
375
376fn settle(result: Result<Vec<String>, Failure>) -> Result<Vec<String>, AppError> {
377 result.map_err(|failure| match failure {
378 Failure::Error(error) => error,
379 Failure::Abandoned => AppError::Other(anyhow::anyhow!(
380 "the run was interrupted; the next invocation recovers it"
381 )),
382 })
383}
384
385fn held(layout: &Layout, apply: bool, purpose: &str) -> Result<Option<Lock>, Failure> {
399 if !apply {
400 return Ok(None);
401 }
402 let lock = Lock::exclusive(&layout.lock_path(), purpose)?;
403 Ok(Some(lock))
404}
405
406fn install_with(
408 layout: &Layout,
409 apply: bool,
410 force: bool,
411 interrupt: Interrupt,
412) -> Result<Vec<String>, Failure> {
413 let _lock = held(layout, apply, "skill install")?;
414 let planned = plan(&layout.roots)?;
415 let mut lines: Vec<String> = planned
416 .iter()
417 .map(|entry| entry.destination.to_string())
418 .collect();
419
420 let record = load_receipt(layout);
421 let kept: BTreeSet<Utf8PathBuf> = planned
422 .iter()
423 .map(|entry| entry.destination.clone())
424 .collect();
425 let stale = leftovers(&layout.scanned(), &record, &kept);
426 for (destination, _, ours) in &stale {
427 if *ours {
428 lines.push(format!(
429 "to sweep (no longer in the payload): {destination}"
430 ));
431 } else {
432 lines.push(format!("kept (edited): {destination}"));
433 }
434 }
435
436 let mut destinations: Vec<Utf8PathBuf> = planned
437 .iter()
438 .map(|entry| entry.destination.clone())
439 .collect();
440 destinations.push(layout.receipt.clone());
441 let refused = blocked(layout, &destinations);
442 for one in &refused {
443 lines.push(format!("conflict: {one}"));
444 }
445
446 let mut foreign: Vec<Utf8PathBuf> = Vec::new();
447 let mut writes: Vec<Planned> = Vec::new();
448 let mut vouched: Vec<Planned> = Vec::new();
453 for entry in &planned {
454 if refused
455 .iter()
456 .any(|one| entry.destination.starts_with(&one.path))
457 {
458 continue;
459 }
460 vouched.push(entry.clone());
461 match standing(&entry.destination, &entry.digest, &record)? {
462 Standing::Current => {}
463 Standing::Foreign => {
464 foreign.push(entry.destination.clone());
465 writes.push(entry.clone());
466 }
467 Standing::Absent | Standing::Recorded => writes.push(entry.clone()),
468 }
469 }
470 for destination in &foreign {
471 lines.push(format!(
472 "conflict: {destination} holds bytes this tool did not write"
473 ));
474 }
475
476 if !apply {
477 lines.push("DRY RUN: no files written".to_string());
478 return Ok(lines);
479 }
480 if !refused.is_empty() {
481 return Err(refuse_blocked(&refused).into());
482 }
483 if !force && !foreign.is_empty() {
484 let paths: Vec<&str> = foreign.iter().map(|path| path.as_str()).collect();
485 return Err(AppError::Refused(format!(
486 "destinations hold bytes this tool did not write: {}; re-run with --force to overwrite",
487 paths.join(", ")
488 ))
489 .into());
490 }
491
492 let swept: Vec<Utf8PathBuf> = stale
493 .iter()
494 .filter(|(_, _, ours)| *ours)
495 .map(|(destination, _, _)| destination.clone())
496 .collect();
497 let receipt = next_receipt(&record, &vouched, &swept);
498 run_transaction(
499 layout,
500 &writes,
501 &stale
502 .into_iter()
503 .filter(|(_, _, ours)| *ours)
504 .map(|(destination, digest, _)| (destination, digest))
505 .collect::<Vec<_>>(),
506 &receipt,
507 &mut lines,
508 interrupt,
509 )?;
510 Ok(lines)
511}
512
513fn uninstall_with(
515 layout: &Layout,
516 apply: bool,
517 interrupt: Interrupt,
518) -> Result<Vec<String>, Failure> {
519 let _lock = held(layout, apply && layout.receipt.exists(), "skill uninstall")?;
523 let planned = plan(&layout.roots)?;
524 let record = load_receipt(layout);
525 let mut lines: Vec<String> = Vec::new();
526 let mut removals: Vec<(Utf8PathBuf, Sha256)> = Vec::new();
527
528 let refused = blocked(
529 layout,
530 &planned
531 .iter()
532 .map(|entry| entry.destination.clone())
533 .collect::<Vec<_>>(),
534 );
535 for one in &refused {
536 lines.push(format!("conflict: {one}"));
537 }
538
539 for entry in &planned {
540 if refused
541 .iter()
542 .any(|one| entry.destination.starts_with(&one.path))
543 {
544 continue;
545 }
546 if !entry.destination.is_file() {
547 continue;
548 }
549 let found = Sha256::of(&std::fs::read(&entry.destination)?);
550 if record.wrote(&entry.destination, &found) {
551 lines.push(entry.destination.to_string());
552 removals.push((entry.destination.clone(), found));
553 } else if found == entry.digest {
554 lines.push(format!("kept (not this tool's): {}", entry.destination));
557 } else {
558 lines.push(format!("kept (edited): {}", entry.destination));
559 }
560 }
561
562 let keep: BTreeSet<Utf8PathBuf> = removals
563 .iter()
564 .map(|(destination, _)| destination.clone())
565 .collect();
566 for (destination, digest, ours) in leftovers(&layout.scanned(), &record, &keep) {
567 if ours {
568 lines.push(format!("sweep (no longer in the payload): {destination}"));
569 removals.push((destination, digest));
570 } else if destination.exists() {
571 lines.push(format!("kept (edited): {destination}"));
572 }
573 }
574
575 if !apply {
576 lines.push("DRY RUN: no files removed".to_string());
577 return Ok(lines);
578 }
579 if !refused.is_empty() {
580 return Err(refuse_blocked(&refused).into());
581 }
582 if removals.is_empty() && !layout.receipt.exists() {
586 return Ok(lines);
587 }
588
589 let gone: Vec<Utf8PathBuf> = removals
590 .iter()
591 .map(|(destination, _)| destination.clone())
592 .collect();
593 let receipt = next_receipt(&record, &[], &gone);
594 run_transaction(layout, &[], &removals, &receipt, &mut lines, interrupt)?;
595 Ok(lines)
596}
597
598fn refuse_blocked(refused: &[Blocked]) -> AppError {
599 let named: Vec<String> = refused.iter().map(Blocked::to_string).collect();
600 AppError::Refused(format!(
601 "destinations cannot be written through: {}; move them aside and run this again",
602 named.join("; ")
603 ))
604}
605
606fn run_transaction(
614 layout: &Layout,
615 writes: &[Planned],
616 removals: &[(Utf8PathBuf, Sha256)],
617 receipt: &SkillRecord,
618 lines: &mut Vec<String>,
619 interrupt: Interrupt,
620) -> Result<(), Failure> {
621 let empty = receipt.written.is_empty();
625 let receipt_bytes = receipt.to_json().into_bytes();
626 let receipt_current = if empty {
627 !layout.receipt.exists()
628 } else {
629 std::fs::read(&layout.receipt).is_ok_and(|held| held == receipt_bytes)
630 };
631 if writes.is_empty() && removals.is_empty() && receipt_current {
632 return Ok(());
633 }
634
635 let mut passed = 0usize;
636 let mut completed: Vec<Utf8PathBuf> = Vec::new();
637 let mut kept: Vec<Utf8PathBuf> = Vec::new();
641
642 for entry in writes {
643 replace_one(layout, &entry.destination, entry.bytes)
644 .map_err(|failure| stopped(failure, &completed))?;
645 completed.push(entry.destination.clone());
646 reached(&mut passed, interrupt)?;
647 }
648 for (destination, vouched) in removals {
649 if let Some(root) = owning_root(layout, destination)
654 && let Some(one) = blocked_by(root, destination)
655 {
656 return Err(stopped(
657 Failure::Error(refuse_blocked(std::slice::from_ref(&one))),
658 &completed,
659 ));
660 }
661 match std::fs::read(destination) {
666 Ok(held) if &Sha256::of(&held) == vouched => {}
667 _ => {
668 kept.push(destination.clone());
669 continue;
670 }
671 }
672 match std::fs::remove_file(destination) {
673 Ok(()) => crate::transaction::sync_parent(destination)?,
674 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
675 Err(source) => {
676 return Err(stopped(Failure::Error(AppError::Io(source)), &completed));
677 }
678 }
679 lines.push(format!("swept: {destination}"));
680 completed.push(destination.clone());
681 reached(&mut passed, interrupt)?;
682 }
683
684 let mut directories: BTreeSet<Utf8PathBuf> = BTreeSet::new();
685 for (destination, _) in removals {
686 let mut parent = destination.parent();
687 while let Some(directory) = parent {
688 if !layout.roots.iter().any(|root| directory.starts_with(root))
689 && !directory.starts_with(&layout.state_root)
690 && !directory.starts_with(&layout.legacy_shared)
691 {
692 break;
693 }
694 directories.insert(directory.to_owned());
695 parent = directory.parent();
696 }
697 }
698 let mut stop = layout.roots.clone();
699 stop.push(layout.state_root.clone());
700 prune_empty(&directories, &stop, lines);
701
702 let mut receipt = receipt.clone();
706 for destination in &kept {
707 if let Some((_, digest)) = removals.iter().find(|(path, _)| path == destination) {
708 receipt.written.insert(destination.clone(), digest.clone());
709 }
710 lines.push(format!("kept (changed since the record): {destination}"));
711 }
712 let empty = receipt.written.is_empty();
713 let receipt_bytes = receipt.to_json().into_bytes();
714
715 if empty {
718 match std::fs::remove_file(&layout.receipt) {
719 Ok(()) => crate::transaction::sync_parent(&layout.receipt)?,
720 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
721 Err(source) => {
722 return Err(Failure::Error(AppError::Receipt(format!(
723 "{}: {source}",
724 layout.receipt
725 ))));
726 }
727 }
728 } else {
729 replace_one(layout, &layout.receipt, &receipt_bytes).map_err(|failure| match failure {
730 Failure::Error(cause) => Failure::Error(AppError::Receipt(format!(
731 "{}: {cause}; the files above are written and the previous receipt still stands, so run this again",
732 layout.receipt
733 ))),
734 abandoned @ Failure::Abandoned => abandoned,
735 })?;
736 }
737 reached(&mut passed, interrupt)?;
738 Ok(())
739}
740
741fn stopped(failure: Failure, completed: &[Utf8PathBuf]) -> Failure {
743 let Failure::Error(cause) = failure else {
744 return failure;
745 };
746 let done: Vec<String> = completed.iter().map(ToString::to_string).collect();
747 let finished = if done.is_empty() {
748 "no destination was written".to_string()
749 } else {
750 format!("these destinations are written: {}", done.join(", "))
751 };
752 Failure::Error(AppError::Refused(format!(
753 "skill install stopped: {cause}; {finished}, the previous receipt still stands, and running this again finishes the rest"
754 )))
755}
756
757fn replace_one(layout: &Layout, destination: &Utf8Path, bytes: &[u8]) -> Result<(), Failure> {
763 let refuse = || {
764 owning_root(layout, destination)
765 .and_then(|root| blocked_by(root, destination))
766 .map(|one| Failure::Error(refuse_blocked(std::slice::from_ref(&one))))
767 };
768 if let Some(refusal) = refuse() {
771 return Err(refusal);
772 }
773 let scratch = Stage::write(destination, bytes)?;
774 if let Some(refusal) = refuse() {
777 Stage::discard(&scratch);
778 return Err(refusal);
779 }
780 Stage::replace(&scratch, destination)?;
781 Ok(())
782}
783
784#[cfg(test)]
785mod tests {
786 #![allow(
787 clippy::unwrap_used,
788 reason = "a test panics as its failure signal, not as control flow"
789 )]
790
791 use std::collections::BTreeMap;
792
793 use super::*;
794
795 fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
796 Utf8PathBuf::from(dir.path().to_str().unwrap())
797 }
798
799 fn tree(dir: &tempfile::TempDir) -> BTreeMap<String, Sha256> {
806 walkdir::WalkDir::new(dir.path())
807 .into_iter()
808 .filter_map(Result::ok)
809 .filter(|entry| entry.file_type().is_file())
810 .filter_map(|entry| {
811 let path = entry.path().to_str()?.to_string();
812 let bytes = std::fs::read(entry.path()).ok()?;
813 Some((path, Sha256::of(&bytes)))
814 })
815 .filter(|(path, _)| {
816 !path.contains("/backups/")
817 && !["lock", "holder"].iter().any(|suffix| {
818 std::path::Path::new(path)
819 .extension()
820 .is_some_and(|found| found == *suffix)
821 })
822 })
823 .collect()
824 }
825
826 fn home(dir: &tempfile::TempDir) -> Layout {
828 let home = root(dir);
829 let state = home.join(crate::domain::paths::STATE_ROOT);
830 Layout {
831 roots: vec![home.join(AGENTS_ROOT), home.join(CLAUDE_ROOT)],
832 receipt: state.join(crate::domain::paths::SKILL_RECEIPT_FILE),
833 legacy_receipt: home.join(crate::domain::paths::LEGACY_SKILL_RECEIPT_PATH),
834 legacy_shared: home.join(LEGACY_SHARED_ROOT),
835 state_root: state,
836 }
837 }
838
839 fn select(layout: &Layout, index: usize) -> Layout {
841 Layout {
842 roots: vec![layout.roots[index].clone()],
843 ..layout.clone()
844 }
845 }
846
847 fn package_files(root: &Utf8Path, name: &str) -> Vec<Utf8PathBuf> {
848 crate::embedded::skill_package(name)
849 .unwrap()
850 .into_iter()
851 .map(|(relative, _)| root.join(name).join(relative))
852 .collect()
853 }
854
855 #[test]
856 fn a_package_is_skill_md_plus_every_shared_artifact_under_references() {
857 let package = crate::embedded::skill_package("sdd-setup").unwrap();
858 let names: Vec<&str> = package.iter().map(|(path, _)| path.as_str()).collect();
859 assert!(names.contains(&"SKILL.md"));
860 assert!(names.contains(&"references/plan-gate.md"));
861 assert!(names.contains(&"references/pre-flight-gate.md"));
862 assert_eq!(package.len(), 1 + crate::embedded::shared_artifacts().len());
863 assert!(crate::embedded::skill_package("no-such-skill").is_none());
864 }
865
866 #[test]
867 fn an_install_lands_every_package_file_and_records_each_digest() {
868 let dir = tempfile::tempdir().unwrap();
869 let layout = home(&dir);
870 install(&layout, true, false).unwrap();
871 let record = SkillRecord::load(&layout.receipt);
872 assert_eq!(record.schema_version, 2);
873 assert_eq!(record.engine_version, env!("CARGO_PKG_VERSION"));
874 for root in &layout.roots {
875 for name in crate::embedded::skill_names() {
876 for path in package_files(root, name) {
877 assert!(path.is_file(), "{path} did not land");
878 let digest = Sha256::of(&std::fs::read(&path).unwrap());
879 assert!(record.wrote(&path, &digest), "{path} was not recorded");
880 }
881 }
882 }
883 }
884
885 #[test]
886 fn a_preview_lists_every_destination_and_writes_nothing() {
887 let dir = tempfile::tempdir().unwrap();
888 let layout = home(&dir);
889 let lines = install(&layout, false, false).unwrap();
890 assert_eq!(lines.last().unwrap(), "DRY RUN: no files written");
891 let package = crate::embedded::skill_package("sdd-setup").unwrap().len();
892 assert_eq!(
893 lines.len(),
894 crate::embedded::skill_names().len() * package * 2 + 1
895 );
896 assert!(!layout.roots[0].exists());
897 assert!(!layout.receipt.exists());
898 }
899
900 #[test]
901 fn a_second_install_is_idempotent_and_writes_nothing() {
902 let dir = tempfile::tempdir().unwrap();
903 let layout = home(&dir);
904 install(&layout, true, false).unwrap();
905 let before = std::fs::metadata(layout.roots[0].join("sdd-setup/SKILL.md"))
906 .unwrap()
907 .modified()
908 .unwrap();
909 let receipt_before = std::fs::read(&layout.receipt).unwrap();
910 install(&layout, true, false).unwrap();
911 assert_eq!(
912 std::fs::metadata(layout.roots[0].join("sdd-setup/SKILL.md"))
913 .unwrap()
914 .modified()
915 .unwrap(),
916 before
917 );
918 assert_eq!(std::fs::read(&layout.receipt).unwrap(), receipt_before);
919 }
920
921 #[test]
922 fn the_retired_root_is_a_root_the_component_check_knows() {
923 let dir = tempfile::tempdir().unwrap();
924 let mut layout = home(&dir);
925 layout.state_root = root(&dir).join("moved/state");
930 let leftover = layout.legacy_shared.join("plan-gate.md");
931 assert_eq!(
932 owning_root(&layout, &leftover),
933 Some(layout.legacy_shared.as_path())
934 );
935 }
936
937 #[test]
938 fn a_stale_package_file_the_receipt_vouches_for_is_replaced_without_force() {
939 let dir = tempfile::tempdir().unwrap();
940 let layout = home(&dir);
941 install(&layout, true, false).unwrap();
942
943 let mut stale = SkillRecord::load(&layout.receipt);
944 let destination = layout.roots[0].join("sdd-setup/references/plan-gate.md");
945 std::fs::write(&destination, "older canon bytes\n").unwrap();
946 stale
947 .written
948 .insert(destination.clone(), Sha256::of(b"older canon bytes\n"));
949 crate::adapters::fs::write_file(&layout.receipt, stale.to_json().as_bytes()).unwrap();
950
951 install(&layout, true, false).unwrap();
952 assert!(
953 std::fs::read_to_string(&destination)
954 .unwrap()
955 .contains("# The plan gate")
956 );
957 }
958
959 #[test]
960 fn an_edited_reference_refuses_the_install_naming_every_conflict() {
961 let dir = tempfile::tempdir().unwrap();
962 let layout = home(&dir);
963 install(&layout, true, false).unwrap();
964 let edited = layout.roots[1].join("sdd-setup/references/plan-gate.md");
965 std::fs::write(&edited, "mine\n").unwrap();
966 let message = install(&layout, true, false).unwrap_err().to_string();
967 assert!(message.contains(edited.as_str()), "{message}");
968 assert_eq!(std::fs::read_to_string(&edited).unwrap(), "mine\n");
969 }
970
971 #[test]
972 fn force_replaces_an_edited_file_and_records_the_new_digest() {
973 let dir = tempfile::tempdir().unwrap();
974 let layout = home(&dir);
975 install(&layout, true, false).unwrap();
976 let edited = layout.roots[1].join("sdd-setup/SKILL.md");
977 std::fs::write(&edited, "mine\n").unwrap();
978 install(&layout, true, true).unwrap();
979 let held = std::fs::read(&edited).unwrap();
980 assert!(String::from_utf8_lossy(&held).contains("name: sdd-setup"));
981 assert!(SkillRecord::load(&layout.receipt).wrote(&edited, &Sha256::of(&held)));
982 }
983
984 #[test]
987 fn an_edited_skill_md_survives_uninstall_and_is_named_as_kept() {
988 let dir = tempfile::tempdir().unwrap();
989 let layout = home(&dir);
990 install(&layout, true, false).unwrap();
991 let edited = layout.roots[0].join("sdd-setup/SKILL.md");
992 std::fs::write(&edited, "mine\n").unwrap();
993
994 let lines = uninstall(&layout, true).unwrap();
995 assert_eq!(std::fs::read_to_string(&edited).unwrap(), "mine\n");
996 assert!(
997 lines
998 .iter()
999 .any(|line| line == &format!("kept (edited): {edited}")),
1000 "{lines:?}"
1001 );
1002 }
1003
1004 #[test]
1005 fn an_edited_reference_survives_uninstall_and_its_directory_stays() {
1006 let dir = tempfile::tempdir().unwrap();
1007 let layout = home(&dir);
1008 install(&layout, true, false).unwrap();
1009 let edited = layout.roots[0].join("sdd-setup/references/plan-gate.md");
1010 std::fs::write(&edited, "mine\n").unwrap();
1011 uninstall(&layout, true).unwrap();
1012 assert_eq!(std::fs::read_to_string(&edited).unwrap(), "mine\n");
1013 assert!(layout.roots[0].join("sdd-setup/references").is_dir());
1014 }
1015
1016 #[test]
1017 fn an_uninstall_removes_a_directory_only_when_nothing_recorded_or_foreign_remains() {
1018 let dir = tempfile::tempdir().unwrap();
1019 let layout = home(&dir);
1020 install(&layout, true, false).unwrap();
1021 let mine = layout.roots[1].join("sdd-setup/notes.md");
1022 std::fs::write(&mine, "mine").unwrap();
1023
1024 let lines = uninstall(&layout, true).unwrap();
1025 assert!(!layout.roots[1].join("sdd-write-docs").exists());
1026 assert!(!layout.roots[1].join("sdd-setup/SKILL.md").exists());
1027 assert!(!layout.roots[1].join("sdd-setup/references").exists());
1028 assert_eq!(std::fs::read_to_string(&mine).unwrap(), "mine");
1029 assert!(
1030 lines
1031 .iter()
1032 .any(|line| line.starts_with("kept (not empty):"))
1033 );
1034 uninstall(&layout, true).unwrap();
1036 }
1037
1038 #[test]
1039 fn the_retired_shared_root_is_swept_when_the_receipt_vouches_for_it() {
1040 let dir = tempfile::tempdir().unwrap();
1041 let layout = home(&dir);
1042 let mut older = SkillRecord::new();
1045 for (name, bytes) in crate::embedded::shared_artifacts() {
1046 let destination = layout.legacy_shared.join(&name);
1047 crate::adapters::fs::write_file(&destination, bytes).unwrap();
1048 older.written.insert(destination, Sha256::of(bytes));
1049 }
1050 crate::adapters::fs::write_file(&layout.receipt, older.to_json().as_bytes()).unwrap();
1051
1052 install(&layout, true, false).unwrap();
1053 assert!(
1054 !layout.legacy_shared.exists(),
1055 "the retired shared root survived"
1056 );
1057 assert!(
1058 layout.roots[0]
1059 .join("sdd-setup/references/plan-gate.md")
1060 .is_file()
1061 );
1062 }
1063
1064 #[test]
1065 fn an_unrecorded_file_at_the_retired_shared_root_is_kept() {
1066 let dir = tempfile::tempdir().unwrap();
1067 let layout = home(&dir);
1068 let leftover = layout.legacy_shared.join("plan-gate.md");
1069 crate::adapters::fs::write_file(&leftover, b"mine\n").unwrap();
1070 install(&layout, true, false).unwrap();
1071 assert_eq!(std::fs::read(&leftover).unwrap(), b"mine\n");
1072 }
1073
1074 #[test]
1075 fn a_symlinked_package_directory_skill_md_reference_or_parent_is_a_typed_conflict() {
1076 for linked in ["sdd-setup", "sdd-setup/SKILL.md", "sdd-setup/references"] {
1077 let dir = tempfile::tempdir().unwrap();
1078 let layout = home(&dir);
1079 let elsewhere = root(&dir).join("elsewhere");
1080 std::fs::create_dir_all(&elsewhere).unwrap();
1081 let target = layout.roots[0].join(linked);
1082 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
1083 std::os::unix::fs::symlink(&elsewhere, target.as_std_path()).unwrap();
1084
1085 for force in [false, true] {
1086 let message = install(&layout, true, force).unwrap_err().to_string();
1087 assert!(
1088 message.contains("symlink"),
1089 "{linked} with force {force}: {message}"
1090 );
1091 assert!(message.contains(target.as_str()), "{message}");
1092 }
1093 assert!(!elsewhere.join("SKILL.md").exists());
1094 assert!(target.is_symlink(), "the link itself was replaced");
1095 }
1096 }
1097
1098 #[test]
1099 fn a_symlink_whose_target_matches_the_recorded_digest_is_still_a_conflict() {
1100 let dir = tempfile::tempdir().unwrap();
1101 let layout = home(&dir);
1102 install(&layout, true, false).unwrap();
1103 let destination = layout.roots[0].join("sdd-setup/SKILL.md");
1104 let elsewhere = root(&dir).join("copy.md");
1105 std::fs::copy(&destination, &elsewhere).unwrap();
1106 std::fs::remove_file(&destination).unwrap();
1107 std::os::unix::fs::symlink(&elsewhere, destination.as_std_path()).unwrap();
1108
1109 let message = install(&layout, true, true).unwrap_err().to_string();
1110 assert!(message.contains("symlink"), "{message}");
1111 let message = uninstall(&layout, true).unwrap_err().to_string();
1112 assert!(message.contains("symlink"), "{message}");
1113 assert!(destination.is_symlink());
1114 }
1115
1116 #[test]
1117 fn two_roots_resolving_to_one_path_are_written_once() {
1118 let dir = tempfile::tempdir().unwrap();
1119 let mut layout = home(&dir);
1120 layout.roots = vec![layout.roots[0].clone()];
1121 let lines = install(&layout, false, false).unwrap();
1122 let landed = lines
1123 .iter()
1124 .filter(|line| line.ends_with("sdd-setup/SKILL.md"))
1125 .count();
1126 assert_eq!(landed, 1);
1127 }
1128
1129 #[test]
1130 fn a_second_installer_refuses_while_the_lock_is_held_naming_the_holder() {
1131 let dir = tempfile::tempdir().unwrap();
1132 let layout = home(&dir);
1133 install(&layout, true, false).unwrap();
1136 age(&layout);
1137 let _held = Lock::exclusive(&layout.lock_path(), "skill install").unwrap();
1138 let error = install(&layout, true, false).unwrap_err();
1139 assert_eq!(error.kind(), "Busy");
1140 assert_eq!(error.exit_code(), 73);
1141 assert!(error.to_string().contains("skill install"));
1142 let error = uninstall(&layout, true).unwrap_err();
1143 assert_eq!(error.kind(), "Busy");
1144 }
1145
1146 fn age(layout: &Layout) {
1152 let mut older = SkillRecord::new();
1153 for root in &layout.roots {
1154 for name in crate::embedded::skill_names() {
1155 for path in package_files(root, name) {
1156 crate::adapters::fs::write_file(&path, b"older\n").unwrap();
1157 older.written.insert(path, Sha256::of(b"older\n"));
1158 }
1159 }
1160 }
1161 crate::adapters::fs::write_file(&layout.receipt, older.to_json().as_bytes()).unwrap();
1162 }
1163
1164 #[test]
1168 fn an_interrupted_install_leaves_whole_files_and_a_rerun_finishes() {
1169 let mut boundaries = 0usize;
1170 for after in 1..200 {
1171 let dir = tempfile::tempdir().unwrap();
1172 let layout = select(&home(&dir), 0);
1176 age(&layout);
1177 let aged = std::fs::read(&layout.receipt).unwrap();
1178
1179 let outcome = install_with(&layout, true, false, Interrupt { after: Some(after) });
1180 let Err(Failure::Abandoned) = outcome else {
1181 break;
1184 };
1185 boundaries = after;
1186
1187 for name in crate::embedded::skill_names() {
1191 for path in package_files(&layout.roots[0], name) {
1192 let held = std::fs::read(&path).unwrap();
1193 assert!(
1194 held == b"older\n" || !held.is_empty(),
1195 "after {after}: {path} is not a whole file"
1196 );
1197 }
1198 }
1199 let receipt = std::fs::read(&layout.receipt).unwrap();
1202 assert!(
1203 receipt == aged || !SkillRecord::load(&layout.receipt).written.is_empty(),
1204 "after {after}: the receipt is neither the old one nor a new one"
1205 );
1206
1207 if after % 3 == 0 {
1211 install(&layout, true, false).unwrap();
1212 let record = SkillRecord::load(&layout.receipt);
1213 for path in package_files(&layout.roots[0], "sdd-setup") {
1214 let digest = Sha256::of(&std::fs::read(&path).unwrap());
1215 assert!(record.wrote(&path, &digest), "after {after}: {path}");
1216 }
1217 }
1218 }
1219 assert!(boundaries > 5, "only {boundaries} boundaries were walked");
1220 }
1221
1222 #[test]
1223 fn a_second_interruption_still_leaves_a_home_a_rerun_finishes() {
1224 let dir = tempfile::tempdir().unwrap();
1225 let layout = home(&dir);
1226 age(&layout);
1227 for after in [3, 5] {
1228 let Err(Failure::Abandoned) =
1229 install_with(&layout, true, false, Interrupt { after: Some(after) })
1230 else {
1231 panic!("the run was not interrupted after {after}");
1232 };
1233 }
1234 install(&layout, true, false).unwrap();
1235 let record = SkillRecord::load(&layout.receipt);
1236 for path in package_files(&layout.roots[0], "sdd-setup") {
1237 let digest = Sha256::of(&std::fs::read(&path).unwrap());
1238 assert!(record.wrote(&path, &digest), "{path}");
1239 }
1240 }
1241
1242 #[test]
1243 fn a_receipt_write_failure_reports_what_the_run_wrote() {
1244 let dir = tempfile::tempdir().unwrap();
1245 let mut layout = home(&dir);
1248 let vault = layout.state_root.join("receipt");
1249 layout.receipt = vault.join("skills.json");
1250 age(&layout);
1251 let aged = std::fs::read(&layout.receipt).unwrap();
1252
1253 let mut permissions = std::fs::metadata(&vault).unwrap().permissions();
1254 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o500);
1255 std::fs::set_permissions(&vault, permissions.clone()).unwrap();
1256
1257 let error = install(&layout, true, false).unwrap_err();
1258
1259 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
1260 std::fs::set_permissions(&vault, permissions).unwrap();
1261
1262 let message = error.to_string();
1263 assert!(message.contains(layout.receipt.as_str()), "{message}");
1264 assert!(message.contains("run this again"), "{message}");
1265
1266 assert_eq!(std::fs::read(&layout.receipt).unwrap(), aged);
1269 assert_ne!(
1270 std::fs::read(layout.roots[0].join("sdd-setup/SKILL.md")).unwrap(),
1271 b"older\n"
1272 );
1273
1274 install(&layout, true, false).unwrap();
1276 let record = SkillRecord::load(&layout.receipt);
1277 for path in package_files(&layout.roots[0], "sdd-setup") {
1278 let digest = Sha256::of(&std::fs::read(&path).unwrap());
1279 assert!(record.wrote(&path, &digest), "{path}");
1280 }
1281 }
1282
1283 #[test]
1284 fn a_schema_one_receipt_reads_through_the_adapter() {
1285 let dir = tempfile::tempdir().unwrap();
1286 let layout = home(&dir);
1287 let destination = layout.roots[0].join("sdd-setup/SKILL.md");
1288 crate::adapters::fs::write_file(&destination, b"older\n").unwrap();
1289 crate::adapters::fs::write_file(
1290 &layout.receipt,
1291 format!(
1292 "{{\"schema_version\":1,\"written\":{{\"{destination}\":\"{}\"}}}}",
1293 Sha256::of(b"older\n")
1294 )
1295 .as_bytes(),
1296 )
1297 .unwrap();
1298 install(&layout, true, false).unwrap();
1301 assert!(
1302 String::from_utf8_lossy(&std::fs::read(&destination).unwrap())
1303 .contains("name: sdd-setup")
1304 );
1305 assert_eq!(SkillRecord::load(&layout.receipt).schema_version, 2);
1306 }
1307
1308 #[test]
1309 fn a_receipt_at_the_legacy_path_is_read_once_and_rewritten_at_the_resolved_one() {
1310 let dir = tempfile::tempdir().unwrap();
1311 let mut layout = home(&dir);
1312 layout.state_root = root(&dir).join("xdg/spec-driven-docs");
1313 layout.receipt = layout
1314 .state_root
1315 .join(crate::domain::paths::SKILL_RECEIPT_FILE);
1316
1317 let destination = layout.roots[0].join("sdd-setup/SKILL.md");
1318 crate::adapters::fs::write_file(&destination, b"older\n").unwrap();
1319 let mut legacy = SkillRecord::new();
1320 legacy
1321 .written
1322 .insert(destination.clone(), Sha256::of(b"older\n"));
1323 crate::adapters::fs::write_file(&layout.legacy_receipt, legacy.to_json().as_bytes())
1324 .unwrap();
1325
1326 install(&layout, true, false).unwrap();
1327 assert!(layout.receipt.is_file());
1328 assert!(
1329 String::from_utf8_lossy(&std::fs::read(&destination).unwrap())
1330 .contains("name: sdd-setup")
1331 );
1332 }
1333
1334 #[test]
1335 fn a_write_that_fails_partway_reports_what_it_finished() {
1336 let dir = tempfile::tempdir().unwrap();
1337 let layout = home(&dir);
1338 install(&layout, true, false).unwrap();
1339
1340 for root in &layout.roots {
1344 for name in crate::embedded::skill_names() {
1345 for path in package_files(root, name) {
1346 std::fs::write(&path, format!("previous {name}\n")).unwrap();
1347 }
1348 }
1349 }
1350 let blocked = layout.roots[1].join("sdd-write-docs");
1351 let mut permissions = std::fs::metadata(&blocked).unwrap().permissions();
1352 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o500);
1353 std::fs::set_permissions(&blocked, permissions.clone()).unwrap();
1354
1355 let message = install(&layout, true, true).unwrap_err().to_string();
1356
1357 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
1358 std::fs::set_permissions(&blocked, permissions).unwrap();
1359
1360 assert!(message.contains("skill install stopped"), "{message}");
1361 assert!(
1362 message.contains("these destinations are written"),
1363 "{message}"
1364 );
1365 assert!(message.contains("running this again"), "{message}");
1366 assert_ne!(
1369 std::fs::read(layout.roots[0].join("sdd-setup/SKILL.md")).unwrap(),
1370 b"previous sdd-setup\n"
1371 );
1372 assert_eq!(
1373 std::fs::read(layout.roots[1].join("sdd-write-docs/SKILL.md")).unwrap(),
1374 b"previous sdd-write-docs\n"
1375 );
1376
1377 install(&layout, true, true).unwrap();
1378 let record = SkillRecord::load(&layout.receipt);
1379 for root in &layout.roots {
1380 for path in package_files(root, "sdd-setup") {
1381 let digest = Sha256::of(&std::fs::read(&path).unwrap());
1382 assert!(record.wrote(&path, &digest), "{path}");
1383 }
1384 }
1385 }
1386
1387 #[test]
1388 fn an_uninstall_of_one_root_keeps_the_others_entries() {
1389 let dir = tempfile::tempdir().unwrap();
1390 let layout = home(&dir);
1391 install(&layout, true, false).unwrap();
1392 uninstall(&select(&layout, 1), true).unwrap();
1393 let kept = SkillRecord::load(&layout.receipt);
1394 assert!(
1395 kept.written
1396 .keys()
1397 .all(|path| path.starts_with(&layout.roots[0]))
1398 );
1399 assert!(!kept.written.is_empty());
1400 assert!(
1401 layout.roots[0]
1402 .join("sdd-setup/references/plan-gate.md")
1403 .is_file()
1404 );
1405 }
1406
1407 #[test]
1408 fn either_agent_alone_lands_a_whole_package() {
1409 for index in 0..2 {
1410 let dir = tempfile::tempdir().unwrap();
1411 let layout = home(&dir);
1412 let narrowed = select(&layout, index);
1413 install(&narrowed, true, false).unwrap();
1414 for path in package_files(&layout.roots[index], "sdd-setup") {
1415 assert!(path.is_file(), "{path} did not land");
1416 }
1417 assert!(!layout.roots[1 - index].exists());
1418 }
1419 }
1420
1421 #[test]
1422 fn the_last_uninstall_takes_the_receipt_with_it() {
1423 let dir = tempfile::tempdir().unwrap();
1424 let layout = home(&dir);
1425 install(&layout, true, false).unwrap();
1426 uninstall(&layout, true).unwrap();
1427 assert!(
1428 !layout.receipt.exists(),
1429 "the receipt outlived every file it vouched for"
1430 );
1431 for root in &layout.roots {
1432 assert!(!root.join("sdd-setup").exists());
1433 }
1434 }
1435
1436 #[test]
1437 fn an_uninstall_on_a_home_this_tool_never_wrote_into_touches_nothing() {
1438 let dir = tempfile::tempdir().unwrap();
1439 let layout = home(&dir);
1440 let before = tree(&dir);
1441 uninstall(&layout, true).unwrap();
1442 assert_eq!(tree(&dir), before);
1443 assert!(!layout.state_root.exists());
1444 }
1445}