use std::cmp::Ordering;
use std::fmt;
use std::ops::Deref;
use std::str::FromStr;
use crate::constants::MAX_AGENT_PREFIX_LENGTH;
use crate::error::AgentPrefixError;
use crate::type_class::TypeClass;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AgentPrefix {
type_class: TypeClass,
modifiers: Vec<String>,
normalized: String,
}
impl AgentPrefix {
pub fn parse(input: &str) -> Result<Self, AgentPrefixError> {
if input.is_empty() {
return Err(AgentPrefixError::Empty);
}
if input.len() > MAX_AGENT_PREFIX_LENGTH {
return Err(AgentPrefixError::TooLong {
max: MAX_AGENT_PREFIX_LENGTH,
actual: input.len(),
});
}
let Some(first) = input.chars().next() else {
return Err(AgentPrefixError::Empty);
};
if !first.is_ascii_lowercase() {
return Err(AgentPrefixError::MustStartWithLetter { found: first });
}
let Some(last) = input.chars().next_back() else {
return Err(AgentPrefixError::Empty);
};
if !last.is_ascii_lowercase() {
return Err(AgentPrefixError::MustEndWithLetter { found: last });
}
for (i, c) in input.chars().enumerate() {
if c.is_ascii_digit() {
return Err(AgentPrefixError::ContainsDigit { position: i });
}
if !c.is_ascii_lowercase() && c != '_' {
return Err(AgentPrefixError::InvalidChar {
char: c,
position: i,
});
}
}
let parts: Vec<&str> = input.split('_').collect();
let type_class =
parts[0]
.parse::<TypeClass>()
.map_err(|reason| AgentPrefixError::InvalidTypeClass {
class: parts[0].to_string(),
reason,
})?;
let modifiers = parts[1..]
.iter()
.filter(|s| !s.is_empty())
.map(|s| (*s).to_string())
.collect();
Ok(Self {
type_class,
modifiers,
normalized: input.to_string(),
})
}
#[must_use]
pub const fn type_class(&self) -> &TypeClass {
&self.type_class
}
#[must_use]
pub fn modifiers(&self) -> &[String] {
&self.modifiers
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.normalized
}
}
impl fmt::Display for AgentPrefix {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.normalized)
}
}
impl FromStr for AgentPrefix {
type Err = AgentPrefixError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl AsRef<str> for AgentPrefix {
fn as_ref(&self) -> &str {
&self.normalized
}
}
impl TryFrom<&str> for AgentPrefix {
type Error = AgentPrefixError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::parse(s)
}
}
impl Deref for AgentPrefix {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.normalized
}
}
impl PartialOrd for AgentPrefix {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for AgentPrefix {
fn cmp(&self, other: &Self) -> Ordering {
self.normalized.cmp(&other.normalized)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for AgentPrefix {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.normalized)
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for AgentPrefix {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_class() {
let prefix = AgentPrefix::parse("llm").unwrap();
assert_eq!(prefix.type_class().as_str(), "llm");
assert!(prefix.modifiers().is_empty());
}
#[test]
fn parse_with_modifiers() {
let prefix = AgentPrefix::parse("llm_chat_streaming").unwrap();
assert_eq!(prefix.type_class().as_str(), "llm");
assert_eq!(prefix.modifiers(), &["chat", "streaming"]);
}
#[test]
fn parse_one_letter_type_class_with_a_modifier() {
let prefix = AgentPrefix::parse("a_b").unwrap();
assert_eq!(prefix.type_class().as_str(), "a");
assert_eq!(prefix.modifiers(), &["b"]);
}
#[test]
fn parse_single_letter_prefix() {
let prefix = AgentPrefix::parse("a").unwrap();
assert_eq!(prefix.type_class().as_str(), "a");
assert!(prefix.modifiers().is_empty());
}
#[test]
fn parse_consecutive_underscores_is_accepted() {
let prefix = AgentPrefix::parse("llm__chat").unwrap();
assert_eq!(prefix.type_class().as_str(), "llm");
assert_eq!(prefix.modifiers(), &["chat"]);
assert_eq!(prefix.as_str(), "llm__chat");
}
#[test]
fn parse_long_underscore_run_yields_no_empty_modifiers() {
let prefix = AgentPrefix::parse("a___b").unwrap();
assert_eq!(prefix.modifiers(), &["b"]);
assert!(
prefix.modifiers().iter().all(|m| !m.is_empty()),
"an empty string is not a modifier by any reading"
);
}
#[test]
fn a_prefix_with_only_underscores_between_two_letters_has_one_modifier() {
let prefix = AgentPrefix::parse("llm____streaming").unwrap();
assert_eq!(prefix.modifiers(), &["streaming"]);
}
#[test]
fn underscore_runs_are_distinct_values_despite_a_shared_reading() {
let one = AgentPrefix::parse("llm_chat").unwrap();
let two = AgentPrefix::parse("llm__chat").unwrap();
assert_eq!(one.type_class(), two.type_class());
assert_eq!(one.modifiers(), two.modifiers());
assert_ne!(one, two);
assert_ne!(one.as_str(), two.as_str());
}
#[test]
fn parse_invalid_character_still_reports_invalid_char() {
let result = AgentPrefix::parse("llm-chat");
assert!(matches!(
result,
Err(AgentPrefixError::InvalidChar {
char: '-',
position: 3,
})
));
}
#[test]
fn parse_empty_fails() {
let result = AgentPrefix::parse("");
assert!(matches!(result, Err(AgentPrefixError::Empty)));
}
#[test]
fn parse_too_long_fails() {
let long = "a".repeat(64);
let result = AgentPrefix::parse(&long);
assert!(matches!(result, Err(AgentPrefixError::TooLong { .. })));
}
#[test]
fn parse_with_digit_fails() {
let result = AgentPrefix::parse("ll2m");
assert!(matches!(
result,
Err(AgentPrefixError::ContainsDigit { position: 2 })
));
}
#[test]
fn parse_uppercase_fails() {
let result = AgentPrefix::parse("LLM");
assert!(matches!(
result,
Err(AgentPrefixError::MustStartWithLetter { found: 'L' })
));
}
#[test]
fn parse_ends_with_underscore_fails() {
let result = AgentPrefix::parse("llm_");
assert!(matches!(
result,
Err(AgentPrefixError::MustEndWithLetter { found: '_' })
));
}
#[test]
fn parse_starts_with_underscore_fails() {
let result = AgentPrefix::parse("_llm");
assert!(matches!(
result,
Err(AgentPrefixError::MustStartWithLetter { found: '_' })
));
}
}