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 SetRename {
30 source_name: SourceName,
31 from: String,
32 to: String,
33 },
34}
35
36#[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 existing.url = entry.url.clone();
124 existing.path = entry.path.clone();
125 existing.version = entry.version.clone();
126 if entry.filter.has_any_filter() {
131 let rename = existing.filter.rename.take();
132 existing.filter = entry.filter.clone();
133 existing.filter.rename = rename;
135 }
136 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 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 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 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 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 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}