Skip to main content

mars_agents/sync/
rewrite.rs

1//! Frontmatter skill reference rewriting after collision-driven renames.
2//!
3//! When a collision forces a rename and affected agents have frontmatter
4//! `skills:` references to the renamed skill, this module rewrites those
5//! references to point at the correct renamed version.
6
7use 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
18/// Rewrite frontmatter skill references for renamed transitive deps.
19///
20/// When a collision forces a rename AND affected agents have frontmatter
21/// `skills:` references to the renamed skill, mars rewrites those references
22/// to point at the correct renamed version.
23pub 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    // Build rename map for skills only:
35    // original skill name -> [(renamed skill name, source name)].
36    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    // For each agent in target, check if it references any renamed skills
55    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}