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 {
94 force: false,
95 dry_run: false,
96 frozen: false,
97 no_refresh_models: false,
98 },
99 };
100
101 let report = crate::sync::execute(ctx, &request)?;
102
103 if !json {
104 print_dependency_messages(&report.dependency_changes);
105 }
106
107 output::print_sync_report(&report, json);
108 return if report.has_conflicts() { Ok(1) } else { Ok(0) };
109 }
110
111 let request = SyncRequest {
113 resolution: ResolutionMode::Normal,
114 mutation: Some(ConfigMutation::BatchUpsert(mutations)),
115 options: SyncOptions {
116 force: false,
117 dry_run: false,
118 frozen: false,
119 no_refresh_models: false,
120 },
121 };
122
123 let report = crate::sync::execute(ctx, &request)?;
124
125 if !json {
126 print_dependency_messages(&report.dependency_changes);
127 }
128
129 output::print_sync_report(&report, json);
130 if report.has_conflicts() { Ok(1) } else { Ok(0) }
131}
132
133fn build_filter_config(args: &AddArgs) -> FilterConfig {
135 FilterConfig {
136 agents: if args.agents.is_empty() {
137 None
138 } else {
139 Some(
140 args.agents
141 .iter()
142 .map(|v| ItemName::from(v.as_str()))
143 .collect(),
144 )
145 },
146 skills: if args.skills.is_empty() {
147 None
148 } else {
149 Some(
150 args.skills
151 .iter()
152 .map(|v| ItemName::from(v.as_str()))
153 .collect(),
154 )
155 },
156 exclude: if args.exclude.is_empty() {
157 None
158 } else {
159 Some(
160 args.exclude
161 .iter()
162 .map(|v| ItemName::from(v.as_str()))
163 .collect(),
164 )
165 },
166 rename: None,
167 only_skills: args.only_skills,
168 only_agents: args.only_agents,
169 }
170}
171
172fn parse_dependency_specifier(spec: &str) -> Result<ParsedDependency, MarsError> {
181 let parsed = parse::parse(spec).map_err(|e| {
182 MarsError::Config(ConfigError::Invalid {
183 message: e.to_string(),
184 })
185 })?;
186
187 Ok(ParsedDependency {
188 name: SourceName::from(parsed.name),
189 entry: DependencyEntry {
190 url: parsed.url,
191 path: parsed.path,
192 version: parsed.version,
193 filter: FilterConfig::default(),
194 },
195 })
196}
197
198fn print_dependency_messages(changes: &[DependencyUpsertChange]) {
199 for change in changes {
200 if change.already_exists {
201 output::print_warn(&format!(
202 "dependency `{}` already exists — updated",
203 change.name
204 ));
205 if let Some(old_filter) = &change.old_filter
206 && old_filter != &change.new_filter
207 {
208 output::print_info(&format!(
209 "filters changed: {} → {}",
210 format_filter(old_filter),
211 format_filter(&change.new_filter)
212 ));
213 }
214 } else {
215 output::print_info(&format!("added dependency `{}`", change.name));
216 }
217 }
218}
219
220fn format_filter(filter: &FilterConfig) -> String {
221 if filter.only_skills {
222 return "only_skills=true".to_string();
223 }
224 if filter.only_agents {
225 return "only_agents=true".to_string();
226 }
227
228 let mut parts = Vec::new();
229 if let Some(agents) = &filter.agents {
230 parts.push(format!("agents=[{}]", format_item_names(agents)));
231 }
232 if let Some(skills) = &filter.skills {
233 parts.push(format!("skills=[{}]", format_item_names(skills)));
234 }
235 if let Some(exclude) = &filter.exclude {
236 parts.push(format!("exclude=[{}]", format_item_names(exclude)));
237 }
238
239 if parts.is_empty() {
240 "all".to_string()
241 } else {
242 parts.join(", ")
243 }
244}
245
246fn format_item_names(items: &[ItemName]) -> String {
247 items
248 .iter()
249 .map(|item| item.to_string())
250 .collect::<Vec<_>>()
251 .join(",")
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::sync::DependencyUpsertChange;
258 use std::path::Path;
259
260 #[test]
261 fn parse_github_shorthand() {
262 let parsed = parse_dependency_specifier("haowjy/meridian-base").unwrap();
263 assert_eq!(parsed.name, "meridian-base");
264 assert_eq!(
265 parsed.entry.url.as_deref(),
266 Some("https://github.com/haowjy/meridian-base")
267 );
268 assert!(parsed.entry.path.is_none());
269 assert!(parsed.entry.version.is_none());
270 }
271
272 #[test]
273 fn parse_github_shorthand_with_version() {
274 let parsed = parse_dependency_specifier("haowjy/meridian-base@v0.5.0").unwrap();
275 assert_eq!(parsed.name, "meridian-base");
276 assert_eq!(
277 parsed.entry.url.as_deref(),
278 Some("https://github.com/haowjy/meridian-base")
279 );
280 assert_eq!(parsed.entry.version.as_deref(), Some("v0.5.0"));
281 }
282
283 #[test]
284 fn parse_full_url() {
285 let parsed =
286 parse_dependency_specifier("github.com/haowjy/meridian-dev-workflow@v2").unwrap();
287 assert_eq!(parsed.name, "meridian-dev-workflow");
288 assert_eq!(
289 parsed.entry.url.as_deref(),
290 Some("https://github.com/haowjy/meridian-dev-workflow")
291 );
292 assert_eq!(parsed.entry.version.as_deref(), Some("v2"));
293 }
294
295 #[test]
296 fn parse_https_url() {
297 let parsed =
298 parse_dependency_specifier("https://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("https://github.com/someone/cool-agents")
303 );
304 }
305
306 #[test]
307 fn parse_ssh_url() {
308 let parsed = parse_dependency_specifier("git@github.com:someone/cool-agents.git").unwrap();
309 assert_eq!(parsed.name, "cool-agents");
310 assert_eq!(
311 parsed.entry.url.as_deref(),
312 Some("git@github.com:someone/cool-agents.git")
313 );
314 assert!(parsed.entry.version.is_none());
315 }
316
317 #[test]
318 fn parse_ssh_url_keeps_at_suffix_in_path() {
319 let parsed =
320 parse_dependency_specifier("git@github.com:someone/cool-agents.git@v2").unwrap();
321 assert_eq!(parsed.name, "cool-agents.git@v2");
322 assert_eq!(
323 parsed.entry.url.as_deref(),
324 Some("git@github.com:someone/cool-agents.git@v2")
325 );
326 assert!(parsed.entry.version.is_none());
327 }
328
329 #[test]
330 fn parse_local_path_relative() {
331 let parsed = parse_dependency_specifier("./my-agents").unwrap();
332 assert_eq!(parsed.name, "my-agents");
333 assert!(parsed.entry.url.is_none());
334 assert_eq!(parsed.entry.path.as_deref(), Some(Path::new("./my-agents")));
335 }
336
337 #[test]
338 fn parse_local_path_parent() {
339 let parsed = parse_dependency_specifier("../meridian-dev-workflow").unwrap();
340 assert_eq!(parsed.name, "meridian-dev-workflow");
341 assert!(parsed.entry.url.is_none());
342 assert_eq!(
343 parsed.entry.path.as_deref(),
344 Some(Path::new("../meridian-dev-workflow"))
345 );
346 }
347
348 #[test]
349 fn parse_local_path_absolute() {
350 let parsed = parse_dependency_specifier("/home/dev/agents").unwrap();
351 assert_eq!(parsed.name, "agents");
352 assert!(parsed.entry.url.is_none());
353 assert_eq!(
354 parsed.entry.path.as_deref(),
355 Some(Path::new("/home/dev/agents"))
356 );
357 }
358
359 #[test]
360 fn format_filter_all() {
361 assert_eq!(format_filter(&FilterConfig::default()), "all");
362 }
363
364 #[test]
365 fn format_filter_only_modes() {
366 assert_eq!(
367 format_filter(&FilterConfig {
368 only_skills: true,
369 ..FilterConfig::default()
370 }),
371 "only_skills=true"
372 );
373 assert_eq!(
374 format_filter(&FilterConfig {
375 only_agents: true,
376 ..FilterConfig::default()
377 }),
378 "only_agents=true"
379 );
380 }
381
382 #[test]
383 fn format_filter_lists() {
384 assert_eq!(
385 format_filter(&FilterConfig {
386 agents: Some(vec!["reviewer".into(), "planner".into()]),
387 ..FilterConfig::default()
388 }),
389 "agents=[reviewer,planner]"
390 );
391 assert_eq!(
392 format_filter(&FilterConfig {
393 exclude: Some(vec!["legacy".into()]),
394 ..FilterConfig::default()
395 }),
396 "exclude=[legacy]"
397 );
398 }
399
400 #[test]
401 fn detects_filter_change_for_message() {
402 let old_filter = FilterConfig {
403 agents: Some(vec!["reviewer".into()]),
404 ..FilterConfig::default()
405 };
406 let change = DependencyUpsertChange {
407 name: "ops".into(),
408 already_exists: true,
409 old_filter: Some(old_filter.clone()),
410 new_filter: FilterConfig {
411 only_skills: true,
412 ..FilterConfig::default()
413 },
414 };
415 assert_ne!(change.old_filter.as_ref(), Some(&change.new_filter));
416 assert_eq!(format_filter(&old_filter), "agents=[reviewer]");
417 assert_eq!(format_filter(&change.new_filter), "only_skills=true");
418 }
419}