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