1pub mod apply;
2pub mod diff;
3pub mod plan;
4pub mod target;
5
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9use crate::config::{
10 Config, DependencyEntry, EffectiveConfig, FilterConfig, LocalConfig, Manifest, OverrideEntry,
11 Settings,
12};
13use crate::error::{ConfigError, MarsError};
14use crate::hash;
15use crate::resolve::{ManifestReader, ResolveOptions, SourceFetcher, VersionLister};
16use crate::source::{self, AvailableVersion, GlobalCache, ResolvedRef};
17use crate::sync::apply::ApplyResult;
18pub use crate::sync::apply::SyncOptions;
19use crate::types::{CommitHash, ContentHash, ItemName, MarsContext, SourceName};
20use crate::validate::ValidationWarning;
21
22#[derive(Debug)]
24pub struct SyncReport {
25 pub applied: ApplyResult,
26 pub pruned: Vec<apply::ActionOutcome>,
27 pub warnings: Vec<ValidationWarning>,
28 pub dependency_changes: Vec<DependencyUpsertChange>,
29 pub dry_run: bool,
31}
32
33impl SyncReport {
34 pub fn has_conflicts(&self) -> bool {
36 self.applied
37 .outcomes
38 .iter()
39 .any(|o| matches!(o.action, apply::ActionTaken::Conflicted))
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct SyncRequest {
46 pub resolution: ResolutionMode,
48 pub mutation: Option<ConfigMutation>,
50 pub options: SyncOptions,
52}
53
54#[derive(Debug, Clone)]
56pub enum ResolutionMode {
57 Normal,
59 Maximize { targets: HashSet<SourceName> },
61}
62
63#[derive(Debug, Clone)]
65pub enum ConfigMutation {
66 UpsertDependency {
68 name: SourceName,
69 entry: DependencyEntry,
70 },
71 BatchUpsert(Vec<(SourceName, DependencyEntry)>),
73 RemoveDependency { name: SourceName },
75 SetOverride {
77 source_name: SourceName,
78 local_path: PathBuf,
79 },
80 ClearOverride { source_name: SourceName },
82 SetRename {
84 source_name: SourceName,
85 from: String,
86 to: String,
87 },
88}
89
90#[derive(Debug, Clone)]
92pub struct DependencyUpsertChange {
93 pub name: SourceName,
94 pub already_exists: bool,
95 pub old_filter: Option<FilterConfig>,
96 pub new_filter: FilterConfig,
97}
98
99#[derive(Debug, Clone)]
102pub enum LinkMutation {
103 Set { target: String },
105 Clear { target: String },
107}
108
109pub fn mutate_link_config(ctx: &MarsContext, mutation: &LinkMutation) -> Result<(), MarsError> {
112 let mars_dir = ctx.project_root.join(".mars");
113 std::fs::create_dir_all(&mars_dir)?;
114 let lock_path = mars_dir.join("sync.lock");
115 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
116
117 let mut config = crate::config::load(&ctx.project_root)?;
118 match mutation {
119 LinkMutation::Set { target } => {
120 if !config.settings.links.contains(target) {
121 config.settings.links.push(target.clone());
122 }
123 }
124 LinkMutation::Clear { target } => {
125 config.settings.links.retain(|l| l != target);
126 }
127 }
128 crate::config::save(&ctx.project_root, &config)?;
129
130 Ok(())
131}
132
133pub fn execute(ctx: &MarsContext, request: &SyncRequest) -> Result<SyncReport, MarsError> {
135 let project_root = &ctx.project_root;
136 let managed_root = &ctx.managed_root;
137 let mars_dir = project_root.join(".mars");
138
139 validate_request(request)?;
140
141 std::fs::create_dir_all(mars_dir.join("cache"))?;
142
143 let lock_path = mars_dir.join("sync.lock");
145 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
146
147 let mut config = match crate::config::load(project_root) {
149 Ok(config) => config,
150 Err(err) if is_config_not_found(&err) && request.mutation.is_some() => Config {
151 settings: Settings::default(),
152 ..Config::default()
153 },
154 Err(err) => return Err(err),
155 };
156
157 let has_mutation = request.mutation.is_some();
159 let dependency_changes = if let Some(mutation) = &request.mutation {
160 apply_mutation(&mut config, mutation)?
161 } else {
162 Vec::new()
163 };
164
165 let mut local = crate::config::load_local(project_root)?;
167 if let Some(mutation) = &request.mutation {
168 apply_local_mutation(&mut local, mutation);
169 }
170
171 let effective = crate::config::merge_with_root(config.clone(), local.clone(), project_root)?;
173
174 validate_targets(&request.resolution, &effective)?;
176
177 let old_lock = crate::lock::load(project_root)?;
179
180 let cache = GlobalCache::new()?;
182 let provider = RealSourceProvider {
183 cache: &cache,
184 project_root,
185 };
186 let resolve_options = to_resolve_options(&request.resolution, request.options.frozen);
187 let graph = crate::resolve::resolve(&effective, &provider, Some(&old_lock), &resolve_options)?;
188
189 let (mut target_state, renames) = target::build_with_collisions(&graph, &effective)?;
191
192 if !renames.is_empty() {
194 let rewrite_warnings = target::rewrite_skill_refs(&mut target_state, &renames, &graph)?;
195 for w in &rewrite_warnings {
196 eprintln!("{w}");
197 }
198 }
199
200 let warnings = validate_skill_refs(managed_root, &target_state);
202
203 let unmanaged_collisions =
205 target::check_unmanaged_collisions(managed_root, &old_lock, &target_state);
206 for collision in &unmanaged_collisions {
207 eprintln!(
208 "warning: source `{}` collides with unmanaged path `{}` — leaving existing content untouched",
209 collision.source_name, collision.path
210 );
211 target_state.items.shift_remove(&collision.path);
212 }
213
214 let sync_diff = diff::compute(
216 managed_root,
217 &old_lock,
218 &target_state,
219 request.options.force,
220 )?;
221
222 let cache_bases_dir = mars_dir.join("cache").join("bases");
224 let mut sync_plan = plan::create(&sync_diff, &request.options, &cache_bases_dir);
225 let mut skipped_self_dests: HashSet<crate::types::DestPath> = HashSet::new();
226
227 if config.package.is_some() {
229 let self_items = discover_local_items(project_root)?;
230
231 for item in &self_items {
233 if target_state.items.contains_key(&item.dest_rel) {
234 let existing = &target_state.items[&item.dest_rel];
235 eprintln!(
236 "warning: local {} `{}` shadows dependency `{}` {} `{}`",
237 item.kind, item.name, existing.source_name, existing.id.kind, existing.id.name
238 );
239 let dest_rel = item.dest_rel.clone();
241 sync_plan
242 .actions
243 .retain(|a| !action_matches_dest(a, &dest_rel));
244 target_state.items.shift_remove(&item.dest_rel);
245 }
246 }
247
248 for item in &self_items {
250 let dest = managed_root.join(item.dest_rel.as_path());
251 if !old_lock.items.contains_key(&item.dest_rel) && dest.symlink_metadata().is_ok() {
252 eprintln!(
253 "warning: local {} `{}` collides with unmanaged path `{}` — leaving existing content untouched",
254 item.kind, item.name, item.dest_rel
255 );
256 skipped_self_dests.insert(item.dest_rel.clone());
257 continue;
258 }
259 let needs_update = match dest.symlink_metadata() {
260 Ok(meta) if meta.file_type().is_symlink() => {
261 let current_target = std::fs::read_link(&dest).ok();
262 let from_dir = dest.parent().unwrap();
263 let expected = pathdiff::diff_paths(&item.source_path, from_dir)
264 .unwrap_or_else(|| item.source_path.clone());
265 current_target.as_deref() != Some(expected.as_path())
266 }
267 Ok(_) => true, Err(_) => true, };
270 if needs_update {
271 sync_plan.actions.push(plan::PlannedAction::Symlink {
272 source_abs: item.source_path.clone(),
273 dest_rel: item.dest_rel.clone(),
274 kind: item.kind,
275 name: item.name.clone(),
276 });
277 }
278 }
279
280 let self_dest_set: std::collections::HashSet<_> =
282 self_items.iter().map(|i| &i.dest_rel).collect();
283 for (dest_path, locked_item) in &old_lock.items {
284 if locked_item.source.as_ref() == "_self" && !self_dest_set.contains(dest_path) {
285 sync_plan.actions.push(plan::PlannedAction::Remove {
286 locked: locked_item.clone(),
287 });
288 }
289 }
290 } else {
291 for (_, locked_item) in &old_lock.items {
293 if locked_item.source.as_ref() == "_self" {
294 sync_plan.actions.push(plan::PlannedAction::Remove {
295 locked: locked_item.clone(),
296 });
297 }
298 }
299 }
300
301 sync_plan.actions.retain(|action| {
307 if let plan::PlannedAction::Remove { locked } = action {
308 locked.source.as_ref() != "_self"
309 } else {
310 true
311 }
312 });
313
314 if request.options.frozen {
316 let has_changes = sync_plan.actions.iter().any(|a| {
317 !matches!(
318 a,
319 plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
320 )
321 });
322 if has_changes {
323 return Err(MarsError::FrozenViolation {
324 message: "lock file would change but --frozen is set".into(),
325 });
326 }
327 }
328
329 if has_mutation && !request.options.dry_run {
331 match &request.mutation {
332 Some(ConfigMutation::SetOverride { .. } | ConfigMutation::ClearOverride { .. }) => {
333 crate::config::save_local(project_root, &local)?;
334 }
335 Some(
336 ConfigMutation::UpsertDependency { .. }
337 | ConfigMutation::BatchUpsert(..)
338 | ConfigMutation::RemoveDependency { .. }
339 | ConfigMutation::SetRename { .. },
340 ) => {
341 crate::config::save(project_root, &config)?;
342 }
343 None => {}
344 }
345 }
346
347 let applied = apply::execute(managed_root, &sync_plan, &request.options, &cache_bases_dir)?;
349 let pruned = Vec::new();
350
351 if !request.options.dry_run {
353 let self_lock_items = if config.package.is_some() {
354 let self_items = discover_local_items(project_root)?;
355 let filtered: Vec<_> = self_items
356 .into_iter()
357 .filter(|item| !skipped_self_dests.contains(&item.dest_rel))
358 .collect();
359 build_self_lock_items(&filtered)?
360 } else {
361 Vec::new()
362 };
363 let self_items_for_lock =
364 (!self_lock_items.is_empty()).then_some(self_lock_items.as_slice());
365 let new_lock = crate::lock::build(&graph, &applied, &old_lock, self_items_for_lock)?;
366 crate::lock::write(project_root, &new_lock)?;
367 }
368
369 Ok(SyncReport {
370 applied,
371 pruned,
372 warnings,
373 dependency_changes,
374 dry_run: request.options.dry_run,
375 })
376}
377
378fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
379 if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
380 return Err(MarsError::InvalidRequest {
381 message:
382 "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
383 .to_string(),
384 });
385 }
386
387 if request.options.frozen && request.mutation.is_some() {
388 return Err(MarsError::InvalidRequest {
389 message:
390 "cannot modify config in --frozen mode (config change would require lock update)"
391 .to_string(),
392 });
393 }
394
395 Ok(())
396}
397
398fn is_config_not_found(error: &MarsError) -> bool {
399 matches!(error, MarsError::Config(ConfigError::NotFound { .. }))
400}
401
402pub fn apply_config_mutation(
406 config: &mut Config,
407 mutation: &ConfigMutation,
408) -> Result<(), MarsError> {
409 apply_mutation(config, mutation).map(|_| ())
410}
411
412fn apply_mutation(
413 config: &mut Config,
414 mutation: &ConfigMutation,
415) -> Result<Vec<DependencyUpsertChange>, MarsError> {
416 match mutation {
417 ConfigMutation::UpsertDependency { name, entry } => {
418 Ok(vec![apply_dependency_upsert(config, name, entry)])
419 }
420 ConfigMutation::BatchUpsert(entries) => {
421 let mut changes = Vec::with_capacity(entries.len());
422 for (name, entry) in entries {
423 changes.push(apply_dependency_upsert(config, name, entry));
424 }
425 Ok(changes)
426 }
427 ConfigMutation::RemoveDependency { name } => {
428 if !config.dependencies.contains_key(name) {
429 return Err(MarsError::Source {
430 source_name: name.to_string(),
431 message: format!("dependency `{name}` not found in mars.toml"),
432 });
433 }
434 config.dependencies.shift_remove(name);
435 Ok(Vec::new())
436 }
437 ConfigMutation::SetOverride { source_name, .. } => {
438 if !config.dependencies.contains_key(source_name) {
439 return Err(MarsError::Source {
440 source_name: source_name.to_string(),
441 message: format!("dependency `{source_name}` not found in mars.toml"),
442 });
443 }
444 Ok(Vec::new())
445 }
446 ConfigMutation::SetRename {
447 source_name,
448 from,
449 to,
450 } => {
451 let dep =
452 config
453 .dependencies
454 .get_mut(source_name)
455 .ok_or_else(|| MarsError::Source {
456 source_name: source_name.to_string(),
457 message: format!("dependency `{source_name}` not found in mars.toml"),
458 })?;
459 let rename_map = dep
460 .filter
461 .rename
462 .get_or_insert_with(crate::types::RenameMap::new);
463 rename_map.insert(ItemName::from(from.as_str()), ItemName::from(to.as_str()));
464 Ok(Vec::new())
465 }
466 ConfigMutation::ClearOverride { .. } => Ok(Vec::new()),
467 }
468}
469
470fn apply_local_mutation(local: &mut LocalConfig, mutation: &ConfigMutation) {
471 match mutation {
472 ConfigMutation::SetOverride {
473 source_name,
474 local_path,
475 } => {
476 local.overrides.insert(
477 source_name.clone(),
478 OverrideEntry {
479 path: local_path.clone(),
480 },
481 );
482 }
483 ConfigMutation::ClearOverride { source_name } => {
484 local.overrides.shift_remove(source_name);
485 }
486 ConfigMutation::UpsertDependency { .. }
487 | ConfigMutation::BatchUpsert(..)
488 | ConfigMutation::RemoveDependency { .. }
489 | ConfigMutation::SetRename { .. } => {}
490 }
491}
492
493fn apply_dependency_upsert(
494 config: &mut Config,
495 name: &SourceName,
496 entry: &DependencyEntry,
497) -> DependencyUpsertChange {
498 if let Some(existing) = config.dependencies.get_mut(name) {
499 let old_filter = existing.filter.clone();
500
501 existing.url = entry.url.clone();
503 existing.path = entry.path.clone();
504 existing.version = entry.version.clone();
505 if entry.filter.has_any_filter() {
510 let rename = existing.filter.rename.take();
511 existing.filter = entry.filter.clone();
512 existing.filter.rename = rename;
514 }
515 DependencyUpsertChange {
518 name: name.clone(),
519 already_exists: true,
520 old_filter: Some(old_filter),
521 new_filter: existing.filter.clone(),
522 }
523 } else {
524 config.dependencies.insert(name.clone(), entry.clone());
525 DependencyUpsertChange {
526 name: name.clone(),
527 already_exists: false,
528 old_filter: None,
529 new_filter: entry.filter.clone(),
530 }
531 }
532}
533
534fn validate_targets(
535 resolution: &ResolutionMode,
536 effective: &EffectiveConfig,
537) -> Result<(), MarsError> {
538 if let ResolutionMode::Maximize { targets } = resolution {
539 for name in targets {
540 if !effective.dependencies.contains_key(name) {
541 return Err(MarsError::Source {
542 source_name: name.to_string(),
543 message: format!("dependency `{name}` not found in mars.toml"),
544 });
545 }
546 }
547 }
548
549 Ok(())
550}
551
552fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
553 match mode {
554 ResolutionMode::Normal => ResolveOptions {
555 frozen,
556 ..ResolveOptions::default()
557 },
558 ResolutionMode::Maximize { targets } => ResolveOptions {
559 maximize: true,
560 upgrade_targets: targets.clone(),
561 frozen,
562 },
563 }
564}
565
566struct RealSourceProvider<'a> {
571 cache: &'a GlobalCache,
572 project_root: &'a Path,
573}
574
575impl VersionLister for RealSourceProvider<'_> {
576 fn list_versions(
577 &self,
578 url: &crate::types::SourceUrl,
579 ) -> Result<Vec<AvailableVersion>, MarsError> {
580 source::list_versions(url, self.cache)
581 }
582}
583
584impl SourceFetcher for RealSourceProvider<'_> {
585 fn fetch_git_version(
586 &self,
587 url: &crate::types::SourceUrl,
588 version: &AvailableVersion,
589 source_name: &str,
590 preferred_commit: Option<&str>,
591 ) -> Result<ResolvedRef, MarsError> {
592 let fetch_options = source::git::FetchOptions {
593 preferred_commit: preferred_commit.map(CommitHash::from),
594 };
595 source::git::fetch(
596 url.as_ref(),
597 Some(&version.tag),
598 source_name,
599 self.cache,
600 &fetch_options,
601 )
602 }
603
604 fn fetch_git_ref(
605 &self,
606 url: &crate::types::SourceUrl,
607 ref_name: &str,
608 source_name: &str,
609 preferred_commit: Option<&str>,
610 ) -> Result<ResolvedRef, MarsError> {
611 let fetch_options = source::git::FetchOptions {
612 preferred_commit: preferred_commit.map(CommitHash::from),
613 };
614 source::git::fetch(
615 url.as_ref(),
616 Some(ref_name),
617 source_name,
618 self.cache,
619 &fetch_options,
620 )
621 }
622
623 fn fetch_path(&self, path: &Path, source_name: &str) -> Result<ResolvedRef, MarsError> {
624 source::path::fetch_path(path, self.project_root, source_name)
625 }
626}
627
628impl ManifestReader for RealSourceProvider<'_> {
629 fn read_manifest(&self, source_tree: &Path) -> Result<Option<Manifest>, MarsError> {
630 crate::config::load_manifest(source_tree)
631 }
632}
633
634fn validate_skill_refs(
637 install_target: &Path,
638 target: &target::TargetState,
639) -> Vec<ValidationWarning> {
640 use crate::lock::ItemKind;
641
642 let available_skills: HashSet<String> = target
644 .items
645 .values()
646 .filter(|item| item.id.kind == ItemKind::Skill)
647 .map(|item| item.id.name.to_string())
648 .collect();
649
650 let agents: Vec<(String, PathBuf)> = target
652 .items
653 .values()
654 .filter(|item| item.id.kind == ItemKind::Agent)
655 .map(|item| {
656 let disk_path = install_target.join(&item.dest_path);
657 let path = if disk_path.exists() {
660 disk_path
661 } else {
662 item.source_path.clone()
663 };
664 (item.id.name.to_string(), path)
665 })
666 .collect();
667
668 crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
669}
670
671struct LocalItem {
673 kind: crate::lock::ItemKind,
674 name: ItemName,
675 source_path: PathBuf,
677 dest_rel: crate::types::DestPath,
679}
680
681fn discover_local_items(project_root: &Path) -> Result<Vec<LocalItem>, MarsError> {
687 use crate::lock::ItemKind;
688 let mut items = Vec::new();
689
690 let agents_dir = project_root.join("agents");
692 if agents_dir.is_dir() {
693 for entry in std::fs::read_dir(&agents_dir)? {
694 let entry = entry?;
695 let path = entry.path();
696 if path.extension().and_then(|e| e.to_str()) == Some("md") && path.is_file() {
697 let name = path
698 .file_stem()
699 .unwrap_or_default()
700 .to_string_lossy()
701 .to_string();
702 items.push(LocalItem {
703 kind: ItemKind::Agent,
704 name: ItemName::from(name.as_str()),
705 source_path: path.canonicalize().unwrap_or(path.clone()),
706 dest_rel: format!("agents/{}.md", name).into(),
707 });
708 }
709 }
710 }
711
712 let skills_dir = project_root.join("skills");
714 if skills_dir.is_dir() {
715 for entry in std::fs::read_dir(&skills_dir)? {
716 let entry = entry?;
717 let path = entry.path();
718 if path.is_dir() && path.join("SKILL.md").exists() {
719 let name = path
720 .file_name()
721 .unwrap_or_default()
722 .to_string_lossy()
723 .to_string();
724 items.push(LocalItem {
725 kind: ItemKind::Skill,
726 name: ItemName::from(name.as_str()),
727 source_path: path.canonicalize().unwrap_or(path.clone()),
728 dest_rel: format!("skills/{}", name).into(),
729 });
730 }
731 }
732 }
733
734 Ok(items)
735}
736
737fn build_self_lock_items(items: &[LocalItem]) -> Result<Vec<crate::lock::SelfLockItem>, MarsError> {
738 let mut lock_items = Vec::with_capacity(items.len());
739 for item in items {
740 let source_checksum = ContentHash::from(hash::compute_hash(&item.source_path, item.kind)?);
741 lock_items.push(crate::lock::SelfLockItem {
742 dest_path: item.dest_rel.clone(),
743 kind: item.kind,
744 source_checksum,
745 });
746 }
747 Ok(lock_items)
748}
749
750fn action_matches_dest(action: &plan::PlannedAction, dest: &crate::types::DestPath) -> bool {
752 match action {
753 plan::PlannedAction::Install { target } | plan::PlannedAction::Overwrite { target } => {
754 &target.dest_path == dest
755 }
756 plan::PlannedAction::Skip { dest_path, .. }
757 | plan::PlannedAction::KeepLocal { dest_path, .. } => dest_path == dest,
758 plan::PlannedAction::Merge { target, .. } => &target.dest_path == dest,
759 plan::PlannedAction::Remove { locked } => &locked.dest_path == dest,
760 plan::PlannedAction::Symlink { dest_rel, .. } => dest_rel == dest,
761 }
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::config::*;
768 use crate::lock::{ItemKind, LockFile};
769 use crate::resolve::{ResolvedGraph, ResolvedNode};
770 use crate::source::ResolvedRef;
771 use indexmap::IndexMap;
772 use std::fs;
773 use tempfile::TempDir;
774
775 struct TestFixture {
777 project_root: TempDir,
778 managed_root: PathBuf,
779 source_trees: Vec<TempDir>,
780 }
781
782 impl TestFixture {
783 fn new() -> Self {
784 let project_root = TempDir::new().unwrap();
785 let managed_root = project_root.path().join(".agents");
786 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
788 TestFixture {
789 project_root,
790 managed_root,
791 source_trees: Vec::new(),
792 }
793 }
794
795 fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
796 let dir = TempDir::new().unwrap();
797 if !agents.is_empty() {
798 let agents_dir = dir.path().join("agents");
799 fs::create_dir_all(&agents_dir).unwrap();
800 for (name, content) in agents {
801 fs::write(agents_dir.join(name), content).unwrap();
802 }
803 }
804 if !skills.is_empty() {
805 let skills_dir = dir.path().join("skills");
806 fs::create_dir_all(&skills_dir).unwrap();
807 for (name, content) in skills {
808 let skill_dir = skills_dir.join(name);
809 fs::create_dir_all(&skill_dir).unwrap();
810 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
811 }
812 }
813 self.source_trees.push(dir);
814 self.source_trees.len() - 1
815 }
816
817 fn project_root(&self) -> &Path {
818 self.project_root.path()
819 }
820
821 fn managed_root(&self) -> &Path {
822 &self.managed_root
823 }
824
825 fn tree_path(&self, idx: usize) -> PathBuf {
826 self.source_trees[idx].path().to_path_buf()
827 }
828 }
829
830 fn make_graph_config(
831 fixture: &TestFixture,
832 sources: Vec<(&str, usize, FilterMode)>,
833 ) -> (ResolvedGraph, EffectiveConfig) {
834 let mut nodes = IndexMap::new();
835 let mut order = Vec::new();
836 let mut config_dependencies = IndexMap::new();
837
838 for (name, tree_idx, filter) in sources {
839 let tree_path = fixture.tree_path(tree_idx);
840 nodes.insert(
841 name.into(),
842 ResolvedNode {
843 source_name: name.into(),
844 source_id: crate::types::SourceId::Path {
845 canonical: tree_path.clone(),
846 },
847 resolved_ref: ResolvedRef {
848 source_name: name.into(),
849 version: None,
850 version_tag: None,
851 commit: None,
852 tree_path: tree_path.clone(),
853 },
854 manifest: None,
855 deps: vec![],
856 },
857 );
858 order.push(name.into());
859
860 config_dependencies.insert(
861 name.into(),
862 EffectiveDependency {
863 name: name.into(),
864 id: crate::types::SourceId::Path {
865 canonical: tree_path.clone(),
866 },
867 spec: SourceSpec::Path(tree_path),
868 filter,
869 rename: crate::types::RenameMap::new(),
870 is_overridden: false,
871 original_git: None,
872 },
873 );
874 }
875
876 (
877 ResolvedGraph {
878 nodes,
879 order,
880 id_index: std::collections::HashMap::new(),
881 },
882 EffectiveConfig {
883 dependencies: config_dependencies,
884 settings: Settings::default(),
885 },
886 )
887 }
888
889 fn path_dependency_entry(path: &Path) -> DependencyEntry {
890 DependencyEntry {
891 url: None,
892 path: Some(path.to_path_buf()),
893 version: None,
894 filter: FilterConfig::default(),
895 }
896 }
897
898 #[test]
899 fn validate_request_rejects_frozen_with_maximize() {
900 let request = SyncRequest {
901 resolution: ResolutionMode::Maximize {
902 targets: HashSet::new(),
903 },
904 mutation: None,
905 options: SyncOptions {
906 force: false,
907 dry_run: false,
908 frozen: true,
909 },
910 };
911
912 let err = validate_request(&request).unwrap_err();
913 assert!(matches!(err, MarsError::InvalidRequest { .. }));
914 assert!(err.to_string().contains("--frozen"));
915 }
916
917 #[test]
918 fn validate_request_rejects_frozen_with_mutation() {
919 let request = SyncRequest {
920 resolution: ResolutionMode::Normal,
921 mutation: Some(ConfigMutation::RemoveDependency {
922 name: "base".into(),
923 }),
924 options: SyncOptions {
925 force: false,
926 dry_run: false,
927 frozen: true,
928 },
929 };
930
931 let err = validate_request(&request).unwrap_err();
932 assert!(matches!(err, MarsError::InvalidRequest { .. }));
933 assert!(err.to_string().contains("cannot modify config"));
934 }
935
936 #[test]
937 fn execute_auto_inits_config_for_mutation() {
938 let project_root = TempDir::new().unwrap();
939 let managed_root = project_root.path().join(".agents");
940 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
941 let source = TempDir::new().unwrap();
942 fs::create_dir_all(source.path().join("agents")).unwrap();
943 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
944
945 let request = SyncRequest {
946 resolution: ResolutionMode::Normal,
947 mutation: Some(ConfigMutation::UpsertDependency {
948 name: "base".into(),
949 entry: path_dependency_entry(source.path()),
950 }),
951 options: SyncOptions::default(),
952 };
953
954 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
955 let report = execute(&ctx, &request).unwrap();
956 assert!(!report.applied.outcomes.is_empty());
957 assert!(project_root.path().join("mars.toml").exists());
958
959 let saved = crate::config::load(project_root.path()).unwrap();
960 assert!(saved.dependencies.contains_key("base"));
961 }
962
963 #[test]
964 fn execute_dry_run_with_mutation_does_not_write_config() {
965 let project_root = TempDir::new().unwrap();
966 let managed_root = project_root.path().join(".agents");
967 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
968 crate::config::save(
969 project_root.path(),
970 &Config {
971 dependencies: IndexMap::new(),
972 settings: Settings::default(),
973 ..Config::default()
974 },
975 )
976 .unwrap();
977
978 let source = TempDir::new().unwrap();
979 fs::create_dir_all(source.path().join("agents")).unwrap();
980 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
981
982 let request = SyncRequest {
983 resolution: ResolutionMode::Normal,
984 mutation: Some(ConfigMutation::UpsertDependency {
985 name: "base".into(),
986 entry: path_dependency_entry(source.path()),
987 }),
988 options: SyncOptions {
989 force: false,
990 dry_run: true,
991 frozen: false,
992 },
993 };
994
995 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
996 let report = execute(&ctx, &request).unwrap();
997 assert!(!report.applied.outcomes.is_empty());
998
999 let saved = crate::config::load(project_root.path()).unwrap();
1000 assert!(!saved.dependencies.contains_key("base"));
1001 assert!(!managed_root.join("agents/coder.md").exists());
1002 assert!(!project_root.path().join("mars.lock").exists());
1003 }
1004
1005 #[test]
1008 fn full_pipeline_fresh_sync() {
1009 let mut fixture = TestFixture::new();
1010 let src_idx = fixture.add_source(
1011 &[("coder.md", "# Coder agent")],
1012 &[("planning", "# Planning skill")],
1013 );
1014
1015 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1016
1017 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1019 assert!(renames.is_empty());
1020 assert_eq!(target.items.len(), 2);
1021
1022 let lock = LockFile::empty();
1024 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1025
1026 assert_eq!(sync_diff.items.len(), 2);
1028 for entry in &sync_diff.items {
1029 assert!(matches!(entry, diff::DiffEntry::Add { .. }));
1030 }
1031
1032 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1034 let options = SyncOptions {
1035 force: false,
1036 dry_run: false,
1037 frozen: false,
1038 };
1039 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1040 assert_eq!(sync_plan.actions.len(), 2);
1041 for action in &sync_plan.actions {
1042 assert!(matches!(action, plan::PlannedAction::Install { .. }));
1043 }
1044
1045 let result =
1047 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1048 assert_eq!(result.outcomes.len(), 2);
1049
1050 assert!(fixture.managed_root().join("agents/coder.md").exists());
1052 assert!(
1053 fixture
1054 .managed_root()
1055 .join("skills/planning/SKILL.md")
1056 .exists()
1057 );
1058
1059 let new_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1061 assert_eq!(new_lock.items.len(), 2);
1062 assert!(new_lock.items.contains_key("agents/coder.md"));
1063 assert!(new_lock.items.contains_key("skills/planning"));
1064 }
1065
1066 #[test]
1067 fn re_sync_no_changes() {
1068 let mut fixture = TestFixture::new();
1069 let content = "# Coder agent";
1070 let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1071
1072 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1073
1074 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1076 let lock = LockFile::empty();
1077 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1078 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1079 let options = SyncOptions {
1080 force: false,
1081 dry_run: false,
1082 frozen: false,
1083 };
1084 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1085 let result =
1086 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1087 let first_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1088
1089 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1091 let sync_diff2 =
1092 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1093
1094 for entry in &sync_diff2.items {
1096 assert!(
1097 matches!(entry, diff::DiffEntry::Unchanged { .. }),
1098 "expected Unchanged, got {entry:?}"
1099 );
1100 }
1101
1102 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1103 for action in &sync_plan2.actions {
1104 assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1105 }
1106 }
1107
1108 #[test]
1109 fn source_update_detects_changes() {
1110 let mut fixture = TestFixture::new();
1111 let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1112
1113 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1114
1115 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1117 let lock = LockFile::empty();
1118 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1119 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1120 let options = SyncOptions {
1121 force: false,
1122 dry_run: false,
1123 frozen: false,
1124 };
1125 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1126 let result =
1127 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1128 let first_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1129
1130 let agents_dir = fixture.tree_path(src_idx).join("agents");
1132 fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1133
1134 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1136 let sync_diff2 =
1137 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1138
1139 assert_eq!(sync_diff2.items.len(), 1);
1141 assert!(matches!(
1142 &sync_diff2.items[0],
1143 diff::DiffEntry::Update { .. }
1144 ));
1145 }
1146
1147 #[test]
1148 fn local_modification_preserved() {
1149 let mut fixture = TestFixture::new();
1150 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1151
1152 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1153
1154 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1156 let lock = LockFile::empty();
1157 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1158 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1159 let options = SyncOptions {
1160 force: false,
1161 dry_run: false,
1162 frozen: false,
1163 };
1164 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1165 let result =
1166 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1167 let first_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1168
1169 fs::write(
1171 fixture.managed_root().join("agents/coder.md"),
1172 "# Locally modified",
1173 )
1174 .unwrap();
1175
1176 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1178 let sync_diff2 =
1179 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1180
1181 assert_eq!(sync_diff2.items.len(), 1);
1183 assert!(matches!(
1184 &sync_diff2.items[0],
1185 diff::DiffEntry::LocalModified { .. }
1186 ));
1187
1188 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1190 assert!(matches!(
1191 &sync_plan2.actions[0],
1192 plan::PlannedAction::KeepLocal { .. }
1193 ));
1194 }
1195
1196 #[test]
1197 fn force_overwrites_local_modifications() {
1198 let mut fixture = TestFixture::new();
1199 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1200
1201 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1202
1203 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1205 let lock = LockFile::empty();
1206 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1207 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1208 let options = SyncOptions {
1209 force: false,
1210 dry_run: false,
1211 frozen: false,
1212 };
1213 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1214 let result =
1215 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1216 let first_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1217
1218 fs::write(
1220 fixture.managed_root().join("agents/coder.md"),
1221 "# Locally modified",
1222 )
1223 .unwrap();
1224
1225 let agents_dir = fixture.tree_path(src_idx).join("agents");
1227 fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1228
1229 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1231 let sync_diff2 =
1232 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1233
1234 let force_options = SyncOptions {
1235 force: true,
1236 dry_run: false,
1237 frozen: false,
1238 };
1239 let sync_plan2 = plan::create(&sync_diff2, &force_options, &cache_dir);
1240 assert!(matches!(
1241 &sync_plan2.actions[0],
1242 plan::PlannedAction::Overwrite { .. }
1243 ));
1244
1245 let result2 = apply::execute(
1246 fixture.managed_root(),
1247 &sync_plan2,
1248 &force_options,
1249 &cache_dir,
1250 )
1251 .unwrap();
1252 assert!(matches!(
1253 result2.outcomes[0].action,
1254 apply::ActionTaken::Updated
1255 ));
1256
1257 let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
1259 assert_eq!(content, "# Upstream update");
1260 }
1261
1262 #[test]
1263 fn orphan_removed_when_source_drops_item() {
1264 let mut fixture = TestFixture::new();
1265 let src_idx = fixture.add_source(
1266 &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
1267 &[],
1268 );
1269
1270 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1271
1272 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1274 let lock = LockFile::empty();
1275 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1276 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1277 let options = SyncOptions {
1278 force: false,
1279 dry_run: false,
1280 frozen: false,
1281 };
1282 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1283 let result =
1284 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1285 let first_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1286
1287 assert!(fixture.managed_root().join("agents/coder.md").exists());
1288 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1289
1290 fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
1292
1293 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1295 let sync_diff2 =
1296 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1297
1298 let orphan_count = sync_diff2
1300 .items
1301 .iter()
1302 .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1303 .count();
1304 assert_eq!(orphan_count, 1);
1305
1306 let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1307 let result2 =
1308 apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
1309
1310 assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
1312 assert!(fixture.managed_root().join("agents/coder.md").exists());
1314
1315 let removed = result2
1317 .outcomes
1318 .iter()
1319 .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1320 assert!(removed);
1321 }
1322
1323 #[test]
1324 fn dry_run_produces_plan_without_changes() {
1325 let mut fixture = TestFixture::new();
1326 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1327
1328 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1329
1330 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1331 let lock = LockFile::empty();
1332 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1333
1334 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1335 let dry_options = SyncOptions {
1336 force: false,
1337 dry_run: true,
1338 frozen: false,
1339 };
1340
1341 let sync_plan = plan::create(&sync_diff, &dry_options, &cache_dir);
1342 assert!(!sync_plan.actions.is_empty());
1343
1344 let result =
1346 apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1347 assert!(!result.outcomes.is_empty());
1348
1349 assert!(!fixture.managed_root().join("agents/coder.md").exists());
1351 }
1352
1353 #[test]
1354 fn lock_written_after_apply() {
1355 let mut fixture = TestFixture::new();
1356 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1357
1358 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1359
1360 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1362 let lock = LockFile::empty();
1363 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1364 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1365 let options = SyncOptions {
1366 force: false,
1367 dry_run: false,
1368 frozen: false,
1369 };
1370 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1371 let result =
1372 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1373
1374 let new_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1375 crate::lock::write(fixture.project_root(), &new_lock).unwrap();
1376
1377 let reloaded = crate::lock::load(fixture.project_root()).unwrap();
1379 assert_eq!(reloaded.items.len(), 1);
1380 assert!(reloaded.items.contains_key("agents/coder.md"));
1381
1382 let item = &reloaded.items["agents/coder.md"];
1383 assert_eq!(item.kind, ItemKind::Agent);
1384 assert!(!item.source_checksum.is_empty());
1385 assert!(!item.installed_checksum.is_empty());
1386 }
1387
1388 #[test]
1389 fn two_sources_no_collision() {
1390 let mut fixture = TestFixture::new();
1391 let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
1392 let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
1393
1394 let (graph, config) = make_graph_config(
1395 &fixture,
1396 vec![
1397 ("source-a", src_a, FilterMode::All),
1398 ("source-b", src_b, FilterMode::All),
1399 ],
1400 );
1401
1402 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1403 assert!(renames.is_empty());
1404 assert_eq!(target.items.len(), 2);
1405
1406 let lock = LockFile::empty();
1407 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1408 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1409 let options = SyncOptions {
1410 force: false,
1411 dry_run: false,
1412 frozen: false,
1413 };
1414 let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1415 let result =
1416 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1417
1418 assert!(fixture.managed_root().join("agents/coder.md").exists());
1419 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1420 assert_eq!(result.outcomes.len(), 2);
1421 }
1422
1423 #[test]
1426 fn apply_mutation_atomic_filter_replacement() {
1427 let mut config = Config::default();
1428 let entry1 = DependencyEntry {
1430 url: Some("https://github.com/org/base.git".into()),
1431 path: None,
1432 version: Some("v1".into()),
1433 filter: FilterConfig {
1434 agents: Some(vec!["reviewer".into()]),
1435 ..FilterConfig::default()
1436 },
1437 };
1438 apply_mutation(
1439 &mut config,
1440 &ConfigMutation::UpsertDependency {
1441 name: "base".into(),
1442 entry: entry1,
1443 },
1444 )
1445 .unwrap();
1446 assert!(config.dependencies["base"].filter.agents.is_some());
1447
1448 let entry2 = DependencyEntry {
1450 url: Some("https://github.com/org/base.git".into()),
1451 path: None,
1452 version: Some("v1".into()),
1453 filter: FilterConfig {
1454 only_skills: true,
1455 ..FilterConfig::default()
1456 },
1457 };
1458 apply_mutation(
1459 &mut config,
1460 &ConfigMutation::UpsertDependency {
1461 name: "base".into(),
1462 entry: entry2,
1463 },
1464 )
1465 .unwrap();
1466
1467 let dep = &config.dependencies["base"];
1468 assert!(dep.filter.only_skills);
1469 assert!(
1470 dep.filter.agents.is_none(),
1471 "agents should be cleared by atomic replacement"
1472 );
1473 }
1474
1475 #[test]
1476 fn apply_mutation_preserves_filters_on_version_bump() {
1477 let mut config = Config::default();
1478 let entry1 = DependencyEntry {
1480 url: Some("https://github.com/org/base.git".into()),
1481 path: None,
1482 version: Some("v1".into()),
1483 filter: FilterConfig {
1484 agents: Some(vec!["coder".into()]),
1485 ..FilterConfig::default()
1486 },
1487 };
1488 apply_mutation(
1489 &mut config,
1490 &ConfigMutation::UpsertDependency {
1491 name: "base".into(),
1492 entry: entry1,
1493 },
1494 )
1495 .unwrap();
1496
1497 let entry2 = DependencyEntry {
1499 url: Some("https://github.com/org/base.git".into()),
1500 path: None,
1501 version: Some("v2".into()),
1502 filter: FilterConfig::default(),
1503 };
1504 apply_mutation(
1505 &mut config,
1506 &ConfigMutation::UpsertDependency {
1507 name: "base".into(),
1508 entry: entry2,
1509 },
1510 )
1511 .unwrap();
1512
1513 let dep = &config.dependencies["base"];
1514 assert_eq!(dep.version.as_deref(), Some("v2"));
1515 assert_eq!(
1516 dep.filter.agents.as_deref(),
1517 Some(&["coder".into()][..]),
1518 "agents filter should be preserved on version bump"
1519 );
1520 }
1521
1522 #[test]
1523 fn apply_mutation_preserves_rename_on_filter_change() {
1524 let mut config = Config::default();
1525 let mut rename_map = crate::types::RenameMap::new();
1526 rename_map.insert("old".into(), "new".into());
1527
1528 let entry1 = DependencyEntry {
1529 url: Some("https://github.com/org/base.git".into()),
1530 path: None,
1531 version: None,
1532 filter: FilterConfig {
1533 agents: Some(vec!["coder".into()]),
1534 rename: Some(rename_map),
1535 ..FilterConfig::default()
1536 },
1537 };
1538 apply_mutation(
1539 &mut config,
1540 &ConfigMutation::UpsertDependency {
1541 name: "base".into(),
1542 entry: entry1,
1543 },
1544 )
1545 .unwrap();
1546
1547 let entry2 = DependencyEntry {
1549 url: Some("https://github.com/org/base.git".into()),
1550 path: None,
1551 version: None,
1552 filter: FilterConfig {
1553 only_skills: true,
1554 ..FilterConfig::default()
1555 },
1556 };
1557 apply_mutation(
1558 &mut config,
1559 &ConfigMutation::UpsertDependency {
1560 name: "base".into(),
1561 entry: entry2,
1562 },
1563 )
1564 .unwrap();
1565
1566 let dep = &config.dependencies["base"];
1567 assert!(dep.filter.only_skills);
1568 assert!(dep.filter.agents.is_none());
1569 assert!(
1570 dep.filter.rename.is_some(),
1571 "rename should be preserved across filter changes"
1572 );
1573 assert_eq!(
1574 dep.filter.rename.as_ref().unwrap().get("old").unwrap(),
1575 "new"
1576 );
1577 }
1578
1579 #[test]
1580 fn apply_mutation_batch_upsert_applies_all_entries() {
1581 let mut config = Config::default();
1582 let batch = vec![
1583 (
1584 "base".into(),
1585 DependencyEntry {
1586 url: Some("https://github.com/org/base.git".into()),
1587 path: None,
1588 version: Some("v1".into()),
1589 filter: FilterConfig::default(),
1590 },
1591 ),
1592 (
1593 "workflow".into(),
1594 DependencyEntry {
1595 url: Some("https://github.com/org/workflow.git".into()),
1596 path: None,
1597 version: Some("v2".into()),
1598 filter: FilterConfig::default(),
1599 },
1600 ),
1601 ];
1602
1603 let changes = apply_mutation(&mut config, &ConfigMutation::BatchUpsert(batch)).unwrap();
1604 assert_eq!(changes.len(), 2);
1605 assert!(config.dependencies.contains_key("base"));
1606 assert!(config.dependencies.contains_key("workflow"));
1607 }
1608
1609 #[test]
1610 fn apply_mutation_returns_old_and_new_filters_for_readd() {
1611 let mut config = Config::default();
1612 let entry1 = DependencyEntry {
1613 url: Some("https://github.com/org/base.git".into()),
1614 path: None,
1615 version: Some("v1".into()),
1616 filter: FilterConfig {
1617 agents: Some(vec!["reviewer".into()]),
1618 ..FilterConfig::default()
1619 },
1620 };
1621 apply_mutation(
1622 &mut config,
1623 &ConfigMutation::UpsertDependency {
1624 name: "base".into(),
1625 entry: entry1,
1626 },
1627 )
1628 .unwrap();
1629
1630 let entry2 = DependencyEntry {
1631 url: Some("https://github.com/org/base.git".into()),
1632 path: None,
1633 version: Some("v2".into()),
1634 filter: FilterConfig {
1635 only_skills: true,
1636 ..FilterConfig::default()
1637 },
1638 };
1639 let changes = apply_mutation(
1640 &mut config,
1641 &ConfigMutation::UpsertDependency {
1642 name: "base".into(),
1643 entry: entry2,
1644 },
1645 )
1646 .unwrap();
1647
1648 assert_eq!(changes.len(), 1);
1649 let change = &changes[0];
1650 assert!(change.already_exists);
1651 assert_eq!(change.name, "base");
1652 assert_eq!(
1653 change.old_filter.as_ref().and_then(|f| f.agents.as_deref()),
1654 Some(&["reviewer".into()][..])
1655 );
1656 assert!(change.new_filter.only_skills);
1657 assert!(change.new_filter.agents.is_none());
1658 }
1659
1660 #[test]
1663 fn pipeline_only_skills_filter() {
1664 let mut fixture = TestFixture::new();
1665 let src_idx = fixture.add_source(
1666 &[("coder.md", "# Coder agent")],
1667 &[("planning", "# Planning skill")],
1668 );
1669
1670 let (graph, config) =
1671 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
1672
1673 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1674 assert_eq!(target.items.len(), 1);
1676 assert!(target.items.contains_key("skills/planning"));
1677 }
1678
1679 #[test]
1680 fn pipeline_only_agents_filter() {
1681 let mut fixture = TestFixture::new();
1682 let agent_content = "---\nskills:\n - planning\n---\n# Coder agent";
1684 let src_idx = fixture.add_source(
1685 &[("coder.md", agent_content)],
1686 &[
1687 ("planning", "# Planning skill"),
1688 ("standalone", "# Standalone skill"),
1689 ],
1690 );
1691
1692 let (graph, config) =
1693 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1694
1695 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1696 assert_eq!(target.items.len(), 2);
1698 assert!(target.items.contains_key("agents/coder.md"));
1699 assert!(target.items.contains_key("skills/planning"));
1700 assert!(!target.items.contains_key("skills/standalone"));
1701 }
1702
1703 #[test]
1704 fn pipeline_only_agents_no_agents_source() {
1705 let mut fixture = TestFixture::new();
1706 let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
1707
1708 let (graph, config) =
1709 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1710
1711 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1712 assert_eq!(target.items.len(), 0);
1714 }
1715}