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)]
424
425 use super::*;
426
427 fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
428 Utf8PathBuf::from(dir.path().to_str().unwrap())
429 }
430
431 fn home(dir: &tempfile::TempDir) -> Layout {
433 let home = root(dir);
434 let roots = vec![home.join(".agents/skills"), home.join(".claude/skills")];
435 Layout {
436 roots: roots.clone(),
437 every_root: roots,
438 shared: home.join(".local/state/spec-driven-docs/skills/shared"),
439 record: home.join(crate::domain::skill_record::RECORD_PATH),
440 }
441 }
442
443 fn select(layout: &Layout, index: usize) -> Layout {
445 Layout {
446 roots: vec![layout.roots[index].clone()],
447 ..layout.clone()
448 }
449 }
450
451 #[test]
452 fn a_preview_lists_every_destination_and_writes_nothing() {
453 let dir = tempfile::tempdir().unwrap();
454 let layout = home(&dir);
455 let (roots, record) = (layout.roots.clone(), layout.record.clone());
456 let lines = install(&layout, false, false).unwrap();
457 assert_eq!(lines.last().unwrap(), "DRY RUN: no files written");
458 assert_eq!(
459 lines.len(),
460 crate::embedded::skill_names().len() * 2
461 + crate::embedded::shared_artifacts().len()
462 + 1
463 );
464 assert!(!layout.shared.exists());
465 assert!(!roots[0].exists());
466 assert!(!record.exists());
467 }
468
469 #[test]
470 fn an_apply_is_idempotent_and_a_conflict_refuses_with_every_path() {
471 let dir = tempfile::tempdir().unwrap();
472 let layout = home(&dir);
473 let roots = layout.roots.clone();
474 install(&layout, true, false).unwrap();
475 install(&layout, true, false).unwrap();
476 for name in crate::embedded::skill_names() {
477 std::fs::write(roots[0].join(name).join("SKILL.md"), "edited").unwrap();
478 }
479 let error = install(&layout, true, false).unwrap_err();
480 let message = error.to_string();
481 for name in crate::embedded::skill_names() {
482 assert!(message.contains(name), "{message} misses {name}");
483 }
484 install(&layout, true, true).unwrap();
485 let text = std::fs::read_to_string(roots[0].join("sdd-setup/SKILL.md")).unwrap();
486 assert!(text.contains("name: sdd-setup"));
487 }
488
489 #[test]
493 fn a_copy_a_previous_release_wrote_is_replaced_without_force() {
494 let dir = tempfile::tempdir().unwrap();
495 let layout = home(&dir);
496 let (roots, record) = (layout.roots.clone(), layout.record.clone());
497 install(&layout, true, false).unwrap();
498
499 let mut stale = SkillRecord::load(&record);
502 for root in &roots {
503 for name in crate::embedded::skill_names() {
504 let destination = root.join(name).join("SKILL.md");
505 std::fs::write(&destination, "older canon bytes\n").unwrap();
506 stale
507 .written
508 .insert(destination, Sha256::of(b"older canon bytes\n"));
509 }
510 }
511 crate::adapters::fs::write_file(&record, stale.to_json().as_bytes()).unwrap();
512
513 install(&layout, true, false).unwrap();
514 let text = std::fs::read_to_string(roots[1].join("sdd-setup/SKILL.md")).unwrap();
515 assert!(text.contains("name: sdd-setup"));
516 }
517
518 #[test]
520 fn an_edit_still_refuses_when_a_sibling_is_merely_stale() {
521 let dir = tempfile::tempdir().unwrap();
522 let layout = home(&dir);
523 let (roots, record) = (layout.roots.clone(), layout.record.clone());
524 install(&layout, true, false).unwrap();
525
526 let stale_path = roots[0].join("sdd-setup/SKILL.md");
527 let edited_path = roots[1].join("sdd-setup/SKILL.md");
528 let mut stale = SkillRecord::load(&record);
529 std::fs::write(&stale_path, "older canon bytes\n").unwrap();
530 stale
531 .written
532 .insert(stale_path, Sha256::of(b"older canon bytes\n"));
533 crate::adapters::fs::write_file(&record, stale.to_json().as_bytes()).unwrap();
534 std::fs::write(&edited_path, "mine\n").unwrap();
535
536 let message = install(&layout, true, false).unwrap_err().to_string();
537 assert!(message.contains(edited_path.as_str()), "{message}");
538 assert!(!message.contains("older canon"), "{message}");
539 assert_eq!(std::fs::read_to_string(&edited_path).unwrap(), "mine\n");
540 }
541
542 #[test]
545 fn a_missing_record_treats_unknown_bytes_as_the_users() {
546 let dir = tempfile::tempdir().unwrap();
547 let layout = home(&dir);
548 let (roots, record) = (layout.roots.clone(), layout.record.clone());
549 install(&layout, true, false).unwrap();
550 std::fs::remove_file(&record).unwrap();
551 std::fs::write(roots[0].join("sdd-setup/SKILL.md"), "older canon bytes\n").unwrap();
552 let error = install(&layout, true, false).unwrap_err();
553 assert!(error.to_string().contains("sdd-setup"));
554 }
555
556 #[test]
562 fn a_write_that_fails_partway_restores_every_destination() {
563 let dir = tempfile::tempdir().unwrap();
564 let layout = home(&dir);
565 let roots = layout.roots.clone();
566 install(&layout, true, false).unwrap();
567
568 let mut before: Vec<(Utf8PathBuf, Vec<u8>)> = Vec::new();
573 for root in &roots {
574 for name in crate::embedded::skill_names() {
575 let path = root.join(name).join("SKILL.md");
576 std::fs::write(&path, format!("previous {name}\n")).unwrap();
577 before.push((path.clone(), std::fs::read(&path).unwrap()));
578 }
579 }
580 let blocked = roots[1].join("sdd-setup");
581 std::fs::remove_file(blocked.join("SKILL.md")).unwrap();
582 before.retain(|(path, _)| path.parent() != Some(blocked.as_path()));
583 let mut permissions = std::fs::metadata(&blocked).unwrap().permissions();
584 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o500);
585 std::fs::set_permissions(&blocked, permissions.clone()).unwrap();
586
587 let message = install(&layout, true, true).unwrap_err().to_string();
588 assert!(message.contains("skill install aborted"), "{message}");
589 assert!(
590 message.contains("the destinations were restored"),
591 "{message}"
592 );
593 assert!(message.contains(blocked.as_str()), "{message}");
594
595 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
596 std::fs::set_permissions(&blocked, permissions).unwrap();
597 for (path, bytes) in &before {
598 assert_eq!(
599 &std::fs::read(path).unwrap(),
600 bytes,
601 "{path} was not restored"
602 );
603 }
604 assert!(
605 !blocked.join("SKILL.md").exists(),
606 "a destination that did not exist before was left behind"
607 );
608 }
609
610 #[test]
614 fn a_recorded_destination_the_payload_dropped_is_swept_by_both_verbs() {
615 for sweep_with_uninstall in [false, true] {
616 let dir = tempfile::tempdir().unwrap();
617 let layout = home(&dir);
618 let (roots, record_path) = (layout.roots.clone(), layout.record.clone());
619 install(&layout, true, false).unwrap();
620
621 let dropped = roots[0].join("sdd-old-name/SKILL.md");
622 crate::adapters::fs::write_file(&dropped, b"older\n").unwrap();
623 let mut record = SkillRecord::load(&record_path);
624 record
625 .written
626 .insert(dropped.clone(), Sha256::of(b"older\n"));
627 crate::adapters::fs::write_file(&record_path, record.to_json().as_bytes()).unwrap();
628
629 if sweep_with_uninstall {
630 uninstall(&layout, true).unwrap();
631 } else {
632 install(&layout, true, false).unwrap();
633 }
634 assert!(!dropped.exists(), "the leftover file survived");
635 assert!(
636 !roots[0].join("sdd-old-name").exists(),
637 "the leftover directory survived"
638 );
639 assert!(
640 !SkillRecord::load(&record_path)
641 .written
642 .contains_key(&dropped)
643 );
644 }
645 }
646
647 #[test]
650 fn an_edited_leftover_is_left_where_it_is() {
651 let dir = tempfile::tempdir().unwrap();
652 let layout = home(&dir);
653 let (roots, record_path) = (layout.roots.clone(), layout.record.clone());
654 install(&layout, true, false).unwrap();
655
656 let dropped = roots[0].join("sdd-old-name/SKILL.md");
657 crate::adapters::fs::write_file(&dropped, b"older\n").unwrap();
658 let mut record = SkillRecord::load(&record_path);
659 record
660 .written
661 .insert(dropped.clone(), Sha256::of(b"older\n"));
662 crate::adapters::fs::write_file(&record_path, record.to_json().as_bytes()).unwrap();
663 crate::adapters::fs::write_file(&dropped, b"mine\n").unwrap();
664
665 install(&layout, true, false).unwrap();
666 assert_eq!(std::fs::read(&dropped).unwrap(), b"mine\n");
667 uninstall(&layout, true).unwrap();
668 assert_eq!(std::fs::read(&dropped).unwrap(), b"mine\n");
669 }
670
671 #[test]
675 fn a_root_that_refuses_every_write_reports_a_clean_restore() {
676 let dir = tempfile::tempdir().unwrap();
677 let layout = home(&dir);
678 let roots = layout.roots.clone();
679 install(&layout, true, false).unwrap();
680
681 let mut locked = Vec::new();
684 for name in crate::embedded::skill_names() {
685 let destination = roots[0].join(name).join("SKILL.md");
686 let mut permissions = std::fs::metadata(&destination).unwrap().permissions();
687 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o444);
688 std::fs::set_permissions(&destination, permissions).unwrap();
689 locked.push(destination);
690 }
691
692 let message = install(&layout, true, true).unwrap_err().to_string();
693
694 for destination in &locked {
695 let mut permissions = std::fs::metadata(destination).unwrap().permissions();
696 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o644);
697 std::fs::set_permissions(destination, permissions).unwrap();
698 }
699
700 assert!(
701 message.contains("the destinations were restored"),
702 "{message}"
703 );
704 assert!(!message.contains("restoration is incomplete"), "{message}");
705 }
706
707 #[test]
708 fn an_uninstall_removes_only_payload_files_and_keeps_foreign_ones() {
709 let dir = tempfile::tempdir().unwrap();
710 let layout = home(&dir);
711 let (roots, record) = (layout.roots.clone(), layout.record.clone());
712 install(&layout, true, false).unwrap();
713 std::fs::write(roots[1].join("sdd-setup/notes.md"), "mine").unwrap();
714
715 let preview = uninstall(&layout, false).unwrap();
716 assert_eq!(preview.last().unwrap(), "DRY RUN: no files removed");
717 assert!(roots[1].join("sdd-setup/SKILL.md").is_file());
718
719 let lines = uninstall(&layout, true).unwrap();
720 assert!(!roots[1].join("sdd-setup/SKILL.md").exists());
721 assert!(!roots[1].join("sdd-write-docs").exists());
722 assert_eq!(
723 std::fs::read_to_string(roots[1].join("sdd-setup/notes.md")).unwrap(),
724 "mine"
725 );
726 assert!(
727 lines
728 .iter()
729 .any(|line| line.starts_with("kept (not empty):"))
730 );
731 assert!(
732 !record.exists(),
733 "the record outlived every file it vouched for"
734 );
735
736 uninstall(&layout, true).unwrap();
738 }
739
740 #[test]
743 fn an_uninstall_of_one_root_keeps_the_others_entries() {
744 let dir = tempfile::tempdir().unwrap();
745 let layout = home(&dir);
746 let (roots, record) = (layout.roots.clone(), layout.record.clone());
747 install(&layout, true, false).unwrap();
748 uninstall(&select(&layout, 1), true).unwrap();
749 let kept = SkillRecord::load(&record);
750 assert!(
751 kept.written
752 .keys()
753 .all(|path| path.starts_with(&roots[0]) || path.starts_with(&layout.shared))
754 );
755 assert!(kept.written.keys().any(|path| path.starts_with(&roots[0])));
756 assert!(
757 kept.written
758 .keys()
759 .any(|path| path.starts_with(&layout.shared))
760 );
761 }
762
763 #[test]
766 fn either_agent_alone_still_lands_the_shared_artifacts() {
767 for index in 0..2 {
768 let dir = tempfile::tempdir().unwrap();
769 let layout = home(&dir);
770 install(&select(&layout, index), true, false).unwrap();
771 assert!(layout.shared.join("plan-gate.md").is_file());
772 assert!(layout.shared.join("pre-flight-gate.md").is_file());
773 }
774 }
775
776 #[test]
779 fn the_shared_artifacts_stay_while_another_root_still_holds_skills() {
780 let dir = tempfile::tempdir().unwrap();
781 let layout = home(&dir);
782 install(&layout, true, false).unwrap();
783 uninstall(&select(&layout, 1), true).unwrap();
784 assert!(layout.shared.join("plan-gate.md").is_file());
785 assert!(layout.shared.join("pre-flight-gate.md").is_file());
786 uninstall(&select(&layout, 0), true).unwrap();
787 assert!(!layout.shared.exists());
788 assert!(!layout.record.exists());
789 }
790
791 #[test]
794 fn the_last_uninstall_previews_the_shared_artifacts() {
795 let dir = tempfile::tempdir().unwrap();
796 let layout = home(&dir);
797 install(&layout, true, false).unwrap();
798 let lines = uninstall(&layout, false).unwrap();
799 for artifact in ["plan-gate.md", "pre-flight-gate.md"] {
800 let shared = layout.shared.join(artifact);
801 assert!(lines.iter().any(|line| line == shared.as_str()));
802 assert!(shared.is_file());
803 }
804 }
805
806 #[test]
809 fn an_edited_shared_artifact_refuses_an_install() {
810 let dir = tempfile::tempdir().unwrap();
811 let layout = home(&dir);
812 install(&layout, true, false).unwrap();
813 let shared = layout.shared.join("plan-gate.md");
814 std::fs::write(&shared, "mine\n").unwrap();
815 let message = install(&layout, true, false).unwrap_err().to_string();
816 assert!(message.contains(shared.as_str()), "{message}");
817 install(&layout, true, true).unwrap();
818 assert!(
819 std::fs::read_to_string(&shared)
820 .unwrap()
821 .contains("# The plan gate")
822 );
823 }
824
825 #[test]
829 fn a_symlinked_shared_root_refuses_install_and_uninstall() {
830 for symlinked in ["skills", "skills/shared"] {
831 let dir = tempfile::tempdir().unwrap();
832 let layout = home(&dir);
833 let elsewhere = root(&dir).join("elsewhere");
834 std::fs::create_dir_all(&elsewhere).unwrap();
835 let state_dir = layout.record.parent().unwrap();
836 let linked = state_dir.join(symlinked);
837 std::fs::create_dir_all(linked.parent().unwrap()).unwrap();
838 std::os::unix::fs::symlink(&elsewhere, &linked).unwrap();
839
840 let message = install(&layout, true, false).unwrap_err().to_string();
841 assert!(message.contains("symlink"), "{message}");
842 assert!(!elsewhere.join("plan-gate.md").exists());
843 assert!(!layout.roots[0].exists());
844
845 std::fs::write(elsewhere.join("plan-gate.md"), "theirs\n").unwrap();
846 let message = uninstall(&layout, true).unwrap_err().to_string();
847 assert!(message.contains("symlink"), "{message}");
848 assert!(elsewhere.join("plan-gate.md").exists());
849 }
850 }
851
852 #[test]
854 fn a_symlinked_shared_destination_refuses_before_writing() {
855 let dir = tempfile::tempdir().unwrap();
856 let layout = home(&dir);
857 std::fs::create_dir_all(&layout.shared).unwrap();
858 let target = root(&dir).join("elsewhere.md");
859 std::fs::write(&target, "x").unwrap();
860 std::os::unix::fs::symlink(&target, layout.shared.join("plan-gate.md")).unwrap();
861 let message = install(&layout, true, false).unwrap_err().to_string();
862 assert!(message.contains("symlink"), "{message}");
863 assert!(!layout.roots[0].exists());
864 }
865}