use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct TestToml {
#[serde(default)]
pub test: Option<TestMeta>,
#[serde(default)]
pub setup: Option<SetupSection>,
#[serde(default)]
pub tests: Vec<TestDef>,
#[serde(default)]
pub steps: Vec<StepDef>,
}
#[derive(Debug, Deserialize)]
pub struct TestMeta {
pub name: Option<String>,
#[serde(default)]
pub browser: bool,
pub ram: Option<u32>,
#[serde(default)]
pub requires_sudo: bool,
}
#[derive(Debug, Deserialize)]
pub struct SetupSection {
#[serde(default)]
pub services: Vec<String>,
#[serde(default)]
pub quadlets: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TestDef {
pub name: String,
#[serde(default)]
pub run: Option<String>,
#[serde(default)]
pub steps: Vec<StepDef>,
#[serde(default = "default_timeout")]
pub timeout: u64,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub browser: bool,
pub ram: Option<u32>,
#[serde(default)]
pub requires_sudo: bool,
}
fn default_timeout() -> u64 {
30
}
fn default_add_timeout() -> u64 {
300
}
fn default_http_status() -> u16 {
200
}
fn default_content_type() -> String {
"application/json".into()
}
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum HttpMethod {
#[default]
Get,
Post,
Put,
Delete,
}
impl HttpMethod {
pub fn as_curl_arg(self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Delete => "DELETE",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct PollConfig {
pub interval: u64,
pub attempts: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "action", rename_all = "lowercase")]
pub enum StepDef {
Add {
service: String,
#[serde(default)]
args: Option<String>,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default = "default_add_timeout")]
timeout: u64,
#[serde(skip)]
project_path: Option<std::path::PathBuf>,
},
Remove {
service: String,
},
Wait {
service: String,
#[serde(default = "default_timeout")]
timeout: u64,
},
Shell {
name: String,
run: String,
#[serde(default = "default_timeout")]
timeout: u64,
#[serde(default)]
poll: Option<PollConfig>,
},
Http {
#[serde(default)]
name: Option<String>,
url: String,
#[serde(default)]
method: HttpMethod,
#[serde(default)]
body: Option<String>,
#[serde(default = "default_content_type")]
content_type: String,
#[serde(default)]
headers: BTreeMap<String, String>,
#[serde(default = "default_http_status")]
status: u16,
#[serde(default)]
service: Option<String>,
#[serde(default)]
poll: Option<PollConfig>,
#[serde(default = "default_timeout")]
timeout: u64,
},
Playwright {
#[serde(default)]
name: Option<String>,
spec: String,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default = "default_browser_timeout")]
timeout: u64,
},
Mail {
#[serde(default)]
name: Option<String>,
mailbox: String,
#[serde(default)]
contains: Option<String>,
#[serde(default = "default_mail_poll")]
poll: PollConfig,
#[serde(default = "default_timeout")]
timeout: u64,
},
}
fn default_mail_poll() -> PollConfig {
PollConfig {
interval: 2,
attempts: 30,
}
}
fn default_browser_timeout() -> u64 {
120
}
impl std::fmt::Display for StepDef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StepDef::Add { service, .. } => write!(f, "add {service}"),
StepDef::Remove { service } => write!(f, "remove {service}"),
StepDef::Wait { service, .. } => write!(f, "wait {service}"),
StepDef::Shell { name, .. } => write!(f, "shell: {name}"),
StepDef::Http { name, url, .. } => {
write!(f, "http: {}", name.as_deref().unwrap_or(url))
}
StepDef::Playwright { name, spec, .. } => {
write!(f, "browser: {}", name.as_deref().unwrap_or(spec))
}
StepDef::Mail { name, mailbox, .. } => {
write!(f, "mail: {}", name.as_deref().unwrap_or(mailbox))
}
}
}
}
impl StepDef {
pub fn service(&self) -> Option<&str> {
match self {
StepDef::Add { service, .. }
| StepDef::Remove { service }
| StepDef::Wait { service, .. } => Some(service),
_ => None,
}
}
pub fn is_setup(&self) -> bool {
matches!(
self,
StepDef::Add { .. } | StepDef::Remove { .. } | StepDef::Wait { .. }
)
}
pub fn step_name(&self) -> String {
format!("{self}")
}
pub fn describe(&self) -> Vec<String> {
let mut lines = Vec::new();
match self {
StepDef::Add {
service,
args,
env,
timeout,
..
} => {
let args_s = args
.as_deref()
.filter(|s| !s.is_empty())
.map(|a| format!(" {a}"))
.unwrap_or_default();
lines.push(format!("ryra add {service}{args_s} (timeout={timeout}s)"));
for (k, v) in env {
lines.push(format!(" env {k}={v}"));
}
}
StepDef::Remove { service } => lines.push(format!("ryra remove --purge {service}")),
StepDef::Wait { service, timeout } => {
lines.push(format!("wait for {service}.service (timeout={timeout}s)"));
}
StepDef::Shell {
name,
run,
timeout,
poll,
} => {
let poll_s = match poll {
Some(p) => {
format!(
" poll={{interval={}s, attempts={}}}",
p.interval, p.attempts
)
}
None => String::new(),
};
lines.push(format!("shell '{name}' (timeout={timeout}s{poll_s})"));
for l in run.trim().lines() {
lines.push(format!(" | {l}"));
}
}
StepDef::Http {
name,
url,
method,
body,
content_type,
headers,
status,
service,
poll,
timeout,
} => {
let label = name.as_deref().unwrap_or("(anon)");
let verb = method.as_curl_arg();
lines.push(format!(
"http '{label}': {verb} {url} (expect {status}, timeout={timeout}s)"
));
if let Some(svc) = service {
lines.push(format!(" env-source: {svc}/.env"));
}
for (k, v) in headers {
lines.push(format!(" header {k}: {v}"));
}
if let Some(b) = body {
lines.push(format!(" content-type: {content_type}"));
for l in b.trim().lines() {
lines.push(format!(" body> {l}"));
}
}
if let Some(p) = poll {
lines.push(format!(
" poll: every {}s, up to {} attempts",
p.interval, p.attempts
));
}
}
StepDef::Playwright {
name,
spec,
env,
timeout,
} => {
let label = name.as_deref().unwrap_or(spec);
lines.push(format!(
"playwright '{label}': spec={spec} (timeout={timeout}s)"
));
for (k, v) in env {
lines.push(format!(" env {k}={v}"));
}
}
StepDef::Mail {
name,
mailbox,
contains,
poll,
timeout,
} => {
let label = name.as_deref().unwrap_or(mailbox);
lines.push(format!(
"mail '{label}': mailbox={mailbox} (timeout={timeout}s)"
));
if let Some(c) = contains {
lines.push(format!(" contains: {c}"));
}
lines.push(format!(
" poll: every {}s, up to {} attempts",
poll.interval, poll.attempts
));
}
}
lines
}
}
impl TestToml {
pub fn parse(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read test.toml at {}", path.display()))?;
let parsed: Self = toml::from_str(&content)
.with_context(|| format!("failed to parse test.toml at {}", path.display()))?;
parsed.validate(path)?;
Ok(parsed)
}
pub fn validate(&self, path: &Path) -> Result<()> {
let ctx = path.display();
let has_legacy_run_tests = self
.tests
.iter()
.any(|t| t.run.is_some() && t.steps.is_empty());
if has_legacy_run_tests && !self.steps.is_empty() {
anyhow::bail!(
"{ctx}: test.toml cannot mix [setup]+[[tests]] (legacy shell) with top-level [[steps]] — \
migrate to the new [[tests]] + [[tests.steps]] format instead",
);
}
for t in &self.tests {
let has_run = t.run.is_some();
let has_steps = !t.steps.is_empty();
if has_run == has_steps {
anyhow::bail!(
"{ctx}: test '{}' must set exactly one of `run` or `steps` \
(got run={}, steps={})",
t.name,
has_run,
has_steps,
);
}
}
Ok(())
}
pub fn is_lifecycle(&self) -> bool {
!self.steps.is_empty()
}
pub fn needs_browser(&self) -> bool {
self.test.as_ref().is_some_and(|t| t.browser)
}
pub fn ram_override(&self) -> Option<u32> {
self.test.as_ref().and_then(|t| t.ram)
}
pub fn requires_sudo(&self) -> bool {
self.test.as_ref().is_some_and(|t| t.requires_sudo)
}
pub fn name_or_default(&self, path: &Path) -> String {
if let Some(ref meta) = self.test
&& let Some(ref name) = meta.name
{
return name.clone();
}
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string()
}
pub fn referenced_services(&self) -> Vec<String> {
let mut services: Vec<String> = self
.setup
.as_ref()
.map_or_else(Vec::new, |s| s.services.clone());
for step in &self.steps {
if let StepDef::Add { service, .. } = step
&& !services.contains(service)
{
services.push(service.clone());
}
}
services
}
pub fn quadlet_files(&self) -> Vec<String> {
self.setup
.as_ref()
.map_or_else(Vec::new, |s| s.quadlets.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn write_temp(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("test.toml");
let mut f = std::fs::File::create(&path).expect("create");
f.write_all(content.as_bytes()).expect("write");
(dir, path)
}
#[test]
fn reject_mixed_tests_and_steps() {
let toml = r#"
[[tests]]
name = "foo"
run = "true"
[[steps]]
action = "add"
service = "bar"
"#;
let (_dir, path) = write_temp(toml);
let result = TestToml::parse(&path);
assert!(result.is_err(), "expected error for mixed tests+steps");
let msg = format!("{:#}", result.unwrap_err());
assert!(msg.contains("[[tests]]") || msg.contains("[[steps]]"));
}
#[test]
fn name_from_metadata() {
let toml = r#"
[test]
name = "my explicit name"
[[tests]]
name = "check"
run = "true"
"#;
let (_dir, path) = write_temp(toml);
let parsed = TestToml::parse(&path).expect("parse");
assert_eq!(parsed.name_or_default(&path), "my explicit name");
}
#[test]
fn name_from_filename() {
let toml = r#"
[[tests]]
name = "check"
run = "true"
"#;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("immich-sso.toml");
std::fs::write(&path, toml).expect("write");
let parsed = TestToml::parse(&path).expect("parse");
assert_eq!(parsed.name_or_default(&path), "immich-sso");
}
#[test]
fn browser_step_requires_spec() {
let toml = r#"
[[steps]]
action = "playwright"
"#;
let (_dir, path) = write_temp(toml);
let result = TestToml::parse(&path);
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(msg.contains("spec") || msg.contains("missing field"));
}
#[test]
fn run_step_rejects_missing_name() {
let toml = r#"
[[steps]]
action = "shell"
run = "true"
"#;
let (_dir, path) = write_temp(toml);
let result = TestToml::parse(&path);
assert!(result.is_err(), "run step without 'name' should fail");
}
#[test]
fn add_step_default_timeout() {
let toml = r#"
[[steps]]
action = "add"
service = "whoami"
"#;
let (_dir, path) = write_temp(toml);
let parsed = TestToml::parse(&path).expect("parse");
if let StepDef::Add { timeout, .. } = parsed.steps[0] {
assert_eq!(timeout, 300);
} else {
panic!("expected Add step");
}
}
#[test]
fn http_step_defaults() {
let toml = r#"
[[steps]]
action = "http"
url = "http://localhost:8080"
"#;
let (_dir, path) = write_temp(toml);
let parsed = TestToml::parse(&path).expect("parse");
if let StepDef::Http {
status, timeout, ..
} = parsed.steps[0]
{
assert_eq!(status, 200);
assert_eq!(timeout, 30);
} else {
panic!("expected Http step");
}
}
#[test]
fn mail_step_defaults() {
let toml = r#"
[[steps]]
action = "mail"
mailbox = "smtptest"
"#;
let (_dir, path) = write_temp(toml);
let parsed = TestToml::parse(&path).expect("parse");
if let StepDef::Mail {
ref contains,
ref poll,
timeout,
..
} = parsed.steps[0]
{
assert!(contains.is_none(), "contains defaults to None");
assert_eq!(poll.interval, 2, "default poll interval");
assert_eq!(poll.attempts, 30, "default poll attempts");
assert_eq!(timeout, 30);
} else {
panic!("expected Mail step");
}
}
#[test]
fn is_setup_classification() {
let toml = r#"
[[steps]]
action = "add"
service = "whoami"
[[steps]]
action = "remove"
service = "whoami"
[[steps]]
action = "wait"
service = "whoami"
[[steps]]
action = "shell"
name = "check"
run = "true"
[[steps]]
action = "http"
url = "http://localhost:8080"
[[steps]]
action = "playwright"
spec = "test.spec.ts"
"#;
let (_dir, path) = write_temp(toml);
let parsed = TestToml::parse(&path).expect("parse");
assert!(parsed.steps[0].is_setup(), "add should be setup");
assert!(parsed.steps[1].is_setup(), "remove should be setup");
assert!(parsed.steps[2].is_setup(), "wait should be setup");
assert!(!parsed.steps[3].is_setup(), "shell should not be setup");
assert!(!parsed.steps[4].is_setup(), "http should not be setup");
assert!(
!parsed.steps[5].is_setup(),
"playwright should not be setup"
);
}
}