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 Skip(Skip),
38 Blocked(Blocked),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Skip {
45 CanonicalMissing,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum Blocked {
52 NeedsAdopt,
55 TargetOccupied,
58 ForeignLink { current: LinkTarget },
60 Unsupported { node: NodeKind },
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Step {
68 pub provider_id: String,
69 pub provider_name: String,
70 pub resource: ResourceKind,
71 pub canonical: RelPath,
72 pub target: RelPath,
73 pub outcome: Outcome,
74 pub note: Option<String>,
75 pub import_body: Option<String>,
81}
82
83impl Step {
84 pub fn is_write(&self) -> bool {
86 matches!(
87 self.outcome,
88 Outcome::Create { .. }
89 | Outcome::Rewrite { .. }
90 | Outcome::Relink { .. }
91 | Outcome::Adopt { .. }
92 )
93 }
94
95 pub fn is_blocked(&self) -> bool {
96 matches!(self.outcome, Outcome::Blocked(_))
97 }
98}
99
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
102pub struct Plan {
103 pub steps: Vec<Step>,
104}
105
106impl Plan {
107 pub fn writes(&self) -> impl Iterator<Item = &Step> {
109 self.steps.iter().filter(|step| step.is_write())
110 }
111
112 pub fn blocked(&self) -> impl Iterator<Item = &Step> {
114 self.steps.iter().filter(|step| step.is_blocked())
115 }
116
117 pub fn free(&self) -> usize {
120 self.steps
121 .iter()
122 .filter(|step| matches!(step.outcome, Outcome::Native | Outcome::UpToDate { .. }))
123 .count()
124 }
125
126 pub fn linked(&self) -> usize {
129 self.steps
130 .iter()
131 .filter(|step| match step.outcome {
132 Outcome::UpToDate { via }
133 | Outcome::Create { via }
134 | Outcome::Relink { via, .. }
135 | Outcome::Adopt { via } => via.is_link(),
136 _ => false,
137 })
138 .count()
139 }
140
141 pub fn is_clean(&self) -> bool {
142 self.steps
143 .iter()
144 .all(|step| !step.is_write() && !step.is_blocked())
145 }
146}
147
148#[derive(Debug)]
150pub struct Planner<'a> {
151 layout: &'a Layout,
152 lock: &'a Lock,
153 support: LinkSupport,
154 adopt: bool,
155}
156
157impl<'a> Planner<'a> {
158 pub fn new(layout: &'a Layout, lock: &'a Lock, support: LinkSupport) -> Self {
159 Self {
160 layout,
161 lock,
162 support,
163 adopt: false,
164 }
165 }
166
167 #[must_use]
171 pub fn with_adopt(mut self, adopt: bool) -> Self {
172 self.adopt = adopt;
173 self
174 }
175
176 pub fn plan(&self, providers: &[&Provider], ws: &dyn Workspace) -> FsResult<Plan> {
177 let mut steps = Vec::new();
178 for provider in providers {
179 for &resource in ResourceKind::ALL {
180 let Some(capability) = provider.capability(resource) else {
181 continue;
182 };
183 steps.push(self.step(provider, capability, ws)?);
184 }
185 }
186 steps.sort_by(|a, b| {
187 a.resource
188 .cmp(&b.resource)
189 .then_with(|| a.provider_id.cmp(&b.provider_id))
190 });
191 Ok(Plan { steps })
192 }
193
194 fn step(
195 &self,
196 provider: &Provider,
197 capability: &Capability,
198 ws: &dyn Workspace,
199 ) -> FsResult<Step> {
200 let resource = capability.resource;
201 let canonical = self.layout.canonical(resource).clone();
202 let outcome = self.decide(capability, &canonical, ws)?;
203 Ok(Step {
204 provider_id: provider.id.clone(),
205 provider_name: provider.name.clone(),
206 resource,
207 canonical: canonical.clone(),
208 target: capability.path.clone(),
209 outcome,
210 note: capability.note.clone(),
211 import_body: capability.import_body(&canonical),
212 })
213 }
214
215 fn decide(
216 &self,
217 capability: &Capability,
218 canonical: &RelPath,
219 ws: &dyn Workspace,
220 ) -> FsResult<Outcome> {
221 let node = capability.resource.node();
222 let canonical_entry = ws.probe(canonical)?;
223
224 if capability.strategy == Strategy::Native {
227 return Ok(match canonical_entry {
228 Some(_) => Outcome::Native,
229 None => Outcome::Skip(Skip::CanonicalMissing),
230 });
231 }
232
233 let Some(via) = self.resolve_via(capability, node) else {
234 return Ok(Outcome::Blocked(Blocked::Unsupported { node }));
235 };
236
237 let target_entry = ws.probe(&capability.path)?;
238
239 match (canonical_entry, target_entry) {
240 (None, Some(target)) if target.is_concrete() => Ok(self.adoption(via)),
244
245 (None, _) => Ok(Outcome::Skip(Skip::CanonicalMissing)),
248
249 (Some(_), None) => Ok(Outcome::Create { via }),
250
251 (Some(_), Some(target)) => self.reconcile(capability, canonical, via, &target, ws),
252 }
253 }
254
255 fn reconcile(
256 &self,
257 capability: &Capability,
258 canonical: &RelPath,
259 via: Via,
260 target: &Entry,
261 ws: &dyn Workspace,
262 ) -> FsResult<Outcome> {
263 if via == Via::Import {
264 return self.reconcile_import(capability, canonical, target, ws);
265 }
266
267 match &target.link {
268 Some(LinkTarget::Inside(actual)) if actual == canonical => {
273 Ok(Outcome::UpToDate { via })
274 }
275 Some(current) => Ok(if self.lock.owns(&capability.path) {
276 Outcome::Relink {
277 via,
278 current: current.clone(),
279 }
280 } else {
281 Outcome::Blocked(Blocked::ForeignLink {
282 current: current.clone(),
283 })
284 }),
285 None => {
288 if Self::canonical_is_free(canonical, ws)? {
289 Ok(self.adoption(via))
290 } else {
291 Ok(Outcome::Blocked(Blocked::TargetOccupied))
292 }
293 }
294 }
295 }
296
297 fn reconcile_import(
298 &self,
299 capability: &Capability,
300 canonical: &RelPath,
301 target: &Entry,
302 ws: &dyn Workspace,
303 ) -> FsResult<Outcome> {
304 let expected = capability.import_body(canonical).unwrap_or_default();
305
306 if !target.is_concrete() {
308 return Ok(match &target.link {
309 Some(current) => Outcome::Blocked(Blocked::ForeignLink {
310 current: current.clone(),
311 }),
312 None => Outcome::Blocked(Blocked::TargetOccupied),
313 });
314 }
315
316 let actual = ws.read(&capability.path)?;
317 if actual == expected {
318 return Ok(Outcome::UpToDate { via: Via::Import });
319 }
320 Ok(if self.lock.owns(&capability.path) {
323 Outcome::Rewrite { via: Via::Import }
324 } else if Self::canonical_is_free(canonical, ws)? {
325 self.adoption(Via::Import)
326 } else {
327 Outcome::Blocked(Blocked::TargetOccupied)
328 })
329 }
330
331 fn canonical_is_free(canonical: &RelPath, ws: &dyn Workspace) -> FsResult<bool> {
334 Ok(match ws.probe(canonical)? {
335 None => true,
336 Some(entry) if entry.node == NodeKind::Dir && entry.is_concrete() => {
337 ws.is_empty_dir(canonical)?
338 }
339 Some(_) => false,
340 })
341 }
342
343 fn adoption(&self, via: Via) -> Outcome {
344 if self.adopt {
345 Outcome::Adopt { via }
346 } else {
347 Outcome::Blocked(Blocked::NeedsAdopt)
348 }
349 }
350
351 fn resolve_via(&self, capability: &Capability, node: NodeKind) -> Option<Via> {
354 match capability.strategy {
355 Strategy::Native => None,
356 Strategy::Import => Some(Via::Import),
357 Strategy::Link => match self.support.best_for(node) {
358 Some(via) => Some(via),
359 None if capability.has_import_fallback() => Some(Via::Import),
363 None => None,
364 },
365 }
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::lock::LockEntry;
373 use crate::testing::FakeWorkspace;
374
375 fn rel(s: &str) -> RelPath {
376 RelPath::new(s).unwrap()
377 }
378
379 fn provider(toml_text: &str) -> Provider {
380 let layout = Layout::default();
381 crate::provider::parse("test.toml", toml_text, |kind| {
382 layout.canonical(kind).clone()
383 })
384 .expect("valid manifest")
385 }
386
387 fn claude() -> Provider {
388 provider(
389 r#"
390 schema = 1
391 id = "claude-code"
392 name = "Claude Code"
393
394 [[capability]]
395 resource = "instructions"
396 strategy = "link"
397 path = "CLAUDE.md"
398
399 [capability.fallback]
400 strategy = "import"
401 template = "@{canonical}\n"
402
403 [[capability]]
404 resource = "skills"
405 strategy = "link"
406 path = ".claude/skills"
407 "#,
408 )
409 }
410
411 fn antigravity() -> Provider {
412 provider(
413 r#"
414 schema = 1
415 id = "antigravity"
416 name = "Google Antigravity"
417
418 [[capability]]
419 resource = "instructions"
420 strategy = "native"
421 path = "AGENTS.md"
422
423 [[capability]]
424 resource = "skills"
425 strategy = "native"
426 path = ".agents/skills"
427 "#,
428 )
429 }
430
431 fn outcome_for(plan: &Plan, provider: &str, resource: ResourceKind) -> Outcome {
432 plan.steps
433 .iter()
434 .find(|step| step.provider_id == provider && step.resource == resource)
435 .unwrap_or_else(|| panic!("no step for {provider}/{resource}"))
436 .outcome
437 .clone()
438 }
439
440 fn plan_with(ws: &FakeWorkspace, lock: &Lock, providers: &[&Provider]) -> Plan {
441 let layout = Layout::default();
442 Planner::new(&layout, lock, ws.support())
443 .plan(providers, ws)
444 .expect("planning")
445 }
446
447 #[test]
448 fn a_tool_reading_the_canonical_path_costs_nothing() {
449 let ws = FakeWorkspace::unix();
450 ws.add_file("AGENTS.md", "# rules");
451 ws.add_dir(".agents/skills");
452
453 let plan = plan_with(&ws, &Lock::default(), &[&antigravity()]);
454
455 assert_eq!(
456 outcome_for(&plan, "antigravity", ResourceKind::Instructions),
457 Outcome::Native
458 );
459 assert_eq!(
460 outcome_for(&plan, "antigravity", ResourceKind::Skills),
461 Outcome::Native
462 );
463 assert_eq!(plan.writes().count(), 0);
465 assert_eq!(plan.free(), 2);
466 }
467
468 #[test]
469 fn missing_canonical_content_is_skipped_not_invented() {
470 let ws = FakeWorkspace::unix();
471 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
472
473 assert_eq!(
474 outcome_for(&plan, "claude-code", ResourceKind::Skills),
475 Outcome::Skip(Skip::CanonicalMissing)
476 );
477 assert_eq!(plan.writes().count(), 0);
478 }
479
480 #[test]
481 fn creates_symlinks_on_a_host_that_supports_them() {
482 let ws = FakeWorkspace::unix();
483 ws.add_file("AGENTS.md", "# rules");
484 ws.add_dir(".agents/skills");
485
486 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
487
488 assert_eq!(
489 outcome_for(&plan, "claude-code", ResourceKind::Instructions),
490 Outcome::Create { via: Via::Symlink }
491 );
492 assert_eq!(
493 outcome_for(&plan, "claude-code", ResourceKind::Skills),
494 Outcome::Create { via: Via::Symlink }
495 );
496 assert_eq!(plan.linked(), 2);
497 }
498
499 #[test]
500 fn windows_without_privileges_junctions_directories_and_stubs_files() {
501 let ws = FakeWorkspace::windows_unprivileged();
505 ws.add_file("AGENTS.md", "# rules");
506 ws.add_dir(".agents/skills");
507
508 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
509
510 assert_eq!(
511 outcome_for(&plan, "claude-code", ResourceKind::Skills),
512 Outcome::Create { via: Via::Junction }
513 );
514 assert_eq!(
515 outcome_for(&plan, "claude-code", ResourceKind::Instructions),
516 Outcome::Create { via: Via::Import }
517 );
518 }
519
520 #[test]
521 fn a_provider_without_a_fallback_is_reported_unsupported_not_silently_dropped() {
522 let no_fallback = provider(
523 r#"
524 schema = 1
525 id = "strict"
526 name = "Strict"
527
528 [[capability]]
529 resource = "instructions"
530 strategy = "link"
531 path = "STRICT.md"
532 "#,
533 );
534 let ws = FakeWorkspace::windows_unprivileged();
535 ws.add_file("AGENTS.md", "# rules");
536
537 let plan = plan_with(&ws, &Lock::default(), &[&no_fallback]);
538
539 assert_eq!(
540 outcome_for(&plan, "strict", ResourceKind::Instructions),
541 Outcome::Blocked(Blocked::Unsupported {
542 node: NodeKind::File
543 })
544 );
545 }
546
547 #[test]
548 fn an_existing_correct_link_is_left_alone() {
549 let ws = FakeWorkspace::unix();
550 ws.add_file("AGENTS.md", "# rules");
551 ws.add_dir(".agents/skills");
552 ws.add_link(".claude/skills", NodeKind::Dir, ".agents/skills");
553 ws.add_link("CLAUDE.md", NodeKind::File, "AGENTS.md");
554
555 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
556
557 assert_eq!(
558 outcome_for(&plan, "claude-code", ResourceKind::Skills),
559 Outcome::UpToDate { via: Via::Symlink }
560 );
561 assert!(plan.is_clean());
562 assert_eq!(plan.writes().count(), 0);
565 }
566
567 #[test]
568 fn a_foreign_link_is_never_repointed_without_asking() {
569 let ws = FakeWorkspace::unix();
570 ws.add_file("AGENTS.md", "# rules");
571 ws.add_dir(".agents/skills");
572 ws.add_dir("somewhere/else");
573 ws.add_link(".claude/skills", NodeKind::Dir, "somewhere/else");
574
575 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
576
577 assert_eq!(
578 outcome_for(&plan, "claude-code", ResourceKind::Skills),
579 Outcome::Blocked(Blocked::ForeignLink {
580 current: LinkTarget::Inside(rel("somewhere/else"))
581 })
582 );
583 }
584
585 #[test]
586 fn a_link_we_created_is_repointed_freely() {
587 let ws = FakeWorkspace::unix();
588 ws.add_file("AGENTS.md", "# rules");
589 ws.add_dir(".agents/skills");
590 ws.add_dir("old/skills");
591 ws.add_link(".claude/skills", NodeKind::Dir, "old/skills");
592
593 let mut lock = Lock::default();
594 lock.record(LockEntry {
595 provider: "claude-code".into(),
596 resource: ResourceKind::Skills,
597 target: rel(".claude/skills"),
598 canonical: rel("old/skills"),
599 via: Via::Symlink,
600 });
601
602 let plan = plan_with(&ws, &lock, &[&claude()]);
603
604 assert_eq!(
605 outcome_for(&plan, "claude-code", ResourceKind::Skills),
606 Outcome::Relink {
607 via: Via::Symlink,
608 current: LinkTarget::Inside(rel("old/skills"))
609 }
610 );
611 }
612
613 #[test]
614 fn existing_provider_content_asks_before_moving_anything() {
615 let ws = FakeWorkspace::unix();
617 ws.add_dir(".claude/skills");
618 ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
619
620 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
621
622 assert_eq!(
624 outcome_for(&plan, "claude-code", ResourceKind::Skills),
625 Outcome::Blocked(Blocked::NeedsAdopt)
626 );
627 }
628
629 #[test]
630 fn adoption_moves_content_into_the_canonical_location_when_asked() {
631 let ws = FakeWorkspace::unix();
632 ws.add_dir(".claude/skills");
633 ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
634
635 let layout = Layout::default();
636 let lock = Lock::default();
637 let plan = Planner::new(&layout, &lock, ws.support())
638 .with_adopt(true)
639 .plan(&[&claude()], &ws)
640 .expect("planning");
641
642 assert_eq!(
643 outcome_for(&plan, "claude-code", ResourceKind::Skills),
644 Outcome::Adopt { via: Via::Symlink }
645 );
646 }
647
648 #[test]
649 fn content_on_both_sides_is_a_merge_only_a_human_can_do() {
650 let ws = FakeWorkspace::unix();
651 ws.add_dir(".agents/skills");
652 ws.add_file(".agents/skills/deploy/SKILL.md", "---\nname: deploy\n---\n");
653 ws.add_dir(".claude/skills");
654 ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
655
656 let layout = Layout::default();
657 let lock = Lock::default();
658 let plan = Planner::new(&layout, &lock, ws.support())
660 .with_adopt(true)
661 .plan(&[&claude()], &ws)
662 .expect("planning");
663
664 assert_eq!(
665 outcome_for(&plan, "claude-code", ResourceKind::Skills),
666 Outcome::Blocked(Blocked::TargetOccupied)
667 );
668 }
669
670 #[test]
671 fn an_empty_canonical_directory_still_accepts_adoption() {
672 let ws = FakeWorkspace::unix();
673 ws.add_dir(".agents/skills");
674 ws.add_dir(".claude/skills");
675 ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
676
677 let layout = Layout::default();
678 let lock = Lock::default();
679 let plan = Planner::new(&layout, &lock, ws.support())
680 .with_adopt(true)
681 .plan(&[&claude()], &ws)
682 .expect("planning");
683
684 assert_eq!(
685 outcome_for(&plan, "claude-code", ResourceKind::Skills),
686 Outcome::Adopt { via: Via::Symlink }
687 );
688 }
689
690 #[test]
691 fn a_correct_import_stub_is_up_to_date() {
692 let ws = FakeWorkspace::windows_unprivileged();
693 ws.add_file("AGENTS.md", "# rules");
694 ws.add_file("CLAUDE.md", "@AGENTS.md\n");
695
696 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
697
698 assert_eq!(
699 outcome_for(&plan, "claude-code", ResourceKind::Instructions),
700 Outcome::UpToDate { via: Via::Import }
701 );
702 }
703
704 #[test]
705 fn a_stale_stub_we_own_is_rewritten() {
706 let ws = FakeWorkspace::windows_unprivileged();
707 ws.add_file("AGENTS.md", "# rules");
708 ws.add_file("CLAUDE.md", "@OLD.md\n");
709
710 let mut lock = Lock::default();
711 lock.record(LockEntry {
712 provider: "claude-code".into(),
713 resource: ResourceKind::Instructions,
714 target: rel("CLAUDE.md"),
715 canonical: rel("AGENTS.md"),
716 via: Via::Import,
717 });
718
719 let plan = plan_with(&ws, &lock, &[&claude()]);
720
721 assert_eq!(
722 outcome_for(&plan, "claude-code", ResourceKind::Instructions),
723 Outcome::Rewrite { via: Via::Import }
724 );
725 }
726
727 #[test]
728 fn a_handwritten_claude_md_is_never_silently_replaced_by_a_stub() {
729 let ws = FakeWorkspace::windows_unprivileged();
732 ws.add_file("AGENTS.md", "# shared rules");
733 ws.add_file("CLAUDE.md", "# my carefully written Claude instructions");
734
735 let plan = plan_with(&ws, &Lock::default(), &[&claude()]);
736
737 assert_eq!(
738 outcome_for(&plan, "claude-code", ResourceKind::Instructions),
739 Outcome::Blocked(Blocked::TargetOccupied)
740 );
741 }
742
743 #[test]
744 fn steps_are_ordered_deterministically() {
745 let ws = FakeWorkspace::unix();
746 ws.add_file("AGENTS.md", "# rules");
747 ws.add_dir(".agents/skills");
748
749 let plan = plan_with(&ws, &Lock::default(), &[&claude(), &antigravity()]);
750
751 let order: Vec<_> = plan
752 .steps
753 .iter()
754 .map(|step| (step.resource, step.provider_id.as_str()))
755 .collect();
756 assert_eq!(
757 order,
758 [
759 (ResourceKind::Instructions, "antigravity"),
760 (ResourceKind::Instructions, "claude-code"),
761 (ResourceKind::Skills, "antigravity"),
762 (ResourceKind::Skills, "claude-code"),
763 ]
764 );
765 }
766}