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