1use indexmap::IndexMap;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
9#[serde(untagged)]
10pub enum Check {
11 Script(String),
14 Declared(Box<Declared>),
16}
17
18impl Check {
19 #[must_use]
21 pub fn path(&self) -> Option<&str> {
22 match self {
23 Check::Script(p) => Some(p),
24 Check::Declared(d) => d.script.as_deref(),
25 }
26 }
27
28 pub fn is_playbook(&self) -> bool {
30 self.path().is_some_and(is_yaml_file)
31 }
32}
33
34pub(crate) fn is_yaml_file(path: &str) -> bool {
36 std::path::Path::new(path).extension().is_some_and(|e| e == "yml" || e == "yaml")
37}
38
39impl<'de> Deserialize<'de> for Check {
42 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
43 struct V;
44 impl<'de> serde::de::Visitor<'de> for V {
45 type Value = Check;
46 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
47 f.write_str("a script path, or a check with `http`, `tcp`, `exec` or `script`")
48 }
49 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Check, E> {
50 Ok(Check::Script(v.to_owned()))
51 }
52 fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> Result<Check, A::Error> {
53 Declared::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(|d| Check::Declared(Box::new(d)))
54 }
55 }
56 d.deserialize_any(V)
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct Declared {
65 #[serde(default)]
67 pub name: Option<String>,
68 #[serde(default)]
71 pub from: Option<String>,
72 #[serde(default)]
75 pub http: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub method: Option<String>,
79 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
81 pub headers: IndexMap<String, String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub body: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub contains: Option<String>,
89 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
94 pub follow: bool,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub send: Option<String>,
99 #[serde(default)]
101 pub tcp: Option<String>,
102 #[serde(default)]
105 pub exec: Option<String>,
106 #[serde(default)]
109 pub script: Option<String>,
110 #[serde(default)]
114 pub expect: Option<Expect>,
115 #[serde(default, deserialize_with = "duration")]
119 #[schemars(with = "Option<WaitSchema>")]
120 pub wait: Option<u32>,
121}
122
123#[derive(JsonSchema)]
126#[serde(untagged)]
127enum WaitSchema {
128 Seconds(u32),
130 Duration(String),
132}
133
134impl<'de> Deserialize<'de> for WaitSchema {
135 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<WaitSchema, D::Error> {
136 let not_a_wait = || serde::de::Error::custom("a wait is a number of seconds or a duration (`90s`, `3m`)");
137 match serde_json::Value::deserialize(d)? {
138 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),
139 serde_json::Value::String(s) => Ok(WaitSchema::Duration(s)),
140 _ => Err(not_a_wait()),
141 }
142 }
143}
144
145fn duration<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u32>, D::Error> {
147 match Option::<WaitSchema>::deserialize(d)? {
148 None => Ok(None),
149 Some(WaitSchema::Seconds(n)) => Ok(Some(n)),
150 Some(WaitSchema::Duration(s)) => super::seconds(&s)
151 .map(Some)
152 .ok_or_else(|| serde::de::Error::custom(format!("`{s}` isn't a duration: write `90s`, `3m` or `1m30s`"))),
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
159#[serde(untagged)]
160pub enum Expect {
161 Status(u16),
163 Text(String),
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 fn check(yaml: &str) -> Result<Check, serde_yaml_ng::Error> {
173 serde_yaml_ng::from_str(yaml)
174 }
175
176 #[test]
177 fn a_script_or_a_declared_script_is_a_path_and_yaml_ones_are_playbooks() -> Result<(), serde_yaml_ng::Error> {
178 let read = |yaml| check(yaml).map(|c| (c.path().map(str::to_owned), c.is_playbook()));
179 assert_eq!(read("checks/login.sh")?, (Some("checks/login.sh".into()), false));
180 assert_eq!(read("checks/site.yml")?, (Some("checks/site.yml".into()), true));
181 assert_eq!(read("{ script: checks/site.yaml, from: web }")?, (Some("checks/site.yaml".into()), true));
182 assert_eq!(read("{ http: \"http://web/\" }")?, (None, false));
183 Ok(())
184 }
185
186 #[test]
187 fn a_check_of_another_shape_says_what_it_takes() {
188 let refusal = check("3").map(|_| ()).map_err(|e| e.to_string());
189 assert!(refusal.is_err_and(|e| e.ends_with("expected a script path, or a check with `http`, `tcp`, `exec` or `script`")));
190 }
191
192 #[test]
193 fn a_wait_is_seconds_or_a_duration_held_in_seconds() -> Result<(), serde_yaml_ng::Error> {
194 let wait = |yaml| {
195 check(yaml).map(|c| match c {
196 Check::Declared(d) => d.wait,
197 Check::Script(_) => None,
198 })
199 };
200 assert_eq!(wait("{ tcp: \"web:22\", wait: 90 }")?, Some(90));
201 assert_eq!(wait("{ tcp: \"web:22\", wait: 1m30s }")?, Some(90));
202 assert_eq!(wait("{ tcp: \"web:22\" }")?, None);
203 Ok(())
204 }
205}