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 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 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 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 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 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}