Skip to main content

mars_agents/cli/
add.rs

1//! `mars add <dependency>` — add or update a dependency, then sync.
2
3use 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/// Arguments for `mars add`.
14#[derive(Debug, clap::Args)]
15pub struct AddArgs {
16    /// Source specifiers (one or more): owner/repo, owner/repo@version, URL, or local path.
17    #[arg(required = true)]
18    pub sources: Vec<String>,
19
20    /// Root the fetched source at a package subdirectory.
21    #[arg(long)]
22    pub subpath: Option<String>,
23
24    /// Only install specific agents from this source.
25    #[arg(long, value_delimiter = ',')]
26    pub agents: Vec<String>,
27
28    /// Only install specific skills from this source.
29    #[arg(long, value_delimiter = ',')]
30    pub skills: Vec<String>,
31
32    /// Exclude specific items from this source.
33    #[arg(long, value_delimiter = ',')]
34    pub exclude: Vec<String>,
35
36    /// Install only skills from this source (no agents).
37    #[arg(long)]
38    pub only_skills: bool,
39
40    /// Install only agents (plus their transitive skill deps) from this source.
41    #[arg(long)]
42    pub only_agents: bool,
43}
44
45/// Parsed dependency specifier.
46#[derive(Debug)]
47struct ParsedDependency {
48    name: SourceName,
49    entry: DependencyEntry,
50}
51
52/// Run `mars add`.
53pub fn run(args: &AddArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
54    // Validate: filters require exactly one source
55    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    // Validate filter flag combinations early
73    let filter_config = build_filter_config(args);
74    crate::config::validate_filter(&filter_config, "cli")?;
75
76    // Build mutations for all sources
77    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    // For single source, use direct mutation path
94    // For multi-source, apply mutations sequentially then run one sync
95    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    // Multi-source: send one batch mutation through sync pipeline.
123    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
144/// Build FilterConfig from CLI args.
145fn 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
183/// Parse a dependency specifier string into a name + DependencyEntry.
184///
185/// Formats:
186/// - `owner/repo` → GitHub shorthand (no `.` in first segment, exactly one `/`)
187/// - `owner/repo@version` → GitHub shorthand with version
188/// - `github.com/owner/repo` → full git URL
189/// - `https://github.com/owner/repo.git` → full git URL
190/// - `./path` or `../path` or `/absolute` → local path
191fn 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}