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        match serde_json::to_string_pretty(&dep_model_aliases) {
560            Ok(json) => {
561                let merged_path = ctx.project_root.join(".mars").join("models-merged.json");
562                if let Err(err) = crate::fs::atomic_write(&merged_path, json.as_bytes()) {
563                    diag.warn(
564                        "models-merge-write",
565                        format!("failed to write models-merged.json: {err}"),
566                    );
567                }
568            }
569            Err(err) => {
570                diag.warn(
571                    "models-merge-write",
572                    format!("failed to serialize merged model aliases: {err}"),
573                );
574            }
575        }
576    }
577
578    for w in &state.applied.planned.targeted.warnings {
579        match w {
580            ValidationWarning::MissingSkill {
581                agent,
582                skill_name,
583                suggestion,
584            } => {
585                let msg = match suggestion {
586                    Some(s) => format!(
587                        "agent `{}` references missing skill `{}` (did you mean `{}`?)",
588                        agent.name, skill_name, s
589                    ),
590                    None => {
591                        format!(
592                            "agent `{}` references missing skill `{}`",
593                            agent.name, skill_name
594                        )
595                    }
596                };
597                diag.warn("missing-skill", msg);
598            }
599        }
600    }
601    let dependency_changes = state
602        .applied
603        .planned
604        .targeted
605        .resolved
606        .loaded
607        .dependency_changes;
608
609    Ok(SyncReport {
610        applied: state.applied.applied,
611        pruned: Vec::new(),
612        diagnostics: diag.drain(),
613        dependency_changes,
614        target_outcomes: state.target_outcomes,
615        dry_run: request.options.dry_run,
616    })
617}
618
619fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
620    match kind {
621        ItemKind::Agent => DestPath::from(PathBuf::from("agents").join(format!("{name}.md"))),
622        ItemKind::Skill => DestPath::from(PathBuf::from("skills").join(name)),
623    }
624}
625
626fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
627    if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
628        return Err(MarsError::InvalidRequest {
629            message:
630                "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
631                    .to_string(),
632        });
633    }
634
635    if request.options.frozen && request.mutation.is_some() {
636        return Err(MarsError::InvalidRequest {
637            message:
638                "cannot modify config in --frozen mode (config change would require lock update)"
639                    .to_string(),
640        });
641    }
642
643    Ok(())
644}
645
646fn validate_targets(
647    resolution: &ResolutionMode,
648    effective: &EffectiveConfig,
649) -> Result<(), MarsError> {
650    if let ResolutionMode::Maximize { targets } = resolution {
651        for name in targets {
652            if !effective.dependencies.contains_key(name) {
653                return Err(MarsError::Source {
654                    source_name: name.to_string(),
655                    message: format!("dependency `{name}` not found in mars.toml"),
656                });
657            }
658        }
659    }
660
661    Ok(())
662}
663
664fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
665    match mode {
666        ResolutionMode::Normal => ResolveOptions {
667            frozen,
668            ..ResolveOptions::default()
669        },
670        ResolutionMode::Maximize { targets } => ResolveOptions {
671            maximize: true,
672            upgrade_targets: targets.clone(),
673            frozen,
674        },
675    }
676}
677
678/// Validate skill references: check that agents' `skills:` frontmatter entries
679/// reference skills that exist in the target state.
680fn validate_skill_refs(
681    install_target: &std::path::Path,
682    target: &target::TargetState,
683) -> Vec<ValidationWarning> {
684    use crate::lock::ItemKind;
685
686    // Collect available skill names
687    let available_skills: HashSet<String> = target
688        .items
689        .values()
690        .filter(|item| item.id.kind == ItemKind::Skill)
691        .map(|item| item.id.name.to_string())
692        .collect();
693
694    // Collect agents with their paths
695    let agents: Vec<(String, PathBuf)> = target
696        .items
697        .values()
698        .filter(|item| item.id.kind == ItemKind::Agent)
699        .map(|item| {
700            let disk_path = install_target.join(&item.dest_path);
701            // If the file exists on disk, use that (may have local edits).
702            // Otherwise, use the source path.
703            let path = if disk_path.exists() {
704                disk_path
705            } else {
706                item.source_path.clone()
707            };
708            (item.id.name.to_string(), path)
709        })
710        .collect();
711
712    crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use crate::config::*;
719    use crate::lock::{ItemKind, LockFile};
720    use crate::resolve::{ResolvedGraph, ResolvedNode};
721    use indexmap::IndexMap;
722    use std::fs;
723    use tempfile::TempDir;
724
725    /// Helper to set up a complete sync context with temp dirs.
726    struct TestFixture {
727        project_root: TempDir,
728        managed_root: PathBuf,
729        source_trees: Vec<TempDir>,
730    }
731
732    impl TestFixture {
733        fn new() -> Self {
734            let project_root = TempDir::new().unwrap();
735            let managed_root = project_root.path().join(".agents");
736            // Create .mars/cache directories
737            fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
738            TestFixture {
739                project_root,
740                managed_root,
741                source_trees: Vec::new(),
742            }
743        }
744
745        fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
746            let dir = TempDir::new().unwrap();
747            if !agents.is_empty() {
748                let agents_dir = dir.path().join("agents");
749                fs::create_dir_all(&agents_dir).unwrap();
750                for (name, content) in agents {
751                    fs::write(agents_dir.join(name), content).unwrap();
752                }
753            }
754            if !skills.is_empty() {
755                let skills_dir = dir.path().join("skills");
756                fs::create_dir_all(&skills_dir).unwrap();
757                for (name, content) in skills {
758                    let skill_dir = skills_dir.join(name);
759                    fs::create_dir_all(&skill_dir).unwrap();
760                    fs::write(skill_dir.join("SKILL.md"), content).unwrap();
761                }
762            }
763            self.source_trees.push(dir);
764            self.source_trees.len() - 1
765        }
766
767        fn project_root(&self) -> &std::path::Path {
768            self.project_root.path()
769        }
770
771        fn managed_root(&self) -> &std::path::Path {
772            &self.managed_root
773        }
774
775        fn tree_path(&self, idx: usize) -> PathBuf {
776            self.source_trees[idx].path().to_path_buf()
777        }
778    }
779
780    fn make_graph_config(
781        fixture: &TestFixture,
782        sources: Vec<(&str, usize, FilterMode)>,
783    ) -> (ResolvedGraph, EffectiveConfig) {
784        let mut nodes = IndexMap::new();
785        let mut order = Vec::new();
786        let mut config_dependencies = IndexMap::new();
787
788        for (name, tree_idx, filter) in sources {
789            let tree_path = fixture.tree_path(tree_idx);
790            nodes.insert(
791                name.into(),
792                ResolvedNode {
793                    source_name: name.into(),
794                    source_id: crate::types::SourceId::Path {
795                        canonical: tree_path.clone(),
796                    },
797                    resolved_ref: crate::source::ResolvedRef {
798                        source_name: name.into(),
799                        version: None,
800                        version_tag: None,
801                        commit: None,
802                        tree_path: tree_path.clone(),
803                    },
804                    manifest: None,
805                    deps: vec![],
806                },
807            );
808            order.push(name.into());
809
810            config_dependencies.insert(
811                name.into(),
812                EffectiveDependency {
813                    name: name.into(),
814                    id: crate::types::SourceId::Path {
815                        canonical: tree_path.clone(),
816                    },
817                    spec: SourceSpec::Path(tree_path),
818                    filter,
819                    rename: crate::types::RenameMap::new(),
820                    is_overridden: false,
821                    original_git: None,
822                },
823            );
824        }
825
826        (
827            ResolvedGraph {
828                nodes,
829                order,
830                id_index: std::collections::HashMap::new(),
831            },
832            EffectiveConfig {
833                dependencies: config_dependencies,
834                settings: Settings::default(),
835            },
836        )
837    }
838
839    fn path_dependency_entry(path: &std::path::Path) -> DependencyEntry {
840        DependencyEntry {
841            url: None,
842            path: Some(path.to_path_buf()),
843            version: None,
844            filter: FilterConfig::default(),
845        }
846    }
847
848    #[test]
849    fn validate_request_rejects_frozen_with_maximize() {
850        let request = SyncRequest {
851            resolution: ResolutionMode::Maximize {
852                targets: HashSet::new(),
853            },
854            mutation: None,
855            options: SyncOptions {
856                force: false,
857                dry_run: false,
858                frozen: true,
859            },
860        };
861
862        let err = validate_request(&request).unwrap_err();
863        assert!(matches!(err, MarsError::InvalidRequest { .. }));
864        assert!(err.to_string().contains("--frozen"));
865    }
866
867    #[test]
868    fn validate_request_rejects_frozen_with_mutation() {
869        let request = SyncRequest {
870            resolution: ResolutionMode::Normal,
871            mutation: Some(ConfigMutation::RemoveDependency {
872                name: "base".into(),
873            }),
874            options: SyncOptions {
875                force: false,
876                dry_run: false,
877                frozen: true,
878            },
879        };
880
881        let err = validate_request(&request).unwrap_err();
882        assert!(matches!(err, MarsError::InvalidRequest { .. }));
883        assert!(err.to_string().contains("cannot modify config"));
884    }
885
886    #[test]
887    fn execute_auto_inits_config_for_mutation() {
888        let project_root = TempDir::new().unwrap();
889        let managed_root = project_root.path().join(".agents");
890        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
891        let source = TempDir::new().unwrap();
892        fs::create_dir_all(source.path().join("agents")).unwrap();
893        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
894
895        let request = SyncRequest {
896            resolution: ResolutionMode::Normal,
897            mutation: Some(ConfigMutation::UpsertDependency {
898                name: "base".into(),
899                entry: path_dependency_entry(source.path()),
900            }),
901            options: SyncOptions::default(),
902        };
903
904        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
905        let report = execute(&ctx, &request).unwrap();
906        assert!(!report.applied.outcomes.is_empty());
907        assert!(project_root.path().join("mars.toml").exists());
908
909        let saved = crate::config::load(project_root.path()).unwrap();
910        assert!(saved.dependencies.contains_key("base"));
911    }
912
913    #[test]
914    fn execute_dry_run_with_mutation_does_not_write_config() {
915        let project_root = TempDir::new().unwrap();
916        let managed_root = project_root.path().join(".agents");
917        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
918        crate::config::save(
919            project_root.path(),
920            &Config {
921                dependencies: IndexMap::new(),
922                settings: Settings::default(),
923                ..Config::default()
924            },
925        )
926        .unwrap();
927
928        let source = TempDir::new().unwrap();
929        fs::create_dir_all(source.path().join("agents")).unwrap();
930        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
931
932        let request = SyncRequest {
933            resolution: ResolutionMode::Normal,
934            mutation: Some(ConfigMutation::UpsertDependency {
935                name: "base".into(),
936                entry: path_dependency_entry(source.path()),
937            }),
938            options: SyncOptions {
939                force: false,
940                dry_run: true,
941                frozen: false,
942            },
943        };
944
945        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
946        let report = execute(&ctx, &request).unwrap();
947        assert!(!report.applied.outcomes.is_empty());
948
949        let saved = crate::config::load(project_root.path()).unwrap();
950        assert!(!saved.dependencies.contains_key("base"));
951        assert!(!managed_root.join("agents/coder.md").exists());
952        assert!(!project_root.path().join("mars.lock").exists());
953    }
954
955    // === Integration tests for the pipeline stages ===
956
957    #[test]
958    fn full_pipeline_fresh_sync() {
959        let mut fixture = TestFixture::new();
960        let src_idx = fixture.add_source(
961            &[("coder.md", "# Coder agent")],
962            &[("planning", "# Planning skill")],
963        );
964
965        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
966
967        // Build target
968        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
969        assert!(renames.is_empty());
970        assert_eq!(target.items.len(), 2);
971
972        // Compute diff against empty lock
973        let lock = LockFile::empty();
974        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
975
976        // All items should be Add
977        assert_eq!(sync_diff.items.len(), 2);
978        for entry in &sync_diff.items {
979            assert!(matches!(entry, diff::DiffEntry::Add { .. }));
980        }
981
982        // Create plan
983        let cache_dir = fixture.project_root().join(".mars/cache/bases");
984        let options = SyncOptions {
985            force: false,
986            dry_run: false,
987            frozen: false,
988        };
989        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
990        assert_eq!(sync_plan.actions.len(), 2);
991        for action in &sync_plan.actions {
992            assert!(matches!(action, plan::PlannedAction::Install { .. }));
993        }
994
995        // Execute plan
996        let result =
997            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
998        assert_eq!(result.outcomes.len(), 2);
999
1000        // Verify files were created
1001        assert!(fixture.managed_root().join("agents/coder.md").exists());
1002        assert!(
1003            fixture
1004                .managed_root()
1005                .join("skills/planning/SKILL.md")
1006                .exists()
1007        );
1008
1009        // Build lock
1010        let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1011        assert_eq!(new_lock.items.len(), 2);
1012        assert!(new_lock.items.contains_key("agents/coder.md"));
1013        assert!(new_lock.items.contains_key("skills/planning"));
1014    }
1015
1016    #[test]
1017    fn re_sync_no_changes() {
1018        let mut fixture = TestFixture::new();
1019        let content = "# Coder agent";
1020        let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1021
1022        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1023
1024        // First sync
1025        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1026        let lock = LockFile::empty();
1027        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1028        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1029        let options = SyncOptions {
1030            force: false,
1031            dry_run: false,
1032            frozen: false,
1033        };
1034        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1035        let result =
1036            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1037        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1038
1039        // Second sync with same content
1040        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1041        let sync_diff2 =
1042            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1043
1044        // All items should be Unchanged
1045        for entry in &sync_diff2.items {
1046            assert!(
1047                matches!(entry, diff::DiffEntry::Unchanged { .. }),
1048                "expected Unchanged, got {entry:?}"
1049            );
1050        }
1051
1052        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1053        for action in &sync_plan2.actions {
1054            assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1055        }
1056    }
1057
1058    #[test]
1059    fn source_update_detects_changes() {
1060        let mut fixture = TestFixture::new();
1061        let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1062
1063        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1064
1065        // First sync
1066        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1067        let lock = LockFile::empty();
1068        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1069        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1070        let options = SyncOptions {
1071            force: false,
1072            dry_run: false,
1073            frozen: false,
1074        };
1075        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1076        let result =
1077            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1078        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1079
1080        // Update source content
1081        let agents_dir = fixture.tree_path(src_idx).join("agents");
1082        fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1083
1084        // Rebuild target with updated content
1085        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1086        let sync_diff2 =
1087            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1088
1089        // Should detect an Update
1090        assert_eq!(sync_diff2.items.len(), 1);
1091        assert!(matches!(
1092            &sync_diff2.items[0],
1093            diff::DiffEntry::Update { .. }
1094        ));
1095    }
1096
1097    #[test]
1098    fn local_modification_preserved() {
1099        let mut fixture = TestFixture::new();
1100        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1101
1102        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1103
1104        // First sync
1105        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1106        let lock = LockFile::empty();
1107        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1108        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1109        let options = SyncOptions {
1110            force: false,
1111            dry_run: false,
1112            frozen: false,
1113        };
1114        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1115        let result =
1116            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1117        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1118
1119        // Locally modify the installed file
1120        fs::write(
1121            fixture.managed_root().join("agents/coder.md"),
1122            "# Locally modified",
1123        )
1124        .unwrap();
1125
1126        // Re-sync (source unchanged)
1127        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1128        let sync_diff2 =
1129            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1130
1131        // Should detect LocalModified
1132        assert_eq!(sync_diff2.items.len(), 1);
1133        assert!(matches!(
1134            &sync_diff2.items[0],
1135            diff::DiffEntry::LocalModified { .. }
1136        ));
1137
1138        // Plan should KeepLocal
1139        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1140        assert!(matches!(
1141            &sync_plan2.actions[0],
1142            plan::PlannedAction::KeepLocal { .. }
1143        ));
1144    }
1145
1146    #[test]
1147    fn force_overwrites_local_modifications() {
1148        let mut fixture = TestFixture::new();
1149        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1150
1151        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1152
1153        // First sync
1154        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1155        let lock = LockFile::empty();
1156        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1157        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1158        let options = SyncOptions {
1159            force: false,
1160            dry_run: false,
1161            frozen: false,
1162        };
1163        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1164        let result =
1165            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1166        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1167
1168        // Locally modify the installed file
1169        fs::write(
1170            fixture.managed_root().join("agents/coder.md"),
1171            "# Locally modified",
1172        )
1173        .unwrap();
1174
1175        // Update source too (triggers conflict)
1176        let agents_dir = fixture.tree_path(src_idx).join("agents");
1177        fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1178
1179        // Re-sync with --force
1180        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1181        let sync_diff2 =
1182            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1183
1184        let force_options = SyncOptions {
1185            force: true,
1186            dry_run: false,
1187            frozen: false,
1188        };
1189        let sync_plan2 = plan::create(&sync_diff2, &force_options, &cache_dir);
1190        assert!(matches!(
1191            &sync_plan2.actions[0],
1192            plan::PlannedAction::Overwrite { .. }
1193        ));
1194
1195        let result2 = apply::execute(
1196            fixture.managed_root(),
1197            &sync_plan2,
1198            &force_options,
1199            &cache_dir,
1200        )
1201        .unwrap();
1202        assert!(matches!(
1203            result2.outcomes[0].action,
1204            apply::ActionTaken::Updated
1205        ));
1206
1207        // File should have upstream content
1208        let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
1209        assert_eq!(content, "# Upstream update");
1210    }
1211
1212    #[test]
1213    fn orphan_removed_when_source_drops_item() {
1214        let mut fixture = TestFixture::new();
1215        let src_idx = fixture.add_source(
1216            &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
1217            &[],
1218        );
1219
1220        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1221
1222        // First sync — install both
1223        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1224        let lock = LockFile::empty();
1225        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1226        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1227        let options = SyncOptions {
1228            force: false,
1229            dry_run: false,
1230            frozen: false,
1231        };
1232        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1233        let result =
1234            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1235        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1236
1237        assert!(fixture.managed_root().join("agents/coder.md").exists());
1238        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1239
1240        // Remove reviewer from source
1241        fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
1242
1243        // Re-sync
1244        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1245        let sync_diff2 =
1246            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1247
1248        // Should have one Unchanged and one Orphan
1249        let orphan_count = sync_diff2
1250            .items
1251            .iter()
1252            .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1253            .count();
1254        assert_eq!(orphan_count, 1);
1255
1256        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1257        let result2 =
1258            apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
1259
1260        // Reviewer should be removed
1261        assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
1262        // Coder should still be there
1263        assert!(fixture.managed_root().join("agents/coder.md").exists());
1264
1265        // Check remove outcome
1266        let removed = result2
1267            .outcomes
1268            .iter()
1269            .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1270        assert!(removed);
1271    }
1272
1273    #[test]
1274    fn dry_run_produces_plan_without_changes() {
1275        let mut fixture = TestFixture::new();
1276        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1277
1278        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1279
1280        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1281        let lock = LockFile::empty();
1282        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1283
1284        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1285        let dry_options = SyncOptions {
1286            force: false,
1287            dry_run: true,
1288            frozen: false,
1289        };
1290
1291        let sync_plan = plan::create(&sync_diff, &dry_options, &cache_dir);
1292        assert!(!sync_plan.actions.is_empty());
1293
1294        // Execute in dry-run mode
1295        let result =
1296            apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1297        assert!(!result.outcomes.is_empty());
1298
1299        // No files should have been created
1300        assert!(!fixture.managed_root().join("agents/coder.md").exists());
1301    }
1302
1303    #[test]
1304    fn lock_written_after_apply() {
1305        let mut fixture = TestFixture::new();
1306        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1307
1308        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1309
1310        // Full pipeline minus actual sync() (which needs real config files)
1311        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1312        let lock = LockFile::empty();
1313        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1314        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1315        let options = SyncOptions {
1316            force: false,
1317            dry_run: false,
1318            frozen: false,
1319        };
1320        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1321        let result =
1322            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1323
1324        let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1325        crate::lock::write(fixture.project_root(), &new_lock).unwrap();
1326
1327        // Verify lock file exists and is valid
1328        let reloaded = crate::lock::load(fixture.project_root()).unwrap();
1329        assert_eq!(reloaded.items.len(), 1);
1330        assert!(reloaded.items.contains_key("agents/coder.md"));
1331
1332        let item = &reloaded.items["agents/coder.md"];
1333        assert_eq!(item.kind, ItemKind::Agent);
1334        assert!(!item.source_checksum.is_empty());
1335        assert!(!item.installed_checksum.is_empty());
1336    }
1337
1338    #[test]
1339    fn two_sources_no_collision() {
1340        let mut fixture = TestFixture::new();
1341        let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
1342        let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
1343
1344        let (graph, config) = make_graph_config(
1345            &fixture,
1346            vec![
1347                ("source-a", src_a, FilterMode::All),
1348                ("source-b", src_b, FilterMode::All),
1349            ],
1350        );
1351
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        let lock = LockFile::empty();
1357        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1358        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1359        let options = SyncOptions {
1360            force: false,
1361            dry_run: false,
1362            frozen: false,
1363        };
1364        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1365        let result =
1366            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1367
1368        assert!(fixture.managed_root().join("agents/coder.md").exists());
1369        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1370        assert_eq!(result.outcomes.len(), 2);
1371    }
1372
1373    // === Tests for OnlySkills / OnlyAgents filter in pipeline ===
1374
1375    #[test]
1376    fn pipeline_only_skills_filter() {
1377        let mut fixture = TestFixture::new();
1378        let src_idx = fixture.add_source(
1379            &[("coder.md", "# Coder agent")],
1380            &[("planning", "# Planning skill")],
1381        );
1382
1383        let (graph, config) =
1384            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
1385
1386        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1387        // Should only have the skill, not the agent
1388        assert_eq!(target.items.len(), 1);
1389        assert!(target.items.contains_key("skills/planning"));
1390    }
1391
1392    #[test]
1393    fn pipeline_only_agents_filter() {
1394        let mut fixture = TestFixture::new();
1395        // Agent with a skill dependency in frontmatter
1396        let agent_content = "---\nskills:\n  - planning\n---\n# Coder agent";
1397        let src_idx = fixture.add_source(
1398            &[("coder.md", agent_content)],
1399            &[
1400                ("planning", "# Planning skill"),
1401                ("standalone", "# Standalone skill"),
1402            ],
1403        );
1404
1405        let (graph, config) =
1406            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1407
1408        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1409        // Should have the agent + its transitive skill dep, but NOT standalone
1410        assert_eq!(target.items.len(), 2);
1411        assert!(target.items.contains_key("agents/coder.md"));
1412        assert!(target.items.contains_key("skills/planning"));
1413        assert!(!target.items.contains_key("skills/standalone"));
1414    }
1415
1416    #[test]
1417    fn pipeline_only_agents_no_agents_source() {
1418        let mut fixture = TestFixture::new();
1419        let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
1420
1421        let (graph, config) =
1422            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1423
1424        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1425        // No agents means nothing gets installed
1426        assert_eq!(target.items.len(), 0);
1427    }
1428}