1use schemars::JsonSchema;
8use serde::{Deserialize, Deserializer, de::Error as _};
9
10#[derive(Deserialize, JsonSchema)]
11#[serde(untagged)]
12#[allow(dead_code)]
13pub(crate) enum BoolOrString {
14 Bool(bool),
15 String(String),
16}
17
18#[derive(Deserialize, JsonSchema)]
19#[serde(untagged)]
20#[allow(dead_code)]
21pub(crate) enum IntOrString {
22 Int(u64),
23 String(String),
24}
25
26fn parse_bool(s: &str) -> Option<bool> {
27 match s.trim().to_ascii_lowercase().as_str() {
28 "true" | "yes" | "on" | "1" => Some(true),
29 "false" | "no" | "off" | "0" | "" => Some(false),
30 _ => None,
31 }
32}
33
34pub(crate) fn bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
35 match BoolOrString::deserialize(d)? {
36 BoolOrString::Bool(b) => Ok(b),
37 BoolOrString::String(s) => {
38 parse_bool(&s).ok_or_else(|| D::Error::custom(format!("expected a boolean, got {s:?}")))
39 }
40 }
41}
42
43pub(crate) fn opt_bool<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
44 bool(d).map(Some)
45}
46
47pub(crate) fn string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
49 Ok(match IntOrString::deserialize(d)? {
50 IntOrString::Int(n) => n.to_string(),
51 IntOrString::String(s) => s,
52 })
53}
54
55pub(crate) fn opt_string<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
56 Ok(Some(match IntOrString::deserialize(d)? {
57 IntOrString::Int(n) => n.to_string(),
58 IntOrString::String(s) => s,
59 }))
60}
61
62#[derive(serde::Serialize, Deserialize, JsonSchema)]
64#[serde(untagged)]
65#[allow(dead_code)]
66pub(crate) enum Scalar {
67 String(String),
68 Bool(bool),
69 Int(i64),
70 Float(f64),
71}
72
73impl Scalar {
74 pub(crate) fn into_string(self) -> String {
75 match self {
76 Scalar::String(s) => s,
77 Scalar::Bool(b) => b.to_string(),
78 Scalar::Int(i) => i.to_string(),
79 Scalar::Float(f) => f.to_string(),
80 }
81 }
82}
83
84pub(crate) fn string_map<'de, D: Deserializer<'de>>(
87 d: D,
88) -> Result<std::collections::BTreeMap<String, String>, D::Error> {
89 let m = std::collections::BTreeMap::<String, Scalar>::deserialize(d)?;
90 Ok(m.into_iter().map(|(k, v)| (k, v.into_string())).collect())
91}
92
93#[derive(Deserialize, JsonSchema)]
96#[serde(untagged, deny_unknown_fields)]
97pub(crate) enum EnvValue {
98 Scalar(Scalar),
99 Secret {
100 secret: String,
102 },
103}
104
105#[derive(Deserialize, JsonSchema)]
108#[serde(untagged)]
109pub(crate) enum EnvMapOrList {
110 Map(std::collections::BTreeMap<String, EnvValue>),
111 List(Vec<String>),
112}
113
114#[derive(Deserialize, JsonSchema)]
116#[serde(untagged)]
117#[allow(dead_code)]
118pub(crate) enum MapOrList {
119 Map(std::collections::BTreeMap<String, Scalar>),
120 List(Vec<String>),
121}
122
123fn map_or_list<'de, D: Deserializer<'de>>(
124 d: D,
125 bare: impl Fn(&str) -> Result<String, String>,
126) -> Result<std::collections::BTreeMap<String, String>, D::Error> {
127 match MapOrList::deserialize(d)? {
128 MapOrList::Map(m) => Ok(m.into_iter().map(|(k, v)| (k, v.into_string())).collect()),
129 MapOrList::List(l) => l
130 .into_iter()
131 .map(|item| match item.split_once('=') {
132 Some((k, v)) => Ok((k.to_string(), v.to_string())),
133 None => bare(&item).map(|v| (item.clone(), v)),
134 })
135 .collect::<Result<_, _>>()
136 .map_err(D::Error::custom),
137 }
138}
139
140pub(crate) fn string_map_or_list<'de, D: Deserializer<'de>>(
143 d: D,
144) -> Result<std::collections::BTreeMap<String, String>, D::Error> {
145 map_or_list(d, |_| Ok(String::new()))
146}
147
148pub(crate) fn env_map_or_list<'de, D: Deserializer<'de>>(
152 d: D,
153) -> Result<std::collections::BTreeMap<String, String>, D::Error> {
154 map_or_list(d, |k| {
155 Err(format!(
156 "environment entry {k:?} has no value: write {k}=VALUE"
157 ))
158 })
159}
160
161#[derive(Deserialize, JsonSchema)]
163#[serde(untagged)]
164#[allow(dead_code)]
165pub(crate) enum Command {
166 String(String),
167 Argv(Vec<Scalar>),
168}
169
170pub(crate) fn opt_command<'de, D: Deserializer<'de>>(
171 d: D,
172) -> Result<Option<Vec<String>>, D::Error> {
173 match Command::deserialize(d)? {
174 Command::Argv(v) => Ok(Some(v.into_iter().map(Scalar::into_string).collect())),
175 Command::String(s) => split_words(&s).map(Some).map_err(D::Error::custom),
176 }
177}
178
179pub fn split_words(s: &str) -> Result<Vec<String>, String> {
183 let mut words = Vec::new();
184 let mut cur = String::new();
185 let mut in_word = false;
186 let mut chars = s.chars();
187 while let Some(c) = chars.next() {
188 match c {
189 c if c.is_whitespace() => {
190 if in_word {
191 words.push(std::mem::take(&mut cur));
192 in_word = false;
193 }
194 }
195 '\'' => {
196 in_word = true;
197 loop {
198 match chars.next() {
199 Some('\'') => break,
200 Some(c) => cur.push(c),
201 None => return Err(format!("unterminated ' in {s:?}")),
202 }
203 }
204 }
205 '"' => {
206 in_word = true;
207 loop {
208 match chars.next() {
209 Some('"') => break,
210 Some('\\') => match chars.next() {
211 Some(c @ ('"' | '\\' | '$' | '`')) => cur.push(c),
212 Some('\n') => {}
213 Some(c) => {
214 cur.push('\\');
215 cur.push(c);
216 }
217 None => return Err(format!("unterminated \" in {s:?}")),
218 },
219 Some(c) => cur.push(c),
220 None => return Err(format!("unterminated \" in {s:?}")),
221 }
222 }
223 }
224 '\\' => {
225 in_word = true;
226 match chars.next() {
227 Some('\n') => {}
228 Some(c) => cur.push(c),
229 None => return Err(format!("trailing \\ in {s:?}")),
230 }
231 }
232 c => {
233 in_word = true;
234 cur.push(c);
235 }
236 }
237 }
238 if in_word {
239 words.push(cur);
240 }
241 if words.is_empty() {
242 return Err("command is empty".into());
243 }
244 Ok(words)
245}
246
247pub(crate) fn string_map_map<'de, D: Deserializer<'de>>(
248 d: D,
249) -> Result<std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>>, D::Error>
250{
251 let m = std::collections::BTreeMap::<String, std::collections::BTreeMap<String, Scalar>>::deserialize(d)?;
252 Ok(m.into_iter()
253 .map(|(k, v)| {
254 (
255 k,
256 v.into_iter().map(|(a, b)| (a, b.into_string())).collect(),
257 )
258 })
259 .collect())
260}
261
262pub fn parse_duration(s: &str) -> Result<std::time::Duration, String> {
264 let s = s.trim();
265 let (num, unit) = match s.find(|c: char| !c.is_ascii_digit() && c != '.') {
266 Some(i) => (&s[..i], s[i..].trim()),
267 None => (s, "s"),
268 };
269 let n: f64 = num
270 .parse()
271 .map_err(|_| format!("invalid duration {s:?} (use e.g. 90s, 5m, 1h)"))?;
272 let secs = match unit {
273 "ms" => n / 1000.0,
274 "s" | "sec" | "secs" => n,
275 "m" | "min" | "mins" => n * 60.0,
276 "h" => n * 3600.0,
277 "d" => n * 86400.0,
278 _ => return Err(format!("invalid duration unit in {s:?} (ms, s, m, h, d)")),
279 };
280 Ok(std::time::Duration::from_secs_f64(secs))
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use std::time::Duration;
287
288 #[test]
289 fn durations() {
290 assert_eq!(parse_duration("90").unwrap(), Duration::from_secs(90));
291 assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90));
292 assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
293 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
294 assert_eq!(
295 parse_duration("90d").unwrap(),
296 Duration::from_secs(90 * 86400)
297 );
298 assert_eq!(
299 parse_duration("1500ms").unwrap(),
300 Duration::from_millis(1500)
301 );
302 assert!(parse_duration("5 parsecs").is_err());
303 }
304
305 #[test]
306 fn words() {
307 assert_eq!(
308 split_words(r#"sh -c 'bun install && exec bun run dev'"#).unwrap(),
309 ["sh", "-c", "bun install && exec bun run dev"]
310 );
311 assert_eq!(
312 split_words(r#"echo "a \"b\" $X" c\ d ''"#).unwrap(),
313 ["echo", r#"a "b" $X"#, "c d", ""]
314 );
315 assert!(split_words("echo 'oops").is_err());
316 assert!(split_words(" ").is_err());
317 }
318
319 #[test]
320 fn bools() {
321 assert_eq!(parse_bool("TRUE"), Some(true));
322 assert_eq!(parse_bool("off"), Some(false));
323 assert_eq!(parse_bool("maybe"), None);
324 }
325}