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 use camino::Utf8PathBuf;
531
532 use super::{Action, Layout, install, leftovers, uninstall};
533 use crate::skills::record::{RECORD_PATH, Record};
534 use crate::skills::{Digest, all};
535
536 struct Home {
538 dir: tempfile::TempDir,
539 }
540
541 impl Home {
542 fn new() -> Self {
543 Self {
544 dir: tempfile::tempdir().expect("a scratch home exists"),
545 }
546 }
547
548 fn path(&self) -> Utf8PathBuf {
549 Utf8PathBuf::from_path_buf(self.dir.path().to_path_buf())
550 .expect("the temp path is UTF-8")
551 }
552
553 fn roots(&self) -> Vec<Utf8PathBuf> {
554 let home = self.path();
555 vec![home.join(".claude/skills"), home.join(".agents/skills")]
556 }
557
558 fn record(&self) -> Utf8PathBuf {
559 self.path().join(RECORD_PATH)
560 }
561
562 fn destination(&self, root: &str, skill: &str) -> Utf8PathBuf {
563 self.path().join(root).join(skill).join("SKILL.md")
564 }
565
566 fn shared(&self) -> Utf8PathBuf {
567 self.path().join(".local/state/release-kit/skills/shared")
568 }
569
570 fn layout(&self) -> Layout {
572 self.layout_for(self.roots())
573 }
574
575 fn layout_for(&self, roots: Vec<Utf8PathBuf>) -> Layout {
577 Layout {
578 roots,
579 every_root: self.roots(),
580 shared: self.shared(),
581 record: self.record(),
582 }
583 }
584 }
585
586 fn shared_count() -> usize {
588 crate::skills::shared().len()
589 }
590
591 fn first_skill() -> String {
592 all().expect("the skills read").swap_remove(0).name
593 }
594
595 #[test]
596 fn a_preview_lists_every_destination_and_writes_nothing() {
597 let home = Home::new();
598 let actions = install(&home.layout(), false, false).unwrap();
599 let count = all().unwrap().len();
600 assert_eq!(actions.len(), count * 2 + shared_count(), "{actions:?}");
601 assert!(
602 actions
603 .iter()
604 .all(|action| matches!(action, Action::Write { .. })),
605 "{actions:?}"
606 );
607 assert!(!home.path().join(".claude").exists());
608 assert!(!home.record().exists());
609 }
610
611 #[test]
612 fn an_apply_is_idempotent_and_records_what_it_wrote() {
613 let home = Home::new();
614 let first = install(&home.layout(), true, false).unwrap();
615 assert!(
616 first
617 .iter()
618 .all(|action| matches!(action, Action::Write { .. })),
619 "{first:?}"
620 );
621 let second = install(&home.layout(), true, false).unwrap();
622 assert!(
623 second
624 .iter()
625 .all(|action| matches!(action, Action::Unchanged { .. })),
626 "{second:?}"
627 );
628 let record = Record::load(&home.record());
629 assert_eq!(
630 record.written.len(),
631 all().unwrap().len() * 2 + shared_count()
632 );
633 }
634
635 #[test]
639 fn a_copy_a_previous_release_wrote_is_replaced_without_force() {
640 let home = Home::new();
641 install(&home.layout(), true, false).unwrap();
642
643 let mut stale = Record::default();
646 for destination in Record::load(&home.record()).written.into_keys() {
647 std::fs::write(&destination, "older canon bytes\n").unwrap();
648 stale
649 .written
650 .insert(destination, Digest::of(b"older canon bytes\n"));
651 }
652 std::fs::write(home.record(), stale.to_text()).unwrap();
653
654 install(&home.layout(), true, false).unwrap();
655 let text =
656 std::fs::read_to_string(home.destination(".claude/skills", &first_skill())).unwrap();
657 assert!(text.contains(&format!("name: {}", first_skill())));
658 }
659
660 #[test]
662 fn an_edit_refuses_and_names_every_conflict() {
663 let home = Home::new();
664 install(&home.layout(), true, false).unwrap();
665 let edited: Vec<Utf8PathBuf> = all()
666 .unwrap()
667 .iter()
668 .map(|skill| home.destination(".claude/skills", &skill.name))
669 .collect();
670 for destination in &edited {
671 std::fs::write(destination, "the user wrote this").unwrap();
672 }
673
674 let message = install(&home.layout(), true, false)
675 .unwrap_err()
676 .to_string();
677 for destination in &edited {
678 assert!(message.contains(destination.as_str()), "{message}");
679 }
680 for destination in &edited {
681 assert_eq!(
682 std::fs::read_to_string(destination).unwrap(),
683 "the user wrote this",
684 "a refused install must not overwrite"
685 );
686 }
687 install(&home.layout(), true, true).unwrap();
688 assert!(
689 std::fs::read_to_string(&edited[0])
690 .unwrap()
691 .starts_with("---")
692 );
693 }
694
695 #[cfg(unix)]
696 #[test]
697 fn a_symlinked_destination_refuses_before_anything_is_written() {
698 let home = Home::new();
699 let skill = first_skill();
700 let destination = home.destination(".claude/skills", &skill);
701 std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
702 let elsewhere = home.path().join("elsewhere");
703 std::fs::write(&elsewhere, "the user's file\n").unwrap();
704 std::os::unix::fs::symlink(&elsewhere, &destination).unwrap();
705
706 let message = install(&home.layout(), true, true).unwrap_err().to_string();
707 assert!(message.contains("symlink"), "{message}");
708 assert_eq!(
709 std::fs::read_to_string(&elsewhere).unwrap(),
710 "the user's file\n"
711 );
712 assert!(!home.path().join(".agents").exists());
713 }
714
715 #[test]
717 fn a_failed_write_restores_every_destination() {
718 let home = Home::new();
719 install(&home.layout(), true, false).unwrap();
720 let first = home.destination(".claude/skills", &first_skill());
721 std::fs::write(&first, "older canon bytes\n").unwrap();
722 let mut record = Record::load(&home.record());
723 record
724 .written
725 .insert(first.clone(), Digest::of(b"older canon bytes\n"));
726 std::fs::write(home.record(), record.to_text()).unwrap();
727
728 let blocked = home.path().join(".agents/skills").join(first_skill());
731 std::fs::remove_file(blocked.join("SKILL.md")).unwrap();
732 std::fs::remove_dir(&blocked).unwrap();
733 std::fs::write(&blocked, "in the way\n").unwrap();
734
735 let message = install(&home.layout(), true, false)
736 .unwrap_err()
737 .to_string();
738 assert!(message.contains("aborted"), "{message}");
739 assert_eq!(
740 std::fs::read_to_string(&first).unwrap(),
741 "older canon bytes\n",
742 "the first root must be restored"
743 );
744 }
745
746 #[test]
747 fn an_install_sweeps_a_destination_the_payload_dropped() {
748 let home = Home::new();
749 install(&home.layout(), true, false).unwrap();
750 let dropped = home.destination(".claude/skills", "rk-retired");
751 std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
752 std::fs::write(&dropped, "a skill a later release dropped\n").unwrap();
753 let mut record = Record::load(&home.record());
754 record.written.insert(
755 dropped.clone(),
756 Digest::of(b"a skill a later release dropped\n"),
757 );
758 std::fs::write(home.record(), record.to_text()).unwrap();
759
760 let actions = install(&home.layout(), true, false).unwrap();
761 assert!(
762 actions.contains(&Action::Sweep {
763 destination: dropped.clone()
764 }),
765 "{actions:?}"
766 );
767 assert!(!dropped.exists());
768 assert!(!dropped.parent().unwrap().exists());
769 assert!(!Record::load(&home.record()).written.contains_key(&dropped));
770 }
771
772 #[test]
774 fn a_sweep_leaves_an_edited_leftover_alone() {
775 let home = Home::new();
776 install(&home.layout(), true, false).unwrap();
777 let dropped = home.destination(".claude/skills", "rk-retired");
778 std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
779 std::fs::write(&dropped, "the user rewrote this\n").unwrap();
780 let mut record = Record::load(&home.record());
781 record
782 .written
783 .insert(dropped.clone(), Digest::of(b"what we wrote\n"));
784 std::fs::write(home.record(), record.to_text()).unwrap();
785
786 assert!(
787 !leftovers(&home.roots(), &record, &[]).contains(&dropped),
788 "a leftover whose bytes differ from the record is the user's"
789 );
790 install(&home.layout(), true, false).unwrap();
791 assert_eq!(
792 std::fs::read_to_string(&dropped).unwrap(),
793 "the user rewrote this\n"
794 );
795 }
796
797 #[test]
801 fn an_uninstall_keeps_an_edited_destination() {
802 let home = Home::new();
803 install(&home.layout(), true, false).unwrap();
804 let edited = home.destination(".claude/skills", &first_skill());
805 std::fs::write(&edited, "the user rewrote this\n").unwrap();
806
807 let preview = uninstall(&home.layout(), false).unwrap();
808 assert!(
809 preview.contains(&Action::KeptEdited {
810 destination: edited.clone()
811 }),
812 "{preview:?}"
813 );
814 assert!(
815 !preview.contains(&Action::Remove {
816 destination: edited.clone()
817 }),
818 "{preview:?}"
819 );
820
821 let actions = uninstall(&home.layout(), true).unwrap();
822 assert!(
823 actions.contains(&Action::KeptEdited {
824 destination: edited.clone()
825 }),
826 "{actions:?}"
827 );
828 assert_eq!(
829 std::fs::read_to_string(&edited).unwrap(),
830 "the user rewrote this\n",
831 "an uninstall must never delete a user's edit"
832 );
833 assert!(!home.destination(".agents/skills", &first_skill()).exists());
835 }
836
837 #[test]
838 fn an_uninstall_removes_what_it_wrote_and_keeps_the_rest() {
839 let home = Home::new();
840 install(&home.layout(), true, false).unwrap();
841 let skill = first_skill();
842 let beside = home
843 .destination(".claude/skills", &skill)
844 .parent()
845 .unwrap()
846 .join("notes.md");
847 std::fs::write(&beside, "the user's notes\n").unwrap();
848
849 let actions = uninstall(&home.layout(), true).unwrap();
850 assert!(
851 actions
852 .iter()
853 .any(|action| matches!(action, Action::KeptDirectory { .. })),
854 "{actions:?}"
855 );
856 assert!(!home.destination(".claude/skills", &skill).exists());
857 assert!(beside.is_file(), "a file beside a skill must survive");
858 assert!(!home.record().exists(), "an empty record is removed");
859 uninstall(&home.layout(), true).unwrap();
861 }
862
863 #[test]
864 fn one_root_installs_and_uninstalls_without_touching_the_other() {
865 let home = Home::new();
866 let claude = home.layout_for(vec![home.path().join(".claude/skills")]);
867 install(&claude, true, false).unwrap();
868 assert!(home.destination(".claude/skills", &first_skill()).is_file());
869 assert!(!home.path().join(".agents").exists());
870
871 uninstall(&claude, true).unwrap();
872 assert!(!home.destination(".claude/skills", &first_skill()).exists());
873 }
874
875 #[test]
878 fn either_agent_alone_still_lands_the_shared_artifacts() {
879 for root in [".claude/skills", ".agents/skills"] {
880 let home = Home::new();
881 let one = home.layout_for(vec![home.path().join(root)]);
882 install(&one, true, false).unwrap();
883 assert!(
884 home.shared().join("plan-gate.md").is_file(),
885 "{root}: the shared gate did not land"
886 );
887 }
888 }
889
890 #[test]
893 fn the_shared_artifacts_stay_while_another_root_still_holds_skills() {
894 let home = Home::new();
895 install(&home.layout(), true, false).unwrap();
896 let gate = home.shared().join("plan-gate.md");
897 assert!(gate.is_file());
898
899 let codex = home.layout_for(vec![home.path().join(".agents/skills")]);
900 uninstall(&codex, true).unwrap();
901 assert!(!home.destination(".agents/skills", &first_skill()).exists());
902 assert!(
903 gate.is_file(),
904 "the Claude skills still read the gate, so it must stay"
905 );
906
907 let claude = home.layout_for(vec![home.path().join(".claude/skills")]);
908 let actions = uninstall(&claude, true).unwrap();
909 assert!(
910 !gate.exists(),
911 "the last uninstall takes the gate: {actions:?}"
912 );
913 }
914
915 #[test]
917 fn the_last_uninstall_previews_the_shared_artifacts() {
918 let home = Home::new();
919 install(&home.layout(), true, false).unwrap();
920 let actions = uninstall(&home.layout(), false).unwrap();
921 assert!(
922 actions.iter().any(|action| matches!(
923 action,
924 Action::Remove { destination } if destination.file_name() == Some("plan-gate.md")
925 )),
926 "{actions:?}"
927 );
928 assert!(
929 home.shared().join("plan-gate.md").is_file(),
930 "a preview writes nothing"
931 );
932 }
933
934 #[test]
936 fn an_edited_shared_artifact_refuses_an_install_and_survives_an_uninstall() {
937 let home = Home::new();
938 install(&home.layout(), true, false).unwrap();
939 let gate = home.shared().join("plan-gate.md");
940 std::fs::write(&gate, b"mine now").unwrap();
941
942 let message = install(&home.layout(), true, false)
943 .expect_err("an edited gate refuses")
944 .to_string();
945 assert!(message.contains("plan-gate.md"), "{message}");
946
947 let actions = uninstall(&home.layout(), true).unwrap();
948 assert!(
949 actions.iter().any(|action| matches!(
950 action,
951 Action::KeptEdited { destination } if destination == &gate
952 )),
953 "{actions:?}"
954 );
955 assert_eq!(std::fs::read(&gate).unwrap(), b"mine now");
956 }
957
958 #[test]
961 fn a_symlinked_shared_destination_refuses_before_writing() {
962 let home = Home::new();
963 let gate = home.shared().join("plan-gate.md");
964 std::fs::create_dir_all(home.shared()).unwrap();
965 std::os::unix::fs::symlink("/etc/passwd", &gate).unwrap();
966
967 let message = install(&home.layout(), true, false)
968 .expect_err("a symlink refuses")
969 .to_string();
970 assert!(message.contains("symlink"), "{message}");
971 assert!(
972 !home.destination(".claude/skills", &first_skill()).exists(),
973 "the refusal must come before the first write"
974 );
975 }
976
977 #[test]
981 fn a_symlinked_shared_root_refuses_install_and_uninstall() {
982 for symlinked in ["skills", "skills/shared"] {
983 let home = Home::new();
984 let elsewhere = home.path().join("elsewhere");
985 std::fs::create_dir_all(&elsewhere).unwrap();
986 let state_dir = home.record().parent().unwrap().to_path_buf();
987 let linked = state_dir.join(symlinked);
988 std::fs::create_dir_all(linked.parent().unwrap()).unwrap();
989 std::os::unix::fs::symlink(&elsewhere, &linked).unwrap();
990
991 let message = install(&home.layout(), true, false)
992 .expect_err("a symlinked shared root refuses an install")
993 .to_string();
994 assert!(message.contains("symlink"), "{message}");
995 assert!(
996 !elsewhere.join("plan-gate.md").exists(),
997 "an install must never write through a symlinked shared root"
998 );
999 assert!(!home.path().join(".claude").exists());
1000
1001 std::fs::write(elsewhere.join("plan-gate.md"), "theirs\n").unwrap();
1002 let message = uninstall(&home.layout(), true)
1003 .expect_err("a symlinked shared root refuses an uninstall")
1004 .to_string();
1005 assert!(message.contains("symlink"), "{message}");
1006 assert!(
1007 elsewhere.join("plan-gate.md").exists(),
1008 "an uninstall must never remove through a symlinked shared root"
1009 );
1010 }
1011 }
1012}