net-cat 0.1.0

Minimal hand-rolled HTTP/1.1 client over std::net::TcpStream. Plain HTTP only in v0 (no TLS); used to give web-api-cat's fetch a concrete backend. No external HTTP crate; all parsing and framing are local. No mut beyond the FFI carve-out for TcpStream::read_to_end. Sixth sub-crate of a Servo-replacement webview runtime targeting Tauri.
//! Minimal URL parser.
//!
//! Handles `http://host[:port][/path][?query]`.  Userinfo, fragments,
//! and IPv6 brackets are not supported in v0.

use crate::error::Error;

/// A parsed URL.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Url {
    scheme: String,
    host: String,
    port: u16,
    path: String,
    query: Option<String>,
}

impl Url {
    /// Parse `source`.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidUrl`] for unparseable input.
    pub fn parse(source: &str) -> Result<Self, Error> {
        let (scheme, rest) = source.split_once("://").ok_or_else(|| Error::InvalidUrl {
            source: source.to_owned(),
        })?;
        if scheme.is_empty() {
            Err(Error::InvalidUrl {
                source: source.to_owned(),
            })
        } else {
            split_host_and_path(scheme, rest).ok_or_else(|| Error::InvalidUrl {
                source: source.to_owned(),
            })
        }
    }

    /// The scheme (`"http"` for v0).
    #[must_use]
    pub fn scheme(&self) -> &str {
        &self.scheme
    }

    /// The host (no port, no brackets).
    #[must_use]
    pub fn host(&self) -> &str {
        &self.host
    }

    /// The port (defaulted to 80 for `http://`).
    #[must_use]
    pub fn port(&self) -> u16 {
        self.port
    }

    /// The path (always starts with `/`).
    #[must_use]
    pub fn path(&self) -> &str {
        &self.path
    }

    /// The query string without the leading `?`, if any.
    #[must_use]
    pub fn query(&self) -> Option<&str> {
        self.query.as_deref()
    }

    /// `path` + `?query` form used in the HTTP request line.
    #[must_use]
    pub fn request_target(&self) -> String {
        match &self.query {
            Some(q) => format!("{}?{q}", self.path),
            None => self.path.clone(),
        }
    }
}

fn split_host_and_path(scheme: &str, rest: &str) -> Option<Url> {
    let (authority, path_and_query) = match rest.find('/') {
        Some(idx) => (rest.get(..idx)?, rest.get(idx..)?),
        None => (rest, "/"),
    };
    let (host_str, port) = parse_authority(authority, scheme)?;
    let (path, query) = match path_and_query.find('?') {
        Some(idx) => (
            path_and_query.get(..idx)?.to_owned(),
            Some(path_and_query.get(idx + 1..)?.to_owned()),
        ),
        None => (path_and_query.to_owned(), None),
    };
    Some(Url {
        scheme: scheme.to_ascii_lowercase(),
        host: host_str,
        port,
        path,
        query,
    })
}

fn parse_authority(authority: &str, scheme: &str) -> Option<(String, u16)> {
    if authority.is_empty() {
        None
    } else {
        match authority.rfind(':') {
            Some(idx) => {
                let host = authority.get(..idx)?.to_owned();
                let port = authority.get(idx + 1..)?.parse::<u16>().ok()?;
                Some((host, port))
            }
            None => Some((authority.to_owned(), default_port(scheme))),
        }
    }
}

fn default_port(scheme: &str) -> u16 {
    match scheme {
        "https" => 443,
        _other => 80,
    }
}