Skip to main content

mars_agents/sync/
apply.rs

1use std::path::Path;
2
3use crate::error::MarsError;
4use crate::fs::{atomic_install_dir, atomic_write};
5use crate::lock::{ItemId, ItemKind};
6use crate::platform::fs as fs_ops;
7use crate::sync::plan::{PlannedAction, SyncPlan};
8use crate::sync::target::TargetItem;
9pub use crate::sync::types::SyncOptions;
10use crate::types::{ContentHash, DestPath, ItemName, SourceName};
11
12/// The result of applying the sync plan.
13#[derive(Debug, Clone)]
14pub struct ApplyResult {
15    pub outcomes: Vec<ActionOutcome>,
16}
17
18/// What action was taken for a single item.
19#[derive(Debug, Clone)]
20pub struct ActionOutcome {
21    pub item_id: ItemId,
22    pub action: ActionTaken,
23    pub dest_path: DestPath,
24    /// Which source this item came from.
25    pub source_name: SourceName,
26    /// Source checksum (pre-rewrite hash of source content).
27    pub source_checksum: Option<ContentHash>,
28    /// Installed checksum (post-rewrite hash of what was written to disk).
29    pub installed_checksum: Option<ContentHash>,
30}
31
32/// The specific action taken.
33#[derive(Debug, Clone)]
34pub enum ActionTaken {
35    Installed,
36    Updated,
37    Removed,
38    Skipped,
39    Kept,
40}
41
42/// Execute the sync plan, applying changes to disk.
43///
44/// For each action:
45/// - Install: copy source content to dest (atomic_write or atomic_install_dir)
46/// - Overwrite: replace existing with new source content
47/// - Remove: delete file/dir from disk
48/// - Skip/KeepLocal: record as no-op
49///
50/// Returns outcomes with both source_checksum and installed_checksum.
51/// The installed_checksum may differ from source_checksum when frontmatter
52/// rewriting occurred.
53pub fn execute(
54    root: &Path,
55    plan: &SyncPlan,
56    options: &SyncOptions,
57) -> Result<ApplyResult, MarsError> {
58    let mut outcomes = Vec::new();
59
60    for action in &plan.actions {
61        let outcome = if options.dry_run {
62            // Dry run: compute the outcome without touching disk
63            dry_run_action(action)
64        } else {
65            execute_action(root, action)?
66        };
67        outcomes.push(outcome);
68    }
69
70    Ok(ApplyResult { outcomes })
71}
72
73/// Execute a single action, writing to disk.
74fn execute_action(root: &Path, action: &PlannedAction) -> Result<ActionOutcome, MarsError> {
75    match action {
76        PlannedAction::Install { target } => {
77            let dest = target.dest_path.resolve(root);
78
79            // Read source content and install
80            let installed_checksum = install_item(target, &dest)?;
81
82            Ok(ActionOutcome {
83                item_id: target.id.clone(),
84                action: ActionTaken::Installed,
85                dest_path: target.dest_path.clone(),
86                source_name: target.source_name.clone(),
87                source_checksum: Some(target.source_hash.clone()),
88                installed_checksum: Some(installed_checksum),
89            })
90        }
91
92        PlannedAction::Overwrite { target } => {
93            let dest = target.dest_path.resolve(root);
94
95            // Install (overwrite) source content
96            let installed_checksum = install_item(target, &dest)?;
97
98            Ok(ActionOutcome {
99                item_id: target.id.clone(),
100                action: ActionTaken::Updated,
101                dest_path: target.dest_path.clone(),
102                source_name: target.source_name.clone(),
103                source_checksum: Some(target.source_hash.clone()),
104                installed_checksum: Some(installed_checksum),
105            })
106        }
107
108        PlannedAction::Remove { locked } => {
109            let dest = removal_path(root, &locked.dest_path, locked.kind);
110            if dest.exists() {
111                fs_ops::safe_remove(&dest)?;
112            }
113
114            let item_id = ItemId {
115                kind: locked.kind,
116                name: ItemName::from(locked.dest_path.item_name(locked.kind)),
117            };
118
119            Ok(ActionOutcome {
120                item_id,
121                action: ActionTaken::Removed,
122                dest_path: locked.dest_path.clone(),
123                source_name: locked.source.clone(),
124                source_checksum: None,
125                installed_checksum: None,
126            })
127        }
128
129        PlannedAction::Skip {
130            item_id,
131            dest_path,
132            source_name,
133            installed_checksum,
134        } => Ok(ActionOutcome {
135            item_id: item_id.clone(),
136            action: ActionTaken::Skipped,
137            dest_path: dest_path.clone(),
138            source_name: source_name.clone(),
139            source_checksum: None,
140            installed_checksum: installed_checksum.clone(),
141        }),
142
143        PlannedAction::KeepLocal {
144            item_id,
145            dest_path,
146            source_name,
147        } => Ok(ActionOutcome {
148            item_id: item_id.clone(),
149            action: ActionTaken::Kept,
150            dest_path: dest_path.clone(),
151            source_name: source_name.clone(),
152            source_checksum: None,
153            installed_checksum: None,
154        }),
155    }
156}
157
158/// Produce a dry-run outcome without touching disk.
159fn dry_run_action(action: &PlannedAction) -> ActionOutcome {
160    match action {
161        PlannedAction::Install { target } => ActionOutcome {
162            item_id: target.id.clone(),
163            action: ActionTaken::Installed,
164            dest_path: target.dest_path.clone(),
165            source_name: target.source_name.clone(),
166            source_checksum: Some(target.source_hash.clone()),
167            installed_checksum: None, // Can't know without actually installing
168        },
169        PlannedAction::Overwrite { target } => ActionOutcome {
170            item_id: target.id.clone(),
171            action: ActionTaken::Updated,
172            dest_path: target.dest_path.clone(),
173            source_name: target.source_name.clone(),
174            source_checksum: Some(target.source_hash.clone()),
175            installed_checksum: None,
176        },
177        PlannedAction::Remove { locked } => {
178            let item_id = ItemId {
179                kind: locked.kind,
180                name: ItemName::from(locked.dest_path.item_name(locked.kind)),
181            };
182            ActionOutcome {
183                item_id,
184                action: ActionTaken::Removed,
185                dest_path: locked.dest_path.clone(),
186                source_name: locked.source.clone(),
187                source_checksum: None,
188                installed_checksum: None,
189            }
190        }
191        PlannedAction::Skip {
192            item_id,
193            dest_path,
194            source_name,
195            installed_checksum,
196            ..
197        } => ActionOutcome {
198            item_id: item_id.clone(),
199            action: ActionTaken::Skipped,
200            dest_path: dest_path.clone(),
201            source_name: source_name.clone(),
202            source_checksum: None,
203            installed_checksum: installed_checksum.clone(),
204        },
205        PlannedAction::KeepLocal {
206            item_id,
207            dest_path,
208            source_name,
209        } => ActionOutcome {
210            item_id: item_id.clone(),
211            action: ActionTaken::Kept,
212            dest_path: dest_path.clone(),
213            source_name: source_name.clone(),
214            source_checksum: None,
215            installed_checksum: None,
216        },
217    }
218}
219
220/// Install an item (file or directory) to the destination.
221///
222/// Returns the installed checksum (hash of what was written to disk).
223fn install_item(target: &TargetItem, dest: &Path) -> Result<ContentHash, MarsError> {
224    match target.id.kind {
225        ItemKind::Agent | ItemKind::McpServer => {
226            let content = content_to_install(target)?;
227            write_file_and_verify(dest, &content)
228        }
229        ItemKind::BootstrapDoc => {
230            let doc_dest = dest.parent().ok_or_else(|| {
231                std::io::Error::other(format!(
232                    "bootstrap destination has no parent directory: {}",
233                    dest.display()
234                ))
235            })?;
236            atomic_install_dir(&target.source_path, doc_dest)?;
237            crate::hash::compute_hash(doc_dest, ItemKind::BootstrapDoc).map(ContentHash::from)
238        }
239        ItemKind::Skill | ItemKind::Hook => {
240            if target.is_flat_skill {
241                crate::fs::atomic_install_dir_filtered(
242                    &target.source_path,
243                    dest,
244                    crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
245                )?;
246            } else {
247                atomic_install_dir(&target.source_path, dest)?;
248            }
249            // Skills are verified by hashing the installed directory content.
250            crate::hash::compute_hash(dest, ItemKind::Skill).map(ContentHash::from)
251        }
252    }
253}
254
255/// Write bytes to `dest` and verify persisted bytes hash matches expected.
256fn write_file_and_verify(dest: &Path, content: &[u8]) -> Result<ContentHash, MarsError> {
257    atomic_write(dest, content)?;
258    let expected = ContentHash::from(crate::hash::hash_bytes(content));
259    let persisted = std::fs::read(dest)?;
260    let actual = ContentHash::from(crate::hash::hash_bytes(&persisted));
261    if expected != actual {
262        return Err(std::io::Error::other(format!(
263            "post-write verification failed for {}: expected {expected}, got {actual}",
264            dest.display()
265        ))
266        .into());
267    }
268    Ok(actual)
269}
270
271/// Read bytes to install for an agent, honoring in-memory rewrite overrides.
272fn content_to_install(target: &TargetItem) -> Result<Vec<u8>, MarsError> {
273    if let Some(content) = &target.rewritten_content {
274        Ok(content.as_bytes().to_vec())
275    } else if target.id.kind == ItemKind::BootstrapDoc {
276        Ok(std::fs::read(target.source_path.join("BOOTSTRAP.md"))?)
277    } else {
278        Ok(std::fs::read(&target.source_path)?)
279    }
280}
281
282fn removal_path(root: &Path, dest_path: &DestPath, kind: ItemKind) -> std::path::PathBuf {
283    let dest = dest_path.resolve(root);
284    if kind == ItemKind::BootstrapDoc {
285        if dest_path.as_str().split('/').count() >= 3 {
286            dest.parent()
287                .map(Path::to_path_buf)
288                .unwrap_or_else(|| dest.clone())
289        } else {
290            dest
291        }
292    } else {
293        dest
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::hash;
301    use crate::lock::{ItemId, ItemKind, LockedItem};
302    use crate::sync::plan::{PlannedAction, SyncPlan};
303    use crate::sync::target::TargetItem;
304    use std::fs;
305    use std::path::PathBuf;
306    use tempfile::TempDir;
307
308    fn make_agent_target(name: &str, source_path: PathBuf, content: &[u8]) -> TargetItem {
309        TargetItem {
310            id: ItemId {
311                kind: ItemKind::Agent,
312                name: name.into(),
313            },
314            source_name: "test-source".into(),
315            source_path,
316            dest_path: format!("agents/{name}.md").into(),
317            source_hash: hash::hash_bytes(content).into(),
318            is_flat_skill: false,
319            rewritten_content: None,
320        }
321    }
322
323    fn make_bootstrap_target(name: &str, source_path: PathBuf) -> TargetItem {
324        TargetItem {
325            id: ItemId {
326                kind: ItemKind::BootstrapDoc,
327                name: name.into(),
328            },
329            source_name: "test-source".into(),
330            source_hash: crate::hash::compute_hash(&source_path, ItemKind::BootstrapDoc)
331                .unwrap()
332                .into(),
333            source_path,
334            dest_path: format!("bootstrap/{name}/BOOTSTRAP.md").into(),
335            is_flat_skill: false,
336            rewritten_content: None,
337        }
338    }
339
340    fn setup_source_agent(dir: &Path, name: &str, content: &[u8]) -> PathBuf {
341        let agents_dir = dir.join("source").join("agents");
342        fs::create_dir_all(&agents_dir).unwrap();
343        let path = agents_dir.join(format!("{name}.md"));
344        fs::write(&path, content).unwrap();
345        path
346    }
347
348    // === Install tests ===
349
350    #[test]
351    fn install_creates_new_file() {
352        let root = TempDir::new().unwrap();
353        let source_dir = TempDir::new().unwrap();
354
355        let content = b"# new agent content";
356        let source_path = setup_source_agent(source_dir.path(), "coder", content);
357        let target = make_agent_target("coder", source_path, content);
358
359        let plan = SyncPlan {
360            actions: vec![PlannedAction::Install {
361                target: target.clone(),
362            }],
363        };
364
365        let options = SyncOptions::default();
366
367        let result = execute(root.path(), &plan, &options).unwrap();
368        assert_eq!(result.outcomes.len(), 1);
369
370        let outcome = &result.outcomes[0];
371        assert!(matches!(outcome.action, ActionTaken::Installed));
372
373        // Verify file was created
374        let installed_path = root.path().join("agents/coder.md");
375        assert!(installed_path.exists());
376        assert_eq!(fs::read(&installed_path).unwrap(), content);
377
378        // Verify checksums
379        assert_eq!(
380            outcome.source_checksum.as_deref(),
381            Some(hash::hash_bytes(content).as_str())
382        );
383        assert!(outcome.installed_checksum.is_some());
384    }
385
386    // === Overwrite tests ===
387
388    #[test]
389    fn overwrite_replaces_existing_file() {
390        let root = TempDir::new().unwrap();
391        let source_dir = TempDir::new().unwrap();
392
393        // Create existing file
394        let agents_dir = root.path().join("agents");
395        fs::create_dir_all(&agents_dir).unwrap();
396        fs::write(agents_dir.join("coder.md"), b"# old content").unwrap();
397
398        let new_content = b"# new content";
399        let source_path = setup_source_agent(source_dir.path(), "coder", new_content);
400        let target = make_agent_target("coder", source_path, new_content);
401
402        let plan = SyncPlan {
403            actions: vec![PlannedAction::Overwrite { target }],
404        };
405
406        let options = SyncOptions::default();
407
408        let result = execute(root.path(), &plan, &options).unwrap();
409        assert!(matches!(result.outcomes[0].action, ActionTaken::Updated));
410
411        let installed = fs::read(root.path().join("agents/coder.md")).unwrap();
412        assert_eq!(installed, new_content);
413    }
414
415    #[test]
416    fn install_bootstrap_doc_directory_to_canonical_file_path() {
417        let root = TempDir::new().unwrap();
418        let source_dir = TempDir::new().unwrap();
419        let bootstrap_dir = source_dir.path().join("bootstrap/global-auth");
420        fs::create_dir_all(&bootstrap_dir).unwrap();
421        fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
422
423        let target = make_bootstrap_target("global-auth", bootstrap_dir);
424        let plan = SyncPlan {
425            actions: vec![PlannedAction::Install { target }],
426        };
427        let options = SyncOptions::default();
428
429        let result = execute(root.path(), &plan, &options).unwrap();
430
431        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
432        assert_eq!(
433            fs::read(root.path().join("bootstrap/global-auth/BOOTSTRAP.md")).unwrap(),
434            b"# auth"
435        );
436    }
437
438    // === Remove tests ===
439
440    #[test]
441    fn remove_deletes_file() {
442        let root = TempDir::new().unwrap();
443
444        // Create file to remove
445        let agents_dir = root.path().join("agents");
446        fs::create_dir_all(&agents_dir).unwrap();
447        fs::write(agents_dir.join("orphan.md"), b"# orphan").unwrap();
448
449        let locked = LockedItem {
450            source: "old-source".into(),
451            kind: ItemKind::Agent,
452            version: None,
453            source_checksum: "sha256:aaa".into(),
454            installed_checksum: "sha256:bbb".into(),
455            dest_path: "agents/orphan.md".into(),
456        };
457
458        let plan = SyncPlan {
459            actions: vec![PlannedAction::Remove { locked }],
460        };
461
462        let options = SyncOptions::default();
463
464        let result = execute(root.path(), &plan, &options).unwrap();
465        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
466        assert!(!root.path().join("agents/orphan.md").exists());
467    }
468
469    #[test]
470    fn remove_skill_directory() {
471        let root = TempDir::new().unwrap();
472
473        // Create skill directory
474        let skill_dir = root.path().join("skills/old-skill");
475        fs::create_dir_all(&skill_dir).unwrap();
476        fs::write(skill_dir.join("SKILL.md"), b"# old skill").unwrap();
477
478        let locked = LockedItem {
479            source: "old-source".into(),
480            kind: ItemKind::Skill,
481            version: None,
482            source_checksum: "sha256:aaa".into(),
483            installed_checksum: "sha256:bbb".into(),
484            dest_path: "skills/old-skill".into(),
485        };
486
487        let plan = SyncPlan {
488            actions: vec![PlannedAction::Remove { locked }],
489        };
490
491        let options = SyncOptions::default();
492
493        let result = execute(root.path(), &plan, &options).unwrap();
494        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
495        assert!(!root.path().join("skills/old-skill").exists());
496    }
497
498    #[test]
499    fn remove_bootstrap_doc_removes_container_directory() {
500        let root = TempDir::new().unwrap();
501        let bootstrap_dir = root.path().join("bootstrap/global-auth");
502        fs::create_dir_all(&bootstrap_dir).unwrap();
503        fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
504
505        let locked = LockedItem {
506            source: "old-source".into(),
507            kind: ItemKind::BootstrapDoc,
508            version: None,
509            source_checksum: "sha256:aaa".into(),
510            installed_checksum: "sha256:bbb".into(),
511            dest_path: "bootstrap/global-auth/BOOTSTRAP.md".into(),
512        };
513
514        let plan = SyncPlan {
515            actions: vec![PlannedAction::Remove { locked }],
516        };
517        let options = SyncOptions::default();
518
519        let result = execute(root.path(), &plan, &options).unwrap();
520        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
521        assert!(!bootstrap_dir.exists());
522    }
523
524    #[test]
525    fn remove_degenerate_bootstrap_doc_path_removes_exact_file_only() {
526        let root = TempDir::new().unwrap();
527        let bootstrap_dir = root.path().join("bootstrap");
528        fs::create_dir_all(&bootstrap_dir).unwrap();
529        fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# root").unwrap();
530        fs::write(bootstrap_dir.join("keep.md"), b"# keep").unwrap();
531
532        let locked = LockedItem {
533            source: "old-source".into(),
534            kind: ItemKind::BootstrapDoc,
535            version: None,
536            source_checksum: "sha256:aaa".into(),
537            installed_checksum: "sha256:bbb".into(),
538            dest_path: "bootstrap/BOOTSTRAP.md".into(),
539        };
540
541        let plan = SyncPlan {
542            actions: vec![PlannedAction::Remove { locked }],
543        };
544        let options = SyncOptions::default();
545
546        let result = execute(root.path(), &plan, &options).unwrap();
547        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
548        assert!(!bootstrap_dir.join("BOOTSTRAP.md").exists());
549        assert!(bootstrap_dir.join("keep.md").exists());
550    }
551
552    // === Dry run tests ===
553
554    #[test]
555    fn dry_run_does_not_modify_files() {
556        let root = TempDir::new().unwrap();
557        let source_dir = TempDir::new().unwrap();
558
559        let content = b"# new agent";
560        let source_path = setup_source_agent(source_dir.path(), "coder", content);
561        let target = make_agent_target("coder", source_path, content);
562
563        let plan = SyncPlan {
564            actions: vec![PlannedAction::Install { target }],
565        };
566
567        let options = SyncOptions {
568            dry_run: true,
569            ..SyncOptions::default()
570        };
571
572        let result = execute(root.path(), &plan, &options).unwrap();
573        assert_eq!(result.outcomes.len(), 1);
574        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
575
576        // File should NOT exist
577        assert!(!root.path().join("agents/coder.md").exists());
578    }
579
580    // === Skip/KeepLocal tests ===
581
582    #[test]
583    fn skip_produces_skipped_outcome() {
584        let root = TempDir::new().unwrap();
585
586        let plan = SyncPlan {
587            actions: vec![PlannedAction::Skip {
588                item_id: ItemId {
589                    kind: ItemKind::Agent,
590                    name: "stable".into(),
591                },
592                dest_path: "agents/stable.md".into(),
593                source_name: "base".into(),
594                installed_checksum: Some("sha256:stable".into()),
595            }],
596        };
597
598        let options = SyncOptions::default();
599
600        let result = execute(root.path(), &plan, &options).unwrap();
601        assert!(matches!(result.outcomes[0].action, ActionTaken::Skipped));
602        assert_eq!(
603            result.outcomes[0].dest_path,
604            crate::types::DestPath::from("agents/stable.md")
605        );
606        assert_eq!(result.outcomes[0].source_name, "base");
607        assert_eq!(
608            result.outcomes[0].installed_checksum.as_deref(),
609            Some("sha256:stable")
610        );
611    }
612
613    #[test]
614    fn keep_local_produces_kept_outcome() {
615        let root = TempDir::new().unwrap();
616
617        let plan = SyncPlan {
618            actions: vec![PlannedAction::KeepLocal {
619                item_id: ItemId {
620                    kind: ItemKind::Agent,
621                    name: "modified".into(),
622                },
623                dest_path: "agents/modified.md".into(),
624                source_name: "base".into(),
625            }],
626        };
627
628        let options = SyncOptions::default();
629
630        let result = execute(root.path(), &plan, &options).unwrap();
631        assert!(matches!(result.outcomes[0].action, ActionTaken::Kept));
632        assert_eq!(
633            result.outcomes[0].dest_path,
634            crate::types::DestPath::from("agents/modified.md")
635        );
636        assert_eq!(result.outcomes[0].source_name, "base");
637    }
638
639    // === Install skill directory tests ===
640
641    #[test]
642    fn install_skill_directory() {
643        let root = TempDir::new().unwrap();
644        let source_dir = TempDir::new().unwrap();
645
646        // Create source skill directory
647        let source_skill = source_dir.path().join("skills/planning");
648        fs::create_dir_all(&source_skill).unwrap();
649        fs::write(source_skill.join("SKILL.md"), b"# Planning skill").unwrap();
650        fs::write(source_skill.join("helper.md"), b"# Helper").unwrap();
651
652        let skill_hash = hash::compute_hash(&source_skill, ItemKind::Skill).unwrap();
653
654        let target = TargetItem {
655            id: ItemId {
656                kind: ItemKind::Skill,
657                name: "planning".into(),
658            },
659            source_name: "test".into(),
660            source_path: source_skill,
661            dest_path: "skills/planning".into(),
662            source_hash: skill_hash.into(),
663            is_flat_skill: false,
664            rewritten_content: None,
665        };
666
667        let plan = SyncPlan {
668            actions: vec![PlannedAction::Install { target }],
669        };
670
671        let options = SyncOptions::default();
672
673        let result = execute(root.path(), &plan, &options).unwrap();
674        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
675
676        let installed_dir = root.path().join("skills/planning");
677        assert!(installed_dir.exists());
678        assert!(installed_dir.join("SKILL.md").exists());
679        assert!(installed_dir.join("helper.md").exists());
680        assert_eq!(
681            fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
682            "# Planning skill"
683        );
684    }
685
686    #[test]
687    fn install_flat_skill_excludes_repo_metadata() {
688        let root = TempDir::new().unwrap();
689        let source_dir = TempDir::new().unwrap();
690
691        let flat_source = source_dir.path().join("flat-skill");
692        fs::create_dir_all(flat_source.join(".git")).unwrap();
693        fs::create_dir_all(flat_source.join("resources")).unwrap();
694        fs::write(flat_source.join("SKILL.md"), b"# Flat skill").unwrap();
695        fs::write(flat_source.join("resources/guide.md"), b"# Guide").unwrap();
696        fs::write(flat_source.join("mars.toml"), b"[sources]").unwrap();
697        fs::write(flat_source.join(".gitignore"), b"target/").unwrap();
698        fs::write(flat_source.join(".git/config"), b"[core]").unwrap();
699
700        let source_hash = hash::compute_skill_hash_filtered(
701            &flat_source,
702            crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
703        )
704        .unwrap();
705
706        let target = TargetItem {
707            id: ItemId {
708                kind: ItemKind::Skill,
709                name: "flat-skill".into(),
710            },
711            source_name: "test".into(),
712            source_path: flat_source,
713            dest_path: "skills/flat-skill".into(),
714            source_hash: source_hash.into(),
715            is_flat_skill: true,
716            rewritten_content: None,
717        };
718
719        let plan = SyncPlan {
720            actions: vec![PlannedAction::Install { target }],
721        };
722
723        let options = SyncOptions::default();
724
725        execute(root.path(), &plan, &options).unwrap();
726
727        let installed = root.path().join("skills/flat-skill");
728        assert!(installed.join("SKILL.md").exists());
729        assert!(installed.join("resources/guide.md").exists());
730        assert!(!installed.join(".git").exists());
731        assert!(!installed.join("mars.toml").exists());
732        assert!(!installed.join(".gitignore").exists());
733    }
734
735    // === DestPath::item_name tests ===
736
737    #[test]
738    fn extract_agent_name() {
739        assert_eq!(
740            crate::types::DestPath::from("agents/coder.md").item_name(ItemKind::Agent),
741            "coder"
742        );
743    }
744
745    #[test]
746    fn extract_skill_name() {
747        assert_eq!(
748            crate::types::DestPath::from("skills/planning").item_name(ItemKind::Skill),
749            "planning"
750        );
751    }
752}