Skip to main content

mars_agents/sync/
mod.rs

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