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 = root.join(&target.dest_path);
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 = root.join(&target.dest_path);
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 = root.join(&target.dest_path);
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 = root.join(&locked.dest_path);
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    let cache_path = cache_bases_dir.join(installed_checksum.as_ref());
381
382    // Only cache if not already present (content-addressed = immutable)
383    if cache_path.exists() {
384        return Ok(());
385    }
386
387    match kind {
388        ItemKind::Agent => {
389            let content = std::fs::read(dest)?;
390            fs_ops::atomic_write_file(&cache_path, &content)?;
391        }
392        ItemKind::Skill => {
393            // For skills, cache the SKILL.md content (the merge-relevant part)
394            let skill_md = dest.join("SKILL.md");
395            if skill_md.exists() {
396                let content = std::fs::read(&skill_md)?;
397                fs_ops::atomic_write_file(&cache_path, &content)?;
398            }
399        }
400    }
401
402    Ok(())
403}
404
405/// Extract the item name from a destination path.
406fn extract_name_from_dest(dest_path: &DestPath, kind: ItemKind) -> String {
407    let path = dest_path.as_path();
408    match kind {
409        ItemKind::Agent => path
410            .file_stem()
411            .map(|s| s.to_string_lossy().to_string())
412            .unwrap_or_default(),
413        ItemKind::Skill => path
414            .file_name()
415            .map(|s| s.to_string_lossy().to_string())
416            .unwrap_or_default(),
417    }
418}
419
420/// Prune orphans: items in old lock but not in new target.
421///
422/// This is handled by the Remove action in the plan, but exposed
423/// separately for the sync pipeline if needed.
424pub fn prune_orphans(
425    root: &Path,
426    lock: &crate::lock::LockFile,
427    target: &crate::sync::target::TargetState,
428) -> Result<Vec<ActionOutcome>, MarsError> {
429    let mut outcomes = Vec::new();
430
431    for (dest_path_str, locked_item) in &lock.items {
432        if !target.items.contains_key(dest_path_str) {
433            let dest = root.join(dest_path_str);
434            if dest.exists() {
435                fs_ops::safe_remove(&dest)?;
436            }
437            outcomes.push(ActionOutcome {
438                item_id: ItemId {
439                    kind: locked_item.kind,
440                    name: ItemName::from(extract_name_from_dest(dest_path_str, locked_item.kind)),
441                },
442                action: ActionTaken::Removed,
443                dest_path: dest_path_str.clone(),
444                source_name: locked_item.source.clone(),
445                source_checksum: None,
446                installed_checksum: None,
447            });
448        }
449    }
450
451    Ok(outcomes)
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::hash;
458    use crate::lock::{ItemId, ItemKind, LockedItem};
459    use crate::sync::plan::{PlannedAction, SyncPlan};
460    use crate::sync::target::TargetItem;
461    use std::fs;
462    use std::path::PathBuf;
463    use tempfile::TempDir;
464
465    fn make_agent_target(name: &str, source_path: PathBuf, content: &[u8]) -> TargetItem {
466        TargetItem {
467            id: ItemId {
468                kind: ItemKind::Agent,
469                name: name.into(),
470            },
471            source_name: "test-source".into(),
472            origin: crate::types::SourceOrigin::Dependency("test-source".into()),
473            source_id: crate::types::SourceId::Path {
474                canonical: source_path.clone(),
475                subpath: None,
476            },
477            source_path,
478            dest_path: format!("agents/{name}.md").into(),
479            source_hash: hash::hash_bytes(content).into(),
480            is_flat_skill: false,
481            rewritten_content: None,
482        }
483    }
484
485    fn setup_source_agent(dir: &Path, name: &str, content: &[u8]) -> PathBuf {
486        let agents_dir = dir.join("source").join("agents");
487        fs::create_dir_all(&agents_dir).unwrap();
488        let path = agents_dir.join(format!("{name}.md"));
489        fs::write(&path, content).unwrap();
490        path
491    }
492
493    // === Install tests ===
494
495    #[test]
496    fn install_creates_new_file() {
497        let root = TempDir::new().unwrap();
498        let source_dir = TempDir::new().unwrap();
499        let cache_dir = TempDir::new().unwrap();
500        let bases_dir = cache_dir.path().join("bases");
501
502        let content = b"# new agent content";
503        let source_path = setup_source_agent(source_dir.path(), "coder", content);
504        let target = make_agent_target("coder", source_path, content);
505
506        let plan = SyncPlan {
507            actions: vec![PlannedAction::Install {
508                target: target.clone(),
509            }],
510        };
511
512        let options = SyncOptions {
513            force: false,
514            dry_run: false,
515            frozen: false,
516            no_refresh_models: false,
517        };
518
519        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
520        assert_eq!(result.outcomes.len(), 1);
521
522        let outcome = &result.outcomes[0];
523        assert!(matches!(outcome.action, ActionTaken::Installed));
524
525        // Verify file was created
526        let installed_path = root.path().join("agents/coder.md");
527        assert!(installed_path.exists());
528        assert_eq!(fs::read(&installed_path).unwrap(), content);
529
530        // Verify checksums
531        assert_eq!(
532            outcome.source_checksum.as_deref(),
533            Some(hash::hash_bytes(content).as_str())
534        );
535        assert!(outcome.installed_checksum.is_some());
536    }
537
538    #[test]
539    fn install_caches_base_content() {
540        let root = TempDir::new().unwrap();
541        let source_dir = TempDir::new().unwrap();
542        let cache_dir = TempDir::new().unwrap();
543        let bases_dir = cache_dir.path().join("bases");
544
545        let content = b"# cached content";
546        let source_path = setup_source_agent(source_dir.path(), "coder", content);
547        let target = make_agent_target("coder", source_path, content);
548
549        let plan = SyncPlan {
550            actions: vec![PlannedAction::Install { target }],
551        };
552
553        let options = SyncOptions {
554            force: false,
555            dry_run: false,
556            frozen: false,
557            no_refresh_models: false,
558        };
559
560        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
561        let installed_checksum = result.outcomes[0].installed_checksum.as_ref().unwrap();
562
563        // Verify base content was cached
564        let cached = bases_dir.join(installed_checksum.as_ref());
565        assert!(cached.exists(), "base content should be cached");
566        assert_eq!(fs::read(&cached).unwrap(), content);
567    }
568
569    // === Overwrite tests ===
570
571    #[test]
572    fn overwrite_replaces_existing_file() {
573        let root = TempDir::new().unwrap();
574        let source_dir = TempDir::new().unwrap();
575        let cache_dir = TempDir::new().unwrap();
576        let bases_dir = cache_dir.path().join("bases");
577
578        // Create existing file
579        let agents_dir = root.path().join("agents");
580        fs::create_dir_all(&agents_dir).unwrap();
581        fs::write(agents_dir.join("coder.md"), b"# old content").unwrap();
582
583        let new_content = b"# new content";
584        let source_path = setup_source_agent(source_dir.path(), "coder", new_content);
585        let target = make_agent_target("coder", source_path, new_content);
586
587        let plan = SyncPlan {
588            actions: vec![PlannedAction::Overwrite { target }],
589        };
590
591        let options = SyncOptions {
592            force: false,
593            dry_run: false,
594            frozen: false,
595            no_refresh_models: false,
596        };
597
598        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
599        assert!(matches!(result.outcomes[0].action, ActionTaken::Updated));
600
601        let installed = fs::read(root.path().join("agents/coder.md")).unwrap();
602        assert_eq!(installed, new_content);
603    }
604
605    // === Remove tests ===
606
607    #[test]
608    fn remove_deletes_file() {
609        let root = TempDir::new().unwrap();
610        let cache_dir = TempDir::new().unwrap();
611        let bases_dir = cache_dir.path().join("bases");
612
613        // Create file to remove
614        let agents_dir = root.path().join("agents");
615        fs::create_dir_all(&agents_dir).unwrap();
616        fs::write(agents_dir.join("orphan.md"), b"# orphan").unwrap();
617
618        let locked = LockedItem {
619            source: "old-source".into(),
620            kind: ItemKind::Agent,
621            version: None,
622            source_checksum: "sha256:aaa".into(),
623            installed_checksum: "sha256:bbb".into(),
624            dest_path: "agents/orphan.md".into(),
625        };
626
627        let plan = SyncPlan {
628            actions: vec![PlannedAction::Remove { locked }],
629        };
630
631        let options = SyncOptions {
632            force: false,
633            dry_run: false,
634            frozen: false,
635            no_refresh_models: false,
636        };
637
638        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
639        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
640        assert!(!root.path().join("agents/orphan.md").exists());
641    }
642
643    #[test]
644    fn remove_skill_directory() {
645        let root = TempDir::new().unwrap();
646        let cache_dir = TempDir::new().unwrap();
647        let bases_dir = cache_dir.path().join("bases");
648
649        // Create skill directory
650        let skill_dir = root.path().join("skills/old-skill");
651        fs::create_dir_all(&skill_dir).unwrap();
652        fs::write(skill_dir.join("SKILL.md"), b"# old skill").unwrap();
653
654        let locked = LockedItem {
655            source: "old-source".into(),
656            kind: ItemKind::Skill,
657            version: None,
658            source_checksum: "sha256:aaa".into(),
659            installed_checksum: "sha256:bbb".into(),
660            dest_path: "skills/old-skill".into(),
661        };
662
663        let plan = SyncPlan {
664            actions: vec![PlannedAction::Remove { locked }],
665        };
666
667        let options = SyncOptions {
668            force: false,
669            dry_run: false,
670            frozen: false,
671            no_refresh_models: false,
672        };
673
674        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
675        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
676        assert!(!root.path().join("skills/old-skill").exists());
677    }
678
679    // === Dry run tests ===
680
681    #[test]
682    fn dry_run_does_not_modify_files() {
683        let root = TempDir::new().unwrap();
684        let source_dir = TempDir::new().unwrap();
685        let cache_dir = TempDir::new().unwrap();
686        let bases_dir = cache_dir.path().join("bases");
687
688        let content = b"# new agent";
689        let source_path = setup_source_agent(source_dir.path(), "coder", content);
690        let target = make_agent_target("coder", source_path, content);
691
692        let plan = SyncPlan {
693            actions: vec![PlannedAction::Install { target }],
694        };
695
696        let options = SyncOptions {
697            force: false,
698            dry_run: true,
699            frozen: false,
700            no_refresh_models: false,
701        };
702
703        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
704        assert_eq!(result.outcomes.len(), 1);
705        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
706
707        // File should NOT exist
708        assert!(!root.path().join("agents/coder.md").exists());
709    }
710
711    // === Skip/KeepLocal tests ===
712
713    #[test]
714    fn skip_produces_skipped_outcome() {
715        let root = TempDir::new().unwrap();
716        let cache_dir = TempDir::new().unwrap();
717        let bases_dir = cache_dir.path().join("bases");
718
719        let plan = SyncPlan {
720            actions: vec![PlannedAction::Skip {
721                item_id: ItemId {
722                    kind: ItemKind::Agent,
723                    name: "stable".into(),
724                },
725                dest_path: "agents/stable.md".into(),
726                source_name: "base".into(),
727                installed_checksum: Some("sha256:stable".into()),
728                reason: "unchanged",
729            }],
730        };
731
732        let options = SyncOptions {
733            force: false,
734            dry_run: false,
735            frozen: false,
736            no_refresh_models: false,
737        };
738
739        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
740        assert!(matches!(result.outcomes[0].action, ActionTaken::Skipped));
741        assert_eq!(
742            result.outcomes[0].dest_path,
743            crate::types::DestPath::from("agents/stable.md")
744        );
745        assert_eq!(result.outcomes[0].source_name, "base");
746        assert_eq!(
747            result.outcomes[0].installed_checksum.as_deref(),
748            Some("sha256:stable")
749        );
750    }
751
752    #[test]
753    fn keep_local_produces_kept_outcome() {
754        let root = TempDir::new().unwrap();
755        let cache_dir = TempDir::new().unwrap();
756        let bases_dir = cache_dir.path().join("bases");
757
758        let plan = SyncPlan {
759            actions: vec![PlannedAction::KeepLocal {
760                item_id: ItemId {
761                    kind: ItemKind::Agent,
762                    name: "modified".into(),
763                },
764                dest_path: "agents/modified.md".into(),
765                source_name: "base".into(),
766            }],
767        };
768
769        let options = SyncOptions {
770            force: false,
771            dry_run: false,
772            frozen: false,
773            no_refresh_models: false,
774        };
775
776        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
777        assert!(matches!(result.outcomes[0].action, ActionTaken::Kept));
778        assert_eq!(
779            result.outcomes[0].dest_path,
780            crate::types::DestPath::from("agents/modified.md")
781        );
782        assert_eq!(result.outcomes[0].source_name, "base");
783    }
784
785    // === Install skill directory tests ===
786
787    #[test]
788    fn install_skill_directory() {
789        let root = TempDir::new().unwrap();
790        let source_dir = TempDir::new().unwrap();
791        let cache_dir = TempDir::new().unwrap();
792        let bases_dir = cache_dir.path().join("bases");
793
794        // Create source skill directory
795        let source_skill = source_dir.path().join("skills/planning");
796        fs::create_dir_all(&source_skill).unwrap();
797        fs::write(source_skill.join("SKILL.md"), b"# Planning skill").unwrap();
798        fs::write(source_skill.join("helper.md"), b"# Helper").unwrap();
799
800        let skill_hash = hash::compute_hash(&source_skill, ItemKind::Skill).unwrap();
801
802        let target = TargetItem {
803            id: ItemId {
804                kind: ItemKind::Skill,
805                name: "planning".into(),
806            },
807            source_name: "test".into(),
808            origin: crate::types::SourceOrigin::Dependency("test".into()),
809            source_id: crate::types::SourceId::Path {
810                canonical: source_skill.clone(),
811                subpath: None,
812            },
813            source_path: source_skill,
814            dest_path: "skills/planning".into(),
815            source_hash: skill_hash.into(),
816            is_flat_skill: false,
817            rewritten_content: None,
818        };
819
820        let plan = SyncPlan {
821            actions: vec![PlannedAction::Install { target }],
822        };
823
824        let options = SyncOptions {
825            force: false,
826            dry_run: false,
827            frozen: false,
828            no_refresh_models: false,
829        };
830
831        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
832        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
833
834        let installed_dir = root.path().join("skills/planning");
835        assert!(installed_dir.exists());
836        assert!(installed_dir.join("SKILL.md").exists());
837        assert!(installed_dir.join("helper.md").exists());
838        assert_eq!(
839            fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
840            "# Planning skill"
841        );
842    }
843
844    #[test]
845    fn install_flat_skill_excludes_repo_metadata() {
846        let root = TempDir::new().unwrap();
847        let source_dir = TempDir::new().unwrap();
848        let cache_dir = TempDir::new().unwrap();
849        let bases_dir = cache_dir.path().join("bases");
850
851        let flat_source = source_dir.path().join("flat-skill");
852        fs::create_dir_all(flat_source.join(".git")).unwrap();
853        fs::create_dir_all(flat_source.join("resources")).unwrap();
854        fs::write(flat_source.join("SKILL.md"), b"# Flat skill").unwrap();
855        fs::write(flat_source.join("resources/guide.md"), b"# Guide").unwrap();
856        fs::write(flat_source.join("mars.toml"), b"[sources]").unwrap();
857        fs::write(flat_source.join(".gitignore"), b"target/").unwrap();
858        fs::write(flat_source.join(".git/config"), b"[core]").unwrap();
859
860        let source_hash = hash::compute_skill_hash_filtered(
861            &flat_source,
862            crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
863        )
864        .unwrap();
865
866        let target = TargetItem {
867            id: ItemId {
868                kind: ItemKind::Skill,
869                name: "flat-skill".into(),
870            },
871            source_name: "test".into(),
872            origin: crate::types::SourceOrigin::Dependency("test".into()),
873            source_id: crate::types::SourceId::Path {
874                canonical: flat_source.clone(),
875                subpath: None,
876            },
877            source_path: flat_source,
878            dest_path: "skills/flat-skill".into(),
879            source_hash: source_hash.into(),
880            is_flat_skill: true,
881            rewritten_content: None,
882        };
883
884        let plan = SyncPlan {
885            actions: vec![PlannedAction::Install { target }],
886        };
887
888        let options = SyncOptions {
889            force: false,
890            dry_run: false,
891            frozen: false,
892            no_refresh_models: false,
893        };
894
895        execute(root.path(), &plan, &options, &bases_dir).unwrap();
896
897        let installed = root.path().join("skills/flat-skill");
898        assert!(installed.join("SKILL.md").exists());
899        assert!(installed.join("resources/guide.md").exists());
900        assert!(!installed.join(".git").exists());
901        assert!(!installed.join("mars.toml").exists());
902        assert!(!installed.join(".gitignore").exists());
903    }
904
905    // === Prune orphans tests ===
906
907    #[test]
908    fn prune_removes_orphaned_items() {
909        let root = TempDir::new().unwrap();
910
911        // Create orphaned file
912        let agents_dir = root.path().join("agents");
913        fs::create_dir_all(&agents_dir).unwrap();
914        fs::write(agents_dir.join("old.md"), b"# orphan").unwrap();
915
916        let mut lock_items = indexmap::IndexMap::new();
917        lock_items.insert(
918            "agents/old.md".into(),
919            LockedItem {
920                source: "old-source".into(),
921                kind: ItemKind::Agent,
922                version: None,
923                source_checksum: "sha256:aaa".into(),
924                installed_checksum: "sha256:bbb".into(),
925                dest_path: "agents/old.md".into(),
926            },
927        );
928        let lock = crate::lock::LockFile {
929            version: 1,
930            dependencies: indexmap::IndexMap::new(),
931            items: lock_items,
932        };
933
934        // Empty target = orphan should be pruned
935        let target = crate::sync::target::TargetState {
936            items: indexmap::IndexMap::new(),
937        };
938
939        let outcomes = prune_orphans(root.path(), &lock, &target).unwrap();
940        assert_eq!(outcomes.len(), 1);
941        assert!(matches!(outcomes[0].action, ActionTaken::Removed));
942        assert!(!root.path().join("agents/old.md").exists());
943    }
944
945    // === extract_name_from_dest tests ===
946
947    #[test]
948    fn extract_agent_name() {
949        assert_eq!(
950            extract_name_from_dest(
951                &crate::types::DestPath::from("agents/coder.md"),
952                ItemKind::Agent
953            ),
954            "coder"
955        );
956    }
957
958    #[test]
959    fn extract_skill_name() {
960        assert_eq!(
961            extract_name_from_dest(
962                &crate::types::DestPath::from("skills/planning"),
963                ItemKind::Skill
964            ),
965            "planning"
966        );
967    }
968}