Skip to main content

eoka_runner/config/
schema.rs

1use super::params::{self, ParamDef, Params};
2use super::Action;
3use crate::{Error, Result};
4use serde::de::{self, MapAccess, Visitor};
5use serde::{Deserialize, Deserializer};
6use std::collections::HashMap;
7use std::fmt;
8use std::path::Path;
9
10/// Top-level config structure.
11#[derive(Debug, Clone, Deserialize)]
12pub struct Config {
13    /// Name of this automation config.
14    pub name: String,
15
16    /// Parameter definitions (optional).
17    #[serde(default)]
18    pub params: HashMap<String, ParamDef>,
19
20    /// Browser configuration.
21    #[serde(default)]
22    pub browser: BrowserConfig,
23
24    /// Target URL to navigate to.
25    pub target: TargetUrl,
26
27    /// List of actions to execute.
28    #[serde(default)]
29    pub actions: Vec<Action>,
30
31    /// Success conditions (optional).
32    pub success: Option<SuccessCondition>,
33
34    /// Failure handling (optional).
35    pub on_failure: Option<OnFailure>,
36}
37
38impl Config {
39    /// Load config from a YAML file.
40    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
41        let content = std::fs::read_to_string(path.as_ref())?;
42        Self::parse_with_params(&content, &Params::new())
43    }
44
45    /// Load config from a YAML file with parameters.
46    pub fn load_with_params<P: AsRef<Path>>(path: P, params: &Params) -> Result<Self> {
47        let content = std::fs::read_to_string(path.as_ref())?;
48        Self::parse_with_params(&content, params)
49    }
50
51    /// Parse config from YAML string (no params).
52    pub fn parse(yaml: &str) -> Result<Self> {
53        Self::parse_with_params(yaml, &Params::new())
54    }
55
56    /// Parse config from YAML string with parameter substitution.
57    pub fn parse_with_params(yaml: &str, params: &Params) -> Result<Self> {
58        // First pass: parse as Value to extract param definitions
59        let mut value: serde_yaml::Value = serde_yaml::from_str(yaml)?;
60
61        // Extract param definitions if present
62        let defs: HashMap<String, ParamDef> = value
63            .get("params")
64            .and_then(|v| serde_yaml::from_value(v.clone()).ok())
65            .unwrap_or_default();
66
67        // Substitute variables in the entire config
68        params::substitute_value(&mut value, params, &defs)?;
69
70        // Now deserialize the substituted config
71        let config: Config = serde_yaml::from_value(value)?;
72        config.validate()?;
73        Ok(config)
74    }
75
76    /// Validate the config.
77    fn validate(&self) -> Result<()> {
78        if self.name.is_empty() {
79            return Err(Error::Config("name is required".into()));
80        }
81        if self.target.url.is_empty() {
82            return Err(Error::Config("target.url is required".into()));
83        }
84        if let Some(ref success) = self.success {
85            if success.any.is_some() && success.all.is_some() {
86                return Err(Error::Config(
87                    "success: specify either 'any' or 'all', not both".into(),
88                ));
89            }
90        }
91        if let Some(ref on_failure) = self.on_failure {
92            if let Some(ref retry) = on_failure.retry {
93                if retry.attempts == 0 {
94                    return Err(Error::Config(
95                        "on_failure.retry.attempts must be at least 1".into(),
96                    ));
97                }
98            }
99        }
100        Ok(())
101    }
102}
103
104/// Browser launch configuration.
105#[derive(Debug, Clone, Deserialize, Default)]
106pub struct BrowserConfig {
107    /// Run in headless mode.
108    #[serde(default)]
109    pub headless: bool,
110
111    /// Proxy URL (e.g., "http://user:pass@host:port").
112    pub proxy: Option<String>,
113
114    /// Custom user agent.
115    pub user_agent: Option<String>,
116
117    /// Viewport size.
118    pub viewport: Option<Viewport>,
119}
120
121/// Viewport dimensions.
122#[derive(Debug, Clone, Deserialize)]
123pub struct Viewport {
124    pub width: u32,
125    pub height: u32,
126}
127
128/// Target URL configuration.
129#[derive(Debug, Clone, Deserialize)]
130pub struct TargetUrl {
131    /// URL to navigate to.
132    pub url: String,
133}
134
135/// Success condition checking.
136#[derive(Debug, Clone, Deserialize)]
137pub struct SuccessCondition {
138    /// Any of these conditions must be true.
139    pub any: Option<Vec<Condition>>,
140
141    /// All of these conditions must be true.
142    pub all: Option<Vec<Condition>>,
143}
144
145/// Individual condition.
146#[derive(Debug, Clone)]
147pub enum Condition {
148    UrlContains(String),
149    TextContains(String),
150}
151
152impl<'de> Deserialize<'de> for Condition {
153    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
154    where
155        D: Deserializer<'de>,
156    {
157        deserializer.deserialize_map(ConditionVisitor)
158    }
159}
160
161struct ConditionVisitor;
162
163impl<'de> Visitor<'de> for ConditionVisitor {
164    type Value = Condition;
165
166    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
167        formatter.write_str("a condition map with single key (url_contains or text_contains)")
168    }
169
170    fn visit_map<M>(self, mut map: M) -> std::result::Result<Self::Value, M::Error>
171    where
172        M: MapAccess<'de>,
173    {
174        let key: String = map
175            .next_key()?
176            .ok_or_else(|| de::Error::custom("expected condition type key"))?;
177
178        match key.as_str() {
179            "url_contains" => Ok(Condition::UrlContains(map.next_value()?)),
180            "text_contains" => Ok(Condition::TextContains(map.next_value()?)),
181            other => Err(de::Error::unknown_variant(
182                other,
183                &["url_contains", "text_contains"],
184            )),
185        }
186    }
187}
188
189/// Failure handling configuration.
190#[derive(Debug, Clone, Deserialize)]
191pub struct OnFailure {
192    /// Screenshot path on failure (supports {timestamp}).
193    pub screenshot: Option<String>,
194
195    /// Retry configuration.
196    pub retry: Option<RetryConfig>,
197}
198
199/// Retry configuration.
200#[derive(Debug, Clone, Deserialize)]
201pub struct RetryConfig {
202    /// Number of retry attempts.
203    pub attempts: u32,
204
205    /// Delay between retries in milliseconds.
206    pub delay_ms: u64,
207}