Skip to main content

aoc_runtime/
config.rs

1//! Loading and validating `config.yaml`.
2//!
3//! ```yaml
4//! template_path: "~/projects/aoc/{{year}}/day{{pad day}}/{{language}}"
5//! cookie: "<advent of code session cookie>"
6//! editor: "code"
7//! ```
8//!
9//! Only `template_path` is required. The template is parsed - and therefore
10//! validated - at load time rather than on first use.
11//!
12//! The cookie may instead live in a [`COOKIE_FILE_NAME`] file beside
13//! `config.yaml`, holding nothing but the cookie, so the configuration itself
14//! carries no secret and can be committed alongside other dotfiles.
15
16use crate::{env::Env, template::Template};
17use serde::Deserialize;
18use std::{
19    collections::BTreeMap,
20    fs, io,
21    path::{Path, PathBuf},
22};
23
24/// The default editor launched by `aoc code`.
25pub const DEFAULT_EDITOR: &str = "code";
26
27/// The file beside `config.yaml` read as a raw session cookie when the
28/// configuration itself does not carry one.
29pub const COOKIE_FILE_NAME: &str = "COOKIE";
30
31/// Validated configuration.
32#[derive(Debug, Clone)]
33pub struct Config {
34    /// The parsed project path template.
35    pub template: Template,
36    /// The Advent of Code session cookie, if one is available.
37    pub cookie: Option<String>,
38    /// The command launched by `aoc code`.
39    pub editor: String,
40    /// The directory the configuration was loaded from.
41    pub config_dir: PathBuf,
42}
43
44/// A non-fatal problem noticed while loading configuration.
45pub type Warning = String;
46
47#[derive(Debug, Deserialize)]
48struct RawConfig {
49    template_path: String,
50    #[serde(default)]
51    cookie: Option<String>,
52    #[serde(default)]
53    editor: Option<String>,
54    #[serde(flatten)]
55    unknown: BTreeMap<String, serde_yaml_ng::Value>,
56}
57
58impl Config {
59    /// Loads and validates the configuration described by `env`.
60    ///
61    /// Returns the configuration together with any non-fatal warnings, such as
62    /// unrecognised keys - a misspelled `cookies:` would otherwise silently
63    /// disable submission.
64    ///
65    /// With no cookie in the environment or the file itself, the
66    /// [`COOKIE_FILE_NAME`] file beside `config.yaml` is read as one.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`ConfigError`] if the file is missing, unreadable, not valid
71    /// YAML, contains an invalid `template_path`, or if a cookie file exists
72    /// but cannot be read.
73    pub fn load(env: &Env) -> Result<(Self, Vec<Warning>), ConfigError> {
74        let contents = read_config(&env.config_file)?;
75        let (mut config, warnings) = Self::from_yaml(&contents, env)?;
76
77        if config.cookie.is_none() {
78            config.cookie = read_cookie(&env.config_dir.join(COOKIE_FILE_NAME))?;
79        }
80
81        Ok((config, warnings))
82    }
83
84    /// Parses configuration from a YAML document, resolving paths and the
85    /// session cookie against `env`. Unlike [`Config::load`], this does not
86    /// fall back to the cookie file.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`ConfigError`] if the document is not valid YAML or the
91    /// `template_path` is not a valid template.
92    pub fn from_yaml(contents: &str, env: &Env) -> Result<(Self, Vec<Warning>), ConfigError> {
93        let raw: RawConfig =
94            serde_yaml_ng::from_str(contents).map_err(|source| ConfigError::Parse {
95                path: env.config_file.clone(),
96                source,
97            })?;
98
99        let mut warnings = Vec::new();
100        for key in raw.unknown.keys() {
101            warnings.push(format!(
102                "ignoring unknown key `{key}` in {}",
103                env.config_file.display()
104            ));
105        }
106
107        let template_path = expand_home(&raw.template_path, &env.home);
108        let template = Template::parse(&template_path).map_err(|source| ConfigError::Template {
109            path: env.config_file.clone(),
110            source,
111        })?;
112
113        let cookie = env
114            .session_cookie
115            .clone()
116            .or(raw.cookie)
117            .map(|cookie| cookie.trim().to_owned())
118            .filter(|cookie| !cookie.is_empty());
119
120        Ok((
121            Self {
122                template,
123                cookie,
124                editor: raw
125                    .editor
126                    .map(|editor| editor.trim().to_owned())
127                    .filter(|editor| !editor.is_empty())
128                    .unwrap_or_else(|| DEFAULT_EDITOR.to_owned()),
129                config_dir: env.config_dir.clone(),
130            },
131            warnings,
132        ))
133    }
134}
135
136fn read_config(path: &Path) -> Result<String, ConfigError> {
137    match fs::read_to_string(path) {
138        Ok(contents) => Ok(contents),
139        Err(source) if source.kind() == io::ErrorKind::NotFound => Err(ConfigError::NotFound {
140            path: path.to_path_buf(),
141        }),
142        Err(source) => Err(ConfigError::Read {
143            path: path.to_path_buf(),
144            source,
145        }),
146    }
147}
148
149fn read_cookie(path: &Path) -> Result<Option<String>, ConfigError> {
150    match fs::read_to_string(path) {
151        Ok(contents) => Ok(Some(contents.trim().to_owned()).filter(|cookie| !cookie.is_empty())),
152        Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
153        Err(source) => Err(ConfigError::Cookie {
154            path: path.to_path_buf(),
155            source,
156        }),
157    }
158}
159
160fn expand_home(path: &str, home: &Path) -> String {
161    let expanded = match path {
162        "~" => home.to_path_buf(),
163        _ => match path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
164            Some(rest) => home.join(rest),
165            None => return path.to_owned(),
166        },
167    };
168
169    expanded.to_string_lossy().into_owned()
170}
171
172/// Errors produced while loading configuration.
173#[derive(Debug, thiserror::Error)]
174pub enum ConfigError {
175    /// No configuration file exists.
176    #[error(
177        "no config file at {path}\n\n\
178         create it with at least a project path template, for example:\n  \
179         template_path: \"~/projects/aoc/{{{{year}}}}/day{{{{pad day}}}}/{{{{language}}}}\""
180    )]
181    NotFound {
182        /// Where the file was expected.
183        path: PathBuf,
184    },
185    /// The configuration file could not be read.
186    #[error("failed to read config file {path}")]
187    Read {
188        /// The file that could not be read.
189        path: PathBuf,
190        /// The underlying I/O error.
191        #[source]
192        source: io::Error,
193    },
194    /// The configuration file is not valid YAML, or is missing a required key.
195    #[error("failed to parse config file {path}")]
196    Parse {
197        /// The offending file.
198        path: PathBuf,
199        /// The underlying deserialisation error.
200        #[source]
201        source: serde_yaml_ng::Error,
202    },
203    /// The cookie file exists but could not be read.
204    #[error("failed to read cookie file {path}")]
205    Cookie {
206        /// The file that could not be read.
207        path: PathBuf,
208        /// The underlying I/O error.
209        #[source]
210        source: io::Error,
211    },
212    /// The `template_path` is not a valid template.
213    #[error("invalid `template_path` in {path}")]
214    Template {
215        /// The offending file.
216        path: PathBuf,
217        /// The underlying template error.
218        #[source]
219        source: crate::template::TemplateError,
220    },
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::{
227        env::CONFIG_FILE_NAME, language::Language, puzzle::Day, puzzle::Year, template::Params,
228    };
229
230    fn env() -> Env {
231        Env {
232            home: PathBuf::from("/home/tester"),
233            config_dir: PathBuf::from("/home/tester/.config/aoc"),
234            config_file: PathBuf::from("/home/tester/.config/aoc/config.yaml"),
235            state_dir: PathBuf::from("/home/tester/.local/state/aoc"),
236            cwd: PathBuf::from("/home/tester"),
237            session_cookie: None,
238        }
239    }
240
241    fn load(yaml: &str) -> Result<(Config, Vec<Warning>), ConfigError> {
242        Config::from_yaml(yaml, &env())
243    }
244
245    /// Writes `config.yaml`, and a cookie file when one is given, into a
246    /// throwaway configuration directory, then loads it from disk.
247    fn load_from_disk(
248        yaml: &str,
249        cookie_file: Option<&str>,
250    ) -> Result<(Config, Vec<Warning>), ConfigError> {
251        let dir = tempfile::tempdir().expect("temp dir");
252        fs::write(dir.path().join(CONFIG_FILE_NAME), yaml).expect("write config");
253        if let Some(cookie) = cookie_file {
254            fs::write(dir.path().join(COOKIE_FILE_NAME), cookie).expect("write cookie file");
255        }
256
257        let mut env = env();
258        env.config_dir = dir.path().to_path_buf();
259        env.config_file = dir.path().join(CONFIG_FILE_NAME);
260
261        Config::load(&env)
262    }
263
264    fn project_path(config: &Config) -> PathBuf {
265        config.template.render(Params {
266            year: Year::new(2024).expect("valid year"),
267            day: Day::new(7).expect("valid day"),
268            language: Language::Rust,
269        })
270    }
271
272    #[test]
273    fn loads_a_minimal_config() {
274        let (config, warnings) =
275            load("template_path: \"/aoc/{{year}}/day{{pad day}}/{{language}}\"")
276                .expect("config should load");
277
278        assert!(warnings.is_empty());
279        assert_eq!(config.cookie, None);
280        assert_eq!(config.editor, DEFAULT_EDITOR);
281        assert_eq!(project_path(&config), Path::new("/aoc/2024/day07/rust"));
282    }
283
284    #[test]
285    fn expands_a_leading_tilde() {
286        let (config, _) = load("template_path: \"~/aoc/{{year}}/day{{pad day}}/{{language}}\"")
287            .expect("config should load");
288
289        assert_eq!(
290            project_path(&config),
291            Path::new("/home/tester/aoc/2024/day07/rust")
292        );
293    }
294
295    #[test]
296    fn leaves_a_tilde_elsewhere_alone() {
297        let (config, _) = load("template_path: \"/aoc/~backup/{{year}}/day{{day}}\"")
298            .expect("config should load");
299
300        assert_eq!(project_path(&config), Path::new("/aoc/~backup/2024/day7"));
301    }
302
303    #[test]
304    fn reads_the_cookie_and_editor() {
305        let (config, _) = load(
306            "template_path: \"/aoc/{{year}}/day{{day}}\"\ncookie: \"  abc123  \"\neditor: nvim\n",
307        )
308        .expect("config should load");
309
310        assert_eq!(config.cookie.as_deref(), Some("abc123"));
311        assert_eq!(config.editor, "nvim");
312    }
313
314    #[test]
315    fn an_empty_cookie_is_no_cookie() {
316        let (config, _) = load("template_path: \"/aoc/{{year}}/day{{day}}\"\ncookie: \"\"\n")
317            .expect("config should load");
318
319        assert_eq!(config.cookie, None);
320    }
321
322    #[test]
323    fn the_environment_cookie_wins() {
324        let mut env = env();
325        env.session_cookie = Some("from-env".to_owned());
326
327        let (config, _) = Config::from_yaml(
328            "template_path: \"/aoc/{{year}}/day{{day}}\"\ncookie: from-file\n",
329            &env,
330        )
331        .expect("config should load");
332
333        assert_eq!(config.cookie.as_deref(), Some("from-env"));
334    }
335
336    #[test]
337    fn the_cookie_file_stands_in_for_a_missing_cookie_key() {
338        let (config, _) = load_from_disk(
339            "template_path: \"/aoc/{{year}}/day{{day}}\"\n",
340            Some("from-file\n"),
341        )
342        .expect("config should load");
343
344        assert_eq!(config.cookie.as_deref(), Some("from-file"));
345    }
346
347    #[test]
348    fn the_configured_cookie_wins_over_the_cookie_file() {
349        let (config, _) = load_from_disk(
350            "template_path: \"/aoc/{{year}}/day{{day}}\"\ncookie: from-config\n",
351            Some("from-file"),
352        )
353        .expect("config should load");
354
355        assert_eq!(config.cookie.as_deref(), Some("from-config"));
356    }
357
358    #[test]
359    fn a_blank_cookie_file_is_no_cookie() {
360        let (config, _) = load_from_disk(
361            "template_path: \"/aoc/{{year}}/day{{day}}\"\n",
362            Some("  \n"),
363        )
364        .expect("config should load");
365
366        assert_eq!(config.cookie, None);
367    }
368
369    #[test]
370    fn no_cookie_file_is_no_cookie() {
371        let (config, _) = load_from_disk("template_path: \"/aoc/{{year}}/day{{day}}\"\n", None)
372            .expect("config should load");
373
374        assert_eq!(config.cookie, None);
375    }
376
377    #[test]
378    fn unknown_keys_produce_a_warning_instead_of_silence() {
379        let (config, warnings) =
380            load("template_path: \"/aoc/{{year}}/day{{day}}\"\ncookies: oops\n")
381                .expect("config should load");
382
383        assert_eq!(config.cookie, None);
384        assert_eq!(warnings.len(), 1);
385        assert!(warnings[0].contains("cookies"), "{warnings:?}");
386    }
387
388    #[test]
389    fn a_missing_template_path_is_an_error() {
390        let error = load("cookie: abc123").expect_err("template_path is required");
391
392        assert!(matches!(error, ConfigError::Parse { .. }), "got {error:?}");
393    }
394
395    #[test]
396    fn an_invalid_template_names_the_config_file() {
397        let error = load("template_path: \"/aoc/{{year}}\"").expect_err("day is missing");
398
399        assert!(
400            matches!(error, ConfigError::Template { .. }),
401            "got {error:?}"
402        );
403        assert!(error.to_string().contains("config.yaml"), "{error}");
404    }
405
406    #[test]
407    fn malformed_yaml_is_an_error() {
408        let error = load("template_path: [unclosed").expect_err("yaml is malformed");
409
410        assert!(matches!(error, ConfigError::Parse { .. }), "got {error:?}");
411    }
412
413    #[test]
414    fn a_missing_file_explains_how_to_create_one() {
415        let error = read_config(Path::new("/nonexistent/aoc/config.yaml"))
416            .expect_err("file should not exist");
417
418        assert!(
419            matches!(error, ConfigError::NotFound { .. }),
420            "got {error:?}"
421        );
422        assert!(error.to_string().contains("template_path"), "{error}");
423    }
424}