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