use crate::AutomationUsage;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ChainStep {
pub instruction: String,
pub condition: Option<ChainCondition>,
pub continue_on_failure: bool,
pub extract: Option<String>,
pub timeout_ms: Option<u64>,
pub max_retries: Option<usize>,
}
impl ChainStep {
pub fn new(instruction: impl Into<String>) -> Self {
Self {
instruction: instruction.into(),
condition: None,
continue_on_failure: false,
extract: None,
timeout_ms: None,
max_retries: None,
}
}
pub fn when(mut self, condition: ChainCondition) -> Self {
self.condition = Some(condition);
self
}
pub fn allow_failure(mut self) -> Self {
self.continue_on_failure = true;
self
}
pub fn then_extract(mut self, prompt: impl Into<String>) -> Self {
self.extract = Some(prompt.into());
self
}
pub fn with_timeout(mut self, ms: u64) -> Self {
self.timeout_ms = Some(ms);
self
}
pub fn with_retries(mut self, retries: usize) -> Self {
self.max_retries = Some(retries);
self
}
pub fn should_execute(&self, context: &ChainContext) -> bool {
match &self.condition {
None => true,
Some(condition) => condition.evaluate(context),
}
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub enum ChainCondition {
UrlContains(String),
UrlMatches(String),
PageContains(String),
ElementExists(String),
PreviousSucceeded,
PreviousFailed,
#[default]
Always,
Never,
All(Vec<ChainCondition>),
Any(Vec<ChainCondition>),
Not(Box<ChainCondition>),
}
impl std::ops::Not for ChainCondition {
type Output = Self;
fn not(self) -> Self::Output {
Self::Not(Box::new(self))
}
}
impl ChainCondition {
pub fn url_contains(pattern: impl Into<String>) -> Self {
Self::UrlContains(pattern.into())
}
pub fn element_exists(selector: impl Into<String>) -> Self {
Self::ElementExists(selector.into())
}
pub fn page_contains(text: impl Into<String>) -> Self {
Self::PageContains(text.into())
}
#[allow(clippy::should_implement_trait)]
pub fn not(self) -> Self {
std::ops::Not::not(self)
}
pub fn and(self, other: ChainCondition) -> Self {
match self {
Self::All(mut conditions) => {
conditions.push(other);
Self::All(conditions)
}
_ => Self::All(vec![self, other]),
}
}
pub fn or(self, other: ChainCondition) -> Self {
match self {
Self::Any(mut conditions) => {
conditions.push(other);
Self::Any(conditions)
}
_ => Self::Any(vec![self, other]),
}
}
pub fn evaluate(&self, ctx: &ChainContext) -> bool {
match self {
Self::Always => true,
Self::Never => false,
Self::UrlContains(pattern) => ctx.current_url.contains(pattern),
Self::UrlMatches(pattern) => {
simple_glob_match(pattern, &ctx.current_url)
}
Self::PageContains(text) => ctx.page_text.contains(text),
Self::ElementExists(selector) => ctx.existing_selectors.contains(selector),
Self::PreviousSucceeded => ctx.previous_succeeded,
Self::PreviousFailed => !ctx.previous_succeeded,
Self::Not(inner) => !inner.evaluate(ctx),
Self::All(conditions) => conditions.iter().all(|c| c.evaluate(ctx)),
Self::Any(conditions) => conditions.iter().any(|c| c.evaluate(ctx)),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ChainContext {
pub current_url: String,
pub page_text: String,
pub existing_selectors: Vec<String>,
pub previous_succeeded: bool,
pub step_index: usize,
}
impl ChainContext {
pub fn new(url: impl Into<String>) -> Self {
Self {
current_url: url.into(),
previous_succeeded: true, ..Default::default()
}
}
pub fn with_url(mut self, url: impl Into<String>) -> Self {
self.current_url = url.into();
self
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.page_text = text.into();
self
}
pub fn add_selector(&mut self, selector: impl Into<String>) {
self.existing_selectors.push(selector.into());
}
pub fn set_previous_result(&mut self, succeeded: bool) {
self.previous_succeeded = succeeded;
}
pub fn advance(&mut self) {
self.step_index += 1;
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ChainResult {
pub success: bool,
pub steps_executed: usize,
pub steps_succeeded: usize,
pub steps_failed: usize,
pub steps_skipped: usize,
pub step_results: Vec<ChainStepResult>,
#[serde(default)]
pub extractions: Vec<serde_json::Value>,
pub duration_ms: u64,
#[serde(default)]
pub total_usage: AutomationUsage,
pub final_url: Option<String>,
pub error: Option<String>,
}
impl ChainResult {
pub fn new() -> Self {
Self::default()
}
pub fn add_step(&mut self, result: ChainStepResult) {
if result.executed {
self.steps_executed += 1;
if result.success {
self.steps_succeeded += 1;
} else {
self.steps_failed += 1;
}
} else {
self.steps_skipped += 1;
}
if let Some(ref extracted) = result.extracted {
self.extractions.push(extracted.clone());
}
self.duration_ms += result.duration_ms;
self.total_usage.accumulate(&result.usage);
self.step_results.push(result);
}
pub fn complete(mut self, success: bool) -> Self {
self.success = success;
self
}
pub fn with_final_url(mut self, url: impl Into<String>) -> Self {
self.final_url = Some(url.into());
self
}
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self.success = false;
self
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ChainStepResult {
pub index: usize,
pub instruction: String,
pub executed: bool,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub action_taken: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub extracted: Option<serde_json::Value>,
#[serde(default)]
pub usage: AutomationUsage,
pub retries: usize,
}
impl ChainStepResult {
pub fn executed(index: usize, instruction: impl Into<String>, success: bool) -> Self {
Self {
index,
instruction: instruction.into(),
executed: true,
success,
..Default::default()
}
}
pub fn skipped(index: usize, instruction: impl Into<String>) -> Self {
Self {
index,
instruction: instruction.into(),
executed: false,
success: false, ..Default::default()
}
}
pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.action_taken = Some(action.into());
self
}
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self.success = false;
self
}
pub fn with_duration(mut self, ms: u64) -> Self {
self.duration_ms = ms;
self
}
pub fn with_extracted(mut self, data: serde_json::Value) -> Self {
self.extracted = Some(data);
self
}
pub fn with_usage(mut self, usage: AutomationUsage) -> Self {
self.usage = usage;
self
}
pub fn with_retries(mut self, retries: usize) -> Self {
self.retries = retries;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ChainBuilder {
steps: Vec<ChainStep>,
}
impl ChainBuilder {
pub fn new() -> Self {
Self { steps: Vec::new() }
}
pub fn step(mut self, step: ChainStep) -> Self {
self.steps.push(step);
self
}
pub fn then(mut self, instruction: impl Into<String>) -> Self {
self.steps.push(ChainStep::new(instruction));
self
}
pub fn when(mut self, condition: ChainCondition, instruction: impl Into<String>) -> Self {
self.steps.push(ChainStep::new(instruction).when(condition));
self
}
pub fn build(self) -> Vec<ChainStep> {
self.steps
}
}
fn simple_glob_match(pattern: &str, text: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == text;
}
let mut pos = 0;
if !parts[0].is_empty() {
if !text.starts_with(parts[0]) {
return false;
}
pos = parts[0].len();
}
for part in &parts[1..parts.len() - 1] {
if part.is_empty() {
continue;
}
if let Some(found) = text[pos..].find(part) {
pos += found + part.len();
} else {
return false;
}
}
let last = parts[parts.len() - 1];
if !last.is_empty() {
text[pos..].ends_with(last)
} else {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chain_step() {
let step = ChainStep::new("Click login button")
.when(ChainCondition::element_exists("button.login"))
.allow_failure()
.then_extract("Extract user info");
assert!(step.continue_on_failure);
assert!(step.extract.is_some());
assert!(step.condition.is_some());
}
#[test]
fn test_chain_condition_evaluate() {
let ctx = ChainContext::new("https://example.com/dashboard").with_text("Welcome, user!");
assert!(ChainCondition::url_contains("dashboard").evaluate(&ctx));
assert!(ChainCondition::page_contains("Welcome").evaluate(&ctx));
assert!(!ChainCondition::url_contains("login").evaluate(&ctx));
}
#[test]
fn test_chain_condition_compound() {
let ctx = ChainContext::new("https://example.com/dashboard");
let condition =
ChainCondition::url_contains("example").and(ChainCondition::url_contains("dashboard"));
assert!(condition.evaluate(&ctx));
let condition =
ChainCondition::url_contains("login").or(ChainCondition::url_contains("dashboard"));
assert!(condition.evaluate(&ctx));
}
#[test]
fn test_chain_result() {
let mut result = ChainResult::new();
result.add_step(ChainStepResult::executed(0, "Step 1", true).with_duration(100));
result.add_step(ChainStepResult::skipped(1, "Step 2"));
result.add_step(ChainStepResult::executed(2, "Step 3", false).with_error("Failed"));
assert_eq!(result.steps_executed, 2);
assert_eq!(result.steps_succeeded, 1);
assert_eq!(result.steps_failed, 1);
assert_eq!(result.steps_skipped, 1);
}
#[test]
fn test_chain_builder() {
let chain = ChainBuilder::new()
.then("Navigate to login")
.then("Enter credentials")
.when(
ChainCondition::element_exists("button.submit"),
"Click submit",
)
.build();
assert_eq!(chain.len(), 3);
assert!(chain[2].condition.is_some());
}
}