1use crate::{env::Env, template::Template};
13use serde::Deserialize;
14use std::{
15 collections::BTreeMap,
16 fs, io,
17 path::{Path, PathBuf},
18};
19
20pub const DEFAULT_EDITOR: &str = "code";
22
23#[derive(Debug, Clone)]
25pub struct Config {
26 pub template: Template,
28 pub cookie: Option<String>,
30 pub editor: String,
32 pub config_dir: PathBuf,
34}
35
36pub 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 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 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#[derive(Debug, thiserror::Error)]
144pub enum ConfigError {
145 #[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 path: PathBuf,
154 },
155 #[error("failed to read config file {path}")]
157 Read {
158 path: PathBuf,
160 #[source]
162 source: io::Error,
163 },
164 #[error("failed to parse config file {path}")]
166 Parse {
167 path: PathBuf,
169 #[source]
171 source: serde_yaml_ng::Error,
172 },
173 #[error("invalid `template_path` in {path}")]
175 Template {
176 path: PathBuf,
178 #[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}