stygian_plugin/domain/
selector.rs1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub enum Selector {
11 Css(String),
13
14 XPath(String),
16
17 Both { css: String, xpath: String },
19}
20
21impl Selector {
22 pub fn css(selector: impl Into<String>) -> Self {
24 Self::Css(selector.into())
25 }
26
27 pub fn xpath(xpath: impl Into<String>) -> Self {
29 Self::XPath(xpath.into())
30 }
31
32 pub fn dual(css: impl Into<String>, xpath: impl Into<String>) -> Self {
34 Self::Both {
35 css: css.into(),
36 xpath: xpath.into(),
37 }
38 }
39
40 #[must_use]
42 pub fn primary(&self) -> &str {
43 match self {
44 Self::Css(s) | Self::XPath(s) => s,
45 Self::Both { css, .. } => css,
46 }
47 }
48
49 #[must_use]
51 pub fn fallback(&self) -> Option<&str> {
52 match self {
53 Self::Both { xpath, .. } => Some(xpath),
54 _ => None,
55 }
56 }
57
58 pub fn validate(&self) -> crate::Result<()> {
66 match self {
67 Self::Css(s) => {
68 if s.is_empty() {
69 return Err(crate::error::PluginError::SelectorError {
70 selector: s.clone(),
71 reason: "CSS selector cannot be empty".to_string(),
72 });
73 }
74 if s.contains("::") && !is_valid_css_pseudo_element(s) {
76 return Err(crate::error::PluginError::SelectorError {
77 selector: s.clone(),
78 reason: "Invalid CSS pseudo-element".to_string(),
79 });
80 }
81 Ok(())
82 }
83 Self::XPath(s) => {
84 if s.is_empty() {
85 return Err(crate::error::PluginError::SelectorError {
86 selector: s.clone(),
87 reason: "XPath expression cannot be empty".to_string(),
88 });
89 }
90 if !is_balanced_xpath(s) {
92 return Err(crate::error::PluginError::SelectorError {
93 selector: s.clone(),
94 reason: "XPath expression has unbalanced brackets".to_string(),
95 });
96 }
97 Ok(())
98 }
99 Self::Both { css, xpath } => {
100 Self::Css(css.clone()).validate()?;
101 Self::XPath(xpath.clone()).validate()?;
102 Ok(())
103 }
104 }
105 }
106}
107
108fn is_valid_css_pseudo_element(s: &str) -> bool {
110 matches!(
111 s,
112 _ if s.contains("::before")
113 || s.contains("::after")
114 || s.contains("::first-line")
115 || s.contains("::first-letter")
116 || s.contains("::selection")
117 || s.contains("::placeholder")
118 )
119}
120
121fn is_balanced_xpath(s: &str) -> bool {
123 let mut depth = 0;
124 for ch in s.chars() {
125 match ch {
126 '(' | '[' => depth += 1,
127 ')' | ']' => {
128 depth -= 1;
129 if depth < 0 {
130 return false;
131 }
132 }
133 _ => {}
134 }
135 }
136 depth == 0
137}
138
139impl std::fmt::Display for Selector {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 match self {
142 Self::Css(s) => write!(f, "CSS({s})"),
143 Self::XPath(s) => write!(f, "XPath({s})"),
144 Self::Both { css, xpath } => write!(f, "Both(CSS: {css}, XPath: {xpath})"),
145 }
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn test_css_selector_creation() {
155 let sel = Selector::css(".my-class");
156 assert_eq!(sel.primary(), ".my-class");
157 assert_eq!(sel.fallback(), None);
158 }
159
160 #[test]
161 fn test_xpath_selector_creation() {
162 let sel = Selector::xpath("//div[@id='main']");
163 assert_eq!(sel.primary(), "//div[@id='main']");
164 assert_eq!(sel.fallback(), None);
165 }
166
167 #[test]
168 fn test_dual_selector() {
169 let sel = Selector::dual(".product", "//div[@class='product']");
170 assert_eq!(sel.primary(), ".product");
171 assert_eq!(sel.fallback(), Some("//div[@class='product']"));
172 }
173
174 #[test]
175 fn test_empty_selector_validation() {
176 let sel = Selector::css("");
177 assert!(sel.validate().is_err());
178 }
179
180 #[test]
181 fn test_unbalanced_xpath_validation() {
182 let sel = Selector::xpath("//div[@id='main'");
183 assert!(sel.validate().is_err());
184 }
185}