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};
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    /// Only install specific agents from this source.
21    #[arg(long, value_delimiter = ',')]
22    pub agents: Vec<String>,
23
24    /// Only install specific skills from this source.
25    #[arg(long, value_delimiter = ',')]
26    pub skills: Vec<String>,
27
28    /// Exclude specific items from this source.
29    #[arg(long, value_delimiter = ',')]
30    pub exclude: Vec<String>,
31
32    /// Install only skills from this source (no agents).
33    #[arg(long)]
34    pub only_skills: bool,
35
36    /// Install only agents (plus their transitive skill deps) from this source.
37    #[arg(long)]
38    pub only_agents: bool,
39}
40
41/// Parsed dependency specifier.
42struct ParsedDependency {
43    name: SourceName,
44    entry: DependencyEntry,
45}
46
47/// Run `mars add`.
48pub fn run(args: &AddArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
49    // Validate: filters require exactly one source
50    let has_filters = !args.agents.is_empty()
51        || !args.skills.is_empty()
52        || !args.exclude.is_empty()
53        || args.only_skills
54        || args.only_agents;
55
56    if has_filters && args.sources.len() > 1 {
57        return Err(MarsError::InvalidRequest {
58            message: "filters may only be used when adding exactly one source".to_string(),
59        });
60    }
61
62    // Validate filter flag combinations early
63    let filter_config = build_filter_config(args);
64    crate::config::validate_filter(&filter_config, "cli")?;
65
66    // Build mutations for all sources
67    let mutations: Vec<(SourceName, DependencyEntry)> = args
68        .sources
69        .iter()
70        .map(|source| {
71            let parsed = parse_dependency_specifier(source)?;
72            let entry = DependencyEntry {
73                url: parsed.entry.url,
74                path: parsed.entry.path,
75                version: parsed.entry.version,
76                filter: filter_config.clone(),
77            };
78            Ok((parsed.name, entry))
79        })
80        .collect::<Result<Vec<_>, MarsError>>()?;
81
82    // For single source, use direct mutation path
83    // For multi-source, apply mutations sequentially then run one sync
84    if mutations.len() == 1 {
85        let (name, entry) = mutations.into_iter().next().unwrap();
86
87        let request = SyncRequest {
88            resolution: ResolutionMode::Normal,
89            mutation: Some(ConfigMutation::UpsertDependency {
90                name: name.clone(),
91                entry,
92            }),
93            options: SyncOptions {
94                force: false,
95                dry_run: false,
96                frozen: false,
97                no_refresh_models: false,
98            },
99        };
100
101        let report = crate::sync::execute(ctx, &request)?;
102
103        if !json {
104            print_dependency_messages(&report.dependency_changes);
105        }
106
107        output::print_sync_report(&report, json, true);
108        return if report.has_conflicts() { Ok(1) } else { Ok(0) };
109    }
110
111    // Multi-source: send one batch mutation through sync pipeline.
112    let request = SyncRequest {
113        resolution: ResolutionMode::Normal,
114        mutation: Some(ConfigMutation::BatchUpsert(mutations)),
115        options: SyncOptions {
116            force: false,
117            dry_run: false,
118            frozen: false,
119            no_refresh_models: false,
120        },
121    };
122
123    let report = crate::sync::execute(ctx, &request)?;
124
125    if !json {
126        print_dependency_messages(&report.dependency_changes);
127    }
128
129    output::print_sync_report(&report, json, true);
130    if report.has_conflicts() { Ok(1) } else { Ok(0) }
131}
132
133/// Build FilterConfig from CLI args.
134fn build_filter_config(args: &AddArgs) -> FilterConfig {
135    FilterConfig {
136        agents: if args.agents.is_empty() {
137            None
138        } else {
139            Some(
140                args.agents
141                    .iter()
142                    .map(|v| ItemName::from(v.as_str()))
143                    .collect(),
144            )
145        },
146        skills: if args.skills.is_empty() {
147            None
148        } else {
149            Some(
150                args.skills
151                    .iter()
152                    .map(|v| ItemName::from(v.as_str()))
153                    .collect(),
154            )
155        },
156        exclude: if args.exclude.is_empty() {
157            None
158        } else {
159            Some(
160                args.exclude
161                    .iter()
162                    .map(|v| ItemName::from(v.as_str()))
163                    .collect(),
164            )
165        },
166        rename: None,
167        only_skills: args.only_skills,
168        only_agents: args.only_agents,
169    }
170}
171
172/// Parse a dependency specifier string into a name + DependencyEntry.
173///
174/// Formats:
175/// - `owner/repo` → GitHub shorthand (no `.` in first segment, exactly one `/`)
176/// - `owner/repo@version` → GitHub shorthand with version
177/// - `github.com/owner/repo` → full git URL
178/// - `https://github.com/owner/repo.git` → full git URL
179/// - `./path` or `../path` or `/absolute` → local path
180fn parse_dependency_specifier(spec: &str) -> Result<ParsedDependency, MarsError> {
181    let parsed = parse::parse(spec).map_err(|e| {
182        MarsError::Config(ConfigError::Invalid {
183            message: e.to_string(),
184        })
185    })?;
186
187    Ok(ParsedDependency {
188        name: SourceName::from(parsed.name),
189        entry: DependencyEntry {
190            url: parsed.url,
191            path: parsed.path,
192            version: parsed.version,
193            filter: FilterConfig::default(),
194        },
195    })
196}
197
198fn print_dependency_messages(changes: &[DependencyUpsertChange]) {
199    for change in changes {
200        if change.already_exists {
201            output::print_warn(&format!(
202                "dependency `{}` already exists — updated",
203                change.name
204            ));
205            if let Some(old_filter) = &change.old_filter
206                && old_filter != &change.new_filter
207            {
208                output::print_info(&format!(
209                    "filters changed: {} → {}",
210                    format_filter(old_filter),
211                    format_filter(&change.new_filter)
212                ));
213            }
214        } else {
215            output::print_info(&format!("added dependency `{}`", change.name));
216        }
217    }
218}
219
220fn format_filter(filter: &FilterConfig) -> String {
221    if filter.only_skills {
222        return "only_skills=true".to_string();
223    }
224    if filter.only_agents {
225        return "only_agents=true".to_string();
226    }
227
228    let mut parts = Vec::new();
229    if let Some(agents) = &filter.agents {
230        parts.push(format!("agents=[{}]", format_item_names(agents)));
231    }
232    if let Some(skills) = &filter.skills {
233        parts.push(format!("skills=[{}]", format_item_names(skills)));
234    }
235    if let Some(exclude) = &filter.exclude {
236        parts.push(format!("exclude=[{}]", format_item_names(exclude)));
237    }
238
239    if parts.is_empty() {
240        "all".to_string()
241    } else {
242        parts.join(", ")
243    }
244}
245
246fn format_item_names(items: &[ItemName]) -> String {
247    items
248        .iter()
249        .map(|item| item.to_string())
250        .collect::<Vec<_>>()
251        .join(",")
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::sync::DependencyUpsertChange;
258    use std::path::Path;
259
260    #[test]
261    fn parse_github_shorthand() {
262        let parsed = parse_dependency_specifier("meridian-flow/meridian-base").unwrap();
263        assert_eq!(parsed.name, "meridian-base");
264        assert_eq!(
265            parsed.entry.url.as_deref(),
266            Some("https://github.com/meridian-flow/meridian-base")
267        );
268        assert!(parsed.entry.path.is_none());
269        assert!(parsed.entry.version.is_none());
270    }
271
272    #[test]
273    fn parse_github_shorthand_with_version() {
274        let parsed = parse_dependency_specifier("meridian-flow/meridian-base@v0.5.0").unwrap();
275        assert_eq!(parsed.name, "meridian-base");
276        assert_eq!(
277            parsed.entry.url.as_deref(),
278            Some("https://github.com/meridian-flow/meridian-base")
279        );
280        assert_eq!(parsed.entry.version.as_deref(), Some("v0.5.0"));
281    }
282
283    #[test]
284    fn parse_full_url() {
285        let parsed =
286            parse_dependency_specifier("github.com/meridian-flow/meridian-dev-workflow@v2")
287                .unwrap();
288        assert_eq!(parsed.name, "meridian-dev-workflow");
289        assert_eq!(
290            parsed.entry.url.as_deref(),
291            Some("https://github.com/meridian-flow/meridian-dev-workflow")
292        );
293        assert_eq!(parsed.entry.version.as_deref(), Some("v2"));
294    }
295
296    #[test]
297    fn parse_https_url() {
298        let parsed =
299            parse_dependency_specifier("https://github.com/someone/cool-agents.git").unwrap();
300        assert_eq!(parsed.name, "cool-agents");
301        assert_eq!(
302            parsed.entry.url.as_deref(),
303            Some("https://github.com/someone/cool-agents")
304        );
305    }
306
307    #[test]
308    fn parse_ssh_url() {
309        let parsed = parse_dependency_specifier("git@github.com:someone/cool-agents.git").unwrap();
310        assert_eq!(parsed.name, "cool-agents");
311        assert_eq!(
312            parsed.entry.url.as_deref(),
313            Some("git@github.com:someone/cool-agents.git")
314        );
315        assert!(parsed.entry.version.is_none());
316    }
317
318    #[test]
319    fn parse_ssh_url_keeps_at_suffix_in_path() {
320        let parsed =
321            parse_dependency_specifier("git@github.com:someone/cool-agents.git@v2").unwrap();
322        assert_eq!(parsed.name, "cool-agents.git@v2");
323        assert_eq!(
324            parsed.entry.url.as_deref(),
325            Some("git@github.com:someone/cool-agents.git@v2")
326        );
327        assert!(parsed.entry.version.is_none());
328    }
329
330    #[test]
331    fn parse_local_path_relative() {
332        let parsed = parse_dependency_specifier("./my-agents").unwrap();
333        assert_eq!(parsed.name, "my-agents");
334        assert!(parsed.entry.url.is_none());
335        assert_eq!(parsed.entry.path.as_deref(), Some(Path::new("./my-agents")));
336    }
337
338    #[test]
339    fn parse_local_path_parent() {
340        let parsed = parse_dependency_specifier("../meridian-dev-workflow").unwrap();
341        assert_eq!(parsed.name, "meridian-dev-workflow");
342        assert!(parsed.entry.url.is_none());
343        assert_eq!(
344            parsed.entry.path.as_deref(),
345            Some(Path::new("../meridian-dev-workflow"))
346        );
347    }
348
349    #[test]
350    fn parse_local_path_absolute() {
351        let parsed = parse_dependency_specifier("/home/dev/agents").unwrap();
352        assert_eq!(parsed.name, "agents");
353        assert!(parsed.entry.url.is_none());
354        assert_eq!(
355            parsed.entry.path.as_deref(),
356            Some(Path::new("/home/dev/agents"))
357        );
358    }
359
360    #[test]
361    fn format_filter_all() {
362        assert_eq!(format_filter(&FilterConfig::default()), "all");
363    }
364
365    #[test]
366    fn format_filter_only_modes() {
367        assert_eq!(
368            format_filter(&FilterConfig {
369                only_skills: true,
370                ..FilterConfig::default()
371            }),
372            "only_skills=true"
373        );
374        assert_eq!(
375            format_filter(&FilterConfig {
376                only_agents: true,
377                ..FilterConfig::default()
378            }),
379            "only_agents=true"
380        );
381    }
382
383    #[test]
384    fn format_filter_lists() {
385        assert_eq!(
386            format_filter(&FilterConfig {
387                agents: Some(vec!["reviewer".into(), "planner".into()]),
388                ..FilterConfig::default()
389            }),
390            "agents=[reviewer,planner]"
391        );
392        assert_eq!(
393            format_filter(&FilterConfig {
394                exclude: Some(vec!["legacy".into()]),
395                ..FilterConfig::default()
396            }),
397            "exclude=[legacy]"
398        );
399    }
400
401    #[test]
402    fn detects_filter_change_for_message() {
403        let old_filter = FilterConfig {
404            agents: Some(vec!["reviewer".into()]),
405            ..FilterConfig::default()
406        };
407        let change = DependencyUpsertChange {
408            name: "ops".into(),
409            already_exists: true,
410            old_version: Some("v0.1.0".into()),
411            new_version: Some("v0.1.0".into()),
412            old_filter: Some(old_filter.clone()),
413            new_filter: FilterConfig {
414                only_skills: true,
415                ..FilterConfig::default()
416            },
417        };
418        assert_ne!(change.old_filter.as_ref(), Some(&change.new_filter));
419        assert_eq!(format_filter(&old_filter), "agents=[reviewer]");
420        assert_eq!(format_filter(&change.new_filter), "only_skills=true");
421    }
422}