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