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 #[serde(default)]
105 on_change: Option<crate::spec::OnChange>,
106 },
107}
108
109#[derive(Deserialize, JsonSchema)]
112#[serde(untagged)]
113pub(crate) enum EnvMapOrList {
114 Map(std::collections::BTreeMap<String, EnvValue>),
115 List(Vec<String>),
116}
117
118#[derive(Deserialize, JsonSchema)]
120#[serde(untagged)]
121#[allow(dead_code)]
122pub(crate) enum MapOrList {
123 Map(std::collections::BTreeMap<String, Scalar>),
124 List(Vec<String>),
125}
126
127fn map_or_list<'de, D: Deserializer<'de>>(
128 d: D,
129 bare: impl Fn(&str) -> Result<String, String>,
130) -> Result<std::collections::BTreeMap<String, String>, D::Error> {
131 match MapOrList::deserialize(d)? {
132 MapOrList::Map(m) => Ok(m.into_iter().map(|(k, v)| (k, v.into_string())).collect()),
133 MapOrList::List(l) => l
134 .into_iter()
135 .map(|item| match item.split_once('=') {
136 Some((k, v)) => Ok((k.to_string(), v.to_string())),
137 None => bare(&item).map(|v| (item.clone(), v)),
138 })
139 .collect::<Result<_, _>>()
140 .map_err(D::Error::custom),
141 }
142}
143
144pub(crate) fn string_map_or_list<'de, D: Deserializer<'de>>(
147 d: D,
148) -> Result<std::collections::BTreeMap<String, String>, D::Error> {
149 map_or_list(d, |_| Ok(String::new()))
150}
151
152pub(crate) fn env_map_or_list<'de, D: Deserializer<'de>>(
156 d: D,
157) -> Result<std::collections::BTreeMap<String, String>, D::Error> {
158 map_or_list(d, |k| {
159 Err(format!(
160 "environment entry {k:?} has no value: write {k}=VALUE"
161 ))
162 })
163}
164
165#[derive(Deserialize, JsonSchema)]
167#[serde(untagged)]
168#[allow(dead_code)]
169pub(crate) enum Command {
170 String(String),
171 Argv(Vec<Scalar>),
172}
173
174pub(crate) fn opt_command<'de, D: Deserializer<'de>>(
175 d: D,
176) -> Result<Option<Vec<String>>, D::Error> {
177 match Command::deserialize(d)? {
178 Command::Argv(v) => Ok(Some(v.into_iter().map(Scalar::into_string).collect())),
179 Command::String(s) => split_words(&s).map(Some).map_err(D::Error::custom),
180 }
181}
182
183pub fn split_words(s: &str) -> Result<Vec<String>, String> {
187 let mut words = Vec::new();
188 let mut cur = String::new();
189 let mut in_word = false;
190 let mut chars = s.chars();
191 while let Some(c) = chars.next() {
192 match c {
193 c if c.is_whitespace() => {
194 if in_word {
195 words.push(std::mem::take(&mut cur));
196 in_word = false;
197 }
198 }
199 '\'' => {
200 in_word = true;
201 loop {
202 match chars.next() {
203 Some('\'') => break,
204 Some(c) => cur.push(c),
205 None => return Err(format!("unterminated ' in {s:?}")),
206 }
207 }
208 }
209 '"' => {
210 in_word = true;
211 loop {
212 match chars.next() {
213 Some('"') => break,
214 Some('\\') => match chars.next() {
215 Some(c @ ('"' | '\\' | '$' | '`')) => cur.push(c),
216 Some('\n') => {}
217 Some(c) => {
218 cur.push('\\');
219 cur.push(c);
220 }
221 None => return Err(format!("unterminated \" in {s:?}")),
222 },
223 Some(c) => cur.push(c),
224 None => return Err(format!("unterminated \" in {s:?}")),
225 }
226 }
227 }
228 '\\' => {
229 in_word = true;
230 match chars.next() {
231 Some('\n') => {}
232 Some(c) => cur.push(c),
233 None => return Err(format!("trailing \\ in {s:?}")),
234 }
235 }
236 c => {
237 in_word = true;
238 cur.push(c);
239 }
240 }
241 }
242 if in_word {
243 words.push(cur);
244 }
245 if words.is_empty() {
246 return Err("command is empty".into());
247 }
248 Ok(words)
249}
250
251pub(crate) fn string_map_map<'de, D: Deserializer<'de>>(
252 d: D,
253) -> Result<std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>>, D::Error>
254{
255 let m = std::collections::BTreeMap::<String, std::collections::BTreeMap<String, Scalar>>::deserialize(d)?;
256 Ok(m.into_iter()
257 .map(|(k, v)| {
258 (
259 k,
260 v.into_iter().map(|(a, b)| (a, b.into_string())).collect(),
261 )
262 })
263 .collect())
264}
265
266pub fn parse_duration(s: &str) -> Result<std::time::Duration, String> {
268 let s = s.trim();
269 let (num, unit) = match s.find(|c: char| !c.is_ascii_digit() && c != '.') {
270 Some(i) => (&s[..i], s[i..].trim()),
271 None => (s, "s"),
272 };
273 let n: f64 = num
274 .parse()
275 .map_err(|_| format!("invalid duration {s:?} (use e.g. 90s, 5m, 1h)"))?;
276 let secs = match unit {
277 "ms" => n / 1000.0,
278 "s" | "sec" | "secs" => n,
279 "m" | "min" | "mins" => n * 60.0,
280 "h" => n * 3600.0,
281 "d" => n * 86400.0,
282 _ => return Err(format!("invalid duration unit in {s:?} (ms, s, m, h, d)")),
283 };
284 Ok(std::time::Duration::from_secs_f64(secs))
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use std::time::Duration;
291
292 #[test]
293 fn durations() {
294 assert_eq!(parse_duration("90").unwrap(), Duration::from_secs(90));
295 assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90));
296 assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
297 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
298 assert_eq!(
299 parse_duration("90d").unwrap(),
300 Duration::from_secs(90 * 86400)
301 );
302 assert_eq!(
303 parse_duration("1500ms").unwrap(),
304 Duration::from_millis(1500)
305 );
306 assert!(parse_duration("5 parsecs").is_err());
307 }
308
309 #[test]
310 fn words() {
311 assert_eq!(
312 split_words(r#"sh -c 'bun install && exec bun run dev'"#).unwrap(),
313 ["sh", "-c", "bun install && exec bun run dev"]
314 );
315 assert_eq!(
316 split_words(r#"echo "a \"b\" $X" c\ d ''"#).unwrap(),
317 ["echo", r#"a "b" $X"#, "c d", ""]
318 );
319 assert!(split_words("echo 'oops").is_err());
320 assert!(split_words(" ").is_err());
321 }
322
323 #[test]
324 fn bools() {
325 assert_eq!(parse_bool("TRUE"), Some(true));
326 assert_eq!(parse_bool("off"), Some(false));
327 assert_eq!(parse_bool("maybe"), None);
328 }
329}