use crate::chrome::escape_js_single_quoted;
use serde_json::Value;
pub(crate) fn text_value_argv(text: &str) -> Vec<String> {
if text.starts_with('-') {
vec!["--".to_string(), text.to_string()]
} else {
vec![text.to_string()]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WaitTarget {
Selector(String),
Url(String),
Text(String),
}
impl WaitTarget {
pub(crate) fn describe(&self) -> String {
match self {
Self::Selector(s) => format!("selector '{s}'"),
Self::Url(p) => format!("url pattern '{p}'"),
Self::Text(t) => format!("text '{t}'"),
}
}
}
pub(crate) fn wait_args(target: &WaitTarget, timeout_ms: u128) -> Vec<String> {
let mut args = match target {
WaitTarget::Selector(s) => vec!["wait".to_string(), s.clone()],
WaitTarget::Url(p) => vec!["wait".to_string(), "--url".to_string(), p.clone()],
WaitTarget::Text(t) => vec!["wait".to_string(), "--text".to_string(), t.clone()],
};
args.push("--timeout".to_string());
args.push(timeout_ms.to_string());
args
}
pub(crate) enum ExpectCond {
State {
selector: String,
state: &'static str,
}, Count {
selector: String,
op: String,
n: u64,
},
Text {
selector: String,
predicate: &'static str,
value: String,
},
Value {
selector: String,
predicate: &'static str,
value: String,
},
Attr {
selector: String,
name: String,
predicate: &'static str,
value: String,
},
Url {
predicate: &'static str,
pattern: String,
},
}
pub(crate) fn parse_state(s: &str) -> Option<&'static str> {
match s {
"visible" => Some("visible"),
"hidden" => Some("hidden"),
"present" => Some("present"),
_ => None,
}
}
pub(crate) fn parse_predicate(s: &str) -> Option<&'static str> {
match s {
"equals" => Some("equals"),
"contains" => Some("contains"),
"matches" => Some("matches"),
_ => None,
}
}
pub(crate) fn parse_count_op(s: &str) -> Option<String> {
let valid = [
"==", "!=", ">", "<", ">=", "<=", "eq", "ne", "gt", "lt", "ge", "le",
];
valid.contains(&s).then(|| s.to_string())
}
pub(crate) fn expect_args(cond: &ExpectCond, timeout_ms: u128) -> Vec<String> {
let mut args = match cond {
ExpectCond::State { selector, state } => {
vec!["expect".to_string(), selector.clone(), (*state).to_string()]
}
ExpectCond::Count { selector, op, n } => vec![
"expect".to_string(),
"count".to_string(),
selector.clone(),
op.clone(),
n.to_string(),
],
ExpectCond::Text {
selector,
predicate,
value,
} => vec![
"expect".to_string(),
"text".to_string(),
selector.clone(),
(*predicate).to_string(),
value.clone(),
],
ExpectCond::Value {
selector,
predicate,
value,
} => vec![
"expect".to_string(),
"value".to_string(),
selector.clone(),
(*predicate).to_string(),
value.clone(),
],
ExpectCond::Attr {
selector,
name,
predicate,
value,
} => vec![
"expect".to_string(),
"attr".to_string(),
selector.clone(),
name.clone(),
(*predicate).to_string(),
value.clone(),
],
ExpectCond::Url { predicate, pattern } => vec![
"expect".to_string(),
"url".to_string(),
(*predicate).to_string(),
pattern.clone(),
],
};
args.push("--timeout".to_string());
args.push(timeout_ms.to_string());
args
}
pub(crate) fn describe(cond: &ExpectCond) -> String {
match cond {
ExpectCond::State { selector, state } => format!("'{selector}' is {state}"),
ExpectCond::Count { selector, op, n } => format!("count('{selector}') {op} {n}"),
ExpectCond::Text {
selector,
predicate,
value,
} => format!("text of '{selector}' {predicate} \"{value}\""),
ExpectCond::Value {
selector,
predicate,
value,
} => format!("value of '{selector}' {predicate} \"{value}\""),
ExpectCond::Attr {
selector,
name,
predicate,
value,
} => format!("attr '{name}' of '{selector}' {predicate} \"{value}\""),
ExpectCond::Url { predicate, pattern } => format!("url {predicate} \"{pattern}\""),
}
}
pub(crate) fn wait_target(
selector: Option<&str>,
url: Option<&str>,
text: Option<&str>,
) -> Result<WaitTarget, String> {
match (selector, url, text) {
(Some(s), None, None) if s.parse::<u64>().is_err() => {
Ok(WaitTarget::Selector(s.to_string()))
}
(Some(_), None, None) => Err(
"numeric wait is a silent sleep — not supported; wait for a selector, a URL pattern, or page text"
.to_string(),
),
(None, Some(u), None) => Ok(WaitTarget::Url(u.to_string())),
(None, None, Some(t)) => Ok(WaitTarget::Text(t.to_string())),
_ => Err(
"give exactly one wait target — a selector, a URL pattern, or page text".to_string(),
),
}
}
pub(crate) fn count_eval_js(selector: &str) -> String {
format!(
"document.querySelectorAll('{}').length",
escape_js_single_quoted(selector)
)
}
pub(crate) enum ExtractGate<E> {
Empty,
Proceed,
Fail(E),
}
pub(crate) fn extract_gate<E>(count: Result<u64, E>) -> ExtractGate<E> {
match count {
Ok(0) => ExtractGate::Empty,
Ok(_) => ExtractGate::Proceed,
Err(e) => ExtractGate::Fail(e),
}
}
const GETTER_GRAMMAR: &str = "valid getters are \"text\" (default), \"html\", \"value\", or \
\"@<attribute>\" (e.g. \"@href\"); attributes require the \"@\" prefix — a bare \
attribute name is not a getter";
pub(crate) fn validate_extract_getters(schema: &Value) -> Result<(), String> {
let Some(fields) = schema.get("fields").and_then(Value::as_object) else {
return Ok(());
};
for (name, field) in fields {
let Some(obj) = field.as_object() else {
continue; };
let Some(get) = obj.get("get") else {
continue;
};
let Some(getter) = get.as_str() else {
return Err(format!(
"extract field '{name}' has a non-string getter {get} — {GETTER_GRAMMAR}"
));
};
if let Some(attr) = getter.strip_prefix('@') {
if attr.is_empty() {
return Err(format!(
"extract field '{name}' has an empty getter \"@\" — {GETTER_GRAMMAR}"
));
}
continue;
}
if !matches!(getter, "text" | "html" | "value") {
return Err(format!(
"extract field '{name}' has an invalid getter \"{getter}\" — {GETTER_GRAMMAR}"
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(args: &[String]) -> Vec<&str> {
args.iter().map(String::as_str).collect()
}
#[test]
fn wait_args_forwards_timeout_for_every_target() {
let cases = [
(WaitTarget::Selector("#main".into()), vec!["wait", "#main"]),
(
WaitTarget::Url("https://example.com".into()),
vec!["wait", "--url", "https://example.com"],
),
(
WaitTarget::Text("Loaded".into()),
vec!["wait", "--text", "Loaded"],
),
];
for (target, mut expected) in cases {
expected.extend(["--timeout", "8000"]);
assert_eq!(argv(&wait_args(&target, 8000)), expected);
}
}
#[test]
fn expect_args_covers_every_variant() {
let cases = [
(
ExpectCond::State {
selector: "#main".into(),
state: "visible",
},
vec!["expect", "#main", "visible"],
),
(
ExpectCond::Count {
selector: ".card".into(),
op: "==".into(),
n: 3,
},
vec!["expect", "count", ".card", "==", "3"],
),
(
ExpectCond::Text {
selector: "#main".into(),
predicate: "contains",
value: "hello".into(),
},
vec!["expect", "text", "#main", "contains", "hello"],
),
(
ExpectCond::Value {
selector: "input".into(),
predicate: "equals",
value: "abc".into(),
},
vec!["expect", "value", "input", "equals", "abc"],
),
(
ExpectCond::Attr {
selector: "a".into(),
name: "href".into(),
predicate: "matches",
value: "example.com".into(),
},
vec!["expect", "attr", "a", "href", "matches", "example.com"],
),
(
ExpectCond::Url {
predicate: "contains",
pattern: "dashboard".into(),
},
vec!["expect", "url", "contains", "dashboard"],
),
];
for (cond, mut expected) in cases {
expected.extend(["--timeout", "8000"]);
assert_eq!(argv(&expect_args(&cond, 8000)), expected);
}
}
#[test]
fn parse_state_accepts_and_rejects() {
assert_eq!(parse_state("visible"), Some("visible"));
assert_eq!(parse_state("hidden"), Some("hidden"));
assert_eq!(parse_state("present"), Some("present"));
assert_eq!(parse_state("gone"), None);
assert_eq!(parse_state("VISIBLE"), None);
assert_eq!(parse_state(""), None);
}
#[test]
fn parse_predicate_accepts_and_rejects() {
assert_eq!(parse_predicate("equals"), Some("equals"));
assert_eq!(parse_predicate("contains"), Some("contains"));
assert_eq!(parse_predicate("matches"), Some("matches"));
assert_eq!(parse_predicate("regex"), None);
assert_eq!(parse_predicate("EQUALS"), None);
assert_eq!(parse_predicate(""), None);
}
#[test]
fn parse_count_op_accepts_symbols_and_words() {
for op in [
"==", "!=", ">", "<", ">=", "<=", "eq", "ne", "gt", "lt", "ge", "le",
] {
assert_eq!(
parse_count_op(op).as_deref(),
Some(op),
"op {op:?} should parse"
);
}
}
#[test]
fn parse_count_op_rejects_unknowns() {
for op in [">>", "=", "like", " between ", ""] {
assert_eq!(parse_count_op(op), None, "op {op:?} should be rejected");
}
}
#[test]
fn wait_target_rejects_numeric_and_multiple_targets() {
assert_eq!(
wait_target(Some("#x"), None, None),
Ok(WaitTarget::Selector("#x".into()))
);
assert_eq!(
wait_target(None, Some("dash"), None),
Ok(WaitTarget::Url("dash".into()))
);
assert_eq!(
wait_target(None, None, Some("hi")),
Ok(WaitTarget::Text("hi".into()))
);
let err = wait_target(Some("5000"), None, None).unwrap_err();
assert!(err.contains("silent sleep"), "err: {err}");
assert!(wait_target(Some("#x"), Some("dash"), None).is_err());
assert!(wait_target(None, None, None).is_err());
assert!(wait_target(Some("#x"), Some("d"), Some("t")).is_err());
}
#[test]
fn count_eval_js_escapes_single_quotes_and_backslashes() {
assert_eq!(
count_eval_js("a'b"),
"document.querySelectorAll('a\\'b').length"
);
assert_eq!(
count_eval_js("a\\b"),
"document.querySelectorAll('a\\\\b').length"
);
assert_eq!(
count_eval_js("div.c"),
"document.querySelectorAll('div.c').length"
);
}
#[test]
fn validate_extract_getters_accepts_documented_grammar() {
let schema = serde_json::json!({
"rows": ".card",
"fields": {
"t": {"sel": ".a", "get": "text"},
"h": {"sel": ".b", "get": "html"},
"v": {"sel": ".c", "get": "value"},
"u": {"sel": "a", "get": "@href"},
"plain": ".title",
},
});
assert!(
validate_extract_getters(&schema).is_ok(),
"doc grammar passes"
);
let no_get = serde_json::json!({"fields": {"title": {"sel": ".title"}}});
assert!(validate_extract_getters(&no_get).is_ok());
}
#[test]
fn validate_extract_getters_rejects_bad_getters() {
let err = validate_extract_getters(&serde_json::json!({
"fields": {"link": {"sel": "a", "get": "href"}}
}))
.unwrap_err();
assert!(err.contains('@'), "err: {err}");
assert!(err.contains("getter"), "err: {err}");
assert!(err.contains("href"), "err: {err}");
let err = validate_extract_getters(&serde_json::json!({
"fields": {"u": {"sel": "a", "get": "@"}}
}))
.unwrap_err();
assert!(err.contains("empty getter"), "err: {err}");
let err = validate_extract_getters(&serde_json::json!({
"fields": {"u": {"sel": "a", "get": 42}}
}))
.unwrap_err();
assert!(err.contains("non-string getter"), "err: {err}");
}
#[test]
fn validate_extract_getters_ignores_non_object_fields_and_shapes() {
let schema = serde_json::json!({"fields": {"title": ".title", "url": "a"}});
assert!(validate_extract_getters(&schema).is_ok());
assert!(validate_extract_getters(&serde_json::json!({"rows": ".card"})).is_ok());
assert!(validate_extract_getters(&serde_json::json!({"fields": ".title"})).is_ok());
}
}