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