Skip to main content

agent_uri/
fragment.rs

1//! Fragment type for sub-agent addressing.
2
3use std::cmp::Ordering;
4use std::fmt;
5use std::ops::Deref;
6use std::str::FromStr;
7
8use crate::constants::MAX_URI_LENGTH;
9use crate::error::FragmentError;
10
11/// A validated fragment from an agent URI.
12///
13/// The fragment addresses sub-agent functionality or capability subsets.
14/// A `Fragment` is always non-empty because a URI ending in a bare `#` has no
15/// fragment.
16///
17/// # Examples
18///
19/// ```
20/// use agent_uri::Fragment;
21///
22/// let frag = Fragment::parse("summarization").unwrap();
23/// assert_eq!(frag.as_str(), "summarization");
24/// ```
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct Fragment(String);
27
28impl Fragment {
29    /// Parses a fragment from a string (without leading '#').
30    ///
31    /// # Errors
32    ///
33    /// Returns [`FragmentError::Empty`] if the input is empty. Input longer than
34    /// 512 bytes ([`crate::MAX_URI_LENGTH`]) is rejected with
35    /// [`FragmentError::TooLong`]. A fragment within the length limit returns
36    /// [`FragmentError::InvalidChar`] if it contains invalid characters.
37    pub fn parse(input: &str) -> Result<Self, FragmentError> {
38        if input.is_empty() {
39            return Err(FragmentError::Empty);
40        }
41
42        if input.len() > MAX_URI_LENGTH {
43            return Err(FragmentError::TooLong {
44                max: MAX_URI_LENGTH,
45                actual: input.len(),
46            });
47        }
48
49        for (i, c) in input.chars().enumerate() {
50            if !Self::is_valid_char(c) {
51                return Err(FragmentError::InvalidChar {
52                    char: c,
53                    position: i,
54                });
55            }
56        }
57        Ok(Self(input.to_string()))
58    }
59
60    /// Returns the fragment as a string.
61    #[must_use]
62    pub fn as_str(&self) -> &str {
63        &self.0
64    }
65
66    /// Returns true if the character is valid for a fragment.
67    #[must_use]
68    pub const fn is_valid_char(c: char) -> bool {
69        c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':')
70    }
71}
72
73impl fmt::Display for Fragment {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        write!(f, "{}", self.0)
76    }
77}
78
79impl FromStr for Fragment {
80    type Err = FragmentError;
81
82    fn from_str(s: &str) -> Result<Self, Self::Err> {
83        Self::parse(s)
84    }
85}
86
87impl AsRef<str> for Fragment {
88    fn as_ref(&self) -> &str {
89        &self.0
90    }
91}
92
93impl TryFrom<&str> for Fragment {
94    type Error = FragmentError;
95
96    fn try_from(s: &str) -> Result<Self, Self::Error> {
97        Self::parse(s)
98    }
99}
100
101impl Deref for Fragment {
102    type Target = str;
103
104    fn deref(&self) -> &Self::Target {
105        &self.0
106    }
107}
108
109impl PartialOrd for Fragment {
110    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
111        Some(self.cmp(other))
112    }
113}
114
115impl Ord for Fragment {
116    fn cmp(&self, other: &Self) -> Ordering {
117        self.0.cmp(&other.0)
118    }
119}
120
121#[cfg(feature = "serde")]
122impl serde::Serialize for Fragment {
123    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
124    where
125        S: serde::Serializer,
126    {
127        serializer.serialize_str(&self.0)
128    }
129}
130
131#[cfg(feature = "serde")]
132impl<'de> serde::Deserialize<'de> for Fragment {
133    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
134    where
135        D: serde::Deserializer<'de>,
136    {
137        let s = String::deserialize(deserializer)?;
138        Self::parse(&s).map_err(serde::de::Error::custom)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn parse_simple_fragment() {
148        let frag = Fragment::parse("summarization").unwrap();
149        assert_eq!(frag.as_str(), "summarization");
150    }
151
152    #[test]
153    fn parse_fragment_with_special_chars() {
154        let frag = Fragment::parse("sub/path:v2").unwrap();
155        assert_eq!(frag.as_str(), "sub/path:v2");
156    }
157
158    #[test]
159    fn parse_fragment_with_hyphen() {
160        let frag = Fragment::parse("sub-capability").unwrap();
161        assert_eq!(frag.as_str(), "sub-capability");
162    }
163
164    #[test]
165    fn parse_fragment_with_underscore() {
166        let frag = Fragment::parse("sub_capability").unwrap();
167        assert_eq!(frag.as_str(), "sub_capability");
168    }
169
170    #[test]
171    fn parse_fragment_with_dot() {
172        let frag = Fragment::parse("v1.0").unwrap();
173        assert_eq!(frag.as_str(), "v1.0");
174    }
175
176    #[test]
177    fn parse_empty_fragment_is_rejected() {
178        assert!(matches!(Fragment::parse(""), Err(FragmentError::Empty)));
179    }
180
181    #[test]
182    fn from_str_empty_fragment_is_rejected() {
183        assert!(matches!("".parse::<Fragment>(), Err(FragmentError::Empty)));
184    }
185
186    #[test]
187    fn try_from_empty_fragment_is_rejected() {
188        assert!(matches!(Fragment::try_from(""), Err(FragmentError::Empty)));
189    }
190
191    #[test]
192    fn fragment_at_maximum_length_parses() {
193        let input = "a".repeat(MAX_URI_LENGTH);
194
195        let fragment = Fragment::parse(&input).unwrap();
196
197        assert_eq!(fragment.as_str().len(), MAX_URI_LENGTH);
198    }
199
200    #[test]
201    fn fragment_over_maximum_length_reports_limit_and_actual_length() {
202        let input = "a".repeat(MAX_URI_LENGTH + 1);
203
204        let error = Fragment::parse(&input).unwrap_err();
205        let FragmentError::TooLong { max, actual } = error else {
206            panic!("expected FragmentError::TooLong");
207        };
208
209        assert_eq!(max, MAX_URI_LENGTH);
210        assert_eq!(actual, input.len());
211    }
212
213    #[test]
214    fn fragment_length_cap_precedes_character_validation() {
215        let input = format!("@{}", "a".repeat(MAX_URI_LENGTH));
216
217        assert!(matches!(
218            Fragment::parse(&input),
219            Err(FragmentError::TooLong {
220                max: MAX_URI_LENGTH,
221                actual
222            }) if actual == input.len()
223        ));
224    }
225
226    #[test]
227    fn parse_invalid_char_fails() {
228        let result = Fragment::parse("test@value");
229        assert!(matches!(
230            result,
231            Err(FragmentError::InvalidChar {
232                char: '@',
233                position: 4
234            })
235        ));
236    }
237
238    #[test]
239    fn parse_space_fails() {
240        let result = Fragment::parse("test value");
241        assert!(matches!(
242            result,
243            Err(FragmentError::InvalidChar {
244                char: ' ',
245                position: 4
246            })
247        ));
248    }
249}