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