| 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"; |
| pub const MORPHEUS: &str = "/morpheus"; |
| const ROOT_SEGMENT: &str = "root"; |
|
|
| pub fn root() -> Self { |
| Self(Self::ROOT.to_string()) |
| } |
|
|
| pub fn morpheus() -> Self { |
| Self(Self::MORPHEUS.to_string()) |
| } |
|
|
| pub fn from_string(path: String) -> Result<Self, String> { |
| 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<Self, String> { |
| validate_agent_name(agent_name)?; |
| Self::from_string(format!("{self}/{agent_name}")) |
| } |
|
|
| pub fn resolve(&self, reference: &str) -> Result<Self, String> { |
| 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<String> for AgentPath { |
| type Error = String; |
|
|
| fn try_from(value: String) -> Result<Self, Self::Error> { |
| Self::from_string(value) |
| } |
| } |
|
|
| impl TryFrom<&str> for AgentPath { |
| type Error = String; |
|
|
| fn try_from(value: &str) -> Result<Self, Self::Error> { |
| Self::from_string(value.to_string()) |
| } |
| } |
|
|
| impl From<AgentPath> for String { |
| fn from(value: AgentPath) -> Self { |
| value.0 |
| } |
| } |
|
|
| impl FromStr for AgentPath { |
| type Err = String; |
|
|
| fn from_str(s: &str) -> Result<Self, Self::Err> { |
| Self::try_from(s) |
| } |
| } |
|
|
| impl AsRef<str> 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> { |
| if path == AgentPath::MORPHEUS { |
| return Ok(()); |
| } |
|
|
| let Some(stripped) = path.strip_prefix('/') else { |
| return Err("absolute agent paths must start with `/root` or be `/morpheus`".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` or be `/morpheus`".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 morpheus_has_expected_name() { |
| let morpheus = AgentPath::morpheus(); |
| assert_eq!(morpheus.as_str(), AgentPath::MORPHEUS); |
| assert_eq!(morpheus.name(), "morpheus"); |
| assert!(!morpheus.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` or be `/morpheus`".to_string()) |
| ); |
| assert_eq!( |
| AgentPath::root().resolve("../sibling"), |
| Err("agent_name `..` is reserved".to_string()) |
| ); |
| } |
| } |
|
|