use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::fmt; use std::ops::Deref; use std::str::FromStr; use ts_rs::TS; #[derive( Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, TS, )] #[serde(try_from = "String", into = "String")] #[schemars(with = "String")] #[ts(type = "string")] pub struct AgentPath(String); impl AgentPath { pub const ROOT: &str = "/root"; const ROOT_SEGMENT: &str = "root"; pub fn root() -> Self { Self(Self::ROOT.to_string()) } pub fn from_string(path: String) -> Result { validate_absolute_path(path.as_str())?; Ok(Self(path)) } pub fn as_str(&self) -> &str { self.0.as_str() } pub fn is_root(&self) -> bool { self.as_str() == Self::ROOT } pub fn name(&self) -> &str { if self.is_root() { return Self::ROOT_SEGMENT; } self.as_str() .rsplit('/') .next() .filter(|segment| !segment.is_empty()) .unwrap_or(Self::ROOT_SEGMENT) } pub fn join(&self, agent_name: &str) -> Result { validate_agent_name(agent_name)?; Self::from_string(format!("{self}/{agent_name}")) } pub fn resolve(&self, reference: &str) -> Result { if reference.is_empty() { return Err("agent path must not be empty".to_string()); } if reference == Self::ROOT { return Ok(Self::root()); } if reference.starts_with('/') { return Self::try_from(reference); } validate_relative_reference(reference)?; Self::from_string(format!("{self}/{reference}")) } } impl TryFrom for AgentPath { type Error = String; fn try_from(value: String) -> Result { Self::from_string(value) } } impl TryFrom<&str> for AgentPath { type Error = String; fn try_from(value: &str) -> Result { Self::from_string(value.to_string()) } } impl From for String { fn from(value: AgentPath) -> Self { value.0 } } impl FromStr for AgentPath { type Err = String; fn from_str(s: &str) -> Result { Self::try_from(s) } } impl AsRef for AgentPath { fn as_ref(&self) -> &str { self.as_str() } } impl Deref for AgentPath { type Target = str; fn deref(&self) -> &Self::Target { self.as_str() } } impl fmt::Display for AgentPath { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } fn validate_agent_name(agent_name: &str) -> Result<(), String> { if agent_name.is_empty() { return Err("agent_name must not be empty".to_string()); } if agent_name == AgentPath::ROOT_SEGMENT { return Err("agent_name `root` is reserved".to_string()); } if agent_name == "." || agent_name == ".." { return Err(format!("agent_name `{agent_name}` is reserved")); } if agent_name.contains('/') { return Err("agent_name must not contain `/`".to_string()); } if !agent_name .chars() .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') { return Err( "agent_name must use only lowercase letters, digits, and underscores".to_string(), ); } Ok(()) } fn validate_absolute_path(path: &str) -> Result<(), String> { let Some(stripped) = path.strip_prefix('/') else { return Err("absolute agent paths must start with `/root`".to_string()); }; let mut segments = stripped.split('/'); let Some(root) = segments.next() else { return Err("absolute agent path must not be empty".to_string()); }; if root != AgentPath::ROOT_SEGMENT { return Err("absolute agent paths must start with `/root`".to_string()); } if stripped.ends_with('/') { return Err("absolute agent path must not end with `/`".to_string()); } for segment in segments { validate_agent_name(segment)?; } Ok(()) } fn validate_relative_reference(reference: &str) -> Result<(), String> { if reference.ends_with('/') { return Err("relative agent path must not end with `/`".to_string()); } for segment in reference.split('/') { validate_agent_name(segment)?; } Ok(()) } #[cfg(test)] mod tests { use super::AgentPath; use pretty_assertions::assert_eq; #[test] fn root_has_expected_name() { let root = AgentPath::root(); assert_eq!(root.as_str(), AgentPath::ROOT); assert_eq!(root.name(), "root"); assert!(root.is_root()); } #[test] fn join_builds_child_paths() { let root = AgentPath::root(); let child = root.join("researcher").expect("child path"); assert_eq!(child.as_str(), "/root/researcher"); assert_eq!(child.name(), "researcher"); } #[test] fn resolve_supports_relative_and_absolute_references() { let current = AgentPath::try_from("/root/researcher").expect("path"); assert_eq!( current.resolve("worker").expect("relative path"), AgentPath::try_from("/root/researcher/worker").expect("path") ); assert_eq!( current.resolve("/root/other").expect("absolute path"), AgentPath::try_from("/root/other").expect("path") ); } #[test] fn invalid_names_and_paths_are_rejected() { assert_eq!( AgentPath::root().join("BadName"), Err("agent_name must use only lowercase letters, digits, and underscores".to_string()) ); assert_eq!( AgentPath::try_from("/not-root"), Err("absolute agent paths must start with `/root`".to_string()) ); assert_eq!( AgentPath::root().resolve("../sibling"), Err("agent_name `..` is reserved".to_string()) ); } }