Skip to main content

mars_agents/cli/
add.rs

1//! `mars add <source>` — add or update a source, then sync.
2
3
4use crate::config::{FilterConfig, SourceEntry};
5use crate::error::{ConfigError, MarsError};
6use crate::source::parse;
7use crate::sync::{ConfigMutation, ResolutionMode, SyncOptions, SyncRequest};
8use crate::types::{ItemName, SourceName};
9
10use super::output;
11
12/// Arguments for `mars add`.
13#[derive(Debug, clap::Args)]
14pub struct AddArgs {
15    /// Source specifier: owner/repo, owner/repo@version, URL, or local path.
16    pub source: String,
17
18    /// Only install specific agents from this source.
19    #[arg(long, value_delimiter = ',')]
20    pub agents: Vec<String>,
21
22    /// Only install specific skills from this source.
23    #[arg(long, value_delimiter = ',')]
24    pub skills: Vec<String>,
25
26    /// Exclude specific items from this source.
27    #[arg(long, value_delimiter = ',')]
28    pub exclude: Vec<String>,
29}
30
31/// Parsed source specifier.
32struct ParsedSource {
33    name: SourceName,
34    entry: SourceEntry,
35}
36
37/// Run `mars add`.
38pub fn run(args: &AddArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
39    // Parse source specifier
40    let parsed = parse_source_specifier(&args.source)?;
41
42    // Build SourceEntry with filters
43    let entry = SourceEntry {
44        url: parsed.entry.url,
45        path: parsed.entry.path,
46        version: parsed.entry.version,
47        filter: FilterConfig {
48            agents: if args.agents.is_empty() {
49                None
50            } else {
51                Some(
52                    args.agents
53                        .iter()
54                        .map(|v| ItemName::from(v.as_str()))
55                        .collect(),
56                )
57            },
58            skills: if args.skills.is_empty() {
59                None
60            } else {
61                Some(
62                    args.skills
63                        .iter()
64                        .map(|v| ItemName::from(v.as_str()))
65                        .collect(),
66                )
67            },
68            exclude: if args.exclude.is_empty() {
69                None
70            } else {
71                Some(
72                    args.exclude
73                        .iter()
74                        .map(|v| ItemName::from(v.as_str()))
75                        .collect(),
76                )
77            },
78            rename: None,
79        },
80    };
81
82    let request = SyncRequest {
83        resolution: ResolutionMode::Normal,
84        mutation: Some(ConfigMutation::UpsertSource {
85            name: parsed.name.clone(),
86            entry,
87        }),
88        options: SyncOptions::default(),
89    };
90
91    // Check if source already exists before executing (for accurate messaging).
92    let already_exists = crate::config::load(&ctx.managed_root)
93        .map(|c| c.sources.contains_key(&parsed.name))
94        .unwrap_or(false);
95
96    let report = crate::sync::execute(&ctx.managed_root, &request)?;
97
98    if !json {
99        if already_exists {
100            output::print_warn(&format!(
101                "source `{}` already exists — updated",
102                parsed.name
103            ));
104        } else {
105            output::print_info(&format!("added source `{}`", parsed.name));
106        }
107    }
108
109    output::print_sync_report(&report, json);
110
111    if report.has_conflicts() { Ok(1) } else { Ok(0) }
112}
113
114/// Parse a source specifier string into a name + SourceEntry.
115///
116/// Formats:
117/// - `owner/repo` → GitHub shorthand (no `.` in first segment, exactly one `/`)
118/// - `owner/repo@version` → GitHub shorthand with version
119/// - `github.com/owner/repo` → full git URL
120/// - `https://github.com/owner/repo.git` → full git URL
121/// - `./path` or `../path` or `/absolute` → local path
122fn parse_source_specifier(spec: &str) -> Result<ParsedSource, MarsError> {
123    let parsed = parse::parse(spec).map_err(|e| {
124        MarsError::Config(ConfigError::Invalid {
125            message: e.to_string(),
126        })
127    })?;
128
129    Ok(ParsedSource {
130        name: SourceName::from(parsed.name),
131        entry: SourceEntry {
132            url: parsed.url,
133            path: parsed.path,
134            version: parsed.version,
135            filter: FilterConfig::default(),
136        },
137    })
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use std::path::Path;
144
145    #[test]
146    fn parse_github_shorthand() {
147        let parsed = parse_source_specifier("haowjy/meridian-base").unwrap();
148        assert_eq!(parsed.name, "meridian-base");
149        assert_eq!(
150            parsed.entry.url.as_deref(),
151            Some("https://github.com/haowjy/meridian-base")
152        );
153        assert!(parsed.entry.path.is_none());
154        assert!(parsed.entry.version.is_none());
155    }
156
157    #[test]
158    fn parse_github_shorthand_with_version() {
159        let parsed = parse_source_specifier("haowjy/meridian-base@v0.5.0").unwrap();
160        assert_eq!(parsed.name, "meridian-base");
161        assert_eq!(
162            parsed.entry.url.as_deref(),
163            Some("https://github.com/haowjy/meridian-base")
164        );
165        assert_eq!(parsed.entry.version.as_deref(), Some("v0.5.0"));
166    }
167
168    #[test]
169    fn parse_full_url() {
170        let parsed = parse_source_specifier("github.com/haowjy/meridian-dev-workflow@v2").unwrap();
171        assert_eq!(parsed.name, "meridian-dev-workflow");
172        assert_eq!(
173            parsed.entry.url.as_deref(),
174            Some("https://github.com/haowjy/meridian-dev-workflow")
175        );
176        assert_eq!(parsed.entry.version.as_deref(), Some("v2"));
177    }
178
179    #[test]
180    fn parse_https_url() {
181        let parsed = parse_source_specifier("https://github.com/someone/cool-agents.git").unwrap();
182        assert_eq!(parsed.name, "cool-agents");
183        assert_eq!(
184            parsed.entry.url.as_deref(),
185            Some("https://github.com/someone/cool-agents")
186        );
187    }
188
189    #[test]
190    fn parse_ssh_url() {
191        let parsed = parse_source_specifier("git@github.com:someone/cool-agents.git").unwrap();
192        assert_eq!(parsed.name, "cool-agents");
193        assert_eq!(
194            parsed.entry.url.as_deref(),
195            Some("git@github.com:someone/cool-agents.git")
196        );
197        assert!(parsed.entry.version.is_none());
198    }
199
200    #[test]
201    fn parse_ssh_url_keeps_at_suffix_in_path() {
202        let parsed = parse_source_specifier("git@github.com:someone/cool-agents.git@v2").unwrap();
203        assert_eq!(parsed.name, "cool-agents.git@v2");
204        assert_eq!(
205            parsed.entry.url.as_deref(),
206            Some("git@github.com:someone/cool-agents.git@v2")
207        );
208        assert!(parsed.entry.version.is_none());
209    }
210
211    #[test]
212    fn parse_local_path_relative() {
213        let parsed = parse_source_specifier("./my-agents").unwrap();
214        assert_eq!(parsed.name, "my-agents");
215        assert!(parsed.entry.url.is_none());
216        assert_eq!(parsed.entry.path.as_deref(), Some(Path::new("./my-agents")));
217    }
218
219    #[test]
220    fn parse_local_path_parent() {
221        let parsed = parse_source_specifier("../meridian-dev-workflow").unwrap();
222        assert_eq!(parsed.name, "meridian-dev-workflow");
223        assert!(parsed.entry.url.is_none());
224        assert_eq!(
225            parsed.entry.path.as_deref(),
226            Some(Path::new("../meridian-dev-workflow"))
227        );
228    }
229
230    #[test]
231    fn parse_local_path_absolute() {
232        let parsed = parse_source_specifier("/home/dev/agents").unwrap();
233        assert_eq!(parsed.name, "agents");
234        assert!(parsed.entry.url.is_none());
235        assert_eq!(
236            parsed.entry.path.as_deref(),
237            Some(Path::new("/home/dev/agents"))
238        );
239    }
240}