waterui_url/
lib.rs

1//! # `WaterUI` URL Utilities
2//!
3//! This crate provides ergonomic URL handling for the `WaterUI` framework,
4//! supporting both web URLs and local file paths with reactive fetching capabilities.
5//!
6//! # Compile-Time URLs
7//!
8//! URLs can be created at compile time using const evaluation:
9//!
10//! ```
11//! use waterui_url::Url;
12//!
13//! const LOGO: Url = Url::new("https://waterui.dev/logo.png");
14//! const STYLESHEET: Url = Url::new("/styles/main.css");
15//! ```
16//!
17//! # Runtime URLs
18//!
19//! For dynamic URLs, use the `FromStr` trait:
20//!
21//! ```
22//! use waterui_url::Url;
23//!
24//! let url: Url = "https://example.com".parse()?;
25//! # Ok::<(), waterui_url::ParseError>(())
26//! ```
27
28#![cfg_attr(not(feature = "std"), no_std)]
29
30extern crate alloc;
31
32mod error;
33mod parser;
34
35pub use error::ParseError;
36
37use alloc::borrow::Cow;
38use alloc::boxed::Box;
39
40use alloc::string::{String, ToString};
41use core::fmt;
42use nami_core::Signal;
43use waterui_str::Str;
44
45#[cfg(feature = "std")]
46use std::path::{Path, PathBuf};
47
48// ============================================================================
49// Parsed Component Types
50// ============================================================================
51
52/// Compact byte range representation using u16 indices.
53///
54/// Special sentinel value `0xFFFF` indicates "not present".
55/// This allows representing optional URL components without using `Option<Span>`,
56/// saving memory.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
58struct Span {
59    start: u16,
60    end: u16,
61}
62
63impl Span {
64    /// Sentinel value indicating the span is not present
65    const NONE: Self = Self {
66        start: 0xFFFF,
67        end: 0xFFFF,
68    };
69
70    /// Check if this span represents a present component
71    #[inline]
72    const fn is_present(self) -> bool {
73        self.start != 0xFFFF
74    }
75}
76
77/// Parsed components for different URL types.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
79enum ParsedComponents {
80    Web(WebComponents),
81    Local(LocalComponents),
82    Data(DataComponents),
83    Blob(BlobComponents),
84}
85
86/// Components specific to web URLs (http://, https://, etc.).
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
88struct WebComponents {
89    /// URL scheme (e.g., "https")
90    scheme: Span,
91    /// Full authority section (user:pass@host:port)
92    authority: Span,
93    /// Host portion (e.g., "example.com" or "[`::1`]")
94    host: Span,
95    /// Port number as string (e.g., "8080"), if present
96    port: Span,
97    /// Path component (e.g., "/api/v1/users")
98    path: Span,
99    /// Query string without '?' (e.g., "id=123&name=foo")
100    query: Span,
101    /// Fragment without '#' (e.g., "section")
102    fragment: Span,
103}
104
105/// Components for local file paths.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
107struct LocalComponents {
108    /// The full path
109    path: Span,
110    /// Whether this is an absolute path
111    is_absolute: bool,
112    /// Whether this is a Windows-style path (contains backslashes or drive letter)
113    is_windows: bool,
114}
115
116/// Components for data URLs (data:...).
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
118struct DataComponents {
119    /// MIME type (e.g., "image/png")
120    mime_type: Span,
121    /// Encoding (e.g., "base64"), if present
122    encoding: Span,
123    /// The actual data content
124    data: Span,
125}
126
127/// Components for blob URLs (blob:...).
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
129struct BlobComponents {
130    /// The blob identifier
131    identifier: Span,
132}
133
134/// A URL that can represent either a web URL or a local file path.
135///
136/// This type provides an ergonomic interface for working with both
137/// web URLs (http/https) and local file paths in a unified way.
138///
139/// # Examples
140///
141/// ```
142/// use waterui_url::Url;
143///
144/// // Web URLs
145/// let web_url = Url::parse("https://example.com/image.jpg").unwrap();
146/// assert!(web_url.is_web());
147/// assert_eq!(web_url.scheme(), Some("https"));
148///
149/// // Local file paths
150/// # #[cfg(feature = "std")]
151/// # {
152/// let file_url = Url::from_file_path("/home/user/image.jpg");
153/// assert!(file_url.is_local());
154/// # }
155///
156/// // Automatic detection
157/// let auto_url = Url::new("./relative/path.png");
158/// assert!(auto_url.is_local());
159/// ```
160#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
161pub struct Url {
162    /// The original URL string
163    inner: Str,
164    /// Parsed component offsets (zero-allocation, const-compatible)
165    components: ParsedComponents,
166}
167
168/// The kind of URL.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
170pub enum UrlKind {
171    /// A web URL (http/https/ftp etc)
172    Web,
173    /// A local file path (absolute or relative)
174    Local,
175    /// Data URL (data:)
176    Data,
177    /// Blob URL (blob:)
178    Blob,
179}
180
181impl Url {
182    /// Creates a URL from a static string at compile time.
183    ///
184    /// This function can be evaluated at compile time and automatically
185    /// detects the URL type (web, local, data, or blob).
186    ///
187    /// For runtime string parsing, use the `FromStr` trait instead:
188    /// `url_string.parse::<Url>()`.
189    ///
190    /// # Panics
191    ///
192    /// Panics if the URL is malformed. This enables compile-time syntax checking:
193    /// invalid URLs will cause compilation errors when used in const contexts.
194    ///
195    /// ```compile_fail
196    /// # use waterui_url::Url;
197    /// // This will fail at compile time - missing host
198    /// const INVALID: Url = Url::new("https://");
199    /// ```
200    ///
201    /// # Examples
202    ///
203    /// ```
204    /// use waterui_url::Url;
205    ///
206    /// const WEB_URL: Url = Url::new("https://example.com");
207    /// const LOCAL_PATH: Url = Url::new("/absolute/path");
208    /// const RELATIVE: Url = Url::new("./relative/path");
209    /// ```
210    #[must_use]
211    pub const fn new(url: &'static str) -> Self {
212        Self {
213            inner: Str::from_static(url),
214            components: parser::parse_url(url.as_bytes()),
215        }
216    }
217
218    /// Parses a URL string, validating it as a proper web URL.
219    ///
220    /// Returns `None` if the URL is not a valid web URL.
221    ///
222    /// # Examples
223    ///
224    /// ```
225    /// use waterui_url::Url;
226    ///
227    /// assert!(Url::parse("https://example.com").is_some());
228    /// assert!(Url::parse("http://localhost:3000").is_some());
229    /// assert!(Url::parse("/local/path").is_none());
230    /// ```
231    pub fn parse(url: impl AsRef<str>) -> Option<Self> {
232        url.as_ref().parse::<Self>().ok().filter(Self::is_web)
233    }
234
235    /// Creates a URL from a file path.
236    ///
237    /// # Examples
238    ///
239    /// ```
240    /// # #[cfg(feature = "std")]
241    /// # {
242    /// use waterui_url::Url;
243    ///
244    /// let url = Url::from_file_path("/home/user/image.jpg");
245    /// assert!(url.is_local());
246    /// # }
247    /// ```
248    #[cfg(feature = "std")]
249    pub fn from_file_path(path: impl AsRef<Path>) -> Self {
250        let path_str = path.as_ref().display().to_string();
251        let inner = Str::from(path_str);
252        let components = parser::parse_url(inner.as_bytes());
253        Self { inner, components }
254    }
255
256    /// Creates a URL from a file path string.
257    pub fn from_file_path_str(path: impl Into<Str>) -> Self {
258        let inner = path.into();
259        let components = parser::parse_url(inner.as_bytes());
260        Self { inner, components }
261    }
262
263    /// Creates a data URL from content and MIME type.
264    ///
265    /// # Examples
266    ///
267    /// ```
268    /// use waterui_url::Url;
269    ///
270    /// let url = Url::from_data("image/png", b"...");
271    /// assert!(url.is_data());
272    /// ```
273    #[must_use]
274    pub fn from_data(mime_type: &str, data: &[u8]) -> Self {
275        use alloc::format;
276
277        // Base64 encode the data
278        let encoded = base64_encode(data);
279        let url_str = format!("data:{mime_type};base64,{encoded}");
280
281        let inner = Str::from(url_str);
282        let components = parser::parse_url(inner.as_bytes());
283        Self { inner, components }
284    }
285
286    /// Helper method to extract a string slice from a Span.
287    ///
288    /// # Safety
289    /// The parser ensures that all Span boundaries are valid UTF-8 boundaries.
290    #[inline]
291    fn slice(&self, span: Span) -> &str {
292        if !span.is_present() {
293            return "";
294        }
295        let bytes = self.inner.as_bytes();
296        let start = span.start as usize;
297        let end = span.end as usize;
298        // SAFETY: Parser ensures valid UTF-8 boundaries
299        unsafe { core::str::from_utf8_unchecked(&bytes[start..end]) }
300    }
301
302    /// Returns true if this is a web URL (http/https/ftp etc).
303    #[must_use]
304    pub const fn is_web(&self) -> bool {
305        matches!(self.components, ParsedComponents::Web(_))
306    }
307
308    /// Returns true if this is a local file path.
309    #[must_use]
310    pub const fn is_local(&self) -> bool {
311        matches!(self.components, ParsedComponents::Local(_))
312    }
313
314    /// Returns true if this is a data URL.
315    #[must_use]
316    pub const fn is_data(&self) -> bool {
317        matches!(self.components, ParsedComponents::Data(_))
318    }
319
320    /// Returns true if this is a blob URL.
321    #[must_use]
322    pub const fn is_blob(&self) -> bool {
323        matches!(self.components, ParsedComponents::Blob(_))
324    }
325
326    /// Returns true if this is an absolute path or URL.
327    #[must_use]
328    pub const fn is_absolute(&self) -> bool {
329        match self.components {
330            ParsedComponents::Web(_) | ParsedComponents::Data(_) | ParsedComponents::Blob(_) => {
331                true
332            }
333            ParsedComponents::Local(local) => local.is_absolute,
334        }
335    }
336
337    /// Returns the inner string representation of the URL.
338    #[must_use]
339    pub fn inner(&self) -> Str {
340        self.inner.clone()
341    }
342
343    /// Returns true if this is a relative path.
344    #[must_use]
345    pub const fn is_relative(&self) -> bool {
346        !self.is_absolute()
347    }
348
349    /// Gets the URL scheme (e.g., "http", "https", "file", "data").
350    ///
351    /// This is now O(1) - no parsing required!
352    #[must_use]
353    pub fn scheme(&self) -> Option<&str> {
354        match self.components {
355            ParsedComponents::Web(web) if web.scheme.is_present() => Some(self.slice(web.scheme)),
356            ParsedComponents::Data(_) => Some("data"),
357            ParsedComponents::Blob(_) => Some("blob"),
358            ParsedComponents::Local(_) => Some("file"),
359            ParsedComponents::Web(_) => None,
360        }
361    }
362
363    /// Gets the host for web URLs.
364    ///
365    /// This is now O(1) - no parsing required!
366    #[must_use]
367    pub fn host(&self) -> Option<&str> {
368        match self.components {
369            ParsedComponents::Web(web) if web.host.is_present() => Some(self.slice(web.host)),
370            _ => None,
371        }
372    }
373
374    /// Gets the path component of the URL.
375    ///
376    /// This is now O(1) - no parsing required!
377    #[must_use]
378    pub fn path(&self) -> &str {
379        match self.components {
380            ParsedComponents::Web(web) if web.path.is_present() => self.slice(web.path),
381            ParsedComponents::Web(_) => "/", // No path means root
382            ParsedComponents::Local(local) => self.slice(local.path),
383            ParsedComponents::Data(_) | ParsedComponents::Blob(_) => "",
384        }
385    }
386
387    /// Gets the port number for web URLs.
388    ///
389    /// This is a new method enabled by the parsed component structure!
390    /// Returns the port as a u16, or None if not present.
391    #[must_use]
392    pub fn port(&self) -> Option<u16> {
393        match self.components {
394            ParsedComponents::Web(web) if web.port.is_present() => {
395                self.slice(web.port).parse().ok()
396            }
397            _ => None,
398        }
399    }
400
401    /// Gets the query string (without the '?') for web URLs.
402    ///
403    /// This is a new method enabled by the parsed component structure!
404    ///
405    /// # Examples
406    ///
407    /// ```
408    /// use waterui_url::Url;
409    ///
410    /// const URL: Url = Url::new("https://example.com/path?foo=bar&baz=qux");
411    /// assert_eq!(URL.query(), Some("foo=bar&baz=qux"));
412    /// ```
413    #[must_use]
414    pub fn query(&self) -> Option<&str> {
415        match self.components {
416            ParsedComponents::Web(web) if web.query.is_present() => Some(self.slice(web.query)),
417            _ => None,
418        }
419    }
420
421    /// Gets the fragment (without the '#') for web URLs.
422    ///
423    /// This is a new method enabled by the parsed component structure!
424    ///
425    /// # Examples
426    ///
427    /// ```
428    /// use waterui_url::Url;
429    ///
430    /// const URL: Url = Url::new("https://example.com/path#section");
431    /// assert_eq!(URL.fragment(), Some("section"));
432    /// ```
433    #[must_use]
434    pub fn fragment(&self) -> Option<&str> {
435        match self.components {
436            ParsedComponents::Web(web) if web.fragment.is_present() => {
437                Some(self.slice(web.fragment))
438            }
439            _ => None,
440        }
441    }
442
443    /// Gets the authority section (user:pass@host:port) for web URLs.
444    ///
445    /// This is a new method enabled by the parsed component structure!
446    #[must_use]
447    pub fn authority(&self) -> Option<&str> {
448        match self.components {
449            ParsedComponents::Web(web) if web.authority.is_present() => {
450                Some(self.slice(web.authority))
451            }
452            _ => None,
453        }
454    }
455
456    /// Gets the file extension if present.
457    #[must_use]
458    pub fn extension(&self) -> Option<&str> {
459        let path = self.path();
460        let name = path.rsplit('/').next()?;
461        let ext_start = name.rfind('.')?;
462
463        if ext_start == 0 || ext_start == name.len() - 1 {
464            None
465        } else {
466            Some(&name[ext_start + 1..])
467        }
468    }
469
470    /// Gets the filename from the URL path.
471    #[must_use]
472    pub fn filename(&self) -> Option<&str> {
473        let path = self.path();
474        path.rsplit('/').next().filter(|s| !s.is_empty())
475    }
476
477    /// Joins this URL with a relative path.
478    ///
479    /// # Examples
480    ///
481    /// ```
482    /// use waterui_url::Url;
483    ///
484    /// let base = Url::new("https://example.com/images/");
485    /// let joined = base.join("photo.jpg");
486    /// assert_eq!(joined.as_str(), "https://example.com/images/photo.jpg");
487    /// ```
488    #[must_use]
489    pub fn join(&self, path: &str) -> Self {
490        if path.is_empty() {
491            return self.clone();
492        }
493
494        // If path is absolute, return it as-is
495        if matches!(parser::parse_url(path.as_bytes()), ParsedComponents::Web(_))
496            || path.starts_with('/')
497        {
498            return path
499                .parse()
500                .unwrap_or_else(|_| Self::from_file_path_str(path.to_string()));
501        }
502
503        match self.components {
504            ParsedComponents::Web(_) => {
505                let base = self.inner.as_str();
506                let mut result = String::from(base);
507
508                // Ensure base ends with /
509                if !result.ends_with('/') {
510                    // Check if we have a path after the host
511                    if let Some(scheme_end) = result.find("://") {
512                        let after_scheme = &result[scheme_end + 3..];
513                        if let Some(path_start) = after_scheme.find('/') {
514                            // We have a path, check if it looks like a file
515                            let full_path_start = scheme_end + 3 + path_start;
516                            let after_slash = &result[full_path_start + 1..];
517                            if after_slash.contains('.')
518                                || after_slash.contains('?')
519                                || after_slash.contains('#')
520                            {
521                                // Remove the file part
522                                if let Some(last_slash) = result.rfind('/') {
523                                    result.truncate(last_slash + 1);
524                                }
525                            } else {
526                                result.push('/');
527                            }
528                        } else {
529                            // No path after host, add trailing slash
530                            result.push('/');
531                        }
532                    } else {
533                        result.push('/');
534                    }
535                }
536
537                result.push_str(path);
538                result
539                    .parse()
540                    .unwrap_or_else(|_| Self::from_file_path_str(result))
541            }
542            ParsedComponents::Local(_) => {
543                #[cfg(feature = "std")]
544                {
545                    let base_path = PathBuf::from(self.inner.as_str());
546                    let joined = if base_path.is_file() {
547                        base_path.parent().unwrap_or(&base_path).join(path)
548                    } else {
549                        base_path.join(path)
550                    };
551                    Self::from_file_path(joined)
552                }
553                #[cfg(not(feature = "std"))]
554                {
555                    let mut result = String::from(self.inner.as_str());
556                    if !result.ends_with('/') && !result.ends_with('\\') {
557                        result.push('/');
558                    }
559                    result.push_str(path);
560                    Self::from_file_path_str(result)
561                }
562            }
563            _ => self.clone(),
564        }
565    }
566
567    /// Fetches the content at this URL (for network resources).
568    ///
569    /// This returns a reactive signal that can be watched for changes.
570    #[must_use]
571    pub fn fetch(&self) -> Fetched {
572        Fetched { url: self.clone() }
573    }
574
575    /// Returns the underlying string representation.
576    #[must_use]
577    pub const fn as_str(&self) -> &str {
578        self.inner.as_str()
579    }
580
581    /// Converts this URL to a string.
582    #[must_use]
583    pub fn into_string(self) -> String {
584        String::from(self.inner)
585    }
586
587    /// Converts to a file path if this is a local URL.
588    #[cfg(feature = "std")]
589    #[must_use]
590    pub fn to_file_path(&self) -> Option<PathBuf> {
591        if self.is_local() {
592            Some(PathBuf::from(self.inner.as_str()))
593        } else {
594            None
595        }
596    }
597}
598
599impl fmt::Display for Url {
600    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
601        write!(f, "{}", self.inner)
602    }
603}
604
605impl AsRef<str> for Url {
606    fn as_ref(&self) -> &str {
607        self.as_str()
608    }
609}
610
611impl core::str::FromStr for Url {
612    type Err = ParseError;
613
614    fn from_str(s: &str) -> Result<Self, Self::Err> {
615        if s.is_empty() {
616            return Err(ParseError::empty());
617        }
618
619        Ok(Self {
620            inner: Str::from(s.to_string()),
621            components: parser::parse_url(s.as_bytes()),
622        })
623    }
624}
625
626impl From<&'static str> for Url {
627    fn from(value: &'static str) -> Self {
628        Self::new(value)
629    }
630}
631
632impl From<String> for Url {
633    fn from(value: String) -> Self {
634        // Infallible: treat parse failures as local paths
635        value
636            .as_str()
637            .parse()
638            .unwrap_or_else(|_| Self::from_file_path_str(value))
639    }
640}
641
642impl From<Str> for Url {
643    fn from(value: Str) -> Self {
644        // Infallible: treat parse failures as local paths
645        value
646            .as_str()
647            .parse()
648            .unwrap_or_else(|_| Self::from_file_path_str(value))
649    }
650}
651
652impl<'a> From<Cow<'a, str>> for Url {
653    fn from(value: Cow<'a, str>) -> Self {
654        match value {
655            Cow::Borrowed(s) => s
656                .parse()
657                .unwrap_or_else(|_| Self::from_file_path_str(s.to_string())),
658            Cow::Owned(s) => s.parse().unwrap_or_else(|_| Self::from_file_path_str(s)),
659        }
660    }
661}
662
663impl From<Url> for Str {
664    fn from(url: Url) -> Self {
665        url.inner
666    }
667}
668
669// Implement Signal for Url as a constant value
670// This allows Url to be used directly with `IntoComputed<Url>`
671nami_core::impl_constant!(Url);
672
673/// A reactive signal for fetched URL content.
674#[derive(Debug, Clone)]
675pub struct Fetched {
676    url: Url,
677}
678
679impl Signal for Fetched {
680    type Output = Option<Url>;
681    type Guard = nami_core::watcher::BoxWatcherGuard;
682
683    fn get(&self) -> Self::Output {
684        // TODO: Implement actual fetching logic
685        Some(self.url.clone())
686    }
687
688    fn watch(
689        &self,
690        _watcher: impl Fn(nami_core::watcher::Context<Self::Output>) + 'static,
691    ) -> Self::Guard {
692        // TODO: Implement actual watching logic
693        Box::new(())
694    }
695}
696
697// Simple base64 encoding for data URLs
698fn base64_encode(data: &[u8]) -> String {
699    use alloc::vec::Vec;
700
701    const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
702
703    let mut result = Vec::with_capacity(data.len().div_ceil(3) * 4);
704
705    for chunk in data.chunks(3) {
706        let mut buf = [0u8; 3];
707        for (i, &byte) in chunk.iter().enumerate() {
708            buf[i] = byte;
709        }
710
711        result.push(TABLE[(buf[0] >> 2) as usize]);
712        result.push(TABLE[(((buf[0] & 0x03) << 4) | (buf[1] >> 4)) as usize]);
713
714        if chunk.len() > 1 {
715            result.push(TABLE[(((buf[1] & 0x0f) << 2) | (buf[2] >> 6)) as usize]);
716        } else {
717            result.push(b'=');
718        }
719
720        if chunk.len() > 2 {
721            result.push(TABLE[(buf[2] & 0x3f) as usize]);
722        } else {
723            result.push(b'=');
724        }
725    }
726
727    String::from_utf8(result).expect("base64 encoding should produce valid utf8")
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733
734    #[test]
735    fn test_const_url_creation() {
736        const WEB: Url = Url::new("https://example.com");
737        const LOCAL: Url = Url::new("/path/to/file");
738        const DATA: Url = Url::new("data:text/plain,hello");
739        const BLOB: Url = Url::new("blob:https://example.com/uuid");
740
741        assert!(WEB.is_web());
742        assert!(LOCAL.is_local());
743        assert!(DATA.is_data());
744        assert!(BLOB.is_blob());
745    }
746
747    #[test]
748    fn test_fromstr_valid_web_urls() {
749        let urls = [
750            "http://example.com",
751            "https://example.com:443/path",
752            "ftp://server.com/file",
753            "ws://example.com",
754            "wss://example.com",
755        ];
756
757        for url_str in urls {
758            let url: Url = url_str.parse().unwrap();
759            assert!(url.is_web(), "Failed for: {url_str}");
760        }
761    }
762
763    #[test]
764    fn test_fromstr_local_paths() {
765        let paths = [
766            "/absolute/path",
767            "./relative",
768            "file.txt",
769            "C:\\Windows\\file.txt",
770        ];
771
772        for path in paths {
773            let url: Url = path.parse().unwrap();
774            assert!(url.is_local(), "Failed for: {path}");
775        }
776    }
777
778    #[test]
779    fn test_fromstr_data_urls() {
780        let url: Url = "data:text/plain,hello".parse().unwrap();
781        assert!(url.is_data());
782    }
783
784    #[test]
785    fn test_fromstr_blob_urls() {
786        let url: Url = "blob:https://example.com/uuid".parse().unwrap();
787        assert!(url.is_blob());
788    }
789
790    #[test]
791    fn test_fromstr_empty_error() {
792        let result: Result<Url, _> = "".parse();
793        assert!(result.is_err());
794    }
795
796    #[test]
797    fn test_web_url_detection() {
798        let url = Url::new("https://example.com/image.jpg");
799        assert!(url.is_web());
800        assert!(!url.is_local());
801        assert_eq!(url.scheme(), Some("https"));
802        assert_eq!(url.host(), Some("example.com"));
803        assert_eq!(url.path(), "/image.jpg");
804    }
805
806    #[test]
807    fn test_local_path_detection() {
808        let url1 = Url::new("/absolute/path/file.txt");
809        assert!(url1.is_local());
810        assert!(!url1.is_web());
811        assert!(url1.is_absolute());
812
813        let url2 = Url::new("./relative/path.txt");
814        assert!(url2.is_local());
815        assert!(url2.is_relative());
816
817        let url3 = Url::new("file.txt");
818        assert!(url3.is_local());
819        assert!(url3.is_relative());
820    }
821
822    #[test]
823    fn test_parse_valid_urls() {
824        assert!(Url::parse("http://localhost:3000").is_some());
825        assert!(Url::parse("https://example.com/path?query=1").is_some());
826        assert!(Url::parse("ftp://server.com/file").is_some());
827
828        assert!(Url::parse("/local/path").is_none());
829        assert!(Url::parse("relative/path").is_none());
830    }
831
832    #[test]
833    fn test_data_url() {
834        let url = Url::from_data("image/png", b"test");
835        assert!(url.is_data());
836        assert!(url.as_str().starts_with("data:image/png;base64,"));
837    }
838
839    #[test]
840    fn test_extension_extraction() {
841        let url1 = Url::new("https://example.com/image.jpg");
842        assert_eq!(url1.extension(), Some("jpg"));
843
844        let url2 = Url::new("/path/to/file.tar.gz");
845        assert_eq!(url2.extension(), Some("gz"));
846
847        let url3 = Url::new("https://example.com/noext");
848        assert_eq!(url3.extension(), None);
849
850        let url4 = Url::new("https://example.com/.hidden");
851        assert_eq!(url4.extension(), None);
852    }
853
854    #[test]
855    fn test_filename_extraction() {
856        let url1 = Url::new("https://example.com/path/image.jpg");
857        assert_eq!(url1.filename(), Some("image.jpg"));
858
859        let url2 = Url::new("/path/to/file.txt");
860        assert_eq!(url2.filename(), Some("file.txt"));
861
862        let url3 = Url::new("https://example.com/");
863        assert_eq!(url3.filename(), None);
864    }
865
866    #[test]
867    fn test_url_joining() {
868        let base1 = Url::new("https://example.com/images/");
869        let joined1 = base1.join("photo.jpg");
870        assert_eq!(joined1.as_str(), "https://example.com/images/photo.jpg");
871
872        let base2 = Url::new("https://example.com/images/old.jpg");
873        let joined2 = base2.join("new.jpg");
874        assert_eq!(joined2.as_str(), "https://example.com/images/new.jpg");
875
876        let base3 = Url::new("https://example.com");
877        let joined3 = base3.join("images/photo.jpg");
878        assert_eq!(joined3.as_str(), "https://example.com/images/photo.jpg");
879    }
880
881    #[test]
882    fn test_windows_paths() {
883        let url = Url::new("C:\\Users\\file.txt");
884        assert!(url.is_local());
885        assert!(url.is_absolute());
886    }
887
888    #[test]
889    fn test_blob_url() {
890        let url = Url::new("blob:https://example.com/uuid");
891        assert!(url.is_blob());
892        assert_eq!(url.scheme(), Some("blob"));
893    }
894
895    #[test]
896    fn test_url_host_extraction() {
897        let url1 = Url::new("https://example.com/path");
898        assert_eq!(url1.host(), Some("example.com"));
899
900        let url2 = Url::new("http://localhost:8080/api");
901        assert_eq!(url2.host(), Some("localhost")); // host() now returns only the host, not host:port
902        assert_eq!(url2.port(), Some(8080)); // port() is now available!
903
904        let url3 = Url::new("https://sub.domain.com");
905        assert_eq!(url3.host(), Some("sub.domain.com"));
906
907        let url4 = Url::new("/local/path");
908        assert_eq!(url4.host(), None);
909    }
910
911    #[test]
912    fn test_complete_url_parsing() {
913        // Test a URL with all components
914        const FULL_URL: Url =
915            Url::new("https://user:pass@example.com:8080/path/to/resource?query=1&foo=bar#section");
916
917        assert_eq!(FULL_URL.scheme(), Some("https"));
918        assert_eq!(FULL_URL.host(), Some("example.com"));
919        assert_eq!(FULL_URL.port(), Some(8080));
920        assert_eq!(FULL_URL.path(), "/path/to/resource");
921        assert_eq!(FULL_URL.query(), Some("query=1&foo=bar"));
922        assert_eq!(FULL_URL.fragment(), Some("section"));
923        assert_eq!(FULL_URL.authority(), Some("user:pass@example.com:8080"));
924    }
925
926    #[test]
927    fn test_minimal_url() {
928        const MIN_URL: Url = Url::new("https://example.com");
929
930        assert_eq!(MIN_URL.scheme(), Some("https"));
931        assert_eq!(MIN_URL.host(), Some("example.com"));
932        assert_eq!(MIN_URL.port(), None);
933        assert_eq!(MIN_URL.path(), "/");
934        assert_eq!(MIN_URL.query(), None);
935        assert_eq!(MIN_URL.fragment(), None);
936    }
937
938    #[test]
939    fn test_ipv6_url() {
940        const IPV6: Url = Url::new("http://[::1]:8080/test");
941        assert_eq!(IPV6.host(), Some("[::1]"));
942        assert_eq!(IPV6.port(), Some(8080));
943        assert_eq!(IPV6.path(), "/test");
944    }
945
946    #[test]
947    fn test_query_and_fragment() {
948        const URL1: Url = Url::new("https://example.com?foo=bar");
949        const URL2: Url = Url::new("https://example.com#section");
950        const URL3: Url = Url::new("https://example.com?foo=bar#section");
951
952        assert_eq!(URL1.query(), Some("foo=bar"));
953        assert_eq!(URL1.fragment(), None);
954
955        assert_eq!(URL2.query(), None);
956        assert_eq!(URL2.fragment(), Some("section"));
957
958        assert_eq!(URL3.query(), Some("foo=bar"));
959        assert_eq!(URL3.fragment(), Some("section"));
960    }
961
962    #[test]
963    fn test_conversions() {
964        let url = Url::new("https://example.com");
965        let as_str: &str = url.as_ref();
966        assert_eq!(as_str, "https://example.com");
967
968        let as_string = url.clone().into_string();
969        assert_eq!(as_string, "https://example.com");
970
971        let from_string = Url::from("test".to_string());
972        assert_eq!(from_string.as_str(), "test");
973    }
974
975    #[test]
976    fn test_base64_encoding() {
977        let encoded = base64_encode(b"hello");
978        assert_eq!(encoded, "aGVsbG8=");
979
980        let encoded2 = base64_encode(b"hi");
981        assert_eq!(encoded2, "aGk=");
982
983        let encoded3 = base64_encode(b"test");
984        assert_eq!(encoded3, "dGVzdA==");
985    }
986
987    #[test]
988    fn test_scheme_detection() {
989        assert_eq!(Url::new("https://example.com").scheme(), Some("https"));
990        assert_eq!(Url::new("http://example.com").scheme(), Some("http"));
991        assert_eq!(Url::new("ftp://example.com").scheme(), Some("ftp"));
992        assert_eq!(Url::new("ws://example.com").scheme(), Some("ws"));
993        assert_eq!(Url::new("data:text/plain,hello").scheme(), Some("data"));
994        assert_eq!(
995            Url::new("blob:https://example.com/uuid").scheme(),
996            Some("blob")
997        );
998        assert_eq!(Url::new("/local/path").scheme(), Some("file"));
999    }
1000
1001    #[test]
1002    fn test_path_parsing() {
1003        let url1 = Url::new("https://example.com/api/v1/users?id=123#section");
1004        assert_eq!(url1.path(), "/api/v1/users");
1005
1006        let url2 = Url::new("https://example.com");
1007        assert_eq!(url2.path(), "/");
1008
1009        let url3 = Url::new("/local/path/file.txt");
1010        assert_eq!(url3.path(), "/local/path/file.txt");
1011    }
1012
1013    #[test]
1014    fn test_absolute_relative_detection() {
1015        assert!(Url::new("https://example.com").is_absolute());
1016        assert!(Url::new("/absolute/path").is_absolute());
1017        assert!(Url::new("C:\\Windows\\file.txt").is_absolute());
1018        assert!(Url::new("data:text/plain,hello").is_absolute());
1019
1020        assert!(Url::new("relative/path").is_relative());
1021        assert!(Url::new("./relative/path").is_relative());
1022        assert!(Url::new("../parent/path").is_relative());
1023        assert!(Url::new("file.txt").is_relative());
1024    }
1025}