use crate::config::{DependencyEntry, FilterConfig};
use crate::error::{ConfigError, MarsError};
use crate::source::parse;
use crate::sync::{
ConfigMutation, DependencyUpsertChange, ResolutionMode, SyncOptions, SyncRequest,
};
use crate::types::{ItemName, SourceName, SourceSubpath};
use super::output;
#[derive(Debug, clap::Args)]
pub struct AddArgs {
#[arg(required = true)]
pub sources: Vec<String>,
#[arg(long)]
pub subpath: Option<String>,
#[arg(long, value_delimiter = ',')]
pub agents: Vec<String>,
#[arg(long, value_delimiter = ',')]
pub skills: Vec<String>,
#[arg(long, value_delimiter = ',')]
pub exclude: Vec<String>,
#[arg(long)]
pub only_skills: bool,
#[arg(long)]
pub only_agents: bool,
}
#[derive(Debug)]
struct ParsedDependency {
name: SourceName,
entry: DependencyEntry,
}
pub fn run(args: &AddArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
let has_filters = !args.agents.is_empty()
|| !args.skills.is_empty()
|| !args.exclude.is_empty()
|| args.only_skills
|| args.only_agents;
if has_filters && args.sources.len() > 1 {
return Err(MarsError::InvalidRequest {
message: "filters may only be used when adding exactly one source".to_string(),
});
}
if args.subpath.is_some() && args.sources.len() != 1 {
return Err(MarsError::InvalidRequest {
message: "--subpath requires exactly one source argument".to_string(),
});
}
let filter_config = build_filter_config(args);
crate::config::validate_filter(&filter_config, "cli")?;
let mutations: Vec<(SourceName, DependencyEntry)> = args
.sources
.iter()
.map(|source| {
let parsed = parse_dependency_specifier(source, args.subpath.as_deref())?;
let entry = DependencyEntry {
url: parsed.entry.url,
path: parsed.entry.path,
subpath: parsed.entry.subpath,
version: parsed.entry.version,
filter: filter_config.clone(),
};
Ok((parsed.name, entry))
})
.collect::<Result<Vec<_>, MarsError>>()?;
if mutations.len() == 1 {
let (name, entry) = mutations.into_iter().next().unwrap();
let request = SyncRequest {
resolution: ResolutionMode::Normal,
mutation: Some(ConfigMutation::UpsertDependency {
name: name.clone(),
entry,
}),
options: SyncOptions {
force: false,
dry_run: false,
frozen: false,
no_refresh_models: false,
},
};
let report = crate::sync::execute(ctx, &request)?;
if !json {
print_dependency_messages(&report.dependency_changes);
}
output::print_sync_report(&report, json, true);
return if report.has_conflicts() { Ok(1) } else { Ok(0) };
}
let request = SyncRequest {
resolution: ResolutionMode::Normal,
mutation: Some(ConfigMutation::BatchUpsert(mutations)),
options: SyncOptions {
force: false,
dry_run: false,
frozen: false,
no_refresh_models: false,
},
};
let report = crate::sync::execute(ctx, &request)?;
if !json {
print_dependency_messages(&report.dependency_changes);
}
output::print_sync_report(&report, json, true);
if report.has_conflicts() { Ok(1) } else { Ok(0) }
}
fn build_filter_config(args: &AddArgs) -> FilterConfig {
FilterConfig {
agents: if args.agents.is_empty() {
None
} else {
Some(
args.agents
.iter()
.map(|v| ItemName::from(v.as_str()))
.collect(),
)
},
skills: if args.skills.is_empty() {
None
} else {
Some(
args.skills
.iter()
.map(|v| ItemName::from(v.as_str()))
.collect(),
)
},
exclude: if args.exclude.is_empty() {
None
} else {
Some(
args.exclude
.iter()
.map(|v| ItemName::from(v.as_str()))
.collect(),
)
},
rename: None,
only_skills: args.only_skills,
only_agents: args.only_agents,
}
}
fn parse_dependency_specifier(
spec: &str,
explicit_subpath: Option<&str>,
) -> Result<ParsedDependency, MarsError> {
let parsed = parse::parse(spec).map_err(|e| {
MarsError::Config(ConfigError::Invalid {
message: e.to_string(),
})
})?;
let explicit_subpath = explicit_subpath
.map(|value| {
SourceSubpath::new(value).map_err(|e| {
MarsError::Config(ConfigError::Invalid {
message: e.to_string(),
})
})
})
.transpose()?;
let subpath = merge_subpath(parsed.subpath.clone(), explicit_subpath)?;
let name = derive_dependency_name(&parsed, subpath.as_ref())?;
Ok(ParsedDependency {
name: SourceName::from(name),
entry: DependencyEntry {
url: parsed.url,
path: parsed.path,
subpath,
version: parsed.version,
filter: FilterConfig::default(),
},
})
}
fn merge_subpath(
parsed_subpath: Option<SourceSubpath>,
explicit_subpath: Option<SourceSubpath>,
) -> Result<Option<SourceSubpath>, MarsError> {
match (parsed_subpath, explicit_subpath) {
(Some(parsed), Some(explicit)) if parsed != explicit => Err(MarsError::InvalidRequest {
message: format!(
"conflicting subpath input: source provides `{parsed}` but --subpath provides `{explicit}`"
),
}),
(Some(parsed), Some(_)) => Ok(Some(parsed)),
(Some(parsed), None) => Ok(Some(parsed)),
(None, Some(explicit)) => Ok(Some(explicit)),
(None, None) => Ok(None),
}
}
fn derive_dependency_name(
parsed: &parse::ParsedSourceSpec,
subpath: Option<&SourceSubpath>,
) -> Result<String, MarsError> {
let root_name = parsed.name.split('/').next().ok_or_else(|| {
MarsError::Config(ConfigError::Invalid {
message: format!("cannot derive dependency name from `{}`", parsed.raw),
})
})?;
Ok(match subpath {
Some(subpath) => format!("{root_name}/{}", subpath.as_str()),
None => root_name.to_string(),
})
}
fn print_dependency_messages(changes: &[DependencyUpsertChange]) {
for change in changes {
if change.already_exists {
output::print_warn(&format!(
"dependency `{}` already exists — updated",
change.name
));
if let Some(old_filter) = &change.old_filter
&& old_filter != &change.new_filter
{
output::print_info(&format!(
"filters changed: {} → {}",
format_filter(old_filter),
format_filter(&change.new_filter)
));
}
} else {
output::print_info(&format!("added dependency `{}`", change.name));
}
}
}
fn format_filter(filter: &FilterConfig) -> String {
if filter.only_skills {
return "only_skills=true".to_string();
}
if filter.only_agents {
return "only_agents=true".to_string();
}
let mut parts = Vec::new();
if let Some(agents) = &filter.agents {
parts.push(format!("agents=[{}]", format_item_names(agents)));
}
if let Some(skills) = &filter.skills {
parts.push(format!("skills=[{}]", format_item_names(skills)));
}
if let Some(exclude) = &filter.exclude {
parts.push(format!("exclude=[{}]", format_item_names(exclude)));
}
if parts.is_empty() {
"all".to_string()
} else {
parts.join(", ")
}
}
fn format_item_names(items: &[ItemName]) -> String {
items
.iter()
.map(|item| item.to_string())
.collect::<Vec<_>>()
.join(",")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sync::DependencyUpsertChange;
use std::path::Path;
#[test]
fn parse_github_shorthand() {
let parsed = parse_dependency_specifier("meridian-flow/meridian-base", None).unwrap();
assert_eq!(parsed.name, "meridian-base");
assert_eq!(
parsed.entry.url.as_deref(),
Some("https://github.com/meridian-flow/meridian-base")
);
assert!(parsed.entry.path.is_none());
assert!(parsed.entry.version.is_none());
}
#[test]
fn parse_github_shorthand_with_version() {
let parsed =
parse_dependency_specifier("meridian-flow/meridian-base@v0.5.0", None).unwrap();
assert_eq!(parsed.name, "meridian-base");
assert_eq!(
parsed.entry.url.as_deref(),
Some("https://github.com/meridian-flow/meridian-base")
);
assert_eq!(parsed.entry.version.as_deref(), Some("v0.5.0"));
}
#[test]
fn parse_full_url() {
let parsed =
parse_dependency_specifier("github.com/meridian-flow/meridian-dev-workflow@v2", None)
.unwrap();
assert_eq!(parsed.name, "meridian-dev-workflow");
assert_eq!(
parsed.entry.url.as_deref(),
Some("https://github.com/meridian-flow/meridian-dev-workflow")
);
assert_eq!(parsed.entry.version.as_deref(), Some("v2"));
}
#[test]
fn parse_https_url() {
let parsed =
parse_dependency_specifier("https://github.com/someone/cool-agents.git", None).unwrap();
assert_eq!(parsed.name, "cool-agents");
assert_eq!(
parsed.entry.url.as_deref(),
Some("https://github.com/someone/cool-agents")
);
}
#[test]
fn parse_ssh_url() {
let parsed =
parse_dependency_specifier("git@github.com:someone/cool-agents.git", None).unwrap();
assert_eq!(parsed.name, "cool-agents");
assert_eq!(
parsed.entry.url.as_deref(),
Some("git@github.com:someone/cool-agents.git")
);
assert!(parsed.entry.version.is_none());
}
#[test]
fn parse_ssh_url_keeps_at_suffix_in_path() {
let parsed =
parse_dependency_specifier("git@github.com:someone/cool-agents.git@v2", None).unwrap();
assert_eq!(parsed.name, "cool-agents");
assert_eq!(
parsed.entry.url.as_deref(),
Some("git@github.com:someone/cool-agents.git")
);
assert_eq!(parsed.entry.version.as_deref(), Some("v2"));
}
#[test]
fn parse_local_path_relative() {
let parsed = parse_dependency_specifier("./my-agents", None).unwrap();
assert_eq!(parsed.name, "my-agents");
assert!(parsed.entry.url.is_none());
assert_eq!(parsed.entry.path.as_deref(), Some(Path::new("./my-agents")));
}
#[test]
fn parse_local_path_parent() {
let parsed = parse_dependency_specifier("../meridian-dev-workflow", None).unwrap();
assert_eq!(parsed.name, "meridian-dev-workflow");
assert!(parsed.entry.url.is_none());
assert_eq!(
parsed.entry.path.as_deref(),
Some(Path::new("../meridian-dev-workflow"))
);
}
#[test]
fn parse_local_path_absolute() {
let parsed = parse_dependency_specifier("/home/dev/agents", None).unwrap();
assert_eq!(parsed.name, "agents");
assert!(parsed.entry.url.is_none());
assert_eq!(
parsed.entry.path.as_deref(),
Some(Path::new("/home/dev/agents"))
);
}
#[test]
fn parse_source_embedded_subpath() {
let parsed = parse_dependency_specifier("owner/repo/plugins/foo", None).unwrap();
assert_eq!(parsed.name, "repo/plugins/foo");
assert_eq!(
parsed.entry.subpath.as_ref().map(SourceSubpath::as_str),
Some("plugins/foo")
);
}
#[test]
fn parse_explicit_subpath_merges_when_source_has_none() {
let parsed =
parse_dependency_specifier("gitlab:group/subgroup/repo", Some("plugins/foo")).unwrap();
assert_eq!(parsed.name, "repo/plugins/foo");
assert_eq!(
parsed.entry.subpath.as_ref().map(SourceSubpath::as_str),
Some("plugins/foo")
);
}
#[test]
fn conflicting_subpath_is_rejected() {
let err =
parse_dependency_specifier("owner/repo/plugins/foo", Some("plugins/bar")).unwrap_err();
assert!(matches!(err, MarsError::InvalidRequest { .. }));
}
#[test]
fn format_filter_all() {
assert_eq!(format_filter(&FilterConfig::default()), "all");
}
#[test]
fn format_filter_only_modes() {
assert_eq!(
format_filter(&FilterConfig {
only_skills: true,
..FilterConfig::default()
}),
"only_skills=true"
);
assert_eq!(
format_filter(&FilterConfig {
only_agents: true,
..FilterConfig::default()
}),
"only_agents=true"
);
}
#[test]
fn format_filter_lists() {
assert_eq!(
format_filter(&FilterConfig {
agents: Some(vec!["reviewer".into(), "planner".into()]),
..FilterConfig::default()
}),
"agents=[reviewer,planner]"
);
assert_eq!(
format_filter(&FilterConfig {
exclude: Some(vec!["legacy".into()]),
..FilterConfig::default()
}),
"exclude=[legacy]"
);
}
#[test]
fn detects_filter_change_for_message() {
let old_filter = FilterConfig {
agents: Some(vec!["reviewer".into()]),
..FilterConfig::default()
};
let change = DependencyUpsertChange {
name: "ops".into(),
already_exists: true,
old_version: Some("v0.1.0".into()),
new_version: Some("v0.1.0".into()),
old_filter: Some(old_filter.clone()),
new_filter: FilterConfig {
only_skills: true,
..FilterConfig::default()
},
};
assert_ne!(change.old_filter.as_ref(), Some(&change.new_filter));
assert_eq!(format_filter(&old_filter), "agents=[reviewer]");
assert_eq!(format_filter(&change.new_filter), "only_skills=true");
}
}