1use std::collections::HashMap;
8
9use indexmap::IndexMap;
10
11use crate::error::MarsError;
12use crate::frontmatter;
13use crate::lock::ItemKind;
14use crate::resolve::ResolvedGraph;
15use crate::sync::target::{ExplicitSkillRename, TargetState};
16use crate::types::{DestPath, ItemName, SourceName};
17
18pub fn rewrite_skill_refs(
24 target: &mut TargetState,
25 renames: &[ExplicitSkillRename],
26 graph: &ResolvedGraph,
27) -> Result<Vec<String>, MarsError> {
28 let mut warnings = Vec::new();
29
30 if renames.is_empty() {
31 return Ok(warnings);
32 }
33
34 let mut skill_renames: HashMap<ItemName, Vec<(ItemName, SourceName)>> = HashMap::new();
37 for ra in renames {
38 let is_skill = target
39 .items
40 .values()
41 .any(|item| item.id.kind == ItemKind::Skill && item.id.name == ra.new_name);
42 if is_skill {
43 skill_renames
44 .entry(ra.original_name.clone())
45 .or_default()
46 .push((ra.new_name.clone(), ra.source_name.clone()));
47 }
48 }
49
50 if skill_renames.is_empty() {
51 return Ok(warnings);
52 }
53
54 let agent_keys: Vec<DestPath> = target
56 .items
57 .iter()
58 .filter(|(_, item)| item.id.kind == ItemKind::Agent)
59 .map(|(key, _)| key.clone())
60 .collect();
61
62 for key in agent_keys {
63 let (source_path, source_name) = {
64 let item = &target.items[&key];
65 (item.source_path.clone(), item.source_name.clone())
66 };
67 let content = match std::fs::read_to_string(&source_path) {
68 Ok(c) => c,
69 Err(_) => continue,
70 };
71
72 let mut renames_for_agent: IndexMap<String, String> = IndexMap::new();
73 let agent_deps: &[SourceName] = graph
74 .nodes
75 .get(&source_name)
76 .map(|n| n.deps.as_slice())
77 .unwrap_or(&[]);
78
79 for (original_name, entries) in &skill_renames {
80 let selected = entries
81 .iter()
82 .find(|(_, source)| source == &source_name)
83 .or_else(|| {
84 entries
85 .iter()
86 .find(|(_, source)| agent_deps.contains(source))
87 });
88 if let Some((new_name, _)) = selected {
89 renames_for_agent.insert(original_name.to_string(), new_name.to_string());
90 }
91 }
92 if renames_for_agent.is_empty() {
93 continue;
94 }
95
96 match frontmatter::rewrite_content_skills(&content, &renames_for_agent) {
97 Ok(Some(new_content)) => {
98 if let Some(target_item) = target.items.get_mut(&key) {
99 target_item.rewritten_content = Some(new_content);
100 }
101 }
102 Ok(None) => {}
103 Err(e) => {
104 warnings.push(format!(
105 "warning: could not rewrite skill refs in {}: {e}",
106 source_path.display()
107 ));
108 }
109 }
110 }
111
112 Ok(warnings)
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::hash;
119 use crate::lock::{ItemId, ItemKind};
120 use crate::resolve::ResolvedGraph;
121 use crate::sync::target::{ExplicitSkillRename, TargetItem, TargetState};
122 use crate::types::SourceId;
123 use indexmap::IndexMap;
124 use std::fs;
125 use tempfile::TempDir;
126
127 #[test]
128 fn rewrite_skill_refs_uses_exact_skill_matches() {
129 let dir = TempDir::new().unwrap();
130 let agent_path = dir.path().join("agents/coder.md");
131 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
132 fs::write(
133 &agent_path,
134 "---\nskills:\n- plan\n- planner\n---\n# Agent\n",
135 )
136 .unwrap();
137
138 let skill_path = dir.path().join("skills/plan__org_base");
139 fs::create_dir_all(&skill_path).unwrap();
140 fs::write(skill_path.join("SKILL.md"), "# Planning").unwrap();
141
142 let mut items = IndexMap::new();
143 items.insert(
144 "agents/coder.md".into(),
145 TargetItem {
146 id: ItemId {
147 kind: ItemKind::Agent,
148 name: "coder".into(),
149 },
150 source_name: "source-a".into(),
151 origin: crate::types::SourceOrigin::Dependency("source-a".into()),
152 source_id: SourceId::Path {
153 canonical: agent_path.clone(),
154 subpath: None,
155 },
156 source_path: agent_path.clone(),
157 dest_path: "agents/coder.md".into(),
158 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
159 is_flat_skill: false,
160 rewritten_content: None,
161 },
162 );
163 items.insert(
164 "skills/plan__org_base".into(),
165 TargetItem {
166 id: ItemId {
167 kind: ItemKind::Skill,
168 name: "plan__org_base".into(),
169 },
170 source_name: "source-a".into(),
171 origin: crate::types::SourceOrigin::Dependency("source-a".into()),
172 source_id: SourceId::Path {
173 canonical: skill_path.clone(),
174 subpath: None,
175 },
176 source_path: skill_path.clone(),
177 dest_path: "skills/plan__org_base".into(),
178 source_hash: hash::compute_hash(&skill_path, ItemKind::Skill)
179 .unwrap()
180 .into(),
181 is_flat_skill: false,
182 rewritten_content: None,
183 },
184 );
185
186 let mut target = TargetState { items };
187 let renames = vec![ExplicitSkillRename {
188 original_name: "plan".into(),
189 new_name: "plan__org_base".into(),
190 source_name: "source-a".into(),
191 }];
192 let graph = ResolvedGraph {
193 nodes: IndexMap::new(),
194 order: vec![],
195 filters: std::collections::HashMap::new(),
196 };
197
198 rewrite_skill_refs(&mut target, &renames, &graph).unwrap();
199
200 let rewritten = target.items["agents/coder.md"]
201 .rewritten_content
202 .as_ref()
203 .unwrap();
204 let fm = crate::frontmatter::parse(rewritten).unwrap();
205 assert_eq!(fm.skills(), vec!["plan__org_base", "planner"]);
206 }
207
208 #[test]
209 fn rewrite_skill_refs_leaves_non_matching_agents_unchanged() {
210 let dir = TempDir::new().unwrap();
211 let agent_path = dir.path().join("agents/coder.md");
212 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
213 fs::write(&agent_path, "---\nskills: [review]\n---\n# Agent\n").unwrap();
214
215 let mut items = IndexMap::new();
216 items.insert(
217 "agents/coder.md".into(),
218 TargetItem {
219 id: ItemId {
220 kind: ItemKind::Agent,
221 name: "coder".into(),
222 },
223 source_name: "source-a".into(),
224 origin: crate::types::SourceOrigin::Dependency("source-a".into()),
225 source_id: SourceId::Path {
226 canonical: agent_path.clone(),
227 subpath: None,
228 },
229 source_path: agent_path.clone(),
230 dest_path: "agents/coder.md".into(),
231 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
232 is_flat_skill: false,
233 rewritten_content: None,
234 },
235 );
236
237 let mut target = TargetState { items };
238 let renames = vec![ExplicitSkillRename {
239 original_name: "plan".into(),
240 new_name: "plan__org_base".into(),
241 source_name: "source-a".into(),
242 }];
243 let graph = ResolvedGraph {
244 nodes: IndexMap::new(),
245 order: vec![],
246 filters: std::collections::HashMap::new(),
247 };
248
249 rewrite_skill_refs(&mut target, &renames, &graph).unwrap();
250 assert!(target.items["agents/coder.md"].rewritten_content.is_none());
251 }
252
253 #[test]
254 fn rewrite_skill_refs_cross_package_uses_dep_graph() {
255 let dir = TempDir::new().unwrap();
256 let agent_path = dir.path().join("agents/coder.md");
257 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
258 fs::write(&agent_path, "---\nskills:\n- planning\n---\n# Agent\n").unwrap();
259
260 let skill_b_path = dir.path().join("skills/planning__org_b");
261 fs::create_dir_all(&skill_b_path).unwrap();
262 fs::write(skill_b_path.join("SKILL.md"), "# Planning from B").unwrap();
263
264 let skill_c_path = dir.path().join("skills/planning__org_c");
265 fs::create_dir_all(&skill_c_path).unwrap();
266 fs::write(skill_c_path.join("SKILL.md"), "# Planning from C").unwrap();
267
268 let mut items = IndexMap::new();
269 items.insert(
270 "agents/coder.md".into(),
271 TargetItem {
272 id: ItemId {
273 kind: ItemKind::Agent,
274 name: "coder".into(),
275 },
276 source_name: "source-a".into(),
277 origin: crate::types::SourceOrigin::Dependency("source-a".into()),
278 source_id: SourceId::Path {
279 canonical: agent_path.clone(),
280 subpath: None,
281 },
282 source_path: agent_path.clone(),
283 dest_path: "agents/coder.md".into(),
284 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
285 is_flat_skill: false,
286 rewritten_content: None,
287 },
288 );
289 items.insert(
290 "skills/planning__org_b".into(),
291 TargetItem {
292 id: ItemId {
293 kind: ItemKind::Skill,
294 name: "planning__org_b".into(),
295 },
296 source_name: "source-b".into(),
297 origin: crate::types::SourceOrigin::Dependency("source-b".into()),
298 source_id: SourceId::Path {
299 canonical: skill_b_path.clone(),
300 subpath: None,
301 },
302 source_path: skill_b_path.clone(),
303 dest_path: "skills/planning__org_b".into(),
304 source_hash: hash::compute_hash(&skill_b_path, ItemKind::Skill)
305 .unwrap()
306 .into(),
307 is_flat_skill: false,
308 rewritten_content: None,
309 },
310 );
311 items.insert(
312 "skills/planning__org_c".into(),
313 TargetItem {
314 id: ItemId {
315 kind: ItemKind::Skill,
316 name: "planning__org_c".into(),
317 },
318 source_name: "source-c".into(),
319 origin: crate::types::SourceOrigin::Dependency("source-c".into()),
320 source_id: SourceId::Path {
321 canonical: skill_c_path.clone(),
322 subpath: None,
323 },
324 source_path: skill_c_path.clone(),
325 dest_path: "skills/planning__org_c".into(),
326 source_hash: hash::compute_hash(&skill_c_path, ItemKind::Skill)
327 .unwrap()
328 .into(),
329 is_flat_skill: false,
330 rewritten_content: None,
331 },
332 );
333
334 let mut target = TargetState { items };
335 let renames = vec![
336 ExplicitSkillRename {
337 original_name: "planning".into(),
338 new_name: "planning__org_b".into(),
339 source_name: "source-b".into(),
340 },
341 ExplicitSkillRename {
342 original_name: "planning".into(),
343 new_name: "planning__org_c".into(),
344 source_name: "source-c".into(),
345 },
346 ];
347
348 let mut nodes = IndexMap::new();
349 nodes.insert(
350 SourceName::from("source-a"),
351 crate::resolve::ResolvedNode {
352 source_name: "source-a".into(),
353 source_id: SourceId::Path {
354 canonical: dir.path().to_path_buf(),
355 subpath: None,
356 },
357 rooted_ref: crate::resolve::RootedSourceRef {
358 checkout_root: dir.path().to_path_buf(),
359 package_root: dir.path().to_path_buf(),
360 },
361 resolved_ref: crate::source::ResolvedRef {
362 source_name: "source-a".into(),
363 version: None,
364 version_tag: None,
365 commit: None,
366 tree_path: dir.path().to_path_buf(),
367 },
368 latest_version: None,
369 manifest: None,
370 deps: vec!["source-b".into()],
371 },
372 );
373 let graph = ResolvedGraph {
374 nodes,
375 order: vec!["source-a".into()],
376 filters: std::collections::HashMap::new(),
377 };
378
379 rewrite_skill_refs(&mut target, &renames, &graph).unwrap();
380
381 let rewritten = target.items["agents/coder.md"]
382 .rewritten_content
383 .as_ref()
384 .expect("agent should have been rewritten");
385 let fm = crate::frontmatter::parse(rewritten).unwrap();
386 assert_eq!(fm.skills(), vec!["planning__org_b"]);
387 }
388}