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