Skip to main content

oratos_core/
finding.rs

1use serde::{Deserialize, Serialize};
2
3use crate::location::Location;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
6#[serde(rename_all = "lowercase")]
7pub enum Severity {
8    Info,
9    Warning,
10    Error,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum Category {
16    Seo,
17    Accessibility,
18    StructuredData,
19    LlmReadiness,
20    PerformanceHint,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Finding {
25    pub rule_id: String,
26    pub severity: Severity,
27    pub category: Category,
28    pub message: String,
29    pub location: Option<Location>,
30    pub recommendation: Option<String>,
31    pub docs_url: Option<String>,
32}
33
34impl Finding {
35    pub fn new(
36        rule_id: impl Into<String>,
37        severity: Severity,
38        category: Category,
39        message: impl Into<String>,
40    ) -> Self {
41        let rule_id = rule_id.into();
42        debug_assert!(
43            is_valid_rule_id(&rule_id),
44            "rule_id should use dot-separated kebab-case segments (e.g. seo.title-too-short)"
45        );
46        Self {
47            rule_id,
48            severity,
49            category,
50            message: message.into(),
51            location: None,
52            recommendation: None,
53            docs_url: None,
54        }
55    }
56
57    pub fn with_location(mut self, location: Location) -> Self {
58        self.location = Some(location);
59        self
60    }
61
62    pub fn with_recommendation(mut self, recommendation: impl Into<String>) -> Self {
63        self.recommendation = Some(recommendation.into());
64        self
65    }
66
67    pub fn with_docs_url(mut self, url: impl Into<String>) -> Self {
68        self.docs_url = Some(url.into());
69        self
70    }
71}
72
73fn is_valid_rule_id(rule_id: &str) -> bool {
74    let mut parts = rule_id.split('.');
75    let Some(prefix) = parts.next() else {
76        return false;
77    };
78    if prefix.is_empty()
79        || !prefix
80            .chars()
81            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
82    {
83        return false;
84    }
85    let Some(suffix) = parts.next() else {
86        return true;
87    };
88    if parts.next().is_some() {
89        return false;
90    }
91    !suffix.is_empty()
92        && suffix
93            .chars()
94            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
95}