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 pub fn primary(&self) -> &str {
42 match self {
43 Self::Css(s) | Self::XPath(s) => s,
44 Self::Both { css, .. } => css,
45 }
46 }
47
48 pub fn fallback(&self) -> Option<&str> {
50 match self {
51 Self::Both { xpath, .. } => Some(xpath),
52 _ => None,
53 }
54 }
55
56 pub fn validate(&self) -> crate::Result<()> {
58 match self {
59 Self::Css(s) => {
60 if s.is_empty() {
61 return Err(crate::error::PluginError::SelectorError {
62 selector: s.clone(),
63 reason: "CSS selector cannot be empty".to_string(),
64 });
65 }
66 if s.contains("::") && !is_valid_css_pseudo_element(s) {
68 return Err(crate::error::PluginError::SelectorError {
69 selector: s.clone(),
70 reason: "Invalid CSS pseudo-element".to_string(),
71 });
72 }
73 Ok(())
74 }
75 Self::XPath(s) => {
76 if s.is_empty() {
77 return Err(crate::error::PluginError::SelectorError {
78 selector: s.clone(),
79 reason: "XPath expression cannot be empty".to_string(),
80 });
81 }
82 if !is_balanced_xpath(s) {
84 return Err(crate::error::PluginError::SelectorError {
85 selector: s.clone(),
86 reason: "XPath expression has unbalanced brackets".to_string(),
87 });
88 }
89 Ok(())
90 }
91 Self::Both { css, xpath } => {
92 Self::Css(css.clone()).validate()?;
93 Self::XPath(xpath.clone()).validate()?;
94 Ok(())
95 }
96 }
97 }
98}
99
100fn is_valid_css_pseudo_element(s: &str) -> bool {
102 matches!(
103 s,
104 _ if s.contains("::before")
105 || s.contains("::after")
106 || s.contains("::first-line")
107 || s.contains("::first-letter")
108 || s.contains("::selection")
109 || s.contains("::placeholder")
110 )
111}
112
113fn is_balanced_xpath(s: &str) -> bool {
115 let mut depth = 0;
116 for ch in s.chars() {
117 match ch {
118 '(' | '[' => depth += 1,
119 ')' | ']' => {
120 depth -= 1;
121 if depth < 0 {
122 return false;
123 }
124 }
125 _ => {}
126 }
127 }
128 depth == 0
129}
130
131impl std::fmt::Display for Selector {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 match self {
134 Self::Css(s) => write!(f, "CSS({s})"),
135 Self::XPath(s) => write!(f, "XPath({s})"),
136 Self::Both { css, xpath } => write!(f, "Both(CSS: {css}, XPath: {xpath})"),
137 }
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn test_css_selector_creation() {
147 let sel = Selector::css(".my-class");
148 assert_eq!(sel.primary(), ".my-class");
149 assert_eq!(sel.fallback(), None);
150 }
151
152 #[test]
153 fn test_xpath_selector_creation() {
154 let sel = Selector::xpath("//div[@id='main']");
155 assert_eq!(sel.primary(), "//div[@id='main']");
156 assert_eq!(sel.fallback(), None);
157 }
158
159 #[test]
160 fn test_dual_selector() {
161 let sel = Selector::dual(".product", "//div[@class='product']");
162 assert_eq!(sel.primary(), ".product");
163 assert_eq!(sel.fallback(), Some("//div[@class='product']"));
164 }
165
166 #[test]
167 fn test_empty_selector_validation() {
168 let sel = Selector::css("");
169 assert!(sel.validate().is_err());
170 }
171
172 #[test]
173 fn test_unbalanced_xpath_validation() {
174 let sel = Selector::xpath("//div[@id='main'");
175 assert!(sel.validate().is_err());
176 }
177}