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