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