Skip to main content

mars_agents/sync/
mod.rs

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::cmp::Reverse;
12use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
13use std::path::Path;
14use std::path::PathBuf;
15
16use crate::config::{Config, EffectiveConfig, LocalConfig, Settings};
17use crate::diagnostic::{Diagnostic, DiagnosticCollector};
18use crate::error::MarsError;
19use crate::fs::FileLock;
20use crate::hash;
21use crate::local_source;
22use crate::lock::LockFile;
23use crate::lock::{ItemId, ItemKind};
24use crate::resolve::{ResolveOptions, ResolvedGraph};
25use crate::source::GlobalCache;
26use crate::sync::apply::ApplyResult;
27pub use crate::sync::apply::SyncOptions;
28use crate::sync::target::{ExplicitSkillRename, TargetItem, TargetState};
29use crate::types::{ContentHash, DestPath, MarsContext, SourceId, SourceName, SourceOrigin};
30use crate::validate::ValidationWarning;
31
32// Re-export mutation types for public API compatibility.
33pub use crate::sync::mutation::{ConfigMutation, DependencyUpsertChange, apply_config_mutation};
34
35/// Report from a completed sync operation.
36#[derive(Debug)]
37pub struct SyncReport {
38    pub applied: ApplyResult,
39    pub pruned: Vec<apply::ActionOutcome>,
40    pub diagnostics: Vec<Diagnostic>,
41    pub dependency_changes: Vec<DependencyUpsertChange>,
42    pub upgrades_available: usize,
43    /// Per-target sync outcomes from the target sync phase.
44    pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
45    /// Whether this was a dry run (`--diff`). Affects output wording only.
46    pub dry_run: bool,
47}
48
49impl SyncReport {
50    /// Whether the sync produced any unresolved conflicts.
51    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/// What a CLI command requests from the sync pipeline.
60#[derive(Debug, Clone)]
61pub struct SyncRequest {
62    /// How to resolve versions.
63    pub resolution: ResolutionMode,
64    /// Config mutation to apply under flock.
65    pub mutation: Option<ConfigMutation>,
66    /// Behavior flags.
67    pub options: SyncOptions,
68}
69
70/// Resolution behavior for the resolver stage.
71#[derive(Debug, Clone)]
72pub enum ResolutionMode {
73    /// Normal sync behavior.
74    Normal,
75    /// Upgrade behavior (maximize versions), optionally scoped to specific
76    /// sources and optionally bumping direct constraints.
77    Maximize {
78        targets: HashSet<SourceName>,
79        bump: bool,
80    },
81}
82
83// ---------------------------------------------------------------------------
84// Pipeline phase structs — typed handoffs between pipeline stages.
85// Phase functions consume prior state by value (move semantics, no cloning).
86// ---------------------------------------------------------------------------
87
88/// Phase 1: Load and validate configuration under sync lock.
89pub struct LoadedConfig {
90    pub config: Config,
91    pub local: LocalConfig,
92    pub effective: EffectiveConfig,
93    pub old_lock: LockFile,
94    pub dependency_changes: Vec<DependencyUpsertChange>,
95    pub _sync_lock: FileLock,
96}
97
98/// Phase 2: Resolved dependency graph.
99pub struct ResolvedState {
100    pub loaded: LoadedConfig,
101    pub graph: ResolvedGraph,
102    pub model_aliases: indexmap::IndexMap<String, crate::models::ModelAlias>,
103}
104
105/// Phase 3: Desired target state after discovery + filtering.
106pub struct TargetedState {
107    pub resolved: ResolvedState,
108    pub target: TargetState,
109    pub renames: Vec<ExplicitSkillRename>,
110    pub warnings: Vec<ValidationWarning>,
111}
112
113/// Phase 4: Diff + plan ready for execution.
114pub struct PlannedState {
115    pub targeted: TargetedState,
116    pub plan: plan::SyncPlan,
117}
118
119/// Phase 5: Applied results.
120pub struct AppliedState {
121    pub planned: PlannedState,
122    pub applied: ApplyResult,
123}
124
125/// Phase 6: Target sync results.
126pub struct SyncedState {
127    pub applied: AppliedState,
128    pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
129}
130
131/// Execute the unified sync pipeline.
132///
133/// Orchestrates phase functions, each consuming the prior phase's output struct.
134pub fn execute(ctx: &MarsContext, request: &SyncRequest) -> Result<SyncReport, MarsError> {
135    validate_request(request)?;
136    let mut diag = DiagnosticCollector::new();
137    let loaded = load_config(ctx, request, &mut diag)?;
138    let resolved = resolve_graph(ctx, loaded, request, &mut diag)?;
139    let targeted = build_target(ctx, resolved, request, &mut diag)?;
140    let planned = create_plan(ctx, targeted, request, &mut diag)?;
141    if request.options.frozen {
142        check_frozen_gate(&planned)?;
143    }
144    let applied = apply_plan(ctx, planned, request)?;
145    let synced = sync_targets(ctx, applied, request, &mut diag);
146    let report = finalize(ctx, synced, request, &mut diag)?;
147    Ok(report)
148}
149
150// ---------------------------------------------------------------------------
151// Phase functions
152// ---------------------------------------------------------------------------
153
154/// Phase 1: Acquire sync lock, load config, apply mutations, merge effective config,
155/// and load the existing lock file.
156fn load_config(
157    ctx: &MarsContext,
158    request: &SyncRequest,
159    diag: &mut DiagnosticCollector,
160) -> Result<LoadedConfig, MarsError> {
161    let project_root = &ctx.project_root;
162    let mars_dir = project_root.join(".mars");
163
164    std::fs::create_dir_all(mars_dir.join("cache"))?;
165
166    // Acquire sync lock before any config reads/mutations.
167    let lock_path = mars_dir.join("sync.lock");
168    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
169
170    // Load config under lock (auto-init when mutating and missing).
171    let mut config = match crate::config::load(project_root) {
172        Ok(config) => config,
173        Err(err) if mutation::is_config_not_found(&err) && request.mutation.is_some() => Config {
174            settings: Settings::default(),
175            ..Config::default()
176        },
177        Err(err) => return Err(err),
178    };
179
180    // Apply config mutation.
181    let dependency_changes = if let Some(m) = &request.mutation {
182        mutation::apply_mutation(&mut config, m)?
183    } else {
184        Vec::new()
185    };
186
187    // Load/mutate local overrides under the same lock.
188    let mut local = crate::config::load_local(project_root)?;
189    if let Some(m) = &request.mutation {
190        mutation::apply_local_mutation(&mut local, m);
191    }
192
193    // Build effective config.
194    let (effective, config_diagnostics) =
195        crate::config::merge_with_root(config.clone(), local.clone(), project_root)?;
196    diag.extend(config_diagnostics);
197
198    // Load existing lock file.
199    let old_lock = crate::lock::load(project_root)?;
200
201    Ok(LoadedConfig {
202        config,
203        local,
204        effective,
205        old_lock,
206        dependency_changes,
207        _sync_lock,
208    })
209}
210
211/// Phase 2: Validate upgrade targets, resolve the dependency graph.
212fn resolve_graph(
213    ctx: &MarsContext,
214    mut loaded: LoadedConfig,
215    request: &SyncRequest,
216    diag: &mut DiagnosticCollector,
217) -> Result<ResolvedState, MarsError> {
218    validate_targets(&request.resolution, &loaded.effective)?;
219
220    let cache = GlobalCache::new()?;
221    let source_provider = provider::RealSourceProvider {
222        cache: &cache,
223        project_root: &ctx.project_root,
224    };
225    let resolve_options = to_resolve_options(&request.resolution, request.options.frozen);
226    let graph = crate::resolve::resolve(
227        &loaded.effective,
228        &source_provider,
229        Some(&loaded.old_lock),
230        &resolve_options,
231        diag,
232    )?;
233
234    let bump_entries = planned_bump_entries(&loaded.config, &graph, &request.resolution);
235    if !bump_entries.is_empty() {
236        let bump_changes = mutation::apply_mutation(
237            &mut loaded.config,
238            &ConfigMutation::BatchUpsert(bump_entries),
239        )?;
240        loaded.dependency_changes.extend(bump_changes);
241    }
242
243    // Merge model config from dependency tree
244    let dep_models = declaration_ordered_dep_models(&graph, &loaded.effective);
245    let model_aliases =
246        crate::models::merge_model_config(&loaded.config.models, &dep_models, diag, None);
247
248    Ok(ResolvedState {
249        loaded,
250        graph,
251        model_aliases,
252    })
253}
254
255/// Phase 3: Build target state, handle collisions, rewrite frontmatter refs, validate.
256fn build_target(
257    ctx: &MarsContext,
258    resolved: ResolvedState,
259    _request: &SyncRequest,
260    diag: &mut DiagnosticCollector,
261) -> Result<TargetedState, MarsError> {
262    // Use .mars/ as the canonical content root for diff/collision checks.
263    let mars_dir = ctx.project_root.join(".mars");
264    let managed_root = &mars_dir;
265
266    // Build target state from resolved graph.
267    let (mut target_state, renames) =
268        target::build_with_collisions(&resolved.graph, &resolved.loaded.effective)?;
269
270    let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
271    let local_source_id = SourceId::Path {
272        canonical: dunce::canonicalize(&ctx.project_root)
273            .unwrap_or_else(|_| ctx.project_root.clone()),
274        subpath: None,
275    };
276
277    let local_items = local_source::discover_local_items(
278        &ctx.project_root,
279        resolved.loaded.config.package.is_some(),
280        Some(local_source_name.as_str()),
281        diag,
282    )?;
283    for item in local_items {
284        let source_path = item.disk_path();
285        let is_flat_skill = item.discovered.id.kind == ItemKind::Skill
286            && item.discovered.source_path == Path::new(".");
287        let source_hash = if is_flat_skill {
288            ContentHash::from(hash::compute_skill_hash_filtered(
289                &source_path,
290                crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
291            )?)
292        } else {
293            ContentHash::from(hash::compute_hash(&source_path, item.discovered.id.kind)?)
294        };
295        let dest_path =
296            default_dest_path(item.discovered.id.kind, item.discovered.id.name.as_str());
297
298        if let Some(existing) = target_state.items.shift_remove(&dest_path)
299            && existing.source_hash != source_hash
300        {
301            diag.warn(
302                "local-shadow",
303                format!(
304                    "local {} `{}` shadows dependency `{}` {} `{}`",
305                    item.discovered.id.kind,
306                    item.discovered.id.name,
307                    existing.source_name,
308                    existing.id.kind,
309                    existing.id.name
310                ),
311            );
312        }
313
314        let disk_path = dest_path.resolve(managed_root);
315        if !resolved.loaded.old_lock.items.contains_key(&dest_path)
316            && disk_path.symlink_metadata().is_ok()
317        {
318            diag.warn(
319                "unmanaged-collision",
320                format!(
321                    "local {} `{}` collides with unmanaged path `{}` — leaving existing content untouched",
322                    item.discovered.id.kind, item.discovered.id.name, dest_path
323                ),
324            );
325            continue;
326        }
327
328        target_state.items.insert(
329            dest_path.clone(),
330            TargetItem {
331                id: ItemId {
332                    kind: item.discovered.id.kind,
333                    name: item.discovered.id.name.clone(),
334                },
335                source_name: local_source_name.clone(),
336                origin: SourceOrigin::LocalPackage,
337                source_id: local_source_id.clone(),
338                source_path,
339                dest_path,
340                source_hash,
341                is_flat_skill,
342                rewritten_content: None,
343            },
344        );
345    }
346
347    // Handle collisions + rewrite frontmatter refs.
348    if !renames.is_empty() {
349        let rewrite_warnings =
350            target::rewrite_skill_refs(&mut target_state, &renames, &resolved.graph)?;
351        for w in &rewrite_warnings {
352            diag.warn("rewrite-warning", w.to_string());
353        }
354    }
355
356    // Validate skill references.
357    let warnings = validate_skill_refs(managed_root, &target_state);
358
359    // Prevent managed installs from overwriting unmanaged files.
360    let unmanaged_collisions =
361        target::check_unmanaged_collisions(managed_root, &resolved.loaded.old_lock, &target_state);
362    for collision in &unmanaged_collisions {
363        diag.warn(
364            "unmanaged-collision",
365            format!(
366                "source `{}` collides with unmanaged path `{}` — leaving existing content untouched",
367                collision.source_name, collision.path
368            ),
369        );
370        target_state.items.shift_remove(&collision.path);
371    }
372
373    Ok(TargetedState {
374        resolved,
375        target: target_state,
376        renames,
377        warnings,
378    })
379}
380
381/// Phase 4: Compute diff, create plan.
382fn create_plan(
383    ctx: &MarsContext,
384    targeted: TargetedState,
385    request: &SyncRequest,
386    diag: &mut DiagnosticCollector,
387) -> Result<PlannedState, MarsError> {
388    // Diff against .mars/ canonical store.
389    let mars_dir = ctx.project_root.join(".mars");
390    let managed_root = &mars_dir;
391    let cache_bases_dir = mars_dir.join("cache").join("bases");
392
393    // Compute diff.
394    let sync_diff = diff::compute(
395        managed_root,
396        &targeted.resolved.loaded.old_lock,
397        &targeted.target,
398        request.options.force,
399    )?;
400
401    if !request.options.force {
402        for entry in &sync_diff.items {
403            if let diff::DiffEntry::LocalModified { target, .. } = entry {
404                diag.warn(
405                    "disk-lock-divergent",
406                    format!(
407                        "{} diverged from mars.lock checksum; preserving local content (run `mars sync --force` or `mars repair` to reset)",
408                        target.dest_path
409                    ),
410                );
411            }
412        }
413    }
414
415    // Create plan.
416    let sync_plan = plan::create(&sync_diff, &request.options, &cache_bases_dir, diag);
417
418    Ok(PlannedState {
419        targeted,
420        plan: sync_plan,
421    })
422}
423
424/// Check that a frozen sync has no pending changes.
425fn check_frozen_gate(planned: &PlannedState) -> Result<(), MarsError> {
426    let has_changes = planned.plan.actions.iter().any(|a| {
427        !matches!(
428            a,
429            plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
430        )
431    });
432    if has_changes {
433        return Err(MarsError::FrozenViolation {
434            message: "lock file would change but --frozen is set".into(),
435        });
436    }
437    Ok(())
438}
439
440/// Phase 5: Persist config if mutated, apply plan to .mars/ canonical store.
441fn apply_plan(
442    ctx: &MarsContext,
443    planned: PlannedState,
444    request: &SyncRequest,
445) -> Result<AppliedState, MarsError> {
446    let project_root = &ctx.project_root;
447    let mars_dir = project_root.join(".mars");
448    let cache_bases_dir = mars_dir.join("cache").join("bases");
449
450    let has_bump_version_changes =
451        has_version_changes(&planned.targeted.resolved.loaded.dependency_changes)
452            && matches!(
453                request.resolution,
454                ResolutionMode::Maximize { bump: true, .. }
455            );
456    let has_mutation = request.mutation.is_some() || has_bump_version_changes;
457
458    // Persist config/local only after validation gate and before apply.
459    if has_mutation && !request.options.dry_run {
460        match &request.mutation {
461            Some(ConfigMutation::SetOverride { .. } | ConfigMutation::ClearOverride { .. }) => {
462                crate::config::save_local(project_root, &planned.targeted.resolved.loaded.local)?;
463            }
464            Some(
465                ConfigMutation::UpsertDependency { .. }
466                | ConfigMutation::BatchUpsert(..)
467                | ConfigMutation::RemoveDependency { .. }
468                | ConfigMutation::SetRename { .. },
469            ) => {
470                crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
471            }
472            None => {
473                if has_bump_version_changes {
474                    crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
475                }
476            }
477        }
478    }
479
480    // Apply plan to .mars/ canonical store (D25).
481    // Content is written to .mars/agents/ and .mars/skills/, then
482    // sync_targets() copies to all managed target directories.
483    let applied = apply::execute(&mars_dir, &planned.plan, &request.options, &cache_bases_dir)?;
484
485    Ok(AppliedState { planned, applied })
486}
487
488/// Phase 6: Sync managed targets from .mars/ canonical store.
489///
490/// Copies content from .mars/ to all configured target directories.
491/// Non-fatal — target sync errors are recorded as diagnostics.
492/// Lock is written regardless of target sync outcome (D21).
493fn sync_targets(
494    ctx: &MarsContext,
495    applied: AppliedState,
496    request: &SyncRequest,
497    diag: &mut DiagnosticCollector,
498) -> SyncedState {
499    if request.options.dry_run {
500        return SyncedState {
501            applied,
502            target_outcomes: Vec::new(),
503        };
504    }
505
506    let mars_dir = ctx.project_root.join(".mars");
507    let targets = applied
508        .planned
509        .targeted
510        .resolved
511        .loaded
512        .effective
513        .settings
514        .managed_targets();
515    let previous_managed_paths = applied
516        .planned
517        .targeted
518        .resolved
519        .loaded
520        .old_lock
521        .items
522        .keys()
523        .map(|dest_path| dest_path.to_string())
524        .collect::<HashSet<String>>();
525
526    let target_outcomes = crate::target_sync::sync_managed_targets(
527        &ctx.project_root,
528        &mars_dir,
529        &targets,
530        &applied.applied.outcomes,
531        &previous_managed_paths,
532        request.options.force,
533        diag,
534    );
535
536    SyncedState {
537        applied,
538        target_outcomes,
539    }
540}
541
542/// Phase 7: Write lock file, construct SyncReport.
543///
544/// Lock is written regardless of target sync outcome (D21).
545fn finalize(
546    ctx: &MarsContext,
547    state: SyncedState,
548    request: &SyncRequest,
549    diag: &mut DiagnosticCollector,
550) -> Result<SyncReport, MarsError> {
551    let project_root = &ctx.project_root;
552    let old_lock = &state.applied.planned.targeted.resolved.loaded.old_lock;
553    let graph = &state.applied.planned.targeted.resolved.graph;
554
555    // Write lock file (D21 — regardless of target sync outcome).
556    if !request.options.dry_run {
557        let new_lock = crate::lock::build(graph, &state.applied.applied, old_lock)?;
558        crate::lock::write(project_root, &new_lock)?;
559
560        // Persist dependency-only model aliases so `mars models list` can load
561        // deps from cache, then overlay current consumer config without keeping
562        // stale consumer aliases from prior syncs.
563        let dep_models = declaration_ordered_dep_models(
564            graph,
565            &state.applied.planned.targeted.resolved.loaded.effective,
566        );
567        let empty_consumer: indexmap::IndexMap<String, crate::models::ModelAlias> =
568            indexmap::IndexMap::new();
569        let mut ignored_diag = DiagnosticCollector::new();
570        let dep_model_aliases = crate::models::merge_model_config(
571            &empty_consumer,
572            &dep_models,
573            &mut ignored_diag,
574            None,
575        );
576
577        // Best-effort models cache refresh: ensure the catalog covers any
578        // new aliases we're about to persist. Sync never aborts on refresh
579        // failure — warn and continue. Skip on dry_run (side-effect-free).
580        if !request.options.dry_run {
581            let mars_path = ctx.project_root.join(".mars");
582            let ttl = crate::models::load_models_cache_ttl(ctx);
583            let mode = crate::models::resolve_refresh_mode(request.options.no_refresh_models);
584            match crate::models::ensure_fresh(&mars_path, ttl, mode) {
585                Ok((_, crate::models::RefreshOutcome::StaleFallback { reason })) => {
586                    diag.warn(
587                        "models-cache-refresh",
588                        format!("using stale models cache: {reason}"),
589                    );
590                }
591                Ok((_, crate::models::RefreshOutcome::Offline)) => {}
592                Ok(_) => {}
593                Err(err) => {
594                    diag.warn(
595                        "models-cache-refresh",
596                        format!("failed to refresh models cache: {err}"),
597                    );
598                }
599            }
600        }
601
602        match serde_json::to_string_pretty(&dep_model_aliases) {
603            Ok(json) => {
604                let merged_path = ctx.project_root.join(".mars").join("models-merged.json");
605                if let Err(err) = crate::fs::atomic_write(&merged_path, json.as_bytes()) {
606                    diag.warn(
607                        "models-merge-write",
608                        format!("failed to write models-merged.json: {err}"),
609                    );
610                }
611            }
612            Err(err) => {
613                diag.warn(
614                    "models-merge-write",
615                    format!("failed to serialize merged model aliases: {err}"),
616                );
617            }
618        }
619    }
620
621    for w in &state.applied.planned.targeted.warnings {
622        match w {
623            ValidationWarning::MissingSkill {
624                agent,
625                skill_name,
626                suggestion,
627            } => {
628                let msg = match suggestion {
629                    Some(s) => format!(
630                        "agent `{}` references missing skill `{}` (did you mean `{}`?)",
631                        agent.name, skill_name, s
632                    ),
633                    None => {
634                        format!(
635                            "agent `{}` references missing skill `{}`",
636                            agent.name, skill_name
637                        )
638                    }
639                };
640                diag.warn("missing-skill", msg);
641            }
642        }
643    }
644    let dependency_changes = state
645        .applied
646        .planned
647        .targeted
648        .resolved
649        .loaded
650        .dependency_changes;
651    let upgrades_available = if request.options.frozen {
652        0
653    } else {
654        graph
655            .nodes
656            .values()
657            .filter(|node| {
658                matches!(
659                    (&node.resolved_ref.version, &node.latest_version),
660                    (Some(resolved), Some(latest)) if latest > resolved
661                )
662            })
663            .count()
664    };
665
666    Ok(SyncReport {
667        applied: state.applied.applied,
668        pruned: Vec::new(),
669        diagnostics: diag.drain(),
670        dependency_changes,
671        upgrades_available,
672        target_outcomes: state.target_outcomes,
673        dry_run: request.options.dry_run,
674    })
675}
676
677fn declaration_ordered_dep_models(
678    graph: &ResolvedGraph,
679    config: &EffectiveConfig,
680) -> Vec<crate::models::ResolvedDepModels> {
681    // Declaration positions for direct deps in consumer mars.toml.
682    let mut decl_pos: HashMap<SourceName, usize> = HashMap::new();
683    for (idx, name) in config.dependencies.keys().enumerate() {
684        decl_pos.insert(name.clone(), idx);
685    }
686
687    // Propagate declaration position to transitives: a transitive dependency
688    // takes the minimum position among all direct dependencies that reach it.
689    for (idx, sponsor) in config.dependencies.keys().enumerate() {
690        let Some(sponsor_node) = graph.nodes.get(sponsor) else {
691            continue;
692        };
693
694        let mut queue: VecDeque<SourceName> = sponsor_node.deps.iter().cloned().collect();
695        let mut visited: HashSet<SourceName> = HashSet::new();
696
697        while let Some(dep) = queue.pop_front() {
698            if !visited.insert(dep.clone()) {
699                continue;
700            }
701
702            decl_pos
703                .entry(dep.clone())
704                .and_modify(|pos| *pos = (*pos).min(idx))
705                .or_insert(idx);
706
707            if let Some(dep_node) = graph.nodes.get(&dep) {
708                queue.extend(dep_node.deps.iter().cloned());
709            }
710        }
711    }
712
713    // Build Kahn structures using dependency edges:
714    // dep -> dependent (name depends on dep).
715    let mut in_degree: HashMap<SourceName, usize> = HashMap::new();
716    let mut adjacency: HashMap<SourceName, Vec<SourceName>> = HashMap::new();
717
718    for name in graph.nodes.keys() {
719        in_degree.entry(name.clone()).or_insert(0);
720        adjacency.entry(name.clone()).or_default();
721    }
722
723    for (name, node) in &graph.nodes {
724        for dep in &node.deps {
725            if graph.nodes.contains_key(dep) {
726                *in_degree.entry(name.clone()).or_insert(0) += 1;
727                adjacency.entry(dep.clone()).or_default().push(name.clone());
728            }
729        }
730    }
731
732    let mut ready: BinaryHeap<Reverse<(usize, SourceName)>> = BinaryHeap::new();
733    for (name, degree) in &in_degree {
734        if *degree == 0 {
735            let position = decl_pos.get(name).copied().unwrap_or(usize::MAX);
736            ready.push(Reverse((position, name.clone())));
737        }
738    }
739
740    let mut ordered: Vec<SourceName> = Vec::with_capacity(graph.nodes.len());
741    while let Some(Reverse((_, current))) = ready.pop() {
742        ordered.push(current.clone());
743
744        if let Some(dependents) = adjacency.get(&current) {
745            for dependent in dependents {
746                if let Some(degree) = in_degree.get_mut(dependent) {
747                    *degree -= 1;
748                    if *degree == 0 {
749                        let position = decl_pos.get(dependent).copied().unwrap_or(usize::MAX);
750                        ready.push(Reverse((position, dependent.clone())));
751                    }
752                }
753            }
754        }
755    }
756
757    // Graph should already be acyclic from resolver; this keeps behavior
758    // deterministic if that invariant is ever violated.
759    let ordered_names: Vec<SourceName> = if ordered.len() == graph.nodes.len() {
760        ordered
761    } else {
762        graph.order.clone()
763    };
764
765    ordered_names
766        .iter()
767        .filter_map(|name| {
768            let node = graph.nodes.get(name)?;
769            let manifest = node.manifest.as_ref()?;
770            if manifest.models.is_empty() {
771                return None;
772            }
773            Some(crate::models::ResolvedDepModels {
774                source_name: name.to_string(),
775                models: manifest.models.clone(),
776            })
777        })
778        .collect()
779}
780
781fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
782    match kind {
783        ItemKind::Agent => DestPath::from(format!("agents/{name}.md")),
784        ItemKind::Skill => DestPath::from(format!("skills/{name}")),
785    }
786}
787
788fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
789    if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
790        return Err(MarsError::InvalidRequest {
791            message:
792                "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
793                    .to_string(),
794        });
795    }
796
797    if request.options.frozen && request.mutation.is_some() {
798        return Err(MarsError::InvalidRequest {
799            message:
800                "cannot modify config in --frozen mode (config change would require lock update)"
801                    .to_string(),
802        });
803    }
804
805    Ok(())
806}
807
808fn validate_targets(
809    resolution: &ResolutionMode,
810    effective: &EffectiveConfig,
811) -> Result<(), MarsError> {
812    if let ResolutionMode::Maximize { targets, .. } = resolution {
813        for name in targets {
814            if !effective.dependencies.contains_key(name) {
815                return Err(MarsError::Source {
816                    source_name: name.to_string(),
817                    message: format!("dependency `{name}` not found in mars.toml"),
818                });
819            }
820        }
821    }
822
823    Ok(())
824}
825
826fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
827    match mode {
828        ResolutionMode::Normal => ResolveOptions {
829            frozen,
830            ..ResolveOptions::default()
831        },
832        ResolutionMode::Maximize { targets, bump } => ResolveOptions {
833            maximize: true,
834            upgrade_targets: targets.clone(),
835            bump_direct_constraints: *bump,
836            frozen,
837        },
838    }
839}
840
841fn planned_bump_entries(
842    config: &Config,
843    graph: &ResolvedGraph,
844    mode: &ResolutionMode,
845) -> Vec<(SourceName, crate::config::DependencyEntry)> {
846    let ResolutionMode::Maximize {
847        targets,
848        bump: true,
849    } = mode
850    else {
851        return Vec::new();
852    };
853
854    config
855        .dependencies
856        .iter()
857        .filter_map(|(name, entry)| {
858            if !targets.is_empty() && !targets.contains(name) {
859                return None;
860            }
861            // Only git dependencies with semver-tagged resolution can be bumped.
862            entry.url.as_ref()?;
863            let node = graph.nodes.get(name)?;
864            let resolved_version = node.resolved_ref.version.as_ref()?;
865            let resolved_tag = node.resolved_ref.version_tag.as_ref()?;
866            if !constraint_needs_bump(entry.version.as_deref(), resolved_version) {
867                return None;
868            }
869            if entry.version.as_deref() == Some(resolved_tag.as_str()) {
870                return None;
871            }
872            let mut bumped = entry.clone();
873            bumped.version = Some(resolved_tag.clone());
874            Some((name.clone(), bumped))
875        })
876        .collect()
877}
878
879fn constraint_needs_bump(current: Option<&str>, resolved: &semver::Version) -> bool {
880    match crate::resolve::parse_version_constraint(current) {
881        crate::resolve::VersionConstraint::Semver(req) => !req.matches(resolved),
882        crate::resolve::VersionConstraint::Latest
883        | crate::resolve::VersionConstraint::RefPin(_) => false,
884    }
885}
886
887fn has_version_changes(changes: &[DependencyUpsertChange]) -> bool {
888    changes
889        .iter()
890        .any(|change| change.old_version != change.new_version)
891}
892
893/// Validate skill references: check that agents' `skills:` frontmatter entries
894/// reference skills that exist in the target state.
895fn validate_skill_refs(
896    install_target: &std::path::Path,
897    target: &target::TargetState,
898) -> Vec<ValidationWarning> {
899    use crate::lock::ItemKind;
900
901    // Collect available skill names
902    let available_skills: HashSet<String> = target
903        .items
904        .values()
905        .filter(|item| item.id.kind == ItemKind::Skill)
906        .map(|item| item.id.name.to_string())
907        .collect();
908
909    // Collect agents with their paths
910    let agents: Vec<(String, PathBuf)> = target
911        .items
912        .values()
913        .filter(|item| item.id.kind == ItemKind::Agent)
914        .map(|item| {
915            let disk_path = item.dest_path.resolve(install_target);
916            // If the file exists on disk, use that (may have local edits).
917            // Otherwise, use the source path.
918            let path = if disk_path.exists() {
919                disk_path
920            } else {
921                item.source_path.clone()
922            };
923            (item.id.name.to_string(), path)
924        })
925        .collect();
926
927    crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use crate::config::*;
934    use crate::lock::{ItemKind, LockFile};
935    use crate::resolve::{ResolvedGraph, ResolvedNode};
936    use indexmap::IndexMap;
937    use std::fs;
938    use tempfile::TempDir;
939
940    /// Helper to set up a complete sync context with temp dirs.
941    struct TestFixture {
942        project_root: TempDir,
943        managed_root: PathBuf,
944        source_trees: Vec<TempDir>,
945    }
946
947    impl TestFixture {
948        fn new() -> Self {
949            let project_root = TempDir::new().unwrap();
950            let managed_root = project_root.path().join(".agents");
951            // Create .mars/cache directories
952            fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
953            TestFixture {
954                project_root,
955                managed_root,
956                source_trees: Vec::new(),
957            }
958        }
959
960        fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
961            let dir = TempDir::new().unwrap();
962            if !agents.is_empty() {
963                let agents_dir = dir.path().join("agents");
964                fs::create_dir_all(&agents_dir).unwrap();
965                for (name, content) in agents {
966                    fs::write(agents_dir.join(name), content).unwrap();
967                }
968            }
969            if !skills.is_empty() {
970                let skills_dir = dir.path().join("skills");
971                fs::create_dir_all(&skills_dir).unwrap();
972                for (name, content) in skills {
973                    let skill_dir = skills_dir.join(name);
974                    fs::create_dir_all(&skill_dir).unwrap();
975                    fs::write(skill_dir.join("SKILL.md"), content).unwrap();
976                }
977            }
978            self.source_trees.push(dir);
979            self.source_trees.len() - 1
980        }
981
982        fn project_root(&self) -> &std::path::Path {
983            self.project_root.path()
984        }
985
986        fn managed_root(&self) -> &std::path::Path {
987            &self.managed_root
988        }
989
990        fn tree_path(&self, idx: usize) -> PathBuf {
991            self.source_trees[idx].path().to_path_buf()
992        }
993    }
994
995    fn make_graph_config(
996        fixture: &TestFixture,
997        sources: Vec<(&str, usize, FilterMode)>,
998    ) -> (ResolvedGraph, EffectiveConfig) {
999        let mut nodes = IndexMap::new();
1000        let mut order = Vec::new();
1001        let mut config_dependencies = IndexMap::new();
1002
1003        for (name, tree_idx, filter) in sources {
1004            let tree_path = fixture.tree_path(tree_idx);
1005            nodes.insert(
1006                name.into(),
1007                ResolvedNode {
1008                    source_name: name.into(),
1009                    source_id: crate::types::SourceId::Path {
1010                        canonical: tree_path.clone(),
1011                        subpath: None,
1012                    },
1013                    rooted_ref: crate::resolve::RootedSourceRef {
1014                        checkout_root: tree_path.clone(),
1015                        package_root: tree_path.clone(),
1016                    },
1017                    resolved_ref: crate::source::ResolvedRef {
1018                        source_name: name.into(),
1019                        version: None,
1020                        version_tag: None,
1021                        commit: None,
1022                        tree_path: tree_path.clone(),
1023                    },
1024                    latest_version: None,
1025                    manifest: None,
1026                    deps: vec![],
1027                },
1028            );
1029            order.push(name.into());
1030
1031            config_dependencies.insert(
1032                name.into(),
1033                EffectiveDependency {
1034                    name: name.into(),
1035                    id: crate::types::SourceId::Path {
1036                        canonical: tree_path.clone(),
1037                        subpath: None,
1038                    },
1039                    spec: SourceSpec::Path(tree_path),
1040                    subpath: None,
1041                    filter,
1042                    rename: crate::types::RenameMap::new(),
1043                    is_overridden: false,
1044                    original_git: None,
1045                },
1046            );
1047        }
1048
1049        (
1050            ResolvedGraph {
1051                nodes,
1052                order,
1053                filters: std::collections::HashMap::new(),
1054            },
1055            EffectiveConfig {
1056                dependencies: config_dependencies,
1057                settings: Settings::default(),
1058            },
1059        )
1060    }
1061
1062    fn path_dependency_entry(path: &std::path::Path) -> DependencyEntry {
1063        DependencyEntry {
1064            url: None,
1065            path: Some(path.to_path_buf()),
1066            subpath: None,
1067            version: None,
1068            filter: FilterConfig::default(),
1069        }
1070    }
1071
1072    fn git_dependency_entry(url: &str, version: &str, filter: FilterConfig) -> DependencyEntry {
1073        DependencyEntry {
1074            url: Some(url.into()),
1075            path: None,
1076            subpath: None,
1077            version: Some(version.to_string()),
1078            filter,
1079        }
1080    }
1081
1082    fn create_sync_plan(
1083        sync_diff: &diff::SyncDiff,
1084        options: &SyncOptions,
1085        cache_bases_dir: &std::path::Path,
1086    ) -> plan::SyncPlan {
1087        let mut diag = DiagnosticCollector::new();
1088        plan::create(sync_diff, options, cache_bases_dir, &mut diag)
1089    }
1090
1091    fn graph_with_versions(entries: &[(&str, &str, &str)]) -> ResolvedGraph {
1092        let mut nodes = IndexMap::new();
1093        let mut order = Vec::new();
1094        for (name, url, tag) in entries {
1095            let version = semver::Version::parse(tag.trim_start_matches('v')).unwrap();
1096            nodes.insert(
1097                (*name).into(),
1098                ResolvedNode {
1099                    source_name: (*name).into(),
1100                    source_id: crate::types::SourceId::git(crate::types::SourceUrl::from(*url)),
1101                    rooted_ref: crate::resolve::RootedSourceRef {
1102                        checkout_root: PathBuf::from(format!("/tmp/{name}")),
1103                        package_root: PathBuf::from(format!("/tmp/{name}")),
1104                    },
1105                    resolved_ref: crate::source::ResolvedRef {
1106                        source_name: (*name).into(),
1107                        version: Some(version),
1108                        version_tag: Some((*tag).to_string()),
1109                        commit: Some("abc123".into()),
1110                        tree_path: PathBuf::from(format!("/tmp/{name}")),
1111                    },
1112                    latest_version: None,
1113                    manifest: None,
1114                    deps: vec![],
1115                },
1116            );
1117            order.push((*name).into());
1118        }
1119
1120        ResolvedGraph {
1121            nodes,
1122            order,
1123            filters: std::collections::HashMap::new(),
1124        }
1125    }
1126
1127    fn model_alias(model: &str) -> crate::models::ModelAlias {
1128        crate::models::ModelAlias {
1129            harness: None,
1130            description: None,
1131            default_effort: None,
1132            autocompact: None,
1133            spec: crate::models::ModelSpec::Pinned {
1134                model: model.to_string(),
1135                provider: None,
1136            },
1137        }
1138    }
1139
1140    fn manifest_with_models(name: &str) -> Manifest {
1141        let mut models = IndexMap::new();
1142        models.insert(
1143            format!("{name}-alias"),
1144            model_alias(&format!("{name}-model")),
1145        );
1146        Manifest {
1147            package: PackageInfo {
1148                name: name.to_string(),
1149                version: "1.0.0".to_string(),
1150                description: None,
1151            },
1152            dependencies: IndexMap::new(),
1153            models,
1154        }
1155    }
1156
1157    fn resolved_node(name: &str, deps: &[&str], with_models: bool) -> ResolvedNode {
1158        let canonical = PathBuf::from(format!("/tmp/{name}"));
1159        ResolvedNode {
1160            source_name: name.into(),
1161            source_id: crate::types::SourceId::Path {
1162                canonical: canonical.clone(),
1163                subpath: None,
1164            },
1165            rooted_ref: crate::resolve::RootedSourceRef {
1166                checkout_root: canonical.clone(),
1167                package_root: canonical.clone(),
1168            },
1169            resolved_ref: crate::source::ResolvedRef {
1170                source_name: name.into(),
1171                version: None,
1172                version_tag: None,
1173                commit: None,
1174                tree_path: canonical,
1175            },
1176            latest_version: None,
1177            manifest: with_models.then(|| manifest_with_models(name)),
1178            deps: deps.iter().map(|dep| (*dep).into()).collect(),
1179        }
1180    }
1181
1182    fn effective_config_with_decl_order(names: &[&str]) -> EffectiveConfig {
1183        let mut dependencies = IndexMap::new();
1184        for name in names {
1185            let canonical = PathBuf::from(format!("/tmp/dep-{name}"));
1186            dependencies.insert(
1187                (*name).into(),
1188                EffectiveDependency {
1189                    name: (*name).into(),
1190                    id: crate::types::SourceId::Path {
1191                        canonical: canonical.clone(),
1192                        subpath: None,
1193                    },
1194                    spec: SourceSpec::Path(canonical),
1195                    subpath: None,
1196                    filter: FilterMode::All,
1197                    rename: crate::types::RenameMap::new(),
1198                    is_overridden: false,
1199                    original_git: None,
1200                },
1201            );
1202        }
1203        EffectiveConfig {
1204            dependencies,
1205            settings: Settings::default(),
1206        }
1207    }
1208
1209    fn dep_model_names(models: &[crate::models::ResolvedDepModels]) -> Vec<String> {
1210        models.iter().map(|m| m.source_name.clone()).collect()
1211    }
1212
1213    #[test]
1214    fn declaration_ordered_dep_models_sibling_order() {
1215        let mut nodes = IndexMap::new();
1216        nodes.insert("a".into(), resolved_node("a", &[], true));
1217        nodes.insert("b".into(), resolved_node("b", &[], true));
1218
1219        let graph = ResolvedGraph {
1220            nodes,
1221            order: vec!["a".into(), "b".into()],
1222            filters: std::collections::HashMap::new(),
1223        };
1224        let config = effective_config_with_decl_order(&["a", "b"]);
1225
1226        let dep_models = declaration_ordered_dep_models(&graph, &config);
1227        assert_eq!(dep_model_names(&dep_models), vec!["a", "b"]);
1228    }
1229
1230    #[test]
1231    fn declaration_ordered_dep_models_diamond_uses_minimum_sponsor_position() {
1232        let mut nodes = IndexMap::new();
1233        nodes.insert("a".into(), resolved_node("a", &["d"], true));
1234        nodes.insert("b".into(), resolved_node("b", &["d"], true));
1235        nodes.insert("d".into(), resolved_node("d", &[], true));
1236
1237        let graph = ResolvedGraph {
1238            nodes,
1239            order: vec!["d".into(), "a".into(), "b".into()],
1240            filters: std::collections::HashMap::new(),
1241        };
1242        let config = effective_config_with_decl_order(&["a", "b"]);
1243
1244        let dep_models = declaration_ordered_dep_models(&graph, &config);
1245        assert_eq!(dep_model_names(&dep_models), vec!["d", "a", "b"]);
1246    }
1247
1248    #[test]
1249    fn declaration_ordered_dep_models_transitives_follow_sponsor_declaration_order() {
1250        let mut nodes = IndexMap::new();
1251        nodes.insert("a".into(), resolved_node("a", &["d"], false));
1252        nodes.insert("b".into(), resolved_node("b", &["e"], false));
1253        nodes.insert("d".into(), resolved_node("d", &[], true));
1254        nodes.insert("e".into(), resolved_node("e", &[], true));
1255
1256        let graph = ResolvedGraph {
1257            nodes,
1258            order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1259            filters: std::collections::HashMap::new(),
1260        };
1261        let config = effective_config_with_decl_order(&["a", "b"]);
1262
1263        let dep_models = declaration_ordered_dep_models(&graph, &config);
1264        assert_eq!(dep_model_names(&dep_models), vec!["d", "e"]);
1265    }
1266
1267    #[test]
1268    fn declaration_ordered_dep_models_keeps_deps_before_dependents() {
1269        let mut nodes = IndexMap::new();
1270        nodes.insert("a".into(), resolved_node("a", &["d"], true));
1271        nodes.insert("d".into(), resolved_node("d", &[], true));
1272
1273        let graph = ResolvedGraph {
1274            nodes,
1275            order: vec!["d".into(), "a".into()],
1276            filters: std::collections::HashMap::new(),
1277        };
1278        // D is declared after A, but topological ordering must still emit D first.
1279        let config = effective_config_with_decl_order(&["a", "d"]);
1280
1281        let dep_models = declaration_ordered_dep_models(&graph, &config);
1282        assert_eq!(dep_model_names(&dep_models), vec!["d", "a"]);
1283    }
1284
1285    #[test]
1286    fn declaration_ordered_dep_models_is_deterministic() {
1287        let mut nodes = IndexMap::new();
1288        nodes.insert("a".into(), resolved_node("a", &["d"], true));
1289        nodes.insert("b".into(), resolved_node("b", &["e"], true));
1290        nodes.insert("d".into(), resolved_node("d", &[], true));
1291        nodes.insert("e".into(), resolved_node("e", &[], true));
1292
1293        let graph = ResolvedGraph {
1294            nodes,
1295            order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1296            filters: std::collections::HashMap::new(),
1297        };
1298        let config = effective_config_with_decl_order(&["a", "b"]);
1299
1300        let first = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1301        for _ in 0..10 {
1302            let current = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1303            assert_eq!(current, first);
1304        }
1305    }
1306
1307    #[test]
1308    fn declaration_ordered_dep_models_is_used_by_resolve_graph_and_finalize() {
1309        let source = include_str!("mod.rs");
1310        assert!(source.contains("declaration_ordered_dep_models(&graph, &loaded.effective)"));
1311        assert!(source.contains("&state.applied.planned.targeted.resolved.loaded.effective"));
1312    }
1313
1314    #[test]
1315    fn validate_request_rejects_frozen_with_maximize() {
1316        let request = SyncRequest {
1317            resolution: ResolutionMode::Maximize {
1318                targets: HashSet::new(),
1319                bump: false,
1320            },
1321            mutation: None,
1322            options: SyncOptions {
1323                force: false,
1324                dry_run: false,
1325                frozen: true,
1326                no_refresh_models: false,
1327            },
1328        };
1329
1330        let err = validate_request(&request).unwrap_err();
1331        assert!(matches!(err, MarsError::InvalidRequest { .. }));
1332        assert!(err.to_string().contains("--frozen"));
1333    }
1334
1335    #[test]
1336    fn validate_request_rejects_frozen_with_mutation() {
1337        let request = SyncRequest {
1338            resolution: ResolutionMode::Normal,
1339            mutation: Some(ConfigMutation::RemoveDependency {
1340                name: "base".into(),
1341            }),
1342            options: SyncOptions {
1343                force: false,
1344                dry_run: false,
1345                frozen: true,
1346                no_refresh_models: false,
1347            },
1348        };
1349
1350        let err = validate_request(&request).unwrap_err();
1351        assert!(matches!(err, MarsError::InvalidRequest { .. }));
1352        assert!(err.to_string().contains("cannot modify config"));
1353    }
1354
1355    #[test]
1356    fn planned_bump_entries_bump_all_outdated_pins() {
1357        let mut config = Config::default();
1358        config.dependencies.insert(
1359            "base".into(),
1360            git_dependency_entry(
1361                "https://example.com/base.git",
1362                "v1.0.0",
1363                FilterConfig::default(),
1364            ),
1365        );
1366        config.dependencies.insert(
1367            "tools".into(),
1368            git_dependency_entry(
1369                "https://example.com/tools.git",
1370                "v2.0.0",
1371                FilterConfig::default(),
1372            ),
1373        );
1374        config.dependencies.insert(
1375            "floating".into(),
1376            DependencyEntry {
1377                url: Some("https://example.com/floating.git".into()),
1378                path: None,
1379                subpath: None,
1380                version: None,
1381                filter: FilterConfig::default(),
1382            },
1383        );
1384
1385        let graph = graph_with_versions(&[
1386            ("base", "https://example.com/base.git", "v1.2.0"),
1387            ("tools", "https://example.com/tools.git", "v2.0.0"),
1388            ("floating", "https://example.com/floating.git", "v3.0.0"),
1389        ]);
1390
1391        let mode = ResolutionMode::Maximize {
1392            targets: HashSet::new(),
1393            bump: true,
1394        };
1395        let entries = planned_bump_entries(&config, &graph, &mode);
1396        assert_eq!(entries.len(), 1);
1397        assert_eq!(entries[0].0, SourceName::from("base"));
1398        assert_eq!(entries[0].1.version.as_deref(), Some("v1.2.0"));
1399    }
1400
1401    #[test]
1402    fn planned_bump_entries_bump_specific_targets_only() {
1403        let mut config = Config::default();
1404        config.dependencies.insert(
1405            "base".into(),
1406            git_dependency_entry(
1407                "https://example.com/base.git",
1408                "v1.0.0",
1409                FilterConfig::default(),
1410            ),
1411        );
1412        config.dependencies.insert(
1413            "tools".into(),
1414            git_dependency_entry(
1415                "https://example.com/tools.git",
1416                "v1.0.0",
1417                FilterConfig::default(),
1418            ),
1419        );
1420
1421        let graph = graph_with_versions(&[
1422            ("base", "https://example.com/base.git", "v2.0.0"),
1423            ("tools", "https://example.com/tools.git", "v2.0.0"),
1424        ]);
1425
1426        let mode = ResolutionMode::Maximize {
1427            targets: HashSet::from([SourceName::from("tools")]),
1428            bump: true,
1429        };
1430        let entries = planned_bump_entries(&config, &graph, &mode);
1431        assert_eq!(entries.len(), 1);
1432        assert_eq!(entries[0].0, SourceName::from("tools"));
1433        assert_eq!(entries[0].1.version.as_deref(), Some("v2.0.0"));
1434    }
1435
1436    #[test]
1437    fn planned_bump_entries_noop_when_already_latest() {
1438        let mut config = Config::default();
1439        config.dependencies.insert(
1440            "base".into(),
1441            git_dependency_entry(
1442                "https://example.com/base.git",
1443                "v1.2.0",
1444                FilterConfig::default(),
1445            ),
1446        );
1447
1448        let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v1.2.0")]);
1449
1450        let mode = ResolutionMode::Maximize {
1451            targets: HashSet::new(),
1452            bump: true,
1453        };
1454        let entries = planned_bump_entries(&config, &graph, &mode);
1455        assert!(entries.is_empty());
1456    }
1457
1458    #[test]
1459    fn planned_bump_entries_preserve_filters_and_renames() {
1460        let mut rename = crate::types::RenameMap::new();
1461        rename.insert("coder".into(), "coder-v2".into());
1462
1463        let mut config = Config::default();
1464        config.dependencies.insert(
1465            "base".into(),
1466            git_dependency_entry(
1467                "https://example.com/base.git",
1468                "v1.0.0",
1469                FilterConfig {
1470                    agents: Some(vec!["coder".into()]),
1471                    rename: Some(rename.clone()),
1472                    ..FilterConfig::default()
1473                },
1474            ),
1475        );
1476
1477        let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v2.0.0")]);
1478        let mode = ResolutionMode::Maximize {
1479            targets: HashSet::new(),
1480            bump: true,
1481        };
1482        let entries = planned_bump_entries(&config, &graph, &mode);
1483        let mut mutated = config.clone();
1484        let changes =
1485            mutation::apply_mutation(&mut mutated, &ConfigMutation::BatchUpsert(entries)).unwrap();
1486
1487        assert_eq!(changes.len(), 1);
1488        assert_eq!(changes[0].old_version.as_deref(), Some("v1.0.0"));
1489        assert_eq!(changes[0].new_version.as_deref(), Some("v2.0.0"));
1490
1491        let dep = &mutated.dependencies["base"];
1492        assert_eq!(dep.version.as_deref(), Some("v2.0.0"));
1493        assert_eq!(dep.filter.agents.as_deref(), Some(&["coder".into()][..]));
1494        assert_eq!(dep.filter.rename.as_ref(), Some(&rename));
1495    }
1496
1497    #[test]
1498    fn execute_auto_inits_config_for_mutation() {
1499        let project_root = TempDir::new().unwrap();
1500        let managed_root = project_root.path().join(".agents");
1501        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1502        let source = TempDir::new().unwrap();
1503        fs::create_dir_all(source.path().join("agents")).unwrap();
1504        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1505
1506        let request = SyncRequest {
1507            resolution: ResolutionMode::Normal,
1508            mutation: Some(ConfigMutation::UpsertDependency {
1509                name: "base".into(),
1510                entry: path_dependency_entry(source.path()),
1511            }),
1512            options: SyncOptions::default(),
1513        };
1514
1515        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1516        let report = execute(&ctx, &request).unwrap();
1517        assert!(!report.applied.outcomes.is_empty());
1518        assert!(project_root.path().join("mars.toml").exists());
1519
1520        let saved = crate::config::load(project_root.path()).unwrap();
1521        assert!(saved.dependencies.contains_key("base"));
1522    }
1523
1524    #[test]
1525    fn execute_dry_run_with_mutation_does_not_write_config() {
1526        let project_root = TempDir::new().unwrap();
1527        let managed_root = project_root.path().join(".agents");
1528        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1529        crate::config::save(
1530            project_root.path(),
1531            &Config {
1532                dependencies: IndexMap::new(),
1533                settings: Settings::default(),
1534                ..Config::default()
1535            },
1536        )
1537        .unwrap();
1538
1539        let source = TempDir::new().unwrap();
1540        fs::create_dir_all(source.path().join("agents")).unwrap();
1541        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1542
1543        let request = SyncRequest {
1544            resolution: ResolutionMode::Normal,
1545            mutation: Some(ConfigMutation::UpsertDependency {
1546                name: "base".into(),
1547                entry: path_dependency_entry(source.path()),
1548            }),
1549            options: SyncOptions {
1550                force: false,
1551                dry_run: true,
1552                frozen: false,
1553                no_refresh_models: false,
1554            },
1555        };
1556
1557        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1558        let report = execute(&ctx, &request).unwrap();
1559        assert!(!report.applied.outcomes.is_empty());
1560
1561        let saved = crate::config::load(project_root.path()).unwrap();
1562        assert!(!saved.dependencies.contains_key("base"));
1563        assert!(!managed_root.join("agents/coder.md").exists());
1564        assert!(!project_root.path().join("mars.lock").exists());
1565    }
1566
1567    // === Integration tests for the pipeline stages ===
1568
1569    #[test]
1570    fn full_pipeline_fresh_sync() {
1571        let mut fixture = TestFixture::new();
1572        let src_idx = fixture.add_source(
1573            &[("coder.md", "# Coder agent")],
1574            &[("planning", "# Planning skill")],
1575        );
1576
1577        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1578
1579        // Build target
1580        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1581        assert!(renames.is_empty());
1582        assert_eq!(target.items.len(), 2);
1583
1584        // Compute diff against empty lock
1585        let lock = LockFile::empty();
1586        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1587
1588        // All items should be Add
1589        assert_eq!(sync_diff.items.len(), 2);
1590        for entry in &sync_diff.items {
1591            assert!(matches!(entry, diff::DiffEntry::Add { .. }));
1592        }
1593
1594        // Create plan
1595        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1596        let options = SyncOptions {
1597            force: false,
1598            dry_run: false,
1599            frozen: false,
1600            no_refresh_models: false,
1601        };
1602        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1603        assert_eq!(sync_plan.actions.len(), 2);
1604        for action in &sync_plan.actions {
1605            assert!(matches!(action, plan::PlannedAction::Install { .. }));
1606        }
1607
1608        // Execute plan
1609        let result =
1610            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1611        assert_eq!(result.outcomes.len(), 2);
1612
1613        // Verify files were created
1614        assert!(fixture.managed_root().join("agents/coder.md").exists());
1615        assert!(
1616            fixture
1617                .managed_root()
1618                .join("skills/planning/SKILL.md")
1619                .exists()
1620        );
1621
1622        // Build lock
1623        let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1624        assert_eq!(new_lock.items.len(), 2);
1625        assert!(new_lock.items.contains_key("agents/coder.md"));
1626        assert!(new_lock.items.contains_key("skills/planning"));
1627    }
1628
1629    #[test]
1630    fn re_sync_no_changes() {
1631        let mut fixture = TestFixture::new();
1632        let content = "# Coder agent";
1633        let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1634
1635        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1636
1637        // First sync
1638        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1639        let lock = LockFile::empty();
1640        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1641        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1642        let options = SyncOptions {
1643            force: false,
1644            dry_run: false,
1645            frozen: false,
1646            no_refresh_models: false,
1647        };
1648        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1649        let result =
1650            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1651        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1652
1653        // Second sync with same content
1654        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1655        let sync_diff2 =
1656            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1657
1658        // All items should be Unchanged
1659        for entry in &sync_diff2.items {
1660            assert!(
1661                matches!(entry, diff::DiffEntry::Unchanged { .. }),
1662                "expected Unchanged, got {entry:?}"
1663            );
1664        }
1665
1666        let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1667        for action in &sync_plan2.actions {
1668            assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1669        }
1670    }
1671
1672    #[test]
1673    fn source_update_detects_changes() {
1674        let mut fixture = TestFixture::new();
1675        let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1676
1677        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1678
1679        // First sync
1680        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1681        let lock = LockFile::empty();
1682        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1683        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1684        let options = SyncOptions {
1685            force: false,
1686            dry_run: false,
1687            frozen: false,
1688            no_refresh_models: false,
1689        };
1690        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1691        let result =
1692            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1693        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1694
1695        // Update source content
1696        let agents_dir = fixture.tree_path(src_idx).join("agents");
1697        fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1698
1699        // Rebuild target with updated content
1700        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1701        let sync_diff2 =
1702            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1703
1704        // Should detect an Update
1705        assert_eq!(sync_diff2.items.len(), 1);
1706        assert!(matches!(
1707            &sync_diff2.items[0],
1708            diff::DiffEntry::Update { .. }
1709        ));
1710    }
1711
1712    #[test]
1713    fn local_modification_preserved() {
1714        let mut fixture = TestFixture::new();
1715        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1716
1717        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1718
1719        // First sync
1720        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1721        let lock = LockFile::empty();
1722        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1723        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1724        let options = SyncOptions {
1725            force: false,
1726            dry_run: false,
1727            frozen: false,
1728            no_refresh_models: false,
1729        };
1730        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1731        let result =
1732            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1733        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1734
1735        // Locally modify the installed file
1736        fs::write(
1737            fixture.managed_root().join("agents/coder.md"),
1738            "# Locally modified",
1739        )
1740        .unwrap();
1741
1742        // Re-sync (source unchanged)
1743        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1744        let sync_diff2 =
1745            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1746
1747        // Should detect LocalModified
1748        assert_eq!(sync_diff2.items.len(), 1);
1749        assert!(matches!(
1750            &sync_diff2.items[0],
1751            diff::DiffEntry::LocalModified { .. }
1752        ));
1753
1754        // Plan should KeepLocal
1755        let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1756        assert!(matches!(
1757            &sync_plan2.actions[0],
1758            plan::PlannedAction::KeepLocal { .. }
1759        ));
1760    }
1761
1762    #[test]
1763    fn force_overwrites_local_modifications() {
1764        let mut fixture = TestFixture::new();
1765        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1766
1767        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1768
1769        // First sync
1770        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1771        let lock = LockFile::empty();
1772        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1773        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1774        let options = SyncOptions {
1775            force: false,
1776            dry_run: false,
1777            frozen: false,
1778            no_refresh_models: false,
1779        };
1780        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1781        let result =
1782            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1783        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1784
1785        // Locally modify the installed file
1786        fs::write(
1787            fixture.managed_root().join("agents/coder.md"),
1788            "# Locally modified",
1789        )
1790        .unwrap();
1791
1792        // Update source too (triggers conflict)
1793        let agents_dir = fixture.tree_path(src_idx).join("agents");
1794        fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1795
1796        // Re-sync with --force
1797        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1798        let sync_diff2 =
1799            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1800
1801        let force_options = SyncOptions {
1802            force: true,
1803            dry_run: false,
1804            frozen: false,
1805            no_refresh_models: false,
1806        };
1807        let sync_plan2 = create_sync_plan(&sync_diff2, &force_options, &cache_dir);
1808        assert!(matches!(
1809            &sync_plan2.actions[0],
1810            plan::PlannedAction::Overwrite { .. }
1811        ));
1812
1813        let result2 = apply::execute(
1814            fixture.managed_root(),
1815            &sync_plan2,
1816            &force_options,
1817            &cache_dir,
1818        )
1819        .unwrap();
1820        assert!(matches!(
1821            result2.outcomes[0].action,
1822            apply::ActionTaken::Updated
1823        ));
1824
1825        // File should have upstream content
1826        let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
1827        assert_eq!(content, "# Upstream update");
1828    }
1829
1830    #[test]
1831    fn orphan_removed_when_source_drops_item() {
1832        let mut fixture = TestFixture::new();
1833        let src_idx = fixture.add_source(
1834            &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
1835            &[],
1836        );
1837
1838        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1839
1840        // First sync — install both
1841        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1842        let lock = LockFile::empty();
1843        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1844        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1845        let options = SyncOptions {
1846            force: false,
1847            dry_run: false,
1848            frozen: false,
1849            no_refresh_models: false,
1850        };
1851        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1852        let result =
1853            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1854        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1855
1856        assert!(fixture.managed_root().join("agents/coder.md").exists());
1857        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1858
1859        // Remove reviewer from source
1860        fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
1861
1862        // Re-sync
1863        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1864        let sync_diff2 =
1865            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1866
1867        // Should have one Unchanged and one Orphan
1868        let orphan_count = sync_diff2
1869            .items
1870            .iter()
1871            .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1872            .count();
1873        assert_eq!(orphan_count, 1);
1874
1875        let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1876        let result2 =
1877            apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
1878
1879        // Reviewer should be removed
1880        assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
1881        // Coder should still be there
1882        assert!(fixture.managed_root().join("agents/coder.md").exists());
1883
1884        // Check remove outcome
1885        let removed = result2
1886            .outcomes
1887            .iter()
1888            .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1889        assert!(removed);
1890    }
1891
1892    #[test]
1893    fn dry_run_produces_plan_without_changes() {
1894        let mut fixture = TestFixture::new();
1895        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1896
1897        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1898
1899        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1900        let lock = LockFile::empty();
1901        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1902
1903        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1904        let dry_options = SyncOptions {
1905            force: false,
1906            dry_run: true,
1907            frozen: false,
1908            no_refresh_models: false,
1909        };
1910
1911        let sync_plan = create_sync_plan(&sync_diff, &dry_options, &cache_dir);
1912        assert!(!sync_plan.actions.is_empty());
1913
1914        // Execute in dry-run mode
1915        let result =
1916            apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1917        assert!(!result.outcomes.is_empty());
1918
1919        // No files should have been created
1920        assert!(!fixture.managed_root().join("agents/coder.md").exists());
1921    }
1922
1923    #[test]
1924    fn lock_written_after_apply() {
1925        let mut fixture = TestFixture::new();
1926        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1927
1928        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1929
1930        // Full pipeline minus actual sync() (which needs real config files)
1931        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1932        let lock = LockFile::empty();
1933        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1934        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1935        let options = SyncOptions {
1936            force: false,
1937            dry_run: false,
1938            frozen: false,
1939            no_refresh_models: false,
1940        };
1941        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1942        let result =
1943            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1944
1945        let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1946        crate::lock::write(fixture.project_root(), &new_lock).unwrap();
1947
1948        // Verify lock file exists and is valid
1949        let reloaded = crate::lock::load(fixture.project_root()).unwrap();
1950        assert_eq!(reloaded.items.len(), 1);
1951        assert!(reloaded.items.contains_key("agents/coder.md"));
1952
1953        let item = &reloaded.items["agents/coder.md"];
1954        assert_eq!(item.kind, ItemKind::Agent);
1955        assert!(!item.source_checksum.is_empty());
1956        assert!(!item.installed_checksum.is_empty());
1957    }
1958
1959    #[test]
1960    fn two_sources_no_collision() {
1961        let mut fixture = TestFixture::new();
1962        let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
1963        let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
1964
1965        let (graph, config) = make_graph_config(
1966            &fixture,
1967            vec![
1968                ("source-a", src_a, FilterMode::All),
1969                ("source-b", src_b, FilterMode::All),
1970            ],
1971        );
1972
1973        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1974        assert!(renames.is_empty());
1975        assert_eq!(target.items.len(), 2);
1976
1977        let lock = LockFile::empty();
1978        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1979        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1980        let options = SyncOptions {
1981            force: false,
1982            dry_run: false,
1983            frozen: false,
1984            no_refresh_models: false,
1985        };
1986        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1987        let result =
1988            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1989
1990        assert!(fixture.managed_root().join("agents/coder.md").exists());
1991        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1992        assert_eq!(result.outcomes.len(), 2);
1993    }
1994
1995    // === Tests for OnlySkills / OnlyAgents filter in pipeline ===
1996
1997    #[test]
1998    fn pipeline_only_skills_filter() {
1999        let mut fixture = TestFixture::new();
2000        let src_idx = fixture.add_source(
2001            &[("coder.md", "# Coder agent")],
2002            &[("planning", "# Planning skill")],
2003        );
2004
2005        let (graph, config) =
2006            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
2007
2008        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2009        // Should only have the skill, not the agent
2010        assert_eq!(target.items.len(), 1);
2011        assert!(target.items.contains_key("skills/planning"));
2012    }
2013
2014    #[test]
2015    fn pipeline_only_agents_filter() {
2016        let mut fixture = TestFixture::new();
2017        // Agent with a skill dependency in frontmatter
2018        let agent_content = "---\nskills:\n  - planning\n---\n# Coder agent";
2019        let src_idx = fixture.add_source(
2020            &[("coder.md", agent_content)],
2021            &[
2022                ("planning", "# Planning skill"),
2023                ("standalone", "# Standalone skill"),
2024            ],
2025        );
2026
2027        let (graph, config) =
2028            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2029
2030        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2031        // Should have the agent + its transitive skill dep, but NOT standalone
2032        assert_eq!(target.items.len(), 2);
2033        assert!(target.items.contains_key("agents/coder.md"));
2034        assert!(target.items.contains_key("skills/planning"));
2035        assert!(!target.items.contains_key("skills/standalone"));
2036    }
2037
2038    #[test]
2039    fn pipeline_only_agents_no_agents_source() {
2040        let mut fixture = TestFixture::new();
2041        let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
2042
2043        let (graph, config) =
2044            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2045
2046        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2047        // No agents means nothing gets installed
2048        assert_eq!(target.items.len(), 0);
2049    }
2050}