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_version: Option<String>,
44 pub new_version: Option<String>,
45 pub old_filter: Option<FilterConfig>,
46 pub new_filter: FilterConfig,
47}
48
49pub 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 existing.url = entry.url.clone();
148 existing.path = entry.path.clone();
149 existing.version = entry.version.clone();
150 if entry.filter.has_any_filter() {
155 let rename = existing.filter.rename.take();
156 existing.filter = entry.filter.clone();
157 existing.filter.rename = rename;
159 }
160 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 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 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 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 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 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}