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