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