use super::schema::{DepSpec, Dependencies, Manifest, PackageType, StringSpec, PACKAGE_TYPES};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddOutcome {
Added,
AlreadyPresent,
}
pub fn add_dep(manifest: &mut Manifest, kind: PackageType, dep: DepSpec) -> AddOutcome {
let list = ensure_list(&mut manifest.dependencies, kind);
if list.iter().any(|existing| existing == &dep) {
return AddOutcome::AlreadyPresent;
}
list.push(dep);
AddOutcome::Added
}
pub fn add_shorthand(
manifest: &mut Manifest,
kind: PackageType,
id: impl Into<String>,
) -> Result<AddOutcome, String> {
let spec = StringSpec::parse(id)?;
Ok(add_dep(manifest, kind, DepSpec::String(spec)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoveOutcome {
Removed,
NotPresent,
}
pub fn remove_shorthand(manifest: &mut Manifest, kind: PackageType, id: &str) -> RemoveOutcome {
let Some(list) = section_mut(&mut manifest.dependencies, kind) else {
return RemoveOutcome::NotPresent;
};
let before = list.len();
list.retain(|dep| !matches_shorthand(dep, id));
if list.len() == before {
return RemoveOutcome::NotPresent;
}
if list.is_empty() {
clear_section(&mut manifest.dependencies, kind);
}
RemoveOutcome::Removed
}
#[must_use]
pub fn sections_containing(manifest: &Manifest, id: &str) -> Vec<PackageType> {
PACKAGE_TYPES
.iter()
.copied()
.filter(|kind| {
manifest
.dependencies
.get(*kind)
.is_some_and(|list| list.iter().any(|dep| matches_shorthand(dep, id)))
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateOutcome {
Updated { previous: String },
NotPresent,
NonShorthand,
}
pub fn update_shorthand(
manifest: &mut Manifest,
kind: PackageType,
id_no_version: &str,
new_version: &str,
) -> UpdateOutcome {
let Some(list) = section_mut(&mut manifest.dependencies, kind) else {
return UpdateOutcome::NotPresent;
};
let mut found_non_shorthand = false;
for dep in list.iter_mut() {
match dep {
DepSpec::String(s) => {
let (id_part, _) = split_shorthand(s.as_str());
if id_part == id_no_version {
let previous = s.as_str().to_owned();
let next = format!("{id_no_version}@{new_version}");
*s = StringSpec::parse(next)
.expect("rewritten shorthand has no whitespace and is non-empty");
return UpdateOutcome::Updated { previous };
}
}
DepSpec::Git(_) | DepSpec::Registry(_) => {
if let DepSpec::Git(g) = dep {
if g.git == id_no_version {
found_non_shorthand = true;
}
} else if let DepSpec::Registry(r) = dep {
let combined = format!("{}/{}", r.registry, r.name);
if combined == id_no_version || r.name == id_no_version {
found_non_shorthand = true;
}
}
}
}
}
if found_non_shorthand {
UpdateOutcome::NonShorthand
} else {
UpdateOutcome::NotPresent
}
}
#[must_use]
pub fn split_shorthand(s: &str) -> (&str, Option<&str>) {
match s.split_once('@') {
Some(("", _)) => (s, None),
Some((id, v)) if !v.is_empty() => (id, Some(v)),
_ => (s, None),
}
}
#[must_use]
pub fn sections_containing_id(manifest: &Manifest, id_no_version: &str) -> Vec<PackageType> {
PACKAGE_TYPES
.iter()
.copied()
.filter(|kind| {
manifest.dependencies.get(*kind).is_some_and(|list| {
list.iter()
.any(|dep| matches_shorthand_id(dep, id_no_version))
})
})
.collect()
}
fn matches_shorthand_id(dep: &DepSpec, id_no_version: &str) -> bool {
match dep {
DepSpec::String(s) => split_shorthand(s.as_str()).0 == id_no_version,
DepSpec::Git(_) | DepSpec::Registry(_) => false,
}
}
fn matches_shorthand(dep: &DepSpec, id: &str) -> bool {
match dep {
DepSpec::String(s) => s.as_str() == id,
DepSpec::Git(_) | DepSpec::Registry(_) => false,
}
}
const fn section_mut(deps: &mut Dependencies, kind: PackageType) -> Option<&mut Vec<DepSpec>> {
match kind {
PackageType::Skills => deps.skills.as_mut(),
PackageType::Mcp => deps.mcp.as_mut(),
PackageType::Subagents => deps.subagents.as_mut(),
PackageType::Prompts => deps.prompts.as_mut(),
PackageType::Commands => deps.commands.as_mut(),
PackageType::Hooks => deps.hooks.as_mut(),
}
}
fn clear_section(deps: &mut Dependencies, kind: PackageType) {
match kind {
PackageType::Skills => deps.skills = None,
PackageType::Mcp => deps.mcp = None,
PackageType::Subagents => deps.subagents = None,
PackageType::Prompts => deps.prompts = None,
PackageType::Commands => deps.commands = None,
PackageType::Hooks => deps.hooks = None,
}
}
fn ensure_list(deps: &mut Dependencies, kind: PackageType) -> &mut Vec<DepSpec> {
match kind {
PackageType::Skills => deps.skills.get_or_insert_with(Vec::new),
PackageType::Mcp => deps.mcp.get_or_insert_with(Vec::new),
PackageType::Subagents => deps.subagents.get_or_insert_with(Vec::new),
PackageType::Prompts => deps.prompts.get_or_insert_with(Vec::new),
PackageType::Commands => deps.commands.get_or_insert_with(Vec::new),
PackageType::Hooks => deps.hooks.get_or_insert_with(Vec::new),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::schema::{Dependencies, Manifest};
fn empty_manifest() -> Manifest {
Manifest {
name: "demo".into(),
version: "0.1.0".into(),
agents: None,
dependencies: Dependencies::default(),
}
}
#[test]
fn remove_shorthand_returns_not_present_for_empty_section() {
let mut m = empty_manifest();
let outcome = remove_shorthand(&mut m, PackageType::Mcp, "a/b");
assert_eq!(outcome, RemoveOutcome::NotPresent);
}
#[test]
fn remove_shorthand_drops_matching_entry_and_clears_section_when_empty() {
let mut m = empty_manifest();
add_shorthand(&mut m, PackageType::Mcp, "a/b").unwrap();
let outcome = remove_shorthand(&mut m, PackageType::Mcp, "a/b");
assert_eq!(outcome, RemoveOutcome::Removed);
assert!(m.dependencies.mcp.is_none(), "empty section pruned");
}
#[test]
fn remove_shorthand_preserves_order_of_remaining_entries() {
let mut m = empty_manifest();
add_shorthand(&mut m, PackageType::Mcp, "a").unwrap();
add_shorthand(&mut m, PackageType::Mcp, "b").unwrap();
add_shorthand(&mut m, PackageType::Mcp, "c").unwrap();
let outcome = remove_shorthand(&mut m, PackageType::Mcp, "b");
assert_eq!(outcome, RemoveOutcome::Removed);
let names: Vec<&str> = m
.dependencies
.mcp
.as_ref()
.unwrap()
.iter()
.map(|d| match d {
DepSpec::String(s) => s.as_str(),
_ => "",
})
.collect();
assert_eq!(names, vec!["a", "c"]);
}
#[test]
fn sections_containing_lists_every_kind_with_a_match() {
let mut m = empty_manifest();
add_shorthand(&mut m, PackageType::Mcp, "shared").unwrap();
add_shorthand(&mut m, PackageType::Skills, "shared").unwrap();
let sections = sections_containing(&m, "shared");
assert_eq!(sections.len(), 2);
assert!(sections.contains(&PackageType::Mcp));
assert!(sections.contains(&PackageType::Skills));
}
#[test]
fn sections_containing_ignores_git_and_registry_specs() {
let mut m = empty_manifest();
m.dependencies.mcp = Some(vec![DepSpec::Git(crate::manifest::GitSpec {
git: "https://example.test".into(),
git_ref: None,
subpath: None,
})]);
assert!(sections_containing(&m, "https://example.test").is_empty());
}
#[test]
fn split_shorthand_separates_id_and_version() {
assert_eq!(split_shorthand("alice/bob"), ("alice/bob", None));
assert_eq!(
split_shorthand("alice/bob@0.1.0"),
("alice/bob", Some("0.1.0"))
);
assert_eq!(split_shorthand("alice/bob@"), ("alice/bob@", None));
assert_eq!(split_shorthand("@scope/name"), ("@scope/name", None));
}
#[test]
fn update_shorthand_rewrites_pin_in_place() {
let mut m = empty_manifest();
add_shorthand(&mut m, PackageType::Skills, "alice/bob@0.1.0").unwrap();
let outcome = update_shorthand(&mut m, PackageType::Skills, "alice/bob", "0.1.2");
assert_eq!(
outcome,
UpdateOutcome::Updated {
previous: "alice/bob@0.1.0".into()
}
);
let after = m
.dependencies
.skills
.as_ref()
.and_then(|v| v.first())
.and_then(|d| match d {
DepSpec::String(s) => Some(s.as_str()),
_ => None,
});
assert_eq!(after, Some("alice/bob@0.1.2"));
}
#[test]
fn update_shorthand_pins_unversioned_entry() {
let mut m = empty_manifest();
add_shorthand(&mut m, PackageType::Skills, "alice/bob").unwrap();
let outcome = update_shorthand(&mut m, PackageType::Skills, "alice/bob", "0.1.2");
assert_eq!(
outcome,
UpdateOutcome::Updated {
previous: "alice/bob".into()
}
);
let after = m
.dependencies
.skills
.as_ref()
.and_then(|v| v.first())
.and_then(|d| match d {
DepSpec::String(s) => Some(s.as_str()),
_ => None,
});
assert_eq!(after, Some("alice/bob@0.1.2"));
}
#[test]
fn update_shorthand_returns_not_present_when_section_missing() {
let mut m = empty_manifest();
let outcome = update_shorthand(&mut m, PackageType::Skills, "alice/bob", "0.1.2");
assert_eq!(outcome, UpdateOutcome::NotPresent);
}
#[test]
fn update_shorthand_returns_not_present_when_id_missing() {
let mut m = empty_manifest();
add_shorthand(&mut m, PackageType::Skills, "other/dep@0.1.0").unwrap();
let outcome = update_shorthand(&mut m, PackageType::Skills, "alice/bob", "0.1.2");
assert_eq!(outcome, UpdateOutcome::NotPresent);
}
#[test]
fn update_shorthand_returns_non_shorthand_for_git_only_match() {
let mut m = empty_manifest();
m.dependencies.mcp = Some(vec![DepSpec::Git(crate::manifest::GitSpec {
git: "alice/bob".into(),
git_ref: None,
subpath: None,
})]);
let outcome = update_shorthand(&mut m, PackageType::Mcp, "alice/bob", "0.1.2");
assert_eq!(outcome, UpdateOutcome::NonShorthand);
}
#[test]
fn update_shorthand_rewrites_first_match_when_duplicate_pins_present() {
let mut m = empty_manifest();
add_shorthand(&mut m, PackageType::Skills, "alice/bob").unwrap();
add_shorthand(&mut m, PackageType::Skills, "alice/bob@0.1.0").unwrap();
let outcome = update_shorthand(&mut m, PackageType::Skills, "alice/bob", "0.1.2");
assert_eq!(
outcome,
UpdateOutcome::Updated {
previous: "alice/bob".into()
}
);
let entries: Vec<&str> = m
.dependencies
.skills
.as_ref()
.unwrap()
.iter()
.filter_map(|d| match d {
DepSpec::String(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert_eq!(entries, vec!["alice/bob@0.1.2", "alice/bob@0.1.0"]);
}
#[test]
fn sections_containing_id_finds_unversioned_and_versioned_pins() {
let mut m = empty_manifest();
add_shorthand(&mut m, PackageType::Skills, "alice/bob@0.1.0").unwrap();
add_shorthand(&mut m, PackageType::Mcp, "alice/bob").unwrap();
let sections = sections_containing_id(&m, "alice/bob");
assert_eq!(sections.len(), 2);
assert!(sections.contains(&PackageType::Skills));
assert!(sections.contains(&PackageType::Mcp));
}
#[test]
fn sections_containing_id_ignores_unrelated_ids() {
let mut m = empty_manifest();
add_shorthand(&mut m, PackageType::Skills, "other/dep@0.1.0").unwrap();
assert!(sections_containing_id(&m, "alice/bob").is_empty());
}
}