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, 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    /// Capabilities that were already correct, or needed nothing to begin with.
24    pub unchanged: usize,
25    /// Capabilities awaiting a human decision.
26    pub blocked: usize,
27}
28
29impl ApplyReport {
30    pub fn changed(&self) -> usize {
31        self.created + self.rewritten + self.relinked + self.adopted
32    }
33
34    /// Accumulates a later pass into this one.
35    ///
36    /// `apply` runs to a fixed point: adopting content creates the canonical
37    /// resource, which in turn unblocks every other provider waiting for it. The
38    /// counts are summed, while `unchanged` and `blocked` are taken from the
39    /// final pass because only that pass describes the resting state.
40    pub fn absorb(&mut self, later: ApplyReport) {
41        self.created += later.created;
42        self.rewritten += later.rewritten;
43        self.relinked += later.relinked;
44        self.adopted += later.adopted;
45        self.unchanged = later.unchanged;
46        self.blocked = later.blocked;
47    }
48}
49
50/// Executes a plan, updating `lock` to reflect what now exists.
51///
52/// Blocked steps are counted and skipped: the caller is responsible for
53/// reporting them and choosing an exit code.
54pub fn apply(plan: &Plan, ws: &dyn Workspace, lock: &mut Lock) -> FsResult<ApplyReport> {
55    let mut report = ApplyReport::default();
56
57    for step in &plan.steps {
58        match &step.outcome {
59            Outcome::Native | Outcome::UpToDate { .. } | Outcome::Skip(_) => {
60                report.unchanged += 1;
61            }
62            Outcome::Blocked(_) => {
63                report.blocked += 1;
64            }
65            Outcome::Create { via } => {
66                materialise(step, *via, ws)?;
67                record(step, *via, lock);
68                report.created += 1;
69            }
70            Outcome::Rewrite { via } => {
71                materialise(step, *via, ws)?;
72                record(step, *via, lock);
73                report.rewritten += 1;
74            }
75            Outcome::Relink { via, .. } => {
76                // Only ever reached for a link the lock says we created, so
77                // removing it cannot destroy anything the user authored.
78                ws.remove_link(&step.target, step.resource.node())?;
79                materialise(step, *via, ws)?;
80                record(step, *via, lock);
81                report.relinked += 1;
82            }
83            Outcome::Adopt { via } => {
84                adopt(step, ws)?;
85                materialise(step, *via, ws)?;
86                record(step, *via, lock);
87                report.adopted += 1;
88            }
89        }
90    }
91
92    Ok(report)
93}
94
95/// Moves the provider's content into the canonical location.
96///
97/// The planner only emits [`Outcome::Adopt`] when the canonical side is absent or
98/// an empty directory, so the rename below never overwrites content.
99fn adopt(step: &Step, ws: &dyn Workspace) -> FsResult<()> {
100    if ws.probe(&step.canonical)?.is_some() {
101        ws.remove_empty_dir(&step.canonical)?;
102    }
103    if let Some(parent) = step.canonical.parent() {
104        ws.create_dir_all(&parent)?;
105    }
106    ws.rename(&step.target, &step.canonical)
107}
108
109fn materialise(step: &Step, via: crate::model::Via, ws: &dyn Workspace) -> FsResult<()> {
110    if via.is_link() {
111        ws.link(via, step.resource.node(), &step.canonical, &step.target)
112    } else {
113        ws.write(
114            &step.target,
115            step.import_body.as_deref().unwrap_or_default(),
116        )
117    }
118}
119
120fn record(step: &Step, via: crate::model::Via, lock: &mut Lock) {
121    lock.record(LockEntry {
122        provider: step.provider_id.clone(),
123        resource: step.resource,
124        target: step.target.clone(),
125        canonical: step.canonical.clone(),
126        via,
127    });
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::layout::Layout;
134    use crate::model::{NodeKind, ResourceKind, Via};
135    use crate::path::RelPath;
136    use crate::plan::Planner;
137    use crate::provider::Provider;
138    use crate::testing::FakeWorkspace;
139
140    fn provider(toml_text: &str) -> Provider {
141        let layout = Layout::default();
142        crate::provider::parse("test.toml", toml_text, |kind| {
143            layout.canonical(kind).clone()
144        })
145        .expect("valid manifest")
146    }
147
148    fn claude() -> Provider {
149        provider(
150            r#"
151            schema = 1
152            id = "claude-code"
153            name = "Claude Code"
154
155            [[capability]]
156            resource = "instructions"
157            strategy = "link"
158            path = "CLAUDE.md"
159
160            [capability.fallback]
161            strategy = "import"
162            template = "@{canonical}\n"
163
164            [[capability]]
165            resource = "skills"
166            strategy = "link"
167            path = ".claude/skills"
168            "#,
169        )
170    }
171
172    /// Plans and applies in one shot, mirroring what `agentlink apply` does.
173    fn run(ws: &FakeWorkspace, lock: &mut Lock, adopt: bool) -> ApplyReport {
174        let layout = Layout::default();
175        let plan = Planner::new(&layout, lock, ws.support())
176            .with_adopt(adopt)
177            .plan(&[&claude()], ws)
178            .expect("planning");
179        apply(&plan, ws, lock).expect("apply")
180    }
181
182    #[test]
183    fn creates_links_and_records_ownership() {
184        let ws = FakeWorkspace::unix();
185        ws.add_file("AGENTS.md", "# rules");
186        ws.add_dir(".agents/skills");
187        let mut lock = Lock::default();
188
189        let report = run(&ws, &mut lock, false);
190
191        assert_eq!(report.created, 2);
192        assert_eq!(
193            ws.link_target(".claude/skills").as_deref(),
194            Some(".agents/skills")
195        );
196        assert_eq!(ws.link_target("CLAUDE.md").as_deref(), Some("AGENTS.md"));
197        assert!(lock.owns(&RelPath::new(".claude/skills").unwrap()));
198        assert!(lock.owns(&RelPath::new("CLAUDE.md").unwrap()));
199    }
200
201    #[test]
202    fn applying_twice_changes_nothing() {
203        // Idempotence is the precondition for running this from a git hook.
204        let ws = FakeWorkspace::unix();
205        ws.add_file("AGENTS.md", "# rules");
206        ws.add_dir(".agents/skills");
207        let mut lock = Lock::default();
208
209        run(&ws, &mut lock, false);
210        let before = ws.paths();
211        let second = run(&ws, &mut lock, false);
212
213        assert_eq!(second.changed(), 0);
214        assert_eq!(second.unchanged, 2);
215        assert_eq!(ws.paths(), before);
216        assert_eq!(lock.entries.len(), 2);
217    }
218
219    #[test]
220    fn writes_an_import_stub_where_files_cannot_be_linked() {
221        let ws = FakeWorkspace::windows_unprivileged();
222        ws.add_file("AGENTS.md", "# rules");
223        ws.add_dir(".agents/skills");
224        let mut lock = Lock::default();
225
226        run(&ws, &mut lock, false);
227
228        // Directory gets a junction; the file gets Claude Code's own import syntax.
229        assert_eq!(
230            ws.link_target(".claude/skills").as_deref(),
231            Some(".agents/skills")
232        );
233        assert_eq!(ws.raw_file("CLAUDE.md").as_deref(), Some("@AGENTS.md\n"));
234        assert_eq!(
235            lock.find("claude-code", ResourceKind::Skills).unwrap().via,
236            Via::Junction
237        );
238        assert_eq!(
239            lock.find("claude-code", ResourceKind::Instructions)
240                .unwrap()
241                .via,
242            Via::Import
243        );
244    }
245
246    #[test]
247    fn adoption_moves_existing_content_then_links_back() {
248        // The onboarding story: a repo that only ever used Claude Code.
249        let ws = FakeWorkspace::unix();
250        ws.add_file(".claude/skills/review/SKILL.md", "---\nname: review\n---\n");
251        ws.add_file("CLAUDE.md", "# my instructions");
252        let mut lock = Lock::default();
253
254        let report = run(&ws, &mut lock, true);
255
256        assert_eq!(report.adopted, 2);
257        // Content now lives in the canonical location...
258        assert_eq!(
259            ws.raw_file(".agents/skills/review/SKILL.md").as_deref(),
260            Some("---\nname: review\n---\n")
261        );
262        assert_eq!(
263            ws.raw_file("AGENTS.md").as_deref(),
264            Some("# my instructions")
265        );
266        // ...and the original paths still resolve, so Claude Code is unaffected.
267        assert_eq!(
268            ws.link_target(".claude/skills").as_deref(),
269            Some(".agents/skills")
270        );
271        assert_eq!(ws.link_target("CLAUDE.md").as_deref(), Some("AGENTS.md"));
272        assert_eq!(
273            ws.read(&RelPath::new("CLAUDE.md").unwrap()).unwrap(),
274            "# my instructions"
275        );
276    }
277
278    #[test]
279    fn adoption_clears_an_empty_canonical_directory_first() {
280        let ws = FakeWorkspace::unix();
281        ws.add_dir(".agents/skills");
282        ws.add_file(".claude/skills/review/SKILL.md", "body");
283        let mut lock = Lock::default();
284
285        let report = run(&ws, &mut lock, true);
286
287        assert_eq!(report.adopted, 1);
288        assert_eq!(
289            ws.raw_file(".agents/skills/review/SKILL.md").as_deref(),
290            Some("body")
291        );
292    }
293
294    #[test]
295    fn blocked_steps_are_counted_and_leave_the_workspace_untouched() {
296        let ws = FakeWorkspace::unix();
297        ws.add_file("AGENTS.md", "# shared");
298        ws.add_file("CLAUDE.md", "# hand written, do not touch");
299        ws.add_dir(".agents/skills");
300        let mut lock = Lock::default();
301
302        let report = run(&ws, &mut lock, false);
303
304        assert_eq!(report.blocked, 1);
305        assert_eq!(
306            ws.raw_file("CLAUDE.md").as_deref(),
307            Some("# hand written, do not touch")
308        );
309        assert!(!lock.owns(&RelPath::new("CLAUDE.md").unwrap()));
310    }
311
312    #[test]
313    fn relinking_replaces_only_links_we_created() {
314        let ws = FakeWorkspace::unix();
315        ws.add_file("AGENTS.md", "# rules");
316        ws.add_dir("old/skills");
317        ws.add_dir(".agents/skills");
318        let mut lock = Lock::default();
319
320        // Simulate a previous run that pointed at a different canonical path.
321        ws.add_link(".claude/skills", NodeKind::Dir, "old/skills");
322        lock.record(LockEntry {
323            provider: "claude-code".into(),
324            resource: ResourceKind::Skills,
325            target: RelPath::new(".claude/skills").unwrap(),
326            canonical: RelPath::new("old/skills").unwrap(),
327            via: Via::Symlink,
328        });
329
330        let report = run(&ws, &mut lock, false);
331
332        assert_eq!(report.relinked, 1);
333        assert_eq!(
334            ws.link_target(".claude/skills").as_deref(),
335            Some(".agents/skills")
336        );
337        // The old target's content is untouched — we removed a link, not a tree.
338        assert!(ws.exists("old/skills"));
339    }
340
341    #[test]
342    fn a_stale_stub_is_rewritten_in_place() {
343        let ws = FakeWorkspace::windows_unprivileged();
344        ws.add_file("AGENTS.md", "# rules");
345        ws.add_file("CLAUDE.md", "@OLD.md\n");
346        let mut lock = Lock::default();
347        lock.record(LockEntry {
348            provider: "claude-code".into(),
349            resource: ResourceKind::Instructions,
350            target: RelPath::new("CLAUDE.md").unwrap(),
351            canonical: RelPath::new("AGENTS.md").unwrap(),
352            via: Via::Import,
353        });
354
355        let report = run(&ws, &mut lock, false);
356
357        assert_eq!(report.rewritten, 1);
358        assert_eq!(ws.raw_file("CLAUDE.md").as_deref(), Some("@AGENTS.md\n"));
359    }
360}