Skip to main content

agent_uri/
capability_path.rs

1//! Capability path type for agent capabilities.
2//!
3//! # Grammar Reference
4//!
5//! The capability path grammar is defined in `grammar.abnf`:
6//!
7//! ```abnf
8//! capability-path = path-segment *31( "/" path-segment )
9//! path-segment    = 1*64( LOWER / DIGIT / "-" )
10//! LOWER           = %x61-7A  ; a-z
11//! ```
12//!
13//! Constraints:
14//! - Maximum 256 characters total
15//! - Maximum 32 segments
16//! - Each segment: 1-64 characters
17
18use std::cmp::Ordering;
19use std::fmt;
20use std::str::FromStr;
21
22use crate::constants::{MAX_CAPABILITY_PATH_LENGTH, MAX_PATH_SEGMENTS};
23use crate::error::CapabilityPathError;
24use crate::path_segment::PathSegment;
25
26/// A validated capability path from an agent URI.
27///
28/// The capability path describes what an agent does, organized as a
29/// hierarchical path of segments separated by forward slashes.
30///
31/// # Constraints
32///
33/// - At least one segment required
34/// - Maximum 32 segments
35/// - Maximum 256 total characters
36/// - Each segment: 1-64 chars, lowercase alphanumeric + hyphens
37///
38/// # Examples
39///
40/// ```
41/// use agent_uri::CapabilityPath;
42///
43/// let path = CapabilityPath::parse("assistant/chat").unwrap();
44/// assert_eq!(path.segments().len(), 2);
45/// assert_eq!(path.segments()[0].as_str(), "assistant");
46/// assert_eq!(path.segments()[1].as_str(), "chat");
47///
48/// // Deep hierarchies
49/// let path = CapabilityPath::parse("workflow/approval/invoice").unwrap();
50/// assert_eq!(path.segments().len(), 3);
51/// ```
52#[derive(Debug, Clone, PartialEq, Eq, Hash)]
53pub struct CapabilityPath {
54    segments: Vec<PathSegment>,
55    /// Normalized string representation
56    normalized: String,
57}
58
59impl CapabilityPath {
60    /// Builds a `CapabilityPath` from pre-validated `PathSegment` vectors.
61    ///
62    /// This method allows constructing a `CapabilityPath` from segments that
63    /// have already been validated individually, while still validating
64    /// that the resulting path meets the overall path constraints.
65    ///
66    /// # Arguments
67    ///
68    /// * `segments` - A vector of pre-validated `PathSegment` instances
69    ///
70    /// # Returns
71    ///
72    /// A validated `CapabilityPath` on success.
73    ///
74    /// # Errors
75    ///
76    /// Returns `CapabilityPathError` if:
77    /// - The segments vector is empty
78    /// - The path exceeds 256 total characters
79    /// - The path has more than 32 segments
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use agent_uri::{CapabilityPath, PathSegment};
85    ///
86    /// let segments = vec![
87    ///     PathSegment::parse("assistant").unwrap(),
88    ///     PathSegment::parse("chat").unwrap(),
89    /// ];
90    /// let path = CapabilityPath::from_segments(segments).unwrap();
91    /// assert_eq!(path.as_str(), "assistant/chat");
92    /// ```
93    pub fn from_segments(segments: Vec<PathSegment>) -> Result<Self, CapabilityPathError> {
94        if segments.is_empty() {
95            return Err(CapabilityPathError::Empty);
96        }
97
98        if segments.len() > MAX_PATH_SEGMENTS {
99            return Err(CapabilityPathError::TooManySegments {
100                max: MAX_PATH_SEGMENTS,
101                actual: segments.len(),
102            });
103        }
104
105        let normalized = segments
106            .iter()
107            .map(PathSegment::as_str)
108            .collect::<Vec<_>>()
109            .join("/");
110
111        if normalized.len() > MAX_CAPABILITY_PATH_LENGTH {
112            return Err(CapabilityPathError::TooLong {
113                max: MAX_CAPABILITY_PATH_LENGTH,
114                actual: normalized.len(),
115            });
116        }
117
118        Ok(Self {
119            segments,
120            normalized,
121        })
122    }
123
124    /// Builds a `CapabilityPath` by parsing each segment string individually.
125    ///
126    /// This is a convenience method for constructing paths from string slices
127    /// without first parsing them into `PathSegment` instances.
128    ///
129    /// # Arguments
130    ///
131    /// * `segments` - A slice of string slices, each representing a path segment
132    ///
133    /// # Returns
134    ///
135    /// A validated `CapabilityPath` on success.
136    ///
137    /// # Errors
138    ///
139    /// Returns `CapabilityPathError` if:
140    /// - The segments slice is empty
141    /// - Any segment string is invalid
142    /// - The path exceeds 256 total characters
143    /// - The path has more than 32 segments
144    ///
145    /// # Examples
146    ///
147    /// ```
148    /// use agent_uri::CapabilityPath;
149    ///
150    /// let path = CapabilityPath::try_from_strs(&["assistant", "chat"]).unwrap();
151    /// assert_eq!(path.as_str(), "assistant/chat");
152    ///
153    /// // Invalid segment fails
154    /// let result = CapabilityPath::try_from_strs(&["valid", "INVALID"]);
155    /// assert!(result.is_err());
156    /// ```
157    pub fn try_from_strs(segments: &[&str]) -> Result<Self, CapabilityPathError> {
158        if segments.is_empty() {
159            return Err(CapabilityPathError::Empty);
160        }
161
162        let parsed_segments: Result<Vec<PathSegment>, CapabilityPathError> = segments
163            .iter()
164            .enumerate()
165            .map(|(i, s)| {
166                PathSegment::parse(s).map_err(|e| CapabilityPathError::InvalidSegment {
167                    segment: (*s).to_string(),
168                    index: i,
169                    reason: e,
170                })
171            })
172            .collect();
173
174        Self::from_segments(parsed_segments?)
175    }
176
177    /// Parses a capability path from a string.
178    ///
179    /// # Errors
180    ///
181    /// Returns `CapabilityPathError` if:
182    /// - The path is empty
183    /// - The path exceeds 256 characters
184    /// - The path has more than 32 segments
185    /// - Any segment is invalid
186    pub fn parse(input: &str) -> Result<Self, CapabilityPathError> {
187        if input.is_empty() {
188            return Err(CapabilityPathError::Empty);
189        }
190
191        if input.len() > MAX_CAPABILITY_PATH_LENGTH {
192            return Err(CapabilityPathError::TooLong {
193                max: MAX_CAPABILITY_PATH_LENGTH,
194                actual: input.len(),
195            });
196        }
197
198        let segment_strs: Vec<&str> = input.split('/').collect();
199
200        if segment_strs.len() > MAX_PATH_SEGMENTS {
201            return Err(CapabilityPathError::TooManySegments {
202                max: MAX_PATH_SEGMENTS,
203                actual: segment_strs.len(),
204            });
205        }
206
207        let mut segments = Vec::with_capacity(segment_strs.len());
208        for (i, seg_str) in segment_strs.iter().enumerate() {
209            let segment =
210                PathSegment::parse(seg_str).map_err(|e| CapabilityPathError::InvalidSegment {
211                    segment: (*seg_str).to_string(),
212                    index: i,
213                    reason: e,
214                })?;
215            segments.push(segment);
216        }
217
218        let normalized = segments
219            .iter()
220            .map(PathSegment::as_str)
221            .collect::<Vec<_>>()
222            .join("/");
223
224        Ok(Self {
225            segments,
226            normalized,
227        })
228    }
229
230    /// Returns the path segments.
231    #[must_use]
232    pub fn segments(&self) -> &[PathSegment] {
233        &self.segments
234    }
235
236    /// Returns the number of segments.
237    #[must_use]
238    pub fn depth(&self) -> usize {
239        self.segments.len()
240    }
241
242    /// Returns true if this path starts with the given prefix path.
243    #[must_use]
244    pub fn starts_with(&self, prefix: &CapabilityPath) -> bool {
245        if prefix.segments.len() > self.segments.len() {
246            return false;
247        }
248        self.segments
249            .iter()
250            .zip(prefix.segments.iter())
251            .all(|(a, b)| a == b)
252    }
253
254    /// Returns the normalized string representation.
255    #[must_use]
256    pub fn as_str(&self) -> &str {
257        &self.normalized
258    }
259
260    /// Returns the parent path, or `None` if this is a single-segment path.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use agent_uri::CapabilityPath;
266    ///
267    /// let path = CapabilityPath::parse("assistant/chat/streaming").unwrap();
268    /// let parent = path.parent().unwrap();
269    /// assert_eq!(parent.as_str(), "assistant/chat");
270    ///
271    /// let root = CapabilityPath::parse("chat").unwrap();
272    /// assert!(root.parent().is_none());
273    /// ```
274    #[must_use]
275    pub fn parent(&self) -> Option<Self> {
276        if self.segments.len() <= 1 {
277            return None;
278        }
279        let parent_segments: Vec<PathSegment> = self.segments[..self.segments.len() - 1].to_vec();
280        let normalized = parent_segments
281            .iter()
282            .map(PathSegment::as_str)
283            .collect::<Vec<_>>()
284            .join("/");
285        Some(Self {
286            segments: parent_segments,
287            normalized,
288        })
289    }
290
291    /// Returns a new path with the given segment appended.
292    ///
293    /// # Errors
294    ///
295    /// Returns `CapabilityPathError` if the resulting path would be invalid.
296    ///
297    /// # Examples
298    ///
299    /// ```
300    /// use agent_uri::{CapabilityPath, PathSegment};
301    ///
302    /// let path = CapabilityPath::parse("assistant").unwrap();
303    /// let segment = PathSegment::parse("chat").unwrap();
304    /// let joined = path.join(&segment).unwrap();
305    /// assert_eq!(joined.as_str(), "assistant/chat");
306    /// ```
307    pub fn join(&self, segment: &PathSegment) -> Result<Self, CapabilityPathError> {
308        let new_len = self.segments.len() + 1;
309        if new_len > MAX_PATH_SEGMENTS {
310            return Err(CapabilityPathError::TooManySegments {
311                max: MAX_PATH_SEGMENTS,
312                actual: new_len,
313            });
314        }
315
316        let mut segments = self.segments.clone();
317        segments.push(segment.clone());
318
319        let normalized = segments
320            .iter()
321            .map(PathSegment::as_str)
322            .collect::<Vec<_>>()
323            .join("/");
324
325        if normalized.len() > MAX_CAPABILITY_PATH_LENGTH {
326            return Err(CapabilityPathError::TooLong {
327                max: MAX_CAPABILITY_PATH_LENGTH,
328                actual: normalized.len(),
329            });
330        }
331
332        Ok(Self {
333            segments,
334            normalized,
335        })
336    }
337
338    /// Returns a new path with a segment parsed from a string appended.
339    ///
340    /// # Errors
341    ///
342    /// Returns `CapabilityPathError` if the segment or resulting path would be invalid.
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// use agent_uri::CapabilityPath;
348    ///
349    /// let path = CapabilityPath::parse("assistant").unwrap();
350    /// let joined = path.try_join("chat").unwrap();
351    /// assert_eq!(joined.as_str(), "assistant/chat");
352    /// ```
353    pub fn try_join(&self, s: &str) -> Result<Self, CapabilityPathError> {
354        let segment = PathSegment::parse(s).map_err(|e| CapabilityPathError::InvalidSegment {
355            segment: s.to_string(),
356            index: self.segments.len(),
357            reason: e,
358        })?;
359        self.join(&segment)
360    }
361
362    /// Returns the last segment of the path.
363    ///
364    /// # Examples
365    ///
366    /// ```
367    /// use agent_uri::CapabilityPath;
368    ///
369    /// let path = CapabilityPath::parse("assistant/chat").unwrap();
370    /// assert_eq!(path.last().as_str(), "chat");
371    /// ```
372    ///
373    /// # Panics
374    ///
375    /// This method will not panic because a `CapabilityPath` always has
376    /// at least one segment (empty paths cannot be created).
377    #[must_use]
378    pub fn last(&self) -> &PathSegment {
379        // Safe: CapabilityPath always has at least one segment
380        self.segments.last().expect("path has at least one segment")
381    }
382
383    /// Returns an iterator over the path segments.
384    ///
385    /// # Examples
386    ///
387    /// ```
388    /// use agent_uri::CapabilityPath;
389    ///
390    /// let path = CapabilityPath::parse("assistant/chat/streaming").unwrap();
391    /// let names: Vec<&str> = path.iter().map(|s| s.as_str()).collect();
392    /// assert_eq!(names, vec!["assistant", "chat", "streaming"]);
393    /// ```
394    pub fn iter(&self) -> std::slice::Iter<'_, PathSegment> {
395        self.segments.iter()
396    }
397}
398
399impl<'a> IntoIterator for &'a CapabilityPath {
400    type Item = &'a PathSegment;
401    type IntoIter = std::slice::Iter<'a, PathSegment>;
402
403    fn into_iter(self) -> Self::IntoIter {
404        self.segments.iter()
405    }
406}
407
408impl fmt::Display for CapabilityPath {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        write!(f, "{}", self.normalized)
411    }
412}
413
414impl FromStr for CapabilityPath {
415    type Err = CapabilityPathError;
416
417    fn from_str(s: &str) -> Result<Self, Self::Err> {
418        Self::parse(s)
419    }
420}
421
422impl AsRef<str> for CapabilityPath {
423    fn as_ref(&self) -> &str {
424        &self.normalized
425    }
426}
427
428impl TryFrom<&str> for CapabilityPath {
429    type Error = CapabilityPathError;
430
431    fn try_from(s: &str) -> Result<Self, Self::Error> {
432        Self::parse(s)
433    }
434}
435
436impl PartialOrd for CapabilityPath {
437    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
438        Some(self.cmp(other))
439    }
440}
441
442impl Ord for CapabilityPath {
443    fn cmp(&self, other: &Self) -> Ordering {
444        self.normalized.cmp(&other.normalized)
445    }
446}
447
448#[cfg(feature = "serde")]
449impl serde::Serialize for CapabilityPath {
450    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
451    where
452        S: serde::Serializer,
453    {
454        serializer.serialize_str(&self.normalized)
455    }
456}
457
458#[cfg(feature = "serde")]
459impl<'de> serde::Deserialize<'de> for CapabilityPath {
460    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
461    where
462        D: serde::Deserializer<'de>,
463    {
464        let s = String::deserialize(deserializer)?;
465        Self::parse(&s).map_err(serde::de::Error::custom)
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn parse_single_segment() {
475        let path = CapabilityPath::parse("chat").unwrap();
476        assert_eq!(path.depth(), 1);
477        assert_eq!(path.as_str(), "chat");
478    }
479
480    #[test]
481    fn parse_multiple_segments() {
482        let path = CapabilityPath::parse("workflow/approval/invoice").unwrap();
483        assert_eq!(path.depth(), 3);
484        assert_eq!(path.segments()[0].as_str(), "workflow");
485        assert_eq!(path.segments()[1].as_str(), "approval");
486        assert_eq!(path.segments()[2].as_str(), "invoice");
487    }
488
489    #[test]
490    fn starts_with_prefix() {
491        let path = CapabilityPath::parse("workflow/approval/invoice").unwrap();
492        let prefix = CapabilityPath::parse("workflow/approval").unwrap();
493        assert!(path.starts_with(&prefix));
494    }
495
496    #[test]
497    fn does_not_start_with_longer_prefix() {
498        let path = CapabilityPath::parse("workflow/approval").unwrap();
499        let prefix = CapabilityPath::parse("workflow/approval/invoice").unwrap();
500        assert!(!path.starts_with(&prefix));
501    }
502
503    #[test]
504    fn does_not_start_with_different_prefix() {
505        let path = CapabilityPath::parse("workflow/approval").unwrap();
506        let prefix = CapabilityPath::parse("assistant/chat").unwrap();
507        assert!(!path.starts_with(&prefix));
508    }
509
510    #[test]
511    fn parse_empty_fails() {
512        let result = CapabilityPath::parse("");
513        assert!(matches!(result, Err(CapabilityPathError::Empty)));
514    }
515
516    #[test]
517    fn parse_too_long_fails() {
518        let long = "a".repeat(257);
519        let result = CapabilityPath::parse(&long);
520        assert!(matches!(result, Err(CapabilityPathError::TooLong { .. })));
521    }
522
523    #[test]
524    fn parse_too_many_segments_fails() {
525        let segments = (0..33).map(|_| "a").collect::<Vec<_>>().join("/");
526        let result = CapabilityPath::parse(&segments);
527        assert!(matches!(
528            result,
529            Err(CapabilityPathError::TooManySegments { max: 32, actual: 33 })
530        ));
531    }
532
533    #[test]
534    fn parse_invalid_segment_fails() {
535        let result = CapabilityPath::parse("valid/INVALID/also-valid");
536        assert!(matches!(
537            result,
538            Err(CapabilityPathError::InvalidSegment { index: 1, .. })
539        ));
540    }
541
542    #[test]
543    fn parse_empty_segment_fails() {
544        let result = CapabilityPath::parse("valid//invalid");
545        assert!(matches!(
546            result,
547            Err(CapabilityPathError::InvalidSegment { index: 1, .. })
548        ));
549    }
550
551    // from_segments tests
552
553    #[test]
554    fn from_segments_empty_returns_error() {
555        let result = CapabilityPath::from_segments(vec![]);
556        assert!(matches!(result, Err(CapabilityPathError::Empty)));
557    }
558
559    #[test]
560    fn from_segments_single_segment_succeeds() {
561        let segments = vec![PathSegment::parse("chat").unwrap()];
562        let path = CapabilityPath::from_segments(segments).unwrap();
563        assert_eq!(path.depth(), 1);
564        assert_eq!(path.as_str(), "chat");
565    }
566
567    #[test]
568    fn from_segments_multiple_segments_succeeds() {
569        let segments = vec![
570            PathSegment::parse("assistant").unwrap(),
571            PathSegment::parse("chat").unwrap(),
572        ];
573        let path = CapabilityPath::from_segments(segments).unwrap();
574        assert_eq!(path.depth(), 2);
575        assert_eq!(path.as_str(), "assistant/chat");
576    }
577
578    #[test]
579    fn from_segments_max_segments_succeeds() {
580        let segments: Vec<PathSegment> = (0..32)
581            .map(|_| PathSegment::parse("a").unwrap())
582            .collect();
583        let path = CapabilityPath::from_segments(segments).unwrap();
584        assert_eq!(path.depth(), 32);
585    }
586
587    #[test]
588    fn from_segments_too_many_segments_returns_error() {
589        let segments: Vec<PathSegment> = (0..33)
590            .map(|_| PathSegment::parse("a").unwrap())
591            .collect();
592        let result = CapabilityPath::from_segments(segments);
593        assert!(matches!(
594            result,
595            Err(CapabilityPathError::TooManySegments {
596                max: 32,
597                actual: 33
598            })
599        ));
600    }
601
602    #[test]
603    fn from_segments_too_long_returns_error() {
604        // Create segments that together exceed 256 chars
605        // 8 segments of 32 chars each = 256 chars + 7 slashes = 263 chars
606        let long_segment = "a".repeat(32);
607        let segments: Vec<PathSegment> = (0..8)
608            .map(|_| PathSegment::parse(&long_segment).unwrap())
609            .collect();
610        let result = CapabilityPath::from_segments(segments);
611        assert!(matches!(result, Err(CapabilityPathError::TooLong { .. })));
612    }
613
614    // try_from_strs tests
615
616    #[test]
617    fn try_from_strs_empty_returns_error() {
618        let result = CapabilityPath::try_from_strs(&[]);
619        assert!(matches!(result, Err(CapabilityPathError::Empty)));
620    }
621
622    #[test]
623    fn try_from_strs_valid_segments_succeeds() {
624        let path = CapabilityPath::try_from_strs(&["assistant", "chat"]).unwrap();
625        assert_eq!(path.as_str(), "assistant/chat");
626    }
627
628    #[test]
629    fn try_from_strs_invalid_segment_uppercase_returns_error() {
630        let result = CapabilityPath::try_from_strs(&["valid", "INVALID"]);
631        assert!(matches!(
632            result,
633            Err(CapabilityPathError::InvalidSegment { index: 1, .. })
634        ));
635    }
636
637    #[test]
638    fn try_from_strs_invalid_segment_empty_returns_error() {
639        let result = CapabilityPath::try_from_strs(&["valid", ""]);
640        assert!(matches!(
641            result,
642            Err(CapabilityPathError::InvalidSegment { index: 1, .. })
643        ));
644    }
645
646    #[test]
647    fn try_from_strs_first_invalid_returns_error() {
648        // Should fail on first invalid, not continue to check others
649        let result = CapabilityPath::try_from_strs(&["INVALID", "also-valid"]);
650        assert!(matches!(
651            result,
652            Err(CapabilityPathError::InvalidSegment { index: 0, .. })
653        ));
654    }
655}