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            autocompact_pct: None,
1202            spec: crate::models::ModelSpec::Pinned {
1203                model: model.to_string(),
1204                provider: None,
1205            },
1206        }
1207    }
1208
1209    fn manifest_with_models(name: &str) -> Manifest {
1210        let mut models = IndexMap::new();
1211        models.insert(
1212            format!("{name}-alias"),
1213            model_alias(&format!("{name}-model")),
1214        );
1215        Manifest {
1216            package: PackageInfo {
1217                name: name.to_string(),
1218                version: "1.0.0".to_string(),
1219                description: None,
1220            },
1221            dependencies: IndexMap::new(),
1222            models,
1223        }
1224    }
1225
1226    fn resolved_node(name: &str, deps: &[&str], with_models: bool) -> ResolvedNode {
1227        let canonical = PathBuf::from(format!("/tmp/{name}"));
1228        ResolvedNode {
1229            source_name: name.into(),
1230            source_id: crate::types::SourceId::Path {
1231                canonical: canonical.clone(),
1232                subpath: None,
1233            },
1234            rooted_ref: crate::resolve::RootedSourceRef {
1235                checkout_root: canonical.clone(),
1236                package_root: canonical.clone(),
1237            },
1238            resolved_ref: crate::source::ResolvedRef {
1239                source_name: name.into(),
1240                version: None,
1241                version_tag: None,
1242                commit: None,
1243                tree_path: canonical,
1244            },
1245            latest_version: None,
1246            manifest: with_models.then(|| manifest_with_models(name)),
1247            deps: deps.iter().map(|dep| (*dep).into()).collect(),
1248        }
1249    }
1250
1251    fn effective_config_with_decl_order(names: &[&str]) -> EffectiveConfig {
1252        let mut dependencies = IndexMap::new();
1253        for name in names {
1254            let canonical = PathBuf::from(format!("/tmp/dep-{name}"));
1255            dependencies.insert(
1256                (*name).into(),
1257                EffectiveDependency {
1258                    name: (*name).into(),
1259                    id: crate::types::SourceId::Path {
1260                        canonical: canonical.clone(),
1261                        subpath: None,
1262                    },
1263                    spec: SourceSpec::Path(canonical),
1264                    subpath: None,
1265                    filter: FilterMode::All,
1266                    rename: crate::types::RenameMap::new(),
1267                    is_overridden: false,
1268                    original_git: None,
1269                },
1270            );
1271        }
1272        EffectiveConfig {
1273            dependencies,
1274            settings: Settings::default(),
1275        }
1276    }
1277
1278    fn dep_model_names(models: &[crate::models::ResolvedDepModels]) -> Vec<String> {
1279        models.iter().map(|m| m.source_name.clone()).collect()
1280    }
1281
1282    #[test]
1283    fn declaration_ordered_dep_models_sibling_order() {
1284        let mut nodes = IndexMap::new();
1285        nodes.insert("a".into(), resolved_node("a", &[], true));
1286        nodes.insert("b".into(), resolved_node("b", &[], true));
1287
1288        let graph = ResolvedGraph {
1289            nodes,
1290            order: vec!["a".into(), "b".into()],
1291            filters: std::collections::HashMap::new(),
1292        };
1293        let config = effective_config_with_decl_order(&["a", "b"]);
1294
1295        let dep_models = declaration_ordered_dep_models(&graph, &config);
1296        assert_eq!(dep_model_names(&dep_models), vec!["a", "b"]);
1297    }
1298
1299    #[test]
1300    fn declaration_ordered_dep_models_diamond_uses_minimum_sponsor_position() {
1301        let mut nodes = IndexMap::new();
1302        nodes.insert("a".into(), resolved_node("a", &["d"], true));
1303        nodes.insert("b".into(), resolved_node("b", &["d"], true));
1304        nodes.insert("d".into(), resolved_node("d", &[], true));
1305
1306        let graph = ResolvedGraph {
1307            nodes,
1308            order: vec!["d".into(), "a".into(), "b".into()],
1309            filters: std::collections::HashMap::new(),
1310        };
1311        let config = effective_config_with_decl_order(&["a", "b"]);
1312
1313        let dep_models = declaration_ordered_dep_models(&graph, &config);
1314        assert_eq!(dep_model_names(&dep_models), vec!["d", "a", "b"]);
1315    }
1316
1317    #[test]
1318    fn declaration_ordered_dep_models_transitives_follow_sponsor_declaration_order() {
1319        let mut nodes = IndexMap::new();
1320        nodes.insert("a".into(), resolved_node("a", &["d"], false));
1321        nodes.insert("b".into(), resolved_node("b", &["e"], false));
1322        nodes.insert("d".into(), resolved_node("d", &[], true));
1323        nodes.insert("e".into(), resolved_node("e", &[], true));
1324
1325        let graph = ResolvedGraph {
1326            nodes,
1327            order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1328            filters: std::collections::HashMap::new(),
1329        };
1330        let config = effective_config_with_decl_order(&["a", "b"]);
1331
1332        let dep_models = declaration_ordered_dep_models(&graph, &config);
1333        assert_eq!(dep_model_names(&dep_models), vec!["d", "e"]);
1334    }
1335
1336    #[test]
1337    fn declaration_ordered_dep_models_keeps_deps_before_dependents() {
1338        let mut nodes = IndexMap::new();
1339        nodes.insert("a".into(), resolved_node("a", &["d"], true));
1340        nodes.insert("d".into(), resolved_node("d", &[], true));
1341
1342        let graph = ResolvedGraph {
1343            nodes,
1344            order: vec!["d".into(), "a".into()],
1345            filters: std::collections::HashMap::new(),
1346        };
1347        // D is declared after A, but topological ordering must still emit D first.
1348        let config = effective_config_with_decl_order(&["a", "d"]);
1349
1350        let dep_models = declaration_ordered_dep_models(&graph, &config);
1351        assert_eq!(dep_model_names(&dep_models), vec!["d", "a"]);
1352    }
1353
1354    #[test]
1355    fn declaration_ordered_dep_models_is_deterministic() {
1356        let mut nodes = IndexMap::new();
1357        nodes.insert("a".into(), resolved_node("a", &["d"], true));
1358        nodes.insert("b".into(), resolved_node("b", &["e"], true));
1359        nodes.insert("d".into(), resolved_node("d", &[], true));
1360        nodes.insert("e".into(), resolved_node("e", &[], true));
1361
1362        let graph = ResolvedGraph {
1363            nodes,
1364            order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1365            filters: std::collections::HashMap::new(),
1366        };
1367        let config = effective_config_with_decl_order(&["a", "b"]);
1368
1369        let first = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1370        for _ in 0..10 {
1371            let current = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1372            assert_eq!(current, first);
1373        }
1374    }
1375
1376    #[test]
1377    fn declaration_ordered_dep_models_is_used_by_resolve_graph_and_finalize() {
1378        let source = include_str!("mod.rs");
1379        assert!(source.contains("declaration_ordered_dep_models(&graph, &loaded.effective)"));
1380        assert!(source.contains("&state.applied.planned.targeted.resolved.loaded.effective"));
1381    }
1382
1383    #[test]
1384    fn validate_request_rejects_frozen_with_maximize() {
1385        let request = SyncRequest {
1386            resolution: ResolutionMode::Maximize {
1387                targets: HashSet::new(),
1388                bump: false,
1389            },
1390            mutation: None,
1391            options: SyncOptions {
1392                force: false,
1393                dry_run: false,
1394                frozen: true,
1395                no_refresh_models: false,
1396            },
1397        };
1398
1399        let err = validate_request(&request).unwrap_err();
1400        assert!(matches!(err, MarsError::InvalidRequest { .. }));
1401        assert!(err.to_string().contains("--frozen"));
1402    }
1403
1404    #[test]
1405    fn validate_request_rejects_frozen_with_mutation() {
1406        let request = SyncRequest {
1407            resolution: ResolutionMode::Normal,
1408            mutation: Some(ConfigMutation::RemoveDependency {
1409                name: "base".into(),
1410            }),
1411            options: SyncOptions {
1412                force: false,
1413                dry_run: false,
1414                frozen: true,
1415                no_refresh_models: false,
1416            },
1417        };
1418
1419        let err = validate_request(&request).unwrap_err();
1420        assert!(matches!(err, MarsError::InvalidRequest { .. }));
1421        assert!(err.to_string().contains("cannot modify config"));
1422    }
1423
1424    #[test]
1425    fn planned_bump_entries_bump_all_outdated_pins() {
1426        let mut config = Config::default();
1427        config.dependencies.insert(
1428            "base".into(),
1429            git_dependency_entry(
1430                "https://example.com/base.git",
1431                "v1.0.0",
1432                FilterConfig::default(),
1433            ),
1434        );
1435        config.dependencies.insert(
1436            "tools".into(),
1437            git_dependency_entry(
1438                "https://example.com/tools.git",
1439                "v2.0.0",
1440                FilterConfig::default(),
1441            ),
1442        );
1443        config.dependencies.insert(
1444            "floating".into(),
1445            DependencyEntry {
1446                url: Some("https://example.com/floating.git".into()),
1447                path: None,
1448                subpath: None,
1449                version: None,
1450                filter: FilterConfig::default(),
1451            },
1452        );
1453
1454        let graph = graph_with_versions(&[
1455            ("base", "https://example.com/base.git", "v1.2.0"),
1456            ("tools", "https://example.com/tools.git", "v2.0.0"),
1457            ("floating", "https://example.com/floating.git", "v3.0.0"),
1458        ]);
1459
1460        let mode = ResolutionMode::Maximize {
1461            targets: HashSet::new(),
1462            bump: true,
1463        };
1464        let entries = planned_bump_entries(&config, &graph, &mode);
1465        assert_eq!(entries.len(), 1);
1466        assert_eq!(entries[0].0, SourceName::from("base"));
1467        assert_eq!(entries[0].1.version.as_deref(), Some("v1.2.0"));
1468    }
1469
1470    #[test]
1471    fn planned_bump_entries_bump_specific_targets_only() {
1472        let mut config = Config::default();
1473        config.dependencies.insert(
1474            "base".into(),
1475            git_dependency_entry(
1476                "https://example.com/base.git",
1477                "v1.0.0",
1478                FilterConfig::default(),
1479            ),
1480        );
1481        config.dependencies.insert(
1482            "tools".into(),
1483            git_dependency_entry(
1484                "https://example.com/tools.git",
1485                "v1.0.0",
1486                FilterConfig::default(),
1487            ),
1488        );
1489
1490        let graph = graph_with_versions(&[
1491            ("base", "https://example.com/base.git", "v2.0.0"),
1492            ("tools", "https://example.com/tools.git", "v2.0.0"),
1493        ]);
1494
1495        let mode = ResolutionMode::Maximize {
1496            targets: HashSet::from([SourceName::from("tools")]),
1497            bump: true,
1498        };
1499        let entries = planned_bump_entries(&config, &graph, &mode);
1500        assert_eq!(entries.len(), 1);
1501        assert_eq!(entries[0].0, SourceName::from("tools"));
1502        assert_eq!(entries[0].1.version.as_deref(), Some("v2.0.0"));
1503    }
1504
1505    #[test]
1506    fn planned_bump_entries_noop_when_already_latest() {
1507        let mut config = Config::default();
1508        config.dependencies.insert(
1509            "base".into(),
1510            git_dependency_entry(
1511                "https://example.com/base.git",
1512                "v1.2.0",
1513                FilterConfig::default(),
1514            ),
1515        );
1516
1517        let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v1.2.0")]);
1518
1519        let mode = ResolutionMode::Maximize {
1520            targets: HashSet::new(),
1521            bump: true,
1522        };
1523        let entries = planned_bump_entries(&config, &graph, &mode);
1524        assert!(entries.is_empty());
1525    }
1526
1527    #[test]
1528    fn planned_bump_entries_preserve_filters_and_renames() {
1529        let mut rename = crate::types::RenameMap::new();
1530        rename.insert("coder".into(), "coder-v2".into());
1531
1532        let mut config = Config::default();
1533        config.dependencies.insert(
1534            "base".into(),
1535            git_dependency_entry(
1536                "https://example.com/base.git",
1537                "v1.0.0",
1538                FilterConfig {
1539                    agents: Some(vec!["coder".into()]),
1540                    rename: Some(rename.clone()),
1541                    ..FilterConfig::default()
1542                },
1543            ),
1544        );
1545
1546        let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v2.0.0")]);
1547        let mode = ResolutionMode::Maximize {
1548            targets: HashSet::new(),
1549            bump: true,
1550        };
1551        let entries = planned_bump_entries(&config, &graph, &mode);
1552        let mut mutated = config.clone();
1553        let changes =
1554            mutation::apply_mutation(&mut mutated, &ConfigMutation::BatchUpsert(entries)).unwrap();
1555
1556        assert_eq!(changes.len(), 1);
1557        assert_eq!(changes[0].old_version.as_deref(), Some("v1.0.0"));
1558        assert_eq!(changes[0].new_version.as_deref(), Some("v2.0.0"));
1559
1560        let dep = &mutated.dependencies["base"];
1561        assert_eq!(dep.version.as_deref(), Some("v2.0.0"));
1562        assert_eq!(dep.filter.agents.as_deref(), Some(&["coder".into()][..]));
1563        assert_eq!(dep.filter.rename.as_ref(), Some(&rename));
1564    }
1565
1566    #[test]
1567    fn execute_auto_inits_config_for_mutation() {
1568        let project_root = TempDir::new().unwrap();
1569        let managed_root = project_root.path().join(".agents");
1570        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1571        let source = TempDir::new().unwrap();
1572        fs::create_dir_all(source.path().join("agents")).unwrap();
1573        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1574
1575        let request = SyncRequest {
1576            resolution: ResolutionMode::Normal,
1577            mutation: Some(ConfigMutation::UpsertDependency {
1578                name: "base".into(),
1579                entry: path_dependency_entry(source.path()),
1580            }),
1581            options: SyncOptions::default(),
1582        };
1583
1584        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1585        let report = execute(&ctx, &request).unwrap();
1586        assert!(!report.applied.outcomes.is_empty());
1587        assert!(project_root.path().join("mars.toml").exists());
1588
1589        let saved = crate::config::load(project_root.path()).unwrap();
1590        assert!(saved.dependencies.contains_key("base"));
1591    }
1592
1593    #[test]
1594    fn execute_dry_run_with_mutation_does_not_write_config() {
1595        let project_root = TempDir::new().unwrap();
1596        let managed_root = project_root.path().join(".agents");
1597        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1598        crate::config::save(
1599            project_root.path(),
1600            &Config {
1601                dependencies: IndexMap::new(),
1602                settings: Settings::default(),
1603                ..Config::default()
1604            },
1605        )
1606        .unwrap();
1607
1608        let source = TempDir::new().unwrap();
1609        fs::create_dir_all(source.path().join("agents")).unwrap();
1610        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1611
1612        let request = SyncRequest {
1613            resolution: ResolutionMode::Normal,
1614            mutation: Some(ConfigMutation::UpsertDependency {
1615                name: "base".into(),
1616                entry: path_dependency_entry(source.path()),
1617            }),
1618            options: SyncOptions {
1619                force: false,
1620                dry_run: true,
1621                frozen: false,
1622                no_refresh_models: false,
1623            },
1624        };
1625
1626        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1627        let report = execute(&ctx, &request).unwrap();
1628        assert!(!report.applied.outcomes.is_empty());
1629
1630        let saved = crate::config::load(project_root.path()).unwrap();
1631        assert!(!saved.dependencies.contains_key("base"));
1632        assert!(!managed_root.join("agents/coder.md").exists());
1633        assert!(!project_root.path().join("mars.lock").exists());
1634    }
1635
1636    // === Integration tests for the pipeline stages ===
1637
1638    #[test]
1639    fn full_pipeline_fresh_sync() {
1640        let mut fixture = TestFixture::new();
1641        let src_idx = fixture.add_source(
1642            &[("coder.md", "# Coder agent")],
1643            &[("planning", "# Planning skill")],
1644        );
1645
1646        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1647
1648        // Build target
1649        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1650        assert!(renames.is_empty());
1651        assert_eq!(target.items.len(), 2);
1652
1653        // Compute diff against empty lock
1654        let lock = LockFile::empty();
1655        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1656
1657        // All items should be Add
1658        assert_eq!(sync_diff.items.len(), 2);
1659        for entry in &sync_diff.items {
1660            assert!(matches!(entry, diff::DiffEntry::Add { .. }));
1661        }
1662
1663        // Create plan
1664        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1665        let options = SyncOptions {
1666            force: false,
1667            dry_run: false,
1668            frozen: false,
1669            no_refresh_models: false,
1670        };
1671        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1672        assert_eq!(sync_plan.actions.len(), 2);
1673        for action in &sync_plan.actions {
1674            assert!(matches!(action, plan::PlannedAction::Install { .. }));
1675        }
1676
1677        // Execute plan
1678        let result =
1679            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1680        assert_eq!(result.outcomes.len(), 2);
1681
1682        // Verify files were created
1683        assert!(fixture.managed_root().join("agents/coder.md").exists());
1684        assert!(
1685            fixture
1686                .managed_root()
1687                .join("skills/planning/SKILL.md")
1688                .exists()
1689        );
1690
1691        // Build lock
1692        let new_lock =
1693            crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1694        assert_eq!(new_lock.items.len(), 2);
1695        assert!(new_lock.items.contains_key("agent/coder"));
1696        assert!(new_lock.items.contains_key("skill/planning"));
1697    }
1698
1699    #[test]
1700    fn re_sync_no_changes() {
1701        let mut fixture = TestFixture::new();
1702        let content = "# Coder agent";
1703        let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1704
1705        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1706
1707        // First sync
1708        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1709        let lock = LockFile::empty();
1710        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1711        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1712        let options = SyncOptions {
1713            force: false,
1714            dry_run: false,
1715            frozen: false,
1716            no_refresh_models: false,
1717        };
1718        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1719        let result =
1720            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1721        let first_lock =
1722            crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1723
1724        // Second sync with same content
1725        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1726        let sync_diff2 =
1727            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1728
1729        // All items should be Unchanged
1730        for entry in &sync_diff2.items {
1731            assert!(
1732                matches!(entry, diff::DiffEntry::Unchanged { .. }),
1733                "expected Unchanged, got {entry:?}"
1734            );
1735        }
1736
1737        let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1738        for action in &sync_plan2.actions {
1739            assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1740        }
1741    }
1742
1743    #[test]
1744    fn source_update_detects_changes() {
1745        let mut fixture = TestFixture::new();
1746        let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1747
1748        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1749
1750        // First sync
1751        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1752        let lock = LockFile::empty();
1753        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1754        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1755        let options = SyncOptions {
1756            force: false,
1757            dry_run: false,
1758            frozen: false,
1759            no_refresh_models: false,
1760        };
1761        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1762        let result =
1763            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1764        let first_lock =
1765            crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1766
1767        // Update source content
1768        let agents_dir = fixture.tree_path(src_idx).join("agents");
1769        fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1770
1771        // Rebuild target with updated content
1772        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1773        let sync_diff2 =
1774            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1775
1776        // Should detect an Update
1777        assert_eq!(sync_diff2.items.len(), 1);
1778        assert!(matches!(
1779            &sync_diff2.items[0],
1780            diff::DiffEntry::Update { .. }
1781        ));
1782    }
1783
1784    #[test]
1785    fn local_modification_preserved() {
1786        let mut fixture = TestFixture::new();
1787        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1788
1789        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1790
1791        // First sync
1792        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1793        let lock = LockFile::empty();
1794        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1795        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1796        let options = SyncOptions {
1797            force: false,
1798            dry_run: false,
1799            frozen: false,
1800            no_refresh_models: false,
1801        };
1802        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1803        let result =
1804            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1805        let first_lock =
1806            crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1807
1808        // Locally modify the installed file
1809        fs::write(
1810            fixture.managed_root().join("agents/coder.md"),
1811            "# Locally modified",
1812        )
1813        .unwrap();
1814
1815        // Re-sync (source unchanged)
1816        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1817        let sync_diff2 =
1818            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1819
1820        // Should detect LocalModified
1821        assert_eq!(sync_diff2.items.len(), 1);
1822        assert!(matches!(
1823            &sync_diff2.items[0],
1824            diff::DiffEntry::LocalModified { .. }
1825        ));
1826
1827        // Plan should KeepLocal
1828        let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1829        assert!(matches!(
1830            &sync_plan2.actions[0],
1831            plan::PlannedAction::KeepLocal { .. }
1832        ));
1833    }
1834
1835    #[test]
1836    fn force_overwrites_local_modifications() {
1837        let mut fixture = TestFixture::new();
1838        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1839
1840        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1841
1842        // First sync
1843        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1844        let lock = LockFile::empty();
1845        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1846        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1847        let options = SyncOptions {
1848            force: false,
1849            dry_run: false,
1850            frozen: false,
1851            no_refresh_models: false,
1852        };
1853        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1854        let result =
1855            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1856        let first_lock =
1857            crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1858
1859        // Locally modify the installed file
1860        fs::write(
1861            fixture.managed_root().join("agents/coder.md"),
1862            "# Locally modified",
1863        )
1864        .unwrap();
1865
1866        // Update source too (triggers conflict)
1867        let agents_dir = fixture.tree_path(src_idx).join("agents");
1868        fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1869
1870        // Re-sync with --force
1871        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1872        let sync_diff2 =
1873            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1874
1875        let force_options = SyncOptions {
1876            force: true,
1877            dry_run: false,
1878            frozen: false,
1879            no_refresh_models: false,
1880        };
1881        let sync_plan2 = create_sync_plan(&sync_diff2, &force_options, &cache_dir);
1882        assert!(matches!(
1883            &sync_plan2.actions[0],
1884            plan::PlannedAction::Overwrite { .. }
1885        ));
1886
1887        let result2 = apply::execute(
1888            fixture.managed_root(),
1889            &sync_plan2,
1890            &force_options,
1891            &cache_dir,
1892        )
1893        .unwrap();
1894        assert!(matches!(
1895            result2.outcomes[0].action,
1896            apply::ActionTaken::Updated
1897        ));
1898
1899        // File should have upstream content
1900        let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
1901        assert_eq!(content, "# Upstream update");
1902    }
1903
1904    #[test]
1905    fn orphan_removed_when_source_drops_item() {
1906        let mut fixture = TestFixture::new();
1907        let src_idx = fixture.add_source(
1908            &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
1909            &[],
1910        );
1911
1912        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1913
1914        // First sync — install both
1915        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1916        let lock = LockFile::empty();
1917        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1918        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1919        let options = SyncOptions {
1920            force: false,
1921            dry_run: false,
1922            frozen: false,
1923            no_refresh_models: false,
1924        };
1925        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1926        let result =
1927            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1928        let first_lock =
1929            crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1930
1931        assert!(fixture.managed_root().join("agents/coder.md").exists());
1932        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1933
1934        // Remove reviewer from source
1935        fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
1936
1937        // Re-sync
1938        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1939        let sync_diff2 =
1940            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1941
1942        // Should have one Unchanged and one Orphan
1943        let orphan_count = sync_diff2
1944            .items
1945            .iter()
1946            .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1947            .count();
1948        assert_eq!(orphan_count, 1);
1949
1950        let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1951        let result2 =
1952            apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
1953
1954        // Reviewer should be removed
1955        assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
1956        // Coder should still be there
1957        assert!(fixture.managed_root().join("agents/coder.md").exists());
1958
1959        // Check remove outcome
1960        let removed = result2
1961            .outcomes
1962            .iter()
1963            .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1964        assert!(removed);
1965    }
1966
1967    #[test]
1968    fn dry_run_produces_plan_without_changes() {
1969        let mut fixture = TestFixture::new();
1970        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1971
1972        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1973
1974        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1975        let lock = LockFile::empty();
1976        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1977
1978        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1979        let dry_options = SyncOptions {
1980            force: false,
1981            dry_run: true,
1982            frozen: false,
1983            no_refresh_models: false,
1984        };
1985
1986        let sync_plan = create_sync_plan(&sync_diff, &dry_options, &cache_dir);
1987        assert!(!sync_plan.actions.is_empty());
1988
1989        // Execute in dry-run mode
1990        let result =
1991            apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1992        assert!(!result.outcomes.is_empty());
1993
1994        // No files should have been created
1995        assert!(!fixture.managed_root().join("agents/coder.md").exists());
1996    }
1997
1998    #[test]
1999    fn lock_written_after_apply() {
2000        let mut fixture = TestFixture::new();
2001        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
2002
2003        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
2004
2005        // Full pipeline minus actual sync() (which needs real config files)
2006        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2007        let lock = LockFile::empty();
2008        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
2009        let cache_dir = fixture.project_root().join(".mars/cache/bases");
2010        let options = SyncOptions {
2011            force: false,
2012            dry_run: false,
2013            frozen: false,
2014            no_refresh_models: false,
2015        };
2016        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
2017        let result =
2018            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
2019
2020        let new_lock =
2021            crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
2022        crate::lock::write(fixture.project_root(), &new_lock).unwrap();
2023
2024        // Verify lock file exists and is valid
2025        let reloaded = crate::lock::load(fixture.project_root()).unwrap();
2026        assert_eq!(reloaded.items.len(), 1);
2027        assert!(reloaded.items.contains_key("agent/coder"));
2028
2029        let item = &reloaded.items["agent/coder"];
2030        assert_eq!(item.kind, ItemKind::Agent);
2031        assert!(!item.source_checksum.is_empty());
2032        assert!(!item.outputs[0].installed_checksum.is_empty());
2033    }
2034
2035    #[test]
2036    fn two_sources_no_collision() {
2037        let mut fixture = TestFixture::new();
2038        let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
2039        let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
2040
2041        let (graph, config) = make_graph_config(
2042            &fixture,
2043            vec![
2044                ("source-a", src_a, FilterMode::All),
2045                ("source-b", src_b, FilterMode::All),
2046            ],
2047        );
2048
2049        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
2050        assert!(renames.is_empty());
2051        assert_eq!(target.items.len(), 2);
2052
2053        let lock = LockFile::empty();
2054        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
2055        let cache_dir = fixture.project_root().join(".mars/cache/bases");
2056        let options = SyncOptions {
2057            force: false,
2058            dry_run: false,
2059            frozen: false,
2060            no_refresh_models: false,
2061        };
2062        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
2063        let result =
2064            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
2065
2066        assert!(fixture.managed_root().join("agents/coder.md").exists());
2067        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
2068        assert_eq!(result.outcomes.len(), 2);
2069    }
2070
2071    // === Tests for OnlySkills / OnlyAgents filter in pipeline ===
2072
2073    #[test]
2074    fn pipeline_only_skills_filter() {
2075        let mut fixture = TestFixture::new();
2076        let src_idx = fixture.add_source(
2077            &[("coder.md", "# Coder agent")],
2078            &[("planning", "# Planning skill")],
2079        );
2080
2081        let (graph, config) =
2082            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
2083
2084        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2085        // Should only have the skill, not the agent
2086        assert_eq!(target.items.len(), 1);
2087        assert!(target.items.contains_key("skills/planning"));
2088    }
2089
2090    #[test]
2091    fn pipeline_only_agents_filter() {
2092        let mut fixture = TestFixture::new();
2093        // Agent with a skill dependency in frontmatter
2094        let agent_content = "---\nskills:\n  - planning\n---\n# Coder agent";
2095        let src_idx = fixture.add_source(
2096            &[("coder.md", agent_content)],
2097            &[
2098                ("planning", "# Planning skill"),
2099                ("standalone", "# Standalone skill"),
2100            ],
2101        );
2102
2103        let (graph, config) =
2104            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2105
2106        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2107        // Should have the agent + its transitive skill dep, but NOT standalone
2108        assert_eq!(target.items.len(), 2);
2109        assert!(target.items.contains_key("agents/coder.md"));
2110        assert!(target.items.contains_key("skills/planning"));
2111        assert!(!target.items.contains_key("skills/standalone"));
2112    }
2113
2114    #[test]
2115    fn pipeline_only_agents_no_agents_source() {
2116        let mut fixture = TestFixture::new();
2117        let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
2118
2119        let (graph, config) =
2120            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2121
2122        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2123        // No agents means nothing gets installed
2124        assert_eq!(target.items.len(), 0);
2125    }
2126}