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
12use crate::{env::Env, template::Template};
13use serde::Deserialize;
14use std::{
15    collections::BTreeMap,
16    fs, io,
17    path::{Path, PathBuf},
18};
19
20/// The default editor launched by `aoc code`.
21pub const DEFAULT_EDITOR: &str = "code";
22
23/// Validated configuration.
24#[derive(Debug, Clone)]
25pub struct Config {
26    /// The parsed project path template.
27    pub template: Template,
28    /// The Advent of Code session cookie, if one is available.
29    pub cookie: Option<String>,
30    /// The command launched by `aoc code`.
31    pub editor: String,
32    /// The directory the configuration was loaded from.
33    pub config_dir: PathBuf,
34}
35
36/// A non-fatal problem noticed while loading configuration.
37pub type Warning = String;
38
39#[derive(Debug, Deserialize)]
40struct RawConfig {
41    template_path: String,
42    #[serde(default)]
43    cookie: Option<String>,
44    #[serde(default)]
45    editor: Option<String>,
46    #[serde(flatten)]
47    unknown: BTreeMap<String, serde_yaml_ng::Value>,
48}
49
50impl Config {
51    /// Loads and validates the configuration described by `env`.
52    ///
53    /// Returns the configuration together with any non-fatal warnings, such as
54    /// unrecognised keys - a misspelled `cookies:` would otherwise silently
55    /// disable submission.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`ConfigError`] if the file is missing, unreadable, not valid
60    /// YAML, or contains an invalid `template_path`.
61    pub fn load(env: &Env) -> Result<(Self, Vec<Warning>), ConfigError> {
62        let contents = read_config(&env.config_file)?;
63        Self::from_yaml(&contents, env)
64    }
65
66    /// Parses configuration from a YAML document, resolving paths and the
67    /// session cookie against `env`.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`ConfigError`] if the document is not valid YAML or the
72    /// `template_path` is not a valid template.
73    pub fn from_yaml(contents: &str, env: &Env) -> Result<(Self, Vec<Warning>), ConfigError> {
74        let raw: RawConfig =
75            serde_yaml_ng::from_str(contents).map_err(|source| ConfigError::Parse {
76                path: env.config_file.clone(),
77                source,
78            })?;
79
80        let mut warnings = Vec::new();
81        for key in raw.unknown.keys() {
82            warnings.push(format!(
83                "ignoring unknown key `{key}` in {}",
84                env.config_file.display()
85            ));
86        }
87
88        let template_path = expand_home(&raw.template_path, &env.home);
89        let template = Template::parse(&template_path).map_err(|source| ConfigError::Template {
90            path: env.config_file.clone(),
91            source,
92        })?;
93
94        let cookie = env
95            .session_cookie
96            .clone()
97            .or(raw.cookie)
98            .map(|cookie| cookie.trim().to_owned())
99            .filter(|cookie| !cookie.is_empty());
100
101        Ok((
102            Self {
103                template,
104                cookie,
105                editor: raw
106                    .editor
107                    .map(|editor| editor.trim().to_owned())
108                    .filter(|editor| !editor.is_empty())
109                    .unwrap_or_else(|| DEFAULT_EDITOR.to_owned()),
110                config_dir: env.config_dir.clone(),
111            },
112            warnings,
113        ))
114    }
115}
116
117fn read_config(path: &Path) -> Result<String, ConfigError> {
118    match fs::read_to_string(path) {
119        Ok(contents) => Ok(contents),
120        Err(source) if source.kind() == io::ErrorKind::NotFound => Err(ConfigError::NotFound {
121            path: path.to_path_buf(),
122        }),
123        Err(source) => Err(ConfigError::Read {
124            path: path.to_path_buf(),
125            source,
126        }),
127    }
128}
129
130fn expand_home(path: &str, home: &Path) -> String {
131    let expanded = match path {
132        "~" => home.to_path_buf(),
133        _ => match path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
134            Some(rest) => home.join(rest),
135            None => return path.to_owned(),
136        },
137    };
138
139    expanded.to_string_lossy().into_owned()
140}
141
142/// Errors produced while loading configuration.
143#[derive(Debug, thiserror::Error)]
144pub enum ConfigError {
145    /// No configuration file exists.
146    #[error(
147        "no config file at {path}\n\n\
148         create it with at least a project path template, for example:\n  \
149         template_path: \"~/projects/aoc/{{{{year}}}}/day{{{{pad day}}}}/{{{{language}}}}\""
150    )]
151    NotFound {
152        /// Where the file was expected.
153        path: PathBuf,
154    },
155    /// The configuration file could not be read.
156    #[error("failed to read config file {path}")]
157    Read {
158        /// The file that could not be read.
159        path: PathBuf,
160        /// The underlying I/O error.
161        #[source]
162        source: io::Error,
163    },
164    /// The configuration file is not valid YAML, or is missing a required key.
165    #[error("failed to parse config file {path}")]
166    Parse {
167        /// The offending file.
168        path: PathBuf,
169        /// The underlying deserialisation error.
170        #[source]
171        source: serde_yaml_ng::Error,
172    },
173    /// The `template_path` is not a valid template.
174    #[error("invalid `template_path` in {path}")]
175    Template {
176        /// The offending file.
177        path: PathBuf,
178        /// The underlying template error.
179        #[source]
180        source: crate::template::TemplateError,
181    },
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::{language::Language, puzzle::Day, puzzle::Year, template::Params};
188
189    fn env() -> Env {
190        Env {
191            home: PathBuf::from("/home/tester"),
192            config_dir: PathBuf::from("/home/tester/.config/aoc"),
193            config_file: PathBuf::from("/home/tester/.config/aoc/config.yaml"),
194            state_dir: PathBuf::from("/home/tester/.local/state/aoc"),
195            cwd: PathBuf::from("/home/tester"),
196            session_cookie: None,
197        }
198    }
199
200    fn load(yaml: &str) -> Result<(Config, Vec<Warning>), ConfigError> {
201        Config::from_yaml(yaml, &env())
202    }
203
204    fn project_path(config: &Config) -> PathBuf {
205        config.template.render(Params {
206            year: Year::new(2024).expect("valid year"),
207            day: Day::new(7).expect("valid day"),
208            language: Language::Rust,
209        })
210    }
211
212    #[test]
213    fn loads_a_minimal_config() {
214        let (config, warnings) =
215            load("template_path: \"/aoc/{{year}}/day{{pad day}}/{{language}}\"")
216                .expect("config should load");
217
218        assert!(warnings.is_empty());
219        assert_eq!(config.cookie, None);
220        assert_eq!(config.editor, DEFAULT_EDITOR);
221        assert_eq!(project_path(&config), Path::new("/aoc/2024/day07/rust"));
222    }
223
224    #[test]
225    fn expands_a_leading_tilde() {
226        let (config, _) = load("template_path: \"~/aoc/{{year}}/day{{pad day}}/{{language}}\"")
227            .expect("config should load");
228
229        assert_eq!(
230            project_path(&config),
231            Path::new("/home/tester/aoc/2024/day07/rust")
232        );
233    }
234
235    #[test]
236    fn leaves_a_tilde_elsewhere_alone() {
237        let (config, _) = load("template_path: \"/aoc/~backup/{{year}}/day{{day}}\"")
238            .expect("config should load");
239
240        assert_eq!(project_path(&config), Path::new("/aoc/~backup/2024/day7"));
241    }
242
243    #[test]
244    fn reads_the_cookie_and_editor() {
245        let (config, _) = load(
246            "template_path: \"/aoc/{{year}}/day{{day}}\"\ncookie: \"  abc123  \"\neditor: nvim\n",
247        )
248        .expect("config should load");
249
250        assert_eq!(config.cookie.as_deref(), Some("abc123"));
251        assert_eq!(config.editor, "nvim");
252    }
253
254    #[test]
255    fn an_empty_cookie_is_no_cookie() {
256        let (config, _) = load("template_path: \"/aoc/{{year}}/day{{day}}\"\ncookie: \"\"\n")
257            .expect("config should load");
258
259        assert_eq!(config.cookie, None);
260    }
261
262    #[test]
263    fn the_environment_cookie_wins() {
264        let mut env = env();
265        env.session_cookie = Some("from-env".to_owned());
266
267        let (config, _) = Config::from_yaml(
268            "template_path: \"/aoc/{{year}}/day{{day}}\"\ncookie: from-file\n",
269            &env,
270        )
271        .expect("config should load");
272
273        assert_eq!(config.cookie.as_deref(), Some("from-env"));
274    }
275
276    #[test]
277    fn unknown_keys_produce_a_warning_instead_of_silence() {
278        let (config, warnings) =
279            load("template_path: \"/aoc/{{year}}/day{{day}}\"\ncookies: oops\n")
280                .expect("config should load");
281
282        assert_eq!(config.cookie, None);
283        assert_eq!(warnings.len(), 1);
284        assert!(warnings[0].contains("cookies"), "{warnings:?}");
285    }
286
287    #[test]
288    fn a_missing_template_path_is_an_error() {
289        let error = load("cookie: abc123").expect_err("template_path is required");
290
291        assert!(matches!(error, ConfigError::Parse { .. }), "got {error:?}");
292    }
293
294    #[test]
295    fn an_invalid_template_names_the_config_file() {
296        let error = load("template_path: \"/aoc/{{year}}\"").expect_err("day is missing");
297
298        assert!(
299            matches!(error, ConfigError::Template { .. }),
300            "got {error:?}"
301        );
302        assert!(error.to_string().contains("config.yaml"), "{error}");
303    }
304
305    #[test]
306    fn malformed_yaml_is_an_error() {
307        let error = load("template_path: [unclosed").expect_err("yaml is malformed");
308
309        assert!(matches!(error, ConfigError::Parse { .. }), "got {error:?}");
310    }
311
312    #[test]
313    fn a_missing_file_explains_how_to_create_one() {
314        let error = read_config(Path::new("/nonexistent/aoc/config.yaml"))
315            .expect_err("file should not exist");
316
317        assert!(
318            matches!(error, ConfigError::NotFound { .. }),
319            "got {error:?}"
320        );
321        assert!(error.to_string().contains("template_path"), "{error}");
322    }
323}