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