1use std::collections::BTreeMap;
2use std::path::Path;
3use std::time::Duration;
4
5use usage_rs::config::{EnvLayer, FileLayer, FileScope, Layers, Resolved, resolve};
6
7use crate::env::Env;
8use crate::error::{Result, YtError};
9
10#[derive(usage_rs::Config, Clone, Debug, PartialEq)]
12#[usage(file(path = "yt/config.toml", xdg = "config", format = "toml"))]
13pub struct Settings {
14 #[usage(env = "YT_HTTP_TIMEOUT", default = "30s", ty = "duration")]
16 pub http_timeout: String,
17 #[usage(env = "YT_HTTP_RETRIES", default = 3)]
19 pub http_retries: u64,
20 #[usage(env = "YT_COLOR", default = "auto", choices("auto", "always", "never"))]
22 pub color: String,
23 #[usage(env = "YT_CACHE_TTL", default = "24h", ty = "duration")]
25 pub cache_ttl: String,
26}
27
28pub const KEYS: [&str; 4] = ["cache_ttl", "color", "http_retries", "http_timeout"];
29
30impl Settings {
31 pub fn http_timeout(&self) -> Result<Duration> {
32 parse_duration(&self.http_timeout)
33 .map_err(|message| invalid_setting("http_timeout", message))
34 }
35
36 pub fn cache_ttl(&self) -> Result<Duration> {
37 parse_duration(&self.cache_ttl).map_err(|message| invalid_setting("cache_ttl", message))
38 }
39}
40
41#[derive(Debug)]
42pub struct ResolvedSettings {
43 pub settings: Settings,
44 resolved: Resolved,
45}
46
47impl ResolvedSettings {
48 pub fn resolve(env: &Env, config_file: &Path) -> Result<Self> {
49 let env_layer = EnvLayer::new(
50 env.iter()
51 .map(|(key, value)| (key.to_string(), value.to_string())),
52 );
53 let file_layer = config_file
54 .is_file()
55 .then(|| FileLayer::at(config_file.to_path_buf(), FileScope::Global).under("settings"));
56 let mut layers = Layers::new().then(&env_layer);
57 if let Some(file_layer) = &file_layer {
58 layers = layers.then(file_layer);
59 }
60 let invalid = |message: String| YtError::Config {
61 path: config_file.display().to_string(),
62 message,
63 };
64 let resolved = resolve(Settings::SETTINGS_REGISTRY, layers)
65 .map_err(|error| invalid(error.to_string()))?;
66 let settings = Settings::read(&resolved).map_err(|error| invalid(format!("{error:?}")))?;
67 settings.http_timeout()?;
68 settings.cache_ttl()?;
69 Ok(Self { settings, resolved })
70 }
71
72 pub fn list(&self) -> Vec<String> {
74 usage_rs::config::explain::list(&self.resolved)
75 }
76
77 pub fn explain(&self, key: &str) -> Result<String> {
79 usage_rs::config::explain::explain(&self.resolved, key).ok_or_else(|| unknown_setting(key))
80 }
81
82 pub fn values(&self) -> BTreeMap<String, String> {
83 KEYS.iter()
84 .filter_map(|key| {
85 self.resolved
86 .get_key(key)
87 .map(|value| (key.to_string(), value.to_string()))
88 })
89 .collect()
90 }
91}
92
93pub fn parse_duration(text: &str) -> std::result::Result<Duration, String> {
95 let text = text.trim();
96 let split = text
97 .find(|c: char| !c.is_ascii_digit())
98 .unwrap_or(text.len());
99 let (number, unit) = text.split_at(split);
100 let value: u64 = number
101 .parse()
102 .map_err(|_| format!("'{text}' is not a duration like 30s, 5m or 24h"))?;
103 let seconds = match unit {
104 "ms" => return Ok(Duration::from_millis(value)),
105 "" | "s" => value,
106 "m" => value * 60,
107 "h" => value * 3_600,
108 "d" => value * 86_400,
109 _ => {
110 return Err(format!(
111 "'{text}' has an unknown unit; use ms, s, m, h or d"
112 ));
113 }
114 };
115 Ok(Duration::from_secs(seconds))
116}
117
118pub fn unknown_setting(key: &str) -> miette::Report {
119 YtError::Usage {
120 message: format!(
121 "unknown setting '{key}'; the settings are {}",
122 KEYS.join(", ")
123 ),
124 }
125 .into()
126}
127
128fn invalid_setting(key: &str, message: String) -> miette::Report {
129 YtError::Config {
130 path: format!("setting {key}"),
131 message,
132 }
133 .into()
134}
135
136pub fn setting_value(key: &str, raw: &str) -> Result<toml_edit::Value> {
138 let usage = |message: String| -> miette::Report { YtError::Usage { message }.into() };
139 match key {
140 "http_timeout" | "cache_ttl" => {
141 parse_duration(raw).map_err(usage)?;
142 Ok(raw.into())
143 }
144 "http_retries" => raw
145 .parse::<i64>()
146 .ok()
147 .filter(|count| *count >= 0)
148 .map(Into::into)
149 .ok_or_else(|| usage("http_retries must be a whole number".into())),
150 "color" if matches!(raw, "auto" | "always" | "never") => Ok(raw.into()),
151 "color" => Err(usage("color must be auto, always or never".into())),
152 _ => Err(unknown_setting(key)),
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn resolve_with(env: &[(&str, &str)], file: Option<&str>) -> Result<ResolvedSettings> {
161 let dir = tempfile::tempdir().unwrap();
162 let path = dir.path().join("config.toml");
163 if let Some(text) = file {
164 std::fs::write(&path, text).unwrap();
165 }
166 ResolvedSettings::resolve(&Env::from_vars(env.iter().copied()), &path)
167 }
168
169 #[test]
170 fn defaults() {
171 let resolved = resolve_with(&[], None).unwrap();
172 assert_eq!(
173 resolved.settings.http_timeout().unwrap(),
174 Duration::from_secs(30)
175 );
176 assert_eq!(resolved.settings.http_retries, 3);
177 assert_eq!(resolved.settings.color, "auto");
178 assert_eq!(
179 resolved.settings.cache_ttl().unwrap(),
180 Duration::from_secs(86_400)
181 );
182 }
183
184 #[test]
185 fn the_settings_table_of_the_config_file_is_read() {
186 let resolved = resolve_with(
187 &[],
188 Some("[settings]\nhttp_retries = 5\n[profiles.acme]\nurl = \"x\"\n"),
189 )
190 .unwrap();
191 assert_eq!(resolved.settings.http_retries, 5);
192 }
193
194 #[test]
195 fn environment_wins_over_the_file() {
196 let resolved = resolve_with(
197 &[("YT_HTTP_RETRIES", "7")],
198 Some("[settings]\nhttp_retries = 5\n"),
199 )
200 .unwrap();
201 assert_eq!(resolved.settings.http_retries, 7);
202 }
203
204 #[test]
205 fn a_bad_duration_is_a_config_error() {
206 let error = resolve_with(&[("YT_HTTP_TIMEOUT", "soon")], None).unwrap_err();
207 assert!(error.to_string().contains("http_timeout"), "{error}");
208 }
209
210 #[test]
211 fn list_has_one_line_per_setting() {
212 let lines = resolve_with(&[], None).unwrap().list();
213 assert_eq!(lines.len(), KEYS.len());
214 for (line, key) in lines.iter().zip(KEYS) {
215 assert!(line.starts_with(key), "{line}");
216 }
217 }
218
219 #[test]
220 fn explain_names_the_source() {
221 let resolved = resolve_with(&[("YT_HTTP_RETRIES", "7")], None).unwrap();
222 let text = resolved.explain("http_retries").unwrap();
223 assert!(text.contains("YT_HTTP_RETRIES"), "{text}");
224 assert!(resolved.explain("nope").is_err());
225 }
226
227 #[test]
228 fn durations() {
229 assert_eq!(
230 parse_duration("1500ms").unwrap(),
231 Duration::from_millis(1500)
232 );
233 assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
234 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
235 assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
236 assert_eq!(parse_duration("24h").unwrap(), Duration::from_secs(86_400));
237 assert_eq!(parse_duration("2d").unwrap(), Duration::from_secs(172_800));
238 assert!(parse_duration("soon").is_err());
239 assert!(parse_duration("5w").is_err());
240 }
241
242 #[test]
243 fn setting_values_are_validated() {
244 assert_eq!(
245 setting_value("http_retries", "5").unwrap().as_integer(),
246 Some(5)
247 );
248 assert_eq!(
249 setting_value("http_timeout", "10s").unwrap().as_str(),
250 Some("10s")
251 );
252 assert_eq!(
253 setting_value("color", "never").unwrap().as_str(),
254 Some("never")
255 );
256 assert!(setting_value("http_retries", "-1").is_err());
257 assert!(setting_value("color", "sometimes").is_err());
258 assert!(setting_value("cache_ttl", "forever").is_err());
259 assert!(setting_value("nope", "1").is_err());
260 }
261}