Skip to main content

agentlink_domain/
plan.rs

1//! The capability lattice: deciding what, if anything, to do.
2//!
3//! Planning is a pure function of observable state — the canonical layout, the
4//! provider manifests, what the filesystem currently holds, what the lock says we
5//! own, and which link primitives the host permits. Nothing is written here.
6//! `agentlink status` renders a plan; `agentlink apply` renders the same plan and
7//! then executes it. That symmetry is what makes the tool predictable.
8//!
9//! The rule the planner exists to enforce: **never destroy content agentlink did
10//! not create.** Every ambiguous situation resolves to a [`Blocked`] outcome that
11//! names the exact command to run, rather than to a guess.
12
13use 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/// What the planner decided for one provider/resource pair.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Outcome {
23    /// The tool reads the canonical path directly. Nothing to do, now or ever.
24    Native,
25    /// Already materialised correctly.
26    UpToDate { via: Via },
27    /// Nothing at the target: create it.
28    Create { via: Via },
29    /// An `import` stub we own exists but its contents are stale.
30    Rewrite { via: Via },
31    /// A link we own points somewhere else: repoint it.
32    Relink { via: Via, current: LinkTarget },
33    /// The target holds the only copy of this content: move it into the canonical
34    /// location, then link back. This is the onboarding path for an existing repo.
35    Adopt { via: Via },
36    /// This provider is no longer served, and what we made for it is still ours:
37    /// remove it and stop claiming it.
38    Retire { via: Via },
39    /// Nothing to do, for a benign reason.
40    Skip(Skip),
41    /// Needs a human decision. Never resolved by guessing.
42    Blocked(Blocked),
43}
44
45/// Benign reasons for inaction.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Skip {
48    /// The canonical resource does not exist yet, so there is nothing to share.
49    CanonicalMissing,
50    /// A deselected provider's path no longer holds what agentlink put there, so
51    /// it is the user's now: the file stays and agentlink drops its claim.
52    Unmanaged,
53}
54
55/// Situations that require the user to choose.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum Blocked {
58    /// The target holds real content and the canonical location is free.
59    /// Running `agentlink adopt` would move it and link back.
60    NeedsAdopt,
61    /// Both the target and the canonical location hold content. Only a human can
62    /// decide how to merge them.
63    TargetOccupied,
64    /// A link exists that agentlink did not create, pointing somewhere else.
65    ForeignLink { current: LinkTarget },
66    /// The host cannot create the required link and the provider declares no
67    /// fallback.
68    Unsupported { node: NodeKind },
69}
70
71/// One decision.
72#[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    /// Exact bytes to write when this step materialises an `import` stub.
82    ///
83    /// Rendered during planning so that a [`Step`] fully describes its own
84    /// execution: the executor needs nothing but the plan, which keeps `status`
85    /// and `apply` provably in agreement about what will happen.
86    pub import_body: Option<String>,
87}
88
89impl Step {
90    /// Whether executing this step materialises something at the target.
91    ///
92    /// Deliberately excludes [`Outcome::Retire`]: callers use this to decide what
93    /// a workspace now contains — a `.gitignore` block, for instance — and a path
94    /// on its way out belongs in neither.
95    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    /// Whether executing this step takes something away.
106    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/// A full set of decisions for a workspace.
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct Plan {
118    pub steps: Vec<Step>,
119}
120
121impl Plan {
122    /// Steps that would write to disk.
123    pub fn writes(&self) -> impl Iterator<Item = &Step> {
124        self.steps.iter().filter(|step| step.is_write())
125    }
126
127    /// Steps needing a human decision.
128    pub fn blocked(&self) -> impl Iterator<Item = &Step> {
129        self.steps.iter().filter(|step| step.is_blocked())
130    }
131
132    /// Steps that would remove something agentlink created.
133    pub fn removals(&self) -> impl Iterator<Item = &Step> {
134        self.steps.iter().filter(|step| step.is_removal())
135    }
136
137    /// How many capabilities require no work at all — the number this project
138    /// exists to maximise.
139    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    /// How many capabilities are served by a real filesystem link, and therefore
147    /// propagate edits, renames and deletions with no further action.
148    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/// Decides what to do, given the world as it is.
169#[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    /// Permits moving user content from a provider path into the canonical
188    /// location. Off by default: adoption is the one operation that relocates
189    /// data the user did not put there, so it must be asked for explicitly.
190    #[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        // `native` is a claim about the tool, already validated against the
245        // canonical path at manifest load time. There is nothing to materialise.
246        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            // The provider path holds the only copy. This is the common state of
261            // a repository that has been using one agent and is now adding
262            // agentlink, so it deserves a first-class path rather than an error.
263            (None, Some(target)) if target.is_concrete() => Ok(self.adoption(via)),
264
265            // Nothing to share yet: either the workspace is empty, or a dangling
266            // link is waiting for canonical content to appear.
267            (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            // Already pointing where it should. We deliberately do not rewrite a
289            // junction into a symlink when privileges appear later: the link is
290            // correct, and churn in a repository is worse than a suboptimal but
291            // working mechanism.
292            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            // Real content sits at the provider path while the canonical location
306            // also exists. Safe to adopt only if the canonical side is empty.
307            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        // A link where we expect a stub is not ours to interpret.
327        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        // The stub is one line of our own generated text. Rewriting it is safe
341        // only if we wrote it; otherwise the file is the user's.
342        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    /// Whether the canonical location can receive adopted content without
352    /// overwriting anything.
353    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    /// Picks the mechanism: the provider's preferred strategy if the host allows
372    /// it, otherwise its declared fallback.
373    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                // No link primitive on this host. This is exactly the Windows
380                // file case: junctions cannot link a file and symlinks need
381                // privileges, so `CLAUDE.md` degrades to an `@AGENTS.md` stub.
382                None if capability.has_import_fallback() => Some(Via::Import),
383                None => None,
384            },
385        }
386    }
387}
388
389/// Decides what to do with paths the lock claims, for providers that are no
390/// longer served.
391///
392/// Narrowing the provider list would otherwise leave orphans behind: a
393/// `.cursor/skills` junction nobody maintains, still listed in `.gitignore`.
394/// These steps are part of the same plan as everything else, so `status`
395/// announces a removal before `apply` performs one.
396///
397/// The safety rule is the one that governs the whole tool: a target is removed
398/// only while it still *is* what agentlink created. A link replaced by a real
399/// directory, or a stub someone edited, resolves to [`Skip::Unmanaged`] — the
400/// content stays and agentlink simply stops claiming it.
401pub 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            // Already gone, or no longer what we made. Either way nothing is
418            // deleted and the claim is dropped, so the next run stays quiet.
419            _ => 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
444/// Whether what sits at a locked target is still the artefact agentlink made.
445fn 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    // An import stub is one line of our own generated text, so comparing it to
458    // what we would write today is an exact ownership test. A provider that has
459    // since been removed from the registry leaves nothing to compare against,
460    // which fails closed: the file is kept.
461    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        // The entire point: a native provider triggers zero writes.
566        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        // The decisive cross-platform case. Skills are a directory, so they get a
604        // junction with no elevation. CLAUDE.md is a file with no available link
605        // primitive, so it degrades to Claude Code's own `@` import syntax.
606        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        // Re-planning after apply must be a no-op: idempotence is what makes this
665        // safe to wire into a git hook.
666        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        // A repo that has been using Claude Code and has no .agents/ yet.
718        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        // Default posture never relocates user data.
725        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        // Even with --adopt, we refuse: adopting would silently discard one side.
761        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        // The single most important safety case: a user with real content in
832        // CLAUDE.md and real content in AGENTS.md must not lose either.
833        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, &registry, 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        // The link became a real directory holding real skills. Whatever happened,
931        // that content is not ours to delete just because a name left a list.
932        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        // `.gitignore` is built from writes, so a path on its way out must not
985        // count as one.
986        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}