Skip to main content

agentlink_domain/
apply.rs

1//! Executing a plan.
2//!
3//! The executor is deliberately dull. Every decision was made during planning;
4//! here we only carry it out and record what we did. That split is what lets
5//! `agentlink status` promise exactly what `agentlink apply` will do.
6//!
7//! Two invariants hold for every operation below:
8//!
9//! * nothing is removed unless the lock says agentlink created it, and
10//! * no removal is ever recursive.
11
12use crate::lock::{Lock, LockEntry};
13use crate::plan::{Outcome, Plan, Skip, Step};
14use crate::workspace::{FsResult, Workspace};
15
16/// What an [`apply`] run did.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub struct ApplyReport {
19    pub created: usize,
20    pub rewritten: usize,
21    pub relinked: usize,
22    pub adopted: usize,
23    /// Artefacts removed because their provider is no longer served.
24    pub retired: usize,
25    /// Capabilities that were already correct, or needed nothing to begin with.
26    pub unchanged: usize,
27    /// Capabilities awaiting a human decision.
28    pub blocked: usize,
29}
30
31impl ApplyReport {
32    pub fn changed(&self) -> usize {
33        self.created + self.rewritten + self.relinked + self.adopted + self.retired
34    }
35
36    /// Accumulates a later pass into this one.
37    ///
38    /// `apply` runs to a fixed point: adopting content creates the canonical
39    /// resource, which in turn unblocks every other provider waiting for it. The
40    /// counts are summed, while `unchanged` and `blocked` are taken from the
41    /// final pass because only that pass describes the resting state.
42    pub fn absorb(&mut self, later: ApplyReport) {
43        self.created += later.created;
44        self.rewritten += later.rewritten;
45        self.relinked += later.relinked;
46        self.adopted += later.adopted;
47        self.retired += later.retired;
48        self.unchanged = later.unchanged;
49        self.blocked = later.blocked;
50    }
51}
52
53/// Executes a plan, updating `lock` to reflect what now exists.
54///
55/// Blocked steps are counted and skipped: the caller is responsible for
56/// reporting them and choosing an exit code.
57pub fn apply(plan: &Plan, ws: &dyn Workspace, lock: &mut Lock) -> FsResult<ApplyReport> {
58    let mut report = ApplyReport::default();
59
60    for step in &plan.steps {
61        match &step.outcome {
62            Outcome::Native | Outcome::UpToDate { .. } | Outcome::Skip(Skip::CanonicalMissing) => {
63                report.unchanged += 1;
64            }
65            // The path diverged from what we created, so it belongs to whoever
66            // changed it. Dropping the claim leaves the content untouched and
67            // stops agentlink reporting a path it no longer has any say over.
68            Outcome::Skip(Skip::Unmanaged) => {
69                lock.forget(&step.provider_id, step.resource);
70                report.unchanged += 1;
71            }
72            Outcome::Blocked(_) => {
73                report.blocked += 1;
74            }
75            Outcome::Create { via } => {
76                materialise(step, *via, ws)?;
77                record(step, *via, lock);
78                report.created += 1;
79            }
80            Outcome::Rewrite { via } => {
81                materialise(step, *via, ws)?;
82                record(step, *via, lock);
83                report.rewritten += 1;
84            }
85            Outcome::Relink { via, .. } => {
86                // Only ever reached for a link the lock says we created, so
87                // removing it cannot destroy anything the user authored.
88                ws.remove_link(&step.target, step.resource.node())?;
89                materialise(step, *via, ws)?;
90                record(step, *via, lock);
91                report.relinked += 1;
92            }
93            Outcome::Adopt { via } => {
94                adopt(step, ws)?;
95                materialise(step, *via, ws)?;
96                record(step, *via, lock);
97                report.adopted += 1;
98            }
99            Outcome::Retire { via } => {
100                // Only planned for a target the lock owns that still matches what
101                // we created, so nothing here can be the user's work.
102                if via.is_link() {
103                    ws.remove_link(&step.target, step.resource.node())?;
104                } else {
105                    ws.remove_file(&step.target)?;
106                }
107                lock.forget(&step.provider_id, step.resource);
108                report.retired += 1;
109            }
110        }
111    }
112
113    Ok(report)
114}
115
116/// Moves the provider's content into the canonical location.
117///
118/// The planner only emits [`Outcome::Adopt`] when the canonical side is absent or
119/// an empty directory, so the rename below never overwrites content.
120fn adopt(step: &Step, ws: &dyn Workspace) -> FsResult<()> {
121    if ws.probe(&step.canonical)?.is_some() {
122        ws.remove_empty_dir(&step.canonical)?;
123    }
124    if let Some(parent) = step.canonical.parent() {
125        ws.create_dir_all(&parent)?;
126    }
127    ws.rename(&step.target, &step.canonical)
128}
129
130fn materialise(step: &Step, via: crate::model::Via, ws: &dyn Workspace) -> FsResult<()> {
131    if via.is_link() {
132        ws.link(via, step.resource.node(), &step.canonical, &step.target)
133    } else {
134        ws.write(
135            &step.target,
136            step.import_body.as_deref().unwrap_or_default(),
137        )
138    }
139}
140
141fn record(step: &Step, via: crate::model::Via, lock: &mut Lock) {
142    lock.record(LockEntry {
143        provider: step.provider_id.clone(),
144        resource: step.resource,
145        target: step.target.clone(),
146        canonical: step.canonical.clone(),
147        via,
148    });
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::layout::Layout;
155    use crate::model::{NodeKind, ResourceKind, Via};
156    use crate::path::RelPath;
157    use crate::plan::Planner;
158    use crate::provider::Provider;
159    use crate::testing::FakeWorkspace;
160
161    fn provider(toml_text: &str) -> Provider {
162        let layout = Layout::default();
163        crate::provider::parse("test.toml", toml_text, |kind| {
164            layout.canonical(kind).clone()
165        })
166        .expect("valid manifest")
167    }
168
169    fn claude() -> Provider {
170        provider(
171            r#"
172            schema = 1
173            id = "claude-code"
174            name = "Claude Code"
175
176            [[capability]]
177            resource = "instructions"
178            strategy = "link"
179            path = "CLAUDE.md"
180
181            [capability.fallback]
182            strategy = "import"
183            template = "@{canonical}\n"
184
185            [[capability]]
186            resource = "skills"
187            strategy = "link"
188            path = ".claude/skills"
189            "#,
190        )
191    }
192
193    /// Plans and applies in one shot, mirroring what `agentlink apply` does.
194    fn run(ws: &FakeWorkspace, lock: &mut Lock, adopt: bool) -> ApplyReport {
195        let layout = Layout::default();
196        let plan = Planner::new(&layout, lock, ws.support())
197            .with_adopt(adopt)
198            .plan(&[&claude()], ws)
199            .expect("planning");
200        apply(&plan, ws, lock).expect("apply")
201    }
202
203    #[test]
204    fn creates_links_and_records_ownership() {
205        let ws = FakeWorkspace::unix();
206        ws.add_file("AGENTS.md", "# rules");
207        ws.add_dir(".agents/skills");
208        let mut lock = Lock::default();
209
210        let report = run(&ws, &mut lock, false);
211
212        assert_eq!(report.created, 2);
213        assert_eq!(
214            ws.link_target(".claude/skills").as_deref(),
215            Some(".agents/skills")
216        );
217        assert_eq!(ws.link_target("CLAUDE.md").as_deref(), Some("AGENTS.md"));
218        assert!(lock.owns(&RelPath::new(".claude/skills").unwrap()));
219        assert!(lock.owns(&RelPath::new("CLAUDE.md").unwrap()));
220    }
221
222    #[test]
223    fn applying_twice_changes_nothing() {
224        // Idempotence is the precondition for running this from a git hook.
225        let ws = FakeWorkspace::unix();
226        ws.add_file("AGENTS.md", "# rules");
227        ws.add_dir(".agents/skills");
228        let mut lock = Lock::default();
229
230        run(&ws, &mut lock, false);
231        let before = ws.paths();
232        let second = run(&ws, &mut lock, false);
233
234        assert_eq!(second.changed(), 0);
235        assert_eq!(second.unchanged, 2);
236        assert_eq!(ws.paths(), before);
237        assert_eq!(lock.entries.len(), 2);
238    }
239
240    #[test]
241    fn writes_an_import_stub_where_files_cannot_be_linked() {
242        let ws = FakeWorkspace::windows_unprivileged();
243        ws.add_file("AGENTS.md", "# rules");
244        ws.add_dir(".agents/skills");
245        let mut lock = Lock::default();
246
247        run(&ws, &mut lock, false);
248
249        // Directory gets a junction; the file gets Claude Code's own import syntax.
250        assert_eq!(
251            ws.link_target(".claude/skills").as_deref(),
252            Some(".agents/skills")
253        );
254        assert_eq!(ws.raw_file("CLAUDE.md").as_deref(), Some("@AGENTS.md\n"));
255        assert_eq!(
256            lock.find("claude-code", ResourceKind::Skills).unwrap().via,
257            Via::Junction
258        );
259        assert_eq!(
260            lock.find("claude-code", ResourceKind::Instructions)
261                .unwrap()
262                .via,
263            Via::Import
264        );
265    }
266
267    #[test]
268    fn adoption_moves_existing_content_then_links_back() {
269        // The onboarding story: a repo that only ever used Claude Code.
270        let ws = FakeWorkspace::unix();
271        ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
272        ws.add_file("CLAUDE.md", "# my instructions");
273        let mut lock = Lock::default();
274
275        let report = run(&ws, &mut lock, true);
276
277        assert_eq!(report.adopted, 2);
278        // Content now lives in the canonical location...
279        assert_eq!(
280            ws.raw_file(".agents/skills/review/SKILL.md").as_deref(),
281            Some("---\nname: review\n---\n")
282        );
283        assert_eq!(
284            ws.raw_file("AGENTS.md").as_deref(),
285            Some("# my instructions")
286        );
287        // ...and the original paths still resolve, so Claude Code is unaffected.
288        assert_eq!(
289            ws.link_target(".claude/skills").as_deref(),
290            Some(".agents/skills")
291        );
292        assert_eq!(ws.link_target("CLAUDE.md").as_deref(), Some("AGENTS.md"));
293        assert_eq!(
294            ws.read(&RelPath::new("CLAUDE.md").unwrap()).unwrap(),
295            "# my instructions"
296        );
297    }
298
299    #[test]
300    fn adoption_clears_an_empty_canonical_directory_first() {
301        let ws = FakeWorkspace::unix();
302        ws.add_dir(".agents/skills");
303        ws.add_file(".claude/skills/review/SKILL.md", "body");
304        let mut lock = Lock::default();
305
306        let report = run(&ws, &mut lock, true);
307
308        assert_eq!(report.adopted, 1);
309        assert_eq!(
310            ws.raw_file(".agents/skills/review/SKILL.md").as_deref(),
311            Some("body")
312        );
313    }
314
315    #[test]
316    fn blocked_steps_are_counted_and_leave_the_workspace_untouched() {
317        let ws = FakeWorkspace::unix();
318        ws.add_file("AGENTS.md", "# shared");
319        ws.add_file("CLAUDE.md", "# hand written, do not touch");
320        ws.add_dir(".agents/skills");
321        let mut lock = Lock::default();
322
323        let report = run(&ws, &mut lock, false);
324
325        assert_eq!(report.blocked, 1);
326        assert_eq!(
327            ws.raw_file("CLAUDE.md").as_deref(),
328            Some("# hand written, do not touch")
329        );
330        assert!(!lock.owns(&RelPath::new("CLAUDE.md").unwrap()));
331    }
332
333    #[test]
334    fn relinking_replaces_only_links_we_created() {
335        let ws = FakeWorkspace::unix();
336        ws.add_file("AGENTS.md", "# rules");
337        ws.add_dir("old/skills");
338        ws.add_dir(".agents/skills");
339        let mut lock = Lock::default();
340
341        // Simulate a previous run that pointed at a different canonical path.
342        ws.add_link(".claude/skills", NodeKind::Dir, "old/skills");
343        lock.record(LockEntry {
344            provider: "claude-code".into(),
345            resource: ResourceKind::Skills,
346            target: RelPath::new(".claude/skills").unwrap(),
347            canonical: RelPath::new("old/skills").unwrap(),
348            via: Via::Symlink,
349        });
350
351        let report = run(&ws, &mut lock, false);
352
353        assert_eq!(report.relinked, 1);
354        assert_eq!(
355            ws.link_target(".claude/skills").as_deref(),
356            Some(".agents/skills")
357        );
358        // The old target's content is untouched — we removed a link, not a tree.
359        assert!(ws.exists("old/skills"));
360    }
361
362    #[test]
363    fn a_stale_stub_is_rewritten_in_place() {
364        let ws = FakeWorkspace::windows_unprivileged();
365        ws.add_file("AGENTS.md", "# rules");
366        ws.add_file("CLAUDE.md", "@OLD.md\n");
367        let mut lock = Lock::default();
368        lock.record(LockEntry {
369            provider: "claude-code".into(),
370            resource: ResourceKind::Instructions,
371            target: RelPath::new("CLAUDE.md").unwrap(),
372            canonical: RelPath::new("AGENTS.md").unwrap(),
373            via: Via::Import,
374        });
375
376        let report = run(&ws, &mut lock, false);
377
378        assert_eq!(report.rewritten, 1);
379        assert_eq!(ws.raw_file("CLAUDE.md").as_deref(), Some("@AGENTS.md\n"));
380    }
381
382    /// Plans a retirement the way `agentlink apply` composes one, then executes it.
383    fn run_retiring(ws: &FakeWorkspace, lock: &mut Lock, selected: &[&str]) -> ApplyReport {
384        let registry = crate::registry::Registry::builtin(&Layout::default()).unwrap();
385        let providers: Vec<&Provider> = selected
386            .iter()
387            .map(|id| registry.get(id).expect("known provider"))
388            .collect();
389        let steps = crate::plan::retire(lock, &providers, &registry, ws).expect("planning");
390        apply(&Plan { steps }, ws, lock).expect("apply")
391    }
392
393    #[test]
394    fn retiring_removes_the_link_and_drops_the_claim() {
395        let ws = FakeWorkspace::unix();
396        ws.add_dir(".agents/skills");
397        ws.add_file(".agents/skills/deploy/SKILL.md", "---\nname: deploy\n---\n");
398        ws.add_link(".cursor/skills", NodeKind::Dir, ".agents/skills");
399        let mut lock = Lock::default();
400        lock.record(LockEntry {
401            provider: "cursor".into(),
402            resource: ResourceKind::Skills,
403            target: RelPath::new(".cursor/skills").unwrap(),
404            canonical: RelPath::new(".agents/skills").unwrap(),
405            via: Via::Symlink,
406        });
407
408        let report = run_retiring(&ws, &mut lock, &["claude-code"]);
409
410        assert_eq!(report.retired, 1);
411        assert!(!ws.exists(".cursor/skills"));
412        assert!(lock.entries.is_empty());
413        // A link was removed, never the tree it pointed at.
414        assert!(ws.exists(".agents/skills/deploy/SKILL.md"));
415    }
416
417    #[test]
418    fn a_deselected_path_the_user_took_over_survives_and_stops_being_claimed() {
419        let ws = FakeWorkspace::unix();
420        ws.add_dir(".agents/skills");
421        ws.add_file(".cursor/skills/deploy/SKILL.md", "---\nname: deploy\n---\n");
422        let mut lock = Lock::default();
423        lock.record(LockEntry {
424            provider: "cursor".into(),
425            resource: ResourceKind::Skills,
426            target: RelPath::new(".cursor/skills").unwrap(),
427            canonical: RelPath::new(".agents/skills").unwrap(),
428            via: Via::Symlink,
429        });
430
431        let report = run_retiring(&ws, &mut lock, &["claude-code"]);
432
433        assert_eq!(report.retired, 0);
434        assert!(ws.exists(".cursor/skills/deploy/SKILL.md"));
435        // Dropping the claim is what keeps the next run from reporting it again.
436        assert!(lock.entries.is_empty());
437    }
438}