1use crate::{env::Env, template::Template};
18use serde::Deserialize;
19use std::{
20 collections::BTreeMap,
21 fs, io,
22 path::{Path, PathBuf},
23};
24
25pub const DEFAULT_EDITOR: &str = "code";
27
28pub const COOKIE_FILE_NAME: &str = "COOKIE";
31
32#[derive(Clone)]
34pub struct Config {
35 pub template: Template,
37 pub cookie: Option<String>,
39 pub editor: String,
41 pub config_dir: PathBuf,
43}
44
45impl 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
59pub 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 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 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
171fn 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#[derive(Debug, thiserror::Error)]
196#[non_exhaustive]
197pub enum ConfigError {
198 #[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 path: PathBuf,
207 },
208 #[error("failed to read config file {path}")]
210 Read {
211 path: PathBuf,
213 #[source]
215 source: io::Error,
216 },
217 #[error("failed to parse config file {path}")]
219 Parse {
220 path: PathBuf,
222 #[source]
224 source: serde_yaml_ng::Error,
225 },
226 #[error("invalid `template_path` in {path}")]
228 Template {
229 path: PathBuf,
231 #[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 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 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}