1use 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#[derive(Debug, clap::Args)]
14pub struct AddArgs {
15 pub source: String,
17
18 #[arg(long, value_delimiter = ',')]
20 pub agents: Vec<String>,
21
22 #[arg(long, value_delimiter = ',')]
24 pub skills: Vec<String>,
25
26 #[arg(long, value_delimiter = ',')]
28 pub exclude: Vec<String>,
29}
30
31struct ParsedSource {
33 name: SourceName,
34 entry: SourceEntry,
35}
36
37pub fn run(args: &AddArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
39 let parsed = parse_source_specifier(&args.source)?;
41
42 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 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
114fn 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}