1use crate::lock::{Lock, LockEntry};
13use crate::plan::{Outcome, Plan, Skip, Step};
14use crate::workspace::{FsResult, Workspace};
15
16#[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 pub retired: usize,
25 pub unchanged: usize,
27 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 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
53pub 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 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 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 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
116fn 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 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 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 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 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 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 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 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 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 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, ®istry, 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 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 assert!(lock.entries.is_empty());
437 }
438}