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};
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, value_delimiter = ',')]
22 pub agents: Vec<String>,
23
24 #[arg(long, value_delimiter = ',')]
26 pub skills: Vec<String>,
27
28 #[arg(long, value_delimiter = ',')]
30 pub exclude: Vec<String>,
31
32 #[arg(long)]
34 pub only_skills: bool,
35
36 #[arg(long)]
38 pub only_agents: bool,
39}
40
41struct ParsedDependency {
43 name: SourceName,
44 entry: DependencyEntry,
45}
46
47pub fn run(args: &AddArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
49 let has_filters = !args.agents.is_empty()
51 || !args.skills.is_empty()
52 || !args.exclude.is_empty()
53 || args.only_skills
54 || args.only_agents;
55
56 if has_filters && args.sources.len() > 1 {
57 return Err(MarsError::InvalidRequest {
58 message: "filters may only be used when adding exactly one source".to_string(),
59 });
60 }
61
62 let filter_config = build_filter_config(args);
64 crate::config::validate_filter(&filter_config, "cli")?;
65
66 let mutations: Vec<(SourceName, DependencyEntry)> = args
68 .sources
69 .iter()
70 .map(|source| {
71 let parsed = parse_dependency_specifier(source)?;
72 let entry = DependencyEntry {
73 url: parsed.entry.url,
74 path: parsed.entry.path,
75 version: parsed.entry.version,
76 filter: filter_config.clone(),
77 };
78 Ok((parsed.name, entry))
79 })
80 .collect::<Result<Vec<_>, MarsError>>()?;
81
82 if mutations.len() == 1 {
85 let (name, entry) = mutations.into_iter().next().unwrap();
86
87 let request = SyncRequest {
88 resolution: ResolutionMode::Normal,
89 mutation: Some(ConfigMutation::UpsertDependency {
90 name: name.clone(),
91 entry,
92 }),
93 options: SyncOptions::default(),
94 };
95
96 let report = crate::sync::execute(ctx, &request)?;
97
98 if !json {
99 print_dependency_messages(&report.dependency_changes);
100 }
101
102 output::print_sync_report(&report, json);
103 return if report.has_conflicts() { Ok(1) } else { Ok(0) };
104 }
105
106 let request = SyncRequest {
108 resolution: ResolutionMode::Normal,
109 mutation: Some(ConfigMutation::BatchUpsert(mutations)),
110 options: SyncOptions::default(),
111 };
112
113 let report = crate::sync::execute(ctx, &request)?;
114
115 if !json {
116 print_dependency_messages(&report.dependency_changes);
117 }
118
119 output::print_sync_report(&report, json);
120 if report.has_conflicts() { Ok(1) } else { Ok(0) }
121}
122
123fn build_filter_config(args: &AddArgs) -> FilterConfig {
125 FilterConfig {
126 agents: if args.agents.is_empty() {
127 None
128 } else {
129 Some(
130 args.agents
131 .iter()
132 .map(|v| ItemName::from(v.as_str()))
133 .collect(),
134 )
135 },
136 skills: if args.skills.is_empty() {
137 None
138 } else {
139 Some(
140 args.skills
141 .iter()
142 .map(|v| ItemName::from(v.as_str()))
143 .collect(),
144 )
145 },
146 exclude: if args.exclude.is_empty() {
147 None
148 } else {
149 Some(
150 args.exclude
151 .iter()
152 .map(|v| ItemName::from(v.as_str()))
153 .collect(),
154 )
155 },
156 rename: None,
157 only_skills: args.only_skills,
158 only_agents: args.only_agents,
159 }
160}
161
162fn parse_dependency_specifier(spec: &str) -> Result<ParsedDependency, MarsError> {
171 let parsed = parse::parse(spec).map_err(|e| {
172 MarsError::Config(ConfigError::Invalid {
173 message: e.to_string(),
174 })
175 })?;
176
177 Ok(ParsedDependency {
178 name: SourceName::from(parsed.name),
179 entry: DependencyEntry {
180 url: parsed.url,
181 path: parsed.path,
182 version: parsed.version,
183 filter: FilterConfig::default(),
184 },
185 })
186}
187
188fn print_dependency_messages(changes: &[DependencyUpsertChange]) {
189 for change in changes {
190 if change.already_exists {
191 output::print_warn(&format!(
192 "dependency `{}` already exists — updated",
193 change.name
194 ));
195 if let Some(old_filter) = &change.old_filter
196 && old_filter != &change.new_filter
197 {
198 output::print_info(&format!(
199 "filters changed: {} → {}",
200 format_filter(old_filter),
201 format_filter(&change.new_filter)
202 ));
203 }
204 } else {
205 output::print_info(&format!("added dependency `{}`", change.name));
206 }
207 }
208}
209
210fn format_filter(filter: &FilterConfig) -> String {
211 if filter.only_skills {
212 return "only_skills=true".to_string();
213 }
214 if filter.only_agents {
215 return "only_agents=true".to_string();
216 }
217
218 let mut parts = Vec::new();
219 if let Some(agents) = &filter.agents {
220 parts.push(format!("agents=[{}]", format_item_names(agents)));
221 }
222 if let Some(skills) = &filter.skills {
223 parts.push(format!("skills=[{}]", format_item_names(skills)));
224 }
225 if let Some(exclude) = &filter.exclude {
226 parts.push(format!("exclude=[{}]", format_item_names(exclude)));
227 }
228
229 if parts.is_empty() {
230 "all".to_string()
231 } else {
232 parts.join(", ")
233 }
234}
235
236fn format_item_names(items: &[ItemName]) -> String {
237 items
238 .iter()
239 .map(|item| item.to_string())
240 .collect::<Vec<_>>()
241 .join(",")
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::sync::DependencyUpsertChange;
248 use std::path::Path;
249
250 #[test]
251 fn parse_github_shorthand() {
252 let parsed = parse_dependency_specifier("haowjy/meridian-base").unwrap();
253 assert_eq!(parsed.name, "meridian-base");
254 assert_eq!(
255 parsed.entry.url.as_deref(),
256 Some("https://github.com/haowjy/meridian-base")
257 );
258 assert!(parsed.entry.path.is_none());
259 assert!(parsed.entry.version.is_none());
260 }
261
262 #[test]
263 fn parse_github_shorthand_with_version() {
264 let parsed = parse_dependency_specifier("haowjy/meridian-base@v0.5.0").unwrap();
265 assert_eq!(parsed.name, "meridian-base");
266 assert_eq!(
267 parsed.entry.url.as_deref(),
268 Some("https://github.com/haowjy/meridian-base")
269 );
270 assert_eq!(parsed.entry.version.as_deref(), Some("v0.5.0"));
271 }
272
273 #[test]
274 fn parse_full_url() {
275 let parsed =
276 parse_dependency_specifier("github.com/haowjy/meridian-dev-workflow@v2").unwrap();
277 assert_eq!(parsed.name, "meridian-dev-workflow");
278 assert_eq!(
279 parsed.entry.url.as_deref(),
280 Some("https://github.com/haowjy/meridian-dev-workflow")
281 );
282 assert_eq!(parsed.entry.version.as_deref(), Some("v2"));
283 }
284
285 #[test]
286 fn parse_https_url() {
287 let parsed =
288 parse_dependency_specifier("https://github.com/someone/cool-agents.git").unwrap();
289 assert_eq!(parsed.name, "cool-agents");
290 assert_eq!(
291 parsed.entry.url.as_deref(),
292 Some("https://github.com/someone/cool-agents")
293 );
294 }
295
296 #[test]
297 fn parse_ssh_url() {
298 let parsed = parse_dependency_specifier("git@github.com:someone/cool-agents.git").unwrap();
299 assert_eq!(parsed.name, "cool-agents");
300 assert_eq!(
301 parsed.entry.url.as_deref(),
302 Some("git@github.com:someone/cool-agents.git")
303 );
304 assert!(parsed.entry.version.is_none());
305 }
306
307 #[test]
308 fn parse_ssh_url_keeps_at_suffix_in_path() {
309 let parsed =
310 parse_dependency_specifier("git@github.com:someone/cool-agents.git@v2").unwrap();
311 assert_eq!(parsed.name, "cool-agents.git@v2");
312 assert_eq!(
313 parsed.entry.url.as_deref(),
314 Some("git@github.com:someone/cool-agents.git@v2")
315 );
316 assert!(parsed.entry.version.is_none());
317 }
318
319 #[test]
320 fn parse_local_path_relative() {
321 let parsed = parse_dependency_specifier("./my-agents").unwrap();
322 assert_eq!(parsed.name, "my-agents");
323 assert!(parsed.entry.url.is_none());
324 assert_eq!(parsed.entry.path.as_deref(), Some(Path::new("./my-agents")));
325 }
326
327 #[test]
328 fn parse_local_path_parent() {
329 let parsed = parse_dependency_specifier("../meridian-dev-workflow").unwrap();
330 assert_eq!(parsed.name, "meridian-dev-workflow");
331 assert!(parsed.entry.url.is_none());
332 assert_eq!(
333 parsed.entry.path.as_deref(),
334 Some(Path::new("../meridian-dev-workflow"))
335 );
336 }
337
338 #[test]
339 fn parse_local_path_absolute() {
340 let parsed = parse_dependency_specifier("/home/dev/agents").unwrap();
341 assert_eq!(parsed.name, "agents");
342 assert!(parsed.entry.url.is_none());
343 assert_eq!(
344 parsed.entry.path.as_deref(),
345 Some(Path::new("/home/dev/agents"))
346 );
347 }
348
349 #[test]
350 fn format_filter_all() {
351 assert_eq!(format_filter(&FilterConfig::default()), "all");
352 }
353
354 #[test]
355 fn format_filter_only_modes() {
356 assert_eq!(
357 format_filter(&FilterConfig {
358 only_skills: true,
359 ..FilterConfig::default()
360 }),
361 "only_skills=true"
362 );
363 assert_eq!(
364 format_filter(&FilterConfig {
365 only_agents: true,
366 ..FilterConfig::default()
367 }),
368 "only_agents=true"
369 );
370 }
371
372 #[test]
373 fn format_filter_lists() {
374 assert_eq!(
375 format_filter(&FilterConfig {
376 agents: Some(vec!["reviewer".into(), "planner".into()]),
377 ..FilterConfig::default()
378 }),
379 "agents=[reviewer,planner]"
380 );
381 assert_eq!(
382 format_filter(&FilterConfig {
383 exclude: Some(vec!["legacy".into()]),
384 ..FilterConfig::default()
385 }),
386 "exclude=[legacy]"
387 );
388 }
389
390 #[test]
391 fn detects_filter_change_for_message() {
392 let old_filter = FilterConfig {
393 agents: Some(vec!["reviewer".into()]),
394 ..FilterConfig::default()
395 };
396 let change = DependencyUpsertChange {
397 name: "ops".into(),
398 already_exists: true,
399 old_filter: Some(old_filter.clone()),
400 new_filter: FilterConfig {
401 only_skills: true,
402 ..FilterConfig::default()
403 },
404 };
405 assert_ne!(change.old_filter.as_ref(), Some(&change.new_filter));
406 assert_eq!(format_filter(&old_filter), "agents=[reviewer]");
407 assert_eq!(format_filter(&change.new_filter), "only_skills=true");
408 }
409}