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