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