1#[cfg(test)]
17use std::collections::BTreeMap;
18use std::collections::BTreeSet;
19use std::fs;
20
21use camino::{Utf8Path, Utf8PathBuf};
22use serde::Serialize;
23
24use crate::atomic;
25use crate::error::RkError;
26use crate::skills::record::Record;
27use crate::skills::{Digest, Package};
28
29#[derive(Debug, Serialize, PartialEq, Eq)]
31#[serde(tag = "action", rename_all = "kebab-case")]
32pub enum Action {
33 Write {
35 destination: Utf8PathBuf,
37 },
38 Unchanged {
40 destination: Utf8PathBuf,
42 },
43 Sweep {
45 destination: Utf8PathBuf,
47 },
48 SweepFailed {
50 destination: Utf8PathBuf,
52 error: String,
54 },
55 Remove {
57 destination: Utf8PathBuf,
59 },
60 KeptEdited {
64 destination: Utf8PathBuf,
66 },
67 KeptDirectory {
69 directory: Utf8PathBuf,
71 },
72 RecordUnwritten {
75 record: Utf8PathBuf,
77 },
78}
79
80struct Planned {
82 destination: Utf8PathBuf,
84 bytes: &'static [u8],
86}
87
88#[derive(Debug, Clone)]
97pub struct Layout {
98 pub roots: Vec<Utf8PathBuf>,
100 pub legacy_shared: Option<Utf8PathBuf>,
103 pub record: Utf8PathBuf,
105}
106
107fn plan_roots(roots: &[Utf8PathBuf], packages: &[Package]) -> Vec<Planned> {
110 let mut planned = Vec::new();
111 for root in roots {
112 for package in packages {
113 for file in &package.files {
114 planned.push(Planned {
115 destination: root.join(&package.name).join(&file.path),
116 bytes: file.bytes,
117 });
118 }
119 }
120 }
121 planned
122}
123
124fn check_legacy_root(legacy: Option<&Utf8Path>, record: &Utf8Path) -> Result<(), RkError> {
134 let (Some(legacy), Some(state_dir)) = (legacy, record.parent()) else {
135 return Ok(());
136 };
137 let mut current = Some(legacy);
138 while let Some(dir) = current {
139 if !dir.starts_with(state_dir) {
140 break;
141 }
142 if dir.is_symlink() {
143 return Err(RkError::Refused(format!(
144 "the shared root is reached through a symlink, and nothing was written: {dir}"
145 )));
146 }
147 current = dir.parent();
148 }
149 Ok(())
150}
151
152fn legacy_kept(legacy: Option<&Utf8Path>, swept: &[Utf8PathBuf]) -> Vec<Utf8PathBuf> {
158 let Some(legacy) = legacy else {
159 return Vec::new();
160 };
161 crate::skills::shared()
162 .into_iter()
163 .map(|artifact| legacy.join(&artifact.path))
164 .filter(|path| path.is_file() && !path.is_symlink() && !swept.contains(path))
165 .collect()
166}
167
168fn check_destination(destination: &Utf8Path) -> Result<(), RkError> {
173 if destination.is_symlink() {
174 return Err(RkError::Refused(format!(
175 "destination is a symlink, and nothing was written: {destination}"
176 )));
177 }
178 if destination.exists() && !destination.is_file() {
179 return Err(RkError::Refused(format!(
180 "destination is not a regular file, and nothing was written: {destination}"
181 )));
182 }
183 Ok(())
184}
185
186#[cfg(test)]
193fn conflicts(planned: &[Planned], record: &Record) -> Result<Vec<String>, RkError> {
194 let mut conflicts = Vec::new();
195 for entry in planned {
196 if !entry.destination.is_file() {
197 continue;
198 }
199 let found = fs::read(&entry.destination)?;
202 if found == entry.bytes || record.wrote(&entry.destination, &Digest::of(&found)) {
203 continue;
204 }
205 conflicts.push(entry.destination.to_string());
206 }
207 Ok(conflicts)
208}
209
210fn leftovers(roots: &[Utf8PathBuf], record: &Record, keep: &[Utf8PathBuf]) -> Vec<Utf8PathBuf> {
218 let kept: BTreeSet<&Utf8Path> = keep.iter().map(Utf8PathBuf::as_path).collect();
219 record
220 .written
221 .iter()
222 .filter(|(destination, digest)| {
223 !kept.contains(destination.as_path())
224 && roots.iter().any(|root| destination.starts_with(root))
225 && !destination.is_symlink()
226 && destination.is_file()
227 && fs::read(destination).is_ok_and(|found| Digest::of(&found) == **digest)
228 })
229 .map(|(destination, _)| destination.clone())
230 .collect()
231}
232
233fn write_file(path: &Utf8Path, bytes: &[u8]) -> std::io::Result<()> {
236 atomic::write(path.as_std_path(), bytes)
237}
238
239fn remove_installed(
247 destinations: &[Utf8PathBuf],
248 stops: &[Utf8PathBuf],
249) -> Result<Vec<Utf8PathBuf>, (Utf8PathBuf, std::io::Error)> {
250 let mut parents: BTreeSet<Utf8PathBuf> = BTreeSet::new();
251 for destination in destinations {
252 fs::remove_file(destination).map_err(|source| (destination.clone(), source))?;
253 if let Some(parent) = destination.parent() {
254 parents.insert(parent.to_owned());
255 }
256 }
257 let mut candidates: Vec<Utf8PathBuf> = Vec::new();
260 for parent in &parents {
261 let mut current = Some(parent.as_path());
262 while let Some(dir) = current {
263 if stops.iter().any(|stop| stop == dir)
264 || !stops.iter().any(|stop| dir.starts_with(stop))
265 {
266 break;
267 }
268 if !candidates.iter().any(|seen| seen == dir) {
269 candidates.push(dir.to_owned());
270 }
271 current = dir.parent();
272 }
273 }
274 candidates.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
275 for dir in &candidates {
276 if fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
277 let _ = fs::remove_dir(dir);
278 }
279 }
280 Ok(parents.into_iter().filter(|dir| dir.is_dir()).collect())
281}
282
283#[cfg(test)]
291fn rollback(backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>) -> Vec<Utf8PathBuf> {
292 let mut unrestored = Vec::new();
293 for (destination, previous) in backups {
294 let restored = previous.as_ref().map_or_else(
295 || !destination.exists() || fs::remove_file(destination).is_ok(),
296 |bytes| {
297 fs::read(destination).is_ok_and(|found| &found == bytes)
298 || write_file(destination, bytes).is_ok()
299 },
300 );
301 if !restored {
302 unrestored.push(destination.clone());
303 }
304 }
305 unrestored
306}
307
308#[cfg(test)]
310fn abort(unrestored: &[Utf8PathBuf], cause: &str) -> RkError {
311 if unrestored.is_empty() {
312 return RkError::Refused(format!(
313 "the install was aborted and the destinations were restored: {cause}"
314 ));
315 }
316 let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
317 RkError::Refused(format!(
318 "the install was aborted and restoration is incomplete; verify these by hand: {}: {cause}",
319 paths.join(", ")
320 ))
321}
322
323#[cfg(test)]
347pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<Action>, RkError> {
348 check_legacy_root(layout.legacy_shared.as_deref(), &layout.record)?;
349 let record_path = layout.record.as_path();
350 let packages = crate::skills::packages()?;
351 let planned = plan_roots(&layout.roots, &packages);
352 for entry in &planned {
353 check_destination(&entry.destination)?;
354 }
355
356 let mut record = Record::load(record_path);
357 let covered: Vec<Utf8PathBuf> = planned
358 .iter()
359 .map(|entry| entry.destination.clone())
360 .collect();
361 let mut scanned = layout.roots.clone();
362 scanned.extend(layout.legacy_shared.clone());
363 let stale = leftovers(&scanned, &record, &covered);
364 let kept = legacy_kept(layout.legacy_shared.as_deref(), &stale);
365
366 if !apply {
367 let mut actions: Vec<Action> = covered
368 .into_iter()
369 .map(|destination| Action::Write { destination })
370 .collect();
371 actions.extend(
372 stale
373 .into_iter()
374 .map(|destination| Action::Sweep { destination }),
375 );
376 actions.extend(
377 kept.into_iter()
378 .map(|destination| Action::KeptEdited { destination }),
379 );
380 return Ok(actions);
381 }
382
383 if !force {
384 let conflicts = conflicts(&planned, &record)?;
385 if !conflicts.is_empty() {
386 return Err(RkError::Refused(format!(
387 "these destinations hold bytes this tool did not write, and nothing was written: {}; re-run with --force to overwrite",
388 conflicts.join(", ")
389 )));
390 }
391 }
392
393 let backups = back_up(&planned)?;
396
397 let mut actions = Vec::new();
398 for entry in &planned {
399 let held = backups.get(&entry.destination).and_then(Option::as_ref);
400 if held.is_some_and(|previous| previous == entry.bytes) {
401 actions.push(Action::Unchanged {
402 destination: entry.destination.clone(),
403 });
404 continue;
405 }
406 if let Err(source) = write_file(&entry.destination, entry.bytes) {
407 return Err(abort(
408 &rollback(&backups),
409 &format!("writing {} failed: {source}", entry.destination),
410 ));
411 }
412 actions.push(Action::Write {
413 destination: entry.destination.clone(),
414 });
415 }
416
417 let stops = sweep_stops(layout);
421 for destination in &stale {
422 match remove_installed(std::slice::from_ref(destination), &stops) {
423 Ok(kept_dirs) => {
424 actions.push(Action::Sweep {
425 destination: destination.clone(),
426 });
427 actions.extend(
428 kept_dirs
429 .into_iter()
430 .map(|directory| Action::KeptDirectory { directory }),
431 );
432 record.written.remove(destination);
433 }
434 Err((_, source)) => actions.push(Action::SweepFailed {
435 destination: destination.clone(),
436 error: source.to_string(),
437 }),
438 }
439 }
440 actions.extend(
441 kept.into_iter()
442 .map(|destination| Action::KeptEdited { destination }),
443 );
444
445 for entry in &planned {
446 record
447 .written
448 .insert(entry.destination.clone(), Digest::of(entry.bytes));
449 }
450 if write_file(record_path, record.to_text().as_bytes()).is_err() {
451 actions.push(Action::RecordUnwritten {
452 record: record_path.to_owned(),
453 });
454 }
455 Ok(actions)
456}
457
458#[cfg(test)]
461fn back_up(planned: &[Planned]) -> Result<BTreeMap<Utf8PathBuf, Option<Vec<u8>>>, RkError> {
462 let mut backups = BTreeMap::new();
463 for entry in planned {
464 let previous = if entry.destination.is_file() {
465 Some(fs::read(&entry.destination).map_err(|source| {
466 RkError::Refused(format!(
467 "cannot back up {}, and nothing was written: {source}",
468 entry.destination
469 ))
470 })?)
471 } else {
472 None
473 };
474 backups.insert(entry.destination.clone(), previous);
475 }
476 Ok(backups)
477}
478
479fn sweep_stops(layout: &Layout) -> Vec<Utf8PathBuf> {
482 let mut stops = layout.roots.clone();
483 if let Some(state_dir) = layout.record.parent() {
484 stops.push(state_dir.to_owned());
485 }
486 stops
487}
488
489pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<Action>, RkError> {
508 check_legacy_root(layout.legacy_shared.as_deref(), &layout.record)?;
509 let record_path = layout.record.as_path();
510 let packages = crate::skills::packages()?;
511 let record_found = Record::load(record_path);
512 let mut removable: Vec<Utf8PathBuf> = Vec::new();
513 let mut edited: Vec<Utf8PathBuf> = Vec::new();
514 for entry in &plan_roots(&layout.roots, &packages) {
515 check_destination(&entry.destination)?;
516 if !entry.destination.is_file() {
517 continue;
518 }
519 let found = fs::read(&entry.destination)?;
524 if found == entry.bytes || record_found.wrote(&entry.destination, &Digest::of(&found)) {
525 removable.push(entry.destination.clone());
526 } else {
527 edited.push(entry.destination.clone());
528 }
529 }
530
531 let mut record = record_found;
532 let mut scanned = layout.roots.clone();
533 scanned.extend(layout.legacy_shared.clone());
534 let stale = leftovers(&scanned, &record, &removable);
538 edited.extend(legacy_kept(layout.legacy_shared.as_deref(), &stale));
539
540 if !apply {
541 let mut actions: Vec<Action> = removable
542 .into_iter()
543 .map(|destination| Action::Remove { destination })
544 .collect();
545 actions.extend(
546 stale
547 .into_iter()
548 .map(|destination| Action::Sweep { destination }),
549 );
550 actions.extend(
551 edited
552 .into_iter()
553 .map(|destination| Action::KeptEdited { destination }),
554 );
555 return Ok(actions);
556 }
557
558 removable.extend(stale);
559 let kept_dirs = remove_installed(&removable, &sweep_stops(layout))
560 .map_err(|(_, source)| RkError::Io(source))?;
561 let mut actions: Vec<Action> = Vec::new();
562 for destination in &removable {
563 actions.push(Action::Remove {
564 destination: destination.clone(),
565 });
566 record.written.remove(destination);
567 }
568 actions.extend(
569 kept_dirs
570 .into_iter()
571 .map(|directory| Action::KeptDirectory { directory }),
572 );
573 actions.extend(
574 edited
575 .into_iter()
576 .map(|destination| Action::KeptEdited { destination }),
577 );
578
579 let recorded = if record.written.is_empty() {
580 fs::remove_file(record_path).or_else(|source| {
581 if source.kind() == std::io::ErrorKind::NotFound {
582 Ok(())
583 } else {
584 Err(source)
585 }
586 })
587 } else {
588 write_file(record_path, record.to_text().as_bytes())
589 };
590 if recorded.is_err() {
591 actions.push(Action::RecordUnwritten {
592 record: record_path.to_owned(),
593 });
594 }
595 Ok(actions)
596}
597
598#[cfg(test)]
599mod tests {
600 use camino::Utf8PathBuf;
601
602 use super::{Action, Layout, install, leftovers, uninstall};
603 use crate::skills::record::{RECORD_PATH, Record};
604 use crate::skills::{Digest, all, packages};
605
606 struct Home {
608 dir: tempfile::TempDir,
609 }
610
611 impl Home {
612 fn new() -> Self {
613 Self {
614 dir: tempfile::tempdir().expect("a scratch home exists"),
615 }
616 }
617
618 fn path(&self) -> Utf8PathBuf {
619 Utf8PathBuf::from_path_buf(self.dir.path().to_path_buf())
620 .expect("the temp path is UTF-8")
621 }
622
623 fn roots(&self) -> Vec<Utf8PathBuf> {
624 let home = self.path();
625 vec![home.join(".claude/skills"), home.join(".agents/skills")]
626 }
627
628 fn record(&self) -> Utf8PathBuf {
629 self.path().join(RECORD_PATH)
630 }
631
632 fn destination(&self, root: &str, skill: &str) -> Utf8PathBuf {
633 self.path().join(root).join(skill).join("SKILL.md")
634 }
635
636 fn legacy_shared(&self) -> Utf8PathBuf {
637 self.path().join(".local/state/release-kit/skills/shared")
638 }
639
640 fn gate(&self, root: &str, skill: &str) -> Utf8PathBuf {
642 self.path()
643 .join(root)
644 .join(skill)
645 .join("references/plan-gate.md")
646 }
647
648 fn layout(&self) -> Layout {
650 self.layout_for(self.roots())
651 }
652
653 fn layout_for(&self, roots: Vec<Utf8PathBuf>) -> Layout {
656 let every = roots == self.roots();
657 Layout {
658 roots,
659 legacy_shared: every.then(|| self.legacy_shared()),
660 record: self.record(),
661 }
662 }
663 }
664
665 fn package_files() -> usize {
667 packages()
668 .expect("the packages compose")
669 .iter()
670 .map(|package| package.files.len())
671 .sum()
672 }
673
674 fn first_skill() -> String {
675 all().expect("the skills read").swap_remove(0).name
676 }
677
678 #[test]
679 fn a_preview_lists_every_destination_and_writes_nothing() {
680 let home = Home::new();
681 let actions = install(&home.layout(), false, false).unwrap();
682 assert_eq!(actions.len(), package_files() * 2, "{actions:?}");
683 assert!(
684 actions
685 .iter()
686 .all(|action| matches!(action, Action::Write { .. })),
687 "{actions:?}"
688 );
689 assert!(!home.path().join(".claude").exists());
690 assert!(!home.record().exists());
691 }
692
693 #[test]
694 fn an_apply_is_idempotent_and_records_what_it_wrote() {
695 let home = Home::new();
696 let first = install(&home.layout(), true, false).unwrap();
697 assert!(
698 first
699 .iter()
700 .all(|action| matches!(action, Action::Write { .. })),
701 "{first:?}"
702 );
703 let second = install(&home.layout(), true, false).unwrap();
704 assert!(
705 second
706 .iter()
707 .all(|action| matches!(action, Action::Unchanged { .. })),
708 "{second:?}"
709 );
710 let record = Record::load(&home.record());
711 assert_eq!(record.written.len(), package_files() * 2);
712 }
713
714 #[test]
718 fn a_copy_a_previous_release_wrote_is_replaced_without_force() {
719 let home = Home::new();
720 install(&home.layout(), true, false).unwrap();
721
722 let mut stale = Record::default();
725 for destination in Record::load(&home.record()).written.into_keys() {
726 std::fs::write(&destination, "older canon bytes\n").unwrap();
727 stale
728 .written
729 .insert(destination, Digest::of(b"older canon bytes\n"));
730 }
731 std::fs::write(home.record(), stale.to_text()).unwrap();
732
733 install(&home.layout(), true, false).unwrap();
734 let text =
735 std::fs::read_to_string(home.destination(".claude/skills", &first_skill())).unwrap();
736 assert!(text.contains(&format!("name: {}", first_skill())));
737 }
738
739 #[test]
741 fn an_edit_refuses_and_names_every_conflict() {
742 let home = Home::new();
743 install(&home.layout(), true, false).unwrap();
744 let edited: Vec<Utf8PathBuf> = all()
745 .unwrap()
746 .iter()
747 .map(|skill| home.destination(".claude/skills", &skill.name))
748 .collect();
749 for destination in &edited {
750 std::fs::write(destination, "the user wrote this").unwrap();
751 }
752
753 let message = install(&home.layout(), true, false)
754 .unwrap_err()
755 .to_string();
756 for destination in &edited {
757 assert!(message.contains(destination.as_str()), "{message}");
758 }
759 for destination in &edited {
760 assert_eq!(
761 std::fs::read_to_string(destination).unwrap(),
762 "the user wrote this",
763 "a refused install must not overwrite"
764 );
765 }
766 install(&home.layout(), true, true).unwrap();
767 assert!(
768 std::fs::read_to_string(&edited[0])
769 .unwrap()
770 .starts_with("---")
771 );
772 }
773
774 #[cfg(unix)]
775 #[test]
776 fn a_symlinked_destination_refuses_before_anything_is_written() {
777 let home = Home::new();
778 let skill = first_skill();
779 let destination = home.destination(".claude/skills", &skill);
780 std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
781 let elsewhere = home.path().join("elsewhere");
782 std::fs::write(&elsewhere, "the user's file\n").unwrap();
783 std::os::unix::fs::symlink(&elsewhere, &destination).unwrap();
784
785 let message = install(&home.layout(), true, true).unwrap_err().to_string();
786 assert!(message.contains("symlink"), "{message}");
787 assert_eq!(
788 std::fs::read_to_string(&elsewhere).unwrap(),
789 "the user's file\n"
790 );
791 assert!(!home.path().join(".agents").exists());
792 }
793
794 #[test]
796 fn a_failed_write_restores_every_destination() {
797 let home = Home::new();
798 install(&home.layout(), true, false).unwrap();
799 let first = home.destination(".claude/skills", &first_skill());
800 std::fs::write(&first, "older canon bytes\n").unwrap();
801 let mut record = Record::load(&home.record());
802 record
803 .written
804 .insert(first.clone(), Digest::of(b"older canon bytes\n"));
805 std::fs::write(home.record(), record.to_text()).unwrap();
806
807 let blocked = home.path().join(".agents/skills").join(first_skill());
810 std::fs::remove_dir_all(&blocked).unwrap();
811 std::fs::write(&blocked, "in the way\n").unwrap();
812
813 let message = install(&home.layout(), true, false)
814 .unwrap_err()
815 .to_string();
816 assert!(message.contains("aborted"), "{message}");
817 assert_eq!(
818 std::fs::read_to_string(&first).unwrap(),
819 "older canon bytes\n",
820 "the first root must be restored"
821 );
822 }
823
824 #[test]
825 fn an_install_sweeps_a_destination_the_binary_dropped() {
826 let home = Home::new();
827 install(&home.layout(), true, false).unwrap();
828 let dropped = home.destination(".claude/skills", "rk-retired");
829 std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
830 std::fs::write(&dropped, "a skill a later release dropped\n").unwrap();
831 let mut record = Record::load(&home.record());
832 record.written.insert(
833 dropped.clone(),
834 Digest::of(b"a skill a later release dropped\n"),
835 );
836 std::fs::write(home.record(), record.to_text()).unwrap();
837
838 let actions = install(&home.layout(), true, false).unwrap();
839 assert!(
840 actions.contains(&Action::Sweep {
841 destination: dropped.clone()
842 }),
843 "{actions:?}"
844 );
845 assert!(!dropped.exists());
846 assert!(!dropped.parent().unwrap().exists());
847 assert!(!Record::load(&home.record()).written.contains_key(&dropped));
848 }
849
850 #[test]
852 fn a_sweep_leaves_an_edited_leftover_alone() {
853 let home = Home::new();
854 install(&home.layout(), true, false).unwrap();
855 let dropped = home.destination(".claude/skills", "rk-retired");
856 std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
857 std::fs::write(&dropped, "the user rewrote this\n").unwrap();
858 let mut record = Record::load(&home.record());
859 record
860 .written
861 .insert(dropped.clone(), Digest::of(b"what we wrote\n"));
862 std::fs::write(home.record(), record.to_text()).unwrap();
863
864 assert!(
865 !leftovers(&home.roots(), &record, &[]).contains(&dropped),
866 "a leftover whose bytes differ from the record is the user's"
867 );
868 install(&home.layout(), true, false).unwrap();
869 assert_eq!(
870 std::fs::read_to_string(&dropped).unwrap(),
871 "the user rewrote this\n"
872 );
873 }
874
875 #[test]
879 fn an_uninstall_keeps_an_edited_destination() {
880 let home = Home::new();
881 install(&home.layout(), true, false).unwrap();
882 let edited = home.destination(".claude/skills", &first_skill());
883 std::fs::write(&edited, "the user rewrote this\n").unwrap();
884
885 let preview = uninstall(&home.layout(), false).unwrap();
886 assert!(
887 preview.contains(&Action::KeptEdited {
888 destination: edited.clone()
889 }),
890 "{preview:?}"
891 );
892 assert!(
893 !preview.contains(&Action::Remove {
894 destination: edited.clone()
895 }),
896 "{preview:?}"
897 );
898
899 let actions = uninstall(&home.layout(), true).unwrap();
900 assert!(
901 actions.contains(&Action::KeptEdited {
902 destination: edited.clone()
903 }),
904 "{actions:?}"
905 );
906 assert_eq!(
907 std::fs::read_to_string(&edited).unwrap(),
908 "the user rewrote this\n",
909 "an uninstall must never delete a user's edit"
910 );
911 assert!(!home.destination(".agents/skills", &first_skill()).exists());
913 }
914
915 #[test]
916 fn an_uninstall_removes_what_it_wrote_and_keeps_the_rest() {
917 let home = Home::new();
918 install(&home.layout(), true, false).unwrap();
919 let skill = first_skill();
920 let beside = home
921 .destination(".claude/skills", &skill)
922 .parent()
923 .unwrap()
924 .join("notes.md");
925 std::fs::write(&beside, "the user's notes\n").unwrap();
926
927 let actions = uninstall(&home.layout(), true).unwrap();
928 assert!(
929 actions
930 .iter()
931 .any(|action| matches!(action, Action::KeptDirectory { .. })),
932 "{actions:?}"
933 );
934 assert!(!home.destination(".claude/skills", &skill).exists());
935 assert!(beside.is_file(), "a file beside a skill must survive");
936 assert!(!home.record().exists(), "an empty record is removed");
937 uninstall(&home.layout(), true).unwrap();
939 }
940
941 #[test]
942 fn one_root_installs_and_uninstalls_without_touching_the_other() {
943 let home = Home::new();
944 let claude = home.layout_for(vec![home.path().join(".claude/skills")]);
945 install(&claude, true, false).unwrap();
946 assert!(home.destination(".claude/skills", &first_skill()).is_file());
947 assert!(!home.path().join(".agents").exists());
948
949 uninstall(&claude, true).unwrap();
950 assert!(!home.destination(".claude/skills", &first_skill()).exists());
951 }
952
953 #[test]
956 fn either_agent_alone_lands_the_gates_in_its_packages() {
957 for (root, other) in [
958 (".claude/skills", ".agents/skills"),
959 (".agents/skills", ".claude/skills"),
960 ] {
961 let home = Home::new();
962 let one = home.layout_for(vec![home.path().join(root)]);
963 install(&one, true, false).unwrap();
964 for skill in all().unwrap() {
965 assert!(
966 home.gate(root, &skill.name).is_file(),
967 "{root}/{}: the gate did not land",
968 skill.name
969 );
970 assert!(!home.gate(other, &skill.name).exists());
971 }
972 assert!(!home.legacy_shared().exists());
973 }
974 }
975
976 #[test]
980 fn an_uninstall_of_one_family_leaves_the_other_family_its_gates() {
981 let home = Home::new();
982 install(&home.layout(), true, false).unwrap();
983 let codex = home.layout_for(vec![home.path().join(".agents/skills")]);
984 uninstall(&codex, true).unwrap();
985 assert!(
986 !home
987 .path()
988 .join(".agents/skills")
989 .join(first_skill())
990 .exists()
991 );
992 assert!(home.gate(".claude/skills", &first_skill()).is_file());
993 }
994
995 #[test]
999 fn an_install_sweeps_the_legacy_shared_root_it_vouches_for() {
1000 let home = Home::new();
1001 let legacy = home.legacy_shared().join("plan-gate.md");
1002 std::fs::create_dir_all(home.legacy_shared()).unwrap();
1003 std::fs::write(&legacy, "an earlier release's gate\n").unwrap();
1004 let mut record = Record::default();
1005 record
1006 .written
1007 .insert(legacy.clone(), Digest::of(b"an earlier release's gate\n"));
1008 std::fs::create_dir_all(home.record().parent().unwrap()).unwrap();
1009 std::fs::write(home.record(), record.to_text()).unwrap();
1010
1011 let preview = install(&home.layout(), false, false).unwrap();
1012 assert!(
1013 preview.contains(&Action::Sweep {
1014 destination: legacy.clone()
1015 }),
1016 "{preview:?}"
1017 );
1018 install(&home.layout(), true, false).unwrap();
1019 assert!(!legacy.exists());
1020 assert!(!home.legacy_shared().exists());
1021 assert!(
1022 !home.legacy_shared().parent().unwrap().exists(),
1023 "the emptied skills directory goes too"
1024 );
1025 assert!(!Record::load(&home.record()).written.contains_key(&legacy));
1026 }
1027
1028 #[test]
1032 fn a_one_family_install_keeps_the_legacy_gates() {
1033 let home = Home::new();
1034 let legacy = home.legacy_shared().join("plan-gate.md");
1035 std::fs::create_dir_all(home.legacy_shared()).unwrap();
1036 std::fs::write(&legacy, "an earlier release's gate\n").unwrap();
1037 let mut record = Record::default();
1038 record
1039 .written
1040 .insert(legacy.clone(), Digest::of(b"an earlier release's gate\n"));
1041 std::fs::create_dir_all(home.record().parent().unwrap()).unwrap();
1042 std::fs::write(home.record(), record.to_text()).unwrap();
1043 let codex = home.layout_for(vec![home.path().join(".agents/skills")]);
1044
1045 let preview = install(&codex, false, false).unwrap();
1046 assert!(
1047 !preview.iter().any(|action| matches!(
1048 action,
1049 Action::Sweep { destination } if destination == &legacy
1050 )),
1051 "{preview:?}"
1052 );
1053 install(&codex, true, false).unwrap();
1054 assert!(legacy.is_file());
1055 uninstall(&codex, true).unwrap();
1056 assert!(legacy.is_file());
1057 assert!(Record::load(&home.record()).written.contains_key(&legacy));
1058 }
1059
1060 #[test]
1063 fn an_install_leaves_and_names_an_unvouched_legacy_gate() {
1064 let home = Home::new();
1065 let legacy = home.legacy_shared().join("plan-gate.md");
1066 std::fs::create_dir_all(home.legacy_shared()).unwrap();
1067 std::fs::write(&legacy, "the user's own gate\n").unwrap();
1068
1069 let actions = install(&home.layout(), true, false).unwrap();
1070 assert!(
1071 actions.contains(&Action::KeptEdited {
1072 destination: legacy.clone()
1073 }),
1074 "{actions:?}"
1075 );
1076 assert_eq!(
1077 std::fs::read_to_string(&legacy).unwrap(),
1078 "the user's own gate\n"
1079 );
1080 }
1081
1082 #[test]
1084 fn an_edited_gate_copy_refuses_an_install_and_survives_an_uninstall() {
1085 let home = Home::new();
1086 install(&home.layout(), true, false).unwrap();
1087 let gate = home.gate(".claude/skills", &first_skill());
1088 std::fs::write(&gate, b"mine now").unwrap();
1089
1090 let message = install(&home.layout(), true, false)
1091 .expect_err("an edited gate refuses")
1092 .to_string();
1093 assert!(message.contains("plan-gate.md"), "{message}");
1094
1095 let actions = uninstall(&home.layout(), true).unwrap();
1096 assert!(
1097 actions.iter().any(|action| matches!(
1098 action,
1099 Action::KeptEdited { destination } if destination == &gate
1100 )),
1101 "{actions:?}"
1102 );
1103 assert_eq!(std::fs::read(&gate).unwrap(), b"mine now");
1104 }
1105
1106 #[test]
1109 fn a_symlinked_gate_copy_refuses_before_writing() {
1110 let home = Home::new();
1111 let gate = home.gate(".agents/skills", &first_skill());
1112 std::fs::create_dir_all(gate.parent().unwrap()).unwrap();
1113 std::os::unix::fs::symlink("/etc/passwd", &gate).unwrap();
1114
1115 let message = install(&home.layout(), true, false)
1116 .expect_err("a symlink refuses")
1117 .to_string();
1118 assert!(message.contains("symlink"), "{message}");
1119 assert!(
1120 !home.destination(".claude/skills", &first_skill()).exists(),
1121 "the refusal must come before the first write"
1122 );
1123 }
1124
1125 #[test]
1129 fn a_symlinked_shared_root_refuses_install_and_uninstall() {
1130 for symlinked in ["skills", "skills/shared"] {
1131 let home = Home::new();
1132 let elsewhere = home.path().join("elsewhere");
1133 std::fs::create_dir_all(&elsewhere).unwrap();
1134 let state_dir = home.record().parent().unwrap().to_path_buf();
1135 let linked = state_dir.join(symlinked);
1136 std::fs::create_dir_all(linked.parent().unwrap()).unwrap();
1137 std::os::unix::fs::symlink(&elsewhere, &linked).unwrap();
1138
1139 let message = install(&home.layout(), true, false)
1140 .expect_err("a symlinked shared root refuses an install")
1141 .to_string();
1142 assert!(message.contains("symlink"), "{message}");
1143 assert!(
1144 !elsewhere.join("plan-gate.md").exists(),
1145 "an install must never write through a symlinked shared root"
1146 );
1147 assert!(!home.path().join(".claude").exists());
1148
1149 std::fs::write(elsewhere.join("plan-gate.md"), "theirs\n").unwrap();
1150 let message = uninstall(&home.layout(), true)
1151 .expect_err("a symlinked shared root refuses an uninstall")
1152 .to_string();
1153 assert!(message.contains("symlink"), "{message}");
1154 assert!(
1155 elsewhere.join("plan-gate.md").exists(),
1156 "an uninstall must never remove through a symlinked shared root"
1157 );
1158 }
1159 }
1160}