Skip to main content

yt/config/
settings.rs

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/// Settings that change how yt behaves. Each comes from the `[settings]` table of the global config, overridable by its environment variable.
11#[derive(usage_rs::Config, Clone, Debug, PartialEq)]
12#[usage(file(path = "yt/config.toml", xdg = "config", format = "toml"))]
13pub struct Settings {
14    /// How long a request may take
15    #[usage(env = "YT_HTTP_TIMEOUT", default = "30s", ty = "duration")]
16    pub http_timeout: String,
17    /// How often to retry a read on a network error or HTTP 408, 429 or 5xx
18    #[usage(env = "YT_HTTP_RETRIES", default = 3)]
19    pub http_retries: u64,
20    /// When to use color: auto, always or never
21    #[usage(env = "YT_COLOR", default = "auto", choices("auto", "always", "never"))]
22    pub color: String,
23    /// How long cached project data stays valid
24    #[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    /// One `key = value` line per setting.
73    pub fn list(&self) -> Vec<String> {
74        usage_rs::config::explain::list(&self.resolved)
75    }
76
77    /// A setting's value and where it came from.
78    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
93/// `1500ms`, `30s` (or `30`), `5m`, `24h` or `2d`.
94pub 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
136/// Checks `raw` for `key` and returns the TOML value to store.
137pub 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}