Skip to main content

agent_uri/
uri.rs

1//! Main agent URI type.
2//!
3//! # Grammar Reference
4//!
5//! The URI grammar is defined in `grammar.abnf` at the crate root:
6//!
7//! ```abnf
8//! agent-uri = scheme "://" trust-root "/" capability-path "/" agent-id
9//!             [ "?" query ] [ "#" fragment ]
10//! scheme    = "agent"
11//! ```
12//!
13//! Maximum URI length: 512 characters.
14
15use std::cmp::Ordering;
16use std::fmt;
17use std::str::FromStr;
18
19use crate::agent_id::AgentId;
20use crate::capability_path::CapabilityPath;
21use crate::constants::{MAX_URI_LENGTH, SCHEME};
22use crate::error::{ParseError, ParseErrorKind};
23use crate::fragment::Fragment;
24use crate::query::QueryParams;
25use crate::trust_root::TrustRoot;
26
27/// A parsed and validated agent URI.
28///
29/// Agent URIs provide topology-independent identity for agents with
30/// capability-based discovery.
31///
32/// # Structure
33///
34/// ```text
35/// agent://<trust-root>/<capability-path>/<agent-id>[?query][#fragment]
36/// ```
37///
38/// # Examples
39///
40/// ```
41/// use agent_uri::AgentUri;
42///
43/// let uri = AgentUri::parse("agent://anthropic.com/assistant/chat/llm_chat_01h455vb4pex5vsknk084sn02q").unwrap();
44/// assert_eq!(uri.trust_root().host_str(), "anthropic.com");
45/// assert_eq!(uri.capability_path().as_str(), "assistant/chat");
46/// assert_eq!(uri.agent_id().prefix().as_str(), "llm_chat");
47///
48/// // With query and fragment
49/// let uri = AgentUri::parse("agent://openai.com/tool/code/llm_01h455vb4pex5vsknk084sn02q?version=2.0#summarization").unwrap();
50/// assert_eq!(uri.query().version(), Some("2.0"));
51/// assert_eq!(uri.fragment().map(|f| f.as_str()), Some("summarization"));
52/// ```
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct AgentUri {
55    trust_root: TrustRoot,
56    capability_path: CapabilityPath,
57    agent_id: AgentId,
58    query: QueryParams,
59    fragment: Option<Fragment>,
60    /// Normalized string representation
61    normalized: String,
62}
63
64impl AgentUri {
65    /// Parses an agent URI from a string.
66    ///
67    /// # Errors
68    ///
69    /// Returns `ParseError` if:
70    /// - The URI is empty
71    /// - The URI exceeds 512 characters
72    /// - The scheme is not "agent://"
73    /// - Any component (trust root, path, agent ID, query, fragment) is invalid
74    pub fn parse(input: &str) -> Result<Self, ParseError> {
75        Self::parse_inner(input).map_err(|kind| ParseError {
76            input: input.to_string(),
77            kind,
78        })
79    }
80
81    /// Creates a new agent URI from its components.
82    ///
83    /// # Errors
84    ///
85    /// Returns `ParseError` if the resulting URI would exceed the maximum length.
86    pub fn new(
87        trust_root: TrustRoot,
88        capability_path: CapabilityPath,
89        agent_id: AgentId,
90        query: QueryParams,
91        fragment: Option<Fragment>,
92    ) -> Result<Self, ParseError> {
93        let normalized = Self::normalize(&trust_root, &capability_path, &agent_id, &query, fragment.as_ref());
94        let len = normalized.len();
95
96        if len > MAX_URI_LENGTH {
97            return Err(ParseError {
98                input: normalized,
99                kind: ParseErrorKind::TooLong {
100                    max: MAX_URI_LENGTH,
101                    actual: len,
102                },
103            });
104        }
105
106        Ok(Self {
107            trust_root,
108            capability_path,
109            agent_id,
110            query,
111            fragment,
112            normalized,
113        })
114    }
115
116    /// Returns the trust root.
117    #[must_use]
118    pub const fn trust_root(&self) -> &TrustRoot {
119        &self.trust_root
120    }
121
122    /// Returns the capability path.
123    #[must_use]
124    pub const fn capability_path(&self) -> &CapabilityPath {
125        &self.capability_path
126    }
127
128    /// Returns the agent ID.
129    #[must_use]
130    pub const fn agent_id(&self) -> &AgentId {
131        &self.agent_id
132    }
133
134    /// Returns the query parameters.
135    #[must_use]
136    pub const fn query(&self) -> &QueryParams {
137        &self.query
138    }
139
140    /// Returns the fragment, if present.
141    #[must_use]
142    pub const fn fragment(&self) -> Option<&Fragment> {
143        self.fragment.as_ref()
144    }
145
146    /// Returns the normalized URI string.
147    #[must_use]
148    pub fn as_str(&self) -> &str {
149        &self.normalized
150    }
151
152    /// Returns the canonical URI (without query and fragment).
153    #[must_use]
154    pub fn canonical(&self) -> String {
155        format!(
156            "{SCHEME}://{}/{}/{}",
157            self.trust_root, self.capability_path, self.agent_id
158        )
159    }
160
161    /// Returns true if this URI references a localhost agent.
162    #[must_use]
163    pub fn is_localhost(&self) -> bool {
164        self.trust_root.is_localhost()
165    }
166
167    /// Returns a new URI with the given query parameters.
168    ///
169    /// # Errors
170    ///
171    /// Returns `ParseError` if the resulting URI would exceed the maximum length.
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// use agent_uri::{AgentUri, QueryParams};
177    ///
178    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
179    /// let query = QueryParams::parse("version=2.0").unwrap();
180    /// let updated = uri.with_query(query).unwrap();
181    /// assert_eq!(updated.query().version(), Some("2.0"));
182    /// ```
183    pub fn with_query(&self, query: QueryParams) -> Result<Self, ParseError> {
184        Self::new(
185            self.trust_root.clone(),
186            self.capability_path.clone(),
187            self.agent_id.clone(),
188            query,
189            self.fragment.clone(),
190        )
191    }
192
193    /// Returns a new URI with query parameters parsed from a string.
194    ///
195    /// # Errors
196    ///
197    /// Returns `ParseError` if the query string is invalid or the resulting URI
198    /// would exceed the maximum length.
199    ///
200    /// # Examples
201    ///
202    /// ```
203    /// use agent_uri::AgentUri;
204    ///
205    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
206    /// let updated = uri.with_query_str("version=2.0&ttl=300").unwrap();
207    /// assert_eq!(updated.query().version(), Some("2.0"));
208    /// assert_eq!(updated.query().ttl(), Some(300));
209    /// ```
210    pub fn with_query_str(&self, s: &str) -> Result<Self, ParseError> {
211        let query = QueryParams::parse(s).map_err(|e| ParseError {
212            input: s.to_string(),
213            kind: ParseErrorKind::InvalidQuery(e),
214        })?;
215        self.with_query(query)
216    }
217
218    /// Returns a new URI without query parameters.
219    ///
220    /// # Errors
221    ///
222    /// Returns `ParseError` if the resulting URI would exceed the maximum length.
223    /// (This is unlikely but possible in edge cases.)
224    ///
225    /// # Examples
226    ///
227    /// ```
228    /// use agent_uri::AgentUri;
229    ///
230    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q?version=2.0").unwrap();
231    /// let updated = uri.without_query().unwrap();
232    /// assert!(updated.query().is_empty());
233    /// ```
234    pub fn without_query(&self) -> Result<Self, ParseError> {
235        self.with_query(QueryParams::new())
236    }
237
238    /// Returns a new URI with the given fragment.
239    ///
240    /// # Errors
241    ///
242    /// Returns `ParseError` if the resulting URI would exceed the maximum length.
243    ///
244    /// # Examples
245    ///
246    /// ```
247    /// use agent_uri::{AgentUri, Fragment};
248    ///
249    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
250    /// let fragment = Fragment::parse("summarization").unwrap();
251    /// let updated = uri.with_fragment(fragment).unwrap();
252    /// assert_eq!(updated.fragment().map(|f| f.as_str()), Some("summarization"));
253    /// ```
254    pub fn with_fragment(&self, fragment: Fragment) -> Result<Self, ParseError> {
255        Self::new(
256            self.trust_root.clone(),
257            self.capability_path.clone(),
258            self.agent_id.clone(),
259            self.query.clone(),
260            Some(fragment),
261        )
262    }
263
264    /// Returns a new URI with a fragment parsed from a string.
265    ///
266    /// # Errors
267    ///
268    /// Returns `ParseError` if the fragment is invalid or the resulting URI
269    /// would exceed the maximum length.
270    ///
271    /// # Examples
272    ///
273    /// ```
274    /// use agent_uri::AgentUri;
275    ///
276    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
277    /// let updated = uri.with_fragment_str("summarization").unwrap();
278    /// assert_eq!(updated.fragment().map(|f| f.as_str()), Some("summarization"));
279    /// ```
280    pub fn with_fragment_str(&self, s: &str) -> Result<Self, ParseError> {
281        let fragment = Fragment::parse(s).map_err(|e| ParseError {
282            input: s.to_string(),
283            kind: ParseErrorKind::InvalidFragment(e),
284        })?;
285        self.with_fragment(fragment)
286    }
287
288    /// Returns a new URI without a fragment.
289    ///
290    /// # Errors
291    ///
292    /// Returns `ParseError` if the resulting URI would exceed the maximum length.
293    /// (This is unlikely but possible in edge cases.)
294    ///
295    /// # Examples
296    ///
297    /// ```
298    /// use agent_uri::AgentUri;
299    ///
300    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q#test").unwrap();
301    /// let updated = uri.without_fragment().unwrap();
302    /// assert!(updated.fragment().is_none());
303    /// ```
304    pub fn without_fragment(&self) -> Result<Self, ParseError> {
305        Self::new(
306            self.trust_root.clone(),
307            self.capability_path.clone(),
308            self.agent_id.clone(),
309            self.query.clone(),
310            None,
311        )
312    }
313
314    fn parse_inner(input: &str) -> Result<Self, ParseErrorKind> {
315        if input.is_empty() {
316            return Err(ParseErrorKind::Empty);
317        }
318
319        if input.len() > MAX_URI_LENGTH {
320            return Err(ParseErrorKind::TooLong {
321                max: MAX_URI_LENGTH,
322                actual: input.len(),
323            });
324        }
325
326        // Check and strip scheme
327        let scheme_prefix = format!("{SCHEME}://");
328        if !input.starts_with(&scheme_prefix) {
329            let found = input.split("://").next().map(str::to_string);
330            return Err(ParseErrorKind::InvalidScheme { found });
331        }
332        let rest = &input[scheme_prefix.len()..];
333
334        // Split off fragment
335        let (rest, fragment) = Self::split_fragment(rest)?;
336
337        // Split off query
338        let (rest, query) = Self::split_query(rest)?;
339
340        // Split trust root from path
341        let (trust_root_str, path_with_id) = Self::split_trust_root(rest)?;
342
343        // Parse trust root
344        let trust_root =
345            TrustRoot::parse(trust_root_str).map_err(ParseErrorKind::InvalidTrustRoot)?;
346
347        // Split capability path from agent ID (agent ID is always the last segment)
348        let (cap_path_str, agent_id_str) = Self::split_path_and_id(path_with_id)?;
349
350        // Parse capability path
351        let capability_path =
352            CapabilityPath::parse(cap_path_str).map_err(ParseErrorKind::InvalidCapabilityPath)?;
353
354        // Parse agent ID
355        let agent_id = AgentId::parse(agent_id_str).map_err(ParseErrorKind::InvalidAgentId)?;
356
357        let normalized =
358            Self::normalize(&trust_root, &capability_path, &agent_id, &query, fragment.as_ref());
359
360        Ok(Self {
361            trust_root,
362            capability_path,
363            agent_id,
364            query,
365            fragment,
366            normalized,
367        })
368    }
369
370    fn split_fragment(input: &str) -> Result<(&str, Option<Fragment>), ParseErrorKind> {
371        if let Some(hash_idx) = input.find('#') {
372            let rest = &input[..hash_idx];
373            let frag_str = &input[hash_idx + 1..];
374            if frag_str.is_empty() {
375                Ok((rest, None)) // Empty fragment is stripped
376            } else {
377                let fragment =
378                    Fragment::parse(frag_str).map_err(ParseErrorKind::InvalidFragment)?;
379                Ok((rest, Some(fragment)))
380            }
381        } else {
382            Ok((input, None))
383        }
384    }
385
386    fn split_query(input: &str) -> Result<(&str, QueryParams), ParseErrorKind> {
387        if let Some(q_idx) = input.find('?') {
388            let rest = &input[..q_idx];
389            let query_str = &input[q_idx + 1..];
390            if query_str.is_empty() {
391                Ok((rest, QueryParams::new())) // Empty query is stripped
392            } else {
393                let query = QueryParams::parse(query_str).map_err(ParseErrorKind::InvalidQuery)?;
394                Ok((rest, query))
395            }
396        } else {
397            Ok((input, QueryParams::new()))
398        }
399    }
400
401    fn split_trust_root(input: &str) -> Result<(&str, &str), ParseErrorKind> {
402        // Find the first '/' which separates trust root from path
403        let slash_idx = input.find('/').ok_or(ParseErrorKind::MissingComponent {
404            component: "capability path",
405        })?;
406
407        let trust_root = &input[..slash_idx];
408        let path = &input[slash_idx + 1..];
409
410        if trust_root.is_empty() {
411            return Err(ParseErrorKind::MissingComponent {
412                component: "trust root",
413            });
414        }
415
416        if path.is_empty() {
417            return Err(ParseErrorKind::MissingComponent {
418                component: "capability path",
419            });
420        }
421
422        Ok((trust_root, path))
423    }
424
425    fn split_path_and_id(input: &str) -> Result<(&str, &str), ParseErrorKind> {
426        // The agent ID is the last segment
427        let last_slash_idx = input.rfind('/').ok_or(ParseErrorKind::MissingComponent {
428            component: "agent ID",
429        })?;
430
431        let path = &input[..last_slash_idx];
432        let agent_id = &input[last_slash_idx + 1..];
433
434        if path.is_empty() {
435            return Err(ParseErrorKind::MissingComponent {
436                component: "capability path",
437            });
438        }
439
440        if agent_id.is_empty() {
441            return Err(ParseErrorKind::MissingComponent {
442                component: "agent ID",
443            });
444        }
445
446        Ok((path, agent_id))
447    }
448
449    fn normalize(
450        trust_root: &TrustRoot,
451        capability_path: &CapabilityPath,
452        agent_id: &AgentId,
453        query: &QueryParams,
454        fragment: Option<&Fragment>,
455    ) -> String {
456        let mut result = format!("{SCHEME}://{trust_root}/{capability_path}/{agent_id}");
457
458        if !query.is_empty() {
459            result.push('?');
460            result.push_str(&query.to_string());
461        }
462
463        if let Some(frag) = fragment {
464            result.push('#');
465            result.push_str(frag.as_str());
466        }
467
468        result
469    }
470}
471
472impl fmt::Display for AgentUri {
473    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
474        write!(f, "{}", self.normalized)
475    }
476}
477
478impl FromStr for AgentUri {
479    type Err = ParseError;
480
481    fn from_str(s: &str) -> Result<Self, Self::Err> {
482        Self::parse(s)
483    }
484}
485
486impl AsRef<str> for AgentUri {
487    fn as_ref(&self) -> &str {
488        &self.normalized
489    }
490}
491
492impl TryFrom<&str> for AgentUri {
493    type Error = ParseError;
494
495    fn try_from(s: &str) -> Result<Self, Self::Error> {
496        Self::parse(s)
497    }
498}
499
500impl PartialOrd for AgentUri {
501    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
502        Some(self.cmp(other))
503    }
504}
505
506impl Ord for AgentUri {
507    fn cmp(&self, other: &Self) -> Ordering {
508        self.normalized.cmp(&other.normalized)
509    }
510}
511
512#[cfg(feature = "serde")]
513impl serde::Serialize for AgentUri {
514    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
515    where
516        S: serde::Serializer,
517    {
518        serializer.serialize_str(&self.normalized)
519    }
520}
521
522#[cfg(feature = "serde")]
523impl<'de> serde::Deserialize<'de> for AgentUri {
524    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
525    where
526        D: serde::Deserializer<'de>,
527    {
528        let s = String::deserialize(deserializer)?;
529        Self::parse(&s).map_err(serde::de::Error::custom)
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn parse_valid_uri() {
539        let input = "agent://anthropic.com/assistant/chat/llm_chat_01h455vb4pex5vsknk084sn02q";
540        let uri = AgentUri::parse(input).unwrap();
541
542        assert_eq!(uri.trust_root().host_str(), "anthropic.com");
543        assert_eq!(uri.capability_path().as_str(), "assistant/chat");
544        assert_eq!(uri.agent_id().prefix().as_str(), "llm_chat");
545    }
546
547    #[test]
548    fn parse_empty_returns_error() {
549        let result = AgentUri::parse("");
550        assert!(matches!(
551            result,
552            Err(ParseError {
553                kind: ParseErrorKind::Empty,
554                ..
555            })
556        ));
557    }
558
559    #[test]
560    fn parse_too_long_returns_error() {
561        let long_path = "a".repeat(500);
562        let input = format!("agent://x.com/{long_path}/llm_01h455vb4pex5vsknk084sn02q");
563        let result = AgentUri::parse(&input);
564        assert!(matches!(
565            result,
566            Err(ParseError {
567                kind: ParseErrorKind::TooLong { .. },
568                ..
569            })
570        ));
571    }
572
573    #[test]
574    fn parse_wrong_scheme_returns_error() {
575        let result =
576            AgentUri::parse("http://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q");
577        assert!(matches!(
578            result,
579            Err(ParseError {
580                kind: ParseErrorKind::InvalidScheme { .. },
581                ..
582            })
583        ));
584    }
585
586    #[test]
587    fn parse_with_query_params() {
588        let input =
589            "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q?version=2.0&ttl=300";
590        let uri = AgentUri::parse(input).unwrap();
591
592        assert_eq!(uri.query().version(), Some("2.0"));
593        assert_eq!(uri.query().ttl(), Some(300));
594    }
595
596    #[test]
597    fn parse_with_fragment() {
598        let input = "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q#summarization";
599        let uri = AgentUri::parse(input).unwrap();
600
601        assert_eq!(
602            uri.fragment().map(crate::Fragment::as_str),
603            Some("summarization")
604        );
605    }
606
607    #[test]
608    fn empty_query_is_stripped() {
609        let input = "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q?";
610        let uri = AgentUri::parse(input).unwrap();
611        assert!(uri.query().is_empty());
612    }
613
614    #[test]
615    fn empty_fragment_is_stripped() {
616        let input = "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q#";
617        let uri = AgentUri::parse(input).unwrap();
618        assert!(uri.fragment().is_none());
619    }
620
621    #[test]
622    fn canonical_strips_query_and_fragment() {
623        let input =
624            "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q?version=2.0#test";
625        let uri = AgentUri::parse(input).unwrap();
626        let canonical = uri.canonical();
627
628        assert!(!canonical.contains('?'));
629        assert!(!canonical.contains('#'));
630    }
631
632    #[test]
633    fn is_localhost() {
634        let uri = AgentUri::parse(
635            "agent://localhost:8472/test/llm_01h455vb4pex5vsknk084sn02q",
636        )
637        .unwrap();
638        assert!(uri.is_localhost());
639
640        let uri =
641            AgentUri::parse("agent://127.0.0.1/test/llm_01h455vb4pex5vsknk084sn02q").unwrap();
642        assert!(uri.is_localhost());
643
644        let uri =
645            AgentUri::parse("agent://anthropic.com/test/llm_01h455vb4pex5vsknk084sn02q").unwrap();
646        assert!(!uri.is_localhost());
647    }
648
649    #[test]
650    fn parse_missing_capability_path() {
651        let result = AgentUri::parse("agent://anthropic.com/llm_01h455vb4pex5vsknk084sn02q");
652        assert!(matches!(
653            result,
654            Err(ParseError {
655                kind: ParseErrorKind::MissingComponent { component: "agent ID" },
656                ..
657            })
658        ));
659    }
660
661    #[test]
662    fn display_roundtrip() {
663        let input = "agent://anthropic.com/assistant/chat/llm_chat_01h455vb4pex5vsknk084sn02q";
664        let uri = AgentUri::parse(input).unwrap();
665        assert_eq!(uri.to_string(), input);
666    }
667
668    #[test]
669    fn new_from_components() {
670        let trust_root = TrustRoot::parse("anthropic.com").unwrap();
671        let cap_path = CapabilityPath::parse("assistant/chat").unwrap();
672        let agent_id = AgentId::parse("llm_01h455vb4pex5vsknk084sn02q").unwrap();
673
674        let uri = AgentUri::new(
675            trust_root,
676            cap_path,
677            agent_id,
678            QueryParams::new(),
679            None,
680        )
681        .unwrap();
682
683        assert_eq!(uri.trust_root().host_str(), "anthropic.com");
684        assert_eq!(uri.capability_path().as_str(), "assistant/chat");
685    }
686}