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