1use crate::lock::{Lock, LockEntry};
13use crate::plan::{Outcome, Plan, 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 unchanged: usize,
25 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 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
50pub 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 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
95fn 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 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 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 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 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 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 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 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 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}