use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum Check {
Script(String),
Declared(Box<Declared>),
}
impl Check {
#[must_use]
pub fn path(&self) -> Option<&str> {
match self {
Check::Script(p) => Some(p),
Check::Declared(d) => d.script.as_deref(),
}
}
pub fn is_playbook(&self) -> bool {
self.path().is_some_and(is_yaml_file)
}
}
pub(crate) fn is_yaml_file(path: &str) -> bool {
std::path::Path::new(path).extension().is_some_and(|e| e == "yml" || e == "yaml")
}
impl<'de> Deserialize<'de> for Check {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = Check;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a script path, or a check with `http`, `tcp`, `exec` or `script`")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Check, E> {
Ok(Check::Script(v.to_owned()))
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> Result<Check, A::Error> {
Declared::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(|d| Check::Declared(Box::new(d)))
}
}
d.deserialize_any(V)
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Declared {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub from: Option<String>,
#[serde(default)]
pub http: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub headers: IndexMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contains: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub follow: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub send: Option<String>,
#[serde(default)]
pub tcp: Option<String>,
#[serde(default)]
pub exec: Option<String>,
#[serde(default)]
pub script: Option<String>,
#[serde(default)]
pub expect: Option<Expect>,
#[serde(default, deserialize_with = "duration")]
#[schemars(with = "Option<WaitSchema>")]
pub wait: Option<u32>,
}
#[derive(JsonSchema)]
#[serde(untagged)]
enum WaitSchema {
Seconds(u32),
Duration(String),
}
impl<'de> Deserialize<'de> for WaitSchema {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<WaitSchema, D::Error> {
let not_a_wait = || serde::de::Error::custom("a wait is a number of seconds or a duration (`90s`, `3m`)");
match serde_json::Value::deserialize(d)? {
serde_json::Value::Number(n) => n.as_u64().and_then(|n| u32::try_from(n).ok()).map(WaitSchema::Seconds).ok_or_else(not_a_wait),
serde_json::Value::String(s) => Ok(WaitSchema::Duration(s)),
_ => Err(not_a_wait()),
}
}
}
fn duration<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u32>, D::Error> {
match Option::<WaitSchema>::deserialize(d)? {
None => Ok(None),
Some(WaitSchema::Seconds(n)) => Ok(Some(n)),
Some(WaitSchema::Duration(s)) => super::seconds(&s)
.map(Some)
.ok_or_else(|| serde::de::Error::custom(format!("`{s}` isn't a duration: write `90s`, `3m` or `1m30s`"))),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Expect {
Status(u16),
Text(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn check(yaml: &str) -> Result<Check, serde_yaml_ng::Error> {
serde_yaml_ng::from_str(yaml)
}
#[test]
fn a_script_or_a_declared_script_is_a_path_and_yaml_ones_are_playbooks() -> Result<(), serde_yaml_ng::Error> {
let read = |yaml| check(yaml).map(|c| (c.path().map(str::to_owned), c.is_playbook()));
assert_eq!(read("checks/login.sh")?, (Some("checks/login.sh".into()), false));
assert_eq!(read("checks/site.yml")?, (Some("checks/site.yml".into()), true));
assert_eq!(read("{ script: checks/site.yaml, from: web }")?, (Some("checks/site.yaml".into()), true));
assert_eq!(read("{ http: \"http://web/\" }")?, (None, false));
Ok(())
}
#[test]
fn a_check_of_another_shape_says_what_it_takes() {
let refusal = check("3").map(|_| ()).map_err(|e| e.to_string());
assert!(refusal.is_err_and(|e| e.ends_with("expected a script path, or a check with `http`, `tcp`, `exec` or `script`")));
}
#[test]
fn a_wait_is_seconds_or_a_duration_held_in_seconds() -> Result<(), serde_yaml_ng::Error> {
let wait = |yaml| {
check(yaml).map(|c| match c {
Check::Declared(d) => d.wait,
Check::Script(_) => None,
})
};
assert_eq!(wait("{ tcp: \"web:22\", wait: 90 }")?, Some(90));
assert_eq!(wait("{ tcp: \"web:22\", wait: 1m30s }")?, Some(90));
assert_eq!(wait("{ tcp: \"web:22\" }")?, None);
Ok(())
}
}