1use 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#[derive(Debug, Clone)]
13pub enum ConfigMutation {
14 UpsertDependency {
16 name: SourceName,
17 entry: DependencyEntry,
18 },
19 BatchUpsert(Vec<(SourceName, DependencyEntry)>),
21 RemoveDependency { name: SourceName },
23 SetOverride {
25 source_name: SourceName,
26 local_path: PathBuf,
27 },
28 ClearOverride { source_name: SourceName },
30 SetRename {
32 source_name: SourceName,
33 from: String,
34 to: String,
35 },
36}
37
38#[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
47pub 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 existing.url = entry.url.clone();
145 existing.path = entry.path.clone();
146 existing.version = entry.version.clone();
147 if entry.filter.has_any_filter() {
152 let rename = existing.filter.rename.take();
153 existing.filter = entry.filter.clone();
154 existing.filter.rename = rename;
156 }
157 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 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 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 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 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 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}