Skip to main content

mars_agents/sync/
apply.rs

1use std::path::Path;
2
3use crate::error::MarsError;
4use crate::lock::{ItemId, ItemKind};
5use crate::reconcile::fs_ops;
6use crate::sync::plan::{PlannedAction, SyncPlan};
7use crate::sync::target::TargetItem;
8pub use crate::sync::types::SyncOptions;
9use crate::types::{ContentHash, DestPath, ItemName, SourceName};
10
11/// The result of applying the sync plan.
12#[derive(Debug, Clone)]
13pub struct ApplyResult {
14    pub outcomes: Vec<ActionOutcome>,
15}
16
17/// What action was taken for a single item.
18#[derive(Debug, Clone)]
19pub struct ActionOutcome {
20    pub item_id: ItemId,
21    pub action: ActionTaken,
22    pub dest_path: DestPath,
23    /// Which source this item came from.
24    pub source_name: SourceName,
25    /// Source checksum (pre-rewrite hash of source content).
26    pub source_checksum: Option<ContentHash>,
27    /// Installed checksum (post-rewrite hash of what was written to disk).
28    pub installed_checksum: Option<ContentHash>,
29}
30
31/// The specific action taken.
32#[derive(Debug, Clone)]
33pub enum ActionTaken {
34    Installed,
35    Updated,
36    Merged,
37    Conflicted,
38    Removed,
39    Skipped,
40    Kept,
41}
42
43/// Execute the sync plan, applying changes to disk.
44///
45/// For each action:
46/// - Install: copy source content to dest (atomic_write or atomic_install_dir)
47/// - Overwrite: replace existing with new source content
48/// - Merge: three-way merge using base from cache
49/// - Remove: delete file/dir from disk
50/// - Skip/KeepLocal: record as no-op
51///
52/// Returns outcomes with both source_checksum and installed_checksum.
53/// The installed_checksum may differ from source_checksum when frontmatter
54/// rewriting occurred.
55pub fn execute(
56    root: &Path,
57    plan: &SyncPlan,
58    options: &SyncOptions,
59    cache_bases_dir: &Path,
60) -> Result<ApplyResult, MarsError> {
61    let mut outcomes = Vec::new();
62
63    for action in &plan.actions {
64        let outcome = if options.dry_run {
65            // Dry run: compute the outcome without touching disk
66            dry_run_action(action)
67        } else {
68            execute_action(root, action, cache_bases_dir)?
69        };
70        outcomes.push(outcome);
71    }
72
73    Ok(ApplyResult { outcomes })
74}
75
76/// Execute a single action, writing to disk.
77fn execute_action(
78    root: &Path,
79    action: &PlannedAction,
80    cache_bases_dir: &Path,
81) -> Result<ActionOutcome, MarsError> {
82    match action {
83        PlannedAction::Install { target } => {
84            let dest = target.dest_path.resolve(root);
85
86            // Read source content and install
87            let installed_checksum = install_item(target, &dest)?;
88
89            // Cache the installed content as base for future merges
90            cache_base_content(cache_bases_dir, &installed_checksum, &dest, target.id.kind)?;
91
92            Ok(ActionOutcome {
93                item_id: target.id.clone(),
94                action: ActionTaken::Installed,
95                dest_path: target.dest_path.clone(),
96                source_name: target.source_name.clone(),
97                source_checksum: Some(target.source_hash.clone()),
98                installed_checksum: Some(installed_checksum),
99            })
100        }
101
102        PlannedAction::Overwrite { target } => {
103            let dest = target.dest_path.resolve(root);
104
105            // Install (overwrite) source content
106            let installed_checksum = install_item(target, &dest)?;
107
108            // Update base cache
109            cache_base_content(cache_bases_dir, &installed_checksum, &dest, target.id.kind)?;
110
111            Ok(ActionOutcome {
112                item_id: target.id.clone(),
113                action: ActionTaken::Updated,
114                dest_path: target.dest_path.clone(),
115                source_name: target.source_name.clone(),
116                source_checksum: Some(target.source_hash.clone()),
117                installed_checksum: Some(installed_checksum),
118            })
119        }
120
121        PlannedAction::Merge {
122            target,
123            base_content,
124            local_path,
125        } => {
126            let dest = target.dest_path.resolve(root);
127            let full_local_path = root.join(local_path);
128
129            // Read source (theirs) content
130            let theirs_content = read_target_content_for_merge(target)?;
131
132            // Read local content
133            let local_content = read_item_content(&full_local_path, target.id.kind)?;
134
135            // Perform three-way merge
136            let labels = crate::merge::MergeLabels {
137                base: "base (last sync)".into(),
138                local: "local".into(),
139                theirs: format!("{}@{}", target.source_name, "upstream"),
140            };
141
142            let merge_result = crate::merge::merge_content(
143                base_content,
144                &local_content,
145                &theirs_content,
146                &labels,
147            )?;
148
149            // Write merged content and verify persisted bytes before recording checksum.
150            let installed_checksum = write_file_and_verify(&dest, &merge_result.content)?;
151
152            // Cache the merged content as new base
153            cache_base_content(cache_bases_dir, &installed_checksum, &dest, target.id.kind)?;
154
155            let action_taken = if merge_result.has_conflicts {
156                ActionTaken::Conflicted
157            } else {
158                ActionTaken::Merged
159            };
160
161            Ok(ActionOutcome {
162                item_id: target.id.clone(),
163                action: action_taken,
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: Some(installed_checksum),
168            })
169        }
170
171        PlannedAction::Remove { locked } => {
172            let dest = locked.dest_path.resolve(root);
173            if dest.exists() {
174                fs_ops::safe_remove(&dest)?;
175            }
176
177            let item_id = ItemId {
178                kind: locked.kind,
179                name: ItemName::from(extract_name_from_dest(&locked.dest_path, locked.kind)),
180            };
181
182            Ok(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
192        PlannedAction::Skip {
193            item_id,
194            dest_path,
195            source_name,
196            installed_checksum,
197            reason: _,
198        } => Ok(ActionOutcome {
199            item_id: item_id.clone(),
200            action: ActionTaken::Skipped,
201            dest_path: dest_path.clone(),
202            source_name: source_name.clone(),
203            source_checksum: None,
204            installed_checksum: installed_checksum.clone(),
205        }),
206
207        PlannedAction::KeepLocal {
208            item_id,
209            dest_path,
210            source_name,
211        } => Ok(ActionOutcome {
212            item_id: item_id.clone(),
213            action: ActionTaken::Kept,
214            dest_path: dest_path.clone(),
215            source_name: source_name.clone(),
216            source_checksum: None,
217            installed_checksum: None,
218        }),
219    }
220}
221
222/// Produce a dry-run outcome without touching disk.
223fn dry_run_action(action: &PlannedAction) -> ActionOutcome {
224    match action {
225        PlannedAction::Install { target } => ActionOutcome {
226            item_id: target.id.clone(),
227            action: ActionTaken::Installed,
228            dest_path: target.dest_path.clone(),
229            source_name: target.source_name.clone(),
230            source_checksum: Some(target.source_hash.clone()),
231            installed_checksum: None, // Can't know without actually installing
232        },
233        PlannedAction::Overwrite { target } => ActionOutcome {
234            item_id: target.id.clone(),
235            action: ActionTaken::Updated,
236            dest_path: target.dest_path.clone(),
237            source_name: target.source_name.clone(),
238            source_checksum: Some(target.source_hash.clone()),
239            installed_checksum: None,
240        },
241        PlannedAction::Merge { target, .. } => ActionOutcome {
242            item_id: target.id.clone(),
243            action: ActionTaken::Merged,
244            dest_path: target.dest_path.clone(),
245            source_name: target.source_name.clone(),
246            source_checksum: Some(target.source_hash.clone()),
247            installed_checksum: None,
248        },
249        PlannedAction::Remove { locked } => {
250            let item_id = ItemId {
251                kind: locked.kind,
252                name: ItemName::from(extract_name_from_dest(&locked.dest_path, locked.kind)),
253            };
254            ActionOutcome {
255                item_id,
256                action: ActionTaken::Removed,
257                dest_path: locked.dest_path.clone(),
258                source_name: locked.source.clone(),
259                source_checksum: None,
260                installed_checksum: None,
261            }
262        }
263        PlannedAction::Skip {
264            item_id,
265            dest_path,
266            source_name,
267            installed_checksum,
268            ..
269        } => ActionOutcome {
270            item_id: item_id.clone(),
271            action: ActionTaken::Skipped,
272            dest_path: dest_path.clone(),
273            source_name: source_name.clone(),
274            source_checksum: None,
275            installed_checksum: installed_checksum.clone(),
276        },
277        PlannedAction::KeepLocal {
278            item_id,
279            dest_path,
280            source_name,
281        } => ActionOutcome {
282            item_id: item_id.clone(),
283            action: ActionTaken::Kept,
284            dest_path: dest_path.clone(),
285            source_name: source_name.clone(),
286            source_checksum: None,
287            installed_checksum: None,
288        },
289    }
290}
291
292/// Install an item (file or directory) to the destination.
293///
294/// Returns the installed checksum (hash of what was written to disk).
295fn install_item(target: &TargetItem, dest: &Path) -> Result<ContentHash, MarsError> {
296    match target.id.kind {
297        ItemKind::Agent => {
298            let content = content_to_install(target)?;
299            write_file_and_verify(dest, &content)
300        }
301        ItemKind::Skill => {
302            if target.is_flat_skill {
303                crate::fs::atomic_install_dir_filtered(
304                    &target.source_path,
305                    dest,
306                    crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
307                )?;
308            } else {
309                fs_ops::atomic_install_dir(&target.source_path, dest)?;
310            }
311            // Skills are verified by hashing the installed directory content.
312            crate::hash::compute_hash(dest, ItemKind::Skill).map(ContentHash::from)
313        }
314    }
315}
316
317/// Write bytes to `dest` and verify persisted bytes hash matches expected.
318fn write_file_and_verify(dest: &Path, content: &[u8]) -> Result<ContentHash, MarsError> {
319    fs_ops::atomic_write_file(dest, content)?;
320    let expected = ContentHash::from(crate::hash::hash_bytes(content));
321    let persisted = std::fs::read(dest)?;
322    let actual = ContentHash::from(crate::hash::hash_bytes(&persisted));
323    if expected != actual {
324        return Err(std::io::Error::other(format!(
325            "post-write verification failed for {}: expected {expected}, got {actual}",
326            dest.display()
327        ))
328        .into());
329    }
330    Ok(actual)
331}
332
333/// Read bytes to install for an agent, honoring in-memory rewrite overrides.
334fn content_to_install(target: &TargetItem) -> Result<Vec<u8>, MarsError> {
335    if let Some(content) = &target.rewritten_content {
336        Ok(content.as_bytes().to_vec())
337    } else {
338        Ok(std::fs::read(&target.source_path)?)
339    }
340}
341
342/// Read source content for merge operations.
343fn read_target_content_for_merge(target: &TargetItem) -> Result<Vec<u8>, MarsError> {
344    match target.id.kind {
345        ItemKind::Agent => content_to_install(target),
346        ItemKind::Skill => read_item_content(&target.source_path, target.id.kind),
347    }
348}
349
350/// Read content from an item (file for agents, concatenated for skills).
351/// For merge purposes, we only support file-level merge (agents).
352/// Skills that need merging would require per-file merge, which is complex.
353/// For now, read the primary file content.
354fn read_item_content(path: &Path, kind: ItemKind) -> Result<Vec<u8>, MarsError> {
355    match kind {
356        ItemKind::Agent => Ok(std::fs::read(path)?),
357        ItemKind::Skill => {
358            // For skills (directories), read the SKILL.md as the merge target
359            let skill_md = path.join("SKILL.md");
360            if skill_md.exists() {
361                Ok(std::fs::read(&skill_md)?)
362            } else {
363                Ok(Vec::new())
364            }
365        }
366    }
367}
368
369/// Cache base content for future three-way merges.
370///
371/// Content-addressed by installed checksum. Written after every install/overwrite.
372/// Missing cache = degrade to two-way diff (more conflict markers), not crash.
373fn cache_base_content(
374    cache_bases_dir: &Path,
375    installed_checksum: &ContentHash,
376    dest: &Path,
377    kind: ItemKind,
378) -> Result<(), MarsError> {
379    std::fs::create_dir_all(cache_bases_dir)?;
380    // Replace colon with underscore for Windows filename compatibility.
381    let safe_filename = installed_checksum.as_ref().replace(':', "_");
382    let cache_path = cache_bases_dir.join(&safe_filename);
383
384    // Only cache if not already present (content-addressed = immutable)
385    if cache_path.exists() {
386        return Ok(());
387    }
388
389    match kind {
390        ItemKind::Agent => {
391            let content = std::fs::read(dest)?;
392            fs_ops::atomic_write_file(&cache_path, &content)?;
393        }
394        ItemKind::Skill => {
395            // For skills, cache the SKILL.md content (the merge-relevant part)
396            let skill_md = dest.join("SKILL.md");
397            if skill_md.exists() {
398                let content = std::fs::read(&skill_md)?;
399                fs_ops::atomic_write_file(&cache_path, &content)?;
400            }
401        }
402    }
403
404    Ok(())
405}
406
407/// Extract the item name from a destination path.
408fn extract_name_from_dest(dest_path: &DestPath, kind: ItemKind) -> String {
409    let last = dest_path.as_str().rsplit('/').next().unwrap_or("");
410    match kind {
411        ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
412        ItemKind::Skill => last.to_string(),
413    }
414}
415
416/// Prune orphans: items in old lock but not in new target.
417///
418/// This is handled by the Remove action in the plan, but exposed
419/// separately for the sync pipeline if needed.
420pub fn prune_orphans(
421    root: &Path,
422    lock: &crate::lock::LockFile,
423    target: &crate::sync::target::TargetState,
424) -> Result<Vec<ActionOutcome>, MarsError> {
425    let mut outcomes = Vec::new();
426
427    for (dest_path_str, locked_item) in &lock.items {
428        if !target.items.contains_key(dest_path_str) {
429            let dest = dest_path_str.resolve(root);
430            if dest.exists() {
431                fs_ops::safe_remove(&dest)?;
432            }
433            outcomes.push(ActionOutcome {
434                item_id: ItemId {
435                    kind: locked_item.kind,
436                    name: ItemName::from(extract_name_from_dest(dest_path_str, locked_item.kind)),
437                },
438                action: ActionTaken::Removed,
439                dest_path: dest_path_str.clone(),
440                source_name: locked_item.source.clone(),
441                source_checksum: None,
442                installed_checksum: None,
443            });
444        }
445    }
446
447    Ok(outcomes)
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::hash;
454    use crate::lock::{ItemId, ItemKind, LockedItem};
455    use crate::sync::plan::{PlannedAction, SyncPlan};
456    use crate::sync::target::TargetItem;
457    use std::fs;
458    use std::path::PathBuf;
459    use tempfile::TempDir;
460
461    fn make_agent_target(name: &str, source_path: PathBuf, content: &[u8]) -> TargetItem {
462        TargetItem {
463            id: ItemId {
464                kind: ItemKind::Agent,
465                name: name.into(),
466            },
467            source_name: "test-source".into(),
468            origin: crate::types::SourceOrigin::Dependency("test-source".into()),
469            source_id: crate::types::SourceId::Path {
470                canonical: source_path.clone(),
471                subpath: None,
472            },
473            source_path,
474            dest_path: format!("agents/{name}.md").into(),
475            source_hash: hash::hash_bytes(content).into(),
476            is_flat_skill: false,
477            rewritten_content: None,
478        }
479    }
480
481    fn setup_source_agent(dir: &Path, name: &str, content: &[u8]) -> PathBuf {
482        let agents_dir = dir.join("source").join("agents");
483        fs::create_dir_all(&agents_dir).unwrap();
484        let path = agents_dir.join(format!("{name}.md"));
485        fs::write(&path, content).unwrap();
486        path
487    }
488
489    // === Install tests ===
490
491    #[test]
492    fn install_creates_new_file() {
493        let root = TempDir::new().unwrap();
494        let source_dir = TempDir::new().unwrap();
495        let cache_dir = TempDir::new().unwrap();
496        let bases_dir = cache_dir.path().join("bases");
497
498        let content = b"# new agent content";
499        let source_path = setup_source_agent(source_dir.path(), "coder", content);
500        let target = make_agent_target("coder", source_path, content);
501
502        let plan = SyncPlan {
503            actions: vec![PlannedAction::Install {
504                target: target.clone(),
505            }],
506        };
507
508        let options = SyncOptions {
509            force: false,
510            dry_run: false,
511            frozen: false,
512            no_refresh_models: false,
513        };
514
515        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
516        assert_eq!(result.outcomes.len(), 1);
517
518        let outcome = &result.outcomes[0];
519        assert!(matches!(outcome.action, ActionTaken::Installed));
520
521        // Verify file was created
522        let installed_path = root.path().join("agents/coder.md");
523        assert!(installed_path.exists());
524        assert_eq!(fs::read(&installed_path).unwrap(), content);
525
526        // Verify checksums
527        assert_eq!(
528            outcome.source_checksum.as_deref(),
529            Some(hash::hash_bytes(content).as_str())
530        );
531        assert!(outcome.installed_checksum.is_some());
532    }
533
534    #[test]
535    fn install_caches_base_content() {
536        let root = TempDir::new().unwrap();
537        let source_dir = TempDir::new().unwrap();
538        let cache_dir = TempDir::new().unwrap();
539        let bases_dir = cache_dir.path().join("bases");
540
541        let content = b"# cached content";
542        let source_path = setup_source_agent(source_dir.path(), "coder", content);
543        let target = make_agent_target("coder", source_path, content);
544
545        let plan = SyncPlan {
546            actions: vec![PlannedAction::Install { target }],
547        };
548
549        let options = SyncOptions {
550            force: false,
551            dry_run: false,
552            frozen: false,
553            no_refresh_models: false,
554        };
555
556        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
557        let installed_checksum = result.outcomes[0].installed_checksum.as_ref().unwrap();
558
559        // Verify base content was cached
560        let cached = bases_dir.join(installed_checksum.as_ref().replace(':', "_"));
561        assert!(cached.exists(), "base content should be cached");
562        assert_eq!(fs::read(&cached).unwrap(), content);
563    }
564
565    // === Overwrite tests ===
566
567    #[test]
568    fn overwrite_replaces_existing_file() {
569        let root = TempDir::new().unwrap();
570        let source_dir = TempDir::new().unwrap();
571        let cache_dir = TempDir::new().unwrap();
572        let bases_dir = cache_dir.path().join("bases");
573
574        // Create existing file
575        let agents_dir = root.path().join("agents");
576        fs::create_dir_all(&agents_dir).unwrap();
577        fs::write(agents_dir.join("coder.md"), b"# old content").unwrap();
578
579        let new_content = b"# new content";
580        let source_path = setup_source_agent(source_dir.path(), "coder", new_content);
581        let target = make_agent_target("coder", source_path, new_content);
582
583        let plan = SyncPlan {
584            actions: vec![PlannedAction::Overwrite { target }],
585        };
586
587        let options = SyncOptions {
588            force: false,
589            dry_run: false,
590            frozen: false,
591            no_refresh_models: false,
592        };
593
594        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
595        assert!(matches!(result.outcomes[0].action, ActionTaken::Updated));
596
597        let installed = fs::read(root.path().join("agents/coder.md")).unwrap();
598        assert_eq!(installed, new_content);
599    }
600
601    // === Remove tests ===
602
603    #[test]
604    fn remove_deletes_file() {
605        let root = TempDir::new().unwrap();
606        let cache_dir = TempDir::new().unwrap();
607        let bases_dir = cache_dir.path().join("bases");
608
609        // Create file to remove
610        let agents_dir = root.path().join("agents");
611        fs::create_dir_all(&agents_dir).unwrap();
612        fs::write(agents_dir.join("orphan.md"), b"# orphan").unwrap();
613
614        let locked = LockedItem {
615            source: "old-source".into(),
616            kind: ItemKind::Agent,
617            version: None,
618            source_checksum: "sha256:aaa".into(),
619            installed_checksum: "sha256:bbb".into(),
620            dest_path: "agents/orphan.md".into(),
621        };
622
623        let plan = SyncPlan {
624            actions: vec![PlannedAction::Remove { locked }],
625        };
626
627        let options = SyncOptions {
628            force: false,
629            dry_run: false,
630            frozen: false,
631            no_refresh_models: false,
632        };
633
634        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
635        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
636        assert!(!root.path().join("agents/orphan.md").exists());
637    }
638
639    #[test]
640    fn remove_skill_directory() {
641        let root = TempDir::new().unwrap();
642        let cache_dir = TempDir::new().unwrap();
643        let bases_dir = cache_dir.path().join("bases");
644
645        // Create skill directory
646        let skill_dir = root.path().join("skills/old-skill");
647        fs::create_dir_all(&skill_dir).unwrap();
648        fs::write(skill_dir.join("SKILL.md"), b"# old skill").unwrap();
649
650        let locked = LockedItem {
651            source: "old-source".into(),
652            kind: ItemKind::Skill,
653            version: None,
654            source_checksum: "sha256:aaa".into(),
655            installed_checksum: "sha256:bbb".into(),
656            dest_path: "skills/old-skill".into(),
657        };
658
659        let plan = SyncPlan {
660            actions: vec![PlannedAction::Remove { locked }],
661        };
662
663        let options = SyncOptions {
664            force: false,
665            dry_run: false,
666            frozen: false,
667            no_refresh_models: false,
668        };
669
670        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
671        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
672        assert!(!root.path().join("skills/old-skill").exists());
673    }
674
675    // === Dry run tests ===
676
677    #[test]
678    fn dry_run_does_not_modify_files() {
679        let root = TempDir::new().unwrap();
680        let source_dir = TempDir::new().unwrap();
681        let cache_dir = TempDir::new().unwrap();
682        let bases_dir = cache_dir.path().join("bases");
683
684        let content = b"# new agent";
685        let source_path = setup_source_agent(source_dir.path(), "coder", content);
686        let target = make_agent_target("coder", source_path, content);
687
688        let plan = SyncPlan {
689            actions: vec![PlannedAction::Install { target }],
690        };
691
692        let options = SyncOptions {
693            force: false,
694            dry_run: true,
695            frozen: false,
696            no_refresh_models: false,
697        };
698
699        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
700        assert_eq!(result.outcomes.len(), 1);
701        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
702
703        // File should NOT exist
704        assert!(!root.path().join("agents/coder.md").exists());
705    }
706
707    // === Skip/KeepLocal tests ===
708
709    #[test]
710    fn skip_produces_skipped_outcome() {
711        let root = TempDir::new().unwrap();
712        let cache_dir = TempDir::new().unwrap();
713        let bases_dir = cache_dir.path().join("bases");
714
715        let plan = SyncPlan {
716            actions: vec![PlannedAction::Skip {
717                item_id: ItemId {
718                    kind: ItemKind::Agent,
719                    name: "stable".into(),
720                },
721                dest_path: "agents/stable.md".into(),
722                source_name: "base".into(),
723                installed_checksum: Some("sha256:stable".into()),
724                reason: "unchanged",
725            }],
726        };
727
728        let options = SyncOptions {
729            force: false,
730            dry_run: false,
731            frozen: false,
732            no_refresh_models: false,
733        };
734
735        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
736        assert!(matches!(result.outcomes[0].action, ActionTaken::Skipped));
737        assert_eq!(
738            result.outcomes[0].dest_path,
739            crate::types::DestPath::from("agents/stable.md")
740        );
741        assert_eq!(result.outcomes[0].source_name, "base");
742        assert_eq!(
743            result.outcomes[0].installed_checksum.as_deref(),
744            Some("sha256:stable")
745        );
746    }
747
748    #[test]
749    fn keep_local_produces_kept_outcome() {
750        let root = TempDir::new().unwrap();
751        let cache_dir = TempDir::new().unwrap();
752        let bases_dir = cache_dir.path().join("bases");
753
754        let plan = SyncPlan {
755            actions: vec![PlannedAction::KeepLocal {
756                item_id: ItemId {
757                    kind: ItemKind::Agent,
758                    name: "modified".into(),
759                },
760                dest_path: "agents/modified.md".into(),
761                source_name: "base".into(),
762            }],
763        };
764
765        let options = SyncOptions {
766            force: false,
767            dry_run: false,
768            frozen: false,
769            no_refresh_models: false,
770        };
771
772        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
773        assert!(matches!(result.outcomes[0].action, ActionTaken::Kept));
774        assert_eq!(
775            result.outcomes[0].dest_path,
776            crate::types::DestPath::from("agents/modified.md")
777        );
778        assert_eq!(result.outcomes[0].source_name, "base");
779    }
780
781    // === Install skill directory tests ===
782
783    #[test]
784    fn install_skill_directory() {
785        let root = TempDir::new().unwrap();
786        let source_dir = TempDir::new().unwrap();
787        let cache_dir = TempDir::new().unwrap();
788        let bases_dir = cache_dir.path().join("bases");
789
790        // Create source skill directory
791        let source_skill = source_dir.path().join("skills/planning");
792        fs::create_dir_all(&source_skill).unwrap();
793        fs::write(source_skill.join("SKILL.md"), b"# Planning skill").unwrap();
794        fs::write(source_skill.join("helper.md"), b"# Helper").unwrap();
795
796        let skill_hash = hash::compute_hash(&source_skill, ItemKind::Skill).unwrap();
797
798        let target = TargetItem {
799            id: ItemId {
800                kind: ItemKind::Skill,
801                name: "planning".into(),
802            },
803            source_name: "test".into(),
804            origin: crate::types::SourceOrigin::Dependency("test".into()),
805            source_id: crate::types::SourceId::Path {
806                canonical: source_skill.clone(),
807                subpath: None,
808            },
809            source_path: source_skill,
810            dest_path: "skills/planning".into(),
811            source_hash: skill_hash.into(),
812            is_flat_skill: false,
813            rewritten_content: None,
814        };
815
816        let plan = SyncPlan {
817            actions: vec![PlannedAction::Install { target }],
818        };
819
820        let options = SyncOptions {
821            force: false,
822            dry_run: false,
823            frozen: false,
824            no_refresh_models: false,
825        };
826
827        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
828        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
829
830        let installed_dir = root.path().join("skills/planning");
831        assert!(installed_dir.exists());
832        assert!(installed_dir.join("SKILL.md").exists());
833        assert!(installed_dir.join("helper.md").exists());
834        assert_eq!(
835            fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
836            "# Planning skill"
837        );
838    }
839
840    #[test]
841    fn install_flat_skill_excludes_repo_metadata() {
842        let root = TempDir::new().unwrap();
843        let source_dir = TempDir::new().unwrap();
844        let cache_dir = TempDir::new().unwrap();
845        let bases_dir = cache_dir.path().join("bases");
846
847        let flat_source = source_dir.path().join("flat-skill");
848        fs::create_dir_all(flat_source.join(".git")).unwrap();
849        fs::create_dir_all(flat_source.join("resources")).unwrap();
850        fs::write(flat_source.join("SKILL.md"), b"# Flat skill").unwrap();
851        fs::write(flat_source.join("resources/guide.md"), b"# Guide").unwrap();
852        fs::write(flat_source.join("mars.toml"), b"[sources]").unwrap();
853        fs::write(flat_source.join(".gitignore"), b"target/").unwrap();
854        fs::write(flat_source.join(".git/config"), b"[core]").unwrap();
855
856        let source_hash = hash::compute_skill_hash_filtered(
857            &flat_source,
858            crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
859        )
860        .unwrap();
861
862        let target = TargetItem {
863            id: ItemId {
864                kind: ItemKind::Skill,
865                name: "flat-skill".into(),
866            },
867            source_name: "test".into(),
868            origin: crate::types::SourceOrigin::Dependency("test".into()),
869            source_id: crate::types::SourceId::Path {
870                canonical: flat_source.clone(),
871                subpath: None,
872            },
873            source_path: flat_source,
874            dest_path: "skills/flat-skill".into(),
875            source_hash: source_hash.into(),
876            is_flat_skill: true,
877            rewritten_content: None,
878        };
879
880        let plan = SyncPlan {
881            actions: vec![PlannedAction::Install { target }],
882        };
883
884        let options = SyncOptions {
885            force: false,
886            dry_run: false,
887            frozen: false,
888            no_refresh_models: false,
889        };
890
891        execute(root.path(), &plan, &options, &bases_dir).unwrap();
892
893        let installed = root.path().join("skills/flat-skill");
894        assert!(installed.join("SKILL.md").exists());
895        assert!(installed.join("resources/guide.md").exists());
896        assert!(!installed.join(".git").exists());
897        assert!(!installed.join("mars.toml").exists());
898        assert!(!installed.join(".gitignore").exists());
899    }
900
901    // === Prune orphans tests ===
902
903    #[test]
904    fn prune_removes_orphaned_items() {
905        let root = TempDir::new().unwrap();
906
907        // Create orphaned file
908        let agents_dir = root.path().join("agents");
909        fs::create_dir_all(&agents_dir).unwrap();
910        fs::write(agents_dir.join("old.md"), b"# orphan").unwrap();
911
912        let mut lock_items = indexmap::IndexMap::new();
913        lock_items.insert(
914            "agents/old.md".into(),
915            LockedItem {
916                source: "old-source".into(),
917                kind: ItemKind::Agent,
918                version: None,
919                source_checksum: "sha256:aaa".into(),
920                installed_checksum: "sha256:bbb".into(),
921                dest_path: "agents/old.md".into(),
922            },
923        );
924        let lock = crate::lock::LockFile {
925            version: 1,
926            dependencies: indexmap::IndexMap::new(),
927            items: lock_items,
928        };
929
930        // Empty target = orphan should be pruned
931        let target = crate::sync::target::TargetState {
932            items: indexmap::IndexMap::new(),
933        };
934
935        let outcomes = prune_orphans(root.path(), &lock, &target).unwrap();
936        assert_eq!(outcomes.len(), 1);
937        assert!(matches!(outcomes[0].action, ActionTaken::Removed));
938        assert!(!root.path().join("agents/old.md").exists());
939    }
940
941    // === extract_name_from_dest tests ===
942
943    #[test]
944    fn extract_agent_name() {
945        assert_eq!(
946            extract_name_from_dest(
947                &crate::types::DestPath::from("agents/coder.md"),
948                ItemKind::Agent
949            ),
950            "coder"
951        );
952    }
953
954    #[test]
955    fn extract_skill_name() {
956        assert_eq!(
957            extract_name_from_dest(
958                &crate::types::DestPath::from("skills/planning"),
959                ItemKind::Skill
960            ),
961            "planning"
962        );
963    }
964}