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    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    /// Get the fallback selector (`XPath` if available)
49    pub fn fallback(&self) -> Option<&str> {
50        match self {
51            Self::Both { xpath, .. } => Some(xpath),
52            _ => None,
53        }
54    }
55
56    /// Validate selector syntax (basic checks)
57    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                // Basic CSS validation: check for common syntax errors
67                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                // Basic XPath validation: check for balanced brackets
83                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
100/// Check if a string is a valid CSS pseudo-element
101fn 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
113/// Check if an `XPath` has balanced brackets
114fn 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}