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