Skip to main content

bare_types/net/
url.rs

1//! URL type for network programming.
2//!
3//! This module provides a type-safe abstraction for URLs,
4//! ensuring compliance with RFC 3986 URL specifications.
5//!
6//! # RFC 3986 URL Rules
7//!
8//! According to [RFC 3986 ยง3](https://datatracker.ietf.org/doc/html/rfc3986#section-3):
9//!
10//! - Total length: up to 2048 characters (common browser limit)
11//! - Scheme: required (e.g., http, https, ftp, file)
12//! - Authority: optional (host:port)
13//! - Path: optional, starts with /
14//! - Query: optional, starts with ?
15//! - Fragment: optional, starts with #
16//! - Scheme must be followed by :
17//! - Authority must be preceded by //
18//! - Scheme names are case-insensitive (stored in lowercase)
19//! - Host and port follow standard network type rules
20//!
21//! # Examples
22//!
23//! ```rust
24//! use bare_types::net::Url;
25//!
26//! // Create a URL
27//! let url = Url::new("https://example.com")?;
28//!
29//! // Get the scheme
30//! assert_eq!(url.scheme(), "https");
31//!
32//! // Get the host
33//! assert_eq!(url.host(), Some("example.com"));
34//!
35//! // Get the string representation
36//! assert_eq!(url.as_str(), "https://example.com");
37//!
38//! // Parse from string
39//! let url: Url = "https://example.com/path?query#fragment".parse()?;
40//! # Ok::<(), bare_types::net::UrlError>(())
41//! ```
42
43use core::fmt;
44use core::str::FromStr;
45
46#[cfg(feature = "serde")]
47use serde::{Deserialize, Serialize};
48
49#[cfg(feature = "zeroize")]
50use zeroize::Zeroize;
51
52/// Error type for URL validation.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
55#[non_exhaustive]
56pub enum UrlError {
57    /// Empty URL
58    ///
59    /// The provided string is empty. URLs must contain at least a scheme.
60    Empty,
61    /// URL exceeds maximum length of 2048 characters
62    ///
63    /// URLs must not exceed 2048 characters (common browser limit).
64    /// This variant contains the actual length of the provided URL.
65    TooLong(usize),
66    /// Missing scheme
67    ///
68    /// URLs must contain a scheme (e.g., http, https, ftp).
69    MissingScheme,
70    /// Invalid scheme
71    ///
72    /// The scheme contains invalid characters.
73    /// Schemes must start with a letter and contain only letters, digits, +, ., and -.
74    InvalidScheme,
75    /// Missing : after scheme
76    ///
77    /// The scheme must be followed by a colon (:).
78    MissingSchemeSeparator,
79    /// Missing authority
80    ///
81    /// The URL has // but no authority (host).
82    MissingAuthority,
83    /// Invalid host
84    ///
85    /// The host part is invalid.
86    InvalidHost,
87    /// Invalid port
88    ///
89    /// The port part is invalid.
90    InvalidPort,
91    /// Invalid character in URL
92    ///
93    /// The URL contains an invalid character.
94    /// This variant contains the invalid character.
95    InvalidChar(char),
96}
97
98impl fmt::Display for UrlError {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self {
101            Self::Empty => write!(f, "URL cannot be empty"),
102            Self::TooLong(len) => write!(
103                f,
104                "URL exceeds maximum length of 2048 characters (got {len})"
105            ),
106            Self::MissingScheme => write!(f, "URL must contain a scheme"),
107            Self::InvalidScheme => write!(f, "URL scheme is invalid"),
108            Self::MissingSchemeSeparator => write!(f, "URL scheme must be followed by :"),
109            Self::MissingAuthority => write!(f, "URL has // but no authority"),
110            Self::InvalidHost => write!(f, "URL host is invalid"),
111            Self::InvalidPort => write!(f, "URL port is invalid"),
112            Self::InvalidChar(c) => write!(f, "URL contains invalid character '{c}'"),
113        }
114    }
115}
116
117#[cfg(feature = "std")]
118impl std::error::Error for UrlError {}
119
120/// A URL.
121///
122/// This type provides type-safe URLs with RFC 3986 validation.
123/// It uses the newtype pattern with `#[repr(transparent)]` for zero-cost abstraction.
124///
125/// # Invariants
126///
127/// - Total length is 1-2048 characters
128/// - Scheme is required and case-insensitive (stored in lowercase)
129/// - Authority (host:port) is optional
130/// - Path, query, and fragment are optional
131/// - Scheme must be followed by :
132/// - Authority must be preceded by //
133///
134/// # Examples
135///
136/// ```rust
137/// use bare_types::net::Url;
138///
139/// // Create a URL
140/// let url = Url::new("https://example.com")?;
141///
142/// // Access the string representation
143/// assert_eq!(url.as_str(), "https://example.com");
144///
145/// // Get the scheme
146/// assert_eq!(url.scheme(), "https");
147///
148/// // Get the host
149/// assert_eq!(url.host(), Some("example.com"));
150///
151/// // Parse from string
152/// let url: Url = "https://example.com/path".parse()?;
153/// # Ok::<(), bare_types::net::UrlError>(())
154/// ```
155#[repr(transparent)]
156#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
157#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
158#[cfg_attr(feature = "zeroize", derive(Zeroize))]
159pub struct Url(heapless::String<2048>);
160
161#[cfg(feature = "arbitrary")]
162impl<'a> arbitrary::Arbitrary<'a> for Url {
163    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
164        const SCHEMES: &[&str] = &["http", "https", "ftp", "file"];
165        const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
166        const DIGITS: &[u8] = b"0123456789";
167
168        // Generate scheme
169        let scheme_idx = u8::arbitrary(u)? as usize % SCHEMES.len();
170        let scheme = SCHEMES[scheme_idx];
171
172        // Generate host (1-3 labels)
173        let label_count = 1 + (u8::arbitrary(u)? % 3);
174        let mut host = heapless::String::<253>::new();
175
176        for label_idx in 0..label_count {
177            let label_len = 1 + (u8::arbitrary(u)? % 20).min(19);
178
179            for _ in 0..label_len {
180                let byte = u8::arbitrary(u)?;
181                let c = match byte % 2 {
182                    0 => ALPHABET[(byte % 26) as usize] as char,
183                    _ => DIGITS[(byte % 10) as usize] as char,
184                };
185                host.push(c)
186                    .map_err(|_| arbitrary::Error::IncorrectFormat)?;
187            }
188
189            if label_idx < label_count - 1 {
190                host.push('.')
191                    .map_err(|_| arbitrary::Error::IncorrectFormat)?;
192            }
193        }
194
195        // Generate optional port
196        let has_port = bool::arbitrary(u)?;
197        let mut url = heapless::String::<2048>::new();
198
199        url.push_str(scheme)
200            .map_err(|_| arbitrary::Error::IncorrectFormat)?;
201        url.push(':')
202            .map_err(|_| arbitrary::Error::IncorrectFormat)?;
203        url.push('/')
204            .map_err(|_| arbitrary::Error::IncorrectFormat)?;
205        url.push('/')
206            .map_err(|_| arbitrary::Error::IncorrectFormat)?;
207        url.push_str(&host)
208            .map_err(|_| arbitrary::Error::IncorrectFormat)?;
209
210        if has_port {
211            let port = 1 + (u16::arbitrary(u)? % 65535);
212            url.push(':')
213                .map_err(|_| arbitrary::Error::IncorrectFormat)?;
214            url.push_str(&port.to_string())
215                .map_err(|_| arbitrary::Error::IncorrectFormat)?;
216        }
217
218        Ok(Self(url))
219    }
220}
221
222impl Url {
223    /// Creates a new URL from a string.
224    ///
225    /// # Errors
226    ///
227    /// Returns `UrlError` if the string does not comply with RFC 3986.
228    ///
229    /// # Examples
230    ///
231    /// ```rust
232    /// use bare_types::net::Url;
233    ///
234    /// let url = Url::new("https://example.com")?;
235    /// assert_eq!(url.as_str(), "https://example.com");
236    /// # Ok::<(), bare_types::net::UrlError>(())
237    /// ```
238    #[allow(clippy::missing_panics_doc)]
239    pub fn new(s: &str) -> Result<Self, UrlError> {
240        if s.is_empty() {
241            return Err(UrlError::Empty);
242        }
243
244        if s.len() > 2048 {
245            return Err(UrlError::TooLong(s.len()));
246        }
247
248        // Find scheme separator (:)
249        let Some(scheme_end) = s.find(':') else {
250            return Err(UrlError::MissingSchemeSeparator);
251        };
252
253        if scheme_end == 0 {
254            return Err(UrlError::MissingScheme);
255        }
256
257        let scheme = &s[..scheme_end];
258
259        // Validate scheme
260        if !scheme
261            .chars()
262            .next()
263            .is_some_and(|c| c.is_ascii_alphabetic())
264        {
265            return Err(UrlError::InvalidScheme);
266        }
267
268        for c in scheme.chars() {
269            if !c.is_ascii_alphanumeric() && !matches!(c, '+' | '.' | '-') {
270                return Err(UrlError::InvalidScheme);
271            }
272        }
273
274        // Check for authority marker (//)
275        let rest = &s[scheme_end + 1..];
276        let has_authority = rest.starts_with("//");
277
278        if has_authority {
279            let authority_start = scheme_end + 3;
280            if authority_start >= s.len() {
281                return Err(UrlError::MissingAuthority);
282            }
283
284            // Find end of authority (look for /, ?, or #)
285            let authority_end = s[authority_start..]
286                .find(['/', '?', '#'])
287                .map_or(s.len(), |pos| authority_start + pos);
288
289            let authority = &s[authority_start..authority_end];
290
291            if authority.is_empty() {
292                return Err(UrlError::MissingAuthority);
293            }
294
295            // Validate host (part before : if port exists)
296            let host = authority.split(':').next().unwrap_or(authority);
297            if host.is_empty() {
298                return Err(UrlError::InvalidHost);
299            }
300
301            // Validate port if exists
302            if let Some(port_str) = authority.split(':').nth(1) {
303                if port_str.is_empty() {
304                    return Err(UrlError::InvalidPort);
305                }
306                if !port_str.chars().all(|c| c.is_ascii_digit()) {
307                    return Err(UrlError::InvalidPort);
308                }
309            }
310        }
311
312        // Validate remaining characters
313        for c in s.chars() {
314            if !Self::is_valid_url_char(c) {
315                return Err(UrlError::InvalidChar(c));
316            }
317        }
318
319        // Convert scheme to lowercase
320        let mut inner = heapless::String::<2048>::new();
321        for c in scheme.chars() {
322            inner
323                .push(c.to_ascii_lowercase())
324                .map_err(|_| UrlError::TooLong(2048))?;
325        }
326        inner.push(':').map_err(|_| UrlError::TooLong(2048))?;
327        inner.push_str(rest).map_err(|_| UrlError::TooLong(2048))?;
328
329        Ok(Self(inner))
330    }
331
332    /// Checks if a character is valid in a URL.
333    const fn is_valid_url_char(c: char) -> bool {
334        c.is_ascii_alphanumeric()
335            || matches!(
336                c,
337                ':' | '/'
338                    | '?'
339                    | '#'
340                    | '['
341                    | ']'
342                    | '@'
343                    | '!'
344                    | '$'
345                    | '&'
346                    | '\''
347                    | '('
348                    | ')'
349                    | '*'
350                    | '+'
351                    | ','
352                    | ';'
353                    | '='
354                    | '-'
355                    | '.'
356                    | '_'
357                    | '~'
358                    | '%'
359            )
360    }
361
362    /// Returns the URL as a string slice.
363    ///
364    /// # Examples
365    ///
366    /// ```rust
367    /// use bare_types::net::Url;
368    ///
369    /// let url = Url::new("https://example.com").unwrap();
370    /// assert_eq!(url.as_str(), "https://example.com");
371    /// ```
372    #[must_use]
373    #[inline]
374    pub fn as_str(&self) -> &str {
375        &self.0
376    }
377
378    /// Returns a reference to the underlying `heapless::String`.
379    ///
380    /// # Examples
381    ///
382    /// ```rust
383    /// use bare_types::net::Url;
384    ///
385    /// let url = Url::new("https://example.com").unwrap();
386    /// let inner: &heapless::String<2048> = url.as_inner();
387    /// assert_eq!(inner.as_str(), "https://example.com");
388    /// ```
389    #[must_use]
390    #[inline]
391    pub const fn as_inner(&self) -> &heapless::String<2048> {
392        &self.0
393    }
394
395    /// Consumes this URL and returns the underlying string.
396    ///
397    /// # Examples
398    ///
399    /// ```rust
400    /// use bare_types::net::Url;
401    ///
402    /// let url = Url::new("https://example.com").unwrap();
403    /// let inner = url.into_inner();
404    /// assert_eq!(inner.as_str(), "https://example.com");
405    /// ```
406    #[must_use]
407    #[inline]
408    pub fn into_inner(self) -> heapless::String<2048> {
409        self.0
410    }
411
412    /// Returns the scheme of the URL.
413    ///
414    /// # Examples
415    ///
416    /// ```rust
417    /// use bare_types::net::Url;
418    ///
419    /// let url = Url::new("https://example.com").unwrap();
420    /// assert_eq!(url.scheme(), "https");
421    /// ```
422    #[must_use]
423    #[allow(clippy::missing_panics_doc)]
424    pub fn scheme(&self) -> &str {
425        self.as_str()
426            .split(':')
427            .next()
428            .expect("URL always contains :")
429    }
430
431    /// Returns the host of the URL, if present.
432    ///
433    /// # Examples
434    ///
435    /// ```rust
436    /// use bare_types::net::Url;
437    ///
438    /// let url = Url::new("https://example.com").unwrap();
439    /// assert_eq!(url.host(), Some("example.com"));
440    /// ```
441    #[must_use]
442    pub fn host(&self) -> Option<&str> {
443        let rest = self.as_str().split_once(':')?.1;
444        if !rest.starts_with("//") {
445            return None;
446        }
447
448        let authority = &rest[2..];
449        let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
450
451        let authority = &authority[..authority_end];
452        authority.split(':').next()
453    }
454
455    /// Returns the port of the URL, if present.
456    ///
457    /// # Examples
458    ///
459    /// ```rust
460    /// use bare_types::net::Url;
461    ///
462    /// let url = Url::new("https://example.com:8080").unwrap();
463    /// assert_eq!(url.port(), Some("8080"));
464    /// ```
465    #[must_use]
466    pub fn port(&self) -> Option<&str> {
467        let rest = self.as_str().split_once(':')?.1;
468        if !rest.starts_with("//") {
469            return None;
470        }
471
472        let authority = &rest[2..];
473        let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
474
475        let authority = &authority[..authority_end];
476        authority.split(':').nth(1)
477    }
478
479    /// Returns the path of the URL, if present.
480    ///
481    /// # Examples
482    ///
483    /// ```rust
484    /// use bare_types::net::Url;
485    ///
486    /// let url = Url::new("https://example.com/path").unwrap();
487    /// assert_eq!(url.path(), Some("/path"));
488    /// ```
489    #[must_use]
490    pub fn path(&self) -> Option<&str> {
491        let rest = self.as_str().split_once(':')?.1;
492        let authority_end = rest.strip_prefix("//").map_or(0, |authority| {
493            let end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
494            2 + end
495        });
496
497        let path_start = self.as_str().len().saturating_sub(rest.len()) + authority_end;
498        if path_start >= self.as_str().len() {
499            return None;
500        }
501
502        let path_and_query_fragment = &self.as_str()[path_start..];
503        let path_end = path_and_query_fragment
504            .find(['?', '#'])
505            .unwrap_or(path_and_query_fragment.len());
506
507        let path = &path_and_query_fragment[..path_end];
508        if path.is_empty() { None } else { Some(path) }
509    }
510
511    /// Returns the query string of the URL, if present.
512    ///
513    /// # Examples
514    ///
515    /// ```rust
516    /// use bare_types::net::Url;
517    ///
518    /// let url = Url::new("https://example.com?query=value").unwrap();
519    /// assert_eq!(url.query(), Some("query=value"));
520    /// ```
521    #[must_use]
522    pub fn query(&self) -> Option<&str> {
523        let query_start = self.as_str().find('?')?;
524        let query_and_fragment = &self.as_str()[query_start + 1..];
525        let query_end = query_and_fragment
526            .find('#')
527            .unwrap_or(query_and_fragment.len());
528
529        let query = &query_and_fragment[..query_end];
530        if query.is_empty() { None } else { Some(query) }
531    }
532
533    /// Returns the fragment of the URL, if present.
534    ///
535    /// # Examples
536    ///
537    /// ```rust
538    /// use bare_types::net::Url;
539    ///
540    /// let url = Url::new("https://example.com#fragment").unwrap();
541    /// assert_eq!(url.fragment(), Some("fragment"));
542    /// ```
543    #[must_use]
544    pub fn fragment(&self) -> Option<&str> {
545        let fragment_start = self.as_str().find('#')?;
546        let fragment = &self.as_str()[fragment_start + 1..];
547        if fragment.is_empty() {
548            None
549        } else {
550            Some(fragment)
551        }
552    }
553}
554
555impl TryFrom<&str> for Url {
556    type Error = UrlError;
557
558    fn try_from(s: &str) -> Result<Self, Self::Error> {
559        Self::new(s)
560    }
561}
562
563impl From<Url> for heapless::String<2048> {
564    fn from(url: Url) -> Self {
565        url.0
566    }
567}
568
569impl FromStr for Url {
570    type Err = UrlError;
571
572    fn from_str(s: &str) -> Result<Self, Self::Err> {
573        Self::new(s)
574    }
575}
576
577impl fmt::Display for Url {
578    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
579        write!(f, "{}", self.0)
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn test_new_valid_url() {
589        assert!(Url::new("https://example.com").is_ok());
590        assert!(Url::new("http://example.com").is_ok());
591        assert!(Url::new("ftp://example.com").is_ok());
592        assert!(Url::new("file://localhost/path/to/file").is_ok());
593    }
594
595    #[test]
596    fn test_empty_url() {
597        assert_eq!(Url::new(""), Err(UrlError::Empty));
598    }
599
600    #[test]
601    fn test_too_long_url() {
602        let long = format!("https://{}.com", "a".repeat(3000));
603        assert!(long.len() > 2048);
604        assert_eq!(Url::new(&long), Err(UrlError::TooLong(long.len())));
605    }
606
607    #[test]
608    fn test_missing_scheme() {
609        assert_eq!(Url::new("://example.com"), Err(UrlError::MissingScheme));
610    }
611
612    #[test]
613    fn test_missing_scheme_separator() {
614        assert_eq!(
615            Url::new("https//example.com"),
616            Err(UrlError::MissingSchemeSeparator)
617        );
618    }
619
620    #[test]
621    fn test_invalid_scheme() {
622        assert_eq!(Url::new("123://example.com"), Err(UrlError::InvalidScheme));
623    }
624
625    #[test]
626    fn test_missing_authority() {
627        assert_eq!(Url::new("https://"), Err(UrlError::MissingAuthority));
628    }
629
630    #[test]
631    fn test_as_str() {
632        let url = Url::new("https://example.com").unwrap();
633        assert_eq!(url.as_str(), "https://example.com");
634    }
635
636    #[test]
637    fn test_as_inner() {
638        let url = Url::new("https://example.com").unwrap();
639        let inner = url.as_inner();
640        assert_eq!(inner.as_str(), "https://example.com");
641    }
642
643    #[test]
644    fn test_into_inner() {
645        let url = Url::new("https://example.com").unwrap();
646        let inner = url.into_inner();
647        assert_eq!(inner.as_str(), "https://example.com");
648    }
649
650    #[test]
651    fn test_scheme() {
652        let url = Url::new("https://example.com").unwrap();
653        assert_eq!(url.scheme(), "https");
654    }
655
656    #[test]
657    fn test_scheme_case_insensitive() {
658        let url = Url::new("HTTPS://example.com").unwrap();
659        assert_eq!(url.scheme(), "https");
660        assert_eq!(url.as_str(), "https://example.com");
661    }
662
663    #[test]
664    fn test_host() {
665        let url = Url::new("https://example.com").unwrap();
666        assert_eq!(url.host(), Some("example.com"));
667    }
668
669    #[test]
670    fn test_host_with_port() {
671        let url = Url::new("https://example.com:8080").unwrap();
672        assert_eq!(url.host(), Some("example.com"));
673    }
674
675    #[test]
676    fn test_port() {
677        let url = Url::new("https://example.com:8080").unwrap();
678        assert_eq!(url.port(), Some("8080"));
679    }
680
681    #[test]
682    fn test_port_none() {
683        let url = Url::new("https://example.com").unwrap();
684        assert_eq!(url.port(), None);
685    }
686
687    #[test]
688    fn test_path() {
689        let url = Url::new("https://example.com/path").unwrap();
690        assert_eq!(url.path(), Some("/path"));
691    }
692
693    #[test]
694    fn test_path_none() {
695        let url = Url::new("https://example.com").unwrap();
696        assert_eq!(url.path(), None);
697    }
698
699    #[test]
700    fn test_query() {
701        let url = Url::new("https://example.com?query=value").unwrap();
702        assert_eq!(url.query(), Some("query=value"));
703    }
704
705    #[test]
706    fn test_query_none() {
707        let url = Url::new("https://example.com").unwrap();
708        assert_eq!(url.query(), None);
709    }
710
711    #[test]
712    fn test_fragment() {
713        let url = Url::new("https://example.com#fragment").unwrap();
714        assert_eq!(url.fragment(), Some("fragment"));
715    }
716
717    #[test]
718    fn test_fragment_none() {
719        let url = Url::new("https://example.com").unwrap();
720        assert_eq!(url.fragment(), None);
721    }
722
723    #[test]
724    fn test_full_url() {
725        let url = Url::new("https://example.com:8080/path?query=value#fragment").unwrap();
726        assert_eq!(url.scheme(), "https");
727        assert_eq!(url.host(), Some("example.com"));
728        assert_eq!(url.port(), Some("8080"));
729        assert_eq!(url.path(), Some("/path"));
730        assert_eq!(url.query(), Some("query=value"));
731        assert_eq!(url.fragment(), Some("fragment"));
732    }
733
734    #[test]
735    fn test_try_from_str() {
736        let url = Url::try_from("https://example.com").unwrap();
737        assert_eq!(url.as_str(), "https://example.com");
738    }
739
740    #[test]
741    fn test_from_url_to_string() {
742        let url = Url::new("https://example.com").unwrap();
743        let inner: heapless::String<2048> = url.into();
744        assert_eq!(inner.as_str(), "https://example.com");
745    }
746
747    #[test]
748    fn test_from_str() {
749        let url: Url = "https://example.com".parse().unwrap();
750        assert_eq!(url.as_str(), "https://example.com");
751    }
752
753    #[test]
754    fn test_from_str_invalid() {
755        assert!("".parse::<Url>().is_err());
756        assert!("https://".parse::<Url>().is_err());
757        assert!("://example.com".parse::<Url>().is_err());
758    }
759
760    #[test]
761    fn test_display() {
762        let url = Url::new("https://example.com").unwrap();
763        assert_eq!(format!("{url}"), "https://example.com");
764    }
765
766    #[test]
767    fn test_equality() {
768        let url1 = Url::new("https://example.com").unwrap();
769        let url2 = Url::new("https://example.com").unwrap();
770        let url3 = Url::new("https://example.org").unwrap();
771
772        assert_eq!(url1, url2);
773        assert_ne!(url1, url3);
774    }
775
776    #[test]
777    fn test_ordering() {
778        let url1 = Url::new("https://a.com").unwrap();
779        let url2 = Url::new("https://b.com").unwrap();
780
781        assert!(url1 < url2);
782    }
783
784    #[test]
785    fn test_clone() {
786        let url = Url::new("https://example.com").unwrap();
787        let url2 = url.clone();
788        assert_eq!(url, url2);
789    }
790
791    #[test]
792    fn test_scheme_with_plus() {
793        assert!(Url::new("git+ssh://example.com").is_ok());
794    }
795
796    #[test]
797    fn test_scheme_with_dot() {
798        assert!(Url::new("web+scheme://example.com").is_ok());
799    }
800
801    #[test]
802    fn test_scheme_with_dash() {
803        assert!(Url::new("custom-scheme://example.com").is_ok());
804    }
805
806    #[test]
807    fn test_error_display() {
808        assert_eq!(format!("{}", UrlError::Empty), "URL cannot be empty");
809        assert_eq!(
810            format!("{}", UrlError::TooLong(3000)),
811            "URL exceeds maximum length of 2048 characters (got 3000)"
812        );
813        assert_eq!(
814            format!("{}", UrlError::MissingScheme),
815            "URL must contain a scheme"
816        );
817        assert_eq!(
818            format!("{}", UrlError::InvalidScheme),
819            "URL scheme is invalid"
820        );
821        assert_eq!(
822            format!("{}", UrlError::MissingSchemeSeparator),
823            "URL scheme must be followed by :"
824        );
825        assert_eq!(
826            format!("{}", UrlError::MissingAuthority),
827            "URL has // but no authority"
828        );
829        assert_eq!(format!("{}", UrlError::InvalidHost), "URL host is invalid");
830        assert_eq!(format!("{}", UrlError::InvalidPort), "URL port is invalid");
831        assert_eq!(
832            format!("{}", UrlError::InvalidChar(' ')),
833            "URL contains invalid character ' '"
834        );
835    }
836
837    #[test]
838    fn test_hash() {
839        use core::hash::Hash;
840        use core::hash::Hasher;
841
842        #[derive(Default)]
843        struct SimpleHasher(u64);
844
845        impl Hasher for SimpleHasher {
846            fn finish(&self) -> u64 {
847                self.0
848            }
849
850            fn write(&mut self, bytes: &[u8]) {
851                for byte in bytes {
852                    self.0 = self.0.wrapping_mul(31).wrapping_add(*byte as u64);
853                }
854            }
855        }
856
857        let url1 = Url::new("https://example.com").unwrap();
858        let url2 = Url::new("https://example.com").unwrap();
859        let url3 = Url::new("https://example.org").unwrap();
860
861        let mut hasher1 = SimpleHasher::default();
862        let mut hasher2 = SimpleHasher::default();
863        let mut hasher3 = SimpleHasher::default();
864
865        url1.hash(&mut hasher1);
866        url2.hash(&mut hasher2);
867        url3.hash(&mut hasher3);
868
869        assert_eq!(hasher1.finish(), hasher2.finish());
870        assert_ne!(hasher1.finish(), hasher3.finish());
871    }
872
873    #[test]
874    fn test_debug() {
875        let url = Url::new("https://example.com").unwrap();
876        assert_eq!(format!("{:?}", url), "Url(\"https://example.com\")");
877    }
878
879    #[test]
880    fn test_from_into_inner_roundtrip() {
881        let url = Url::new("https://example.com").unwrap();
882        let inner: heapless::String<2048> = url.into();
883        let url2 = Url::new(inner.as_str()).unwrap();
884        assert_eq!(url2.as_str(), "https://example.com");
885    }
886
887    #[test]
888    fn test_url_without_authority() {
889        let url = Url::new("mailto:user@example.com").unwrap();
890        assert_eq!(url.scheme(), "mailto");
891        assert_eq!(url.host(), None);
892    }
893
894    #[test]
895    fn test_ip_host() {
896        assert!(Url::new("https://127.0.0.1").is_ok());
897    }
898
899    #[test]
900    fn test_percent_encoding() {
901        assert!(Url::new("https://example.com/path%20with%20spaces").is_ok());
902    }
903}