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