use crate::ErrorCode;
pub const MAX_SEGMENT_LEN: usize = 64;
pub const SEPARATOR: char = ':';
pub const MCP_SEGMENT: &str = "mcp";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillNameKind {
User,
Marketplace,
Mcp,
}
impl std::fmt::Display for SkillNameKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
SkillNameKind::User => "user",
SkillNameKind::Marketplace => "marketplace",
SkillNameKind::Mcp => "mcp",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedSkillName {
pub raw: String,
pub kind: SkillNameKind,
pub skill: String,
pub plugin: Option<String>,
pub server: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid skill name {name:?}: {reason}")]
pub struct SkillNameError {
pub name: String,
pub reason: String,
}
impl SkillNameError {
fn new(name: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
name: name.into(),
reason: reason.into(),
}
}
pub fn error_code(&self) -> ErrorCode {
ErrorCode::SkillNameInvalid
}
pub fn to_error_payload(&self) -> crate::ErrorPayload {
crate::ErrorPayload::new(i64::from(self.error_code().code()), self.to_string())
.with_detail("name", self.name.clone())
}
}
fn is_strict_kebab(segment: &str) -> bool {
let bytes = segment.as_bytes();
if bytes.is_empty() || bytes.len() > MAX_SEGMENT_LEN {
return false;
}
if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' {
return false;
}
let mut prev_hyphen = false;
for &b in bytes {
match b {
b'a'..=b'z' | b'0'..=b'9' => prev_hyphen = false,
b'-' => {
if prev_hyphen {
return false; }
prev_hyphen = true;
}
_ => return false,
}
}
true
}
fn is_valid_mcp_server_segment(segment: &str) -> bool {
let len = segment.len();
if len == 0 || len > MAX_SEGMENT_LEN {
return false;
}
segment
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
pub fn normalize_mcp_server_segment(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
pub fn parse_skill_name(name: &str) -> Result<ParsedSkillName, SkillNameError> {
let segments: Vec<&str> = name.split(SEPARATOR).collect();
match segments.len() {
1 => {
let skill = segments[0];
if !is_strict_kebab(skill) {
return Err(SkillNameError::new(
name,
"user name must be a strict-kebab 1-segment bare name",
));
}
Ok(ParsedSkillName {
raw: name.to_string(),
kind: SkillNameKind::User,
skill: skill.to_string(),
plugin: None,
server: None,
})
}
2 => {
let (plugin, skill) = (segments[0], segments[1]);
if !is_strict_kebab(plugin) || !is_strict_kebab(skill) {
return Err(SkillNameError::new(
name,
"marketplace name must be <plugin>:<skill> with strict-kebab segments",
));
}
Ok(ParsedSkillName {
raw: name.to_string(),
kind: SkillNameKind::Marketplace,
skill: skill.to_string(),
plugin: Some(plugin.to_string()),
server: None,
})
}
3 => {
let (head, server, skill) = (segments[0], segments[1], segments[2]);
if head != MCP_SEGMENT {
return Err(SkillNameError::new(
name,
"3-segment names are reserved for the mcp source (first segment must be 'mcp')",
));
}
if !is_valid_mcp_server_segment(server) {
return Err(SkillNameError::new(
name,
"mcp <server> segment must match [A-Za-z0-9_-]{1,64}",
));
}
if !is_strict_kebab(skill) {
return Err(SkillNameError::new(
name,
"mcp <skill> leaf must be strict kebab",
));
}
Ok(ParsedSkillName {
raw: name.to_string(),
kind: SkillNameKind::Mcp,
skill: skill.to_string(),
plugin: None,
server: Some(server.to_string()),
})
}
n => Err(SkillNameError::new(
name,
format!("segment count {n} not in {{1, 2, 3}}"),
)),
}
}
pub fn is_valid_skill_name(name: &str) -> bool {
parse_skill_name(name).is_ok()
}
pub fn synthesize_user_name(skill: &str) -> Result<String, SkillNameError> {
if !is_strict_kebab(skill) {
return Err(SkillNameError::new(
skill,
"user <skill> must be strict kebab",
));
}
Ok(skill.to_string())
}
pub fn synthesize_marketplace_name(plugin: &str, skill: &str) -> Result<String, SkillNameError> {
let combined = format!("{plugin}{SEPARATOR}{skill}");
if !is_strict_kebab(plugin) {
return Err(SkillNameError::new(
combined,
"marketplace <plugin> must be strict kebab",
));
}
if !is_strict_kebab(skill) {
return Err(SkillNameError::new(
combined,
"marketplace <skill> must be strict kebab",
));
}
Ok(combined)
}
pub fn synthesize_mcp_name(server: &str, skill: &str) -> Result<String, SkillNameError> {
let normalized = normalize_mcp_server_segment(server);
if !is_valid_mcp_server_segment(&normalized) {
return Err(SkillNameError::new(
format!("{MCP_SEGMENT}{SEPARATOR}{normalized}{SEPARATOR}{skill}"),
"normalized mcp <server> must match [A-Za-z0-9_-]{1,64} (empty or >64 rejected)",
));
}
if !is_strict_kebab(skill) {
return Err(SkillNameError::new(
format!("{MCP_SEGMENT}{SEPARATOR}{normalized}{SEPARATOR}{skill}"),
"mcp <skill> leaf must be strict kebab",
));
}
Ok(format!(
"{MCP_SEGMENT}{SEPARATOR}{normalized}{SEPARATOR}{skill}"
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_user_marketplace_mcp() {
let u = parse_skill_name("code-review").unwrap();
assert_eq!(u.kind, SkillNameKind::User);
assert_eq!(u.skill, "code-review");
assert!(u.plugin.is_none() && u.server.is_none());
let m = parse_skill_name("my-plugin:do-thing").unwrap();
assert_eq!(m.kind, SkillNameKind::Marketplace);
assert_eq!(m.plugin.as_deref(), Some("my-plugin"));
assert_eq!(m.skill, "do-thing");
assert!(m.server.is_none());
let c = parse_skill_name("mcp:My_Server-1:fetch-url").unwrap();
assert_eq!(c.kind, SkillNameKind::Mcp);
assert_eq!(c.server.as_deref(), Some("My_Server-1"));
assert_eq!(c.skill, "fetch-url");
assert!(c.plugin.is_none());
}
#[test]
fn test_parse_invalid_cases_map_to_4016() {
let invalid = [
"", "a:b:c:d", "notmcp:srv:skill", "Bad-Case", "has_underscore", "-leading", "trailing-", "double--hyphen", "mcp:srv:Bad", "mcp:bad server:ok", ];
for name in invalid {
let err = parse_skill_name(name).expect_err(&format!("{name:?} 应非法"));
assert_eq!(err.error_code(), ErrorCode::SkillNameInvalid);
assert!(!is_valid_skill_name(name));
let payload = err.to_error_payload();
assert_eq!(payload.code, 4016);
assert_eq!(payload.details.unwrap()["name"], name);
}
}
#[test]
fn test_synthesize_roundtrip() {
let n = synthesize_user_name("solo-skill").unwrap();
assert_eq!(n, "solo-skill");
assert_eq!(parse_skill_name(&n).unwrap().kind, SkillNameKind::User);
let n = synthesize_marketplace_name("plug", "skill-a").unwrap();
assert_eq!(n, "plug:skill-a");
let p = parse_skill_name(&n).unwrap();
assert_eq!(p.kind, SkillNameKind::Marketplace);
assert_eq!(p.plugin.as_deref(), Some("plug"));
let n = synthesize_mcp_name("srv.example com", "do-it").unwrap();
assert_eq!(n, "mcp:srv_example_com:do-it");
let p = parse_skill_name(&n).unwrap();
assert_eq!(p.kind, SkillNameKind::Mcp);
assert_eq!(p.server.as_deref(), Some("srv_example_com"));
assert_eq!(p.skill, "do-it");
}
#[test]
fn test_synthesize_rejects_bad_segments() {
assert!(synthesize_user_name("Bad").is_err());
assert!(synthesize_marketplace_name("ok", "Bad").is_err());
assert!(synthesize_marketplace_name("Bad", "ok").is_err());
assert!(synthesize_mcp_name("", "ok").is_err());
assert!(synthesize_mcp_name("srv", "Bad").is_err());
}
#[test]
fn test_normalize_mcp_server_segment() {
assert_eq!(normalize_mcp_server_segment("Already_OK-1"), "Already_OK-1");
assert_eq!(normalize_mcp_server_segment("a.b/c d"), "a_b_c_d");
assert_eq!(normalize_mcp_server_segment("café"), "caf_"); }
}