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::HashSet;
12use std::path::Path;
13use std::path::PathBuf;
14
15use crate::config::{Config, EffectiveConfig, LocalConfig, Settings};
16use crate::diagnostic::{Diagnostic, DiagnosticCollector};
17use crate::discover;
18use crate::error::MarsError;
19use crate::fs::FileLock;
20use crate::hash;
21use crate::lock::LockFile;
22use crate::lock::{ItemId, ItemKind};
23use crate::resolve::{ResolveOptions, ResolvedGraph};
24use crate::source::GlobalCache;
25use crate::sync::apply::ApplyResult;
26pub use crate::sync::apply::SyncOptions;
27use crate::sync::target::{RenameAction, TargetItem, TargetState};
28use crate::types::{
29    ContentHash, DestPath, MarsContext, Materialization, SourceId, SourceName, SourceOrigin,
30};
31use crate::validate::ValidationWarning;
32
33// Re-export mutation types for public API compatibility.
34pub use crate::sync::mutation::{ConfigMutation, DependencyUpsertChange, apply_config_mutation};
35
36/// Report from a completed sync operation.
37#[derive(Debug)]
38pub struct SyncReport {
39    pub applied: ApplyResult,
40    pub pruned: Vec<apply::ActionOutcome>,
41    pub diagnostics: Vec<Diagnostic>,
42    pub dependency_changes: Vec<DependencyUpsertChange>,
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 sources.
76    Maximize { targets: HashSet<SourceName> },
77}
78
79// ---------------------------------------------------------------------------
80// Pipeline phase structs — typed handoffs between pipeline stages.
81// Phase functions consume prior state by value (move semantics, no cloning).
82// ---------------------------------------------------------------------------
83
84/// Phase 1: Load and validate configuration under sync lock.
85pub struct LoadedConfig {
86    pub config: Config,
87    pub local: LocalConfig,
88    pub effective: EffectiveConfig,
89    pub old_lock: LockFile,
90    pub dependency_changes: Vec<DependencyUpsertChange>,
91    pub _sync_lock: FileLock,
92}
93
94/// Phase 2: Resolved dependency graph.
95pub struct ResolvedState {
96    pub loaded: LoadedConfig,
97    pub graph: ResolvedGraph,
98    pub model_aliases: indexmap::IndexMap<String, crate::models::ModelAlias>,
99}
100
101/// Phase 3: Desired target state after discovery + filtering.
102pub struct TargetedState {
103    pub resolved: ResolvedState,
104    pub target: TargetState,
105    pub renames: Vec<RenameAction>,
106    pub warnings: Vec<ValidationWarning>,
107}
108
109/// Phase 4: Diff + plan ready for execution.
110pub struct PlannedState {
111    pub targeted: TargetedState,
112    pub plan: plan::SyncPlan,
113}
114
115/// Phase 5: Applied results.
116pub struct AppliedState {
117    pub planned: PlannedState,
118    pub applied: ApplyResult,
119}
120
121/// Phase 6: Target sync results.
122pub struct SyncedState {
123    pub applied: AppliedState,
124    pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
125}
126
127/// Execute the unified sync pipeline.
128///
129/// Orchestrates phase functions, each consuming the prior phase's output struct.
130pub fn execute(ctx: &MarsContext, request: &SyncRequest) -> Result<SyncReport, MarsError> {
131    validate_request(request)?;
132    let mut diag = DiagnosticCollector::new();
133    let loaded = load_config(ctx, request, &mut diag)?;
134    let resolved = resolve_graph(ctx, loaded, request, &mut diag)?;
135    let targeted = build_target(ctx, resolved, request, &mut diag)?;
136    let planned = create_plan(ctx, targeted, request, &mut diag)?;
137    if request.options.frozen {
138        check_frozen_gate(&planned)?;
139    }
140    let applied = apply_plan(ctx, planned, request)?;
141    let synced = sync_targets(ctx, applied, request, &mut diag);
142    let report = finalize(ctx, synced, request, &mut diag)?;
143    Ok(report)
144}
145
146// ---------------------------------------------------------------------------
147// Phase functions
148// ---------------------------------------------------------------------------
149
150/// Phase 1: Acquire sync lock, load config, apply mutations, merge effective config,
151/// and load the existing lock file.
152fn load_config(
153    ctx: &MarsContext,
154    request: &SyncRequest,
155    diag: &mut DiagnosticCollector,
156) -> Result<LoadedConfig, MarsError> {
157    let project_root = &ctx.project_root;
158    let mars_dir = project_root.join(".mars");
159
160    std::fs::create_dir_all(mars_dir.join("cache"))?;
161
162    // Acquire sync lock before any config reads/mutations.
163    let lock_path = mars_dir.join("sync.lock");
164    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
165
166    // Load config under lock (auto-init when mutating and missing).
167    let mut config = match crate::config::load(project_root) {
168        Ok(config) => config,
169        Err(err) if mutation::is_config_not_found(&err) && request.mutation.is_some() => Config {
170            settings: Settings::default(),
171            ..Config::default()
172        },
173        Err(err) => return Err(err),
174    };
175
176    // Apply config mutation.
177    let dependency_changes = if let Some(m) = &request.mutation {
178        mutation::apply_mutation(&mut config, m)?
179    } else {
180        Vec::new()
181    };
182
183    // Load/mutate local overrides under the same lock.
184    let mut local = crate::config::load_local(project_root)?;
185    if let Some(m) = &request.mutation {
186        mutation::apply_local_mutation(&mut local, m);
187    }
188
189    // Build effective config.
190    let (effective, config_diagnostics) =
191        crate::config::merge_with_root(config.clone(), local.clone(), project_root)?;
192    diag.extend(config_diagnostics);
193
194    // Load existing lock file.
195    let old_lock = crate::lock::load(project_root)?;
196
197    Ok(LoadedConfig {
198        config,
199        local,
200        effective,
201        old_lock,
202        dependency_changes,
203        _sync_lock,
204    })
205}
206
207/// Phase 2: Validate upgrade targets, resolve the dependency graph.
208fn resolve_graph(
209    ctx: &MarsContext,
210    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 {
218        cache: &cache,
219        project_root: &ctx.project_root,
220    };
221    let resolve_options = to_resolve_options(&request.resolution, request.options.frozen);
222    let graph = crate::resolve::resolve(
223        &loaded.effective,
224        &source_provider,
225        Some(&loaded.old_lock),
226        &resolve_options,
227        diag,
228    )?;
229
230    // Merge model config from dependency tree
231    let dep_models: Vec<crate::models::ResolvedDepModels> = graph
232        .order
233        .iter()
234        .filter_map(|name| {
235            let node = graph.nodes.get(name)?;
236            let manifest = node.manifest.as_ref()?;
237            if manifest.models.is_empty() {
238                return None;
239            }
240            Some(crate::models::ResolvedDepModels {
241                source_name: name.to_string(),
242                models: manifest.models.clone(),
243            })
244        })
245        .collect();
246    let model_aliases = crate::models::merge_model_config(&loaded.config.models, &dep_models, diag);
247
248    Ok(ResolvedState {
249        loaded,
250        graph,
251        model_aliases,
252    })
253}
254
255/// Phase 3: Build target state, handle collisions, rewrite frontmatter refs, validate.
256fn build_target(
257    ctx: &MarsContext,
258    resolved: ResolvedState,
259    _request: &SyncRequest,
260    diag: &mut DiagnosticCollector,
261) -> Result<TargetedState, MarsError> {
262    // Use .mars/ as the canonical content root for diff/collision checks.
263    let mars_dir = ctx.project_root.join(".mars");
264    let managed_root = &mars_dir;
265
266    // Build target state from resolved graph.
267    let (mut target_state, renames) =
268        target::build_with_collisions(&resolved.graph, &resolved.loaded.effective)?;
269
270    if resolved.loaded.config.package.is_some() {
271        let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
272        let local_source_id = SourceId::Path {
273            canonical: ctx
274                .project_root
275                .canonicalize()
276                .unwrap_or_else(|_| ctx.project_root.clone()),
277        };
278
279        let local_items =
280            discover::discover_source(&ctx.project_root, Some(local_source_name.as_str()))?;
281        for item in local_items {
282            let source_path = ctx.project_root.join(&item.source_path);
283            let is_flat_skill =
284                item.id.kind == ItemKind::Skill && item.source_path == Path::new(".");
285            let source_hash = if is_flat_skill {
286                ContentHash::from(hash::compute_skill_hash_filtered(
287                    &source_path,
288                    crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
289                )?)
290            } else {
291                ContentHash::from(hash::compute_hash(&source_path, item.id.kind)?)
292            };
293            let dest_path = default_dest_path(item.id.kind, item.id.name.as_str());
294
295            if let Some(existing) = target_state.items.shift_remove(&dest_path) {
296                diag.warn(
297                    "local-shadow",
298                    format!(
299                        "local {} `{}` shadows dependency `{}` {} `{}`",
300                        item.id.kind,
301                        item.id.name,
302                        existing.source_name,
303                        existing.id.kind,
304                        existing.id.name
305                    ),
306                );
307            }
308
309            let disk_path = managed_root.join(dest_path.as_path());
310            if !resolved.loaded.old_lock.items.contains_key(&dest_path)
311                && disk_path.symlink_metadata().is_ok()
312            {
313                diag.warn(
314                    "unmanaged-collision",
315                    format!(
316                        "local {} `{}` collides with unmanaged path `{}` — leaving existing content untouched",
317                        item.id.kind, item.id.name, dest_path
318                    ),
319                );
320                continue;
321            }
322
323            target_state.items.insert(
324                dest_path.clone(),
325                TargetItem {
326                    id: ItemId {
327                        kind: item.id.kind,
328                        name: item.id.name.clone(),
329                    },
330                    source_name: local_source_name.clone(),
331                    origin: SourceOrigin::LocalPackage,
332                    materialization: Materialization::Symlink {
333                        source_abs: source_path.clone(),
334                    },
335                    source_id: local_source_id.clone(),
336                    source_path,
337                    dest_path,
338                    source_hash,
339                    is_flat_skill,
340                    rewritten_content: None,
341                },
342            );
343        }
344    }
345
346    // Handle collisions + rewrite frontmatter refs.
347    if !renames.is_empty() {
348        let rewrite_warnings =
349            target::rewrite_skill_refs(&mut target_state, &renames, &resolved.graph)?;
350        for w in &rewrite_warnings {
351            diag.warn("rewrite-warning", w.to_string());
352        }
353    }
354
355    // Validate skill references.
356    let warnings = validate_skill_refs(managed_root, &target_state);
357
358    // Prevent managed installs from overwriting unmanaged files.
359    let unmanaged_collisions =
360        target::check_unmanaged_collisions(managed_root, &resolved.loaded.old_lock, &target_state);
361    for collision in &unmanaged_collisions {
362        diag.warn(
363            "unmanaged-collision",
364            format!(
365                "source `{}` collides with unmanaged path `{}` — leaving existing content untouched",
366                collision.source_name, collision.path
367            ),
368        );
369        target_state.items.shift_remove(&collision.path);
370    }
371
372    Ok(TargetedState {
373        resolved,
374        target: target_state,
375        renames,
376        warnings,
377    })
378}
379
380/// Phase 4: Compute diff, create plan.
381fn create_plan(
382    ctx: &MarsContext,
383    targeted: TargetedState,
384    request: &SyncRequest,
385    _diag: &mut DiagnosticCollector,
386) -> Result<PlannedState, MarsError> {
387    // Diff against .mars/ canonical store.
388    let mars_dir = ctx.project_root.join(".mars");
389    let managed_root = &mars_dir;
390    let cache_bases_dir = mars_dir.join("cache").join("bases");
391
392    // Compute diff.
393    let sync_diff = diff::compute(
394        managed_root,
395        &targeted.resolved.loaded.old_lock,
396        &targeted.target,
397        request.options.force,
398    )?;
399
400    // Create plan.
401    let sync_plan = plan::create(&sync_diff, &request.options, &cache_bases_dir);
402
403    Ok(PlannedState {
404        targeted,
405        plan: sync_plan,
406    })
407}
408
409/// Check that a frozen sync has no pending changes.
410fn check_frozen_gate(planned: &PlannedState) -> Result<(), MarsError> {
411    let has_changes = planned.plan.actions.iter().any(|a| {
412        !matches!(
413            a,
414            plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
415        )
416    });
417    if has_changes {
418        return Err(MarsError::FrozenViolation {
419            message: "lock file would change but --frozen is set".into(),
420        });
421    }
422    Ok(())
423}
424
425/// Phase 5: Persist config if mutated, apply plan to .mars/ canonical store.
426fn apply_plan(
427    ctx: &MarsContext,
428    planned: PlannedState,
429    request: &SyncRequest,
430) -> Result<AppliedState, MarsError> {
431    let project_root = &ctx.project_root;
432    let mars_dir = project_root.join(".mars");
433    let cache_bases_dir = mars_dir.join("cache").join("bases");
434
435    let has_mutation = request.mutation.is_some();
436
437    // Persist config/local only after validation gate and before apply.
438    if has_mutation && !request.options.dry_run {
439        match &request.mutation {
440            Some(ConfigMutation::SetOverride { .. } | ConfigMutation::ClearOverride { .. }) => {
441                crate::config::save_local(project_root, &planned.targeted.resolved.loaded.local)?;
442            }
443            Some(
444                ConfigMutation::UpsertDependency { .. }
445                | ConfigMutation::BatchUpsert(..)
446                | ConfigMutation::RemoveDependency { .. }
447                | ConfigMutation::SetRename { .. },
448            ) => {
449                crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
450            }
451            None => {}
452        }
453    }
454
455    // Apply plan to .mars/ canonical store (D25).
456    // Content is written to .mars/agents/ and .mars/skills/, then
457    // sync_targets() copies to all managed target directories.
458    let applied = apply::execute(&mars_dir, &planned.plan, &request.options, &cache_bases_dir)?;
459
460    Ok(AppliedState { planned, applied })
461}
462
463/// Phase 6: Sync managed targets from .mars/ canonical store.
464///
465/// Copies content from .mars/ to all configured target directories.
466/// Non-fatal — target sync errors are recorded as diagnostics.
467/// Lock is written regardless of target sync outcome (D21).
468fn sync_targets(
469    ctx: &MarsContext,
470    applied: AppliedState,
471    request: &SyncRequest,
472    diag: &mut DiagnosticCollector,
473) -> SyncedState {
474    if request.options.dry_run {
475        return SyncedState {
476            applied,
477            target_outcomes: Vec::new(),
478        };
479    }
480
481    let mars_dir = ctx.project_root.join(".mars");
482    let targets = applied
483        .planned
484        .targeted
485        .resolved
486        .loaded
487        .effective
488        .settings
489        .managed_targets();
490    let previous_managed_paths = applied
491        .planned
492        .targeted
493        .resolved
494        .loaded
495        .old_lock
496        .items
497        .keys()
498        .map(|dest_path| dest_path.as_path().to_path_buf())
499        .collect::<HashSet<PathBuf>>();
500
501    let target_outcomes = crate::target_sync::sync_managed_targets(
502        &ctx.project_root,
503        &mars_dir,
504        &targets,
505        &applied.applied.outcomes,
506        &previous_managed_paths,
507        request.options.force,
508        diag,
509    );
510
511    SyncedState {
512        applied,
513        target_outcomes,
514    }
515}
516
517/// Phase 7: Write lock file, construct SyncReport.
518///
519/// Lock is written regardless of target sync outcome (D21).
520fn finalize(
521    ctx: &MarsContext,
522    state: SyncedState,
523    request: &SyncRequest,
524    diag: &mut DiagnosticCollector,
525) -> Result<SyncReport, MarsError> {
526    let project_root = &ctx.project_root;
527    let old_lock = &state.applied.planned.targeted.resolved.loaded.old_lock;
528    let graph = &state.applied.planned.targeted.resolved.graph;
529
530    // Write lock file (D21 — regardless of target sync outcome).
531    if !request.options.dry_run {
532        let new_lock = crate::lock::build(graph, &state.applied.applied, old_lock)?;
533        crate::lock::write(project_root, &new_lock)?;
534
535        // Persist dependency-only model aliases so `mars models list` can load
536        // deps from cache, then overlay current consumer config without keeping
537        // stale consumer aliases from prior syncs.
538        let dep_models: Vec<crate::models::ResolvedDepModels> = graph
539            .order
540            .iter()
541            .filter_map(|name| {
542                let node = graph.nodes.get(name)?;
543                let manifest = node.manifest.as_ref()?;
544                if manifest.models.is_empty() {
545                    return None;
546                }
547                Some(crate::models::ResolvedDepModels {
548                    source_name: name.to_string(),
549                    models: manifest.models.clone(),
550                })
551            })
552            .collect();
553        let empty_consumer: indexmap::IndexMap<String, crate::models::ModelAlias> =
554            indexmap::IndexMap::new();
555        let mut ignored_diag = DiagnosticCollector::new();
556        let dep_model_aliases =
557            crate::models::merge_model_config(&empty_consumer, &dep_models, &mut ignored_diag);
558
559        // Best-effort models cache refresh: ensure the catalog covers any
560        // new aliases we're about to persist. Sync never aborts on refresh
561        // failure — warn and continue. Skip on dry_run (side-effect-free).
562        if !request.options.dry_run {
563            let mars_path = ctx.project_root.join(".mars");
564            let ttl = crate::models::load_models_cache_ttl(ctx);
565            let mode = crate::models::resolve_refresh_mode(request.options.no_refresh_models);
566            match crate::models::ensure_fresh(&mars_path, ttl, mode) {
567                Ok((_, crate::models::RefreshOutcome::StaleFallback { reason })) => {
568                    diag.warn(
569                        "models-cache-refresh",
570                        format!("using stale models cache: {reason}"),
571                    );
572                }
573                Ok((_, crate::models::RefreshOutcome::Offline)) => {}
574                Ok(_) => {}
575                Err(err) => {
576                    diag.warn(
577                        "models-cache-refresh",
578                        format!("failed to refresh models cache: {err}"),
579                    );
580                }
581            }
582        }
583
584        match serde_json::to_string_pretty(&dep_model_aliases) {
585            Ok(json) => {
586                let merged_path = ctx.project_root.join(".mars").join("models-merged.json");
587                if let Err(err) = crate::fs::atomic_write(&merged_path, json.as_bytes()) {
588                    diag.warn(
589                        "models-merge-write",
590                        format!("failed to write models-merged.json: {err}"),
591                    );
592                }
593            }
594            Err(err) => {
595                diag.warn(
596                    "models-merge-write",
597                    format!("failed to serialize merged model aliases: {err}"),
598                );
599            }
600        }
601    }
602
603    for w in &state.applied.planned.targeted.warnings {
604        match w {
605            ValidationWarning::MissingSkill {
606                agent,
607                skill_name,
608                suggestion,
609            } => {
610                let msg = match suggestion {
611                    Some(s) => format!(
612                        "agent `{}` references missing skill `{}` (did you mean `{}`?)",
613                        agent.name, skill_name, s
614                    ),
615                    None => {
616                        format!(
617                            "agent `{}` references missing skill `{}`",
618                            agent.name, skill_name
619                        )
620                    }
621                };
622                diag.warn("missing-skill", msg);
623            }
624        }
625    }
626    let dependency_changes = state
627        .applied
628        .planned
629        .targeted
630        .resolved
631        .loaded
632        .dependency_changes;
633
634    Ok(SyncReport {
635        applied: state.applied.applied,
636        pruned: Vec::new(),
637        diagnostics: diag.drain(),
638        dependency_changes,
639        target_outcomes: state.target_outcomes,
640        dry_run: request.options.dry_run,
641    })
642}
643
644fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
645    match kind {
646        ItemKind::Agent => DestPath::from(PathBuf::from("agents").join(format!("{name}.md"))),
647        ItemKind::Skill => DestPath::from(PathBuf::from("skills").join(name)),
648    }
649}
650
651fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
652    if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
653        return Err(MarsError::InvalidRequest {
654            message:
655                "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
656                    .to_string(),
657        });
658    }
659
660    if request.options.frozen && request.mutation.is_some() {
661        return Err(MarsError::InvalidRequest {
662            message:
663                "cannot modify config in --frozen mode (config change would require lock update)"
664                    .to_string(),
665        });
666    }
667
668    Ok(())
669}
670
671fn validate_targets(
672    resolution: &ResolutionMode,
673    effective: &EffectiveConfig,
674) -> Result<(), MarsError> {
675    if let ResolutionMode::Maximize { targets } = resolution {
676        for name in targets {
677            if !effective.dependencies.contains_key(name) {
678                return Err(MarsError::Source {
679                    source_name: name.to_string(),
680                    message: format!("dependency `{name}` not found in mars.toml"),
681                });
682            }
683        }
684    }
685
686    Ok(())
687}
688
689fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
690    match mode {
691        ResolutionMode::Normal => ResolveOptions {
692            frozen,
693            ..ResolveOptions::default()
694        },
695        ResolutionMode::Maximize { targets } => ResolveOptions {
696            maximize: true,
697            upgrade_targets: targets.clone(),
698            frozen,
699        },
700    }
701}
702
703/// Validate skill references: check that agents' `skills:` frontmatter entries
704/// reference skills that exist in the target state.
705fn validate_skill_refs(
706    install_target: &std::path::Path,
707    target: &target::TargetState,
708) -> Vec<ValidationWarning> {
709    use crate::lock::ItemKind;
710
711    // Collect available skill names
712    let available_skills: HashSet<String> = target
713        .items
714        .values()
715        .filter(|item| item.id.kind == ItemKind::Skill)
716        .map(|item| item.id.name.to_string())
717        .collect();
718
719    // Collect agents with their paths
720    let agents: Vec<(String, PathBuf)> = target
721        .items
722        .values()
723        .filter(|item| item.id.kind == ItemKind::Agent)
724        .map(|item| {
725            let disk_path = install_target.join(&item.dest_path);
726            // If the file exists on disk, use that (may have local edits).
727            // Otherwise, use the source path.
728            let path = if disk_path.exists() {
729                disk_path
730            } else {
731                item.source_path.clone()
732            };
733            (item.id.name.to_string(), path)
734        })
735        .collect();
736
737    crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use crate::config::*;
744    use crate::lock::{ItemKind, LockFile};
745    use crate::resolve::{ResolvedGraph, ResolvedNode};
746    use indexmap::IndexMap;
747    use std::fs;
748    use tempfile::TempDir;
749
750    /// Helper to set up a complete sync context with temp dirs.
751    struct TestFixture {
752        project_root: TempDir,
753        managed_root: PathBuf,
754        source_trees: Vec<TempDir>,
755    }
756
757    impl TestFixture {
758        fn new() -> Self {
759            let project_root = TempDir::new().unwrap();
760            let managed_root = project_root.path().join(".agents");
761            // Create .mars/cache directories
762            fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
763            TestFixture {
764                project_root,
765                managed_root,
766                source_trees: Vec::new(),
767            }
768        }
769
770        fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
771            let dir = TempDir::new().unwrap();
772            if !agents.is_empty() {
773                let agents_dir = dir.path().join("agents");
774                fs::create_dir_all(&agents_dir).unwrap();
775                for (name, content) in agents {
776                    fs::write(agents_dir.join(name), content).unwrap();
777                }
778            }
779            if !skills.is_empty() {
780                let skills_dir = dir.path().join("skills");
781                fs::create_dir_all(&skills_dir).unwrap();
782                for (name, content) in skills {
783                    let skill_dir = skills_dir.join(name);
784                    fs::create_dir_all(&skill_dir).unwrap();
785                    fs::write(skill_dir.join("SKILL.md"), content).unwrap();
786                }
787            }
788            self.source_trees.push(dir);
789            self.source_trees.len() - 1
790        }
791
792        fn project_root(&self) -> &std::path::Path {
793            self.project_root.path()
794        }
795
796        fn managed_root(&self) -> &std::path::Path {
797            &self.managed_root
798        }
799
800        fn tree_path(&self, idx: usize) -> PathBuf {
801            self.source_trees[idx].path().to_path_buf()
802        }
803    }
804
805    fn make_graph_config(
806        fixture: &TestFixture,
807        sources: Vec<(&str, usize, FilterMode)>,
808    ) -> (ResolvedGraph, EffectiveConfig) {
809        let mut nodes = IndexMap::new();
810        let mut order = Vec::new();
811        let mut config_dependencies = IndexMap::new();
812
813        for (name, tree_idx, filter) in sources {
814            let tree_path = fixture.tree_path(tree_idx);
815            nodes.insert(
816                name.into(),
817                ResolvedNode {
818                    source_name: name.into(),
819                    source_id: crate::types::SourceId::Path {
820                        canonical: tree_path.clone(),
821                    },
822                    resolved_ref: crate::source::ResolvedRef {
823                        source_name: name.into(),
824                        version: None,
825                        version_tag: None,
826                        commit: None,
827                        tree_path: tree_path.clone(),
828                    },
829                    manifest: None,
830                    deps: vec![],
831                },
832            );
833            order.push(name.into());
834
835            config_dependencies.insert(
836                name.into(),
837                EffectiveDependency {
838                    name: name.into(),
839                    id: crate::types::SourceId::Path {
840                        canonical: tree_path.clone(),
841                    },
842                    spec: SourceSpec::Path(tree_path),
843                    filter,
844                    rename: crate::types::RenameMap::new(),
845                    is_overridden: false,
846                    original_git: None,
847                },
848            );
849        }
850
851        (
852            ResolvedGraph {
853                nodes,
854                order,
855                id_index: std::collections::HashMap::new(),
856                filters: std::collections::HashMap::new(),
857            },
858            EffectiveConfig {
859                dependencies: config_dependencies,
860                settings: Settings::default(),
861            },
862        )
863    }
864
865    fn path_dependency_entry(path: &std::path::Path) -> DependencyEntry {
866        DependencyEntry {
867            url: None,
868            path: Some(path.to_path_buf()),
869            version: None,
870            filter: FilterConfig::default(),
871        }
872    }
873
874    #[test]
875    fn validate_request_rejects_frozen_with_maximize() {
876        let request = SyncRequest {
877            resolution: ResolutionMode::Maximize {
878                targets: HashSet::new(),
879            },
880            mutation: None,
881            options: SyncOptions {
882                force: false,
883                dry_run: false,
884                frozen: true,
885                no_refresh_models: false,
886            },
887        };
888
889        let err = validate_request(&request).unwrap_err();
890        assert!(matches!(err, MarsError::InvalidRequest { .. }));
891        assert!(err.to_string().contains("--frozen"));
892    }
893
894    #[test]
895    fn validate_request_rejects_frozen_with_mutation() {
896        let request = SyncRequest {
897            resolution: ResolutionMode::Normal,
898            mutation: Some(ConfigMutation::RemoveDependency {
899                name: "base".into(),
900            }),
901            options: SyncOptions {
902                force: false,
903                dry_run: false,
904                frozen: true,
905                no_refresh_models: false,
906            },
907        };
908
909        let err = validate_request(&request).unwrap_err();
910        assert!(matches!(err, MarsError::InvalidRequest { .. }));
911        assert!(err.to_string().contains("cannot modify config"));
912    }
913
914    #[test]
915    fn execute_auto_inits_config_for_mutation() {
916        let project_root = TempDir::new().unwrap();
917        let managed_root = project_root.path().join(".agents");
918        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
919        let source = TempDir::new().unwrap();
920        fs::create_dir_all(source.path().join("agents")).unwrap();
921        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
922
923        let request = SyncRequest {
924            resolution: ResolutionMode::Normal,
925            mutation: Some(ConfigMutation::UpsertDependency {
926                name: "base".into(),
927                entry: path_dependency_entry(source.path()),
928            }),
929            options: SyncOptions::default(),
930        };
931
932        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
933        let report = execute(&ctx, &request).unwrap();
934        assert!(!report.applied.outcomes.is_empty());
935        assert!(project_root.path().join("mars.toml").exists());
936
937        let saved = crate::config::load(project_root.path()).unwrap();
938        assert!(saved.dependencies.contains_key("base"));
939    }
940
941    #[test]
942    fn execute_dry_run_with_mutation_does_not_write_config() {
943        let project_root = TempDir::new().unwrap();
944        let managed_root = project_root.path().join(".agents");
945        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
946        crate::config::save(
947            project_root.path(),
948            &Config {
949                dependencies: IndexMap::new(),
950                settings: Settings::default(),
951                ..Config::default()
952            },
953        )
954        .unwrap();
955
956        let source = TempDir::new().unwrap();
957        fs::create_dir_all(source.path().join("agents")).unwrap();
958        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
959
960        let request = SyncRequest {
961            resolution: ResolutionMode::Normal,
962            mutation: Some(ConfigMutation::UpsertDependency {
963                name: "base".into(),
964                entry: path_dependency_entry(source.path()),
965            }),
966            options: SyncOptions {
967                force: false,
968                dry_run: true,
969                frozen: false,
970                no_refresh_models: false,
971            },
972        };
973
974        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
975        let report = execute(&ctx, &request).unwrap();
976        assert!(!report.applied.outcomes.is_empty());
977
978        let saved = crate::config::load(project_root.path()).unwrap();
979        assert!(!saved.dependencies.contains_key("base"));
980        assert!(!managed_root.join("agents/coder.md").exists());
981        assert!(!project_root.path().join("mars.lock").exists());
982    }
983
984    // === Integration tests for the pipeline stages ===
985
986    #[test]
987    fn full_pipeline_fresh_sync() {
988        let mut fixture = TestFixture::new();
989        let src_idx = fixture.add_source(
990            &[("coder.md", "# Coder agent")],
991            &[("planning", "# Planning skill")],
992        );
993
994        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
995
996        // Build target
997        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
998        assert!(renames.is_empty());
999        assert_eq!(target.items.len(), 2);
1000
1001        // Compute diff against empty lock
1002        let lock = LockFile::empty();
1003        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1004
1005        // All items should be Add
1006        assert_eq!(sync_diff.items.len(), 2);
1007        for entry in &sync_diff.items {
1008            assert!(matches!(entry, diff::DiffEntry::Add { .. }));
1009        }
1010
1011        // Create plan
1012        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1013        let options = SyncOptions {
1014            force: false,
1015            dry_run: false,
1016            frozen: false,
1017            no_refresh_models: false,
1018        };
1019        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1020        assert_eq!(sync_plan.actions.len(), 2);
1021        for action in &sync_plan.actions {
1022            assert!(matches!(action, plan::PlannedAction::Install { .. }));
1023        }
1024
1025        // Execute plan
1026        let result =
1027            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1028        assert_eq!(result.outcomes.len(), 2);
1029
1030        // Verify files were created
1031        assert!(fixture.managed_root().join("agents/coder.md").exists());
1032        assert!(
1033            fixture
1034                .managed_root()
1035                .join("skills/planning/SKILL.md")
1036                .exists()
1037        );
1038
1039        // Build lock
1040        let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1041        assert_eq!(new_lock.items.len(), 2);
1042        assert!(new_lock.items.contains_key("agents/coder.md"));
1043        assert!(new_lock.items.contains_key("skills/planning"));
1044    }
1045
1046    #[test]
1047    fn re_sync_no_changes() {
1048        let mut fixture = TestFixture::new();
1049        let content = "# Coder agent";
1050        let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1051
1052        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1053
1054        // First sync
1055        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1056        let lock = LockFile::empty();
1057        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1058        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1059        let options = SyncOptions {
1060            force: false,
1061            dry_run: false,
1062            frozen: false,
1063            no_refresh_models: false,
1064        };
1065        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1066        let result =
1067            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1068        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1069
1070        // Second sync with same content
1071        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1072        let sync_diff2 =
1073            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1074
1075        // All items should be Unchanged
1076        for entry in &sync_diff2.items {
1077            assert!(
1078                matches!(entry, diff::DiffEntry::Unchanged { .. }),
1079                "expected Unchanged, got {entry:?}"
1080            );
1081        }
1082
1083        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1084        for action in &sync_plan2.actions {
1085            assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1086        }
1087    }
1088
1089    #[test]
1090    fn source_update_detects_changes() {
1091        let mut fixture = TestFixture::new();
1092        let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1093
1094        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1095
1096        // First sync
1097        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1098        let lock = LockFile::empty();
1099        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1100        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1101        let options = SyncOptions {
1102            force: false,
1103            dry_run: false,
1104            frozen: false,
1105            no_refresh_models: false,
1106        };
1107        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1108        let result =
1109            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1110        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1111
1112        // Update source content
1113        let agents_dir = fixture.tree_path(src_idx).join("agents");
1114        fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1115
1116        // Rebuild target with updated content
1117        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1118        let sync_diff2 =
1119            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1120
1121        // Should detect an Update
1122        assert_eq!(sync_diff2.items.len(), 1);
1123        assert!(matches!(
1124            &sync_diff2.items[0],
1125            diff::DiffEntry::Update { .. }
1126        ));
1127    }
1128
1129    #[test]
1130    fn local_modification_preserved() {
1131        let mut fixture = TestFixture::new();
1132        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1133
1134        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1135
1136        // First sync
1137        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1138        let lock = LockFile::empty();
1139        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1140        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1141        let options = SyncOptions {
1142            force: false,
1143            dry_run: false,
1144            frozen: false,
1145            no_refresh_models: false,
1146        };
1147        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1148        let result =
1149            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1150        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1151
1152        // Locally modify the installed file
1153        fs::write(
1154            fixture.managed_root().join("agents/coder.md"),
1155            "# Locally modified",
1156        )
1157        .unwrap();
1158
1159        // Re-sync (source unchanged)
1160        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1161        let sync_diff2 =
1162            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1163
1164        // Should detect LocalModified
1165        assert_eq!(sync_diff2.items.len(), 1);
1166        assert!(matches!(
1167            &sync_diff2.items[0],
1168            diff::DiffEntry::LocalModified { .. }
1169        ));
1170
1171        // Plan should KeepLocal
1172        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1173        assert!(matches!(
1174            &sync_plan2.actions[0],
1175            plan::PlannedAction::KeepLocal { .. }
1176        ));
1177    }
1178
1179    #[test]
1180    fn force_overwrites_local_modifications() {
1181        let mut fixture = TestFixture::new();
1182        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1183
1184        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1185
1186        // First sync
1187        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1188        let lock = LockFile::empty();
1189        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1190        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1191        let options = SyncOptions {
1192            force: false,
1193            dry_run: false,
1194            frozen: false,
1195            no_refresh_models: false,
1196        };
1197        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1198        let result =
1199            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1200        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1201
1202        // Locally modify the installed file
1203        fs::write(
1204            fixture.managed_root().join("agents/coder.md"),
1205            "# Locally modified",
1206        )
1207        .unwrap();
1208
1209        // Update source too (triggers conflict)
1210        let agents_dir = fixture.tree_path(src_idx).join("agents");
1211        fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1212
1213        // Re-sync with --force
1214        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1215        let sync_diff2 =
1216            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1217
1218        let force_options = SyncOptions {
1219            force: true,
1220            dry_run: false,
1221            frozen: false,
1222            no_refresh_models: false,
1223        };
1224        let sync_plan2 = plan::create(&sync_diff2, &force_options, &cache_dir);
1225        assert!(matches!(
1226            &sync_plan2.actions[0],
1227            plan::PlannedAction::Overwrite { .. }
1228        ));
1229
1230        let result2 = apply::execute(
1231            fixture.managed_root(),
1232            &sync_plan2,
1233            &force_options,
1234            &cache_dir,
1235        )
1236        .unwrap();
1237        assert!(matches!(
1238            result2.outcomes[0].action,
1239            apply::ActionTaken::Updated
1240        ));
1241
1242        // File should have upstream content
1243        let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
1244        assert_eq!(content, "# Upstream update");
1245    }
1246
1247    #[test]
1248    fn orphan_removed_when_source_drops_item() {
1249        let mut fixture = TestFixture::new();
1250        let src_idx = fixture.add_source(
1251            &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
1252            &[],
1253        );
1254
1255        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1256
1257        // First sync — install both
1258        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1259        let lock = LockFile::empty();
1260        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1261        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1262        let options = SyncOptions {
1263            force: false,
1264            dry_run: false,
1265            frozen: false,
1266            no_refresh_models: false,
1267        };
1268        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1269        let result =
1270            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1271        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1272
1273        assert!(fixture.managed_root().join("agents/coder.md").exists());
1274        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1275
1276        // Remove reviewer from source
1277        fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
1278
1279        // Re-sync
1280        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1281        let sync_diff2 =
1282            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1283
1284        // Should have one Unchanged and one Orphan
1285        let orphan_count = sync_diff2
1286            .items
1287            .iter()
1288            .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1289            .count();
1290        assert_eq!(orphan_count, 1);
1291
1292        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1293        let result2 =
1294            apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
1295
1296        // Reviewer should be removed
1297        assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
1298        // Coder should still be there
1299        assert!(fixture.managed_root().join("agents/coder.md").exists());
1300
1301        // Check remove outcome
1302        let removed = result2
1303            .outcomes
1304            .iter()
1305            .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1306        assert!(removed);
1307    }
1308
1309    #[test]
1310    fn dry_run_produces_plan_without_changes() {
1311        let mut fixture = TestFixture::new();
1312        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1313
1314        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1315
1316        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1317        let lock = LockFile::empty();
1318        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1319
1320        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1321        let dry_options = SyncOptions {
1322            force: false,
1323            dry_run: true,
1324            frozen: false,
1325            no_refresh_models: false,
1326        };
1327
1328        let sync_plan = plan::create(&sync_diff, &dry_options, &cache_dir);
1329        assert!(!sync_plan.actions.is_empty());
1330
1331        // Execute in dry-run mode
1332        let result =
1333            apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1334        assert!(!result.outcomes.is_empty());
1335
1336        // No files should have been created
1337        assert!(!fixture.managed_root().join("agents/coder.md").exists());
1338    }
1339
1340    #[test]
1341    fn lock_written_after_apply() {
1342        let mut fixture = TestFixture::new();
1343        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1344
1345        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1346
1347        // Full pipeline minus actual sync() (which needs real config files)
1348        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1349        let lock = LockFile::empty();
1350        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1351        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1352        let options = SyncOptions {
1353            force: false,
1354            dry_run: false,
1355            frozen: false,
1356            no_refresh_models: false,
1357        };
1358        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1359        let result =
1360            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1361
1362        let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1363        crate::lock::write(fixture.project_root(), &new_lock).unwrap();
1364
1365        // Verify lock file exists and is valid
1366        let reloaded = crate::lock::load(fixture.project_root()).unwrap();
1367        assert_eq!(reloaded.items.len(), 1);
1368        assert!(reloaded.items.contains_key("agents/coder.md"));
1369
1370        let item = &reloaded.items["agents/coder.md"];
1371        assert_eq!(item.kind, ItemKind::Agent);
1372        assert!(!item.source_checksum.is_empty());
1373        assert!(!item.installed_checksum.is_empty());
1374    }
1375
1376    #[test]
1377    fn two_sources_no_collision() {
1378        let mut fixture = TestFixture::new();
1379        let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
1380        let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
1381
1382        let (graph, config) = make_graph_config(
1383            &fixture,
1384            vec![
1385                ("source-a", src_a, FilterMode::All),
1386                ("source-b", src_b, FilterMode::All),
1387            ],
1388        );
1389
1390        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1391        assert!(renames.is_empty());
1392        assert_eq!(target.items.len(), 2);
1393
1394        let lock = LockFile::empty();
1395        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1396        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1397        let options = SyncOptions {
1398            force: false,
1399            dry_run: false,
1400            frozen: false,
1401            no_refresh_models: false,
1402        };
1403        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1404        let result =
1405            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1406
1407        assert!(fixture.managed_root().join("agents/coder.md").exists());
1408        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1409        assert_eq!(result.outcomes.len(), 2);
1410    }
1411
1412    // === Tests for OnlySkills / OnlyAgents filter in pipeline ===
1413
1414    #[test]
1415    fn pipeline_only_skills_filter() {
1416        let mut fixture = TestFixture::new();
1417        let src_idx = fixture.add_source(
1418            &[("coder.md", "# Coder agent")],
1419            &[("planning", "# Planning skill")],
1420        );
1421
1422        let (graph, config) =
1423            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
1424
1425        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1426        // Should only have the skill, not the agent
1427        assert_eq!(target.items.len(), 1);
1428        assert!(target.items.contains_key("skills/planning"));
1429    }
1430
1431    #[test]
1432    fn pipeline_only_agents_filter() {
1433        let mut fixture = TestFixture::new();
1434        // Agent with a skill dependency in frontmatter
1435        let agent_content = "---\nskills:\n  - planning\n---\n# Coder agent";
1436        let src_idx = fixture.add_source(
1437            &[("coder.md", agent_content)],
1438            &[
1439                ("planning", "# Planning skill"),
1440                ("standalone", "# Standalone skill"),
1441            ],
1442        );
1443
1444        let (graph, config) =
1445            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1446
1447        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1448        // Should have the agent + its transitive skill dep, but NOT standalone
1449        assert_eq!(target.items.len(), 2);
1450        assert!(target.items.contains_key("agents/coder.md"));
1451        assert!(target.items.contains_key("skills/planning"));
1452        assert!(!target.items.contains_key("skills/standalone"));
1453    }
1454
1455    #[test]
1456    fn pipeline_only_agents_no_agents_source() {
1457        let mut fixture = TestFixture::new();
1458        let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
1459
1460        let (graph, config) =
1461            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1462
1463        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1464        // No agents means nothing gets installed
1465        assert_eq!(target.items.len(), 0);
1466    }
1467}