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
22struct Planned {
24 destination: Utf8PathBuf,
25 bytes: &'static [u8],
26}
27
28#[derive(Debug, Clone)]
35pub struct Layout {
36 pub roots: Vec<Utf8PathBuf>,
38 pub every_root: Vec<Utf8PathBuf>,
40 pub shared: Utf8PathBuf,
42 pub record: Utf8PathBuf,
44}
45
46fn plan_roots(roots: &[Utf8PathBuf]) -> Result<Vec<Planned>, AppError> {
48 let mut planned = Vec::new();
49 for root in roots {
50 for name in crate::embedded::skill_names() {
51 let text = crate::embedded::skill(name)
52 .ok_or_else(|| anyhow::anyhow!("payload skill missing: {name}"))?;
53 planned.push(Planned {
54 destination: root.join(name).join("SKILL.md"),
55 bytes: text.as_bytes(),
56 });
57 }
58 }
59 Ok(planned)
60}
61
62fn plan_shared(shared: &Utf8Path) -> Vec<Planned> {
64 crate::embedded::shared_artifacts()
65 .into_iter()
66 .map(|(path, bytes)| Planned {
67 destination: shared.join(path),
68 bytes,
69 })
70 .collect()
71}
72
73fn check_shared_root(shared: &Utf8Path, record: &Utf8Path) -> Result<(), AppError> {
82 let Some(state_dir) = record.parent() else {
83 return Ok(());
84 };
85 let mut current = Some(shared);
86 while let Some(dir) = current {
87 if !dir.starts_with(state_dir) {
88 break;
89 }
90 if dir.is_symlink() {
91 return Err(AppError::Refused(format!(
92 "the shared root is reached through a symlink: {dir}"
93 )));
94 }
95 current = dir.parent();
96 }
97 Ok(())
98}
99
100fn check_destination(destination: &Utf8Path) -> Result<(), AppError> {
101 if destination.is_symlink() {
102 return Err(AppError::Refused(format!(
103 "destination is a symlink: {destination}"
104 )));
105 }
106 if destination.exists() && !destination.is_file() {
107 return Err(AppError::Refused(format!(
108 "destination exists and is not a regular file: {destination}"
109 )));
110 }
111 Ok(())
112}
113
114fn conflicts(planned: &[Planned], record: &SkillRecord) -> Result<Vec<String>, AppError> {
121 let mut conflicts = Vec::new();
122 for entry in planned {
123 if !entry.destination.is_file() {
124 continue;
125 }
126 let found = std::fs::read(&entry.destination)?;
129 if found == entry.bytes {
130 continue;
131 }
132 if record.wrote(&entry.destination, &Sha256::of(&found)) {
133 continue;
134 }
135 conflicts.push(entry.destination.to_string());
136 }
137 Ok(conflicts)
138}
139
140fn leftovers(
148 roots: &[Utf8PathBuf],
149 record: &SkillRecord,
150 keep: &[Utf8PathBuf],
151) -> Vec<Utf8PathBuf> {
152 let kept: BTreeSet<&Utf8Path> = keep.iter().map(Utf8PathBuf::as_path).collect();
153 record
154 .written
155 .iter()
156 .filter(|(destination, digest)| {
157 !kept.contains(destination.as_path())
158 && roots.iter().any(|root| destination.starts_with(root))
159 && !destination.is_symlink()
160 && destination.is_file()
161 && std::fs::read(destination).is_ok_and(|found| Sha256::of(&found) == **digest)
162 })
163 .map(|(destination, _)| destination.clone())
164 .collect()
165}
166
167fn remove_installed(destination: &Utf8Path, lines: &mut Vec<String>) -> Result<(), AppError> {
170 std::fs::remove_file(destination)?;
171 let directory = destination
172 .parent()
173 .ok_or_else(|| anyhow::anyhow!("destination has no parent: {destination}"))?;
174 if std::fs::read_dir(directory)?.next().is_none() {
175 std::fs::remove_dir(directory)?;
176 } else {
177 lines.push(format!("kept (not empty): {directory}"));
178 }
179 Ok(())
180}
181
182fn rollback(backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>) -> Vec<Utf8PathBuf> {
190 let mut unrestored = Vec::new();
191 for (destination, previous) in backups {
192 let restored = previous.as_ref().map_or_else(
193 || !destination.exists() || std::fs::remove_file(destination).is_ok(),
194 |bytes| {
195 std::fs::read(destination).is_ok_and(|found| &found == bytes)
196 || crate::adapters::fs::write_file(destination, bytes).is_ok()
197 },
198 );
199 if !restored {
200 unrestored.push(destination.clone());
201 }
202 }
203 unrestored
204}
205
206fn abort(unrestored: &[Utf8PathBuf], cause: &str) -> AppError {
208 if unrestored.is_empty() {
209 AppError::Refused(format!(
210 "skill install aborted; the destinations were restored: {cause}"
211 ))
212 } else {
213 let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
214 AppError::Refused(format!(
215 "skill install aborted and restoration is incomplete; verify by hand: {}: {cause}",
216 paths.join(" ")
217 ))
218 }
219}
220
221pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<String>, AppError> {
235 let record_path = layout.record.as_path();
236 check_shared_root(&layout.shared, &layout.record)?;
237 let mut planned = plan_roots(&layout.roots)?;
238 planned.extend(plan_shared(&layout.shared));
239 let mut lines: Vec<String> = Vec::new();
240 for entry in &planned {
241 check_destination(&entry.destination)?;
242 lines.push(entry.destination.to_string());
243 }
244 let mut record = SkillRecord::load(record_path);
245 let kept: Vec<Utf8PathBuf> = planned
246 .iter()
247 .map(|entry| entry.destination.clone())
248 .collect();
249 let mut scanned = layout.roots.clone();
250 scanned.push(layout.shared.clone());
251 let stale = leftovers(&scanned, &record, &kept);
252 for destination in &stale {
253 lines.push(format!("sweep (no longer in the payload): {destination}"));
254 }
255 if !apply {
256 lines.push("DRY RUN: no files written".to_string());
257 return Ok(lines);
258 }
259
260 if !force {
261 let conflicts = conflicts(&planned, &record)?;
262 if !conflicts.is_empty() {
263 return Err(AppError::Refused(format!(
264 "destinations hold bytes this tool did not write: {}; re-run with --force to overwrite",
265 conflicts.join(", ")
266 )));
267 }
268 }
269
270 let mut backups: BTreeMap<Utf8PathBuf, Option<Vec<u8>>> = BTreeMap::new();
273 for entry in &planned {
274 let previous = if entry.destination.is_file() {
275 Some(std::fs::read(&entry.destination).map_err(|source| {
276 AppError::Refused(format!("cannot back up {}: {source}", entry.destination))
277 })?)
278 } else {
279 None
280 };
281 backups.insert(entry.destination.clone(), previous);
282 }
283
284 for entry in &planned {
285 if let Err(source) = crate::adapters::fs::write_file(&entry.destination, entry.bytes) {
286 return Err(abort(
287 &rollback(&backups),
288 &format!("writing {} failed: {source}", entry.destination),
289 ));
290 }
291 }
292
293 for destination in &stale {
297 if let Err(source) = remove_installed(destination, &mut lines) {
298 lines.push(format!(
299 "could not remove {destination}; remove it by hand: {source}"
300 ));
301 continue;
302 }
303 record.written.remove(destination);
304 }
305
306 for entry in &planned {
307 record
308 .written
309 .insert(entry.destination.clone(), Sha256::of(entry.bytes));
310 }
311 if crate::adapters::fs::write_file(record_path, record.to_json().as_bytes()).is_err() {
312 lines.push(format!(
313 "note: could not record the installed digests at {record_path}; a later install may ask for --force"
314 ));
315 }
316 Ok(lines)
317}
318
319pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<String>, AppError> {
331 let record_path = layout.record.as_path();
332 let mut lines: Vec<String> = Vec::new();
333 let mut removable: Vec<Utf8PathBuf> = Vec::new();
334 for root in &layout.roots {
335 for name in crate::embedded::skill_names() {
336 let destination = root.join(name).join("SKILL.md");
337 check_destination(&destination)?;
338 if destination.is_file() {
339 lines.push(destination.to_string());
340 removable.push(destination);
341 }
342 }
343 }
344 let going: BTreeSet<&Utf8Path> = removable.iter().map(Utf8PathBuf::as_path).collect();
349 let retained = plan_roots(&layout.every_root)?
350 .iter()
351 .any(|entry| !going.contains(entry.destination.as_path()) && entry.destination.is_file());
352 let mut scanned = layout.roots.clone();
353 if !retained {
354 check_shared_root(&layout.shared, &layout.record)?;
355 for entry in plan_shared(&layout.shared) {
356 check_destination(&entry.destination)?;
357 if entry.destination.is_file() {
358 lines.push(entry.destination.to_string());
359 removable.push(entry.destination);
360 }
361 }
362 scanned.push(layout.shared.clone());
363 }
364 let mut record = SkillRecord::load(record_path);
365 for destination in leftovers(&scanned, &record, &removable) {
369 lines.push(format!("sweep (no longer in the payload): {destination}"));
370 removable.push(destination);
371 }
372 if !apply {
373 lines.push("DRY RUN: no files removed".to_string());
374 return Ok(lines);
375 }
376 for destination in &removable {
377 remove_installed(destination, &mut lines)?;
378 }
379
380 for destination in &removable {
381 record.written.remove(destination);
382 }
383 let _ = if record.written.is_empty() {
384 std::fs::remove_file(record_path).map_err(|_| ())
385 } else {
386 crate::adapters::fs::write_file(record_path, record.to_json().as_bytes()).map_err(|_| ())
387 };
388 Ok(lines)
389}
390
391#[cfg(test)]
392mod tests {
393 #![allow(clippy::unwrap_used)]
394
395 use super::*;
396
397 fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
398 Utf8PathBuf::from(dir.path().to_str().unwrap())
399 }
400
401 fn home(dir: &tempfile::TempDir) -> Layout {
403 let home = root(dir);
404 let roots = vec![home.join(".agents/skills"), home.join(".claude/skills")];
405 Layout {
406 roots: roots.clone(),
407 every_root: roots,
408 shared: home.join(".local/state/spec-driven-docs/skills/shared"),
409 record: home.join(crate::domain::skill_record::RECORD_PATH),
410 }
411 }
412
413 fn select(layout: &Layout, index: usize) -> Layout {
415 Layout {
416 roots: vec![layout.roots[index].clone()],
417 ..layout.clone()
418 }
419 }
420
421 #[test]
422 fn a_preview_lists_every_destination_and_writes_nothing() {
423 let dir = tempfile::tempdir().unwrap();
424 let layout = home(&dir);
425 let (roots, record) = (layout.roots.clone(), layout.record.clone());
426 let lines = install(&layout, false, false).unwrap();
427 assert_eq!(lines.last().unwrap(), "DRY RUN: no files written");
428 assert_eq!(
429 lines.len(),
430 crate::embedded::skill_names().len() * 2
431 + crate::embedded::shared_artifacts().len()
432 + 1
433 );
434 assert!(!layout.shared.exists());
435 assert!(!roots[0].exists());
436 assert!(!record.exists());
437 }
438
439 #[test]
440 fn an_apply_is_idempotent_and_a_conflict_refuses_with_every_path() {
441 let dir = tempfile::tempdir().unwrap();
442 let layout = home(&dir);
443 let roots = layout.roots.clone();
444 install(&layout, true, false).unwrap();
445 install(&layout, true, false).unwrap();
446 for name in crate::embedded::skill_names() {
447 std::fs::write(roots[0].join(name).join("SKILL.md"), "edited").unwrap();
448 }
449 let error = install(&layout, true, false).unwrap_err();
450 let message = error.to_string();
451 for name in crate::embedded::skill_names() {
452 assert!(message.contains(name), "{message} misses {name}");
453 }
454 install(&layout, true, true).unwrap();
455 let text = std::fs::read_to_string(roots[0].join("sdd-setup/SKILL.md")).unwrap();
456 assert!(text.contains("name: sdd-setup"));
457 }
458
459 #[test]
463 fn a_copy_a_previous_release_wrote_is_replaced_without_force() {
464 let dir = tempfile::tempdir().unwrap();
465 let layout = home(&dir);
466 let (roots, record) = (layout.roots.clone(), layout.record.clone());
467 install(&layout, true, false).unwrap();
468
469 let mut stale = SkillRecord::load(&record);
472 for root in &roots {
473 for name in crate::embedded::skill_names() {
474 let destination = root.join(name).join("SKILL.md");
475 std::fs::write(&destination, "older canon bytes\n").unwrap();
476 stale
477 .written
478 .insert(destination, Sha256::of(b"older canon bytes\n"));
479 }
480 }
481 crate::adapters::fs::write_file(&record, stale.to_json().as_bytes()).unwrap();
482
483 install(&layout, true, false).unwrap();
484 let text = std::fs::read_to_string(roots[1].join("sdd-setup/SKILL.md")).unwrap();
485 assert!(text.contains("name: sdd-setup"));
486 }
487
488 #[test]
490 fn an_edit_still_refuses_when_a_sibling_is_merely_stale() {
491 let dir = tempfile::tempdir().unwrap();
492 let layout = home(&dir);
493 let (roots, record) = (layout.roots.clone(), layout.record.clone());
494 install(&layout, true, false).unwrap();
495
496 let stale_path = roots[0].join("sdd-setup/SKILL.md");
497 let edited_path = roots[1].join("sdd-setup/SKILL.md");
498 let mut stale = SkillRecord::load(&record);
499 std::fs::write(&stale_path, "older canon bytes\n").unwrap();
500 stale
501 .written
502 .insert(stale_path, Sha256::of(b"older canon bytes\n"));
503 crate::adapters::fs::write_file(&record, stale.to_json().as_bytes()).unwrap();
504 std::fs::write(&edited_path, "mine\n").unwrap();
505
506 let message = install(&layout, true, false).unwrap_err().to_string();
507 assert!(message.contains(edited_path.as_str()), "{message}");
508 assert!(!message.contains("older canon"), "{message}");
509 assert_eq!(std::fs::read_to_string(&edited_path).unwrap(), "mine\n");
510 }
511
512 #[test]
515 fn a_missing_record_treats_unknown_bytes_as_the_users() {
516 let dir = tempfile::tempdir().unwrap();
517 let layout = home(&dir);
518 let (roots, record) = (layout.roots.clone(), layout.record.clone());
519 install(&layout, true, false).unwrap();
520 std::fs::remove_file(&record).unwrap();
521 std::fs::write(roots[0].join("sdd-setup/SKILL.md"), "older canon bytes\n").unwrap();
522 let error = install(&layout, true, false).unwrap_err();
523 assert!(error.to_string().contains("sdd-setup"));
524 }
525
526 #[test]
532 fn a_write_that_fails_partway_restores_every_destination() {
533 let dir = tempfile::tempdir().unwrap();
534 let layout = home(&dir);
535 let roots = layout.roots.clone();
536 install(&layout, true, false).unwrap();
537
538 let mut before: Vec<(Utf8PathBuf, Vec<u8>)> = Vec::new();
543 for root in &roots {
544 for name in crate::embedded::skill_names() {
545 let path = root.join(name).join("SKILL.md");
546 std::fs::write(&path, format!("previous {name}\n")).unwrap();
547 before.push((path.clone(), std::fs::read(&path).unwrap()));
548 }
549 }
550 let blocked = roots[1].join("sdd-setup");
551 std::fs::remove_file(blocked.join("SKILL.md")).unwrap();
552 before.retain(|(path, _)| path.parent() != Some(blocked.as_path()));
553 let mut permissions = std::fs::metadata(&blocked).unwrap().permissions();
554 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o500);
555 std::fs::set_permissions(&blocked, permissions.clone()).unwrap();
556
557 let message = install(&layout, true, true).unwrap_err().to_string();
558 assert!(message.contains("skill install aborted"), "{message}");
559 assert!(
560 message.contains("the destinations were restored"),
561 "{message}"
562 );
563 assert!(message.contains(blocked.as_str()), "{message}");
564
565 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
566 std::fs::set_permissions(&blocked, permissions).unwrap();
567 for (path, bytes) in &before {
568 assert_eq!(
569 &std::fs::read(path).unwrap(),
570 bytes,
571 "{path} was not restored"
572 );
573 }
574 assert!(
575 !blocked.join("SKILL.md").exists(),
576 "a destination that did not exist before was left behind"
577 );
578 }
579
580 #[test]
584 fn a_recorded_destination_the_payload_dropped_is_swept_by_both_verbs() {
585 for sweep_with_uninstall in [false, true] {
586 let dir = tempfile::tempdir().unwrap();
587 let layout = home(&dir);
588 let (roots, record_path) = (layout.roots.clone(), layout.record.clone());
589 install(&layout, true, false).unwrap();
590
591 let dropped = roots[0].join("sdd-old-name/SKILL.md");
592 crate::adapters::fs::write_file(&dropped, b"older\n").unwrap();
593 let mut record = SkillRecord::load(&record_path);
594 record
595 .written
596 .insert(dropped.clone(), Sha256::of(b"older\n"));
597 crate::adapters::fs::write_file(&record_path, record.to_json().as_bytes()).unwrap();
598
599 if sweep_with_uninstall {
600 uninstall(&layout, true).unwrap();
601 } else {
602 install(&layout, true, false).unwrap();
603 }
604 assert!(!dropped.exists(), "the leftover file survived");
605 assert!(
606 !roots[0].join("sdd-old-name").exists(),
607 "the leftover directory survived"
608 );
609 assert!(
610 !SkillRecord::load(&record_path)
611 .written
612 .contains_key(&dropped)
613 );
614 }
615 }
616
617 #[test]
620 fn an_edited_leftover_is_left_where_it_is() {
621 let dir = tempfile::tempdir().unwrap();
622 let layout = home(&dir);
623 let (roots, record_path) = (layout.roots.clone(), layout.record.clone());
624 install(&layout, true, false).unwrap();
625
626 let dropped = roots[0].join("sdd-old-name/SKILL.md");
627 crate::adapters::fs::write_file(&dropped, b"older\n").unwrap();
628 let mut record = SkillRecord::load(&record_path);
629 record
630 .written
631 .insert(dropped.clone(), Sha256::of(b"older\n"));
632 crate::adapters::fs::write_file(&record_path, record.to_json().as_bytes()).unwrap();
633 crate::adapters::fs::write_file(&dropped, b"mine\n").unwrap();
634
635 install(&layout, true, false).unwrap();
636 assert_eq!(std::fs::read(&dropped).unwrap(), b"mine\n");
637 uninstall(&layout, true).unwrap();
638 assert_eq!(std::fs::read(&dropped).unwrap(), b"mine\n");
639 }
640
641 #[test]
645 fn a_root_that_refuses_every_write_reports_a_clean_restore() {
646 let dir = tempfile::tempdir().unwrap();
647 let layout = home(&dir);
648 let roots = layout.roots.clone();
649 install(&layout, true, false).unwrap();
650
651 let mut locked = Vec::new();
654 for name in crate::embedded::skill_names() {
655 let destination = roots[0].join(name).join("SKILL.md");
656 let mut permissions = std::fs::metadata(&destination).unwrap().permissions();
657 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o444);
658 std::fs::set_permissions(&destination, permissions).unwrap();
659 locked.push(destination);
660 }
661
662 let message = install(&layout, true, true).unwrap_err().to_string();
663
664 for destination in &locked {
665 let mut permissions = std::fs::metadata(destination).unwrap().permissions();
666 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o644);
667 std::fs::set_permissions(destination, permissions).unwrap();
668 }
669
670 assert!(
671 message.contains("the destinations were restored"),
672 "{message}"
673 );
674 assert!(!message.contains("restoration is incomplete"), "{message}");
675 }
676
677 #[test]
678 fn an_uninstall_removes_only_payload_files_and_keeps_foreign_ones() {
679 let dir = tempfile::tempdir().unwrap();
680 let layout = home(&dir);
681 let (roots, record) = (layout.roots.clone(), layout.record.clone());
682 install(&layout, true, false).unwrap();
683 std::fs::write(roots[1].join("sdd-setup/notes.md"), "mine").unwrap();
684
685 let preview = uninstall(&layout, false).unwrap();
686 assert_eq!(preview.last().unwrap(), "DRY RUN: no files removed");
687 assert!(roots[1].join("sdd-setup/SKILL.md").is_file());
688
689 let lines = uninstall(&layout, true).unwrap();
690 assert!(!roots[1].join("sdd-setup/SKILL.md").exists());
691 assert!(!roots[1].join("sdd-write-docs").exists());
692 assert_eq!(
693 std::fs::read_to_string(roots[1].join("sdd-setup/notes.md")).unwrap(),
694 "mine"
695 );
696 assert!(
697 lines
698 .iter()
699 .any(|line| line.starts_with("kept (not empty):"))
700 );
701 assert!(
702 !record.exists(),
703 "the record outlived every file it vouched for"
704 );
705
706 uninstall(&layout, true).unwrap();
708 }
709
710 #[test]
713 fn an_uninstall_of_one_root_keeps_the_others_entries() {
714 let dir = tempfile::tempdir().unwrap();
715 let layout = home(&dir);
716 let (roots, record) = (layout.roots.clone(), layout.record.clone());
717 install(&layout, true, false).unwrap();
718 uninstall(&select(&layout, 1), true).unwrap();
719 let kept = SkillRecord::load(&record);
720 assert!(
721 kept.written
722 .keys()
723 .all(|path| path.starts_with(&roots[0]) || path.starts_with(&layout.shared))
724 );
725 assert!(kept.written.keys().any(|path| path.starts_with(&roots[0])));
726 assert!(
727 kept.written
728 .keys()
729 .any(|path| path.starts_with(&layout.shared))
730 );
731 }
732
733 #[test]
736 fn either_agent_alone_still_lands_the_shared_artifacts() {
737 for index in 0..2 {
738 let dir = tempfile::tempdir().unwrap();
739 let layout = home(&dir);
740 install(&select(&layout, index), true, false).unwrap();
741 assert!(layout.shared.join("plan-gate.md").is_file());
742 }
743 }
744
745 #[test]
748 fn the_shared_artifacts_stay_while_another_root_still_holds_skills() {
749 let dir = tempfile::tempdir().unwrap();
750 let layout = home(&dir);
751 install(&layout, true, false).unwrap();
752 uninstall(&select(&layout, 1), true).unwrap();
753 assert!(layout.shared.join("plan-gate.md").is_file());
754 uninstall(&select(&layout, 0), true).unwrap();
755 assert!(!layout.shared.exists());
756 assert!(!layout.record.exists());
757 }
758
759 #[test]
762 fn the_last_uninstall_previews_the_shared_artifacts() {
763 let dir = tempfile::tempdir().unwrap();
764 let layout = home(&dir);
765 install(&layout, true, false).unwrap();
766 let lines = uninstall(&layout, false).unwrap();
767 let shared = layout.shared.join("plan-gate.md");
768 assert!(lines.iter().any(|line| line == shared.as_str()));
769 assert!(shared.is_file());
770 }
771
772 #[test]
775 fn an_edited_shared_artifact_refuses_an_install() {
776 let dir = tempfile::tempdir().unwrap();
777 let layout = home(&dir);
778 install(&layout, true, false).unwrap();
779 let shared = layout.shared.join("plan-gate.md");
780 std::fs::write(&shared, "mine\n").unwrap();
781 let message = install(&layout, true, false).unwrap_err().to_string();
782 assert!(message.contains(shared.as_str()), "{message}");
783 install(&layout, true, true).unwrap();
784 assert!(
785 std::fs::read_to_string(&shared)
786 .unwrap()
787 .contains("# The plan gate")
788 );
789 }
790
791 #[test]
795 fn a_symlinked_shared_root_refuses_install_and_uninstall() {
796 for symlinked in ["skills", "skills/shared"] {
797 let dir = tempfile::tempdir().unwrap();
798 let layout = home(&dir);
799 let elsewhere = root(&dir).join("elsewhere");
800 std::fs::create_dir_all(&elsewhere).unwrap();
801 let state_dir = layout.record.parent().unwrap();
802 let linked = state_dir.join(symlinked);
803 std::fs::create_dir_all(linked.parent().unwrap()).unwrap();
804 std::os::unix::fs::symlink(&elsewhere, &linked).unwrap();
805
806 let message = install(&layout, true, false).unwrap_err().to_string();
807 assert!(message.contains("symlink"), "{message}");
808 assert!(!elsewhere.join("plan-gate.md").exists());
809 assert!(!layout.roots[0].exists());
810
811 std::fs::write(elsewhere.join("plan-gate.md"), "theirs\n").unwrap();
812 let message = uninstall(&layout, true).unwrap_err().to_string();
813 assert!(message.contains("symlink"), "{message}");
814 assert!(elsewhere.join("plan-gate.md").exists());
815 }
816 }
817
818 #[test]
820 fn a_symlinked_shared_destination_refuses_before_writing() {
821 let dir = tempfile::tempdir().unwrap();
822 let layout = home(&dir);
823 std::fs::create_dir_all(&layout.shared).unwrap();
824 let target = root(&dir).join("elsewhere.md");
825 std::fs::write(&target, "x").unwrap();
826 std::os::unix::fs::symlink(&target, layout.shared.join("plan-gate.md")).unwrap();
827 let message = install(&layout, true, false).unwrap_err().to_string();
828 assert!(message.contains("symlink"), "{message}");
829 assert!(!layout.roots[0].exists());
830 }
831}