kagi-sdk 1.0.0

Rust-first Kagi SDK with explicit official-api and session-web surfaces
Documentation
use std::fmt;

use url::Url;

use crate::error::KagiError;

#[derive(Clone, PartialEq, Eq, Hash)]
pub struct NonEmptyString(String);

impl NonEmptyString {
    pub fn new(field: &'static str, value: impl Into<String>) -> Result<Self, KagiError> {
        let candidate = value.into();
        let trimmed = candidate.trim();

        if trimmed.is_empty() {
            return Err(KagiError::InvalidInput {
                field,
                reason: "value cannot be empty".to_string(),
            });
        }

        Ok(Self(trimmed.to_string()))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Debug for NonEmptyString {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_tuple("NonEmptyString")
            .field(&self.0)
            .finish()
    }
}

#[derive(Clone, PartialEq, Eq, Hash)]
pub struct NonBlankString(String);

impl NonBlankString {
    pub fn new(field: &'static str, value: impl Into<String>) -> Result<Self, KagiError> {
        let candidate = value.into();
        if candidate.trim().is_empty() {
            return Err(KagiError::InvalidInput {
                field,
                reason: "value cannot be blank".to_string(),
            });
        }

        Ok(Self(candidate))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Debug for NonBlankString {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_tuple("NonBlankString")
            .field(&self.0)
            .finish()
    }
}

#[derive(Clone, PartialEq, Eq, Hash)]
pub struct HttpUrl(String);

impl HttpUrl {
    pub fn new(field: &'static str, value: impl AsRef<str>) -> Result<Self, KagiError> {
        let parsed = Url::parse(value.as_ref()).map_err(|source| KagiError::InvalidInput {
            field,
            reason: format!("invalid URL: {source}"),
        })?;

        if !matches!(parsed.scheme(), "http" | "https") {
            return Err(KagiError::InvalidInput {
                field,
                reason: "URL must use http or https".to_string(),
            });
        }

        Ok(Self(parsed.to_string()))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Debug for HttpUrl {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.debug_tuple("HttpUrl").field(&self.0).finish()
    }
}

#[cfg(test)]
mod tests {
    use super::{HttpUrl, NonBlankString, NonEmptyString};

    #[test]
    fn non_empty_string_rejects_blank_values() {
        let result = NonEmptyString::new("query", "   ");
        assert!(result.is_err());
    }

    #[test]
    fn http_url_rejects_non_http_scheme() {
        let result = HttpUrl::new("url", "ftp://example.com");
        assert!(result.is_err());
    }

    #[test]
    fn http_url_normalizes_idn_host_to_punycode() {
        let from_unicode =
            HttpUrl::new("url", "https://bücher.example/search?q=kagi").expect("should parse");
        let from_punycode = HttpUrl::new("url", "https://xn--bcher-kva.example/search?q=kagi")
            .expect("should parse");

        assert_eq!(from_unicode.as_str(), from_punycode.as_str());
        assert_eq!(
            from_unicode.as_str(),
            "https://xn--bcher-kva.example/search?q=kagi"
        );
    }

    #[test]
    fn non_blank_string_preserves_original_whitespace() {
        let parsed = NonBlankString::new("text", "  keep me  ").expect("should parse");
        assert_eq!(parsed.as_str(), "  keep me  ");
    }
}