agent-uri 0.6.0

Parser and validator for agent:// URI scheme
Documentation
//! Fragment type for sub-agent addressing.

use std::cmp::Ordering;
use std::fmt;
use std::ops::Deref;
use std::str::FromStr;

use crate::constants::MAX_URI_LENGTH;
use crate::error::FragmentError;

/// A validated fragment from an agent URI.
///
/// The fragment addresses sub-agent functionality or capability subsets.
/// A `Fragment` is always non-empty because a URI ending in a bare `#` has no
/// fragment.
///
/// # Examples
///
/// ```
/// use agent_uri::Fragment;
///
/// let frag = Fragment::parse("summarization").unwrap();
/// assert_eq!(frag.as_str(), "summarization");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Fragment(String);

impl Fragment {
    /// Parses a fragment from a string (without leading '#').
    ///
    /// # Errors
    ///
    /// Returns [`FragmentError::Empty`] if the input is empty. Input longer than
    /// 512 bytes ([`crate::MAX_URI_LENGTH`]) is rejected with
    /// [`FragmentError::TooLong`]. A fragment within the length limit returns
    /// [`FragmentError::InvalidChar`] if it contains invalid characters.
    pub fn parse(input: &str) -> Result<Self, FragmentError> {
        if input.is_empty() {
            return Err(FragmentError::Empty);
        }

        if input.len() > MAX_URI_LENGTH {
            return Err(FragmentError::TooLong {
                max: MAX_URI_LENGTH,
                actual: input.len(),
            });
        }

        for (i, c) in input.chars().enumerate() {
            if !Self::is_valid_char(c) {
                return Err(FragmentError::InvalidChar {
                    char: c,
                    position: i,
                });
            }
        }
        Ok(Self(input.to_string()))
    }

    /// Returns the fragment as a string.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Returns true if the character is valid for a fragment.
    #[must_use]
    pub const fn is_valid_char(c: char) -> bool {
        c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':')
    }
}

impl fmt::Display for Fragment {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl FromStr for Fragment {
    type Err = FragmentError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::parse(s)
    }
}

impl AsRef<str> for Fragment {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

impl TryFrom<&str> for Fragment {
    type Error = FragmentError;

    fn try_from(s: &str) -> Result<Self, Self::Error> {
        Self::parse(s)
    }
}

impl Deref for Fragment {
    type Target = str;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl PartialOrd for Fragment {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Fragment {
    fn cmp(&self, other: &Self) -> Ordering {
        self.0.cmp(&other.0)
    }
}

#[cfg(feature = "serde")]
impl serde::Serialize for Fragment {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        serializer.serialize_str(&self.0)
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Fragment {
    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_fragment() {
        let frag = Fragment::parse("summarization").unwrap();
        assert_eq!(frag.as_str(), "summarization");
    }

    #[test]
    fn parse_fragment_with_special_chars() {
        let frag = Fragment::parse("sub/path:v2").unwrap();
        assert_eq!(frag.as_str(), "sub/path:v2");
    }

    #[test]
    fn parse_fragment_with_hyphen() {
        let frag = Fragment::parse("sub-capability").unwrap();
        assert_eq!(frag.as_str(), "sub-capability");
    }

    #[test]
    fn parse_fragment_with_underscore() {
        let frag = Fragment::parse("sub_capability").unwrap();
        assert_eq!(frag.as_str(), "sub_capability");
    }

    #[test]
    fn parse_fragment_with_dot() {
        let frag = Fragment::parse("v1.0").unwrap();
        assert_eq!(frag.as_str(), "v1.0");
    }

    #[test]
    fn parse_empty_fragment_is_rejected() {
        assert!(matches!(Fragment::parse(""), Err(FragmentError::Empty)));
    }

    #[test]
    fn from_str_empty_fragment_is_rejected() {
        assert!(matches!("".parse::<Fragment>(), Err(FragmentError::Empty)));
    }

    #[test]
    fn try_from_empty_fragment_is_rejected() {
        assert!(matches!(Fragment::try_from(""), Err(FragmentError::Empty)));
    }

    #[test]
    fn fragment_at_maximum_length_parses() {
        let input = "a".repeat(MAX_URI_LENGTH);

        let fragment = Fragment::parse(&input).unwrap();

        assert_eq!(fragment.as_str().len(), MAX_URI_LENGTH);
    }

    #[test]
    fn fragment_over_maximum_length_reports_limit_and_actual_length() {
        let input = "a".repeat(MAX_URI_LENGTH + 1);

        let error = Fragment::parse(&input).unwrap_err();
        let FragmentError::TooLong { max, actual } = error else {
            panic!("expected FragmentError::TooLong");
        };

        assert_eq!(max, MAX_URI_LENGTH);
        assert_eq!(actual, input.len());
    }

    #[test]
    fn fragment_length_cap_precedes_character_validation() {
        let input = format!("@{}", "a".repeat(MAX_URI_LENGTH));

        assert!(matches!(
            Fragment::parse(&input),
            Err(FragmentError::TooLong {
                max: MAX_URI_LENGTH,
                actual
            }) if actual == input.len()
        ));
    }

    #[test]
    fn parse_invalid_char_fails() {
        let result = Fragment::parse("test@value");
        assert!(matches!(
            result,
            Err(FragmentError::InvalidChar {
                char: '@',
                position: 4
            })
        ));
    }

    #[test]
    fn parse_space_fails() {
        let result = Fragment::parse("test value");
        assert!(matches!(
            result,
            Err(FragmentError::InvalidChar {
                char: ' ',
                position: 4
            })
        ));
    }
}