1use crate::layout::Layout;
14use crate::lock::Lock;
15use crate::model::{Entry, LinkSupport, LinkTarget, NodeKind, ResourceKind, Strategy, Via};
16use crate::path::RelPath;
17use crate::provider::{Capability, Provider};
18use crate::workspace::{FsResult, Workspace};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Outcome {
23 Native,
25 UpToDate { via: Via },
27 Create { via: Via },
29 Rewrite { via: Via },
31 Relink { via: Via, current: LinkTarget },
33 Adopt { via: Via },
36 Retire { via: Via },
39 Skip(Skip),
41 Blocked(Blocked),
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Skip {
48 CanonicalMissing,
50 Unmanaged,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum Blocked {
58 NeedsAdopt,
61 TargetOccupied,
64 ForeignLink { current: LinkTarget },
66 Unsupported { node: NodeKind },
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Step {
74 pub provider_id: String,
75 pub provider_name: String,
76 pub resource: ResourceKind,
77 pub canonical: RelPath,
78 pub target: RelPath,
79 pub outcome: Outcome,
80 pub note: Option<String>,
81 pub import_body: Option<String>,
87}
88
89impl Step {
90 pub fn is_write(&self) -> bool {
96 matches!(
97 self.outcome,
98 Outcome::Create { .. }
99 | Outcome::Rewrite { .. }
100 | Outcome::Relink { .. }
101 | Outcome::Adopt { .. }
102 )
103 }
104
105 pub fn is_removal(&self) -> bool {
107 matches!(self.outcome, Outcome::Retire { .. })
108 }
109
110 pub fn is_blocked(&self) -> bool {
111 matches!(self.outcome, Outcome::Blocked(_))
112 }
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct Plan {
118 pub steps: Vec<Step>,
119}
120
121impl Plan {
122 pub fn writes(&self) -> impl Iterator<Item = &Step> {
124 self.steps.iter().filter(|step| step.is_write())
125 }
126
127 pub fn blocked(&self) -> impl Iterator<Item = &Step> {
129 self.steps.iter().filter(|step| step.is_blocked())
130 }
131
132 pub fn removals(&self) -> impl Iterator<Item = &Step> {
134 self.steps.iter().filter(|step| step.is_removal())
135 }
136
137 pub fn free(&self) -> usize {
140 self.steps
141 .iter()
142 .filter(|step| matches!(step.outcome, Outcome::Native | Outcome::UpToDate { .. }))
143 .count()
144 }
145
146 pub fn linked(&self) -> usize {
149 self.steps
150 .iter()
151 .filter(|step| match step.outcome {
152 Outcome::UpToDate { via }
153 | Outcome::Create { via }
154 | Outcome::Relink { via, .. }
155 | Outcome::Adopt { via } => via.is_link(),
156 _ => false,
157 })
158 .count()
159 }
160
161 pub fn is_clean(&self) -> bool {
162 self.steps
163 .iter()
164 .all(|step| !step.is_write() && !step.is_removal() && !step.is_blocked())
165 }
166}
167
168#[derive(Debug)]
170pub struct Planner<'a> {
171 layout: &'a Layout,
172 lock: &'a Lock,
173 support: LinkSupport,
174 adopt: bool,
175}
176
177impl<'a> Planner<'a> {
178 pub fn new(layout: &'a Layout, lock: &'a Lock, support: LinkSupport) -> Self {
179 Self {
180 layout,
181 lock,
182 support,
183 adopt: false,
184 }
185 }
186
187 #[must_use]
191 pub fn with_adopt(mut self, adopt: bool) -> Self {
192 self.adopt = adopt;
193 self
194 }
195
196 pub fn plan(&self, providers: &[&Provider], ws: &dyn Workspace) -> FsResult<Plan> {
197 let mut steps = Vec::new();
198 for provider in providers {
199 for &resource in ResourceKind::ALL {
200 let Some(capability) = provider.capability(resource) else {
201 continue;
202 };
203 steps.push(self.step(provider, capability, ws)?);
204 }
205 }
206 steps.sort_by(|a, b| {
207 a.resource
208 .cmp(&b.resource)
209 .then_with(|| a.provider_id.cmp(&b.provider_id))
210 });
211 Ok(Plan { steps })
212 }
213
214 fn step(
215 &self,
216 provider: &Provider,
217 capability: &Capability,
218 ws: &dyn Workspace,
219 ) -> FsResult<Step> {
220 let resource = capability.resource;
221 let canonical = self.layout.canonical(resource).clone();
222 let outcome = self.decide(capability, &canonical, ws)?;
223 Ok(Step {
224 provider_id: provider.id.clone(),
225 provider_name: provider.name.clone(),
226 resource,
227 canonical: canonical.clone(),
228 target: capability.path.clone(),
229 outcome,
230 note: capability.note.clone(),
231 import_body: capability.import_body(&canonical),
232 })
233 }
234
235 fn decide(
236 &self,
237 capability: &Capability,
238 canonical: &RelPath,
239 ws: &dyn Workspace,
240 ) -> FsResult<Outcome> {
241 let node = capability.resource.node();
242 let canonical_entry = ws.probe(canonical)?;
243
244 if capability.strategy == Strategy::Native {
247 return Ok(match canonical_entry {
248 Some(_) => Outcome::Native,
249 None => Outcome::Skip(Skip::CanonicalMissing),
250 });
251 }
252
253 let Some(via) = self.resolve_via(capability, node) else {
254 return Ok(Outcome::Blocked(Blocked::Unsupported { node }));
255 };
256
257 let target_entry = ws.probe(&capability.path)?;
258
259 match (canonical_entry, target_entry) {
260 (None, Some(target)) if target.is_concrete() => Ok(self.adoption(via)),
264
265 (None, _) => Ok(Outcome::Skip(Skip::CanonicalMissing)),
268
269 (Some(_), None) => Ok(Outcome::Create { via }),
270
271 (Some(_), Some(target)) => self.reconcile(capability, canonical, via, &target, ws),
272 }
273 }
274
275 fn reconcile(
276 &self,
277 capability: &Capability,
278 canonical: &RelPath,
279 via: Via,
280 target: &Entry,
281 ws: &dyn Workspace,
282 ) -> FsResult<Outcome> {
283 if via == Via::Import {
284 return self.reconcile_import(capability, canonical, target, ws);
285 }
286
287 match &target.link {
288 Some(LinkTarget::Inside(actual)) if actual == canonical => {
293 Ok(Outcome::UpToDate { via })
294 }
295 Some(current) => Ok(if self.lock.owns(&capability.path) {
296 Outcome::Relink {
297 via,
298 current: current.clone(),
299 }
300 } else {
301 Outcome::Blocked(Blocked::ForeignLink {
302 current: current.clone(),
303 })
304 }),
305 None => {
308 if Self::canonical_is_free(canonical, ws)? {
309 Ok(self.adoption(via))
310 } else {
311 Ok(Outcome::Blocked(Blocked::TargetOccupied))
312 }
313 }
314 }
315 }
316
317 fn reconcile_import(
318 &self,
319 capability: &Capability,
320 canonical: &RelPath,
321 target: &Entry,
322 ws: &dyn Workspace,
323 ) -> FsResult<Outcome> {
324 let expected = capability.import_body(canonical).unwrap_or_default();
325
326 if !target.is_concrete() {
328 return Ok(match &target.link {
329 Some(current) => Outcome::Blocked(Blocked::ForeignLink {
330 current: current.clone(),
331 }),
332 None => Outcome::Blocked(Blocked::TargetOccupied),
333 });
334 }
335
336 let actual = ws.read(&capability.path)?;
337 if actual == expected {
338 return Ok(Outcome::UpToDate { via: Via::Import });
339 }
340 Ok(if self.lock.owns(&capability.path) {
343 Outcome::Rewrite { via: Via::Import }
344 } else if Self::canonical_is_free(canonical, ws)? {
345 self.adoption(Via::Import)
346 } else {
347 Outcome::Blocked(Blocked::TargetOccupied)
348 })
349 }
350
351 fn canonical_is_free(canonical: &RelPath, ws: &dyn Workspace) -> FsResult<bool> {
354 Ok(match ws.probe(canonical)? {
355 None => true,
356 Some(entry) if entry.node == NodeKind::Dir && entry.is_concrete() => {
357 ws.is_empty_dir(canonical)?
358 }
359 Some(_) => false,
360 })
361 }
362
363 fn adoption(&self, via: Via) -> Outcome {
364 if self.adopt {
365 Outcome::Adopt { via }
366 } else {
367 Outcome::Blocked(Blocked::NeedsAdopt)
368 }
369 }
370
371 fn resolve_via(&self, capability: &Capability, node: NodeKind) -> Option<Via> {
374 match capability.strategy {
375 Strategy::Native => None,
376 Strategy::Import => Some(Via::Import),
377 Strategy::Link => match self.support.best_for(node) {
378 Some(via) => Some(via),
379 None if capability.has_import_fallback() => Some(Via::Import),
383 None => None,
384 },
385 }
386 }
387}
388
389pub fn retire(
402 lock: &Lock,
403 selected: &[&Provider],
404 registry: &crate::registry::Registry,
405 ws: &dyn Workspace,
406) -> FsResult<Vec<Step>> {
407 let mut steps = Vec::new();
408 for entry in &lock.entries {
409 if selected.iter().any(|p| p.id == entry.provider) {
410 continue;
411 }
412
413 let outcome = match ws.probe(&entry.target)? {
414 Some(found) if still_ours(entry, &found, registry, ws)? => {
415 Outcome::Retire { via: entry.via }
416 }
417 _ => Outcome::Skip(Skip::Unmanaged),
420 };
421
422 steps.push(Step {
423 provider_id: entry.provider.clone(),
424 provider_name: registry
425 .get(&entry.provider)
426 .map_or_else(|| entry.provider.clone(), |p| p.name.clone()),
427 resource: entry.resource,
428 canonical: entry.canonical.clone(),
429 target: entry.target.clone(),
430 outcome,
431 note: None,
432 import_body: None,
433 });
434 }
435
436 steps.sort_by(|a, b| {
437 a.resource
438 .cmp(&b.resource)
439 .then_with(|| a.provider_id.cmp(&b.provider_id))
440 });
441 Ok(steps)
442}
443
444fn still_ours(
446 entry: &crate::lock::LockEntry,
447 found: &Entry,
448 registry: &crate::registry::Registry,
449 ws: &dyn Workspace,
450) -> FsResult<bool> {
451 if entry.via.is_link() {
452 return Ok(found.link.is_some());
453 }
454 if !found.is_concrete() {
455 return Ok(false);
456 }
457 let expected = registry
462 .get(&entry.provider)
463 .and_then(|provider| provider.capability(entry.resource))
464 .and_then(|capability| capability.import_body(&entry.canonical));
465 Ok(match expected {
466 Some(expected) => ws.read(&entry.target)? == expected,
467 None => false,
468 })
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use crate::lock::LockEntry;
475 use crate::testing::FakeWorkspace;
476
477 fn rel(s: &str) -> RelPath {
478 RelPath::new(s).unwrap()
479 }
480
481 fn provider(toml_text: &str) -> Provider {
482 let layout = Layout::default();
483 crate::provider::parse("test.toml", toml_text, |kind| {
484 layout.canonical(kind).clone()
485 })
486 .expect("valid manifest")
487 }
488
489 fn claude() -> Provider {
490 provider(
491 r#"
492 schema = 1
493 id = "claude-code"
494 name = "Claude Code"
495
496 [[capability]]
497 resource = "instructions"
498 strategy = "link"
499 path = "CLAUDE.md"
500
501 [capability.fallback]
502 strategy = "import"
503 template = "@{canonical}\n"
504
505 [[capability]]
506 resource = "skills"
507 strategy = "link"
508 path = ".claude/skills"
509 "#,
510 )
511 }
512
513 fn antigravity() -> Provider {
514 provider(
515 r#"
516 schema = 1
517 id = "antigravity"
518 name = "Google Antigravity"
519
520 [[capability]]
521 resource = "instructions"
522 strategy = "native"
523 path = "AGENTS.md"
524
525 [[capability]]
526 resource = "skills"
527 strategy = "native"
528 path = ".agents/skills"
529 "#,
530 )
531 }
532
533 fn outcome_for(plan: &Plan, provider: &str, resource: ResourceKind) -> Outcome {
534 plan.steps
535 .iter()
536 .find(|step| step.provider_id == provider && step.resource == resource)
537 .unwrap_or_else(|| panic!("no step for {provider}/{resource}"))
538 .outcome
539 .clone()
540 }
541
542 fn plan_with(ws: &FakeWorkspace, lock: &Lock, providers: &[&Provider]) -> Plan {
543 let layout = Layout::default();
544 Planner::new(&layout, lock, ws.support())
545 .plan(providers, ws)
546 .expect("planning")
547 }
548
549 #[test]
550 fn a_tool_reading_the_canonical_path_costs_nothing() {
551 let ws = FakeWorkspace::unix();
552 ws.add_file("AGENTS.md", "# rules");
553 ws.add_dir(".agents/skills");
554
555 let plan = plan_with(&ws, &Lock::default(), &[&antigravity()]);
556
557 assert_eq!(
558 outcome_for(&plan, "antigravity", ResourceKind::Instructions),
559 Outcome::Native
560 );
561 assert_eq!(
562 outcome_for(&plan, "antigravity", ResourceKind::Skills),
563 Outcome::Native
564 );
565 assert_eq!(plan.writes().count(), 0);
567 assert_eq!(plan.free(), 2);
568 }
569
570 #[test]
571 fn missing_canonical_content_is_skipped_not_invented() {
572 let ws = FakeWorkspace::unix();
573 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
574
575 assert_eq!(
576 outcome_for(&plan, "claude-code", ResourceKind::Skills),
577 Outcome::Skip(Skip::CanonicalMissing)
578 );
579 assert_eq!(plan.writes().count(), 0);
580 }
581
582 #[test]
583 fn creates_symlinks_on_a_host_that_supports_them() {
584 let ws = FakeWorkspace::unix();
585 ws.add_file("AGENTS.md", "# rules");
586 ws.add_dir(".agents/skills");
587
588 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
589
590 assert_eq!(
591 outcome_for(&plan, "claude-code", ResourceKind::Instructions),
592 Outcome::Create { via: Via::Symlink }
593 );
594 assert_eq!(
595 outcome_for(&plan, "claude-code", ResourceKind::Skills),
596 Outcome::Create { via: Via::Symlink }
597 );
598 assert_eq!(plan.linked(), 2);
599 }
600
601 #[test]
602 fn windows_without_privileges_junctions_directories_and_stubs_files() {
603 let ws = FakeWorkspace::windows_unprivileged();
607 ws.add_file("AGENTS.md", "# rules");
608 ws.add_dir(".agents/skills");
609
610 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
611
612 assert_eq!(
613 outcome_for(&plan, "claude-code", ResourceKind::Skills),
614 Outcome::Create { via: Via::Junction }
615 );
616 assert_eq!(
617 outcome_for(&plan, "claude-code", ResourceKind::Instructions),
618 Outcome::Create { via: Via::Import }
619 );
620 }
621
622 #[test]
623 fn a_provider_without_a_fallback_is_reported_unsupported_not_silently_dropped() {
624 let no_fallback = provider(
625 r#"
626 schema = 1
627 id = "strict"
628 name = "Strict"
629
630 [[capability]]
631 resource = "instructions"
632 strategy = "link"
633 path = "STRICT.md"
634 "#,
635 );
636 let ws = FakeWorkspace::windows_unprivileged();
637 ws.add_file("AGENTS.md", "# rules");
638
639 let plan = plan_with(&ws, &Lock::default(), &[&no_fallback]);
640
641 assert_eq!(
642 outcome_for(&plan, "strict", ResourceKind::Instructions),
643 Outcome::Blocked(Blocked::Unsupported {
644 node: NodeKind::File
645 })
646 );
647 }
648
649 #[test]
650 fn an_existing_correct_link_is_left_alone() {
651 let ws = FakeWorkspace::unix();
652 ws.add_file("AGENTS.md", "# rules");
653 ws.add_dir(".agents/skills");
654 ws.add_link(".claude/skills", NodeKind::Dir, ".agents/skills");
655 ws.add_link("CLAUDE.md", NodeKind::File, "AGENTS.md");
656
657 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
658
659 assert_eq!(
660 outcome_for(&plan, "claude-code", ResourceKind::Skills),
661 Outcome::UpToDate { via: Via::Symlink }
662 );
663 assert!(plan.is_clean());
664 assert_eq!(plan.writes().count(), 0);
667 }
668
669 #[test]
670 fn a_foreign_link_is_never_repointed_without_asking() {
671 let ws = FakeWorkspace::unix();
672 ws.add_file("AGENTS.md", "# rules");
673 ws.add_dir(".agents/skills");
674 ws.add_dir("somewhere/else");
675 ws.add_link(".claude/skills", NodeKind::Dir, "somewhere/else");
676
677 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
678
679 assert_eq!(
680 outcome_for(&plan, "claude-code", ResourceKind::Skills),
681 Outcome::Blocked(Blocked::ForeignLink {
682 current: LinkTarget::Inside(rel("somewhere/else"))
683 })
684 );
685 }
686
687 #[test]
688 fn a_link_we_created_is_repointed_freely() {
689 let ws = FakeWorkspace::unix();
690 ws.add_file("AGENTS.md", "# rules");
691 ws.add_dir(".agents/skills");
692 ws.add_dir("old/skills");
693 ws.add_link(".claude/skills", NodeKind::Dir, "old/skills");
694
695 let mut lock = Lock::default();
696 lock.record(LockEntry {
697 provider: "claude-code".into(),
698 resource: ResourceKind::Skills,
699 target: rel(".claude/skills"),
700 canonical: rel("old/skills"),
701 via: Via::Symlink,
702 });
703
704 let plan = plan_with(&ws, &lock, &[&claude()]);
705
706 assert_eq!(
707 outcome_for(&plan, "claude-code", ResourceKind::Skills),
708 Outcome::Relink {
709 via: Via::Symlink,
710 current: LinkTarget::Inside(rel("old/skills"))
711 }
712 );
713 }
714
715 #[test]
716 fn existing_provider_content_asks_before_moving_anything() {
717 let ws = FakeWorkspace::unix();
719 ws.add_dir(".claude/skills");
720 ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
721
722 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
723
724 assert_eq!(
726 outcome_for(&plan, "claude-code", ResourceKind::Skills),
727 Outcome::Blocked(Blocked::NeedsAdopt)
728 );
729 }
730
731 #[test]
732 fn adoption_moves_content_into_the_canonical_location_when_asked() {
733 let ws = FakeWorkspace::unix();
734 ws.add_dir(".claude/skills");
735 ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
736
737 let layout = Layout::default();
738 let lock = Lock::default();
739 let plan = Planner::new(&layout, &lock, ws.support())
740 .with_adopt(true)
741 .plan(&[&claude()], &ws)
742 .expect("planning");
743
744 assert_eq!(
745 outcome_for(&plan, "claude-code", ResourceKind::Skills),
746 Outcome::Adopt { via: Via::Symlink }
747 );
748 }
749
750 #[test]
751 fn content_on_both_sides_is_a_merge_only_a_human_can_do() {
752 let ws = FakeWorkspace::unix();
753 ws.add_dir(".agents/skills");
754 ws.add_file(".agents/skills/deploy/SKILL.md", "---\nname: deploy\n---\n");
755 ws.add_dir(".claude/skills");
756 ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
757
758 let layout = Layout::default();
759 let lock = Lock::default();
760 let plan = Planner::new(&layout, &lock, ws.support())
762 .with_adopt(true)
763 .plan(&[&claude()], &ws)
764 .expect("planning");
765
766 assert_eq!(
767 outcome_for(&plan, "claude-code", ResourceKind::Skills),
768 Outcome::Blocked(Blocked::TargetOccupied)
769 );
770 }
771
772 #[test]
773 fn an_empty_canonical_directory_still_accepts_adoption() {
774 let ws = FakeWorkspace::unix();
775 ws.add_dir(".agents/skills");
776 ws.add_dir(".claude/skills");
777 ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
778
779 let layout = Layout::default();
780 let lock = Lock::default();
781 let plan = Planner::new(&layout, &lock, ws.support())
782 .with_adopt(true)
783 .plan(&[&claude()], &ws)
784 .expect("planning");
785
786 assert_eq!(
787 outcome_for(&plan, "claude-code", ResourceKind::Skills),
788 Outcome::Adopt { via: Via::Symlink }
789 );
790 }
791
792 #[test]
793 fn a_correct_import_stub_is_up_to_date() {
794 let ws = FakeWorkspace::windows_unprivileged();
795 ws.add_file("AGENTS.md", "# rules");
796 ws.add_file("CLAUDE.md", "@AGENTS.md\n");
797
798 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
799
800 assert_eq!(
801 outcome_for(&plan, "claude-code", ResourceKind::Instructions),
802 Outcome::UpToDate { via: Via::Import }
803 );
804 }
805
806 #[test]
807 fn a_stale_stub_we_own_is_rewritten() {
808 let ws = FakeWorkspace::windows_unprivileged();
809 ws.add_file("AGENTS.md", "# rules");
810 ws.add_file("CLAUDE.md", "@OLD.md\n");
811
812 let mut lock = Lock::default();
813 lock.record(LockEntry {
814 provider: "claude-code".into(),
815 resource: ResourceKind::Instructions,
816 target: rel("CLAUDE.md"),
817 canonical: rel("AGENTS.md"),
818 via: Via::Import,
819 });
820
821 let plan = plan_with(&ws, &lock, &[&claude()]);
822
823 assert_eq!(
824 outcome_for(&plan, "claude-code", ResourceKind::Instructions),
825 Outcome::Rewrite { via: Via::Import }
826 );
827 }
828
829 #[test]
830 fn a_handwritten_claude_md_is_never_silently_replaced_by_a_stub() {
831 let ws = FakeWorkspace::windows_unprivileged();
834 ws.add_file("AGENTS.md", "# shared rules");
835 ws.add_file("CLAUDE.md", "# my carefully written Claude instructions");
836
837 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
838
839 assert_eq!(
840 outcome_for(&plan, "claude-code", ResourceKind::Instructions),
841 Outcome::Blocked(Blocked::TargetOccupied)
842 );
843 }
844
845 #[test]
846 fn steps_are_ordered_deterministically() {
847 let ws = FakeWorkspace::unix();
848 ws.add_file("AGENTS.md", "# rules");
849 ws.add_dir(".agents/skills");
850
851 let plan = plan_with(&ws, &Lock::default(), &[&claude(), &antigravity()]);
852
853 let order: Vec<_> = plan
854 .steps
855 .iter()
856 .map(|step| (step.resource, step.provider_id.as_str()))
857 .collect();
858 assert_eq!(
859 order,
860 [
861 (ResourceKind::Instructions, "antigravity"),
862 (ResourceKind::Instructions, "claude-code"),
863 (ResourceKind::Skills, "antigravity"),
864 (ResourceKind::Skills, "claude-code"),
865 ]
866 );
867 }
868
869 fn registry() -> crate::registry::Registry {
870 crate::registry::Registry::builtin(&Layout::default()).expect("shipped manifests")
871 }
872
873 fn locked(provider: &str, resource: ResourceKind, target: &str, via: Via) -> Lock {
874 let mut lock = Lock::default();
875 lock.record(LockEntry {
876 provider: provider.to_string(),
877 resource,
878 target: rel(target),
879 canonical: Layout::default().canonical(resource).clone(),
880 via,
881 });
882 lock
883 }
884
885 fn retire_with(ws: &FakeWorkspace, lock: &Lock, selected: &[&str]) -> Vec<Step> {
886 let registry = registry();
887 let providers: Vec<&Provider> = selected
888 .iter()
889 .map(|id| registry.get(id).expect("known provider"))
890 .collect();
891 retire(lock, &providers, ®istry, ws).expect("planning")
892 }
893
894 #[test]
895 fn a_deselected_providers_link_is_retired() {
896 let ws = FakeWorkspace::unix();
897 ws.add_dir(".agents/skills");
898 ws.add_link(".cursor/skills", NodeKind::Dir, ".agents/skills");
899 let lock = locked(
900 "cursor",
901 ResourceKind::Skills,
902 ".cursor/skills",
903 Via::Symlink,
904 );
905
906 let steps = retire_with(&ws, &lock, &["claude-code"]);
907
908 assert_eq!(steps.len(), 1);
909 assert_eq!(steps[0].target.as_str(), ".cursor/skills");
910 assert_eq!(steps[0].outcome, Outcome::Retire { via: Via::Symlink });
911 }
912
913 #[test]
914 fn a_still_selected_provider_is_never_retired() {
915 let ws = FakeWorkspace::unix();
916 ws.add_dir(".agents/skills");
917 ws.add_link(".cursor/skills", NodeKind::Dir, ".agents/skills");
918 let lock = locked(
919 "cursor",
920 ResourceKind::Skills,
921 ".cursor/skills",
922 Via::Symlink,
923 );
924
925 assert!(retire_with(&ws, &lock, &["cursor", "claude-code"]).is_empty());
926 }
927
928 #[test]
929 fn a_deselected_path_the_user_took_over_is_never_removed() {
930 let ws = FakeWorkspace::unix();
933 ws.add_dir(".agents/skills");
934 ws.add_file(".cursor/skills/deploy/SKILL.md", "---\nname: deploy\n---\n");
935 let lock = locked(
936 "cursor",
937 ResourceKind::Skills,
938 ".cursor/skills",
939 Via::Symlink,
940 );
941
942 let steps = retire_with(&ws, &lock, &["claude-code"]);
943
944 assert_eq!(steps[0].outcome, Outcome::Skip(Skip::Unmanaged));
945 }
946
947 #[test]
948 fn a_deselected_import_stub_is_retired_only_while_it_still_says_what_we_wrote() {
949 let ws = FakeWorkspace::unix();
950 ws.add_file("AGENTS.md", "# rules\n");
951 ws.add_file("CLAUDE.md", "@AGENTS.md\n");
952 let lock = locked(
953 "claude-code",
954 ResourceKind::Instructions,
955 "CLAUDE.md",
956 Via::Import,
957 );
958
959 let steps = retire_with(&ws, &lock, &["antigravity"]);
960 assert_eq!(steps[0].outcome, Outcome::Retire { via: Via::Import });
961
962 ws.add_file("CLAUDE.md", "@AGENTS.md\n\nAlso: never force push.\n");
963 let steps = retire_with(&ws, &lock, &["antigravity"]);
964 assert_eq!(steps[0].outcome, Outcome::Skip(Skip::Unmanaged));
965 }
966
967 #[test]
968 fn a_claim_on_something_already_gone_is_dropped_rather_than_repeated() {
969 let ws = FakeWorkspace::unix();
970 let lock = locked(
971 "cursor",
972 ResourceKind::Skills,
973 ".cursor/skills",
974 Via::Symlink,
975 );
976
977 let steps = retire_with(&ws, &lock, &["claude-code"]);
978
979 assert_eq!(steps[0].outcome, Outcome::Skip(Skip::Unmanaged));
980 }
981
982 #[test]
983 fn a_retirement_is_a_removal_not_a_write() {
984 let ws = FakeWorkspace::unix();
987 ws.add_dir(".agents/skills");
988 ws.add_link(".cursor/skills", NodeKind::Dir, ".agents/skills");
989 let lock = locked(
990 "cursor",
991 ResourceKind::Skills,
992 ".cursor/skills",
993 Via::Symlink,
994 );
995
996 let step = &retire_with(&ws, &lock, &["claude-code"])[0];
997
998 assert!(step.is_removal());
999 assert!(!step.is_write());
1000 }
1001}