Skip to main content

stygian_plugin/domain/
selector.rs

1//! Selector types for DOM element selection
2
3use serde::{Deserialize, Serialize};
4
5/// A selector for finding DOM elements
6///
7/// Supports both CSS selectors (fast, reliable) and `XPath` (more powerful, fragile).
8/// The plugin generates both and tries CSS first, falling back to `XPath`.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub enum Selector {
11    /// CSS selector (preferred, tried first)
12    Css(String),
13
14    /// `XPath` expression (fallback)
15    XPath(String),
16
17    /// Both CSS and `XPath`; try CSS first
18    Both { css: String, xpath: String },
19}
20
21impl Selector {
22    /// Create a CSS selector
23    pub fn css(selector: impl Into<String>) -> Self {
24        Self::Css(selector.into())
25    }
26
27    /// Create an `XPath` selector
28    pub fn xpath(xpath: impl Into<String>) -> Self {
29        Self::XPath(xpath.into())
30    }
31
32    /// Create a dual selector (prefer CSS)
33    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    /// Get the primary selector (CSS if available, otherwise `XPath`)
41    #[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    /// Get the fallback selector (`XPath` if available)
50    #[must_use]
51    pub fn fallback(&self) -> Option<&str> {
52        match self {
53            Self::Both { xpath, .. } => Some(xpath),
54            _ => None,
55        }
56    }
57
58    /// Validate selector syntax (basic checks)
59    ///
60    /// # Errors
61    ///
62    /// Returns [`crate::error::PluginError::SelectorError`] when the CSS
63    /// selector is empty, references an unknown pseudo-element, the `XPath`
64    /// expression is empty, or the `XPath` brackets are unbalanced.
65    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                // Basic CSS validation: check for common syntax errors
75                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                // Basic XPath validation: check for balanced brackets
91                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
108/// Check if a string is a valid CSS pseudo-element
109fn 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
121/// Check if an `XPath` has balanced brackets
122fn 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}