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