use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum AttrOp {
Eq,
Contains,
StartsWith,
EndsWith,
Has,
Regex,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AttrPredicate {
pub name: String,
pub op: AttrOp,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Selector {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub css: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub xpath: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_exact: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_regex: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attrs: Vec<AttrPredicate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nth: Option<usize>,
#[serde(default = "default_visible_only")]
pub visible_only: bool,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame_id: Option<String>,
}
fn default_visible_only() -> bool {
true
}
fn default_timeout_ms() -> u64 {
5000
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SelectorError {
#[error(
"Selector requires exactly one of: css, xpath, text, text_exact, text_regex, role, or a predicate group (tag/attrs)"
)]
NoneOrMultiple,
#[error("`role_name` requires `role` to also be set")]
OrphanRoleName,
#[error(
"predicate fields (`tag`/`attrs`) cannot be combined with `css`, `xpath`, or `role`; use one selector style per query"
)]
PredicateConflict,
#[error("attribute predicate `{name}` with op `{op:?}` requires a `value` field")]
AttrValueRequired { name: String, op: AttrOp },
}
impl Selector {
fn has_predicates(&self) -> bool {
self.tag.is_some() || !self.attrs.is_empty()
}
pub fn validate(&self) -> Result<(), SelectorError> {
let has_pred = self.has_predicates();
let single_count = [
self.css.is_some(),
self.xpath.is_some(),
self.role.is_some(),
]
.into_iter()
.filter(|b| *b)
.count();
let text_count = [
self.text.is_some(),
self.text_exact.is_some(),
self.text_regex.is_some(),
]
.into_iter()
.filter(|b| *b)
.count();
if has_pred {
if single_count > 0 {
return Err(SelectorError::PredicateConflict);
}
if text_count > 1 {
return Err(SelectorError::NoneOrMultiple);
}
for ap in &self.attrs {
if ap.value.is_none() && ap.op != AttrOp::Has {
return Err(SelectorError::AttrValueRequired {
name: ap.name.clone(),
op: ap.op.clone(),
});
}
}
} else {
let n = single_count + text_count;
if n != 1 {
return Err(SelectorError::NoneOrMultiple);
}
}
if self.role_name.is_some() && self.role.is_none() {
return Err(SelectorError::OrphanRoleName);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Selector {
Selector {
css: None,
xpath: None,
text: None,
text_exact: None,
text_regex: None,
role: None,
role_name: None,
tag: None,
attrs: vec![],
nth: None,
visible_only: true,
timeout_ms: 5000,
frame_id: None,
}
}
#[test]
fn validate_rejects_zero_selectors() {
let s = base();
assert_eq!(s.validate(), Err(SelectorError::NoneOrMultiple));
}
#[test]
fn validate_rejects_two_selectors() {
let mut s = base();
s.css = Some("#x".into());
s.text = Some("hi".into());
assert_eq!(s.validate(), Err(SelectorError::NoneOrMultiple));
}
#[test]
fn validate_accepts_single_css() {
let mut s = base();
s.css = Some("#x".into());
assert!(s.validate().is_ok());
}
#[test]
fn validate_accepts_role_with_name() {
let mut s = base();
s.role = Some("button".into());
s.role_name = Some("Submit".into());
assert!(s.validate().is_ok());
}
#[test]
fn validate_rejects_role_name_without_role() {
let mut s = base();
s.text = Some("hi".into());
s.role_name = Some("Submit".into());
assert_eq!(s.validate(), Err(SelectorError::OrphanRoleName));
}
#[test]
fn validate_accepts_tag_only() {
let mut s = base();
s.tag = Some("button".into());
assert!(s.validate().is_ok());
}
#[test]
fn validate_accepts_tag_with_text() {
let mut s = base();
s.tag = Some("a".into());
s.text = Some("Buy".into());
assert!(s.validate().is_ok());
}
#[test]
fn validate_rejects_predicate_plus_css() {
let mut s = base();
s.tag = Some("div".into());
s.css = Some(".foo".into());
assert_eq!(s.validate(), Err(SelectorError::PredicateConflict));
}
#[test]
fn validate_rejects_attr_predicate_missing_value() {
let mut s = base();
s.attrs = vec![AttrPredicate {
name: "data-id".into(),
op: AttrOp::Eq,
value: None,
}];
assert!(matches!(
s.validate(),
Err(SelectorError::AttrValueRequired { .. })
));
}
#[test]
fn validate_accepts_has_attr_without_value() {
let mut s = base();
s.attrs = vec![AttrPredicate {
name: "data-ready".into(),
op: AttrOp::Has,
value: None,
}];
assert!(s.validate().is_ok());
}
}