1pub mod apply;
2pub mod diff;
3pub mod filter;
4pub mod mutation;
5pub mod plan;
6pub mod provider;
7pub mod rewrite;
8pub mod target;
9pub mod types;
10
11use std::collections::HashSet;
12use std::path::Path;
13use std::path::PathBuf;
14
15use crate::config::{Config, EffectiveConfig, LocalConfig, Settings};
16use crate::diagnostic::{Diagnostic, DiagnosticCollector};
17use crate::discover;
18use crate::error::MarsError;
19use crate::fs::FileLock;
20use crate::hash;
21use crate::lock::LockFile;
22use crate::lock::{ItemId, ItemKind};
23use crate::resolve::{ResolveOptions, ResolvedGraph};
24use crate::source::GlobalCache;
25use crate::sync::apply::ApplyResult;
26pub use crate::sync::apply::SyncOptions;
27use crate::sync::target::{RenameAction, TargetItem, TargetState};
28use crate::types::{
29 ContentHash, DestPath, MarsContext, Materialization, SourceId, SourceName, SourceOrigin,
30};
31use crate::validate::ValidationWarning;
32
33pub use crate::sync::mutation::{ConfigMutation, DependencyUpsertChange, apply_config_mutation};
35
36#[derive(Debug)]
38pub struct SyncReport {
39 pub applied: ApplyResult,
40 pub pruned: Vec<apply::ActionOutcome>,
41 pub diagnostics: Vec<Diagnostic>,
42 pub dependency_changes: Vec<DependencyUpsertChange>,
43 pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
45 pub dry_run: bool,
47}
48
49impl SyncReport {
50 pub fn has_conflicts(&self) -> bool {
52 self.applied
53 .outcomes
54 .iter()
55 .any(|o| matches!(o.action, apply::ActionTaken::Conflicted))
56 }
57}
58
59#[derive(Debug, Clone)]
61pub struct SyncRequest {
62 pub resolution: ResolutionMode,
64 pub mutation: Option<ConfigMutation>,
66 pub options: SyncOptions,
68}
69
70#[derive(Debug, Clone)]
72pub enum ResolutionMode {
73 Normal,
75 Maximize { targets: HashSet<SourceName> },
77}
78
79pub struct LoadedConfig {
86 pub config: Config,
87 pub local: LocalConfig,
88 pub effective: EffectiveConfig,
89 pub old_lock: LockFile,
90 pub dependency_changes: Vec<DependencyUpsertChange>,
91 pub _sync_lock: FileLock,
92}
93
94pub struct ResolvedState {
96 pub loaded: LoadedConfig,
97 pub graph: ResolvedGraph,
98 pub model_aliases: indexmap::IndexMap<String, crate::models::ModelAlias>,
99}
100
101pub struct TargetedState {
103 pub resolved: ResolvedState,
104 pub target: TargetState,
105 pub renames: Vec<RenameAction>,
106 pub warnings: Vec<ValidationWarning>,
107}
108
109pub struct PlannedState {
111 pub targeted: TargetedState,
112 pub plan: plan::SyncPlan,
113}
114
115pub struct AppliedState {
117 pub planned: PlannedState,
118 pub applied: ApplyResult,
119}
120
121pub struct SyncedState {
123 pub applied: AppliedState,
124 pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
125}
126
127pub fn execute(ctx: &MarsContext, request: &SyncRequest) -> Result<SyncReport, MarsError> {
131 validate_request(request)?;
132 let mut diag = DiagnosticCollector::new();
133 let loaded = load_config(ctx, request, &mut diag)?;
134 let resolved = resolve_graph(ctx, loaded, request, &mut diag)?;
135 let targeted = build_target(ctx, resolved, request, &mut diag)?;
136 let planned = create_plan(ctx, targeted, request, &mut diag)?;
137 if request.options.frozen {
138 check_frozen_gate(&planned)?;
139 }
140 let applied = apply_plan(ctx, planned, request)?;
141 let synced = sync_targets(ctx, applied, request, &mut diag);
142 let report = finalize(ctx, synced, request, &mut diag)?;
143 Ok(report)
144}
145
146fn load_config(
153 ctx: &MarsContext,
154 request: &SyncRequest,
155 diag: &mut DiagnosticCollector,
156) -> Result<LoadedConfig, MarsError> {
157 let project_root = &ctx.project_root;
158 let mars_dir = project_root.join(".mars");
159
160 std::fs::create_dir_all(mars_dir.join("cache"))?;
161
162 let lock_path = mars_dir.join("sync.lock");
164 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
165
166 let mut config = match crate::config::load(project_root) {
168 Ok(config) => config,
169 Err(err) if mutation::is_config_not_found(&err) && request.mutation.is_some() => Config {
170 settings: Settings::default(),
171 ..Config::default()
172 },
173 Err(err) => return Err(err),
174 };
175
176 let dependency_changes = if let Some(m) = &request.mutation {
178 mutation::apply_mutation(&mut config, m)?
179 } else {
180 Vec::new()
181 };
182
183 let mut local = crate::config::load_local(project_root)?;
185 if let Some(m) = &request.mutation {
186 mutation::apply_local_mutation(&mut local, m);
187 }
188
189 let (effective, config_diagnostics) =
191 crate::config::merge_with_root(config.clone(), local.clone(), project_root)?;
192 diag.extend(config_diagnostics);
193
194 let old_lock = crate::lock::load(project_root)?;
196
197 Ok(LoadedConfig {
198 config,
199 local,
200 effective,
201 old_lock,
202 dependency_changes,
203 _sync_lock,
204 })
205}
206
207fn resolve_graph(
209 ctx: &MarsContext,
210 loaded: LoadedConfig,
211 request: &SyncRequest,
212 diag: &mut DiagnosticCollector,
213) -> Result<ResolvedState, MarsError> {
214 validate_targets(&request.resolution, &loaded.effective)?;
215
216 let cache = GlobalCache::new()?;
217 let source_provider = provider::RealSourceProvider {
218 cache: &cache,
219 project_root: &ctx.project_root,
220 };
221 let resolve_options = to_resolve_options(&request.resolution, request.options.frozen);
222 let graph = crate::resolve::resolve(
223 &loaded.effective,
224 &source_provider,
225 Some(&loaded.old_lock),
226 &resolve_options,
227 diag,
228 )?;
229
230 let dep_models: Vec<crate::models::ResolvedDepModels> = graph
232 .order
233 .iter()
234 .filter_map(|name| {
235 let node = graph.nodes.get(name)?;
236 let manifest = node.manifest.as_ref()?;
237 if manifest.models.is_empty() {
238 return None;
239 }
240 Some(crate::models::ResolvedDepModels {
241 source_name: name.to_string(),
242 models: manifest.models.clone(),
243 })
244 })
245 .collect();
246 let model_aliases = crate::models::merge_model_config(&loaded.config.models, &dep_models, diag);
247
248 Ok(ResolvedState {
249 loaded,
250 graph,
251 model_aliases,
252 })
253}
254
255fn build_target(
257 ctx: &MarsContext,
258 resolved: ResolvedState,
259 _request: &SyncRequest,
260 diag: &mut DiagnosticCollector,
261) -> Result<TargetedState, MarsError> {
262 let mars_dir = ctx.project_root.join(".mars");
264 let managed_root = &mars_dir;
265
266 let (mut target_state, renames) =
268 target::build_with_collisions(&resolved.graph, &resolved.loaded.effective)?;
269
270 if resolved.loaded.config.package.is_some() {
271 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
272 let local_source_id = SourceId::Path {
273 canonical: ctx
274 .project_root
275 .canonicalize()
276 .unwrap_or_else(|_| ctx.project_root.clone()),
277 };
278
279 let local_items =
280 discover::discover_source(&ctx.project_root, Some(local_source_name.as_str()))?;
281 for item in local_items {
282 let source_path = ctx.project_root.join(&item.source_path);
283 let is_flat_skill =
284 item.id.kind == ItemKind::Skill && item.source_path == Path::new(".");
285 let source_hash = if is_flat_skill {
286 ContentHash::from(hash::compute_skill_hash_filtered(
287 &source_path,
288 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
289 )?)
290 } else {
291 ContentHash::from(hash::compute_hash(&source_path, item.id.kind)?)
292 };
293 let dest_path = default_dest_path(item.id.kind, item.id.name.as_str());
294
295 if let Some(existing) = target_state.items.shift_remove(&dest_path) {
296 diag.warn(
297 "local-shadow",
298 format!(
299 "local {} `{}` shadows dependency `{}` {} `{}`",
300 item.id.kind,
301 item.id.name,
302 existing.source_name,
303 existing.id.kind,
304 existing.id.name
305 ),
306 );
307 }
308
309 let disk_path = managed_root.join(dest_path.as_path());
310 if !resolved.loaded.old_lock.items.contains_key(&dest_path)
311 && disk_path.symlink_metadata().is_ok()
312 {
313 diag.warn(
314 "unmanaged-collision",
315 format!(
316 "local {} `{}` collides with unmanaged path `{}` — leaving existing content untouched",
317 item.id.kind, item.id.name, dest_path
318 ),
319 );
320 continue;
321 }
322
323 target_state.items.insert(
324 dest_path.clone(),
325 TargetItem {
326 id: ItemId {
327 kind: item.id.kind,
328 name: item.id.name.clone(),
329 },
330 source_name: local_source_name.clone(),
331 origin: SourceOrigin::LocalPackage,
332 materialization: Materialization::Symlink {
333 source_abs: source_path.clone(),
334 },
335 source_id: local_source_id.clone(),
336 source_path,
337 dest_path,
338 source_hash,
339 is_flat_skill,
340 rewritten_content: None,
341 },
342 );
343 }
344 }
345
346 if !renames.is_empty() {
348 let rewrite_warnings =
349 target::rewrite_skill_refs(&mut target_state, &renames, &resolved.graph)?;
350 for w in &rewrite_warnings {
351 diag.warn("rewrite-warning", w.to_string());
352 }
353 }
354
355 let warnings = validate_skill_refs(managed_root, &target_state);
357
358 let unmanaged_collisions =
360 target::check_unmanaged_collisions(managed_root, &resolved.loaded.old_lock, &target_state);
361 for collision in &unmanaged_collisions {
362 diag.warn(
363 "unmanaged-collision",
364 format!(
365 "source `{}` collides with unmanaged path `{}` — leaving existing content untouched",
366 collision.source_name, collision.path
367 ),
368 );
369 target_state.items.shift_remove(&collision.path);
370 }
371
372 Ok(TargetedState {
373 resolved,
374 target: target_state,
375 renames,
376 warnings,
377 })
378}
379
380fn create_plan(
382 ctx: &MarsContext,
383 targeted: TargetedState,
384 request: &SyncRequest,
385 _diag: &mut DiagnosticCollector,
386) -> Result<PlannedState, MarsError> {
387 let mars_dir = ctx.project_root.join(".mars");
389 let managed_root = &mars_dir;
390 let cache_bases_dir = mars_dir.join("cache").join("bases");
391
392 let sync_diff = diff::compute(
394 managed_root,
395 &targeted.resolved.loaded.old_lock,
396 &targeted.target,
397 request.options.force,
398 )?;
399
400 let sync_plan = plan::create(&sync_diff, &request.options, &cache_bases_dir);
402
403 Ok(PlannedState {
404 targeted,
405 plan: sync_plan,
406 })
407}
408
409fn check_frozen_gate(planned: &PlannedState) -> Result<(), MarsError> {
411 let has_changes = planned.plan.actions.iter().any(|a| {
412 !matches!(
413 a,
414 plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
415 )
416 });
417 if has_changes {
418 return Err(MarsError::FrozenViolation {
419 message: "lock file would change but --frozen is set".into(),
420 });
421 }
422 Ok(())
423}
424
425fn apply_plan(
427 ctx: &MarsContext,
428 planned: PlannedState,
429 request: &SyncRequest,
430) -> Result<AppliedState, MarsError> {
431 let project_root = &ctx.project_root;
432 let mars_dir = project_root.join(".mars");
433 let cache_bases_dir = mars_dir.join("cache").join("bases");
434
435 let has_mutation = request.mutation.is_some();
436
437 if has_mutation && !request.options.dry_run {
439 match &request.mutation {
440 Some(ConfigMutation::SetOverride { .. } | ConfigMutation::ClearOverride { .. }) => {
441 crate::config::save_local(project_root, &planned.targeted.resolved.loaded.local)?;
442 }
443 Some(
444 ConfigMutation::UpsertDependency { .. }
445 | ConfigMutation::BatchUpsert(..)
446 | ConfigMutation::RemoveDependency { .. }
447 | ConfigMutation::SetRename { .. },
448 ) => {
449 crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
450 }
451 None => {}
452 }
453 }
454
455 let applied = apply::execute(&mars_dir, &planned.plan, &request.options, &cache_bases_dir)?;
459
460 Ok(AppliedState { planned, applied })
461}
462
463fn sync_targets(
469 ctx: &MarsContext,
470 applied: AppliedState,
471 request: &SyncRequest,
472 diag: &mut DiagnosticCollector,
473) -> SyncedState {
474 if request.options.dry_run {
475 return SyncedState {
476 applied,
477 target_outcomes: Vec::new(),
478 };
479 }
480
481 let mars_dir = ctx.project_root.join(".mars");
482 let targets = applied
483 .planned
484 .targeted
485 .resolved
486 .loaded
487 .effective
488 .settings
489 .managed_targets();
490 let previous_managed_paths = applied
491 .planned
492 .targeted
493 .resolved
494 .loaded
495 .old_lock
496 .items
497 .keys()
498 .map(|dest_path| dest_path.as_path().to_path_buf())
499 .collect::<HashSet<PathBuf>>();
500
501 let target_outcomes = crate::target_sync::sync_managed_targets(
502 &ctx.project_root,
503 &mars_dir,
504 &targets,
505 &applied.applied.outcomes,
506 &previous_managed_paths,
507 request.options.force,
508 diag,
509 );
510
511 SyncedState {
512 applied,
513 target_outcomes,
514 }
515}
516
517fn finalize(
521 ctx: &MarsContext,
522 state: SyncedState,
523 request: &SyncRequest,
524 diag: &mut DiagnosticCollector,
525) -> Result<SyncReport, MarsError> {
526 let project_root = &ctx.project_root;
527 let old_lock = &state.applied.planned.targeted.resolved.loaded.old_lock;
528 let graph = &state.applied.planned.targeted.resolved.graph;
529
530 if !request.options.dry_run {
532 let new_lock = crate::lock::build(graph, &state.applied.applied, old_lock)?;
533 crate::lock::write(project_root, &new_lock)?;
534
535 let dep_models: Vec<crate::models::ResolvedDepModels> = graph
539 .order
540 .iter()
541 .filter_map(|name| {
542 let node = graph.nodes.get(name)?;
543 let manifest = node.manifest.as_ref()?;
544 if manifest.models.is_empty() {
545 return None;
546 }
547 Some(crate::models::ResolvedDepModels {
548 source_name: name.to_string(),
549 models: manifest.models.clone(),
550 })
551 })
552 .collect();
553 let empty_consumer: indexmap::IndexMap<String, crate::models::ModelAlias> =
554 indexmap::IndexMap::new();
555 let mut ignored_diag = DiagnosticCollector::new();
556 let dep_model_aliases =
557 crate::models::merge_model_config(&empty_consumer, &dep_models, &mut ignored_diag);
558
559 if !request.options.dry_run {
563 let mars_path = ctx.project_root.join(".mars");
564 let ttl = crate::models::load_models_cache_ttl(ctx);
565 let mode = crate::models::resolve_refresh_mode(request.options.no_refresh_models);
566 match crate::models::ensure_fresh(&mars_path, ttl, mode) {
567 Ok((_, crate::models::RefreshOutcome::StaleFallback { reason })) => {
568 diag.warn(
569 "models-cache-refresh",
570 format!("using stale models cache: {reason}"),
571 );
572 }
573 Ok((_, crate::models::RefreshOutcome::Offline)) => {}
574 Ok(_) => {}
575 Err(err) => {
576 diag.warn(
577 "models-cache-refresh",
578 format!("failed to refresh models cache: {err}"),
579 );
580 }
581 }
582 }
583
584 match serde_json::to_string_pretty(&dep_model_aliases) {
585 Ok(json) => {
586 let merged_path = ctx.project_root.join(".mars").join("models-merged.json");
587 if let Err(err) = crate::fs::atomic_write(&merged_path, json.as_bytes()) {
588 diag.warn(
589 "models-merge-write",
590 format!("failed to write models-merged.json: {err}"),
591 );
592 }
593 }
594 Err(err) => {
595 diag.warn(
596 "models-merge-write",
597 format!("failed to serialize merged model aliases: {err}"),
598 );
599 }
600 }
601 }
602
603 for w in &state.applied.planned.targeted.warnings {
604 match w {
605 ValidationWarning::MissingSkill {
606 agent,
607 skill_name,
608 suggestion,
609 } => {
610 let msg = match suggestion {
611 Some(s) => format!(
612 "agent `{}` references missing skill `{}` (did you mean `{}`?)",
613 agent.name, skill_name, s
614 ),
615 None => {
616 format!(
617 "agent `{}` references missing skill `{}`",
618 agent.name, skill_name
619 )
620 }
621 };
622 diag.warn("missing-skill", msg);
623 }
624 }
625 }
626 let dependency_changes = state
627 .applied
628 .planned
629 .targeted
630 .resolved
631 .loaded
632 .dependency_changes;
633
634 Ok(SyncReport {
635 applied: state.applied.applied,
636 pruned: Vec::new(),
637 diagnostics: diag.drain(),
638 dependency_changes,
639 target_outcomes: state.target_outcomes,
640 dry_run: request.options.dry_run,
641 })
642}
643
644fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
645 match kind {
646 ItemKind::Agent => DestPath::from(PathBuf::from("agents").join(format!("{name}.md"))),
647 ItemKind::Skill => DestPath::from(PathBuf::from("skills").join(name)),
648 }
649}
650
651fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
652 if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
653 return Err(MarsError::InvalidRequest {
654 message:
655 "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
656 .to_string(),
657 });
658 }
659
660 if request.options.frozen && request.mutation.is_some() {
661 return Err(MarsError::InvalidRequest {
662 message:
663 "cannot modify config in --frozen mode (config change would require lock update)"
664 .to_string(),
665 });
666 }
667
668 Ok(())
669}
670
671fn validate_targets(
672 resolution: &ResolutionMode,
673 effective: &EffectiveConfig,
674) -> Result<(), MarsError> {
675 if let ResolutionMode::Maximize { targets } = resolution {
676 for name in targets {
677 if !effective.dependencies.contains_key(name) {
678 return Err(MarsError::Source {
679 source_name: name.to_string(),
680 message: format!("dependency `{name}` not found in mars.toml"),
681 });
682 }
683 }
684 }
685
686 Ok(())
687}
688
689fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
690 match mode {
691 ResolutionMode::Normal => ResolveOptions {
692 frozen,
693 ..ResolveOptions::default()
694 },
695 ResolutionMode::Maximize { targets } => ResolveOptions {
696 maximize: true,
697 upgrade_targets: targets.clone(),
698 frozen,
699 },
700 }
701}
702
703fn validate_skill_refs(
706 install_target: &std::path::Path,
707 target: &target::TargetState,
708) -> Vec<ValidationWarning> {
709 use crate::lock::ItemKind;
710
711 let available_skills: HashSet<String> = target
713 .items
714 .values()
715 .filter(|item| item.id.kind == ItemKind::Skill)
716 .map(|item| item.id.name.to_string())
717 .collect();
718
719 let agents: Vec<(String, PathBuf)> = target
721 .items
722 .values()
723 .filter(|item| item.id.kind == ItemKind::Agent)
724 .map(|item| {
725 let disk_path = install_target.join(&item.dest_path);
726 let path = if disk_path.exists() {
729 disk_path
730 } else {
731 item.source_path.clone()
732 };
733 (item.id.name.to_string(), path)
734 })
735 .collect();
736
737 crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use crate::config::*;
744 use crate::lock::{ItemKind, LockFile};
745 use crate::resolve::{ResolvedGraph, ResolvedNode};
746 use indexmap::IndexMap;
747 use std::fs;
748 use tempfile::TempDir;
749
750 struct TestFixture {
752 project_root: TempDir,
753 managed_root: PathBuf,
754 source_trees: Vec<TempDir>,
755 }
756
757 impl TestFixture {
758 fn new() -> Self {
759 let project_root = TempDir::new().unwrap();
760 let managed_root = project_root.path().join(".agents");
761 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
763 TestFixture {
764 project_root,
765 managed_root,
766 source_trees: Vec::new(),
767 }
768 }
769
770 fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
771 let dir = TempDir::new().unwrap();
772 if !agents.is_empty() {
773 let agents_dir = dir.path().join("agents");
774 fs::create_dir_all(&agents_dir).unwrap();
775 for (name, content) in agents {
776 fs::write(agents_dir.join(name), content).unwrap();
777 }
778 }
779 if !skills.is_empty() {
780 let skills_dir = dir.path().join("skills");
781 fs::create_dir_all(&skills_dir).unwrap();
782 for (name, content) in skills {
783 let skill_dir = skills_dir.join(name);
784 fs::create_dir_all(&skill_dir).unwrap();
785 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
786 }
787 }
788 self.source_trees.push(dir);
789 self.source_trees.len() - 1
790 }
791
792 fn project_root(&self) -> &std::path::Path {
793 self.project_root.path()
794 }
795
796 fn managed_root(&self) -> &std::path::Path {
797 &self.managed_root
798 }
799
800 fn tree_path(&self, idx: usize) -> PathBuf {
801 self.source_trees[idx].path().to_path_buf()
802 }
803 }
804
805 fn make_graph_config(
806 fixture: &TestFixture,
807 sources: Vec<(&str, usize, FilterMode)>,
808 ) -> (ResolvedGraph, EffectiveConfig) {
809 let mut nodes = IndexMap::new();
810 let mut order = Vec::new();
811 let mut config_dependencies = IndexMap::new();
812
813 for (name, tree_idx, filter) in sources {
814 let tree_path = fixture.tree_path(tree_idx);
815 nodes.insert(
816 name.into(),
817 ResolvedNode {
818 source_name: name.into(),
819 source_id: crate::types::SourceId::Path {
820 canonical: tree_path.clone(),
821 },
822 resolved_ref: crate::source::ResolvedRef {
823 source_name: name.into(),
824 version: None,
825 version_tag: None,
826 commit: None,
827 tree_path: tree_path.clone(),
828 },
829 manifest: None,
830 deps: vec![],
831 },
832 );
833 order.push(name.into());
834
835 config_dependencies.insert(
836 name.into(),
837 EffectiveDependency {
838 name: name.into(),
839 id: crate::types::SourceId::Path {
840 canonical: tree_path.clone(),
841 },
842 spec: SourceSpec::Path(tree_path),
843 filter,
844 rename: crate::types::RenameMap::new(),
845 is_overridden: false,
846 original_git: None,
847 },
848 );
849 }
850
851 (
852 ResolvedGraph {
853 nodes,
854 order,
855 id_index: std::collections::HashMap::new(),
856 filters: std::collections::HashMap::new(),
857 },
858 EffectiveConfig {
859 dependencies: config_dependencies,
860 settings: Settings::default(),
861 },
862 )
863 }
864
865 fn path_dependency_entry(path: &std::path::Path) -> DependencyEntry {
866 DependencyEntry {
867 url: None,
868 path: Some(path.to_path_buf()),
869 version: None,
870 filter: FilterConfig::default(),
871 }
872 }
873
874 #[test]
875 fn validate_request_rejects_frozen_with_maximize() {
876 let request = SyncRequest {
877 resolution: ResolutionMode::Maximize {
878 targets: HashSet::new(),
879 },
880 mutation: None,
881 options: SyncOptions {
882 force: false,
883 dry_run: false,
884 frozen: true,
885 no_refresh_models: false,
886 },
887 };
888
889 let err = validate_request(&request).unwrap_err();
890 assert!(matches!(err, MarsError::InvalidRequest { .. }));
891 assert!(err.to_string().contains("--frozen"));
892 }
893
894 #[test]
895 fn validate_request_rejects_frozen_with_mutation() {
896 let request = SyncRequest {
897 resolution: ResolutionMode::Normal,
898 mutation: Some(ConfigMutation::RemoveDependency {
899 name: "base".into(),
900 }),
901 options: SyncOptions {
902 force: false,
903 dry_run: false,
904 frozen: true,
905 no_refresh_models: false,
906 },
907 };
908
909 let err = validate_request(&request).unwrap_err();
910 assert!(matches!(err, MarsError::InvalidRequest { .. }));
911 assert!(err.to_string().contains("cannot modify config"));
912 }
913
914 #[test]
915 fn execute_auto_inits_config_for_mutation() {
916 let project_root = TempDir::new().unwrap();
917 let managed_root = project_root.path().join(".agents");
918 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
919 let source = TempDir::new().unwrap();
920 fs::create_dir_all(source.path().join("agents")).unwrap();
921 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
922
923 let request = SyncRequest {
924 resolution: ResolutionMode::Normal,
925 mutation: Some(ConfigMutation::UpsertDependency {
926 name: "base".into(),
927 entry: path_dependency_entry(source.path()),
928 }),
929 options: SyncOptions::default(),
930 };
931
932 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
933 let report = execute(&ctx, &request).unwrap();
934 assert!(!report.applied.outcomes.is_empty());
935 assert!(project_root.path().join("mars.toml").exists());
936
937 let saved = crate::config::load(project_root.path()).unwrap();
938 assert!(saved.dependencies.contains_key("base"));
939 }
940
941 #[test]
942 fn execute_dry_run_with_mutation_does_not_write_config() {
943 let project_root = TempDir::new().unwrap();
944 let managed_root = project_root.path().join(".agents");
945 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
946 crate::config::save(
947 project_root.path(),
948 &Config {
949 dependencies: IndexMap::new(),
950 settings: Settings::default(),
951 ..Config::default()
952 },
953 )
954 .unwrap();
955
956 let source = TempDir::new().unwrap();
957 fs::create_dir_all(source.path().join("agents")).unwrap();
958 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
959
960 let request = SyncRequest {
961 resolution: ResolutionMode::Normal,
962 mutation: Some(ConfigMutation::UpsertDependency {
963 name: "base".into(),
964 entry: path_dependency_entry(source.path()),
965 }),
966 options: SyncOptions {
967 force: false,
968 dry_run: true,
969 frozen: false,
970 no_refresh_models: false,
971 },
972 };
973
974 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
975 let report = execute(&ctx, &request).unwrap();
976 assert!(!report.applied.outcomes.is_empty());
977
978 let saved = crate::config::load(project_root.path()).unwrap();
979 assert!(!saved.dependencies.contains_key("base"));
980 assert!(!managed_root.join("agents/coder.md").exists());
981 assert!(!project_root.path().join("mars.lock").exists());
982 }
983
984 #[test]
987 fn full_pipeline_fresh_sync() {
988 let mut fixture = TestFixture::new();
989 let src_idx = fixture.add_source(
990 &[("coder.md", "# Coder agent")],
991 &[("planning", "# Planning skill")],
992 );
993
994 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
995
996 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
998 assert!(renames.is_empty());
999 assert_eq!(target.items.len(), 2);
1000
1001 let lock = LockFile::empty();
1003 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1004
1005 assert_eq!(sync_diff.items.len(), 2);
1007 for entry in &sync_diff.items {
1008 assert!(matches!(entry, diff::DiffEntry::Add { .. }));
1009 }
1010
1011 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1013 let options = SyncOptions {
1014 force: false,
1015 dry_run: false,
1016 frozen: false,
1017 no_refresh_models: false,
1018 };
1019 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1020 assert_eq!(sync_plan.actions.len(), 2);
1021 for action in &sync_plan.actions {
1022 assert!(matches!(action, plan::PlannedAction::Install { .. }));
1023 }
1024
1025 let result =
1027 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1028 assert_eq!(result.outcomes.len(), 2);
1029
1030 assert!(fixture.managed_root().join("agents/coder.md").exists());
1032 assert!(
1033 fixture
1034 .managed_root()
1035 .join("skills/planning/SKILL.md")
1036 .exists()
1037 );
1038
1039 let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1041 assert_eq!(new_lock.items.len(), 2);
1042 assert!(new_lock.items.contains_key("agents/coder.md"));
1043 assert!(new_lock.items.contains_key("skills/planning"));
1044 }
1045
1046 #[test]
1047 fn re_sync_no_changes() {
1048 let mut fixture = TestFixture::new();
1049 let content = "# Coder agent";
1050 let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1051
1052 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1053
1054 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1056 let lock = LockFile::empty();
1057 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1058 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1059 let options = SyncOptions {
1060 force: false,
1061 dry_run: false,
1062 frozen: false,
1063 no_refresh_models: false,
1064 };
1065 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1066 let result =
1067 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1068 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1069
1070 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1072 let sync_diff2 =
1073 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1074
1075 for entry in &sync_diff2.items {
1077 assert!(
1078 matches!(entry, diff::DiffEntry::Unchanged { .. }),
1079 "expected Unchanged, got {entry:?}"
1080 );
1081 }
1082
1083 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1084 for action in &sync_plan2.actions {
1085 assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1086 }
1087 }
1088
1089 #[test]
1090 fn source_update_detects_changes() {
1091 let mut fixture = TestFixture::new();
1092 let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1093
1094 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1095
1096 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1098 let lock = LockFile::empty();
1099 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1100 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1101 let options = SyncOptions {
1102 force: false,
1103 dry_run: false,
1104 frozen: false,
1105 no_refresh_models: false,
1106 };
1107 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1108 let result =
1109 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1110 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1111
1112 let agents_dir = fixture.tree_path(src_idx).join("agents");
1114 fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1115
1116 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1118 let sync_diff2 =
1119 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1120
1121 assert_eq!(sync_diff2.items.len(), 1);
1123 assert!(matches!(
1124 &sync_diff2.items[0],
1125 diff::DiffEntry::Update { .. }
1126 ));
1127 }
1128
1129 #[test]
1130 fn local_modification_preserved() {
1131 let mut fixture = TestFixture::new();
1132 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1133
1134 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1135
1136 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1138 let lock = LockFile::empty();
1139 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1140 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1141 let options = SyncOptions {
1142 force: false,
1143 dry_run: false,
1144 frozen: false,
1145 no_refresh_models: false,
1146 };
1147 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1148 let result =
1149 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1150 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1151
1152 fs::write(
1154 fixture.managed_root().join("agents/coder.md"),
1155 "# Locally modified",
1156 )
1157 .unwrap();
1158
1159 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1161 let sync_diff2 =
1162 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1163
1164 assert_eq!(sync_diff2.items.len(), 1);
1166 assert!(matches!(
1167 &sync_diff2.items[0],
1168 diff::DiffEntry::LocalModified { .. }
1169 ));
1170
1171 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1173 assert!(matches!(
1174 &sync_plan2.actions[0],
1175 plan::PlannedAction::KeepLocal { .. }
1176 ));
1177 }
1178
1179 #[test]
1180 fn force_overwrites_local_modifications() {
1181 let mut fixture = TestFixture::new();
1182 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1183
1184 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1185
1186 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1188 let lock = LockFile::empty();
1189 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1190 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1191 let options = SyncOptions {
1192 force: false,
1193 dry_run: false,
1194 frozen: false,
1195 no_refresh_models: false,
1196 };
1197 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1198 let result =
1199 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1200 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1201
1202 fs::write(
1204 fixture.managed_root().join("agents/coder.md"),
1205 "# Locally modified",
1206 )
1207 .unwrap();
1208
1209 let agents_dir = fixture.tree_path(src_idx).join("agents");
1211 fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1212
1213 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1215 let sync_diff2 =
1216 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1217
1218 let force_options = SyncOptions {
1219 force: true,
1220 dry_run: false,
1221 frozen: false,
1222 no_refresh_models: false,
1223 };
1224 let sync_plan2 = plan::create(&sync_diff2, &force_options, &cache_dir);
1225 assert!(matches!(
1226 &sync_plan2.actions[0],
1227 plan::PlannedAction::Overwrite { .. }
1228 ));
1229
1230 let result2 = apply::execute(
1231 fixture.managed_root(),
1232 &sync_plan2,
1233 &force_options,
1234 &cache_dir,
1235 )
1236 .unwrap();
1237 assert!(matches!(
1238 result2.outcomes[0].action,
1239 apply::ActionTaken::Updated
1240 ));
1241
1242 let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
1244 assert_eq!(content, "# Upstream update");
1245 }
1246
1247 #[test]
1248 fn orphan_removed_when_source_drops_item() {
1249 let mut fixture = TestFixture::new();
1250 let src_idx = fixture.add_source(
1251 &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
1252 &[],
1253 );
1254
1255 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1256
1257 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1259 let lock = LockFile::empty();
1260 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1261 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1262 let options = SyncOptions {
1263 force: false,
1264 dry_run: false,
1265 frozen: false,
1266 no_refresh_models: false,
1267 };
1268 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1269 let result =
1270 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1271 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1272
1273 assert!(fixture.managed_root().join("agents/coder.md").exists());
1274 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1275
1276 fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
1278
1279 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1281 let sync_diff2 =
1282 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1283
1284 let orphan_count = sync_diff2
1286 .items
1287 .iter()
1288 .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1289 .count();
1290 assert_eq!(orphan_count, 1);
1291
1292 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1293 let result2 =
1294 apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
1295
1296 assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
1298 assert!(fixture.managed_root().join("agents/coder.md").exists());
1300
1301 let removed = result2
1303 .outcomes
1304 .iter()
1305 .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1306 assert!(removed);
1307 }
1308
1309 #[test]
1310 fn dry_run_produces_plan_without_changes() {
1311 let mut fixture = TestFixture::new();
1312 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1313
1314 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1315
1316 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1317 let lock = LockFile::empty();
1318 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1319
1320 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1321 let dry_options = SyncOptions {
1322 force: false,
1323 dry_run: true,
1324 frozen: false,
1325 no_refresh_models: false,
1326 };
1327
1328 let sync_plan = plan::create(&sync_diff, &dry_options, &cache_dir);
1329 assert!(!sync_plan.actions.is_empty());
1330
1331 let result =
1333 apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1334 assert!(!result.outcomes.is_empty());
1335
1336 assert!(!fixture.managed_root().join("agents/coder.md").exists());
1338 }
1339
1340 #[test]
1341 fn lock_written_after_apply() {
1342 let mut fixture = TestFixture::new();
1343 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1344
1345 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1346
1347 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1349 let lock = LockFile::empty();
1350 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1351 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1352 let options = SyncOptions {
1353 force: false,
1354 dry_run: false,
1355 frozen: false,
1356 no_refresh_models: false,
1357 };
1358 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1359 let result =
1360 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1361
1362 let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1363 crate::lock::write(fixture.project_root(), &new_lock).unwrap();
1364
1365 let reloaded = crate::lock::load(fixture.project_root()).unwrap();
1367 assert_eq!(reloaded.items.len(), 1);
1368 assert!(reloaded.items.contains_key("agents/coder.md"));
1369
1370 let item = &reloaded.items["agents/coder.md"];
1371 assert_eq!(item.kind, ItemKind::Agent);
1372 assert!(!item.source_checksum.is_empty());
1373 assert!(!item.installed_checksum.is_empty());
1374 }
1375
1376 #[test]
1377 fn two_sources_no_collision() {
1378 let mut fixture = TestFixture::new();
1379 let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
1380 let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
1381
1382 let (graph, config) = make_graph_config(
1383 &fixture,
1384 vec![
1385 ("source-a", src_a, FilterMode::All),
1386 ("source-b", src_b, FilterMode::All),
1387 ],
1388 );
1389
1390 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1391 assert!(renames.is_empty());
1392 assert_eq!(target.items.len(), 2);
1393
1394 let lock = LockFile::empty();
1395 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1396 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1397 let options = SyncOptions {
1398 force: false,
1399 dry_run: false,
1400 frozen: false,
1401 no_refresh_models: false,
1402 };
1403 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1404 let result =
1405 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1406
1407 assert!(fixture.managed_root().join("agents/coder.md").exists());
1408 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1409 assert_eq!(result.outcomes.len(), 2);
1410 }
1411
1412 #[test]
1415 fn pipeline_only_skills_filter() {
1416 let mut fixture = TestFixture::new();
1417 let src_idx = fixture.add_source(
1418 &[("coder.md", "# Coder agent")],
1419 &[("planning", "# Planning skill")],
1420 );
1421
1422 let (graph, config) =
1423 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
1424
1425 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1426 assert_eq!(target.items.len(), 1);
1428 assert!(target.items.contains_key("skills/planning"));
1429 }
1430
1431 #[test]
1432 fn pipeline_only_agents_filter() {
1433 let mut fixture = TestFixture::new();
1434 let agent_content = "---\nskills:\n - planning\n---\n# Coder agent";
1436 let src_idx = fixture.add_source(
1437 &[("coder.md", agent_content)],
1438 &[
1439 ("planning", "# Planning skill"),
1440 ("standalone", "# Standalone skill"),
1441 ],
1442 );
1443
1444 let (graph, config) =
1445 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1446
1447 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1448 assert_eq!(target.items.len(), 2);
1450 assert!(target.items.contains_key("agents/coder.md"));
1451 assert!(target.items.contains_key("skills/planning"));
1452 assert!(!target.items.contains_key("skills/standalone"));
1453 }
1454
1455 #[test]
1456 fn pipeline_only_agents_no_agents_source() {
1457 let mut fixture = TestFixture::new();
1458 let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
1459
1460 let (graph, config) =
1461 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1462
1463 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1464 assert_eq!(target.items.len(), 0);
1466 }
1467}