Skip to main content

mars_agents/sync/
mutation.rs

1//! Config mutation logic for the sync pipeline.
2//!
3//! Handles applying mutations to `mars.toml` and `mars.local.toml` under the sync lock.
4
5use std::path::PathBuf;
6
7use crate::config::{Config, DependencyEntry, FilterConfig, LocalConfig, OverrideEntry};
8use crate::error::{ConfigError, MarsError};
9use crate::types::{ItemName, RenameMap, SourceName};
10
11/// Config mutation to apply atomically under flock.
12#[derive(Debug, Clone)]
13pub enum ConfigMutation {
14    /// Add or update a dependency in mars.toml.
15    UpsertDependency {
16        name: SourceName,
17        entry: DependencyEntry,
18    },
19    /// Add or update multiple dependencies in mars.toml atomically under one sync lock.
20    BatchUpsert(Vec<(SourceName, DependencyEntry)>),
21    /// Remove a dependency from mars.toml.
22    RemoveDependency { name: SourceName },
23    /// Add or update an override in mars.local.toml.
24    SetOverride {
25        source_name: SourceName,
26        local_path: PathBuf,
27    },
28    /// Remove an override from mars.local.toml.
29    ClearOverride { source_name: SourceName },
30    /// Set or update a rename mapping for one managed item.
31    SetRename {
32        source_name: SourceName,
33        from: String,
34        to: String,
35    },
36}
37
38/// Metadata captured when `UpsertDependency` mutates an existing/new dependency.
39#[derive(Debug, Clone)]
40pub struct DependencyUpsertChange {
41    pub name: SourceName,
42    pub already_exists: bool,
43    pub old_filter: Option<FilterConfig>,
44    pub new_filter: FilterConfig,
45}
46
47/// Apply a config mutation to the in-memory config.
48///
49/// Public so that CLI commands can batch mutations before triggering sync.
50pub fn apply_config_mutation(
51    config: &mut Config,
52    mutation: &ConfigMutation,
53) -> Result<(), MarsError> {
54    apply_mutation(config, mutation).map(|_| ())
55}
56
57pub(crate) fn apply_mutation(
58    config: &mut Config,
59    mutation: &ConfigMutation,
60) -> Result<Vec<DependencyUpsertChange>, MarsError> {
61    match mutation {
62        ConfigMutation::UpsertDependency { name, entry } => {
63            Ok(vec![apply_dependency_upsert(config, name, entry)])
64        }
65        ConfigMutation::BatchUpsert(entries) => {
66            let mut changes = Vec::with_capacity(entries.len());
67            for (name, entry) in entries {
68                changes.push(apply_dependency_upsert(config, name, entry));
69            }
70            Ok(changes)
71        }
72        ConfigMutation::RemoveDependency { name } => {
73            if !config.dependencies.contains_key(name) {
74                return Err(MarsError::Source {
75                    source_name: name.to_string(),
76                    message: format!("dependency `{name}` not found in mars.toml"),
77                });
78            }
79            config.dependencies.shift_remove(name);
80            Ok(Vec::new())
81        }
82        ConfigMutation::SetOverride { source_name, .. } => {
83            if !config.dependencies.contains_key(source_name) {
84                return Err(MarsError::Source {
85                    source_name: source_name.to_string(),
86                    message: format!("dependency `{source_name}` not found in mars.toml"),
87                });
88            }
89            Ok(Vec::new())
90        }
91        ConfigMutation::SetRename {
92            source_name,
93            from,
94            to,
95        } => {
96            let dep =
97                config
98                    .dependencies
99                    .get_mut(source_name)
100                    .ok_or_else(|| MarsError::Source {
101                        source_name: source_name.to_string(),
102                        message: format!("dependency `{source_name}` not found in mars.toml"),
103                    })?;
104            let rename_map = dep.filter.rename.get_or_insert_with(RenameMap::new);
105            rename_map.insert(ItemName::from(from.as_str()), ItemName::from(to.as_str()));
106            Ok(Vec::new())
107        }
108        ConfigMutation::ClearOverride { .. } => Ok(Vec::new()),
109    }
110}
111
112pub(crate) fn apply_local_mutation(local: &mut LocalConfig, mutation: &ConfigMutation) {
113    match mutation {
114        ConfigMutation::SetOverride {
115            source_name,
116            local_path,
117        } => {
118            local.overrides.insert(
119                source_name.clone(),
120                OverrideEntry {
121                    path: local_path.clone(),
122                },
123            );
124        }
125        ConfigMutation::ClearOverride { source_name } => {
126            local.overrides.shift_remove(source_name);
127        }
128        ConfigMutation::UpsertDependency { .. }
129        | ConfigMutation::BatchUpsert(..)
130        | ConfigMutation::RemoveDependency { .. }
131        | ConfigMutation::SetRename { .. } => {}
132    }
133}
134
135fn apply_dependency_upsert(
136    config: &mut Config,
137    name: &SourceName,
138    entry: &DependencyEntry,
139) -> DependencyUpsertChange {
140    if let Some(existing) = config.dependencies.get_mut(name) {
141        let old_filter = existing.filter.clone();
142
143        // Merge: update location fields, preserve user customizations
144        existing.url = entry.url.clone();
145        existing.path = entry.path.clone();
146        existing.version = entry.version.clone();
147        // Atomic filter replacement: when any filter field is set on the
148        // incoming entry, replace the entire filter config (minus rename).
149        // This prevents mixed-mode states like agents + only_skills.
150        // When no filter flags are provided (e.g., version bump), preserve existing.
151        if entry.filter.has_any_filter() {
152            let rename = existing.filter.rename.take();
153            existing.filter = entry.filter.clone();
154            // Preserve rename — those are set via `mars rename`, not `mars add`
155            existing.filter.rename = rename;
156        }
157        // Never overwrite rename rules from add — those are set via `mars rename`
158
159        DependencyUpsertChange {
160            name: name.clone(),
161            already_exists: true,
162            old_filter: Some(old_filter),
163            new_filter: existing.filter.clone(),
164        }
165    } else {
166        config.dependencies.insert(name.clone(), entry.clone());
167        DependencyUpsertChange {
168            name: name.clone(),
169            already_exists: false,
170            old_filter: None,
171            new_filter: entry.filter.clone(),
172        }
173    }
174}
175
176pub(crate) fn is_config_not_found(error: &MarsError) -> bool {
177    matches!(error, MarsError::Config(ConfigError::NotFound { .. }))
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn apply_mutation_atomic_filter_replacement() {
186        let mut config = Config::default();
187        // First add with agents filter
188        let entry1 = DependencyEntry {
189            url: Some("https://github.com/org/base.git".into()),
190            path: None,
191            version: Some("v1".into()),
192            filter: FilterConfig {
193                agents: Some(vec!["reviewer".into()]),
194                ..FilterConfig::default()
195            },
196        };
197        apply_mutation(
198            &mut config,
199            &ConfigMutation::UpsertDependency {
200                name: "base".into(),
201                entry: entry1,
202            },
203        )
204        .unwrap();
205        assert!(config.dependencies["base"].filter.agents.is_some());
206
207        // Re-add with only_skills — should atomically replace, clearing agents
208        let entry2 = DependencyEntry {
209            url: Some("https://github.com/org/base.git".into()),
210            path: None,
211            version: Some("v1".into()),
212            filter: FilterConfig {
213                only_skills: true,
214                ..FilterConfig::default()
215            },
216        };
217        apply_mutation(
218            &mut config,
219            &ConfigMutation::UpsertDependency {
220                name: "base".into(),
221                entry: entry2,
222            },
223        )
224        .unwrap();
225
226        let dep = &config.dependencies["base"];
227        assert!(dep.filter.only_skills);
228        assert!(
229            dep.filter.agents.is_none(),
230            "agents should be cleared by atomic replacement"
231        );
232    }
233
234    #[test]
235    fn apply_mutation_preserves_filters_on_version_bump() {
236        let mut config = Config::default();
237        // Add with agents filter
238        let entry1 = DependencyEntry {
239            url: Some("https://github.com/org/base.git".into()),
240            path: None,
241            version: Some("v1".into()),
242            filter: FilterConfig {
243                agents: Some(vec!["coder".into()]),
244                ..FilterConfig::default()
245            },
246        };
247        apply_mutation(
248            &mut config,
249            &ConfigMutation::UpsertDependency {
250                name: "base".into(),
251                entry: entry1,
252            },
253        )
254        .unwrap();
255
256        // Re-add with no filter (version bump only)
257        let entry2 = DependencyEntry {
258            url: Some("https://github.com/org/base.git".into()),
259            path: None,
260            version: Some("v2".into()),
261            filter: FilterConfig::default(),
262        };
263        apply_mutation(
264            &mut config,
265            &ConfigMutation::UpsertDependency {
266                name: "base".into(),
267                entry: entry2,
268            },
269        )
270        .unwrap();
271
272        let dep = &config.dependencies["base"];
273        assert_eq!(dep.version.as_deref(), Some("v2"));
274        assert_eq!(
275            dep.filter.agents.as_deref(),
276            Some(&["coder".into()][..]),
277            "agents filter should be preserved on version bump"
278        );
279    }
280
281    #[test]
282    fn apply_mutation_preserves_rename_on_filter_change() {
283        let mut config = Config::default();
284        let mut rename_map = RenameMap::new();
285        rename_map.insert("old".into(), "new".into());
286
287        let entry1 = DependencyEntry {
288            url: Some("https://github.com/org/base.git".into()),
289            path: None,
290            version: None,
291            filter: FilterConfig {
292                agents: Some(vec!["coder".into()]),
293                rename: Some(rename_map),
294                ..FilterConfig::default()
295            },
296        };
297        apply_mutation(
298            &mut config,
299            &ConfigMutation::UpsertDependency {
300                name: "base".into(),
301                entry: entry1,
302            },
303        )
304        .unwrap();
305
306        // Re-add with different filter — rename should be preserved
307        let entry2 = DependencyEntry {
308            url: Some("https://github.com/org/base.git".into()),
309            path: None,
310            version: None,
311            filter: FilterConfig {
312                only_skills: true,
313                ..FilterConfig::default()
314            },
315        };
316        apply_mutation(
317            &mut config,
318            &ConfigMutation::UpsertDependency {
319                name: "base".into(),
320                entry: entry2,
321            },
322        )
323        .unwrap();
324
325        let dep = &config.dependencies["base"];
326        assert!(dep.filter.only_skills);
327        assert!(dep.filter.agents.is_none());
328        assert!(
329            dep.filter.rename.is_some(),
330            "rename should be preserved across filter changes"
331        );
332        assert_eq!(
333            dep.filter.rename.as_ref().unwrap().get("old").unwrap(),
334            "new"
335        );
336    }
337
338    #[test]
339    fn apply_mutation_batch_upsert_applies_all_entries() {
340        let mut config = Config::default();
341        let batch = vec![
342            (
343                "base".into(),
344                DependencyEntry {
345                    url: Some("https://github.com/org/base.git".into()),
346                    path: None,
347                    version: Some("v1".into()),
348                    filter: FilterConfig::default(),
349                },
350            ),
351            (
352                "workflow".into(),
353                DependencyEntry {
354                    url: Some("https://github.com/org/workflow.git".into()),
355                    path: None,
356                    version: Some("v2".into()),
357                    filter: FilterConfig::default(),
358                },
359            ),
360        ];
361
362        let changes = apply_mutation(&mut config, &ConfigMutation::BatchUpsert(batch)).unwrap();
363        assert_eq!(changes.len(), 2);
364        assert!(config.dependencies.contains_key("base"));
365        assert!(config.dependencies.contains_key("workflow"));
366    }
367
368    #[test]
369    fn apply_mutation_returns_old_and_new_filters_for_readd() {
370        let mut config = Config::default();
371        let entry1 = DependencyEntry {
372            url: Some("https://github.com/org/base.git".into()),
373            path: None,
374            version: Some("v1".into()),
375            filter: FilterConfig {
376                agents: Some(vec!["reviewer".into()]),
377                ..FilterConfig::default()
378            },
379        };
380        apply_mutation(
381            &mut config,
382            &ConfigMutation::UpsertDependency {
383                name: "base".into(),
384                entry: entry1,
385            },
386        )
387        .unwrap();
388
389        let entry2 = DependencyEntry {
390            url: Some("https://github.com/org/base.git".into()),
391            path: None,
392            version: Some("v2".into()),
393            filter: FilterConfig {
394                only_skills: true,
395                ..FilterConfig::default()
396            },
397        };
398        let changes = apply_mutation(
399            &mut config,
400            &ConfigMutation::UpsertDependency {
401                name: "base".into(),
402                entry: entry2,
403            },
404        )
405        .unwrap();
406
407        assert_eq!(changes.len(), 1);
408        let change = &changes[0];
409        assert!(change.already_exists);
410        assert_eq!(change.name, "base");
411        assert_eq!(
412            change.old_filter.as_ref().and_then(|f| f.agents.as_deref()),
413            Some(&["reviewer".into()][..])
414        );
415        assert!(change.new_filter.only_skills);
416        assert!(change.new_filter.agents.is_none());
417    }
418}