1use crate::config::{DependencyEntry, FilterConfig};
4use crate::error::{ConfigError, MarsError};
5use crate::source::parse;
6use crate::sync::{
7 ConfigMutation, DependencyUpsertChange, ResolutionMode, SyncOptions, SyncRequest,
8};
9use crate::types::{ItemName, SourceName, SourceSubpath};
10
11use super::output;
12
13#[derive(Debug, clap::Args)]
15pub struct AddArgs {
16 #[arg(required = true)]
18 pub sources: Vec<String>,
19
20 #[arg(long)]
22 pub subpath: Option<String>,
23
24 #[arg(long, value_delimiter = ',')]
26 pub agents: Vec<String>,
27
28 #[arg(long, value_delimiter = ',')]
30 pub skills: Vec<String>,
31
32 #[arg(long, value_delimiter = ',')]
34 pub exclude: Vec<String>,
35
36 #[arg(long)]
38 pub only_skills: bool,
39
40 #[arg(long)]
42 pub only_agents: bool,
43}
44
45#[derive(Debug)]
47struct ParsedDependency {
48 name: SourceName,
49 entry: DependencyEntry,
50}
51
52pub fn run(args: &AddArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
54 let has_filters = !args.agents.is_empty()
56 || !args.skills.is_empty()
57 || !args.exclude.is_empty()
58 || args.only_skills
59 || args.only_agents;
60
61 if has_filters && args.sources.len() > 1 {
62 return Err(MarsError::InvalidRequest {
63 message: "filters may only be used when adding exactly one source".to_string(),
64 });
65 }
66 if args.subpath.is_some() && args.sources.len() != 1 {
67 return Err(MarsError::InvalidRequest {
68 message: "--subpath requires exactly one source argument".to_string(),
69 });
70 }
71
72 let filter_config = build_filter_config(args);
74 crate::config::validate_filter(&filter_config, "cli")?;
75
76 let mutations: Vec<(SourceName, DependencyEntry)> = args
78 .sources
79 .iter()
80 .map(|source| {
81 let parsed = parse_dependency_specifier(source, args.subpath.as_deref())?;
82 let entry = DependencyEntry {
83 url: parsed.entry.url,
84 path: parsed.entry.path,
85 subpath: parsed.entry.subpath,
86 version: parsed.entry.version,
87 filter: filter_config.clone(),
88 };
89 Ok((parsed.name, entry))
90 })
91 .collect::<Result<Vec<_>, MarsError>>()?;
92
93 if mutations.len() == 1 {
96 let (name, entry) = mutations.into_iter().next().unwrap();
97
98 let request = SyncRequest {
99 resolution: ResolutionMode::Normal,
100 mutation: Some(ConfigMutation::UpsertDependency {
101 name: name.clone(),
102 entry,
103 }),
104 options: SyncOptions::default(),
105 };
106
107 let report = crate::sync::execute(ctx, &request)?;
108
109 if !json {
110 print_dependency_messages(&report.dependency_changes);
111 }
112
113 output::print_sync_report(&report, json, true);
114 return if report.has_conflicts() { Ok(1) } else { Ok(0) };
115 }
116
117 let request = SyncRequest {
119 resolution: ResolutionMode::Normal,
120 mutation: Some(ConfigMutation::BatchUpsert(mutations)),
121 options: SyncOptions::default(),
122 };
123
124 let report = crate::sync::execute(ctx, &request)?;
125
126 if !json {
127 print_dependency_messages(&report.dependency_changes);
128 }
129
130 output::print_sync_report(&report, json, true);
131 if report.has_conflicts() { Ok(1) } else { Ok(0) }
132}
133
134fn build_filter_config(args: &AddArgs) -> FilterConfig {
136 FilterConfig {
137 agents: if args.agents.is_empty() {
138 None
139 } else {
140 Some(
141 args.agents
142 .iter()
143 .map(|v| ItemName::from(v.as_str()))
144 .collect(),
145 )
146 },
147 skills: if args.skills.is_empty() {
148 None
149 } else {
150 Some(
151 args.skills
152 .iter()
153 .map(|v| ItemName::from(v.as_str()))
154 .collect(),
155 )
156 },
157 exclude: if args.exclude.is_empty() {
158 None
159 } else {
160 Some(
161 args.exclude
162 .iter()
163 .map(|v| ItemName::from(v.as_str()))
164 .collect(),
165 )
166 },
167 rename: None,
168 only_skills: args.only_skills,
169 only_agents: args.only_agents,
170 }
171}
172
173fn parse_dependency_specifier(
182 spec: &str,
183 explicit_subpath: Option<&str>,
184) -> Result<ParsedDependency, MarsError> {
185 let parsed = parse::parse(spec).map_err(|e| {
186 MarsError::Config(ConfigError::Invalid {
187 message: e.to_string(),
188 })
189 })?;
190
191 let explicit_subpath = explicit_subpath
192 .map(|value| {
193 SourceSubpath::new(value).map_err(|e| {
194 MarsError::Config(ConfigError::Invalid {
195 message: e.to_string(),
196 })
197 })
198 })
199 .transpose()?;
200 let subpath = merge_subpath(parsed.subpath.clone(), explicit_subpath)?;
201 let name = derive_dependency_name(&parsed, subpath.as_ref())?;
202
203 Ok(ParsedDependency {
204 name: SourceName::from(name),
205 entry: DependencyEntry {
206 url: parsed.url,
207 path: parsed.path,
208 subpath,
209 version: parsed.version,
210 filter: FilterConfig::default(),
211 },
212 })
213}
214
215fn merge_subpath(
216 parsed_subpath: Option<SourceSubpath>,
217 explicit_subpath: Option<SourceSubpath>,
218) -> Result<Option<SourceSubpath>, MarsError> {
219 match (parsed_subpath, explicit_subpath) {
220 (Some(parsed), Some(explicit)) if parsed != explicit => Err(MarsError::InvalidRequest {
221 message: format!(
222 "conflicting subpath input: source provides `{parsed}` but --subpath provides `{explicit}`"
223 ),
224 }),
225 (Some(parsed), Some(_)) => Ok(Some(parsed)),
226 (Some(parsed), None) => Ok(Some(parsed)),
227 (None, Some(explicit)) => Ok(Some(explicit)),
228 (None, None) => Ok(None),
229 }
230}
231
232fn derive_dependency_name(
233 parsed: &parse::ParsedSourceSpec,
234 subpath: Option<&SourceSubpath>,
235) -> Result<String, MarsError> {
236 let root_name = parsed.name.split('/').next().ok_or_else(|| {
237 MarsError::Config(ConfigError::Invalid {
238 message: format!("cannot derive dependency name from `{}`", parsed.raw),
239 })
240 })?;
241
242 Ok(match subpath {
243 Some(subpath) => format!("{root_name}/{}", subpath.as_str()),
244 None => root_name.to_string(),
245 })
246}
247
248fn print_dependency_messages(changes: &[DependencyUpsertChange]) {
249 for change in changes {
250 if change.already_exists {
251 output::print_warn(&format!(
252 "dependency `{}` already exists — updated",
253 change.name
254 ));
255 if let Some(old_filter) = &change.old_filter
256 && old_filter != &change.new_filter
257 {
258 output::print_info(&format!(
259 "filters changed: {} → {}",
260 format_filter(old_filter),
261 format_filter(&change.new_filter)
262 ));
263 }
264 } else {
265 output::print_info(&format!("added dependency `{}`", change.name));
266 }
267 }
268}
269
270fn format_filter(filter: &FilterConfig) -> String {
271 if filter.only_skills {
272 return "only_skills=true".to_string();
273 }
274 if filter.only_agents {
275 return "only_agents=true".to_string();
276 }
277
278 let mut parts = Vec::new();
279 if let Some(agents) = &filter.agents {
280 parts.push(format!("agents=[{}]", format_item_names(agents)));
281 }
282 if let Some(skills) = &filter.skills {
283 parts.push(format!("skills=[{}]", format_item_names(skills)));
284 }
285 if let Some(exclude) = &filter.exclude {
286 parts.push(format!("exclude=[{}]", format_item_names(exclude)));
287 }
288
289 if parts.is_empty() {
290 "all".to_string()
291 } else {
292 parts.join(", ")
293 }
294}
295
296fn format_item_names(items: &[ItemName]) -> String {
297 items
298 .iter()
299 .map(|item| item.to_string())
300 .collect::<Vec<_>>()
301 .join(",")
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use crate::sync::DependencyUpsertChange;
308 use std::path::Path;
309
310 #[test]
311 fn parse_github_shorthand() {
312 let parsed = parse_dependency_specifier("meridian-flow/meridian-base", None).unwrap();
313 assert_eq!(parsed.name, "meridian-base");
314 assert_eq!(
315 parsed.entry.url.as_deref(),
316 Some("https://github.com/meridian-flow/meridian-base")
317 );
318 assert!(parsed.entry.path.is_none());
319 assert!(parsed.entry.version.is_none());
320 }
321
322 #[test]
323 fn parse_github_shorthand_with_version() {
324 let parsed =
325 parse_dependency_specifier("meridian-flow/meridian-base@v0.5.0", None).unwrap();
326 assert_eq!(parsed.name, "meridian-base");
327 assert_eq!(
328 parsed.entry.url.as_deref(),
329 Some("https://github.com/meridian-flow/meridian-base")
330 );
331 assert_eq!(parsed.entry.version.as_deref(), Some("v0.5.0"));
332 }
333
334 #[test]
335 fn parse_full_url() {
336 let parsed =
337 parse_dependency_specifier("github.com/meridian-flow/meridian-dev-workflow@v2", None)
338 .unwrap();
339 assert_eq!(parsed.name, "meridian-dev-workflow");
340 assert_eq!(
341 parsed.entry.url.as_deref(),
342 Some("https://github.com/meridian-flow/meridian-dev-workflow")
343 );
344 assert_eq!(parsed.entry.version.as_deref(), Some("v2"));
345 }
346
347 #[test]
348 fn parse_https_url() {
349 let parsed =
350 parse_dependency_specifier("https://github.com/someone/cool-agents.git", None).unwrap();
351 assert_eq!(parsed.name, "cool-agents");
352 assert_eq!(
353 parsed.entry.url.as_deref(),
354 Some("https://github.com/someone/cool-agents")
355 );
356 }
357
358 #[test]
359 fn parse_ssh_url() {
360 let parsed =
361 parse_dependency_specifier("git@github.com:someone/cool-agents.git", None).unwrap();
362 assert_eq!(parsed.name, "cool-agents");
363 assert_eq!(
364 parsed.entry.url.as_deref(),
365 Some("git@github.com:someone/cool-agents.git")
366 );
367 assert!(parsed.entry.version.is_none());
368 }
369
370 #[test]
371 fn parse_ssh_url_keeps_at_suffix_in_path() {
372 let parsed =
373 parse_dependency_specifier("git@github.com:someone/cool-agents.git@v2", None).unwrap();
374 assert_eq!(parsed.name, "cool-agents");
375 assert_eq!(
376 parsed.entry.url.as_deref(),
377 Some("git@github.com:someone/cool-agents.git")
378 );
379 assert_eq!(parsed.entry.version.as_deref(), Some("v2"));
380 }
381
382 #[test]
383 fn parse_local_path_relative() {
384 let parsed = parse_dependency_specifier("./my-agents", None).unwrap();
385 assert_eq!(parsed.name, "my-agents");
386 assert!(parsed.entry.url.is_none());
387 assert_eq!(parsed.entry.path.as_deref(), Some(Path::new("./my-agents")));
388 }
389
390 #[test]
391 fn parse_local_path_parent() {
392 let parsed = parse_dependency_specifier("../meridian-dev-workflow", None).unwrap();
393 assert_eq!(parsed.name, "meridian-dev-workflow");
394 assert!(parsed.entry.url.is_none());
395 assert_eq!(
396 parsed.entry.path.as_deref(),
397 Some(Path::new("../meridian-dev-workflow"))
398 );
399 }
400
401 #[test]
402 fn parse_local_path_absolute() {
403 let parsed = parse_dependency_specifier("/home/dev/agents", None).unwrap();
404 assert_eq!(parsed.name, "agents");
405 assert!(parsed.entry.url.is_none());
406 assert_eq!(
407 parsed.entry.path.as_deref(),
408 Some(Path::new("/home/dev/agents"))
409 );
410 }
411
412 #[test]
413 fn parse_source_embedded_subpath() {
414 let parsed = parse_dependency_specifier("owner/repo/plugins/foo", None).unwrap();
415 assert_eq!(parsed.name, "repo/plugins/foo");
416 assert_eq!(
417 parsed.entry.subpath.as_ref().map(SourceSubpath::as_str),
418 Some("plugins/foo")
419 );
420 }
421
422 #[test]
423 fn parse_explicit_subpath_merges_when_source_has_none() {
424 let parsed =
425 parse_dependency_specifier("gitlab:group/subgroup/repo", Some("plugins/foo")).unwrap();
426 assert_eq!(parsed.name, "repo/plugins/foo");
427 assert_eq!(
428 parsed.entry.subpath.as_ref().map(SourceSubpath::as_str),
429 Some("plugins/foo")
430 );
431 }
432
433 #[test]
434 fn conflicting_subpath_is_rejected() {
435 let err =
436 parse_dependency_specifier("owner/repo/plugins/foo", Some("plugins/bar")).unwrap_err();
437 assert!(matches!(err, MarsError::InvalidRequest { .. }));
438 }
439
440 #[test]
441 fn format_filter_all() {
442 assert_eq!(format_filter(&FilterConfig::default()), "all");
443 }
444
445 #[test]
446 fn format_filter_only_modes() {
447 assert_eq!(
448 format_filter(&FilterConfig {
449 only_skills: true,
450 ..FilterConfig::default()
451 }),
452 "only_skills=true"
453 );
454 assert_eq!(
455 format_filter(&FilterConfig {
456 only_agents: true,
457 ..FilterConfig::default()
458 }),
459 "only_agents=true"
460 );
461 }
462
463 #[test]
464 fn format_filter_lists() {
465 assert_eq!(
466 format_filter(&FilterConfig {
467 agents: Some(vec!["reviewer".into(), "planner".into()]),
468 ..FilterConfig::default()
469 }),
470 "agents=[reviewer,planner]"
471 );
472 assert_eq!(
473 format_filter(&FilterConfig {
474 exclude: Some(vec!["legacy".into()]),
475 ..FilterConfig::default()
476 }),
477 "exclude=[legacy]"
478 );
479 }
480
481 #[test]
482 fn detects_filter_change_for_message() {
483 let old_filter = FilterConfig {
484 agents: Some(vec!["reviewer".into()]),
485 ..FilterConfig::default()
486 };
487 let change = DependencyUpsertChange {
488 name: "ops".into(),
489 already_exists: true,
490 old_version: Some("v0.1.0".into()),
491 new_version: Some("v0.1.0".into()),
492 old_filter: Some(old_filter.clone()),
493 new_filter: FilterConfig {
494 only_skills: true,
495 ..FilterConfig::default()
496 },
497 };
498 assert_ne!(change.old_filter.as_ref(), Some(&change.new_filter));
499 assert_eq!(format_filter(&old_filter), "agents=[reviewer]");
500 assert_eq!(format_filter(&change.new_filter), "only_skills=true");
501 }
502}