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