1use crate::{env::Env, template::Template};
17use serde::Deserialize;
18use std::{
19 collections::BTreeMap,
20 fs, io,
21 path::{Path, PathBuf},
22};
23
24pub const DEFAULT_EDITOR: &str = "code";
26
27pub const COOKIE_FILE_NAME: &str = "COOKIE";
30
31#[derive(Debug, Clone)]
33pub struct Config {
34 pub template: Template,
36 pub cookie: Option<String>,
38 pub editor: String,
40 pub config_dir: PathBuf,
42}
43
44pub 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 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 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#[derive(Debug, thiserror::Error)]
174pub enum ConfigError {
175 #[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 path: PathBuf,
184 },
185 #[error("failed to read config file {path}")]
187 Read {
188 path: PathBuf,
190 #[source]
192 source: io::Error,
193 },
194 #[error("failed to parse config file {path}")]
196 Parse {
197 path: PathBuf,
199 #[source]
201 source: serde_yaml_ng::Error,
202 },
203 #[error("failed to read cookie file {path}")]
205 Cookie {
206 path: PathBuf,
208 #[source]
210 source: io::Error,
211 },
212 #[error("invalid `template_path` in {path}")]
214 Template {
215 path: PathBuf,
217 #[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 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}