1use std::collections::{BTreeMap, BTreeSet};
15
16use camino::{Utf8Path, Utf8PathBuf};
17
18use crate::domain::ownership::Sha256;
19use crate::domain::skill_record::SkillRecord;
20use crate::error::AppError;
21
22pub const SHARED_ROOT: &str = ".local/state/spec-driven-docs/skills/shared";
30
31pub const CLAUDE_ROOT: &str = ".claude/skills";
33
34pub const AGENTS_ROOT: &str = ".agents/skills";
37
38pub fn home() -> Result<Utf8PathBuf, AppError> {
44 std::env::var("HOME")
45 .ok()
46 .filter(|home| !home.is_empty())
47 .map(Utf8PathBuf::from)
48 .ok_or_else(|| AppError::Usage("HOME is not set".to_string()))
49}
50
51struct Planned {
53 destination: Utf8PathBuf,
54 bytes: &'static [u8],
55}
56
57#[derive(Debug, Clone)]
64pub struct Layout {
65 pub roots: Vec<Utf8PathBuf>,
67 pub every_root: Vec<Utf8PathBuf>,
69 pub shared: Utf8PathBuf,
71 pub record: Utf8PathBuf,
73}
74
75fn plan_roots(roots: &[Utf8PathBuf]) -> Result<Vec<Planned>, AppError> {
77 let mut planned = Vec::new();
78 for root in roots {
79 for name in crate::embedded::skill_names() {
80 let text = crate::embedded::skill(name)
81 .ok_or_else(|| anyhow::anyhow!("payload skill missing: {name}"))?;
82 planned.push(Planned {
83 destination: root.join(name).join("SKILL.md"),
84 bytes: text.as_bytes(),
85 });
86 }
87 }
88 Ok(planned)
89}
90
91fn plan_shared(shared: &Utf8Path) -> Vec<Planned> {
93 crate::embedded::shared_artifacts()
94 .into_iter()
95 .map(|(path, bytes)| Planned {
96 destination: shared.join(path),
97 bytes,
98 })
99 .collect()
100}
101
102fn check_shared_root(shared: &Utf8Path, record: &Utf8Path) -> Result<(), AppError> {
111 let Some(state_dir) = record.parent() else {
112 return Ok(());
113 };
114 let mut current = Some(shared);
115 while let Some(dir) = current {
116 if !dir.starts_with(state_dir) {
117 break;
118 }
119 if dir.is_symlink() {
120 return Err(AppError::Refused(format!(
121 "the shared root is reached through a symlink: {dir}"
122 )));
123 }
124 current = dir.parent();
125 }
126 Ok(())
127}
128
129fn check_destination(destination: &Utf8Path) -> Result<(), AppError> {
130 if destination.is_symlink() {
131 return Err(AppError::Refused(format!(
132 "destination is a symlink: {destination}"
133 )));
134 }
135 if destination.exists() && !destination.is_file() {
136 return Err(AppError::Refused(format!(
137 "destination exists and is not a regular file: {destination}"
138 )));
139 }
140 Ok(())
141}
142
143fn conflicts(planned: &[Planned], record: &SkillRecord) -> Result<Vec<String>, AppError> {
150 let mut conflicts = Vec::new();
151 for entry in planned {
152 if !entry.destination.is_file() {
153 continue;
154 }
155 let found = std::fs::read(&entry.destination)?;
158 if found == entry.bytes {
159 continue;
160 }
161 if record.wrote(&entry.destination, &Sha256::of(&found)) {
162 continue;
163 }
164 conflicts.push(entry.destination.to_string());
165 }
166 Ok(conflicts)
167}
168
169fn leftovers(
177 roots: &[Utf8PathBuf],
178 record: &SkillRecord,
179 keep: &[Utf8PathBuf],
180) -> Vec<Utf8PathBuf> {
181 let kept: BTreeSet<&Utf8Path> = keep.iter().map(Utf8PathBuf::as_path).collect();
182 record
183 .written
184 .iter()
185 .filter(|(destination, digest)| {
186 !kept.contains(destination.as_path())
187 && roots.iter().any(|root| destination.starts_with(root))
188 && !destination.is_symlink()
189 && destination.is_file()
190 && std::fs::read(destination).is_ok_and(|found| Sha256::of(&found) == **digest)
191 })
192 .map(|(destination, _)| destination.clone())
193 .collect()
194}
195
196fn remove_installed(destination: &Utf8Path, lines: &mut Vec<String>) -> Result<(), AppError> {
199 std::fs::remove_file(destination)?;
200 let directory = destination
201 .parent()
202 .ok_or_else(|| anyhow::anyhow!("destination has no parent: {destination}"))?;
203 if std::fs::read_dir(directory)?.next().is_none() {
204 std::fs::remove_dir(directory)?;
205 } else {
206 lines.push(format!("kept (not empty): {directory}"));
207 }
208 Ok(())
209}
210
211fn rollback(backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>) -> Vec<Utf8PathBuf> {
219 let mut unrestored = Vec::new();
220 for (destination, previous) in backups {
221 let restored = previous.as_ref().map_or_else(
222 || !destination.exists() || std::fs::remove_file(destination).is_ok(),
223 |bytes| {
224 std::fs::read(destination).is_ok_and(|found| &found == bytes)
225 || crate::adapters::fs::write_file(destination, bytes).is_ok()
226 },
227 );
228 if !restored {
229 unrestored.push(destination.clone());
230 }
231 }
232 unrestored
233}
234
235fn abort(unrestored: &[Utf8PathBuf], cause: &str) -> AppError {
237 if unrestored.is_empty() {
238 AppError::Refused(format!(
239 "skill install aborted; the destinations were restored: {cause}"
240 ))
241 } else {
242 let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
243 AppError::Refused(format!(
244 "skill install aborted and restoration is incomplete; verify by hand: {}: {cause}",
245 paths.join(" ")
246 ))
247 }
248}
249
250pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<String>, AppError> {
264 let record_path = layout.record.as_path();
265 check_shared_root(&layout.shared, &layout.record)?;
266 let mut planned = plan_roots(&layout.roots)?;
267 planned.extend(plan_shared(&layout.shared));
268 let mut lines: Vec<String> = Vec::new();
269 for entry in &planned {
270 check_destination(&entry.destination)?;
271 lines.push(entry.destination.to_string());
272 }
273 let mut record = SkillRecord::load(record_path);
274 let kept: Vec<Utf8PathBuf> = planned
275 .iter()
276 .map(|entry| entry.destination.clone())
277 .collect();
278 let mut scanned = layout.roots.clone();
279 scanned.push(layout.shared.clone());
280 let stale = leftovers(&scanned, &record, &kept);
281 for destination in &stale {
282 lines.push(format!("sweep (no longer in the payload): {destination}"));
283 }
284 if !apply {
285 lines.push("DRY RUN: no files written".to_string());
286 return Ok(lines);
287 }
288
289 if !force {
290 let conflicts = conflicts(&planned, &record)?;
291 if !conflicts.is_empty() {
292 return Err(AppError::Refused(format!(
293 "destinations hold bytes this tool did not write: {}; re-run with --force to overwrite",
294 conflicts.join(", ")
295 )));
296 }
297 }
298
299 let mut backups: BTreeMap<Utf8PathBuf, Option<Vec<u8>>> = BTreeMap::new();
302 for entry in &planned {
303 let previous = if entry.destination.is_file() {
304 Some(std::fs::read(&entry.destination).map_err(|source| {
305 AppError::Refused(format!("cannot back up {}: {source}", entry.destination))
306 })?)
307 } else {
308 None
309 };
310 backups.insert(entry.destination.clone(), previous);
311 }
312
313 for entry in &planned {
314 if let Err(source) = crate::adapters::fs::write_file(&entry.destination, entry.bytes) {
315 return Err(abort(
316 &rollback(&backups),
317 &format!("writing {} failed: {source}", entry.destination),
318 ));
319 }
320 }
321
322 for destination in &stale {
326 if let Err(source) = remove_installed(destination, &mut lines) {
327 lines.push(format!(
328 "could not remove {destination}; remove it by hand: {source}"
329 ));
330 continue;
331 }
332 record.written.remove(destination);
333 }
334
335 for entry in &planned {
336 record
337 .written
338 .insert(entry.destination.clone(), Sha256::of(entry.bytes));
339 }
340 if crate::adapters::fs::write_file(record_path, record.to_json().as_bytes()).is_err() {
341 lines.push(format!(
342 "note: could not record the installed digests at {record_path}; a later install may ask for --force"
343 ));
344 }
345 Ok(lines)
346}
347
348pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<String>, AppError> {
360 let record_path = layout.record.as_path();
361 let mut lines: Vec<String> = Vec::new();
362 let mut removable: Vec<Utf8PathBuf> = Vec::new();
363 for root in &layout.roots {
364 for name in crate::embedded::skill_names() {
365 let destination = root.join(name).join("SKILL.md");
366 check_destination(&destination)?;
367 if destination.is_file() {
368 lines.push(destination.to_string());
369 removable.push(destination);
370 }
371 }
372 }
373 let going: BTreeSet<&Utf8Path> = removable.iter().map(Utf8PathBuf::as_path).collect();
378 let retained = plan_roots(&layout.every_root)?
379 .iter()
380 .any(|entry| !going.contains(entry.destination.as_path()) && entry.destination.is_file());
381 let mut scanned = layout.roots.clone();
382 if !retained {
383 check_shared_root(&layout.shared, &layout.record)?;
384 for entry in plan_shared(&layout.shared) {
385 check_destination(&entry.destination)?;
386 if entry.destination.is_file() {
387 lines.push(entry.destination.to_string());
388 removable.push(entry.destination);
389 }
390 }
391 scanned.push(layout.shared.clone());
392 }
393 let mut record = SkillRecord::load(record_path);
394 for destination in leftovers(&scanned, &record, &removable) {
398 lines.push(format!("sweep (no longer in the payload): {destination}"));
399 removable.push(destination);
400 }
401 if !apply {
402 lines.push("DRY RUN: no files removed".to_string());
403 return Ok(lines);
404 }
405 for destination in &removable {
406 remove_installed(destination, &mut lines)?;
407 }
408
409 for destination in &removable {
410 record.written.remove(destination);
411 }
412 let _ = if record.written.is_empty() {
413 std::fs::remove_file(record_path).map_err(|_| ())
414 } else {
415 crate::adapters::fs::write_file(record_path, record.to_json().as_bytes()).map_err(|_| ())
416 };
417 Ok(lines)
418}
419
420#[cfg(test)]
421mod tests {
422 #![allow(clippy::unwrap_used)]
423
424 use super::*;
425
426 fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
427 Utf8PathBuf::from(dir.path().to_str().unwrap())
428 }
429
430 fn home(dir: &tempfile::TempDir) -> Layout {
432 let home = root(dir);
433 let roots = vec![home.join(".agents/skills"), home.join(".claude/skills")];
434 Layout {
435 roots: roots.clone(),
436 every_root: roots,
437 shared: home.join(".local/state/spec-driven-docs/skills/shared"),
438 record: home.join(crate::domain::skill_record::RECORD_PATH),
439 }
440 }
441
442 fn select(layout: &Layout, index: usize) -> Layout {
444 Layout {
445 roots: vec![layout.roots[index].clone()],
446 ..layout.clone()
447 }
448 }
449
450 #[test]
451 fn a_preview_lists_every_destination_and_writes_nothing() {
452 let dir = tempfile::tempdir().unwrap();
453 let layout = home(&dir);
454 let (roots, record) = (layout.roots.clone(), layout.record.clone());
455 let lines = install(&layout, false, false).unwrap();
456 assert_eq!(lines.last().unwrap(), "DRY RUN: no files written");
457 assert_eq!(
458 lines.len(),
459 crate::embedded::skill_names().len() * 2
460 + crate::embedded::shared_artifacts().len()
461 + 1
462 );
463 assert!(!layout.shared.exists());
464 assert!(!roots[0].exists());
465 assert!(!record.exists());
466 }
467
468 #[test]
469 fn an_apply_is_idempotent_and_a_conflict_refuses_with_every_path() {
470 let dir = tempfile::tempdir().unwrap();
471 let layout = home(&dir);
472 let roots = layout.roots.clone();
473 install(&layout, true, false).unwrap();
474 install(&layout, true, false).unwrap();
475 for name in crate::embedded::skill_names() {
476 std::fs::write(roots[0].join(name).join("SKILL.md"), "edited").unwrap();
477 }
478 let error = install(&layout, true, false).unwrap_err();
479 let message = error.to_string();
480 for name in crate::embedded::skill_names() {
481 assert!(message.contains(name), "{message} misses {name}");
482 }
483 install(&layout, true, true).unwrap();
484 let text = std::fs::read_to_string(roots[0].join("sdd-setup/SKILL.md")).unwrap();
485 assert!(text.contains("name: sdd-setup"));
486 }
487
488 #[test]
492 fn a_copy_a_previous_release_wrote_is_replaced_without_force() {
493 let dir = tempfile::tempdir().unwrap();
494 let layout = home(&dir);
495 let (roots, record) = (layout.roots.clone(), layout.record.clone());
496 install(&layout, true, false).unwrap();
497
498 let mut stale = SkillRecord::load(&record);
501 for root in &roots {
502 for name in crate::embedded::skill_names() {
503 let destination = root.join(name).join("SKILL.md");
504 std::fs::write(&destination, "older canon bytes\n").unwrap();
505 stale
506 .written
507 .insert(destination, Sha256::of(b"older canon bytes\n"));
508 }
509 }
510 crate::adapters::fs::write_file(&record, stale.to_json().as_bytes()).unwrap();
511
512 install(&layout, true, false).unwrap();
513 let text = std::fs::read_to_string(roots[1].join("sdd-setup/SKILL.md")).unwrap();
514 assert!(text.contains("name: sdd-setup"));
515 }
516
517 #[test]
519 fn an_edit_still_refuses_when_a_sibling_is_merely_stale() {
520 let dir = tempfile::tempdir().unwrap();
521 let layout = home(&dir);
522 let (roots, record) = (layout.roots.clone(), layout.record.clone());
523 install(&layout, true, false).unwrap();
524
525 let stale_path = roots[0].join("sdd-setup/SKILL.md");
526 let edited_path = roots[1].join("sdd-setup/SKILL.md");
527 let mut stale = SkillRecord::load(&record);
528 std::fs::write(&stale_path, "older canon bytes\n").unwrap();
529 stale
530 .written
531 .insert(stale_path, Sha256::of(b"older canon bytes\n"));
532 crate::adapters::fs::write_file(&record, stale.to_json().as_bytes()).unwrap();
533 std::fs::write(&edited_path, "mine\n").unwrap();
534
535 let message = install(&layout, true, false).unwrap_err().to_string();
536 assert!(message.contains(edited_path.as_str()), "{message}");
537 assert!(!message.contains("older canon"), "{message}");
538 assert_eq!(std::fs::read_to_string(&edited_path).unwrap(), "mine\n");
539 }
540
541 #[test]
544 fn a_missing_record_treats_unknown_bytes_as_the_users() {
545 let dir = tempfile::tempdir().unwrap();
546 let layout = home(&dir);
547 let (roots, record) = (layout.roots.clone(), layout.record.clone());
548 install(&layout, true, false).unwrap();
549 std::fs::remove_file(&record).unwrap();
550 std::fs::write(roots[0].join("sdd-setup/SKILL.md"), "older canon bytes\n").unwrap();
551 let error = install(&layout, true, false).unwrap_err();
552 assert!(error.to_string().contains("sdd-setup"));
553 }
554
555 #[test]
561 fn a_write_that_fails_partway_restores_every_destination() {
562 let dir = tempfile::tempdir().unwrap();
563 let layout = home(&dir);
564 let roots = layout.roots.clone();
565 install(&layout, true, false).unwrap();
566
567 let mut before: Vec<(Utf8PathBuf, Vec<u8>)> = Vec::new();
572 for root in &roots {
573 for name in crate::embedded::skill_names() {
574 let path = root.join(name).join("SKILL.md");
575 std::fs::write(&path, format!("previous {name}\n")).unwrap();
576 before.push((path.clone(), std::fs::read(&path).unwrap()));
577 }
578 }
579 let blocked = roots[1].join("sdd-setup");
580 std::fs::remove_file(blocked.join("SKILL.md")).unwrap();
581 before.retain(|(path, _)| path.parent() != Some(blocked.as_path()));
582 let mut permissions = std::fs::metadata(&blocked).unwrap().permissions();
583 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o500);
584 std::fs::set_permissions(&blocked, permissions.clone()).unwrap();
585
586 let message = install(&layout, true, true).unwrap_err().to_string();
587 assert!(message.contains("skill install aborted"), "{message}");
588 assert!(
589 message.contains("the destinations were restored"),
590 "{message}"
591 );
592 assert!(message.contains(blocked.as_str()), "{message}");
593
594 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
595 std::fs::set_permissions(&blocked, permissions).unwrap();
596 for (path, bytes) in &before {
597 assert_eq!(
598 &std::fs::read(path).unwrap(),
599 bytes,
600 "{path} was not restored"
601 );
602 }
603 assert!(
604 !blocked.join("SKILL.md").exists(),
605 "a destination that did not exist before was left behind"
606 );
607 }
608
609 #[test]
613 fn a_recorded_destination_the_payload_dropped_is_swept_by_both_verbs() {
614 for sweep_with_uninstall in [false, true] {
615 let dir = tempfile::tempdir().unwrap();
616 let layout = home(&dir);
617 let (roots, record_path) = (layout.roots.clone(), layout.record.clone());
618 install(&layout, true, false).unwrap();
619
620 let dropped = roots[0].join("sdd-old-name/SKILL.md");
621 crate::adapters::fs::write_file(&dropped, b"older\n").unwrap();
622 let mut record = SkillRecord::load(&record_path);
623 record
624 .written
625 .insert(dropped.clone(), Sha256::of(b"older\n"));
626 crate::adapters::fs::write_file(&record_path, record.to_json().as_bytes()).unwrap();
627
628 if sweep_with_uninstall {
629 uninstall(&layout, true).unwrap();
630 } else {
631 install(&layout, true, false).unwrap();
632 }
633 assert!(!dropped.exists(), "the leftover file survived");
634 assert!(
635 !roots[0].join("sdd-old-name").exists(),
636 "the leftover directory survived"
637 );
638 assert!(
639 !SkillRecord::load(&record_path)
640 .written
641 .contains_key(&dropped)
642 );
643 }
644 }
645
646 #[test]
649 fn an_edited_leftover_is_left_where_it_is() {
650 let dir = tempfile::tempdir().unwrap();
651 let layout = home(&dir);
652 let (roots, record_path) = (layout.roots.clone(), layout.record.clone());
653 install(&layout, true, false).unwrap();
654
655 let dropped = roots[0].join("sdd-old-name/SKILL.md");
656 crate::adapters::fs::write_file(&dropped, b"older\n").unwrap();
657 let mut record = SkillRecord::load(&record_path);
658 record
659 .written
660 .insert(dropped.clone(), Sha256::of(b"older\n"));
661 crate::adapters::fs::write_file(&record_path, record.to_json().as_bytes()).unwrap();
662 crate::adapters::fs::write_file(&dropped, b"mine\n").unwrap();
663
664 install(&layout, true, false).unwrap();
665 assert_eq!(std::fs::read(&dropped).unwrap(), b"mine\n");
666 uninstall(&layout, true).unwrap();
667 assert_eq!(std::fs::read(&dropped).unwrap(), b"mine\n");
668 }
669
670 #[test]
674 fn a_root_that_refuses_every_write_reports_a_clean_restore() {
675 let dir = tempfile::tempdir().unwrap();
676 let layout = home(&dir);
677 let roots = layout.roots.clone();
678 install(&layout, true, false).unwrap();
679
680 let mut locked = Vec::new();
683 for name in crate::embedded::skill_names() {
684 let destination = roots[0].join(name).join("SKILL.md");
685 let mut permissions = std::fs::metadata(&destination).unwrap().permissions();
686 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o444);
687 std::fs::set_permissions(&destination, permissions).unwrap();
688 locked.push(destination);
689 }
690
691 let message = install(&layout, true, true).unwrap_err().to_string();
692
693 for destination in &locked {
694 let mut permissions = std::fs::metadata(destination).unwrap().permissions();
695 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o644);
696 std::fs::set_permissions(destination, permissions).unwrap();
697 }
698
699 assert!(
700 message.contains("the destinations were restored"),
701 "{message}"
702 );
703 assert!(!message.contains("restoration is incomplete"), "{message}");
704 }
705
706 #[test]
707 fn an_uninstall_removes_only_payload_files_and_keeps_foreign_ones() {
708 let dir = tempfile::tempdir().unwrap();
709 let layout = home(&dir);
710 let (roots, record) = (layout.roots.clone(), layout.record.clone());
711 install(&layout, true, false).unwrap();
712 std::fs::write(roots[1].join("sdd-setup/notes.md"), "mine").unwrap();
713
714 let preview = uninstall(&layout, false).unwrap();
715 assert_eq!(preview.last().unwrap(), "DRY RUN: no files removed");
716 assert!(roots[1].join("sdd-setup/SKILL.md").is_file());
717
718 let lines = uninstall(&layout, true).unwrap();
719 assert!(!roots[1].join("sdd-setup/SKILL.md").exists());
720 assert!(!roots[1].join("sdd-write-docs").exists());
721 assert_eq!(
722 std::fs::read_to_string(roots[1].join("sdd-setup/notes.md")).unwrap(),
723 "mine"
724 );
725 assert!(
726 lines
727 .iter()
728 .any(|line| line.starts_with("kept (not empty):"))
729 );
730 assert!(
731 !record.exists(),
732 "the record outlived every file it vouched for"
733 );
734
735 uninstall(&layout, true).unwrap();
737 }
738
739 #[test]
742 fn an_uninstall_of_one_root_keeps_the_others_entries() {
743 let dir = tempfile::tempdir().unwrap();
744 let layout = home(&dir);
745 let (roots, record) = (layout.roots.clone(), layout.record.clone());
746 install(&layout, true, false).unwrap();
747 uninstall(&select(&layout, 1), true).unwrap();
748 let kept = SkillRecord::load(&record);
749 assert!(
750 kept.written
751 .keys()
752 .all(|path| path.starts_with(&roots[0]) || path.starts_with(&layout.shared))
753 );
754 assert!(kept.written.keys().any(|path| path.starts_with(&roots[0])));
755 assert!(
756 kept.written
757 .keys()
758 .any(|path| path.starts_with(&layout.shared))
759 );
760 }
761
762 #[test]
765 fn either_agent_alone_still_lands_the_shared_artifacts() {
766 for index in 0..2 {
767 let dir = tempfile::tempdir().unwrap();
768 let layout = home(&dir);
769 install(&select(&layout, index), true, false).unwrap();
770 assert!(layout.shared.join("plan-gate.md").is_file());
771 assert!(layout.shared.join("pre-flight-gate.md").is_file());
772 }
773 }
774
775 #[test]
778 fn the_shared_artifacts_stay_while_another_root_still_holds_skills() {
779 let dir = tempfile::tempdir().unwrap();
780 let layout = home(&dir);
781 install(&layout, true, false).unwrap();
782 uninstall(&select(&layout, 1), true).unwrap();
783 assert!(layout.shared.join("plan-gate.md").is_file());
784 assert!(layout.shared.join("pre-flight-gate.md").is_file());
785 uninstall(&select(&layout, 0), true).unwrap();
786 assert!(!layout.shared.exists());
787 assert!(!layout.record.exists());
788 }
789
790 #[test]
793 fn the_last_uninstall_previews_the_shared_artifacts() {
794 let dir = tempfile::tempdir().unwrap();
795 let layout = home(&dir);
796 install(&layout, true, false).unwrap();
797 let lines = uninstall(&layout, false).unwrap();
798 for artifact in ["plan-gate.md", "pre-flight-gate.md"] {
799 let shared = layout.shared.join(artifact);
800 assert!(lines.iter().any(|line| line == shared.as_str()));
801 assert!(shared.is_file());
802 }
803 }
804
805 #[test]
808 fn an_edited_shared_artifact_refuses_an_install() {
809 let dir = tempfile::tempdir().unwrap();
810 let layout = home(&dir);
811 install(&layout, true, false).unwrap();
812 let shared = layout.shared.join("plan-gate.md");
813 std::fs::write(&shared, "mine\n").unwrap();
814 let message = install(&layout, true, false).unwrap_err().to_string();
815 assert!(message.contains(shared.as_str()), "{message}");
816 install(&layout, true, true).unwrap();
817 assert!(
818 std::fs::read_to_string(&shared)
819 .unwrap()
820 .contains("# The plan gate")
821 );
822 }
823
824 #[test]
828 fn a_symlinked_shared_root_refuses_install_and_uninstall() {
829 for symlinked in ["skills", "skills/shared"] {
830 let dir = tempfile::tempdir().unwrap();
831 let layout = home(&dir);
832 let elsewhere = root(&dir).join("elsewhere");
833 std::fs::create_dir_all(&elsewhere).unwrap();
834 let state_dir = layout.record.parent().unwrap();
835 let linked = state_dir.join(symlinked);
836 std::fs::create_dir_all(linked.parent().unwrap()).unwrap();
837 std::os::unix::fs::symlink(&elsewhere, &linked).unwrap();
838
839 let message = install(&layout, true, false).unwrap_err().to_string();
840 assert!(message.contains("symlink"), "{message}");
841 assert!(!elsewhere.join("plan-gate.md").exists());
842 assert!(!layout.roots[0].exists());
843
844 std::fs::write(elsewhere.join("plan-gate.md"), "theirs\n").unwrap();
845 let message = uninstall(&layout, true).unwrap_err().to_string();
846 assert!(message.contains("symlink"), "{message}");
847 assert!(elsewhere.join("plan-gate.md").exists());
848 }
849 }
850
851 #[test]
853 fn a_symlinked_shared_destination_refuses_before_writing() {
854 let dir = tempfile::tempdir().unwrap();
855 let layout = home(&dir);
856 std::fs::create_dir_all(&layout.shared).unwrap();
857 let target = root(&dir).join("elsewhere.md");
858 std::fs::write(&target, "x").unwrap();
859 std::os::unix::fs::symlink(&target, layout.shared.join("plan-gate.md")).unwrap();
860 let message = install(&layout, true, false).unwrap_err().to_string();
861 assert!(message.contains("symlink"), "{message}");
862 assert!(!layout.roots[0].exists());
863 }
864}