1use std::collections::{BTreeMap, BTreeSet};
17use std::fs;
18
19use camino::{Utf8Path, Utf8PathBuf};
20use serde::Serialize;
21
22use crate::atomic;
23use crate::error::RkError;
24use crate::skills::record::Record;
25use crate::skills::{Digest, Skill};
26
27#[derive(Debug, Serialize, PartialEq, Eq)]
29#[serde(tag = "action", rename_all = "kebab-case")]
30pub enum Action {
31 Write {
33 destination: Utf8PathBuf,
35 },
36 Unchanged {
38 destination: Utf8PathBuf,
40 },
41 Sweep {
43 destination: Utf8PathBuf,
45 },
46 SweepFailed {
48 destination: Utf8PathBuf,
50 error: String,
52 },
53 Remove {
55 destination: Utf8PathBuf,
57 },
58 KeptEdited {
61 destination: Utf8PathBuf,
63 },
64 KeptDirectory {
66 directory: Utf8PathBuf,
68 },
69 RecordUnwritten {
72 record: Utf8PathBuf,
74 },
75}
76
77struct Planned {
79 destination: Utf8PathBuf,
81 bytes: &'static [u8],
83}
84
85#[derive(Debug, Clone)]
92pub struct Layout {
93 pub roots: Vec<Utf8PathBuf>,
95 pub every_root: Vec<Utf8PathBuf>,
97 pub shared: Utf8PathBuf,
99 pub record: Utf8PathBuf,
101}
102
103fn plan_roots(roots: &[Utf8PathBuf], skills: &[Skill]) -> Vec<Planned> {
105 let mut planned = Vec::new();
106 for root in roots {
107 for skill in skills {
108 planned.push(Planned {
109 destination: root.join(&skill.name).join("SKILL.md"),
110 bytes: skill.text.as_bytes(),
111 });
112 }
113 }
114 planned
115}
116
117fn plan_shared(shared: &Utf8Path) -> Vec<Planned> {
119 crate::skills::shared()
120 .into_iter()
121 .map(|artifact| Planned {
122 destination: shared.join(&artifact.path),
123 bytes: artifact.bytes,
124 })
125 .collect()
126}
127
128fn check_shared_root(shared: &Utf8Path, record: &Utf8Path) -> Result<(), RkError> {
138 let Some(state_dir) = record.parent() else {
139 return Ok(());
140 };
141 let mut current = Some(shared);
142 while let Some(dir) = current {
143 if !dir.starts_with(state_dir) {
144 break;
145 }
146 if dir.is_symlink() {
147 return Err(RkError::Refused(format!(
148 "the shared root is reached through a symlink, and nothing was written: {dir}"
149 )));
150 }
151 current = dir.parent();
152 }
153 Ok(())
154}
155
156fn check_destination(destination: &Utf8Path) -> Result<(), RkError> {
161 if destination.is_symlink() {
162 return Err(RkError::Refused(format!(
163 "destination is a symlink, and nothing was written: {destination}"
164 )));
165 }
166 if destination.exists() && !destination.is_file() {
167 return Err(RkError::Refused(format!(
168 "destination is not a regular file, and nothing was written: {destination}"
169 )));
170 }
171 Ok(())
172}
173
174fn conflicts(planned: &[Planned], record: &Record) -> Result<Vec<String>, RkError> {
181 let mut conflicts = Vec::new();
182 for entry in planned {
183 if !entry.destination.is_file() {
184 continue;
185 }
186 let found = fs::read(&entry.destination)?;
189 if found == entry.bytes || record.wrote(&entry.destination, &Digest::of(&found)) {
190 continue;
191 }
192 conflicts.push(entry.destination.to_string());
193 }
194 Ok(conflicts)
195}
196
197fn leftovers(roots: &[Utf8PathBuf], record: &Record, keep: &[Utf8PathBuf]) -> Vec<Utf8PathBuf> {
205 let kept: BTreeSet<&Utf8Path> = keep.iter().map(Utf8PathBuf::as_path).collect();
206 record
207 .written
208 .iter()
209 .filter(|(destination, digest)| {
210 !kept.contains(destination.as_path())
211 && roots.iter().any(|root| destination.starts_with(root))
212 && !destination.is_symlink()
213 && destination.is_file()
214 && fs::read(destination).is_ok_and(|found| Digest::of(&found) == **digest)
215 })
216 .map(|(destination, _)| destination.clone())
217 .collect()
218}
219
220fn write_file(path: &Utf8Path, bytes: &[u8]) -> std::io::Result<()> {
223 atomic::write(path.as_std_path(), bytes)
224}
225
226fn remove_installed(destination: &Utf8Path) -> Result<Option<Utf8PathBuf>, RkError> {
232 fs::remove_file(destination)?;
233 let Some(directory) = destination.parent() else {
234 return Ok(None);
235 };
236 if fs::read_dir(directory)?.next().is_none() {
237 fs::remove_dir(directory)?;
238 return Ok(None);
239 }
240 Ok(Some(directory.to_owned()))
241}
242
243fn rollback(backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>) -> Vec<Utf8PathBuf> {
251 let mut unrestored = Vec::new();
252 for (destination, previous) in backups {
253 let restored = previous.as_ref().map_or_else(
254 || !destination.exists() || fs::remove_file(destination).is_ok(),
255 |bytes| {
256 fs::read(destination).is_ok_and(|found| &found == bytes)
257 || write_file(destination, bytes).is_ok()
258 },
259 );
260 if !restored {
261 unrestored.push(destination.clone());
262 }
263 }
264 unrestored
265}
266
267fn abort(unrestored: &[Utf8PathBuf], cause: &str) -> RkError {
269 if unrestored.is_empty() {
270 return RkError::Refused(format!(
271 "the install was aborted and the destinations were restored: {cause}"
272 ));
273 }
274 let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
275 RkError::Refused(format!(
276 "the install was aborted and restoration is incomplete; verify these by hand: {}: {cause}",
277 paths.join(", ")
278 ))
279}
280
281pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<Action>, RkError> {
296 check_shared_root(&layout.shared, &layout.record)?;
297 let record_path = layout.record.as_path();
298 let skills = crate::skills::all()?;
299 let mut planned = plan_roots(&layout.roots, &skills);
300 planned.extend(plan_shared(&layout.shared));
301 for entry in &planned {
302 check_destination(&entry.destination)?;
303 }
304
305 let mut record = Record::load(record_path);
306 let covered: Vec<Utf8PathBuf> = planned
307 .iter()
308 .map(|entry| entry.destination.clone())
309 .collect();
310 let mut scanned = layout.roots.clone();
311 scanned.push(layout.shared.clone());
312 let stale = leftovers(&scanned, &record, &covered);
313
314 if !apply {
315 let mut actions: Vec<Action> = covered
316 .into_iter()
317 .map(|destination| Action::Write { destination })
318 .collect();
319 actions.extend(
320 stale
321 .into_iter()
322 .map(|destination| Action::Sweep { destination }),
323 );
324 return Ok(actions);
325 }
326
327 if !force {
328 let conflicts = conflicts(&planned, &record)?;
329 if !conflicts.is_empty() {
330 return Err(RkError::Refused(format!(
331 "these destinations hold bytes this tool did not write, and nothing was written: {}; re-run with --force to overwrite",
332 conflicts.join(", ")
333 )));
334 }
335 }
336
337 let mut backups: BTreeMap<Utf8PathBuf, Option<Vec<u8>>> = BTreeMap::new();
340 for entry in &planned {
341 let previous = if entry.destination.is_file() {
342 Some(fs::read(&entry.destination).map_err(|source| {
343 RkError::Refused(format!(
344 "cannot back up {}, and nothing was written: {source}",
345 entry.destination
346 ))
347 })?)
348 } else {
349 None
350 };
351 backups.insert(entry.destination.clone(), previous);
352 }
353
354 let mut actions = Vec::new();
355 for entry in &planned {
356 let held = backups.get(&entry.destination).and_then(Option::as_ref);
357 if held.is_some_and(|previous| previous == entry.bytes) {
358 actions.push(Action::Unchanged {
359 destination: entry.destination.clone(),
360 });
361 continue;
362 }
363 if let Err(source) = write_file(&entry.destination, entry.bytes) {
364 return Err(abort(
365 &rollback(&backups),
366 &format!("writing {} failed: {source}", entry.destination),
367 ));
368 }
369 actions.push(Action::Write {
370 destination: entry.destination.clone(),
371 });
372 }
373
374 for destination in &stale {
378 match remove_installed(destination) {
379 Ok(kept) => {
380 actions.push(Action::Sweep {
381 destination: destination.clone(),
382 });
383 actions.extend(kept.map(|directory| Action::KeptDirectory { directory }));
384 record.written.remove(destination);
385 }
386 Err(source) => actions.push(Action::SweepFailed {
387 destination: destination.clone(),
388 error: source.to_string(),
389 }),
390 }
391 }
392
393 for entry in &planned {
394 record
395 .written
396 .insert(entry.destination.clone(), Digest::of(entry.bytes));
397 }
398 if write_file(record_path, record.to_text().as_bytes()).is_err() {
399 actions.push(Action::RecordUnwritten {
400 record: record_path.to_owned(),
401 });
402 }
403 Ok(actions)
404}
405
406pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<Action>, RkError> {
421 check_shared_root(&layout.shared, &layout.record)?;
422 let record_path = layout.record.as_path();
423 let skills = crate::skills::all()?;
424 let record_found = Record::load(record_path);
425 let mut removable: Vec<Utf8PathBuf> = Vec::new();
426 let mut edited: Vec<Utf8PathBuf> = Vec::new();
427 let classify = |entry: &Planned,
428 removable: &mut Vec<Utf8PathBuf>,
429 edited: &mut Vec<Utf8PathBuf>|
430 -> Result<(), RkError> {
431 check_destination(&entry.destination)?;
432 if !entry.destination.is_file() {
433 return Ok(());
434 }
435 let found = fs::read(&entry.destination)?;
440 if found == entry.bytes || record_found.wrote(&entry.destination, &Digest::of(&found)) {
441 removable.push(entry.destination.clone());
442 } else {
443 edited.push(entry.destination.clone());
444 }
445 Ok(())
446 };
447
448 let selected = plan_roots(&layout.roots, &skills);
449 for entry in &selected {
450 classify(entry, &mut removable, &mut edited)?;
451 }
452
453 let going: BTreeSet<&Utf8Path> = removable.iter().map(Utf8PathBuf::as_path).collect();
458 let retained = plan_roots(&layout.every_root, &skills)
459 .iter()
460 .any(|entry| !going.contains(entry.destination.as_path()) && entry.destination.is_file());
461 let mut scanned = layout.roots.clone();
462 if !retained {
463 for entry in plan_shared(&layout.shared) {
464 classify(&entry, &mut removable, &mut edited)?;
465 }
466 scanned.push(layout.shared.clone());
467 }
468
469 let mut record = record_found;
470 let stale = leftovers(&scanned, &record, &removable);
474
475 if !apply {
476 let mut actions: Vec<Action> = removable
477 .into_iter()
478 .map(|destination| Action::Remove { destination })
479 .collect();
480 actions.extend(
481 stale
482 .into_iter()
483 .map(|destination| Action::Sweep { destination }),
484 );
485 actions.extend(
486 edited
487 .into_iter()
488 .map(|destination| Action::KeptEdited { destination }),
489 );
490 return Ok(actions);
491 }
492
493 removable.extend(stale);
494 let mut actions = Vec::new();
495 for destination in &removable {
496 let kept = remove_installed(destination)?;
497 actions.push(Action::Remove {
498 destination: destination.clone(),
499 });
500 actions.extend(kept.map(|directory| Action::KeptDirectory { directory }));
501 record.written.remove(destination);
502 }
503 actions.extend(
504 edited
505 .into_iter()
506 .map(|destination| Action::KeptEdited { destination }),
507 );
508
509 let recorded = if record.written.is_empty() {
510 fs::remove_file(record_path).or_else(|source| {
511 if source.kind() == std::io::ErrorKind::NotFound {
512 Ok(())
513 } else {
514 Err(source)
515 }
516 })
517 } else {
518 write_file(record_path, record.to_text().as_bytes())
519 };
520 if recorded.is_err() {
521 actions.push(Action::RecordUnwritten {
522 record: record_path.to_owned(),
523 });
524 }
525 Ok(actions)
526}
527
528#[cfg(test)]
529mod tests {
530 #![allow(clippy::expect_used, clippy::unwrap_used)]
531
532 use camino::Utf8PathBuf;
533
534 use super::{Action, Layout, install, leftovers, uninstall};
535 use crate::skills::record::{RECORD_PATH, Record};
536 use crate::skills::{Digest, all};
537
538 struct Home {
540 dir: tempfile::TempDir,
541 }
542
543 impl Home {
544 fn new() -> Self {
545 Self {
546 dir: tempfile::tempdir().expect("a scratch home exists"),
547 }
548 }
549
550 fn path(&self) -> Utf8PathBuf {
551 Utf8PathBuf::from_path_buf(self.dir.path().to_path_buf())
552 .expect("the temp path is UTF-8")
553 }
554
555 fn roots(&self) -> Vec<Utf8PathBuf> {
556 let home = self.path();
557 vec![home.join(".claude/skills"), home.join(".agents/skills")]
558 }
559
560 fn record(&self) -> Utf8PathBuf {
561 self.path().join(RECORD_PATH)
562 }
563
564 fn destination(&self, root: &str, skill: &str) -> Utf8PathBuf {
565 self.path().join(root).join(skill).join("SKILL.md")
566 }
567
568 fn shared(&self) -> Utf8PathBuf {
569 self.path().join(".local/state/release-kit/skills/shared")
570 }
571
572 fn layout(&self) -> Layout {
574 self.layout_for(self.roots())
575 }
576
577 fn layout_for(&self, roots: Vec<Utf8PathBuf>) -> Layout {
579 Layout {
580 roots,
581 every_root: self.roots(),
582 shared: self.shared(),
583 record: self.record(),
584 }
585 }
586 }
587
588 fn shared_count() -> usize {
590 crate::skills::shared().len()
591 }
592
593 fn first_skill() -> String {
594 all().expect("the skills read").swap_remove(0).name
595 }
596
597 #[test]
598 fn a_preview_lists_every_destination_and_writes_nothing() {
599 let home = Home::new();
600 let actions = install(&home.layout(), false, false).unwrap();
601 let count = all().unwrap().len();
602 assert_eq!(actions.len(), count * 2 + shared_count(), "{actions:?}");
603 assert!(
604 actions
605 .iter()
606 .all(|action| matches!(action, Action::Write { .. })),
607 "{actions:?}"
608 );
609 assert!(!home.path().join(".claude").exists());
610 assert!(!home.record().exists());
611 }
612
613 #[test]
614 fn an_apply_is_idempotent_and_records_what_it_wrote() {
615 let home = Home::new();
616 let first = install(&home.layout(), true, false).unwrap();
617 assert!(
618 first
619 .iter()
620 .all(|action| matches!(action, Action::Write { .. })),
621 "{first:?}"
622 );
623 let second = install(&home.layout(), true, false).unwrap();
624 assert!(
625 second
626 .iter()
627 .all(|action| matches!(action, Action::Unchanged { .. })),
628 "{second:?}"
629 );
630 let record = Record::load(&home.record());
631 assert_eq!(
632 record.written.len(),
633 all().unwrap().len() * 2 + shared_count()
634 );
635 }
636
637 #[test]
641 fn a_copy_a_previous_release_wrote_is_replaced_without_force() {
642 let home = Home::new();
643 install(&home.layout(), true, false).unwrap();
644
645 let mut stale = Record::default();
648 for destination in Record::load(&home.record()).written.into_keys() {
649 std::fs::write(&destination, "older canon bytes\n").unwrap();
650 stale
651 .written
652 .insert(destination, Digest::of(b"older canon bytes\n"));
653 }
654 std::fs::write(home.record(), stale.to_text()).unwrap();
655
656 install(&home.layout(), true, false).unwrap();
657 let text =
658 std::fs::read_to_string(home.destination(".claude/skills", &first_skill())).unwrap();
659 assert!(text.contains(&format!("name: {}", first_skill())));
660 }
661
662 #[test]
664 fn an_edit_refuses_and_names_every_conflict() {
665 let home = Home::new();
666 install(&home.layout(), true, false).unwrap();
667 let edited: Vec<Utf8PathBuf> = all()
668 .unwrap()
669 .iter()
670 .map(|skill| home.destination(".claude/skills", &skill.name))
671 .collect();
672 for destination in &edited {
673 std::fs::write(destination, "the user wrote this").unwrap();
674 }
675
676 let message = install(&home.layout(), true, false)
677 .unwrap_err()
678 .to_string();
679 for destination in &edited {
680 assert!(message.contains(destination.as_str()), "{message}");
681 }
682 for destination in &edited {
683 assert_eq!(
684 std::fs::read_to_string(destination).unwrap(),
685 "the user wrote this",
686 "a refused install must not overwrite"
687 );
688 }
689 install(&home.layout(), true, true).unwrap();
690 assert!(
691 std::fs::read_to_string(&edited[0])
692 .unwrap()
693 .starts_with("---")
694 );
695 }
696
697 #[cfg(unix)]
698 #[test]
699 fn a_symlinked_destination_refuses_before_anything_is_written() {
700 let home = Home::new();
701 let skill = first_skill();
702 let destination = home.destination(".claude/skills", &skill);
703 std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
704 let elsewhere = home.path().join("elsewhere");
705 std::fs::write(&elsewhere, "the user's file\n").unwrap();
706 std::os::unix::fs::symlink(&elsewhere, &destination).unwrap();
707
708 let message = install(&home.layout(), true, true).unwrap_err().to_string();
709 assert!(message.contains("symlink"), "{message}");
710 assert_eq!(
711 std::fs::read_to_string(&elsewhere).unwrap(),
712 "the user's file\n"
713 );
714 assert!(!home.path().join(".agents").exists());
715 }
716
717 #[test]
719 fn a_failed_write_restores_every_destination() {
720 let home = Home::new();
721 install(&home.layout(), true, false).unwrap();
722 let first = home.destination(".claude/skills", &first_skill());
723 std::fs::write(&first, "older canon bytes\n").unwrap();
724 let mut record = Record::load(&home.record());
725 record
726 .written
727 .insert(first.clone(), Digest::of(b"older canon bytes\n"));
728 std::fs::write(home.record(), record.to_text()).unwrap();
729
730 let blocked = home.path().join(".agents/skills").join(first_skill());
733 std::fs::remove_file(blocked.join("SKILL.md")).unwrap();
734 std::fs::remove_dir(&blocked).unwrap();
735 std::fs::write(&blocked, "in the way\n").unwrap();
736
737 let message = install(&home.layout(), true, false)
738 .unwrap_err()
739 .to_string();
740 assert!(message.contains("aborted"), "{message}");
741 assert_eq!(
742 std::fs::read_to_string(&first).unwrap(),
743 "older canon bytes\n",
744 "the first root must be restored"
745 );
746 }
747
748 #[test]
749 fn an_install_sweeps_a_destination_the_payload_dropped() {
750 let home = Home::new();
751 install(&home.layout(), true, false).unwrap();
752 let dropped = home.destination(".claude/skills", "rk-retired");
753 std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
754 std::fs::write(&dropped, "a skill a later release dropped\n").unwrap();
755 let mut record = Record::load(&home.record());
756 record.written.insert(
757 dropped.clone(),
758 Digest::of(b"a skill a later release dropped\n"),
759 );
760 std::fs::write(home.record(), record.to_text()).unwrap();
761
762 let actions = install(&home.layout(), true, false).unwrap();
763 assert!(
764 actions.contains(&Action::Sweep {
765 destination: dropped.clone()
766 }),
767 "{actions:?}"
768 );
769 assert!(!dropped.exists());
770 assert!(!dropped.parent().unwrap().exists());
771 assert!(!Record::load(&home.record()).written.contains_key(&dropped));
772 }
773
774 #[test]
776 fn a_sweep_leaves_an_edited_leftover_alone() {
777 let home = Home::new();
778 install(&home.layout(), true, false).unwrap();
779 let dropped = home.destination(".claude/skills", "rk-retired");
780 std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
781 std::fs::write(&dropped, "the user rewrote this\n").unwrap();
782 let mut record = Record::load(&home.record());
783 record
784 .written
785 .insert(dropped.clone(), Digest::of(b"what we wrote\n"));
786 std::fs::write(home.record(), record.to_text()).unwrap();
787
788 assert!(
789 !leftovers(&home.roots(), &record, &[]).contains(&dropped),
790 "a leftover whose bytes differ from the record is the user's"
791 );
792 install(&home.layout(), true, false).unwrap();
793 assert_eq!(
794 std::fs::read_to_string(&dropped).unwrap(),
795 "the user rewrote this\n"
796 );
797 }
798
799 #[test]
803 fn an_uninstall_keeps_an_edited_destination() {
804 let home = Home::new();
805 install(&home.layout(), true, false).unwrap();
806 let edited = home.destination(".claude/skills", &first_skill());
807 std::fs::write(&edited, "the user rewrote this\n").unwrap();
808
809 let preview = uninstall(&home.layout(), false).unwrap();
810 assert!(
811 preview.contains(&Action::KeptEdited {
812 destination: edited.clone()
813 }),
814 "{preview:?}"
815 );
816 assert!(
817 !preview.contains(&Action::Remove {
818 destination: edited.clone()
819 }),
820 "{preview:?}"
821 );
822
823 let actions = uninstall(&home.layout(), true).unwrap();
824 assert!(
825 actions.contains(&Action::KeptEdited {
826 destination: edited.clone()
827 }),
828 "{actions:?}"
829 );
830 assert_eq!(
831 std::fs::read_to_string(&edited).unwrap(),
832 "the user rewrote this\n",
833 "an uninstall must never delete a user's edit"
834 );
835 assert!(!home.destination(".agents/skills", &first_skill()).exists());
837 }
838
839 #[test]
840 fn an_uninstall_removes_what_it_wrote_and_keeps_the_rest() {
841 let home = Home::new();
842 install(&home.layout(), true, false).unwrap();
843 let skill = first_skill();
844 let beside = home
845 .destination(".claude/skills", &skill)
846 .parent()
847 .unwrap()
848 .join("notes.md");
849 std::fs::write(&beside, "the user's notes\n").unwrap();
850
851 let actions = uninstall(&home.layout(), true).unwrap();
852 assert!(
853 actions
854 .iter()
855 .any(|action| matches!(action, Action::KeptDirectory { .. })),
856 "{actions:?}"
857 );
858 assert!(!home.destination(".claude/skills", &skill).exists());
859 assert!(beside.is_file(), "a file beside a skill must survive");
860 assert!(!home.record().exists(), "an empty record is removed");
861 uninstall(&home.layout(), true).unwrap();
863 }
864
865 #[test]
866 fn one_root_installs_and_uninstalls_without_touching_the_other() {
867 let home = Home::new();
868 let claude = home.layout_for(vec![home.path().join(".claude/skills")]);
869 install(&claude, true, false).unwrap();
870 assert!(home.destination(".claude/skills", &first_skill()).is_file());
871 assert!(!home.path().join(".agents").exists());
872
873 uninstall(&claude, true).unwrap();
874 assert!(!home.destination(".claude/skills", &first_skill()).exists());
875 }
876
877 #[test]
880 fn either_agent_alone_still_lands_the_shared_artifacts() {
881 for root in [".claude/skills", ".agents/skills"] {
882 let home = Home::new();
883 let one = home.layout_for(vec![home.path().join(root)]);
884 install(&one, true, false).unwrap();
885 assert!(
886 home.shared().join("plan-gate.md").is_file(),
887 "{root}: the shared gate did not land"
888 );
889 }
890 }
891
892 #[test]
895 fn the_shared_artifacts_stay_while_another_root_still_holds_skills() {
896 let home = Home::new();
897 install(&home.layout(), true, false).unwrap();
898 let gate = home.shared().join("plan-gate.md");
899 assert!(gate.is_file());
900
901 let codex = home.layout_for(vec![home.path().join(".agents/skills")]);
902 uninstall(&codex, true).unwrap();
903 assert!(!home.destination(".agents/skills", &first_skill()).exists());
904 assert!(
905 gate.is_file(),
906 "the Claude skills still read the gate, so it must stay"
907 );
908
909 let claude = home.layout_for(vec![home.path().join(".claude/skills")]);
910 let actions = uninstall(&claude, true).unwrap();
911 assert!(
912 !gate.exists(),
913 "the last uninstall takes the gate: {actions:?}"
914 );
915 }
916
917 #[test]
919 fn the_last_uninstall_previews_the_shared_artifacts() {
920 let home = Home::new();
921 install(&home.layout(), true, false).unwrap();
922 let actions = uninstall(&home.layout(), false).unwrap();
923 assert!(
924 actions.iter().any(|action| matches!(
925 action,
926 Action::Remove { destination } if destination.file_name() == Some("plan-gate.md")
927 )),
928 "{actions:?}"
929 );
930 assert!(
931 home.shared().join("plan-gate.md").is_file(),
932 "a preview writes nothing"
933 );
934 }
935
936 #[test]
938 fn an_edited_shared_artifact_refuses_an_install_and_survives_an_uninstall() {
939 let home = Home::new();
940 install(&home.layout(), true, false).unwrap();
941 let gate = home.shared().join("plan-gate.md");
942 std::fs::write(&gate, b"mine now").unwrap();
943
944 let message = install(&home.layout(), true, false)
945 .expect_err("an edited gate refuses")
946 .to_string();
947 assert!(message.contains("plan-gate.md"), "{message}");
948
949 let actions = uninstall(&home.layout(), true).unwrap();
950 assert!(
951 actions.iter().any(|action| matches!(
952 action,
953 Action::KeptEdited { destination } if destination == &gate
954 )),
955 "{actions:?}"
956 );
957 assert_eq!(std::fs::read(&gate).unwrap(), b"mine now");
958 }
959
960 #[test]
963 fn a_symlinked_shared_destination_refuses_before_writing() {
964 let home = Home::new();
965 let gate = home.shared().join("plan-gate.md");
966 std::fs::create_dir_all(home.shared()).unwrap();
967 std::os::unix::fs::symlink("/etc/passwd", &gate).unwrap();
968
969 let message = install(&home.layout(), true, false)
970 .expect_err("a symlink refuses")
971 .to_string();
972 assert!(message.contains("symlink"), "{message}");
973 assert!(
974 !home.destination(".claude/skills", &first_skill()).exists(),
975 "the refusal must come before the first write"
976 );
977 }
978
979 #[test]
983 fn a_symlinked_shared_root_refuses_install_and_uninstall() {
984 for symlinked in ["skills", "skills/shared"] {
985 let home = Home::new();
986 let elsewhere = home.path().join("elsewhere");
987 std::fs::create_dir_all(&elsewhere).unwrap();
988 let state_dir = home.record().parent().unwrap().to_path_buf();
989 let linked = state_dir.join(symlinked);
990 std::fs::create_dir_all(linked.parent().unwrap()).unwrap();
991 std::os::unix::fs::symlink(&elsewhere, &linked).unwrap();
992
993 let message = install(&home.layout(), true, false)
994 .expect_err("a symlinked shared root refuses an install")
995 .to_string();
996 assert!(message.contains("symlink"), "{message}");
997 assert!(
998 !elsewhere.join("plan-gate.md").exists(),
999 "an install must never write through a symlinked shared root"
1000 );
1001 assert!(!home.path().join(".claude").exists());
1002
1003 std::fs::write(elsewhere.join("plan-gate.md"), "theirs\n").unwrap();
1004 let message = uninstall(&home.layout(), true)
1005 .expect_err("a symlinked shared root refuses an uninstall")
1006 .to_string();
1007 assert!(message.contains("symlink"), "{message}");
1008 assert!(
1009 elsewhere.join("plan-gate.md").exists(),
1010 "an uninstall must never remove through a symlinked shared root"
1011 );
1012 }
1013 }
1014}