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///
54/// # Identity
55///
56/// **Two `AgentUri` values are equal when they name the same agent, which is a
57/// weaker condition than being the same string.** Equality, ordering, and
58/// hashing compare the trust root, the capability path, and the agent ID —
59/// the canonical form. The query and the fragment are excluded: a query
60/// carries resolution hints and a fragment names a sub-capability, and neither
61/// changes which agent is being addressed. This is [Section 5.2 of the
62/// specification][spec], and it is what makes `?version=2.0` a request about an
63/// agent rather than a different agent.
64///
65/// A value that is not part of identity is still part of the string.
66/// [`Display`](std::fmt::Display), [`as_str`](Self::as_str), and serde all
67/// preserve the query and the fragment exactly as parsed. Only
68/// [`canonical`](Self::canonical) drops them.
69///
70/// ```
71/// use agent_uri::AgentUri;
72///
73/// let plain = AgentUri::parse(
74///     "agent://acme.com/workflow/approval/rule_01h455vb4pex5vsknk084sn02q"
75/// ).unwrap();
76/// let hinted = AgentUri::parse(
77///     "agent://acme.com/workflow/approval/rule_01h455vb4pex5vsknk084sn02q?ttl=300#invoice"
78/// ).unwrap();
79///
80/// // Equal as identities...
81/// assert_eq!(plain, hinted);
82/// assert_eq!(plain.canonical(), hinted.canonical());
83///
84/// // ...and not as strings.
85/// assert_ne!(plain.as_str(), hinted.as_str());
86/// assert_eq!(hinted.query().ttl(), Some(300));
87/// assert_eq!(hinted.fragment().map(|f| f.as_str()), Some("invoice"));
88/// ```
89///
90/// ## What this means for collections
91///
92/// A `HashSet` or `HashMap` keyed by `AgentUri` deduplicates by identity, so
93/// inserting a decorated URI where an undecorated one is already present keeps
94/// the first and discards the second — including its query and fragment. That
95/// is the intent for caches, registries, and DHT keys, where one agent must
96/// have one entry. It is a defect if the query is data you meant to keep, so
97/// key those by [`as_str`](Self::as_str) instead.
98///
99/// ```
100/// use std::collections::HashSet;
101/// use agent_uri::AgentUri;
102///
103/// let plain = AgentUri::parse(
104///     "agent://acme.com/workflow/approval/rule_01h455vb4pex5vsknk084sn02q"
105/// ).unwrap();
106/// let hinted = AgentUri::parse(
107///     "agent://acme.com/workflow/approval/rule_01h455vb4pex5vsknk084sn02q?ttl=300"
108/// ).unwrap();
109///
110/// let mut agents = HashSet::new();
111/// assert!(agents.insert(plain));
112/// assert!(!agents.insert(hinted)); // same agent, so not a second entry
113/// assert_eq!(agents.len(), 1);
114/// ```
115///
116/// [spec]: https://github.com/Govcraft/agent-uri-rs/blob/main/SPECIFICATION.md#52-comparison-algorithm
117#[derive(Debug, Clone)]
118pub struct AgentUri {
119    trust_root: TrustRoot,
120    capability_path: CapabilityPath,
121    agent_id: AgentId,
122    query: QueryParams,
123    fragment: Option<Fragment>,
124    /// Normalized string representation
125    normalized: String,
126}
127
128/// Identity equality: see the `Identity` section on [`AgentUri`].
129///
130/// The three fields compared here are the three fields [`Hash`] hashes and the
131/// three fields [`Ord`] orders by. All three impls sit together so that a
132/// change to one is a visible omission in the others.
133impl PartialEq for AgentUri {
134    fn eq(&self, other: &Self) -> bool {
135        self.trust_root == other.trust_root
136            && self.capability_path == other.capability_path
137            && self.agent_id == other.agent_id
138    }
139}
140
141impl Eq for AgentUri {}
142
143/// Hashes exactly what [`PartialEq`] compares, as the `Hash`/`Eq` contract
144/// requires: equal URIs hash equally, so a decorated URI and its plain form
145/// land in the same bucket rather than in two.
146impl std::hash::Hash for AgentUri {
147    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
148        self.trust_root.hash(state);
149        self.capability_path.hash(state);
150        self.agent_id.hash(state);
151    }
152}
153
154impl AgentUri {
155    /// Parses an agent URI from a string.
156    ///
157    /// # Case
158    ///
159    /// Case is folded exactly where a standard makes a component
160    /// case-insensitive: the scheme (RFC 3986 section 3.1) and the trust root's
161    /// DNS name (RFC 4343). The capability path and the agent ID are identity
162    /// material that the grammar fixes as lowercase, so an uppercase letter in
163    /// either is rejected rather than folded.
164    ///
165    /// ```
166    /// use agent_uri::AgentUri;
167    ///
168    /// let uri = AgentUri::parse("AGENT://Anthropic.COM/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
169    /// assert_eq!(
170    ///     uri.as_str(),
171    ///     "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q"
172    /// );
173    ///
174    /// assert!(AgentUri::parse("agent://anthropic.com/Chat/llm_01h455vb4pex5vsknk084sn02q").is_err());
175    /// ```
176    ///
177    /// # Errors
178    ///
179    /// Returns `ParseError` if:
180    /// - The URI is empty
181    /// - The URI exceeds 512 characters
182    /// - The scheme is not "agent" (compared case-insensitively)
183    /// - Any component (trust root, path, agent ID, query, fragment) is invalid
184    pub fn parse(input: &str) -> Result<Self, ParseError> {
185        Self::parse_inner(input).map_err(|kind| ParseError::new(input, kind))
186    }
187
188    /// Creates a new agent URI from its components.
189    ///
190    /// # Errors
191    ///
192    /// Returns `ParseError` if the resulting URI would exceed the maximum length.
193    pub fn new(
194        trust_root: TrustRoot,
195        capability_path: CapabilityPath,
196        agent_id: AgentId,
197        query: QueryParams,
198        fragment: Option<Fragment>,
199    ) -> Result<Self, ParseError> {
200        let normalized = Self::normalize(
201            &trust_root,
202            &capability_path,
203            &agent_id,
204            &query,
205            fragment.as_ref(),
206        );
207        let len = normalized.len();
208
209        if len > MAX_URI_LENGTH {
210            return Err(ParseError::new(
211                &normalized,
212                ParseErrorKind::TooLong {
213                    max: MAX_URI_LENGTH,
214                    actual: len,
215                },
216            ));
217        }
218
219        Ok(Self {
220            trust_root,
221            capability_path,
222            agent_id,
223            query,
224            fragment,
225            normalized,
226        })
227    }
228
229    /// Returns the trust root.
230    #[must_use]
231    pub const fn trust_root(&self) -> &TrustRoot {
232        &self.trust_root
233    }
234
235    /// Returns the capability path.
236    #[must_use]
237    pub const fn capability_path(&self) -> &CapabilityPath {
238        &self.capability_path
239    }
240
241    /// Returns the agent ID.
242    #[must_use]
243    pub const fn agent_id(&self) -> &AgentId {
244        &self.agent_id
245    }
246
247    /// Returns the query parameters.
248    #[must_use]
249    pub const fn query(&self) -> &QueryParams {
250        &self.query
251    }
252
253    /// Returns the fragment, if present.
254    #[must_use]
255    pub const fn fragment(&self) -> Option<&Fragment> {
256        self.fragment.as_ref()
257    }
258
259    /// Returns the normalized URI string.
260    #[must_use]
261    pub fn as_str(&self) -> &str {
262        &self.normalized
263    }
264
265    /// Returns the canonical URI (without query and fragment).
266    #[must_use]
267    pub fn canonical(&self) -> String {
268        format!(
269            "{SCHEME}://{}/{}/{}",
270            self.trust_root, self.capability_path, self.agent_id
271        )
272    }
273
274    /// Returns true if this URI references a localhost agent.
275    #[must_use]
276    pub fn is_localhost(&self) -> bool {
277        self.trust_root.is_localhost()
278    }
279
280    /// Returns a new URI with the given query parameters.
281    ///
282    /// # Errors
283    ///
284    /// Returns `ParseError` if the resulting URI would exceed the maximum length.
285    ///
286    /// # Examples
287    ///
288    /// ```
289    /// use agent_uri::{AgentUri, QueryParams};
290    ///
291    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
292    /// let query = QueryParams::parse("version=2.0").unwrap();
293    /// let updated = uri.with_query(query).unwrap();
294    /// assert_eq!(updated.query().version(), Some("2.0"));
295    /// ```
296    pub fn with_query(&self, query: QueryParams) -> Result<Self, ParseError> {
297        Self::new(
298            self.trust_root.clone(),
299            self.capability_path.clone(),
300            self.agent_id.clone(),
301            query,
302            self.fragment.clone(),
303        )
304    }
305
306    /// Returns a new URI with query parameters parsed from a string.
307    ///
308    /// # Errors
309    ///
310    /// Returns `ParseError` if the query string is invalid or the resulting URI
311    /// would exceed the maximum length.
312    ///
313    /// # Examples
314    ///
315    /// ```
316    /// use agent_uri::AgentUri;
317    ///
318    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
319    /// let updated = uri.with_query_str("version=2.0&ttl=300").unwrap();
320    /// assert_eq!(updated.query().version(), Some("2.0"));
321    /// assert_eq!(updated.query().ttl(), Some(300));
322    /// ```
323    pub fn with_query_str(&self, s: &str) -> Result<Self, ParseError> {
324        let query = QueryParams::parse(s)
325            .map_err(|e| ParseError::new(s, ParseErrorKind::InvalidQuery(e)))?;
326        self.with_query(query)
327    }
328
329    /// Returns a new URI without query parameters.
330    ///
331    /// # Errors
332    ///
333    /// Returns `ParseError` if the resulting URI would exceed the maximum length.
334    /// (This is unlikely but possible in edge cases.)
335    ///
336    /// # Examples
337    ///
338    /// ```
339    /// use agent_uri::AgentUri;
340    ///
341    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q?version=2.0").unwrap();
342    /// let updated = uri.without_query().unwrap();
343    /// assert!(updated.query().is_empty());
344    /// ```
345    pub fn without_query(&self) -> Result<Self, ParseError> {
346        self.with_query(QueryParams::new())
347    }
348
349    /// Returns a new URI with the given fragment.
350    ///
351    /// # Errors
352    ///
353    /// Returns `ParseError` if the resulting URI would exceed the maximum length.
354    ///
355    /// # Examples
356    ///
357    /// ```
358    /// use agent_uri::{AgentUri, Fragment};
359    ///
360    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
361    /// let fragment = Fragment::parse("summarization").unwrap();
362    /// let updated = uri.with_fragment(fragment).unwrap();
363    /// assert_eq!(updated.fragment().map(|f| f.as_str()), Some("summarization"));
364    /// ```
365    pub fn with_fragment(&self, fragment: Fragment) -> Result<Self, ParseError> {
366        Self::new(
367            self.trust_root.clone(),
368            self.capability_path.clone(),
369            self.agent_id.clone(),
370            self.query.clone(),
371            Some(fragment),
372        )
373    }
374
375    /// Returns a new URI with a fragment parsed from a string.
376    ///
377    /// # Errors
378    ///
379    /// Returns `ParseError` if the fragment is invalid or the resulting URI
380    /// would exceed the maximum length.
381    ///
382    /// # Examples
383    ///
384    /// ```
385    /// use agent_uri::AgentUri;
386    ///
387    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
388    /// let updated = uri.with_fragment_str("summarization").unwrap();
389    /// assert_eq!(updated.fragment().map(|f| f.as_str()), Some("summarization"));
390    /// ```
391    pub fn with_fragment_str(&self, s: &str) -> Result<Self, ParseError> {
392        let fragment = Fragment::parse(s)
393            .map_err(|e| ParseError::new(s, ParseErrorKind::InvalidFragment(e)))?;
394        self.with_fragment(fragment)
395    }
396
397    /// Returns a new URI without a fragment.
398    ///
399    /// # Errors
400    ///
401    /// Returns `ParseError` if the resulting URI would exceed the maximum length.
402    /// (This is unlikely but possible in edge cases.)
403    ///
404    /// # Examples
405    ///
406    /// ```
407    /// use agent_uri::AgentUri;
408    ///
409    /// let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q#test").unwrap();
410    /// let updated = uri.without_fragment().unwrap();
411    /// assert!(updated.fragment().is_none());
412    /// ```
413    pub fn without_fragment(&self) -> Result<Self, ParseError> {
414        Self::new(
415            self.trust_root.clone(),
416            self.capability_path.clone(),
417            self.agent_id.clone(),
418            self.query.clone(),
419            None,
420        )
421    }
422
423    fn parse_inner(input: &str) -> Result<Self, ParseErrorKind> {
424        if input.is_empty() {
425            return Err(ParseErrorKind::Empty);
426        }
427
428        if input.len() > MAX_URI_LENGTH {
429            return Err(ParseErrorKind::TooLong {
430                max: MAX_URI_LENGTH,
431                actual: input.len(),
432            });
433        }
434
435        // Check and strip scheme. RFC 3986 section 3.1 makes the scheme
436        // case-insensitive with lowercase as the canonical form, so `AGENT://`
437        // is accepted and folded. Nothing after it is: the trust root is
438        // case-insensitive by DNS, and every remaining component is identity
439        // material the grammar fixes as lowercase.
440        let scheme_prefix = format!("{SCHEME}://");
441        let scheme_matches = input
442            .get(..scheme_prefix.len())
443            .is_some_and(|found| found.eq_ignore_ascii_case(&scheme_prefix));
444        if !scheme_matches {
445            let found = input.split("://").next().map(str::to_string);
446            return Err(ParseErrorKind::InvalidScheme { found });
447        }
448        let rest = &input[scheme_prefix.len()..];
449
450        // Split off fragment
451        let (rest, fragment) = Self::split_fragment(rest)?;
452
453        // Split off query
454        let (rest, query) = Self::split_query(rest)?;
455
456        // Split trust root from path
457        let (trust_root_str, path_with_id) = Self::split_trust_root(rest)?;
458
459        // Parse trust root
460        let trust_root =
461            TrustRoot::parse(trust_root_str).map_err(ParseErrorKind::InvalidTrustRoot)?;
462
463        // Split capability path from agent ID (agent ID is always the last segment)
464        let (cap_path_str, agent_id_str) = Self::split_path_and_id(path_with_id)?;
465
466        // Parse capability path
467        let capability_path =
468            CapabilityPath::parse(cap_path_str).map_err(ParseErrorKind::InvalidCapabilityPath)?;
469
470        // Parse agent ID
471        let agent_id = AgentId::parse(agent_id_str).map_err(ParseErrorKind::InvalidAgentId)?;
472
473        let normalized = Self::normalize(
474            &trust_root,
475            &capability_path,
476            &agent_id,
477            &query,
478            fragment.as_ref(),
479        );
480
481        Ok(Self {
482            trust_root,
483            capability_path,
484            agent_id,
485            query,
486            fragment,
487            normalized,
488        })
489    }
490
491    fn split_fragment(input: &str) -> Result<(&str, Option<Fragment>), ParseErrorKind> {
492        if let Some(hash_idx) = input.find('#') {
493            let rest = &input[..hash_idx];
494            let frag_str = &input[hash_idx + 1..];
495            if frag_str.is_empty() {
496                // Handle this case here because `Fragment::parse` rejects an empty fragment.
497                Ok((rest, None))
498            } else {
499                let fragment =
500                    Fragment::parse(frag_str).map_err(ParseErrorKind::InvalidFragment)?;
501                Ok((rest, Some(fragment)))
502            }
503        } else {
504            Ok((input, None))
505        }
506    }
507
508    fn split_query(input: &str) -> Result<(&str, QueryParams), ParseErrorKind> {
509        if let Some(q_idx) = input.find('?') {
510            let rest = &input[..q_idx];
511            let query_str = &input[q_idx + 1..];
512            if query_str.is_empty() {
513                Ok((rest, QueryParams::new())) // Empty query is stripped
514            } else {
515                let query = QueryParams::parse(query_str).map_err(ParseErrorKind::InvalidQuery)?;
516                Ok((rest, query))
517            }
518        } else {
519            Ok((input, QueryParams::new()))
520        }
521    }
522
523    fn split_trust_root(input: &str) -> Result<(&str, &str), ParseErrorKind> {
524        // Find the first '/' which separates trust root from path
525        let slash_idx = input.find('/').ok_or(ParseErrorKind::MissingComponent {
526            component: "capability path",
527        })?;
528
529        let trust_root = &input[..slash_idx];
530        let path = &input[slash_idx + 1..];
531
532        if trust_root.is_empty() {
533            return Err(ParseErrorKind::MissingComponent {
534                component: "trust root",
535            });
536        }
537
538        if path.is_empty() {
539            return Err(ParseErrorKind::MissingComponent {
540                component: "capability path",
541            });
542        }
543
544        Ok((trust_root, path))
545    }
546
547    fn split_path_and_id(input: &str) -> Result<(&str, &str), ParseErrorKind> {
548        // The agent ID is the last segment
549        let last_slash_idx = input.rfind('/').ok_or(ParseErrorKind::MissingComponent {
550            component: "agent ID",
551        })?;
552
553        let path = &input[..last_slash_idx];
554        let agent_id = &input[last_slash_idx + 1..];
555
556        if path.is_empty() {
557            return Err(ParseErrorKind::MissingComponent {
558                component: "capability path",
559            });
560        }
561
562        if agent_id.is_empty() {
563            return Err(ParseErrorKind::MissingComponent {
564                component: "agent ID",
565            });
566        }
567
568        Ok((path, agent_id))
569    }
570
571    fn normalize(
572        trust_root: &TrustRoot,
573        capability_path: &CapabilityPath,
574        agent_id: &AgentId,
575        query: &QueryParams,
576        fragment: Option<&Fragment>,
577    ) -> String {
578        let mut result = format!("{SCHEME}://{trust_root}/{capability_path}/{agent_id}");
579
580        if !query.is_empty() {
581            result.push('?');
582            result.push_str(&query.to_string());
583        }
584
585        if let Some(frag) = fragment {
586            result.push('#');
587            result.push_str(frag.as_str());
588        }
589
590        result
591    }
592}
593
594impl fmt::Display for AgentUri {
595    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
596        write!(f, "{}", self.normalized)
597    }
598}
599
600impl FromStr for AgentUri {
601    type Err = ParseError;
602
603    fn from_str(s: &str) -> Result<Self, Self::Err> {
604        Self::parse(s)
605    }
606}
607
608impl AsRef<str> for AgentUri {
609    fn as_ref(&self) -> &str {
610        &self.normalized
611    }
612}
613
614impl TryFrom<&str> for AgentUri {
615    type Error = ParseError;
616
617    fn try_from(s: &str) -> Result<Self, Self::Error> {
618        Self::parse(s)
619    }
620}
621
622impl PartialOrd for AgentUri {
623    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
624        Some(self.cmp(other))
625    }
626}
627
628/// Identity ordering: the same three fields [`PartialEq`] compares and [`Hash`]
629/// hashes, in the order they appear in a URI.
630///
631/// This used to render both sides with [`canonical`](AgentUri::canonical) and
632/// compare the strings, which allocated twice per comparison — a `BTreeMap`
633/// keyed by `AgentUri` paid that on every probe. The order is the same except
634/// where one component is a prefix of another, because the rendered form has a
635/// `/` between components and `/` sorts after `.` and after every letter:
636/// string order put `acme.com.br` before `acme.com`, and this puts `acme.com`
637/// first. Ordering by the components is the order the type's own identity
638/// implies; the old one was an artifact of the separator.
639impl Ord for AgentUri {
640    fn cmp(&self, other: &Self) -> Ordering {
641        self.trust_root
642            .cmp(&other.trust_root)
643            .then_with(|| self.capability_path.cmp(&other.capability_path))
644            .then_with(|| self.agent_id.cmp(&other.agent_id))
645    }
646}
647
648#[cfg(feature = "serde")]
649impl serde::Serialize for AgentUri {
650    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
651    where
652        S: serde::Serializer,
653    {
654        serializer.serialize_str(&self.normalized)
655    }
656}
657
658#[cfg(feature = "serde")]
659impl<'de> serde::Deserialize<'de> for AgentUri {
660    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
661    where
662        D: serde::Deserializer<'de>,
663    {
664        let s = String::deserialize(deserializer)?;
665        Self::parse(&s).map_err(serde::de::Error::custom)
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use crate::error::{FragmentError, TrustRootError};
673
674    /// Parses a URI the test asserts is well formed.
675    fn uri(input: &str) -> AgentUri {
676        AgentUri::parse(input).unwrap_or_else(|e| panic!("{input} should parse: {e}"))
677    }
678
679    #[test]
680    fn parse_valid_uri() {
681        let input = "agent://anthropic.com/assistant/chat/llm_chat_01h455vb4pex5vsknk084sn02q";
682        let uri = AgentUri::parse(input).unwrap();
683
684        assert_eq!(uri.trust_root().host_str(), "anthropic.com");
685        assert_eq!(uri.capability_path().as_str(), "assistant/chat");
686        assert_eq!(uri.agent_id().prefix().as_str(), "llm_chat");
687    }
688
689    #[test]
690    fn parse_empty_returns_error() {
691        let result = AgentUri::parse("");
692        assert!(matches!(
693            result,
694            Err(ParseError {
695                kind: ParseErrorKind::Empty,
696                ..
697            })
698        ));
699    }
700
701    #[test]
702    fn parse_too_long_returns_error() {
703        let long_path = "a".repeat(500);
704        let input = format!("agent://x.com/{long_path}/llm_01h455vb4pex5vsknk084sn02q");
705        let result = AgentUri::parse(&input);
706        assert!(matches!(
707            result,
708            Err(ParseError {
709                kind: ParseErrorKind::TooLong { .. },
710                ..
711            })
712        ));
713    }
714
715    #[test]
716    fn scheme_case_is_folded() {
717        // Vectors norm-001 and norm-002 (issue #19). RFC 3986 section 3.1.
718        for input in [
719            "AGENT://example.com/chat/llm_01h455vb4pex5vsknk084sn02q",
720            "AgEnT://example.com/chat/llm_01h455vb4pex5vsknk084sn02q",
721            "Agent://example.com/chat/llm_01h455vb4pex5vsknk084sn02q",
722        ] {
723            let uri = AgentUri::parse(input).unwrap();
724            assert_eq!(
725                uri.as_str(),
726                "agent://example.com/chat/llm_01h455vb4pex5vsknk084sn02q"
727            );
728        }
729    }
730
731    #[test]
732    fn folding_the_scheme_does_not_fold_anything_after_it() {
733        // The trust root folds by DNS; the path and the agent ID do not fold at
734        // all, whatever the scheme's case was (issue #19).
735        let uri =
736            AgentUri::parse("AGENT://Anthropic.COM/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
737        assert_eq!(
738            uri.as_str(),
739            "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q"
740        );
741
742        assert!(
743            AgentUri::parse("AGENT://example.com/Chat/llm_01h455vb4pex5vsknk084sn02q").is_err()
744        );
745        assert!(
746            AgentUri::parse("AGENT://example.com/chat/LLM_01H455VB4PEX5VSKNK084SN02Q").is_err()
747        );
748    }
749
750    #[test]
751    fn a_scheme_shorter_than_the_prefix_is_an_invalid_scheme() {
752        // `input.get(..8)` is None both for a short input and for one whose
753        // eighth byte falls inside a character; neither is `agent://`.
754        assert!(matches!(
755            AgentUri::parse("agent:/"),
756            Err(ParseError {
757                kind: ParseErrorKind::InvalidScheme { .. },
758                ..
759            })
760        ));
761        assert!(matches!(
762            AgentUri::parse("agent:/\u{00e9}x/y/llm_01h455vb4pex5vsknk084sn02q"),
763            Err(ParseError {
764                kind: ParseErrorKind::InvalidScheme { .. },
765                ..
766            })
767        ));
768    }
769
770    #[test]
771    fn parse_wrong_scheme_returns_error() {
772        let result = AgentUri::parse("http://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q");
773        assert!(matches!(
774            result,
775            Err(ParseError {
776                kind: ParseErrorKind::InvalidScheme { .. },
777                ..
778            })
779        ));
780    }
781
782    #[test]
783    fn parse_with_query_params() {
784        let input = "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q?version=2.0&ttl=300";
785        let uri = AgentUri::parse(input).unwrap();
786
787        assert_eq!(uri.query().version(), Some("2.0"));
788        assert_eq!(uri.query().ttl(), Some(300));
789    }
790
791    #[test]
792    fn parse_with_fragment() {
793        let input = "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q#summarization";
794        let uri = AgentUri::parse(input).unwrap();
795
796        assert_eq!(
797            uri.fragment().map(crate::Fragment::as_str),
798            Some("summarization")
799        );
800    }
801
802    #[test]
803    fn empty_query_is_stripped() {
804        let input = "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q?";
805        let uri = AgentUri::parse(input).unwrap();
806        assert!(uri.query().is_empty());
807    }
808
809    #[test]
810    fn empty_fragment_is_stripped() {
811        let input = "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q#";
812        let uri = AgentUri::parse(input).unwrap();
813        assert!(uri.fragment().is_none());
814    }
815
816    #[test]
817    fn with_fragment_str_rejects_empty_fragment() {
818        let uri =
819            AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
820
821        let result = uri.with_fragment_str("");
822
823        assert!(matches!(
824            result,
825            Err(ParseError {
826                kind: ParseErrorKind::InvalidFragment(FragmentError::Empty),
827                ..
828            })
829        ));
830    }
831
832    #[test]
833    fn uri_with_fragment_round_trips_through_as_str() {
834        let uri = AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q")
835            .unwrap()
836            .with_fragment_str("sub/task:v2")
837            .unwrap();
838
839        assert!(uri.as_str().ends_with("#sub/task:v2"));
840
841        let reparsed = AgentUri::parse(uri.as_str()).unwrap();
842        assert_eq!(
843            reparsed.fragment().map(Fragment::as_str),
844            Some("sub/task:v2")
845        );
846        assert_eq!(reparsed.as_str(), uri.as_str());
847    }
848
849    #[test]
850    fn parsed_uri_never_renders_a_bare_hash() {
851        let uri =
852            AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q#").unwrap();
853
854        assert!(uri.fragment().is_none());
855        assert!(!uri.as_str().ends_with('#'));
856
857        let reparsed = AgentUri::parse(uri.as_str()).unwrap();
858        assert_eq!(reparsed.as_str(), uri.as_str());
859    }
860
861    #[test]
862    fn canonical_strips_query_and_fragment() {
863        let input = "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q?version=2.0#test";
864        let uri = AgentUri::parse(input).unwrap();
865        let canonical = uri.canonical();
866
867        assert!(!canonical.contains('?'));
868        assert!(!canonical.contains('#'));
869    }
870
871    #[test]
872    fn query_and_fragment_do_not_change_identity_equality() {
873        let base =
874            AgentUri::parse("agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
875        let decorated = AgentUri::parse(
876            "agent://anthropic.com/chat/llm_01h455vb4pex5vsknk084sn02q?version=2.0#task",
877        )
878        .unwrap();
879
880        assert_eq!(base, decorated);
881        assert_eq!(base.cmp(&decorated), Ordering::Equal);
882        assert_eq!(hash_of(&base), hash_of(&decorated));
883    }
884
885    fn hash_of(uri: &AgentUri) -> u64 {
886        use std::hash::{Hash, Hasher};
887
888        let mut hasher = std::collections::hash_map::DefaultHasher::new();
889        uri.hash(&mut hasher);
890        hasher.finish()
891    }
892
893    #[test]
894    fn equal_uris_hash_equally_and_key_one_entry() {
895        use std::collections::HashSet;
896
897        let base =
898            AgentUri::parse("agent://acme.com/workflow/rule_01h455vb4pex5vsknk084sn02q").unwrap();
899        let decorated =
900            AgentUri::parse("agent://acme.com/workflow/rule_01h455vb4pex5vsknk084sn02q?ttl=300")
901                .unwrap();
902
903        let mut set = HashSet::new();
904        assert!(set.insert(base.clone()));
905        assert!(!set.insert(decorated));
906        assert!(set.contains(&base));
907        assert_eq!(set.len(), 1);
908    }
909
910    #[test]
911    fn different_agents_do_not_share_a_hash_bucket_entry() {
912        use std::collections::HashSet;
913
914        let one =
915            AgentUri::parse("agent://acme.com/workflow/rule_01h455vb4pex5vsknk084sn02q").unwrap();
916        let other =
917            AgentUri::parse("agent://acme.com/workflow/rule_01h455vb4pex5vsknk084sn02r").unwrap();
918        let elsewhere =
919            AgentUri::parse("agent://other.com/workflow/rule_01h455vb4pex5vsknk084sn02q").unwrap();
920
921        assert_ne!(one, other);
922        assert_ne!(one, elsewhere);
923
924        let set: HashSet<_> = [one, other, elsewhere].into_iter().collect();
925        assert_eq!(set.len(), 3);
926    }
927
928    #[test]
929    fn hashing_ignores_exactly_what_equality_ignores() {
930        // If a field is added to one impl and not the other, the Hash/Eq
931        // contract breaks silently. This checks the pair rather than either.
932        let base =
933            AgentUri::parse("agent://acme.com/workflow/rule_01h455vb4pex5vsknk084sn02q").unwrap();
934
935        for variant in [
936            "agent://acme.com/workflow/rule_01h455vb4pex5vsknk084sn02q?ttl=300",
937            "agent://acme.com/workflow/rule_01h455vb4pex5vsknk084sn02q#invoice",
938            "agent://acme.com/workflow/rule_01h455vb4pex5vsknk084sn02q?v=1&ttl=9#x",
939            "agent://ACME.COM/workflow/rule_01h455vb4pex5vsknk084sn02q",
940        ] {
941            let variant = AgentUri::parse(variant).unwrap();
942            assert_eq!(base, variant, "{variant} should be the same identity");
943            assert_eq!(
944                hash_of(&base),
945                hash_of(&variant),
946                "{variant} is equal, so it must hash equally"
947            );
948        }
949    }
950
951    #[test]
952    fn is_localhost() {
953        let uri =
954            AgentUri::parse("agent://localhost:8472/test/llm_01h455vb4pex5vsknk084sn02q").unwrap();
955        assert!(uri.is_localhost());
956
957        let uri = AgentUri::parse("agent://127.0.0.1/test/llm_01h455vb4pex5vsknk084sn02q").unwrap();
958        assert!(uri.is_localhost());
959
960        let uri =
961            AgentUri::parse("agent://anthropic.com/test/llm_01h455vb4pex5vsknk084sn02q").unwrap();
962        assert!(!uri.is_localhost());
963    }
964
965    #[test]
966    fn parse_missing_capability_path() {
967        let result = AgentUri::parse("agent://anthropic.com/llm_01h455vb4pex5vsknk084sn02q");
968        assert!(matches!(
969            result,
970            Err(ParseError {
971                kind: ParseErrorKind::MissingComponent {
972                    component: "agent ID"
973                },
974                ..
975            })
976        ));
977    }
978
979    #[test]
980    fn display_roundtrip() {
981        let input = "agent://anthropic.com/assistant/chat/llm_chat_01h455vb4pex5vsknk084sn02q";
982        let uri = AgentUri::parse(input).unwrap();
983        assert_eq!(uri.to_string(), input);
984    }
985
986    #[test]
987    fn new_from_components() {
988        let trust_root = TrustRoot::parse("anthropic.com").unwrap();
989        let cap_path = CapabilityPath::parse("assistant/chat").unwrap();
990        let agent_id = AgentId::parse("llm_01h455vb4pex5vsknk084sn02q").unwrap();
991
992        let uri = AgentUri::new(trust_root, cap_path, agent_id, QueryParams::new(), None).unwrap();
993
994        assert_eq!(uri.trust_root().host_str(), "anthropic.com");
995        assert_eq!(uri.capability_path().as_str(), "assistant/chat");
996    }
997
998    #[test]
999    fn ipv4_shaped_trust_root_with_out_of_range_octet_is_rejected() {
1000        let result = AgentUri::parse("agent://256.1.1.1/test/llm_01h455vb4pex5vsknk084sn02q");
1001
1002        assert!(matches!(
1003            result,
1004            Err(ParseError {
1005                kind: ParseErrorKind::InvalidTrustRoot(TrustRootError::InvalidIpAddress { .. }),
1006                ..
1007            })
1008        ));
1009    }
1010
1011    #[test]
1012    fn ordering_agrees_with_identity_equality() {
1013        // `Ord` and `PartialEq` must not disagree about which URIs are the
1014        // same one, and the query and fragment are outside identity for both.
1015        let plain = uri("agent://acme.com/chat/llm_01h455vb4pex5vsknk084sn02q");
1016        let hinted = uri("agent://acme.com/chat/llm_01h455vb4pex5vsknk084sn02q?ttl=300#part");
1017
1018        assert_eq!(plain.cmp(&hinted), Ordering::Equal);
1019        assert_eq!(plain, hinted);
1020    }
1021
1022    #[test]
1023    fn ordering_walks_the_components_in_the_order_a_uri_writes_them() {
1024        let by_root = uri("agent://b.com/chat/llm_01h455vb4pex5vsknk084sn02q");
1025        let by_path = uri("agent://a.com/zeta/llm_01h455vb4pex5vsknk084sn02q");
1026        let by_id = uri("agent://a.com/chat/rule_01h455vb4pex5vsknk084sn02q");
1027        let first = uri("agent://a.com/chat/llm_01h455vb4pex5vsknk084sn02q");
1028
1029        // The trust root decides before the path, and the path before the ID.
1030        assert!(first < by_id);
1031        assert!(by_id < by_path);
1032        assert!(by_path < by_root);
1033    }
1034
1035    #[test]
1036    fn ordering_is_by_component_and_not_by_the_rendered_string() {
1037        // The two differ exactly here: rendered, `acme.com` is followed by
1038        // `/`, and `/` sorts after the `.` that continues `acme.com.br`, so
1039        // comparing the strings put the longer name first. Comparing trust
1040        // roots puts the shorter one first, which is what a reader sorting a
1041        // list of hosts expects.
1042        let shorter = uri("agent://acme.com/chat/llm_01h455vb4pex5vsknk084sn02q");
1043        let longer = uri("agent://acme.com.br/chat/llm_01h455vb4pex5vsknk084sn02q");
1044
1045        assert!(shorter < longer);
1046        assert!(longer.canonical() < shorter.canonical());
1047    }
1048
1049    #[test]
1050    fn ordering_is_a_total_order() {
1051        let uris = [
1052            uri("agent://a.com/chat/llm_01h455vb4pex5vsknk084sn02q"),
1053            uri("agent://a.com.br/chat/llm_01h455vb4pex5vsknk084sn02q"),
1054            uri("agent://a.com/chat/rule_01h455vb4pex5vsknk084sn02q"),
1055            uri("agent://b.com/zeta/deep/human_01h455vb4pex5vsknk084sn02q"),
1056        ];
1057
1058        for left in &uris {
1059            for right in &uris {
1060                assert_eq!(
1061                    left.cmp(right).reverse(),
1062                    right.cmp(left),
1063                    "{left} vs {right} is not antisymmetric"
1064                );
1065                assert_eq!(left.cmp(right) == Ordering::Equal, left == right);
1066            }
1067        }
1068    }
1069
1070    #[test]
1071    fn a_rejected_input_too_large_to_be_a_uri_is_not_copied_into_the_error() {
1072        // `parse` used to clone whatever it was handed, so rejecting a
1073        // megabyte cost a megabyte.
1074        let hostile = format!("agent://{}", "a".repeat(2 * 1024 * 1024));
1075
1076        let Err(error) = AgentUri::parse(&hostile) else {
1077            panic!("a two-megabyte input must not parse");
1078        };
1079
1080        assert!(error.input.len() < 1024, "the error kept the whole input");
1081        assert!(matches!(error.kind, ParseErrorKind::TooLong { .. }));
1082    }
1083
1084    #[test]
1085    fn a_rejected_input_short_enough_to_have_been_a_uri_is_kept_whole() {
1086        let input = "agent://acme.com/Chat/llm_01h455vb4pex5vsknk084sn02q";
1087
1088        let Err(error) = AgentUri::parse(input) else {
1089            panic!("an uppercase path segment must not parse");
1090        };
1091
1092        assert_eq!(error.input, input);
1093    }
1094}