use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Param {
#[serde(default = "yes")]
pub required: bool,
#[serde(default)]
pub secret: bool,
}
const fn yes() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Step {
#[serde(rename = "do")]
pub action: Value,
#[serde(default)]
pub expect: Guards,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unguarded: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Guards {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delivery: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verdict: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbatim: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url_matches: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_contains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exists: Option<Exists>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Exists {
pub selector: String,
#[serde(default = "one")]
pub min: usize,
}
const fn one() -> usize {
1
}
impl Guards {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.delivery.is_none()
&& self.verdict.is_none()
&& self.verbatim.is_none()
&& self.url_matches.is_none()
&& self.text_contains.is_none()
&& self.exists.is_none()
}
#[must_use]
pub fn response_guards(&self) -> Vec<(&'static str, String)> {
let mut out = Vec::new();
if let Some(delivery) = &self.delivery {
out.push(("delivery", delivery.clone()));
}
if let Some(verdict) = &self.verdict {
out.push(("verdict", verdict.clone()));
}
if let Some(verbatim) = self.verbatim {
out.push(("verbatim", verbatim.to_string()));
}
out
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Macro {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub site: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recorded_at: Option<String>,
#[serde(default)]
pub params: BTreeMap<String, Param>,
pub steps: Vec<Step>,
}
pub fn check_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("A macro name cannot be empty.".into());
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(format!(
"Invalid macro name '{name}': use letters, digits, '-' and '_' only. The name is \
the file name under ~/.chrome-agent/macros, so a path separator is refused rather \
than rewritten."
));
}
Ok(())
}
#[must_use]
pub fn store_dir() -> PathBuf {
let home = std::env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from);
home.join(".chrome-agent").join("macros")
}
#[must_use]
pub fn path_of(name: &str) -> PathBuf {
store_dir().join(format!("{name}.json"))
}
impl Macro {
pub fn load(name: &str) -> Result<Self, crate::BoxError> {
check_name(name)?;
let path = path_of(name);
let text = std::fs::read_to_string(&path).map_err(|e| {
format!(
"No macro named '{name}' ({}): {e}. `macro list` shows what exists.",
path.display()
)
})?;
Self::parse(&text)
}
pub fn parse(text: &str) -> Result<Self, crate::BoxError> {
let parsed: Self = serde_json::from_str(text)
.map_err(|e| format!("Not a usable macro file: {e}"))?;
check_name(&parsed.name)?;
if parsed.steps.is_empty() {
return Err("This macro has no steps.".into());
}
Ok(parsed)
}
pub fn save(&self) -> Result<PathBuf, crate::BoxError> {
check_name(&self.name)?;
let dir = store_dir();
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Cannot create {}: {e}", dir.display()))?;
let path = path_of(&self.name);
let text = serde_json::to_string_pretty(self)?;
std::fs::write(&path, text).map_err(|e| format!("Cannot write {}: {e}", path.display()))?;
restrict(&path);
Ok(path)
}
pub fn bind(&self, vars: &BTreeMap<String, String>) -> Result<(), crate::BoxError> {
let mut missing: Vec<&str> = Vec::new();
for (name, param) in &self.params {
if param.required && !vars.contains_key(name) {
missing.push(name);
}
}
if missing.is_empty() {
return Ok(());
}
let secret: Vec<&str> = missing
.iter()
.copied()
.filter(|name| self.params.get(*name).is_some_and(|p| p.secret))
.collect();
let mut message = format!(
"This macro needs {}: {}.",
if missing.len() == 1 { "a value" } else { "values" },
missing.join(", ")
);
if !secret.is_empty() {
message.push_str(&format!(
" {} declared secret, so {} never stored in the file and there is nothing to \
fall back on.",
secret.join(", "),
if secret.len() == 1 { "it is" } else { "they are" }
));
}
message.push_str(&format!(
" Pass {}: {}.",
if missing.len() == 1 { "it" } else { "them" },
missing.iter().map(|name| format!("--var {name}=…")).collect::<Vec<_>>().join(" ")
));
Err(message.into())
}
pub fn resolve(&self, step: &Step, vars: &BTreeMap<String, String>) -> Result<Value, crate::BoxError> {
let mut text = serde_json::to_string(&step.action)?;
for (key, value) in vars {
let escaped = serde_json::to_string(value)?;
let inner = escaped.trim_matches('"');
text = text.replace(&format!("{{{{{key}}}}}"), inner);
}
let resolved: Value = serde_json::from_str(&text)?;
if let Some(left) = unresolved_placeholder(&text) {
return Err(format!(
"Step still carries {{{{{left}}}}} after substitution: pass --var {left}=… . A \
macro never runs with a placeholder in it — the page would receive the braces."
)
.into());
}
Ok(resolved)
}
}
fn unresolved_placeholder(text: &str) -> Option<String> {
let start = text.find("{{")?;
let rest = &text[start + 2..];
let end = rest.find("}}")?;
Some(rest[..end].to_string())
}
pub fn list() -> Vec<String> {
let Ok(entries) = std::fs::read_dir(store_dir()) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
path.file_stem().map(|stem| stem.to_string_lossy().into_owned())
} else {
None
}
})
.collect();
names.sort();
names
}
#[must_use]
pub fn summary(name: &str) -> Value {
match Macro::load(name) {
Ok(macro_file) => {
let unguarded = macro_file.steps.iter().filter(|s| s.expect.is_empty()).count();
json!({
"name": macro_file.name,
"site": macro_file.site,
"steps": macro_file.steps.len(),
"unguarded_steps": unguarded,
"params": macro_file.params.keys().collect::<Vec<_>>(),
})
}
Err(e) => json!({"name": name, "error": e.to_string()}),
}
}
fn restrict(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
let _ = path;
}
#[cfg(test)]
mod tests {
use super::*;
fn vars(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect()
}
const CANCEL: &str = r##"{
"name": "cancel-subscription",
"site": "example.com",
"params": {"email": {"required": true, "secret": false},
"password": {"required": true, "secret": true}},
"steps": [
{"do": {"cmd": "goto", "url": "https://example.com/account"},
"expect": {"url_matches": "/account"}},
{"do": {"cmd": "fill", "selector": "#email", "value": "{{email}}"},
"expect": {"verbatim": true}}
]
}"##;
#[test]
fn a_macro_round_trips_through_the_file_it_declares() {
let parsed = Macro::parse(CANCEL).expect("parses");
assert_eq!(parsed.steps.len(), 2);
assert!(parsed.params["password"].secret);
assert_eq!(parsed.steps[0].expect.url_matches.as_deref(), Some("/account"));
let again = Macro::parse(&serde_json::to_string(&parsed).unwrap()).expect("re-parses");
assert_eq!(parsed, again);
}
#[test]
fn an_unknown_guard_is_refused_rather_than_dropped() {
let text = r#"{"name":"x","steps":[{"do":{"cmd":"click"},"expect":{"added":450}}]}"#;
let error = Macro::parse(text).expect_err("must refuse").to_string();
assert!(error.contains("added"), "the refusal names the field: {error}");
}
#[test]
fn a_macro_with_no_steps_is_not_a_macro() {
assert!(Macro::parse(r#"{"name":"x","steps":[]}"#).is_err());
}
#[test]
fn a_name_that_leaves_the_store_is_refused_not_sanitised() {
assert!(check_name("cancel-subscription").is_ok());
assert!(check_name("../../etc/passwd").is_err());
assert!(check_name("a/b").is_err());
assert!(check_name("").is_err());
let error = check_name("a b").expect_err("space");
assert!(error.contains("file name"), "{error}");
}
#[test]
fn a_missing_secret_says_why_it_cannot_be_defaulted() {
let parsed = Macro::parse(CANCEL).unwrap();
let error = parsed
.bind(&vars(&[("email", "ada@example.com")]))
.expect_err("password is missing")
.to_string();
assert!(error.contains("password"), "{error}");
assert!(error.contains("never stored"), "{error}");
assert!(error.contains("--var password="), "{error}");
assert!(parsed.bind(&vars(&[("email", "a@b.c"), ("password", "hunter2")])).is_ok());
}
#[test]
fn substitution_puts_the_value_inside_the_json_string_it_replaces() {
let parsed = Macro::parse(CANCEL).unwrap();
let resolved = parsed
.resolve(&parsed.steps[1], &vars(&[("email", "ada@example.com")]))
.expect("resolves");
assert_eq!(resolved["value"], "ada@example.com");
let awkward = parsed
.resolve(&parsed.steps[1], &vars(&[("email", "a\"b\\c")]))
.expect("stays valid JSON");
assert_eq!(awkward["value"], "a\"b\\c");
}
#[test]
fn a_placeholder_nobody_bound_stops_the_step() {
let parsed = Macro::parse(CANCEL).unwrap();
let error = parsed
.resolve(&parsed.steps[1], &BTreeMap::new())
.expect_err("must refuse")
.to_string();
assert!(error.contains("email"), "{error}");
assert!(error.contains("--var email="), "{error}");
}
#[test]
fn an_empty_expect_is_the_shape_that_promises_nothing() {
assert!(Guards::default().is_empty());
let guards = Guards { verdict: Some("changed".into()), ..Guards::default() };
assert!(!guards.is_empty());
assert_eq!(guards.response_guards(), vec![("verdict", "changed".to_string())]);
}
}