1use std::{fmt, num::NonZeroU32, str::FromStr};
4
5use once_cell::sync::Lazy;
6use regex::Regex;
7use serde::Serialize;
8use thiserror::Error;
9use url::Url;
10
11static GOLANG_DURATION_REGEX_STR: &str = r"([+-]?(\d+(h|m|s|ms|us|µs|ns))+|0)";
12static GOLANG_DURATION_REGEX: Lazy<Regex> = Lazy::new(|| {
13 Regex::new(&format!(r"^{GOLANG_DURATION_REGEX_STR}$"))
14 .expect("instantiating GOLANG_DURATION_REGEX from given static string must not fail")
15});
16
17#[derive(Debug, PartialEq, Serialize)]
24#[serde(rename_all = "lowercase")]
25pub enum LogLevel {
26 Debug,
27 Info,
28 Warn,
29 Error,
30 Fatal,
31 Panic,
32}
33
34#[derive(Debug, PartialEq, Serialize)]
41#[serde(rename_all = "lowercase")]
42pub enum LogFormat {
43 Runner,
44 Text,
45 Json,
46}
47
48#[derive(Debug, PartialEq, Eq, Error)]
49#[error("invalid Golang duration (which look like 15m, 1h, 1h15m, etc.)")]
50pub struct GolangDurationParseError;
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65#[serde(transparent)]
66pub struct GolangDuration(String);
67
68impl GolangDuration {
69 pub fn parse<S>(duration: S) -> Result<Self, GolangDurationParseError>
71 where
72 S: Into<String>,
73 {
74 let duration = duration.into();
75
76 if !GOLANG_DURATION_REGEX.is_match(&duration) {
77 #[cfg(feature = "tracing")]
78 tracing::error!("invalid Golang duration: {duration}");
79 return Err(GolangDurationParseError);
80 }
81
82 Ok(Self(duration))
83 }
84
85 pub fn as_str(&self) -> &str {
87 &self.0
88 }
89}
90
91impl fmt::Display for GolangDuration {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 self.0.fmt(f)
94 }
95}
96
97impl FromStr for GolangDuration {
98 type Err = GolangDurationParseError;
99
100 fn from_str(duration: &str) -> Result<Self, Self::Err> {
101 Self::parse(duration)
102 }
103}
104
105#[derive(Debug, Serialize)]
112pub struct GlobalSection {
113 pub concurrent: NonZeroU32,
114 pub log_level: LogLevel,
115 pub log_format: LogFormat,
116 pub check_interval: u32,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub sentry_dsn: Option<Url>,
119 pub connection_max_age: GolangDuration,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub listen_address: Option<Url>,
122 pub shutdown_timeout: u32,
123}
124
125impl Default for GlobalSection {
126 fn default() -> Self {
127 Self {
128 concurrent: NonZeroU32::new(1).expect("1 is not zero"),
129 log_level: LogLevel::Error,
130 log_format: LogFormat::Json,
131 check_interval: 3,
132 sentry_dsn: None,
133 connection_max_age: GolangDuration::parse("15m").expect("15m is a valid duration"),
134 listen_address: None,
135 shutdown_timeout: 30,
136 }
137 }
138}
139
140#[cfg(test)]
141mod test {
142 use pretty_assertions::assert_eq;
143 use test_strategy::proptest;
144
145 use super::{GlobalSection, GolangDuration, GOLANG_DURATION_REGEX, GOLANG_DURATION_REGEX_STR};
146
147 #[test]
148 fn test_default() {
149 let global_section = GlobalSection::default();
150
151 let toml = toml::to_string_pretty(&global_section).expect("could not serialize to TOML");
152
153 assert_eq!(
154 toml,
155 indoc::indoc! {r#"
156 concurrent = 1
157 log_level = "error"
158 log_format = "json"
159 check_interval = 3
160 connection_max_age = "15m"
161 shutdown_timeout = 30
162 "#}
163 );
164 }
165
166 #[proptest]
167 fn parse_valid_golang_durations(#[strategy(GOLANG_DURATION_REGEX_STR)] duration: String) {
168 assert_eq!(duration, GolangDuration::parse(&duration).unwrap().as_str());
169 }
170
171 #[proptest]
172 fn parse_invalid_golang_durations(
173 #[filter(|s| !GOLANG_DURATION_REGEX.is_match(s))] duration: String,
174 ) {
175 assert!(GolangDuration::parse(duration).is_err());
176 }
177}