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}