use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::fmt;
const SEPARATOR: &str = "__";
fn parse_path(path: &str) -> Vec<&str> {
path.split(SEPARATOR).collect()
}
fn join_path(components: &[&str]) -> String {
components.join(SEPARATOR)
}
fn format_version(version: &str) -> String {
format!("v{}", version.replace('.', "_"))
}
fn parse_version(version: &str) -> String {
version
.strip_prefix('v')
.unwrap_or(version)
.replace('_', ".")
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Namespace {
Bin, Sbin, Docs, User(String), Mcp(String), }
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ToolPath {
pub namespace: Namespace,
pub package: Option<String>,
pub name: String,
pub version: String,
}
impl ToolPath {
pub fn bin(name: impl Into<String>) -> Self {
Self {
namespace: Namespace::Bin,
package: None,
name: name.into(),
version: "latest".to_string(),
}
}
pub fn sbin(name: impl Into<String>) -> Self {
Self {
namespace: Namespace::Sbin,
package: None,
name: name.into(),
version: "latest".to_string(),
}
}
pub fn docs(name: impl Into<String>) -> Self {
Self {
namespace: Namespace::Docs,
package: None,
name: name.into(),
version: "latest".to_string(),
}
}
pub fn user(
user: impl Into<String>,
package: impl Into<String>,
name: impl Into<String>,
version: impl Into<String>,
) -> Self {
Self {
namespace: Namespace::User(user.into()),
package: Some(package.into()),
name: name.into(),
version: version.into(),
}
}
pub fn mcp(
server: impl Into<String>,
name: impl Into<String>,
version: impl Into<String>,
) -> Self {
Self {
namespace: Namespace::Mcp(server.into()),
package: None,
name: name.into(),
version: version.into(),
}
}
pub fn parse(path: &str) -> Result<Self> {
Self::from_mcp_name(path)
}
pub fn to_mcp_name(&self) -> String {
match &self.namespace {
Namespace::Bin => join_path(&["bin", &self.name]),
Namespace::Sbin => join_path(&["sbin", &self.name]),
Namespace::Docs => join_path(&["docs", &self.name]),
Namespace::Mcp(server) => {
if self.version == "latest" {
join_path(&["mcp", server, &self.name])
} else {
join_path(&["mcp", server, &self.name, &format_version(&self.version)])
}
}
Namespace::User(user) => {
if let Some(package) = &self.package {
if self.version == "latest" {
join_path(&["user", user, package, &self.name])
} else {
join_path(&["user", user, package, &self.name, &format_version(&self.version)])
}
} else {
join_path(&["user", user, &self.name])
}
}
}
}
pub fn from_mcp_name(mcp_name: &str) -> Result<Self> {
let parts = parse_path(mcp_name);
match parts.as_slice() {
["bin", name] => {
if name.is_empty() || *name == "_" {
return Err(anyhow!("Tool name cannot be empty or just underscore"));
}
Ok(Self::bin(name.to_string()))
}
["sbin", name] => {
if name.is_empty() || *name == "_" {
return Err(anyhow!("Tool name cannot be empty or just underscore"));
}
Ok(Self::sbin(name.to_string()))
}
["docs", name] => {
if name.is_empty() || *name == "_" {
return Err(anyhow!("Tool name cannot be empty or just underscore"));
}
Ok(Self::docs(name.to_string()))
}
["mcp", server, name] => {
if name.is_empty() || server.is_empty() || *name == "_" || *server == "_" {
return Err(anyhow!("MCP tool name and server cannot be empty or just underscore"));
}
Ok(Self::mcp(server.to_string(), name.to_string(), "latest".to_string()))
}
["mcp", server, name, version] if version.starts_with('v') => {
if name.is_empty() || server.is_empty() || *name == "_" || *server == "_" {
return Err(anyhow!("MCP tool name and server cannot be empty or just underscore"));
}
Ok(Self::mcp(server.to_string(), name.to_string(), parse_version(version)))
}
["user", user, name] => {
if name.is_empty() || user.is_empty() || *name == "_" || *user == "_" {
return Err(anyhow!("User tool name and user cannot be empty or just underscore"));
}
Ok(Self {
namespace: Namespace::User(user.to_string()),
package: None,
name: name.to_string(),
version: "latest".to_string(),
})
}
["user", user, package, name] => {
if name.is_empty() || user.is_empty() || package.is_empty() || *name == "_" || *user == "_" || *package == "_" {
return Err(anyhow!("User tool name, user, and package cannot be empty or just underscore"));
}
Ok(Self {
namespace: Namespace::User(user.to_string()),
package: Some(package.to_string()),
name: name.to_string(),
version: "latest".to_string(),
})
}
["user", user, package, name, version] if version.starts_with('v') => {
if name.is_empty() || user.is_empty() || package.is_empty() || *name == "_" || *user == "_" || *package == "_" {
return Err(anyhow!("User tool name, user, and package cannot be empty or just underscore"));
}
Ok(Self {
namespace: Namespace::User(user.to_string()),
package: Some(package.to_string()),
name: name.to_string(),
version: parse_version(version),
})
}
_ => Err(anyhow!("Invalid MCP path format: {}", mcp_name)),
}
}
pub fn is_system(&self) -> bool {
matches!(
self.namespace,
Namespace::Bin | Namespace::Sbin | Namespace::Docs
)
}
pub fn is_mcp(&self) -> bool {
matches!(self.namespace, Namespace::Mcp(_))
}
}
impl fmt::Display for ToolPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_mcp_name())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_paths() {
assert_eq!(
ToolPath::parse("bin__tcl_execute").unwrap(),
ToolPath::bin("tcl_execute")
);
assert_eq!(
ToolPath::parse("sbin__tcl_tool_add").unwrap(),
ToolPath::sbin("tcl_tool_add")
);
assert_eq!(
ToolPath::parse("user__alice__utils__reverse_string__v1_0").unwrap(),
ToolPath::user("alice", "utils", "reverse_string", "1.0")
);
assert_eq!(
ToolPath::parse("user__bob__math__calculate").unwrap(),
ToolPath::user("bob", "math", "calculate", "latest")
);
}
#[test]
fn test_mcp_names() {
assert_eq!(
ToolPath::bin("tcl_execute").to_mcp_name(),
"bin__tcl_execute"
);
assert_eq!(
ToolPath::user("alice", "utils", "reverse_string", "1.0").to_mcp_name(),
"user__alice__utils__reverse_string__v1_0"
);
assert_eq!(
ToolPath::user("bob", "math", "calculate", "latest").to_mcp_name(),
"user__bob__math__calculate"
);
}
#[test]
fn test_round_trip() {
let paths = vec![
ToolPath::bin("tcl_execute"),
ToolPath::sbin("tcl_tool_add"),
ToolPath::user("alice", "utils", "reverse_string", "1.0"),
ToolPath::user("bob", "math", "calculate", "latest"),
];
for path in paths {
let mcp_name = path.to_mcp_name();
let parsed = ToolPath::from_mcp_name(&mcp_name).unwrap();
assert_eq!(path, parsed);
}
}
}