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 match serde_json::to_string_pretty(&dep_model_aliases) {
560 Ok(json) => {
561 let merged_path = ctx.project_root.join(".mars").join("models-merged.json");
562 if let Err(err) = crate::fs::atomic_write(&merged_path, json.as_bytes()) {
563 diag.warn(
564 "models-merge-write",
565 format!("failed to write models-merged.json: {err}"),
566 );
567 }
568 }
569 Err(err) => {
570 diag.warn(
571 "models-merge-write",
572 format!("failed to serialize merged model aliases: {err}"),
573 );
574 }
575 }
576 }
577
578 for w in &state.applied.planned.targeted.warnings {
579 match w {
580 ValidationWarning::MissingSkill {
581 agent,
582 skill_name,
583 suggestion,
584 } => {
585 let msg = match suggestion {
586 Some(s) => format!(
587 "agent `{}` references missing skill `{}` (did you mean `{}`?)",
588 agent.name, skill_name, s
589 ),
590 None => {
591 format!(
592 "agent `{}` references missing skill `{}`",
593 agent.name, skill_name
594 )
595 }
596 };
597 diag.warn("missing-skill", msg);
598 }
599 }
600 }
601 let dependency_changes = state
602 .applied
603 .planned
604 .targeted
605 .resolved
606 .loaded
607 .dependency_changes;
608
609 Ok(SyncReport {
610 applied: state.applied.applied,
611 pruned: Vec::new(),
612 diagnostics: diag.drain(),
613 dependency_changes,
614 target_outcomes: state.target_outcomes,
615 dry_run: request.options.dry_run,
616 })
617}
618
619fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
620 match kind {
621 ItemKind::Agent => DestPath::from(PathBuf::from("agents").join(format!("{name}.md"))),
622 ItemKind::Skill => DestPath::from(PathBuf::from("skills").join(name)),
623 }
624}
625
626fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
627 if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
628 return Err(MarsError::InvalidRequest {
629 message:
630 "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
631 .to_string(),
632 });
633 }
634
635 if request.options.frozen && request.mutation.is_some() {
636 return Err(MarsError::InvalidRequest {
637 message:
638 "cannot modify config in --frozen mode (config change would require lock update)"
639 .to_string(),
640 });
641 }
642
643 Ok(())
644}
645
646fn validate_targets(
647 resolution: &ResolutionMode,
648 effective: &EffectiveConfig,
649) -> Result<(), MarsError> {
650 if let ResolutionMode::Maximize { targets } = resolution {
651 for name in targets {
652 if !effective.dependencies.contains_key(name) {
653 return Err(MarsError::Source {
654 source_name: name.to_string(),
655 message: format!("dependency `{name}` not found in mars.toml"),
656 });
657 }
658 }
659 }
660
661 Ok(())
662}
663
664fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
665 match mode {
666 ResolutionMode::Normal => ResolveOptions {
667 frozen,
668 ..ResolveOptions::default()
669 },
670 ResolutionMode::Maximize { targets } => ResolveOptions {
671 maximize: true,
672 upgrade_targets: targets.clone(),
673 frozen,
674 },
675 }
676}
677
678fn validate_skill_refs(
681 install_target: &std::path::Path,
682 target: &target::TargetState,
683) -> Vec<ValidationWarning> {
684 use crate::lock::ItemKind;
685
686 let available_skills: HashSet<String> = target
688 .items
689 .values()
690 .filter(|item| item.id.kind == ItemKind::Skill)
691 .map(|item| item.id.name.to_string())
692 .collect();
693
694 let agents: Vec<(String, PathBuf)> = target
696 .items
697 .values()
698 .filter(|item| item.id.kind == ItemKind::Agent)
699 .map(|item| {
700 let disk_path = install_target.join(&item.dest_path);
701 let path = if disk_path.exists() {
704 disk_path
705 } else {
706 item.source_path.clone()
707 };
708 (item.id.name.to_string(), path)
709 })
710 .collect();
711
712 crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use crate::config::*;
719 use crate::lock::{ItemKind, LockFile};
720 use crate::resolve::{ResolvedGraph, ResolvedNode};
721 use indexmap::IndexMap;
722 use std::fs;
723 use tempfile::TempDir;
724
725 struct TestFixture {
727 project_root: TempDir,
728 managed_root: PathBuf,
729 source_trees: Vec<TempDir>,
730 }
731
732 impl TestFixture {
733 fn new() -> Self {
734 let project_root = TempDir::new().unwrap();
735 let managed_root = project_root.path().join(".agents");
736 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
738 TestFixture {
739 project_root,
740 managed_root,
741 source_trees: Vec::new(),
742 }
743 }
744
745 fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
746 let dir = TempDir::new().unwrap();
747 if !agents.is_empty() {
748 let agents_dir = dir.path().join("agents");
749 fs::create_dir_all(&agents_dir).unwrap();
750 for (name, content) in agents {
751 fs::write(agents_dir.join(name), content).unwrap();
752 }
753 }
754 if !skills.is_empty() {
755 let skills_dir = dir.path().join("skills");
756 fs::create_dir_all(&skills_dir).unwrap();
757 for (name, content) in skills {
758 let skill_dir = skills_dir.join(name);
759 fs::create_dir_all(&skill_dir).unwrap();
760 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
761 }
762 }
763 self.source_trees.push(dir);
764 self.source_trees.len() - 1
765 }
766
767 fn project_root(&self) -> &std::path::Path {
768 self.project_root.path()
769 }
770
771 fn managed_root(&self) -> &std::path::Path {
772 &self.managed_root
773 }
774
775 fn tree_path(&self, idx: usize) -> PathBuf {
776 self.source_trees[idx].path().to_path_buf()
777 }
778 }
779
780 fn make_graph_config(
781 fixture: &TestFixture,
782 sources: Vec<(&str, usize, FilterMode)>,
783 ) -> (ResolvedGraph, EffectiveConfig) {
784 let mut nodes = IndexMap::new();
785 let mut order = Vec::new();
786 let mut config_dependencies = IndexMap::new();
787
788 for (name, tree_idx, filter) in sources {
789 let tree_path = fixture.tree_path(tree_idx);
790 nodes.insert(
791 name.into(),
792 ResolvedNode {
793 source_name: name.into(),
794 source_id: crate::types::SourceId::Path {
795 canonical: tree_path.clone(),
796 },
797 resolved_ref: crate::source::ResolvedRef {
798 source_name: name.into(),
799 version: None,
800 version_tag: None,
801 commit: None,
802 tree_path: tree_path.clone(),
803 },
804 manifest: None,
805 deps: vec![],
806 },
807 );
808 order.push(name.into());
809
810 config_dependencies.insert(
811 name.into(),
812 EffectiveDependency {
813 name: name.into(),
814 id: crate::types::SourceId::Path {
815 canonical: tree_path.clone(),
816 },
817 spec: SourceSpec::Path(tree_path),
818 filter,
819 rename: crate::types::RenameMap::new(),
820 is_overridden: false,
821 original_git: None,
822 },
823 );
824 }
825
826 (
827 ResolvedGraph {
828 nodes,
829 order,
830 id_index: std::collections::HashMap::new(),
831 },
832 EffectiveConfig {
833 dependencies: config_dependencies,
834 settings: Settings::default(),
835 },
836 )
837 }
838
839 fn path_dependency_entry(path: &std::path::Path) -> DependencyEntry {
840 DependencyEntry {
841 url: None,
842 path: Some(path.to_path_buf()),
843 version: None,
844 filter: FilterConfig::default(),
845 }
846 }
847
848 #[test]
849 fn validate_request_rejects_frozen_with_maximize() {
850 let request = SyncRequest {
851 resolution: ResolutionMode::Maximize {
852 targets: HashSet::new(),
853 },
854 mutation: None,
855 options: SyncOptions {
856 force: false,
857 dry_run: false,
858 frozen: true,
859 },
860 };
861
862 let err = validate_request(&request).unwrap_err();
863 assert!(matches!(err, MarsError::InvalidRequest { .. }));
864 assert!(err.to_string().contains("--frozen"));
865 }
866
867 #[test]
868 fn validate_request_rejects_frozen_with_mutation() {
869 let request = SyncRequest {
870 resolution: ResolutionMode::Normal,
871 mutation: Some(ConfigMutation::RemoveDependency {
872 name: "base".into(),
873 }),
874 options: SyncOptions {
875 force: false,
876 dry_run: false,
877 frozen: true,
878 },
879 };
880
881 let err = validate_request(&request).unwrap_err();
882 assert!(matches!(err, MarsError::InvalidRequest { .. }));
883 assert!(err.to_string().contains("cannot modify config"));
884 }
885
886 #[test]
887 fn execute_auto_inits_config_for_mutation() {
888 let project_root = TempDir::new().unwrap();
889 let managed_root = project_root.path().join(".agents");
890 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
891 let source = TempDir::new().unwrap();
892 fs::create_dir_all(source.path().join("agents")).unwrap();
893 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
894
895 let request = SyncRequest {
896 resolution: ResolutionMode::Normal,
897 mutation: Some(ConfigMutation::UpsertDependency {
898 name: "base".into(),
899 entry: path_dependency_entry(source.path()),
900 }),
901 options: SyncOptions::default(),
902 };
903
904 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
905 let report = execute(&ctx, &request).unwrap();
906 assert!(!report.applied.outcomes.is_empty());
907 assert!(project_root.path().join("mars.toml").exists());
908
909 let saved = crate::config::load(project_root.path()).unwrap();
910 assert!(saved.dependencies.contains_key("base"));
911 }
912
913 #[test]
914 fn execute_dry_run_with_mutation_does_not_write_config() {
915 let project_root = TempDir::new().unwrap();
916 let managed_root = project_root.path().join(".agents");
917 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
918 crate::config::save(
919 project_root.path(),
920 &Config {
921 dependencies: IndexMap::new(),
922 settings: Settings::default(),
923 ..Config::default()
924 },
925 )
926 .unwrap();
927
928 let source = TempDir::new().unwrap();
929 fs::create_dir_all(source.path().join("agents")).unwrap();
930 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
931
932 let request = SyncRequest {
933 resolution: ResolutionMode::Normal,
934 mutation: Some(ConfigMutation::UpsertDependency {
935 name: "base".into(),
936 entry: path_dependency_entry(source.path()),
937 }),
938 options: SyncOptions {
939 force: false,
940 dry_run: true,
941 frozen: false,
942 },
943 };
944
945 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
946 let report = execute(&ctx, &request).unwrap();
947 assert!(!report.applied.outcomes.is_empty());
948
949 let saved = crate::config::load(project_root.path()).unwrap();
950 assert!(!saved.dependencies.contains_key("base"));
951 assert!(!managed_root.join("agents/coder.md").exists());
952 assert!(!project_root.path().join("mars.lock").exists());
953 }
954
955 #[test]
958 fn full_pipeline_fresh_sync() {
959 let mut fixture = TestFixture::new();
960 let src_idx = fixture.add_source(
961 &[("coder.md", "# Coder agent")],
962 &[("planning", "# Planning skill")],
963 );
964
965 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
966
967 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
969 assert!(renames.is_empty());
970 assert_eq!(target.items.len(), 2);
971
972 let lock = LockFile::empty();
974 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
975
976 assert_eq!(sync_diff.items.len(), 2);
978 for entry in &sync_diff.items {
979 assert!(matches!(entry, diff::DiffEntry::Add { .. }));
980 }
981
982 let cache_dir = fixture.project_root().join(".mars/cache/bases");
984 let options = SyncOptions {
985 force: false,
986 dry_run: false,
987 frozen: false,
988 };
989 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
990 assert_eq!(sync_plan.actions.len(), 2);
991 for action in &sync_plan.actions {
992 assert!(matches!(action, plan::PlannedAction::Install { .. }));
993 }
994
995 let result =
997 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
998 assert_eq!(result.outcomes.len(), 2);
999
1000 assert!(fixture.managed_root().join("agents/coder.md").exists());
1002 assert!(
1003 fixture
1004 .managed_root()
1005 .join("skills/planning/SKILL.md")
1006 .exists()
1007 );
1008
1009 let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1011 assert_eq!(new_lock.items.len(), 2);
1012 assert!(new_lock.items.contains_key("agents/coder.md"));
1013 assert!(new_lock.items.contains_key("skills/planning"));
1014 }
1015
1016 #[test]
1017 fn re_sync_no_changes() {
1018 let mut fixture = TestFixture::new();
1019 let content = "# Coder agent";
1020 let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1021
1022 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1023
1024 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1026 let lock = LockFile::empty();
1027 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1028 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1029 let options = SyncOptions {
1030 force: false,
1031 dry_run: false,
1032 frozen: false,
1033 };
1034 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1035 let result =
1036 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1037 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1038
1039 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1041 let sync_diff2 =
1042 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1043
1044 for entry in &sync_diff2.items {
1046 assert!(
1047 matches!(entry, diff::DiffEntry::Unchanged { .. }),
1048 "expected Unchanged, got {entry:?}"
1049 );
1050 }
1051
1052 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1053 for action in &sync_plan2.actions {
1054 assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1055 }
1056 }
1057
1058 #[test]
1059 fn source_update_detects_changes() {
1060 let mut fixture = TestFixture::new();
1061 let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1062
1063 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1064
1065 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1067 let lock = LockFile::empty();
1068 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1069 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1070 let options = SyncOptions {
1071 force: false,
1072 dry_run: false,
1073 frozen: false,
1074 };
1075 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1076 let result =
1077 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1078 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1079
1080 let agents_dir = fixture.tree_path(src_idx).join("agents");
1082 fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1083
1084 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1086 let sync_diff2 =
1087 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1088
1089 assert_eq!(sync_diff2.items.len(), 1);
1091 assert!(matches!(
1092 &sync_diff2.items[0],
1093 diff::DiffEntry::Update { .. }
1094 ));
1095 }
1096
1097 #[test]
1098 fn local_modification_preserved() {
1099 let mut fixture = TestFixture::new();
1100 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1101
1102 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1103
1104 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1106 let lock = LockFile::empty();
1107 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1108 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1109 let options = SyncOptions {
1110 force: false,
1111 dry_run: false,
1112 frozen: false,
1113 };
1114 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1115 let result =
1116 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1117 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1118
1119 fs::write(
1121 fixture.managed_root().join("agents/coder.md"),
1122 "# Locally modified",
1123 )
1124 .unwrap();
1125
1126 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1128 let sync_diff2 =
1129 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1130
1131 assert_eq!(sync_diff2.items.len(), 1);
1133 assert!(matches!(
1134 &sync_diff2.items[0],
1135 diff::DiffEntry::LocalModified { .. }
1136 ));
1137
1138 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1140 assert!(matches!(
1141 &sync_plan2.actions[0],
1142 plan::PlannedAction::KeepLocal { .. }
1143 ));
1144 }
1145
1146 #[test]
1147 fn force_overwrites_local_modifications() {
1148 let mut fixture = TestFixture::new();
1149 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1150
1151 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1152
1153 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1155 let lock = LockFile::empty();
1156 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1157 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1158 let options = SyncOptions {
1159 force: false,
1160 dry_run: false,
1161 frozen: false,
1162 };
1163 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1164 let result =
1165 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1166 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1167
1168 fs::write(
1170 fixture.managed_root().join("agents/coder.md"),
1171 "# Locally modified",
1172 )
1173 .unwrap();
1174
1175 let agents_dir = fixture.tree_path(src_idx).join("agents");
1177 fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1178
1179 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1181 let sync_diff2 =
1182 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1183
1184 let force_options = SyncOptions {
1185 force: true,
1186 dry_run: false,
1187 frozen: false,
1188 };
1189 let sync_plan2 = plan::create(&sync_diff2, &force_options, &cache_dir);
1190 assert!(matches!(
1191 &sync_plan2.actions[0],
1192 plan::PlannedAction::Overwrite { .. }
1193 ));
1194
1195 let result2 = apply::execute(
1196 fixture.managed_root(),
1197 &sync_plan2,
1198 &force_options,
1199 &cache_dir,
1200 )
1201 .unwrap();
1202 assert!(matches!(
1203 result2.outcomes[0].action,
1204 apply::ActionTaken::Updated
1205 ));
1206
1207 let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
1209 assert_eq!(content, "# Upstream update");
1210 }
1211
1212 #[test]
1213 fn orphan_removed_when_source_drops_item() {
1214 let mut fixture = TestFixture::new();
1215 let src_idx = fixture.add_source(
1216 &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
1217 &[],
1218 );
1219
1220 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1221
1222 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1224 let lock = LockFile::empty();
1225 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1226 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1227 let options = SyncOptions {
1228 force: false,
1229 dry_run: false,
1230 frozen: false,
1231 };
1232 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1233 let result =
1234 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1235 let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1236
1237 assert!(fixture.managed_root().join("agents/coder.md").exists());
1238 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1239
1240 fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
1242
1243 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1245 let sync_diff2 =
1246 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1247
1248 let orphan_count = sync_diff2
1250 .items
1251 .iter()
1252 .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1253 .count();
1254 assert_eq!(orphan_count, 1);
1255
1256 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1257 let result2 =
1258 apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
1259
1260 assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
1262 assert!(fixture.managed_root().join("agents/coder.md").exists());
1264
1265 let removed = result2
1267 .outcomes
1268 .iter()
1269 .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1270 assert!(removed);
1271 }
1272
1273 #[test]
1274 fn dry_run_produces_plan_without_changes() {
1275 let mut fixture = TestFixture::new();
1276 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1277
1278 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1279
1280 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1281 let lock = LockFile::empty();
1282 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1283
1284 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1285 let dry_options = SyncOptions {
1286 force: false,
1287 dry_run: true,
1288 frozen: false,
1289 };
1290
1291 let sync_plan = plan::create(&sync_diff, &dry_options, &cache_dir);
1292 assert!(!sync_plan.actions.is_empty());
1293
1294 let result =
1296 apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1297 assert!(!result.outcomes.is_empty());
1298
1299 assert!(!fixture.managed_root().join("agents/coder.md").exists());
1301 }
1302
1303 #[test]
1304 fn lock_written_after_apply() {
1305 let mut fixture = TestFixture::new();
1306 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1307
1308 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1309
1310 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1312 let lock = LockFile::empty();
1313 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1314 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1315 let options = SyncOptions {
1316 force: false,
1317 dry_run: false,
1318 frozen: false,
1319 };
1320 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1321 let result =
1322 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1323
1324 let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1325 crate::lock::write(fixture.project_root(), &new_lock).unwrap();
1326
1327 let reloaded = crate::lock::load(fixture.project_root()).unwrap();
1329 assert_eq!(reloaded.items.len(), 1);
1330 assert!(reloaded.items.contains_key("agents/coder.md"));
1331
1332 let item = &reloaded.items["agents/coder.md"];
1333 assert_eq!(item.kind, ItemKind::Agent);
1334 assert!(!item.source_checksum.is_empty());
1335 assert!(!item.installed_checksum.is_empty());
1336 }
1337
1338 #[test]
1339 fn two_sources_no_collision() {
1340 let mut fixture = TestFixture::new();
1341 let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
1342 let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
1343
1344 let (graph, config) = make_graph_config(
1345 &fixture,
1346 vec![
1347 ("source-a", src_a, FilterMode::All),
1348 ("source-b", src_b, FilterMode::All),
1349 ],
1350 );
1351
1352 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1353 assert!(renames.is_empty());
1354 assert_eq!(target.items.len(), 2);
1355
1356 let lock = LockFile::empty();
1357 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1358 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1359 let options = SyncOptions {
1360 force: false,
1361 dry_run: false,
1362 frozen: false,
1363 };
1364 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1365 let result =
1366 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1367
1368 assert!(fixture.managed_root().join("agents/coder.md").exists());
1369 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1370 assert_eq!(result.outcomes.len(), 2);
1371 }
1372
1373 #[test]
1376 fn pipeline_only_skills_filter() {
1377 let mut fixture = TestFixture::new();
1378 let src_idx = fixture.add_source(
1379 &[("coder.md", "# Coder agent")],
1380 &[("planning", "# Planning skill")],
1381 );
1382
1383 let (graph, config) =
1384 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
1385
1386 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1387 assert_eq!(target.items.len(), 1);
1389 assert!(target.items.contains_key("skills/planning"));
1390 }
1391
1392 #[test]
1393 fn pipeline_only_agents_filter() {
1394 let mut fixture = TestFixture::new();
1395 let agent_content = "---\nskills:\n - planning\n---\n# Coder agent";
1397 let src_idx = fixture.add_source(
1398 &[("coder.md", agent_content)],
1399 &[
1400 ("planning", "# Planning skill"),
1401 ("standalone", "# Standalone skill"),
1402 ],
1403 );
1404
1405 let (graph, config) =
1406 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1407
1408 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1409 assert_eq!(target.items.len(), 2);
1411 assert!(target.items.contains_key("agents/coder.md"));
1412 assert!(target.items.contains_key("skills/planning"));
1413 assert!(!target.items.contains_key("skills/standalone"));
1414 }
1415
1416 #[test]
1417 fn pipeline_only_agents_no_agents_source() {
1418 let mut fixture = TestFixture::new();
1419 let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
1420
1421 let (graph, config) =
1422 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1423
1424 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1425 assert_eq!(target.items.len(), 0);
1427 }
1428}