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            filter: FilterConfig {
201                agents: Some(vec!["reviewer".into()]),
202                ..FilterConfig::default()
203            },
204        };
205        apply_mutation(
206            &mut config,
207            &ConfigMutation::UpsertDependency {
208                name: "base".into(),
209                entry: entry1,
210            },
211        )
212        .unwrap();
213        assert!(config.dependencies["base"].filter.agents.is_some());
214
215        // Re-add with only_skills — should atomically replace, clearing agents
216        let entry2 = DependencyEntry {
217            url: Some("https://github.com/org/base.git".into()),
218            path: None,
219            subpath: None,
220            version: Some("v1".into()),
221            filter: FilterConfig {
222                only_skills: true,
223                ..FilterConfig::default()
224            },
225        };
226        apply_mutation(
227            &mut config,
228            &ConfigMutation::UpsertDependency {
229                name: "base".into(),
230                entry: entry2,
231            },
232        )
233        .unwrap();
234
235        let dep = &config.dependencies["base"];
236        assert!(dep.filter.only_skills);
237        assert!(
238            dep.filter.agents.is_none(),
239            "agents should be cleared by atomic replacement"
240        );
241    }
242
243    #[test]
244    fn apply_mutation_preserves_filters_on_version_bump() {
245        let mut config = Config::default();
246        // Add with agents filter
247        let entry1 = DependencyEntry {
248            url: Some("https://github.com/org/base.git".into()),
249            path: None,
250            subpath: None,
251            version: Some("v1".into()),
252            filter: FilterConfig {
253                agents: Some(vec!["coder".into()]),
254                ..FilterConfig::default()
255            },
256        };
257        apply_mutation(
258            &mut config,
259            &ConfigMutation::UpsertDependency {
260                name: "base".into(),
261                entry: entry1,
262            },
263        )
264        .unwrap();
265
266        // Re-add with no filter (version bump only)
267        let entry2 = DependencyEntry {
268            url: Some("https://github.com/org/base.git".into()),
269            path: None,
270            subpath: None,
271            version: Some("v2".into()),
272            filter: FilterConfig::default(),
273        };
274        apply_mutation(
275            &mut config,
276            &ConfigMutation::UpsertDependency {
277                name: "base".into(),
278                entry: entry2,
279            },
280        )
281        .unwrap();
282
283        let dep = &config.dependencies["base"];
284        assert_eq!(dep.version.as_deref(), Some("v2"));
285        assert_eq!(
286            dep.filter.agents.as_deref(),
287            Some(&["coder".into()][..]),
288            "agents filter should be preserved on version bump"
289        );
290    }
291
292    #[test]
293    fn apply_mutation_preserves_rename_on_filter_change() {
294        let mut config = Config::default();
295        let mut rename_map = RenameMap::new();
296        rename_map.insert("old".into(), "new".into());
297
298        let entry1 = DependencyEntry {
299            url: Some("https://github.com/org/base.git".into()),
300            path: None,
301            subpath: None,
302            version: None,
303            filter: FilterConfig {
304                agents: Some(vec!["coder".into()]),
305                rename: Some(rename_map),
306                ..FilterConfig::default()
307            },
308        };
309        apply_mutation(
310            &mut config,
311            &ConfigMutation::UpsertDependency {
312                name: "base".into(),
313                entry: entry1,
314            },
315        )
316        .unwrap();
317
318        // Re-add with different filter — rename should be preserved
319        let entry2 = DependencyEntry {
320            url: Some("https://github.com/org/base.git".into()),
321            path: None,
322            subpath: None,
323            version: None,
324            filter: FilterConfig {
325                only_skills: true,
326                ..FilterConfig::default()
327            },
328        };
329        apply_mutation(
330            &mut config,
331            &ConfigMutation::UpsertDependency {
332                name: "base".into(),
333                entry: entry2,
334            },
335        )
336        .unwrap();
337
338        let dep = &config.dependencies["base"];
339        assert!(dep.filter.only_skills);
340        assert!(dep.filter.agents.is_none());
341        assert!(
342            dep.filter.rename.is_some(),
343            "rename should be preserved across filter changes"
344        );
345        assert_eq!(
346            dep.filter.rename.as_ref().unwrap().get("old").unwrap(),
347            "new"
348        );
349    }
350
351    #[test]
352    fn apply_mutation_batch_upsert_applies_all_entries() {
353        let mut config = Config::default();
354        let batch = vec![
355            (
356                "base".into(),
357                DependencyEntry {
358                    url: Some("https://github.com/org/base.git".into()),
359                    path: None,
360                    subpath: None,
361                    version: Some("v1".into()),
362                    filter: FilterConfig::default(),
363                },
364            ),
365            (
366                "workflow".into(),
367                DependencyEntry {
368                    url: Some("https://github.com/org/workflow.git".into()),
369                    path: None,
370                    subpath: None,
371                    version: Some("v2".into()),
372                    filter: FilterConfig::default(),
373                },
374            ),
375        ];
376
377        let changes = apply_mutation(&mut config, &ConfigMutation::BatchUpsert(batch)).unwrap();
378        assert_eq!(changes.len(), 2);
379        assert!(config.dependencies.contains_key("base"));
380        assert!(config.dependencies.contains_key("workflow"));
381    }
382
383    #[test]
384    fn apply_mutation_returns_old_and_new_filters_for_readd() {
385        let mut config = Config::default();
386        let entry1 = DependencyEntry {
387            url: Some("https://github.com/org/base.git".into()),
388            path: None,
389            subpath: None,
390            version: Some("v1".into()),
391            filter: FilterConfig {
392                agents: Some(vec!["reviewer".into()]),
393                ..FilterConfig::default()
394            },
395        };
396        apply_mutation(
397            &mut config,
398            &ConfigMutation::UpsertDependency {
399                name: "base".into(),
400                entry: entry1,
401            },
402        )
403        .unwrap();
404
405        let entry2 = DependencyEntry {
406            url: Some("https://github.com/org/base.git".into()),
407            path: None,
408            subpath: None,
409            version: Some("v2".into()),
410            filter: FilterConfig {
411                only_skills: true,
412                ..FilterConfig::default()
413            },
414        };
415        let changes = apply_mutation(
416            &mut config,
417            &ConfigMutation::UpsertDependency {
418                name: "base".into(),
419                entry: entry2,
420            },
421        )
422        .unwrap();
423
424        assert_eq!(changes.len(), 1);
425        let change = &changes[0];
426        assert!(change.already_exists);
427        assert_eq!(change.name, "base");
428        assert_eq!(
429            change.old_filter.as_ref().and_then(|f| f.agents.as_deref()),
430            Some(&["reviewer".into()][..])
431        );
432        assert!(change.new_filter.only_skills);
433        assert!(change.new_filter.agents.is_none());
434    }
435}