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