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