Skip to main content

mars_agents/sync/
mod.rs

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