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