oratos 0.3.1

Website visibility intelligence for SEO, accessibility, structured metadata, and AI readiness.
Documentation
use serde::{Deserialize, Serialize};

use super::location::Location;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    Info,
    Warning,
    Error,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Category {
    Seo,
    Accessibility,
    StructuredData,
    LlmReadiness,
    PerformanceHint,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
    pub rule_id: String,
    pub severity: Severity,
    pub category: Category,
    pub message: String,
    pub location: Option<Location>,
    pub recommendation: Option<String>,
    pub docs_url: Option<String>,
}

impl Finding {
    pub fn new(
        rule_id: impl Into<String>,
        severity: Severity,
        category: Category,
        message: impl Into<String>,
    ) -> Self {
        let rule_id = rule_id.into();
        debug_assert!(
            is_valid_rule_id(&rule_id),
            "rule_id should use dot-separated kebab-case segments (e.g. seo.title-too-short)"
        );
        Self {
            rule_id,
            severity,
            category,
            message: message.into(),
            location: None,
            recommendation: None,
            docs_url: None,
        }
    }

    pub fn with_location(mut self, location: Location) -> Self {
        self.location = Some(location);
        self
    }

    pub fn with_recommendation(mut self, recommendation: impl Into<String>) -> Self {
        self.recommendation = Some(recommendation.into());
        self
    }

    pub fn with_docs_url(mut self, url: impl Into<String>) -> Self {
        self.docs_url = Some(url.into());
        self
    }
}

fn is_valid_rule_id(rule_id: &str) -> bool {
    let mut parts = rule_id.split('.');
    let Some(prefix) = parts.next() else {
        return false;
    };
    if prefix.is_empty()
        || !prefix
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
    {
        return false;
    }
    let Some(suffix) = parts.next() else {
        return true;
    };
    if parts.next().is_some() {
        return false;
    }
    !suffix.is_empty()
        && suffix
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}