Skip to main content

mars_agents/cli/
add.rs

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