use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq)]
pub enum Locator {
Css(String),
XPath(String),
Text(String),
TextContains(String),
TextStartsWith(String),
TextEndsWith(String),
AttrEquals { attr: String, value: String },
AttrContains { attr: String, value: String },
AttrStartsWith { attr: String, value: String },
AttrEndsWith { attr: String, value: String },
And(Vec<Locator>),
Or(Vec<Locator>),
Chain(Vec<Locator>),
}
impl Locator {
fn to_xpath_predicate(&self) -> Option<String> {
let esc = |s: &str| s.replace('\'', "\\'");
match self {
Locator::Text(t) => Some(format!("text()='{}'", esc(t))),
Locator::TextContains(t) => Some(format!("contains(text(),'{}')", esc(t))),
Locator::TextStartsWith(t) => Some(format!("starts-with(text(),'{}')", esc(t))),
Locator::TextEndsWith(t) => {
let t = esc(t);
Some(format!(
"substring(text(),string-length(text())-string-length('{t}')+1)='{t}'"
))
}
Locator::AttrEquals { attr, value } => Some(format!("@{}='{}'", attr, esc(value))),
Locator::AttrContains { attr, value } => {
Some(format!("contains(@{},'{}')", attr, esc(value)))
}
Locator::AttrStartsWith { attr, value } => {
Some(format!("starts-with(@{},'{}')", attr, esc(value)))
}
Locator::AttrEndsWith { attr, value } => {
let value = esc(value);
Some(format!(
"substring(@{attr},string-length(@{attr})-string-length('{value}')+1)='{value}'"
))
}
Locator::Css(s) => {
if let Some(id) = s.strip_prefix('#') {
Some(format!("@id='{}'", esc(id)))
} else if let Some(cls) = s.strip_prefix('.') {
Some(format!(
"contains(concat(' ',normalize-space(@class),' '),' {} ')",
esc(cls)
))
} else {
None }
}
_ => None,
}
}
fn multi_cond_xpath(parts: &[Locator], join: &str) -> Option<String> {
let mut tag = "*".to_string();
let mut preds = Vec::new();
for (i, p) in parts.iter().enumerate() {
if i == 0 {
if let Locator::Css(s) = p {
if !s.starts_with('#') && !s.starts_with('.') && !s.contains(['>', ' ', '[']) {
tag = s.clone();
continue;
}
}
}
preds.push(p.to_xpath_predicate()?);
}
if preds.is_empty() {
return None;
}
Some(format!("//{}[{}]", tag, preds.join(join)))
}
}
impl Locator {
pub fn to_css(&self) -> Option<String> {
match self {
Locator::Css(s) => Some(s.clone()),
Locator::Text(_)
| Locator::TextContains(_)
| Locator::TextStartsWith(_)
| Locator::TextEndsWith(_)
| Locator::XPath(_)
| Locator::AttrEquals { .. }
| Locator::AttrContains { .. }
| Locator::AttrStartsWith { .. }
| Locator::AttrEndsWith { .. }
| Locator::And(_)
| Locator::Or(_)
| Locator::Chain(_) => None,
}
}
pub fn to_xpath(&self) -> Option<String> {
match self {
Locator::XPath(x) => Some(x.clone()),
Locator::Css(sel) => {
let mut xpath = String::from("//");
if let Some(id) = sel.strip_prefix('#') {
xpath.push_str(&format!("*[@id='{id}']"));
} else if let Some(cls) = sel.strip_prefix('.') {
xpath.push_str(&format!(
"*[contains(concat(' ',normalize-space(@class),' '),' {cls} ')]"
));
} else {
xpath.push_str(sel);
}
Some(xpath)
}
Locator::Text(t) => Some(format!("//*[text()='{}']", t.replace('\'', "\\'"))),
Locator::TextContains(t) => Some(format!(
"//*[contains(text(),'{}')]",
t.replace('\'', "\\'")
)),
Locator::TextStartsWith(t) => Some(format!(
"//*[starts-with(text(),'{}')]",
t.replace('\'', "\\'")
)),
Locator::TextEndsWith(t) => {
let t = t.replace('\'', "\\'");
Some(format!(
"//*[substring(text(),string-length(text())-string-length('{t}')+1)='{t}']"
))
}
Locator::AttrEquals { attr, value } => {
Some(format!("//*[@{}='{}']", attr, value.replace('\'', "\\'")))
}
Locator::AttrContains { attr, value } => Some(format!(
"//*[contains(@{},'{}')]",
attr,
value.replace('\'', "\\'")
)),
Locator::AttrStartsWith { attr, value } => Some(format!(
"//*[starts-with(@{},'{}')]",
attr,
value.replace('\'', "\\'")
)),
Locator::AttrEndsWith { attr, value } => {
let value = value.replace('\'', "\\'");
Some(format!(
"//*[substring(@{attr},string-length(@{attr})-string-length('{value}')+1)='{value}']"
))
}
Locator::And(parts) => Self::multi_cond_xpath(parts, " and "),
Locator::Or(parts) => Self::multi_cond_xpath(parts, " or "),
Locator::Chain(locators) => {
let mut parts = Vec::new();
for loc in locators {
if let Some(xp) = loc.to_xpath() {
parts.push(xp);
} else {
return None;
}
}
Some(parts.join(" | "))
}
}
}
pub fn is_css(&self) -> bool {
matches!(self, Locator::Css(_))
}
pub fn is_xpath(&self) -> bool {
matches!(
self,
Locator::XPath(_)
| Locator::Text(_)
| Locator::TextContains(_)
| Locator::TextStartsWith(_)
| Locator::TextEndsWith(_)
| Locator::AttrEquals { .. }
| Locator::AttrContains { .. }
| Locator::AttrStartsWith { .. }
| Locator::AttrEndsWith { .. }
| Locator::And(_)
| Locator::Or(_)
)
}
}
pub fn parse_locator(input: &str) -> Result<Locator> {
let input = input.trim();
if input.is_empty() {
return Err(Error::InvalidLocator("empty locator string".into()));
}
if input.contains("@|") {
return parse_multi_cond(input, "@|", false);
}
if input.contains("@@@") {
let parts: Vec<&str> = input.split("@@@").collect();
if parts.len() < 2 {
return Err(Error::InvalidLocator(format!(
"invalid chain locator: {input}"
)));
}
let locators: Vec<Locator> = parts
.iter()
.map(|p| parse_single_locator(p.trim()))
.collect::<Result<Vec<_>>>()?;
return Ok(Locator::Chain(locators));
}
if input.contains("@@") && !input.starts_with('@') {
return parse_multi_cond(input, "@@", true);
}
parse_single_locator(input)
}
fn parse_multi_cond(input: &str, sep: &str, and: bool) -> Result<Locator> {
let parts: Vec<&str> = input
.split(sep)
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect();
if parts.len() < 2 {
return Err(Error::InvalidLocator(format!(
"invalid multi-condition locator: {input}"
)));
}
let locs: Vec<Locator> = parts
.iter()
.map(|p| parse_condition(p))
.collect::<Result<Vec<_>>>()?;
Ok(if and {
Locator::And(locs)
} else {
Locator::Or(locs)
})
}
fn parse_condition(s: &str) -> Result<Locator> {
let prefixed = s.starts_with('@')
|| s.starts_with("text")
|| s.starts_with("tag:")
|| s.starts_with("css:")
|| s.starts_with("xpath:")
|| s.starts_with('#')
|| s.starts_with('.');
if !prefixed && s.contains(['=', ':', '^', '$']) {
return parse_single_locator(&format!("@{s}"));
}
parse_single_locator(s)
}
#[derive(Clone, Copy)]
enum MatchOp {
Exact,
Contains,
StartsWith,
EndsWith,
}
fn split_match_op(s: &str) -> Option<(MatchOp, &str)> {
if let Some(v) = s.strip_prefix("*=") {
return Some((MatchOp::Contains, v));
}
if let Some(v) = s.strip_prefix(':') {
return Some((MatchOp::Contains, v));
}
if let Some(v) = s.strip_prefix("^=").or_else(|| s.strip_prefix('^')) {
return Some((MatchOp::StartsWith, v));
}
if let Some(v) = s.strip_prefix("$=").or_else(|| s.strip_prefix('$')) {
return Some((MatchOp::EndsWith, v));
}
if let Some(v) = s.strip_prefix('=') {
return Some((MatchOp::Exact, v));
}
None
}
fn parse_single_locator(input: &str) -> Result<Locator> {
let input = input.trim();
if input.is_empty() {
return Err(Error::InvalidLocator("empty locator segment".into()));
}
if let Some(rest) = input.strip_prefix("xpath:") {
if rest.is_empty() {
return Err(Error::InvalidLocator(
"xpath: requires an expression".into(),
));
}
return Ok(Locator::XPath(rest.to_string()));
}
if let Some(rest) = input.strip_prefix("css:") {
if rest.is_empty() {
return Err(Error::InvalidLocator("css: requires a selector".into()));
}
return Ok(Locator::Css(rest.to_string()));
}
if let Some(rest) = input.strip_prefix("text") {
if let Some((op, value)) = split_match_op(rest) {
if value.is_empty() {
return Err(Error::InvalidLocator(
"text matcher requires a value".into(),
));
}
let value = value.to_string();
return Ok(match op {
MatchOp::Exact => Locator::Text(value),
MatchOp::Contains => Locator::TextContains(value),
MatchOp::StartsWith => Locator::TextStartsWith(value),
MatchOp::EndsWith => Locator::TextEndsWith(value),
});
}
}
if let Some(rest) = input.strip_prefix('@') {
let name_len = rest
.find(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_'))
.unwrap_or(rest.len());
let attr = &rest[..name_len];
if !attr.is_empty() {
if let Some((op, value)) = split_match_op(&rest[name_len..]) {
let attr = attr.to_string();
let value = value.to_string();
return Ok(match op {
MatchOp::Exact => Locator::AttrEquals { attr, value },
MatchOp::Contains => Locator::AttrContains { attr, value },
MatchOp::StartsWith => Locator::AttrStartsWith { attr, value },
MatchOp::EndsWith => Locator::AttrEndsWith { attr, value },
});
}
}
}
if let Some(rest) = input.strip_prefix("tag:") {
if rest.is_empty() {
return Err(Error::InvalidLocator("tag: requires a tag name".into()));
}
return Ok(Locator::Css(rest.to_string()));
}
if input.starts_with('#') {
return Ok(Locator::Css(input.to_string()));
}
if input.starts_with('.') {
return Ok(Locator::Css(input.to_string()));
}
if input.starts_with('[') {
return Ok(Locator::Css(input.to_string()));
}
if input
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Ok(Locator::Css(input.to_string()));
}
Ok(Locator::Css(input.to_string()))
}
pub fn locator_to_selector(locator: &Locator) -> Result<String> {
match locator {
Locator::Css(sel) => Ok(sel.clone()),
Locator::XPath(xp) => Ok(format!("xpath:{xp}")),
Locator::Text(t) => Ok(format!("xpath://*[text()='{}']", t.replace('\'', "\\'"))),
Locator::TextContains(t) => Ok(format!(
"xpath://*[contains(text(),'{}')]",
t.replace('\'', "\\'")
)),
Locator::AttrEquals { attr, value } => Ok(format!(
"xpath://*[@{}='{}']",
attr,
value.replace('\'', "\\'")
)),
Locator::AttrContains { attr, value } => Ok(format!(
"xpath://*[contains(@{},'{}')]",
attr,
value.replace('\'', "\\'")
)),
Locator::TextStartsWith(_)
| Locator::TextEndsWith(_)
| Locator::AttrStartsWith { .. }
| Locator::AttrEndsWith { .. }
| Locator::And(_)
| Locator::Or(_) => locator
.to_xpath()
.map(|xp| format!("xpath:{xp}"))
.ok_or_else(|| Error::InvalidLocator("cannot build xpath".into())),
Locator::Chain(locators) => locators
.last()
.ok_or_else(|| Error::InvalidLocator("empty chain".into()))
.and_then(locator_to_selector),
}
}
pub trait IntoLocator {
fn to_locator(&self) -> Result<Locator>;
}
impl IntoLocator for &str {
fn to_locator(&self) -> Result<Locator> {
parse_locator(self)
}
}
impl IntoLocator for String {
fn to_locator(&self) -> Result<Locator> {
parse_locator(self)
}
}
impl IntoLocator for Locator {
fn to_locator(&self) -> Result<Locator> {
Ok(self.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_css_id() {
let loc = parse_locator("#myid").unwrap();
assert_eq!(loc, Locator::Css("#myid".to_string()));
}
#[test]
fn test_css_class() {
let loc = parse_locator(".myclass").unwrap();
assert_eq!(loc, Locator::Css(".myclass".to_string()));
}
#[test]
fn test_tag() {
let loc = parse_locator("div").unwrap();
assert_eq!(loc, Locator::Css("div".to_string()));
}
#[test]
fn test_css_prefix() {
let loc = parse_locator("css:div.container > p").unwrap();
assert_eq!(loc, Locator::Css("div.container > p".to_string()));
}
#[test]
fn test_xpath_prefix() {
let loc = parse_locator("xpath://div[@id='foo']").unwrap();
assert_eq!(loc, Locator::XPath("//div[@id='foo']".to_string()));
}
#[test]
fn test_text_exact() {
let loc = parse_locator("text=Login").unwrap();
assert_eq!(loc, Locator::Text("Login".to_string()));
}
#[test]
fn test_text_contains() {
let loc = parse_locator("text*=Log").unwrap();
assert_eq!(loc, Locator::TextContains("Log".to_string()));
}
#[test]
fn test_text_contains_colon() {
let loc = parse_locator("text:登录").unwrap();
assert_eq!(loc, Locator::TextContains("登录".to_string()));
}
#[test]
fn test_text_starts_ends_with() {
assert_eq!(
parse_locator("text^Hello").unwrap(),
Locator::TextStartsWith("Hello".to_string())
);
assert_eq!(
parse_locator("text$world").unwrap(),
Locator::TextEndsWith("world".to_string())
);
}
#[test]
fn test_textarea_is_css_not_text_locator() {
assert_eq!(
parse_locator("textarea").unwrap(),
Locator::Css("textarea".to_string())
);
}
#[test]
fn test_attr_contains_colon() {
let loc = parse_locator("@class:btn").unwrap();
assert_eq!(
loc,
Locator::AttrContains {
attr: "class".to_string(),
value: "btn".to_string(),
}
);
}
#[test]
fn test_attr_starts_ends_with() {
assert_eq!(
parse_locator("@href^https").unwrap(),
Locator::AttrStartsWith {
attr: "href".to_string(),
value: "https".to_string()
}
);
assert_eq!(
parse_locator("@src$.png").unwrap(),
Locator::AttrEndsWith {
attr: "src".to_string(),
value: ".png".to_string()
}
);
}
#[test]
fn test_text_ends_with_xpath_uses_substring() {
let xp = Locator::TextEndsWith("bar".to_string()).to_xpath().unwrap();
assert!(xp.contains("substring") && xp.contains("string-length"));
}
#[test]
fn test_attr_equals() {
let loc = parse_locator("@name=login").unwrap();
assert_eq!(
loc,
Locator::AttrEquals {
attr: "name".to_string(),
value: "login".to_string()
}
);
}
#[test]
fn test_attr_contains() {
let loc = parse_locator("@name*=log").unwrap();
assert_eq!(
loc,
Locator::AttrContains {
attr: "name".to_string(),
value: "log".to_string()
}
);
}
#[test]
fn test_chain_double_at() {
let loc = parse_locator("tag:form@@text=Login").unwrap();
match loc {
Locator::And(parts) => {
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], Locator::Css("form".to_string()));
assert_eq!(parts[1], Locator::Text("Login".to_string()));
}
_ => panic!("expected And"),
}
}
#[test]
fn test_chain_triple_at() {
let loc = parse_locator("tag:form@@@name=login").unwrap();
match loc {
Locator::Chain(parts) => {
assert_eq!(parts.len(), 2);
}
_ => panic!("expected Chain"),
}
}
#[test]
fn test_css_div_child_p() {
let loc = parse_locator("css:div>p").unwrap();
assert_eq!(loc, Locator::Css("div>p".to_string()));
assert!(loc.is_css());
}
#[test]
fn test_css_combined_selector() {
let loc = parse_locator("css:#main .content > p:first-child").unwrap();
assert_eq!(
loc,
Locator::Css("#main .content > p:first-child".to_string())
);
}
#[test]
fn test_css_bracket_selector() {
let loc = parse_locator("[data-testid='submit']").unwrap();
assert_eq!(loc, Locator::Css("[data-testid='submit']".to_string()));
}
#[test]
fn test_xpath_div() {
let loc = parse_locator("xpath://div").unwrap();
assert_eq!(loc, Locator::XPath("//div".to_string()));
assert!(loc.is_xpath());
assert!(!loc.is_css());
}
#[test]
fn test_text_equals_chinese() {
let loc = parse_locator("text=登录").unwrap();
assert_eq!(loc, Locator::Text("登录".to_string()));
assert!(loc.is_xpath());
}
#[test]
fn test_text_contains_chinese() {
let loc = parse_locator("text*=登录").unwrap();
assert_eq!(loc, Locator::TextContains("登录".to_string()));
}
#[test]
fn test_attr_equals_class_btn() {
let loc = parse_locator("@class=btn").unwrap();
assert_eq!(
loc,
Locator::AttrEquals {
attr: "class".to_string(),
value: "btn".to_string(),
}
);
}
#[test]
fn test_attr_contains_class_btn() {
let loc = parse_locator("@class*=btn").unwrap();
assert_eq!(
loc,
Locator::AttrContains {
attr: "class".to_string(),
value: "btn".to_string(),
}
);
}
#[test]
fn test_tag_prefix() {
let loc = parse_locator("tag:form").unwrap();
assert_eq!(loc, Locator::Css("form".to_string()));
}
#[test]
fn test_chain_tag_text_chinese() {
let loc = parse_locator("tag:form@@text=登录").unwrap();
match loc {
Locator::And(parts) => {
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], Locator::Css("form".to_string()));
assert_eq!(parts[1], Locator::Text("登录".to_string()));
}
_ => panic!("expected And"),
}
}
#[test]
fn test_chain_with_attr() {
let loc = parse_locator("tag:input@@@name=login").unwrap();
match loc {
Locator::Chain(parts) => {
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], Locator::Css("input".to_string()));
assert_eq!(parts[1], Locator::Css("name=login".to_string()));
}
_ => panic!("expected Chain"),
}
}
#[test]
fn test_chain_with_at_attr() {
let loc = parse_locator("tag:div@@@name=user").unwrap();
match loc {
Locator::Chain(parts) => {
assert_eq!(parts.len(), 2);
assert_eq!(parts[1], Locator::Css("name=user".to_string()));
}
_ => panic!("expected Chain"),
}
}
#[test]
fn test_chain_double_at_with_attr() {
let loc = parse_locator("tag:div@@name=user").unwrap();
match loc {
Locator::And(parts) => {
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], Locator::Css("div".to_string()));
assert_eq!(
parts[1],
Locator::AttrEquals {
attr: "name".to_string(),
value: "user".to_string()
}
);
}
_ => panic!("expected And"),
}
}
#[test]
fn test_and_xpath_same_element() {
let loc = parse_locator("tag:div@@class=test@@text:hi").unwrap();
assert_eq!(
loc.to_xpath().unwrap(),
"//div[@class='test' and contains(text(),'hi')]"
);
assert!(loc.is_xpath() && !loc.is_css());
}
#[test]
fn test_or_xpath() {
let loc = parse_locator("@|class=a@|class=b").unwrap();
assert!(matches!(loc, Locator::Or(_)));
assert_eq!(loc.to_xpath().unwrap(), "//*[@class='a' or @class='b']");
}
#[test]
fn test_and_startswith_and_text_contains() {
let loc = parse_locator("tag:a@@href^http@@text:more").unwrap();
assert_eq!(
loc.to_xpath().unwrap(),
"//a[starts-with(@href,'http') and contains(text(),'more')]"
);
}
#[test]
fn test_empty_locator_error() {
let result = parse_locator("");
assert!(result.is_err());
}
#[test]
fn test_whitespace_only_locator_error() {
let result = parse_locator(" ");
assert!(result.is_err());
}
#[test]
fn test_xpath_empty_error() {
let result = parse_locator("xpath:");
assert!(result.is_err());
}
#[test]
fn test_css_empty_error() {
let result = parse_locator("css:");
assert!(result.is_err());
}
#[test]
fn test_text_empty_error() {
let result = parse_locator("text=");
assert!(result.is_err());
}
#[test]
fn test_to_css_returns_some_for_css() {
let loc = Locator::Css("#id".to_string());
assert_eq!(loc.to_css(), Some("#id".to_string()));
}
#[test]
fn test_to_css_returns_none_for_xpath() {
let loc = Locator::XPath("//div".to_string());
assert!(loc.to_css().is_none());
}
#[test]
fn test_to_css_returns_none_for_text() {
let loc = Locator::Text("hello".to_string());
assert!(loc.to_css().is_none());
}
#[test]
fn test_to_xpath_css_id() {
let loc = Locator::Css("#myid".to_string());
assert_eq!(loc.to_xpath(), Some("//*[@id='myid']".to_string()));
}
#[test]
fn test_to_xpath_css_class() {
let loc = Locator::Css(".myclass".to_string());
let xp = loc.to_xpath().unwrap();
assert!(xp.contains("contains") && xp.contains("@class"));
}
#[test]
fn test_to_xpath_text() {
let loc = Locator::Text("hello".to_string());
assert_eq!(loc.to_xpath(), Some("//*[text()='hello']".to_string()));
}
#[test]
fn test_to_xpath_text_contains() {
let loc = Locator::TextContains("hello".to_string());
assert_eq!(
loc.to_xpath(),
Some("//*[contains(text(),'hello')]".to_string())
);
}
#[test]
fn test_to_xpath_attr_equals() {
let loc = Locator::AttrEquals {
attr: "class".to_string(),
value: "btn".to_string(),
};
assert_eq!(loc.to_xpath(), Some("//*[@class='btn']".to_string()));
}
#[test]
fn test_to_xpath_attr_contains() {
let loc = Locator::AttrContains {
attr: "class".to_string(),
value: "btn".to_string(),
};
assert_eq!(
loc.to_xpath(),
Some("//*[contains(@class,'btn')]".to_string())
);
}
#[test]
fn test_locator_to_selector_css() {
let loc = Locator::Css("div > p".to_string());
assert_eq!(locator_to_selector(&loc).unwrap(), "div > p");
}
#[test]
fn test_locator_to_selector_xpath() {
let loc = Locator::XPath("//div".to_string());
assert_eq!(locator_to_selector(&loc).unwrap(), "xpath://div");
}
#[test]
fn test_locator_to_selector_text() {
let loc = Locator::Text("Login".to_string());
assert_eq!(
locator_to_selector(&loc).unwrap(),
"xpath://*[text()='Login']"
);
}
#[test]
fn test_into_locator_str() {
use super::IntoLocator;
let loc = "#test".to_locator().unwrap();
assert_eq!(loc, Locator::Css("#test".to_string()));
}
#[test]
fn test_into_locator_string() {
use super::IntoLocator;
let s = String::from(".cls");
let loc = s.to_locator().unwrap();
assert_eq!(loc, Locator::Css(".cls".to_string()));
}
#[test]
fn test_into_locator_locator() {
use super::IntoLocator;
let original = Locator::XPath("//div".to_string());
let cloned = original.to_locator().unwrap();
assert_eq!(original, cloned);
}
}