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