uagent 0.1.0

Browser user agents with matching navigation headers and deterministic profile selection.
Documentation
use std::borrow::Cow;

use crate::{Browser, Error, Header, Platform, Result, language};

/// A browser version and platform with navigation headers for a page opened directly.
///
/// The latest capture supplies header values; other major versions substitute the
/// version into the user agent and Chromium client hints. This does not emulate
/// TLS, HTTP/2, or the browser's networking stack.
///
/// ```
/// use uagent::{Browser, Platform, Profile};
/// let profile = Profile::new(Browser::Chrome, Platform::Windows)?;
/// assert_eq!(profile.version(), Browser::Chrome.latest());
/// # Ok::<_, uagent::Error>(())
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Profile {
    browser: Browser,
    version: u32,
    platform: Platform,
    pub(crate) language: Option<String>,
}

impl Profile {
    /// Creates a profile of `browser` on `platform` at its latest bundled version.
    ///
    /// # Errors
    ///
    /// Returns an error if the browser/platform combination has no template.
    pub fn new(browser: Browser, platform: Platform) -> Result<Self> {
        if !browser.platforms().contains(&platform) {
            return Err(Error::UnsupportedPlatform { browser, platform });
        }
        Ok(Self {
            browser,
            version: browser.latest(),
            platform,
            language: None,
        })
    }

    /// Overrides the browser's major version.
    ///
    /// Other major versions reuse the latest captured navigation headers;
    /// historical header changes are not modeled.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidVersion`] if `version` is zero.
    pub fn with_version(mut self, version: u32) -> Result<Self> {
        if version == 0 {
            return Err(Error::InvalidVersion);
        }
        self.version = version;
        Ok(self)
    }

    /// Restores the browser's latest bundled major version after an override.
    #[must_use]
    pub const fn with_latest_version(mut self) -> Self {
        self.version = self.browser.latest();
        self
    }

    /// Overrides the captured `Accept-Language` value.
    ///
    /// Accepts a BCP 47 language tag or a comma-separated list of language tags
    /// and optional quality values, such as `de-DE,de;q=0.9`. `*` is also allowed.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidLanguage`] for invalid tags, quality values,
    /// or characters that cannot appear in the header.
    pub fn with_language(mut self, language: &str) -> Result<Self> {
        language::validate(language)?;
        self.language = Some(language.to_owned());
        Ok(self)
    }

    /// Returns the browser.
    #[must_use]
    pub const fn browser(&self) -> Browser {
        self.browser
    }

    /// Returns the major version.
    #[must_use]
    pub const fn version(&self) -> u32 {
        self.version
    }

    /// Returns the platform.
    #[must_use]
    pub const fn platform(&self) -> Platform {
        self.platform
    }

    /// Returns the captured user agent with the requested version and platform.
    ///
    /// Chromium reduces its version to the major; Safari retains the captured
    /// minor/patch suffix. Platform variants without captures use the draft's
    /// fixed platform descriptions.
    #[must_use]
    pub fn user_agent(&self) -> String {
        let template = self
            .browser
            .headers()
            .iter()
            .find_map(|header| (header.name == "user-agent").then_some(header.value.as_ref()))
            .unwrap_or_else(|| unreachable!("every browser capture has a user agent"));
        let latest = self.browser.latest();
        let version = self.version;
        let mut agent = template.to_owned();
        for product in ["Chrome/", "Edg/", "Firefox/", "Version/", "rv:"] {
            agent = agent.replace(
                &format!("{product}{latest}."),
                &format!("{product}{version}."),
            );
        }
        let platform = match (self.browser, self.platform) {
            (Browser::Firefox, Platform::MacOs) => "Macintosh; Intel Mac OS X 10.15",
            (Browser::Firefox, Platform::Android) => "Android 15; Mobile",
            (_, Platform::Windows) => "Windows NT 10.0; Win64; x64",
            (_, Platform::MacOs) => "Macintosh; Intel Mac OS X 10_15_7",
            (_, Platform::Linux) => "X11; Linux x86_64",
            (_, Platform::Android) => "Linux; Android 10; K",
            (_, Platform::Ios) => "iPhone; CPU iPhone OS 18_6 like Mac OS X",
        };
        if let Some(start) = agent.find('(')
            && let Some(end) = agent[start..].find(')')
        {
            let platform = if self.browser == Browser::Firefox {
                format!("{platform}; rv:{version}.0")
            } else {
                platform.to_owned()
            };
            agent.replace_range(start + 1..start + end, &platform);
        }
        if self.platform == Platform::Android {
            if self.browser == Browser::Firefox {
                agent = agent.replace("Gecko/20100101", &format!("Gecko/{version}.0"));
            } else {
                agent = agent.replace(" Safari/", " Mobile Safari/");
            }
        } else if self.platform == Platform::Ios {
            agent = agent.replace(" Safari/605.1.15", " Mobile/15E148 Safari/604.1");
        }
        agent
    }

    /// Returns Chromium's `Sec-CH-UA`: the browser, Chromium and a made-up
    /// brand, ordered and named by the major version, as Chromium derives
    /// them.
    fn brands(&self) -> String {
        const GREASE_CHARS: [&str; 11] = [" ", "(", ":", "-", ".", "/", ")", ";", "=", "?", "_"];
        const GREASE_VERSIONS: [&str; 3] = ["8", "99", "24"];
        const ORDERS: [[usize; 3]; 6] = [
            [0, 1, 2],
            [0, 2, 1],
            [1, 0, 2],
            [1, 2, 0],
            [2, 0, 1],
            [2, 1, 0],
        ];

        let seed = usize::try_from(self.version).expect("u32 fits usize on supported targets");
        let grease = format!(
            "\"Not{}A{}Brand\";v=\"{}\"",
            GREASE_CHARS[seed % GREASE_CHARS.len()],
            GREASE_CHARS[(seed + 1) % GREASE_CHARS.len()],
            GREASE_VERSIONS[seed % GREASE_VERSIONS.len()],
        );
        let brand = match self.browser {
            Browser::Edge => "Microsoft Edge",
            _ => "Google Chrome",
        };
        let order = ORDERS[seed % ORDERS.len()];
        let mut brands = [String::new(), String::new(), String::new()];
        brands[order[0]] = grease;
        brands[order[1]] = format!("\"Chromium\";v=\"{}\"", self.version);
        brands[order[2]] = format!("\"{brand}\";v=\"{}\"", self.version);
        brands.join(", ")
    }

    /// Returns header names and values in capture order, excluding transport
    /// headers (`Host`, connection management, and `Accept-Encoding`).
    ///
    /// `Accept-Encoding` belongs to the HTTP client, which knows its decoders.
    /// Header order is a convenience for clients that support it; an HTTP header
    /// map does not preserve it.
    #[must_use]
    pub fn headers(&self) -> Vec<Header> {
        self.browser
            .headers()
            .iter()
            .map(|header| {
                let value = match header.name {
                    "user-agent" => Cow::Owned(self.user_agent()),
                    "sec-ch-ua" => Cow::Owned(self.brands()),
                    "sec-ch-ua-mobile" => Cow::Borrowed(if self.platform.is_mobile() {
                        "?1"
                    } else {
                        "?0"
                    }),
                    "sec-ch-ua-platform" => Cow::Borrowed(self.platform.client_hint()),
                    "accept-language" => self.language.as_ref().map_or_else(
                        || header.value.clone(),
                        |language| Cow::Owned(language.clone()),
                    ),
                    _ => header.value.clone(),
                };
                Header {
                    name: header.name,
                    value,
                }
            })
            .collect()
    }
}