coil-wasm 0.1.0

WASM extension runtime and host APIs for the Coil framework.
Documentation
use std::collections::BTreeMap;
use std::collections::BTreeSet;

use crate::error::WasmModelError;
use crate::validation::{require_non_empty, validate_token};

use super::json_ld::{JsonLdNode, RobotsDirective};

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TypedMetadata {
    pub title: Option<String>,
    pub description: Option<String>,
    pub canonical_url: Option<String>,
    pub alternate_urls: BTreeMap<String, String>,
    pub robots: BTreeSet<RobotsDirective>,
    pub json_ld: Vec<JsonLdNode>,
}

impl TypedMetadata {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_title(mut self, title: impl Into<String>) -> Result<Self, WasmModelError> {
        self.title = Some(require_non_empty("title", title.into())?);
        Ok(self)
    }

    pub fn with_description(
        mut self,
        description: impl Into<String>,
    ) -> Result<Self, WasmModelError> {
        self.description = Some(require_non_empty("description", description.into())?);
        Ok(self)
    }

    pub fn with_canonical_url(
        mut self,
        canonical_url: impl Into<String>,
    ) -> Result<Self, WasmModelError> {
        self.canonical_url = Some(validate_absolute_url(
            "canonical_url",
            canonical_url.into(),
        )?);
        Ok(self)
    }

    pub fn insert_alternate_url(
        mut self,
        locale: impl Into<String>,
        url: impl Into<String>,
    ) -> Result<Self, WasmModelError> {
        let locale = validate_token("alternate_url_locale", locale.into())?;
        let url = validate_absolute_url("alternate_url", url.into())?;
        self.alternate_urls.insert(locale, url);
        Ok(self)
    }

    pub fn with_robot_directive(mut self, directive: RobotsDirective) -> Self {
        self.robots.insert(directive);
        self
    }

    pub fn push_json_ld(mut self, node: JsonLdNode) -> Self {
        self.json_ld.push(node);
        self
    }

    pub fn merge_from(&mut self, other: &Self) {
        if other.title.is_some() {
            self.title = other.title.clone();
        }
        if other.description.is_some() {
            self.description = other.description.clone();
        }
        if other.canonical_url.is_some() {
            self.canonical_url = other.canonical_url.clone();
        }
        for (locale, url) in &other.alternate_urls {
            self.alternate_urls.insert(locale.clone(), url.clone());
        }
        self.robots.extend(other.robots.iter().copied());
        self.json_ld.extend(other.json_ld.iter().cloned());
    }

    pub(crate) fn validate(&self) -> Result<(), WasmModelError> {
        if self
            .title
            .as_ref()
            .is_some_and(|value| value.trim().is_empty())
            || self
                .description
                .as_ref()
                .is_some_and(|value| value.trim().is_empty())
        {
            return Err(WasmModelError::InvalidTypedReturn {
                reason: "metadata title and description must be non-empty when present".to_string(),
            });
        }
        if let Some(canonical_url) = &self.canonical_url {
            let _ = validate_absolute_url("canonical_url", canonical_url.clone())?;
        }
        if self
            .alternate_urls
            .iter()
            .any(|(locale, url)| locale.trim().is_empty() || url.trim().is_empty())
        {
            return Err(WasmModelError::InvalidTypedReturn {
                reason: "metadata alternate URLs must be non-empty".to_string(),
            });
        }
        for (locale, url) in &self.alternate_urls {
            let _ = validate_token("alternate_url_locale", locale.clone())?;
            let _ = validate_absolute_url("alternate_url", url.clone())?;
        }
        for node in &self.json_ld {
            node.validate()?;
        }
        Ok(())
    }
}

fn validate_absolute_url(field: &'static str, value: String) -> Result<String, WasmModelError> {
    let trimmed = require_non_empty(field, value)?;
    if is_absolute_http_url(&trimmed) {
        Ok(trimmed)
    } else {
        Err(WasmModelError::InvalidTypedReturn {
            reason: format!("`{field}` must be an absolute URL"),
        })
    }
}

fn is_absolute_http_url(value: &str) -> bool {
    value.starts_with("https://") || value.starts_with("http://")
}