Skip to main content

mars_agents/sync/
apply.rs

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