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