1pub mod apply;
2pub mod diff;
3pub mod plan;
4pub mod target;
5
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9use indexmap::IndexMap;
10
11use crate::config::{Config, EffectiveConfig, LocalConfig, OverrideEntry, Settings, SourceEntry};
12use crate::error::{ConfigError, MarsError};
13use crate::resolve::{ManifestReader, ResolveOptions, SourceFetcher, VersionLister};
14use crate::source::{self, AvailableVersion, GlobalCache, ResolvedRef};
15use crate::sync::apply::ApplyResult;
16pub use crate::sync::apply::SyncOptions;
17use crate::types::{CommitHash, ItemName, SourceName};
18use crate::validate::ValidationWarning;
19
20#[derive(Debug)]
22pub struct SyncReport {
23 pub applied: ApplyResult,
24 pub pruned: Vec<apply::ActionOutcome>,
25 pub warnings: Vec<ValidationWarning>,
26 pub dry_run: bool,
28}
29
30impl SyncReport {
31 pub fn has_conflicts(&self) -> bool {
33 self.applied
34 .outcomes
35 .iter()
36 .any(|o| matches!(o.action, apply::ActionTaken::Conflicted))
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct SyncRequest {
43 pub resolution: ResolutionMode,
45 pub mutation: Option<ConfigMutation>,
47 pub options: SyncOptions,
49}
50
51#[derive(Debug, Clone)]
53pub enum ResolutionMode {
54 Normal,
56 Maximize { targets: HashSet<SourceName> },
58}
59
60#[derive(Debug, Clone)]
62pub enum ConfigMutation {
63 UpsertSource {
65 name: SourceName,
66 entry: SourceEntry,
67 },
68 RemoveSource { name: SourceName },
70 SetOverride {
72 source_name: SourceName,
73 local_path: PathBuf,
74 },
75 ClearOverride { source_name: SourceName },
77 SetRename {
79 source_name: SourceName,
80 from: String,
81 to: String,
82 },
83 SetLink { target: String },
85 ClearLink { target: String },
87}
88
89#[derive(Debug, Clone)]
92pub enum LinkMutation {
93 Set { target: String },
95 Clear { target: String },
97}
98
99pub fn mutate_link_config(root: &Path, mutation: &LinkMutation) -> Result<(), MarsError> {
102 let lock_path = root.join(".mars").join("sync.lock");
103 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
104
105 let mut config = crate::config::load(root)?;
106 match mutation {
107 LinkMutation::Set { target } => {
108 if !config.settings.links.contains(target) {
109 config.settings.links.push(target.clone());
110 }
111 }
112 LinkMutation::Clear { target } => {
113 config.settings.links.retain(|l| l != target);
114 }
115 }
116 crate::config::save(root, &config)?;
117
118 Ok(())
119}
120
121pub fn execute(root: &Path, request: &SyncRequest) -> Result<SyncReport, MarsError> {
123 validate_request(request)?;
124
125 std::fs::create_dir_all(root.join(".mars").join("cache"))?;
126
127 let lock_path = root.join(".mars").join("sync.lock");
129 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
130
131 let mut config = match crate::config::load(root) {
133 Ok(config) => config,
134 Err(err) if is_config_not_found(&err) && request.mutation.is_some() => Config {
135 sources: IndexMap::new(),
136 settings: Settings::default(),
137 },
138 Err(err) => return Err(err),
139 };
140
141 let has_mutation = request.mutation.is_some();
143 if let Some(mutation) = &request.mutation {
144 apply_mutation(&mut config, mutation)?;
145 }
146
147 let mut local = crate::config::load_local(root)?;
149 if let Some(mutation) = &request.mutation {
150 apply_local_mutation(&mut local, mutation);
151 }
152
153 let effective = crate::config::merge_with_root(config.clone(), local.clone(), root)?;
155
156 validate_targets(&request.resolution, &effective)?;
158
159 let old_lock = crate::lock::load(root)?;
161
162 let cache = GlobalCache::new()?;
164 let project_root = root.parent().unwrap_or(root);
165 let provider = RealSourceProvider {
166 cache: &cache,
167 project_root,
168 };
169 let resolve_options = to_resolve_options(&request.resolution, request.options.frozen);
170 let graph = crate::resolve::resolve(&effective, &provider, Some(&old_lock), &resolve_options)?;
171
172 let (mut target_state, renames) = target::build_with_collisions(&graph, &effective)?;
174
175 if !renames.is_empty() {
177 let rewrite_warnings = target::rewrite_skill_refs(&mut target_state, &renames, &graph)?;
178 for w in &rewrite_warnings {
179 eprintln!("{w}");
180 }
181 }
182
183 let warnings = validate_skill_refs(root, &target_state);
185
186 target::check_unmanaged_collisions(root, &old_lock, &target_state)?;
188
189 let sync_diff = diff::compute(root, &old_lock, &target_state, request.options.force)?;
191
192 let cache_bases_dir = root.join(".mars").join("cache").join("bases");
194 let sync_plan = plan::create(&sync_diff, &request.options, &cache_bases_dir);
195
196 if request.options.frozen {
198 let has_changes = sync_plan.actions.iter().any(|a| {
199 !matches!(
200 a,
201 plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
202 )
203 });
204 if has_changes {
205 return Err(MarsError::FrozenViolation {
206 message: "lock file would change but --frozen is set".into(),
207 });
208 }
209 }
210
211 if has_mutation && !request.options.dry_run {
213 match request.mutation {
214 Some(ConfigMutation::SetOverride { .. } | ConfigMutation::ClearOverride { .. }) => {
215 crate::config::save_local(root, &local)?;
216 }
217 Some(
218 ConfigMutation::UpsertSource { .. }
219 | ConfigMutation::RemoveSource { .. }
220 | ConfigMutation::SetRename { .. }
221 | ConfigMutation::SetLink { .. }
222 | ConfigMutation::ClearLink { .. },
223 ) => {
224 crate::config::save(root, &config)?;
225 }
226 None => {}
227 }
228 }
229
230 let applied = apply::execute(root, &sync_plan, &request.options, &cache_bases_dir)?;
232 let pruned = Vec::new();
233
234 if !request.options.dry_run {
236 let new_lock = crate::lock::build(&graph, &applied, &old_lock)?;
237 crate::lock::write(root, &new_lock)?;
238 }
239
240 Ok(SyncReport {
241 applied,
242 pruned,
243 warnings,
244 dry_run: request.options.dry_run,
245 })
246}
247
248fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
249 if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
250 return Err(MarsError::InvalidRequest {
251 message:
252 "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
253 .to_string(),
254 });
255 }
256
257 if request.options.frozen && request.mutation.is_some() {
258 return Err(MarsError::InvalidRequest {
259 message:
260 "cannot modify config in --frozen mode (config change would require lock update)"
261 .to_string(),
262 });
263 }
264
265 Ok(())
266}
267
268fn is_config_not_found(error: &MarsError) -> bool {
269 matches!(error, MarsError::Config(ConfigError::NotFound { .. }))
270}
271
272fn apply_mutation(config: &mut Config, mutation: &ConfigMutation) -> Result<(), MarsError> {
273 match mutation {
274 ConfigMutation::UpsertSource { name, entry } => {
275 if let Some(existing) = config.sources.get_mut(name) {
276 existing.url = entry.url.clone();
278 existing.path = entry.path.clone();
279 existing.version = entry.version.clone();
280 if entry.filter.agents.is_some() {
282 existing.filter.agents = entry.filter.agents.clone();
283 }
284 if entry.filter.skills.is_some() {
285 existing.filter.skills = entry.filter.skills.clone();
286 }
287 if entry.filter.exclude.is_some() {
288 existing.filter.exclude = entry.filter.exclude.clone();
289 }
290 } else {
293 config.sources.insert(name.clone(), entry.clone());
294 }
295 Ok(())
296 }
297 ConfigMutation::RemoveSource { name } => {
298 if !config.sources.contains_key(name) {
299 return Err(MarsError::Source {
300 source_name: name.to_string(),
301 message: format!("source `{name}` not found in mars.toml"),
302 });
303 }
304 config.sources.shift_remove(name);
305 Ok(())
306 }
307 ConfigMutation::SetOverride { source_name, .. } => {
308 if !config.sources.contains_key(source_name) {
309 return Err(MarsError::Source {
310 source_name: source_name.to_string(),
311 message: format!("source `{source_name}` not found in mars.toml"),
312 });
313 }
314 Ok(())
315 }
316 ConfigMutation::SetRename {
317 source_name,
318 from,
319 to,
320 } => {
321 let source = config
322 .sources
323 .get_mut(source_name)
324 .ok_or_else(|| MarsError::Source {
325 source_name: source_name.to_string(),
326 message: format!("source `{source_name}` not found in mars.toml"),
327 })?;
328 let rename_map = source
329 .filter
330 .rename
331 .get_or_insert_with(crate::types::RenameMap::new);
332 rename_map.insert(ItemName::from(from.as_str()), ItemName::from(to.as_str()));
333 Ok(())
334 }
335 ConfigMutation::ClearOverride { .. } => Ok(()),
336 ConfigMutation::SetLink { target } => {
337 if !config.settings.links.contains(target) {
338 config.settings.links.push(target.clone());
339 }
340 Ok(())
341 }
342 ConfigMutation::ClearLink { target } => {
343 config.settings.links.retain(|l| l != target);
344 Ok(())
345 }
346 }
347}
348
349fn apply_local_mutation(local: &mut LocalConfig, mutation: &ConfigMutation) {
350 match mutation {
351 ConfigMutation::SetOverride {
352 source_name,
353 local_path,
354 } => {
355 local.overrides.insert(
356 source_name.clone(),
357 OverrideEntry {
358 path: local_path.clone(),
359 },
360 );
361 }
362 ConfigMutation::ClearOverride { source_name } => {
363 local.overrides.shift_remove(source_name);
364 }
365 ConfigMutation::UpsertSource { .. }
366 | ConfigMutation::RemoveSource { .. }
367 | ConfigMutation::SetRename { .. }
368 | ConfigMutation::SetLink { .. }
369 | ConfigMutation::ClearLink { .. } => {}
370 }
371}
372
373fn validate_targets(
374 resolution: &ResolutionMode,
375 effective: &EffectiveConfig,
376) -> Result<(), MarsError> {
377 if let ResolutionMode::Maximize { targets } = resolution {
378 for name in targets {
379 if !effective.sources.contains_key(name) {
380 return Err(MarsError::Source {
381 source_name: name.to_string(),
382 message: format!("source `{name}` not found in mars.toml"),
383 });
384 }
385 }
386 }
387
388 Ok(())
389}
390
391fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
392 match mode {
393 ResolutionMode::Normal => ResolveOptions {
394 frozen,
395 ..ResolveOptions::default()
396 },
397 ResolutionMode::Maximize { targets } => ResolveOptions {
398 maximize: true,
399 upgrade_targets: targets.clone(),
400 frozen,
401 },
402 }
403}
404
405struct RealSourceProvider<'a> {
410 cache: &'a GlobalCache,
411 project_root: &'a Path,
412}
413
414impl VersionLister for RealSourceProvider<'_> {
415 fn list_versions(
416 &self,
417 url: &crate::types::SourceUrl,
418 ) -> Result<Vec<AvailableVersion>, MarsError> {
419 source::list_versions(url, self.cache)
420 }
421}
422
423impl SourceFetcher for RealSourceProvider<'_> {
424 fn fetch_git_version(
425 &self,
426 url: &crate::types::SourceUrl,
427 version: &AvailableVersion,
428 source_name: &str,
429 preferred_commit: Option<&str>,
430 ) -> Result<ResolvedRef, MarsError> {
431 let fetch_options = source::git::FetchOptions {
432 preferred_commit: preferred_commit.map(CommitHash::from),
433 };
434 source::git::fetch(
435 url.as_ref(),
436 Some(&version.tag),
437 source_name,
438 self.cache,
439 &fetch_options,
440 )
441 }
442
443 fn fetch_git_ref(
444 &self,
445 url: &crate::types::SourceUrl,
446 ref_name: &str,
447 source_name: &str,
448 preferred_commit: Option<&str>,
449 ) -> Result<ResolvedRef, MarsError> {
450 let fetch_options = source::git::FetchOptions {
451 preferred_commit: preferred_commit.map(CommitHash::from),
452 };
453 source::git::fetch(
454 url.as_ref(),
455 Some(ref_name),
456 source_name,
457 self.cache,
458 &fetch_options,
459 )
460 }
461
462 fn fetch_path(&self, path: &Path, source_name: &str) -> Result<ResolvedRef, MarsError> {
463 source::path::fetch_path(path, self.project_root, source_name)
464 }
465}
466
467impl ManifestReader for RealSourceProvider<'_> {
468 fn read_manifest(
469 &self,
470 source_tree: &Path,
471 ) -> Result<Option<crate::manifest::Manifest>, MarsError> {
472 crate::manifest::load(source_tree)
473 }
474}
475
476fn validate_skill_refs(
479 install_target: &Path,
480 target: &target::TargetState,
481) -> Vec<ValidationWarning> {
482 use crate::lock::ItemKind;
483
484 let available_skills: HashSet<String> = target
486 .items
487 .values()
488 .filter(|item| item.id.kind == ItemKind::Skill)
489 .map(|item| item.id.name.to_string())
490 .collect();
491
492 let agents: Vec<(String, PathBuf)> = target
494 .items
495 .values()
496 .filter(|item| item.id.kind == ItemKind::Agent)
497 .map(|item| {
498 let disk_path = install_target.join(&item.dest_path);
499 let path = if disk_path.exists() {
502 disk_path
503 } else {
504 item.source_path.clone()
505 };
506 (item.id.name.to_string(), path)
507 })
508 .collect();
509
510 crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::config::*;
517 use crate::lock::{ItemKind, LockFile};
518 use crate::resolve::{ResolvedGraph, ResolvedNode};
519 use crate::source::ResolvedRef;
520 use indexmap::IndexMap;
521 use std::fs;
522 use tempfile::TempDir;
523
524 struct TestFixture {
526 root: TempDir,
527 source_trees: Vec<TempDir>,
528 }
529
530 impl TestFixture {
531 fn new() -> Self {
532 let root = TempDir::new().unwrap();
533 fs::create_dir_all(root.path().join(".mars/cache/bases")).unwrap();
535 TestFixture {
536 root,
537 source_trees: Vec::new(),
538 }
539 }
540
541 fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
542 let dir = TempDir::new().unwrap();
543 if !agents.is_empty() {
544 let agents_dir = dir.path().join("agents");
545 fs::create_dir_all(&agents_dir).unwrap();
546 for (name, content) in agents {
547 fs::write(agents_dir.join(name), content).unwrap();
548 }
549 }
550 if !skills.is_empty() {
551 let skills_dir = dir.path().join("skills");
552 fs::create_dir_all(&skills_dir).unwrap();
553 for (name, content) in skills {
554 let skill_dir = skills_dir.join(name);
555 fs::create_dir_all(&skill_dir).unwrap();
556 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
557 }
558 }
559 self.source_trees.push(dir);
560 self.source_trees.len() - 1
561 }
562
563 fn root(&self) -> &Path {
564 self.root.path()
565 }
566
567 fn tree_path(&self, idx: usize) -> PathBuf {
568 self.source_trees[idx].path().to_path_buf()
569 }
570 }
571
572 fn make_graph_config(
573 fixture: &TestFixture,
574 sources: Vec<(&str, usize, FilterMode)>,
575 ) -> (ResolvedGraph, EffectiveConfig) {
576 let mut nodes = IndexMap::new();
577 let mut order = Vec::new();
578 let mut config_sources = IndexMap::new();
579
580 for (name, tree_idx, filter) in sources {
581 let tree_path = fixture.tree_path(tree_idx);
582 nodes.insert(
583 name.into(),
584 ResolvedNode {
585 source_name: name.into(),
586 source_id: crate::types::SourceId::Path {
587 canonical: tree_path.clone(),
588 },
589 resolved_ref: ResolvedRef {
590 source_name: name.into(),
591 version: None,
592 version_tag: None,
593 commit: None,
594 tree_path: tree_path.clone(),
595 },
596 manifest: None,
597 deps: vec![],
598 },
599 );
600 order.push(name.into());
601
602 config_sources.insert(
603 name.into(),
604 EffectiveSource {
605 name: name.into(),
606 id: crate::types::SourceId::Path {
607 canonical: tree_path.clone(),
608 },
609 spec: SourceSpec::Path(tree_path),
610 filter,
611 rename: crate::types::RenameMap::new(),
612 is_overridden: false,
613 original_git: None,
614 },
615 );
616 }
617
618 (
619 ResolvedGraph {
620 nodes,
621 order,
622 id_index: std::collections::HashMap::new(),
623 },
624 EffectiveConfig {
625 sources: config_sources,
626 settings: Settings::default(),
627 },
628 )
629 }
630
631 fn path_source_entry(path: &Path) -> SourceEntry {
632 SourceEntry {
633 url: None,
634 path: Some(path.to_path_buf()),
635 version: None,
636 filter: FilterConfig::default(),
637 }
638 }
639
640 #[test]
641 fn validate_request_rejects_frozen_with_maximize() {
642 let request = SyncRequest {
643 resolution: ResolutionMode::Maximize {
644 targets: HashSet::new(),
645 },
646 mutation: None,
647 options: SyncOptions {
648 force: false,
649 dry_run: false,
650 frozen: true,
651 },
652 };
653
654 let err = validate_request(&request).unwrap_err();
655 assert!(matches!(err, MarsError::InvalidRequest { .. }));
656 assert!(err.to_string().contains("--frozen"));
657 }
658
659 #[test]
660 fn validate_request_rejects_frozen_with_mutation() {
661 let request = SyncRequest {
662 resolution: ResolutionMode::Normal,
663 mutation: Some(ConfigMutation::RemoveSource {
664 name: "base".into(),
665 }),
666 options: SyncOptions {
667 force: false,
668 dry_run: false,
669 frozen: true,
670 },
671 };
672
673 let err = validate_request(&request).unwrap_err();
674 assert!(matches!(err, MarsError::InvalidRequest { .. }));
675 assert!(err.to_string().contains("cannot modify config"));
676 }
677
678 #[test]
679 fn execute_auto_inits_config_for_mutation() {
680 let root = TempDir::new().unwrap();
681 let source = TempDir::new().unwrap();
682 fs::create_dir_all(source.path().join("agents")).unwrap();
683 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
684
685 let request = SyncRequest {
686 resolution: ResolutionMode::Normal,
687 mutation: Some(ConfigMutation::UpsertSource {
688 name: "base".into(),
689 entry: path_source_entry(source.path()),
690 }),
691 options: SyncOptions::default(),
692 };
693
694 let report = execute(root.path(), &request).unwrap();
695 assert!(!report.applied.outcomes.is_empty());
696 assert!(root.path().join("mars.toml").exists());
697
698 let saved = crate::config::load(root.path()).unwrap();
699 assert!(saved.sources.contains_key("base"));
700 }
701
702 #[test]
703 fn execute_dry_run_with_mutation_does_not_write_config() {
704 let root = TempDir::new().unwrap();
705 crate::config::save(
706 root.path(),
707 &Config {
708 sources: IndexMap::new(),
709 settings: Settings::default(),
710 },
711 )
712 .unwrap();
713
714 let source = TempDir::new().unwrap();
715 fs::create_dir_all(source.path().join("agents")).unwrap();
716 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
717
718 let request = SyncRequest {
719 resolution: ResolutionMode::Normal,
720 mutation: Some(ConfigMutation::UpsertSource {
721 name: "base".into(),
722 entry: path_source_entry(source.path()),
723 }),
724 options: SyncOptions {
725 force: false,
726 dry_run: true,
727 frozen: false,
728 },
729 };
730
731 let report = execute(root.path(), &request).unwrap();
732 assert!(!report.applied.outcomes.is_empty());
733
734 let saved = crate::config::load(root.path()).unwrap();
735 assert!(!saved.sources.contains_key("base"));
736 assert!(!root.path().join("agents/coder.md").exists());
737 assert!(!root.path().join("mars.lock").exists());
738 }
739
740 #[test]
743 fn full_pipeline_fresh_sync() {
744 let mut fixture = TestFixture::new();
745 let src_idx = fixture.add_source(
746 &[("coder.md", "# Coder agent")],
747 &[("planning", "# Planning skill")],
748 );
749
750 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
751
752 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
754 assert!(renames.is_empty());
755 assert_eq!(target.items.len(), 2);
756
757 let lock = LockFile::empty();
759 let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
760
761 assert_eq!(sync_diff.items.len(), 2);
763 for entry in &sync_diff.items {
764 assert!(matches!(entry, diff::DiffEntry::Add { .. }));
765 }
766
767 let cache_dir = fixture.root().join(".mars/cache/bases");
769 let options = SyncOptions {
770 force: false,
771 dry_run: false,
772 frozen: false,
773 };
774 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
775 assert_eq!(sync_plan.actions.len(), 2);
776 for action in &sync_plan.actions {
777 assert!(matches!(action, plan::PlannedAction::Install { .. }));
778 }
779
780 let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
782 assert_eq!(result.outcomes.len(), 2);
783
784 assert!(fixture.root().join("agents/coder.md").exists());
786 assert!(fixture.root().join("skills/planning/SKILL.md").exists());
787
788 let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
790 assert_eq!(new_lock.items.len(), 2);
791 assert!(new_lock.items.contains_key("agents/coder.md"));
792 assert!(new_lock.items.contains_key("skills/planning"));
793 }
794
795 #[test]
796 fn re_sync_no_changes() {
797 let mut fixture = TestFixture::new();
798 let content = "# Coder agent";
799 let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
800
801 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
802
803 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
805 let lock = LockFile::empty();
806 let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
807 let cache_dir = fixture.root().join(".mars/cache/bases");
808 let options = SyncOptions {
809 force: false,
810 dry_run: false,
811 frozen: false,
812 };
813 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
814 let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
815 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
816
817 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
819 let sync_diff2 = diff::compute(fixture.root(), &first_lock, &target2, false).unwrap();
820
821 for entry in &sync_diff2.items {
823 assert!(
824 matches!(entry, diff::DiffEntry::Unchanged { .. }),
825 "expected Unchanged, got {entry:?}"
826 );
827 }
828
829 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
830 for action in &sync_plan2.actions {
831 assert!(matches!(action, plan::PlannedAction::Skip { .. }));
832 }
833 }
834
835 #[test]
836 fn source_update_detects_changes() {
837 let mut fixture = TestFixture::new();
838 let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
839
840 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
841
842 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
844 let lock = LockFile::empty();
845 let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
846 let cache_dir = fixture.root().join(".mars/cache/bases");
847 let options = SyncOptions {
848 force: false,
849 dry_run: false,
850 frozen: false,
851 };
852 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
853 let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
854 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
855
856 let agents_dir = fixture.tree_path(src_idx).join("agents");
858 fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
859
860 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
862 let sync_diff2 = diff::compute(fixture.root(), &first_lock, &target2, false).unwrap();
863
864 assert_eq!(sync_diff2.items.len(), 1);
866 assert!(matches!(
867 &sync_diff2.items[0],
868 diff::DiffEntry::Update { .. }
869 ));
870 }
871
872 #[test]
873 fn local_modification_preserved() {
874 let mut fixture = TestFixture::new();
875 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
876
877 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
878
879 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
881 let lock = LockFile::empty();
882 let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
883 let cache_dir = fixture.root().join(".mars/cache/bases");
884 let options = SyncOptions {
885 force: false,
886 dry_run: false,
887 frozen: false,
888 };
889 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
890 let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
891 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
892
893 fs::write(fixture.root().join("agents/coder.md"), "# Locally modified").unwrap();
895
896 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
898 let sync_diff2 = diff::compute(fixture.root(), &first_lock, &target2, false).unwrap();
899
900 assert_eq!(sync_diff2.items.len(), 1);
902 assert!(matches!(
903 &sync_diff2.items[0],
904 diff::DiffEntry::LocalModified { .. }
905 ));
906
907 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
909 assert!(matches!(
910 &sync_plan2.actions[0],
911 plan::PlannedAction::KeepLocal { .. }
912 ));
913 }
914
915 #[test]
916 fn force_overwrites_local_modifications() {
917 let mut fixture = TestFixture::new();
918 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
919
920 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
921
922 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
924 let lock = LockFile::empty();
925 let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
926 let cache_dir = fixture.root().join(".mars/cache/bases");
927 let options = SyncOptions {
928 force: false,
929 dry_run: false,
930 frozen: false,
931 };
932 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
933 let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
934 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
935
936 fs::write(fixture.root().join("agents/coder.md"), "# Locally modified").unwrap();
938
939 let agents_dir = fixture.tree_path(src_idx).join("agents");
941 fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
942
943 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
945 let sync_diff2 = diff::compute(fixture.root(), &first_lock, &target2, false).unwrap();
946
947 let force_options = SyncOptions {
948 force: true,
949 dry_run: false,
950 frozen: false,
951 };
952 let sync_plan2 = plan::create(&sync_diff2, &force_options, &cache_dir);
953 assert!(matches!(
954 &sync_plan2.actions[0],
955 plan::PlannedAction::Overwrite { .. }
956 ));
957
958 let result2 =
959 apply::execute(fixture.root(), &sync_plan2, &force_options, &cache_dir).unwrap();
960 assert!(matches!(
961 result2.outcomes[0].action,
962 apply::ActionTaken::Updated
963 ));
964
965 let content = fs::read_to_string(fixture.root().join("agents/coder.md")).unwrap();
967 assert_eq!(content, "# Upstream update");
968 }
969
970 #[test]
971 fn orphan_removed_when_source_drops_item() {
972 let mut fixture = TestFixture::new();
973 let src_idx = fixture.add_source(
974 &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
975 &[],
976 );
977
978 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
979
980 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
982 let lock = LockFile::empty();
983 let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
984 let cache_dir = fixture.root().join(".mars/cache/bases");
985 let options = SyncOptions {
986 force: false,
987 dry_run: false,
988 frozen: false,
989 };
990 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
991 let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
992 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
993
994 assert!(fixture.root().join("agents/coder.md").exists());
995 assert!(fixture.root().join("agents/reviewer.md").exists());
996
997 fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
999
1000 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1002 let sync_diff2 = diff::compute(fixture.root(), &first_lock, &target2, false).unwrap();
1003
1004 let orphan_count = sync_diff2
1006 .items
1007 .iter()
1008 .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1009 .count();
1010 assert_eq!(orphan_count, 1);
1011
1012 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1013 let result2 = apply::execute(fixture.root(), &sync_plan2, &options, &cache_dir).unwrap();
1014
1015 assert!(!fixture.root().join("agents/reviewer.md").exists());
1017 assert!(fixture.root().join("agents/coder.md").exists());
1019
1020 let removed = result2
1022 .outcomes
1023 .iter()
1024 .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1025 assert!(removed);
1026 }
1027
1028 #[test]
1029 fn dry_run_produces_plan_without_changes() {
1030 let mut fixture = TestFixture::new();
1031 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1032
1033 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1034
1035 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1036 let lock = LockFile::empty();
1037 let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
1038
1039 let cache_dir = fixture.root().join(".mars/cache/bases");
1040 let dry_options = SyncOptions {
1041 force: false,
1042 dry_run: true,
1043 frozen: false,
1044 };
1045
1046 let sync_plan = plan::create(&sync_diff, &dry_options, &cache_dir);
1047 assert!(!sync_plan.actions.is_empty());
1048
1049 let result = apply::execute(fixture.root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1051 assert!(!result.outcomes.is_empty());
1052
1053 assert!(!fixture.root().join("agents/coder.md").exists());
1055 }
1056
1057 #[test]
1058 fn lock_written_after_apply() {
1059 let mut fixture = TestFixture::new();
1060 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1061
1062 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1063
1064 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1066 let lock = LockFile::empty();
1067 let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
1068 let cache_dir = fixture.root().join(".mars/cache/bases");
1069 let options = SyncOptions {
1070 force: false,
1071 dry_run: false,
1072 frozen: false,
1073 };
1074 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1075 let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
1076
1077 let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1078 crate::lock::write(fixture.root(), &new_lock).unwrap();
1079
1080 let reloaded = crate::lock::load(fixture.root()).unwrap();
1082 assert_eq!(reloaded.items.len(), 1);
1083 assert!(reloaded.items.contains_key("agents/coder.md"));
1084
1085 let item = &reloaded.items["agents/coder.md"];
1086 assert_eq!(item.kind, ItemKind::Agent);
1087 assert!(!item.source_checksum.is_empty());
1088 assert!(!item.installed_checksum.is_empty());
1089 }
1090
1091 #[test]
1092 fn two_sources_no_collision() {
1093 let mut fixture = TestFixture::new();
1094 let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
1095 let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
1096
1097 let (graph, config) = make_graph_config(
1098 &fixture,
1099 vec![
1100 ("source-a", src_a, FilterMode::All),
1101 ("source-b", src_b, FilterMode::All),
1102 ],
1103 );
1104
1105 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1106 assert!(renames.is_empty());
1107 assert_eq!(target.items.len(), 2);
1108
1109 let lock = LockFile::empty();
1110 let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
1111 let cache_dir = fixture.root().join(".mars/cache/bases");
1112 let options = SyncOptions {
1113 force: false,
1114 dry_run: false,
1115 frozen: false,
1116 };
1117 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1118 let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
1119
1120 assert!(fixture.root().join("agents/coder.md").exists());
1121 assert!(fixture.root().join("agents/reviewer.md").exists());
1122 assert_eq!(result.outcomes.len(), 2);
1123 }
1124}