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