advent_of_code_data/
config.rs1use std::{
2 fs,
3 path::{Path, PathBuf},
4};
5use thiserror::Error;
6
7const DIRS_QUALIFIER: &str = "com";
13const DIRS_ORG: &str = "smacdo";
14const DIRS_APP: &str = "advent_of_code_data";
15
16const CONFIG_FILENAME: &str = "aoc_settings.toml";
17const EXAMPLE_CONFIG_FILENAME: &str = "aoc_settings.example.toml";
18const HOME_DIR_CONFIG_FILENAME: &str = ".aoc_settings.toml";
19
20const EXAMPLE_CONFIG_TEXT: &str = r#"[client]
21# passphrase = "REPLACE_ME" # Used to encrypt/decrypt the puzzle cache.
22# session_id = "REPLACE_ME" # See "Finding your Advent of Code session cookie" in the README for help.
23"#;
24
25#[derive(Debug, Error)]
27pub enum ConfigError {
28 #[error(
29 "session cookie required; use the advent-of-code-data README to learn how to obtain this"
30 )]
31 SessionIdRequired,
32 #[error("an passphrase for encrypting puzzle inputs is required")]
33 PassphraseRequired,
34 #[error("a puzzle cache directory is required")]
35 PuzzleCacheDirRequired,
36 #[error("a session cache directory is required")]
37 SessionCacheDirRequired,
38 #[error("{}", .0)]
39 IoError(#[from] std::io::Error),
40 #[error("{}", .0)]
41 TomlError(#[from] toml::de::Error),
42}
43
44#[derive(Default, Debug)]
50pub struct Config {
51 pub session_id: String,
53 pub puzzle_dir: PathBuf,
55 pub sessions_dir: PathBuf,
57 pub passphrase: String,
59 pub start_time: chrono::DateTime<chrono::Utc>,
61}
62
63pub struct ConfigBuilder {
65 pub session_id: Option<String>,
66 pub puzzle_dir: Option<PathBuf>,
67 pub sessions_dir: Option<PathBuf>,
68 pub passphrase: Option<String>,
69 pub fake_time: Option<chrono::DateTime<chrono::Utc>>,
70}
71
72impl ConfigBuilder {
73 pub fn new() -> Self {
74 let project_dir = directories::ProjectDirs::from(DIRS_QUALIFIER, DIRS_ORG, DIRS_APP)
77 .expect("TODO: implement default fallback cache directories if this fails");
78
79 Self {
80 session_id: None,
81 puzzle_dir: Some(project_dir.cache_dir().join("puzzles").to_path_buf()),
82 sessions_dir: Some(project_dir.cache_dir().join("sessions").to_path_buf()),
83 passphrase: None,
84 fake_time: None,
85 }
86 }
87
88 pub fn use_toml(mut self, config_text: &str) -> Result<Self, ConfigError> {
91 const CLIENT_TABLE_NAME: &str = "client";
92 const SESSIONS_DIR_KEY: &str = "sessions_dir";
93 const SESSION_ID_KEY: &str = "session_id";
94 const PUZZLE_DIR_KEY: &str = "puzzle_dir";
95 const PASSPHRASE_KEY: &str = "passphrase";
96 const REPLACE_ME: &str = "REPLACE_ME";
97
98 fn try_read_key<F: FnOnce(&str)>(table: &toml::Table, key: &str, setter: F) {
99 match table.get(key).as_ref() {
100 Some(toml::Value::String(s)) => {
101 if s == REPLACE_ME {
102 tracing::debug!("ignoring TOML key {key} because value is `{REPLACE_ME}`");
103 } else {
104 tracing::debug!("found TOML key `{key}` with value `{s}`");
105 setter(s)
106 }
107 }
108 None => {
109 tracing::debug!("TOML key {key} not present, or its value was not a string");
110 }
111 _ => {
112 tracing::warn!("TOML key {key} must be string value");
113 }
114 };
115 }
116
117 let toml: toml::Table = config_text.parse::<toml::Table>()?;
118
119 match toml.get(CLIENT_TABLE_NAME) {
120 Some(toml::Value::Table(client_config)) => {
121 try_read_key(client_config, PASSPHRASE_KEY, |v| {
122 self.passphrase = Some(v.to_string())
123 });
124
125 try_read_key(client_config, SESSION_ID_KEY, |v| {
126 self.session_id = Some(v.to_string())
127 });
128
129 try_read_key(client_config, PUZZLE_DIR_KEY, |v| {
130 self.puzzle_dir = Some(PathBuf::from(v))
131 });
132
133 try_read_key(client_config, SESSIONS_DIR_KEY, |v| {
134 self.sessions_dir = Some(PathBuf::from(v))
135 });
136 }
137 _ => {
138 tracing::warn!(
139 "TOML table {CLIENT_TABLE_NAME} was missing; this config will be skipped!"
140 );
141 }
142 }
143
144 Ok(self)
145 }
146
147 pub fn with_session_id<S: Into<String>>(mut self, session_id: S) -> Self {
148 self.session_id = Some(session_id.into());
149 self
150 }
151
152 pub fn with_puzzle_dir<P: Into<PathBuf>>(mut self, puzzle_dir: P) -> Self {
153 self.puzzle_dir = Some(puzzle_dir.into());
154 self
155 }
156
157 pub fn with_passphrase<S: Into<String>>(mut self, passphrase: S) -> Self {
158 self.passphrase = Some(passphrase.into());
159 self
160 }
161
162 pub fn with_fake_time(mut self, fake_time: chrono::DateTime<chrono::Utc>) -> Self {
163 self.fake_time = Some(fake_time);
164 self
165 }
166
167 pub fn build(self) -> Result<Config, ConfigError> {
168 Ok(Config {
169 session_id: self.session_id.ok_or(ConfigError::SessionIdRequired)?,
170 puzzle_dir: self.puzzle_dir.ok_or(ConfigError::PuzzleCacheDirRequired)?,
171 sessions_dir: self
172 .sessions_dir
173 .ok_or(ConfigError::SessionCacheDirRequired)?,
174 passphrase: self.passphrase.ok_or(ConfigError::PassphraseRequired)?,
175 start_time: self.fake_time.unwrap_or(chrono::Utc::now()),
176 })
177 }
178}
179
180impl Default for ConfigBuilder {
181 fn default() -> Self {
182 Self::new()
183 }
184}
185
186pub fn load_config() -> Result<ConfigBuilder, ConfigError> {
191 let mut config: ConfigBuilder = Default::default();
192
193 config = read_config_from_user_config_dirs(Some(config))?;
194 config = read_config_from_current_dir(Some(config))?;
195 config = read_config_from_env_vars(Some(config));
196
197 Ok(config)
198}
199
200pub fn read_config_from_file<P: AsRef<Path>>(
202 config: Option<ConfigBuilder>,
203 path: P,
204) -> Result<ConfigBuilder, ConfigError> {
205 let config = config.unwrap_or_default();
206 let config_text = fs::read_to_string(&path)?;
207
208 config.use_toml(&config_text)
209}
210
211pub fn read_config_from_current_dir(
213 config: Option<ConfigBuilder>,
214) -> Result<ConfigBuilder, ConfigError> {
215 let mut config = config.unwrap_or_default();
216
217 match std::env::current_dir() {
218 Ok(current_dir) => {
219 let local_config_path = current_dir.join(CONFIG_FILENAME);
220 tracing::debug!("loading current directory config values from: {local_config_path:?}");
221
222 if local_config_path.exists() {
223 config = read_config_from_file(Some(config), local_config_path)?;
224 } else {
225 tracing::warn!("loading config from current directory will be skipped because {local_config_path:?} does not exist")
226 }
227 }
228 Err(e) => {
229 tracing::error!("loading config from current directory will be skipped because {e}")
230 }
231 }
232
233 Ok(config)
234}
235
236pub fn read_config_from_user_config_dirs(
239 config: Option<ConfigBuilder>,
240) -> Result<ConfigBuilder, ConfigError> {
241 let mut config = config.unwrap_or_default();
242
243 const CUSTOM_CONFIG_ENV_KEY: &str = "AOC_CONFIG_PATH";
246
247 if let Ok(custom_config_path) = std::env::var(CUSTOM_CONFIG_ENV_KEY) {
248 if std::fs::exists(&custom_config_path).unwrap_or(false) {
249 tracing::debug!("loading user config at: {custom_config_path:?}");
250 config = read_config_from_file(Some(config), custom_config_path)?;
251 } else {
252 tracing::debug!("no user config found at: {custom_config_path:?}");
253 }
254
255 return Ok(config);
256 } else {
257 tracing::debug!(
258 "skipping custom user config because env var `{CUSTOM_CONFIG_ENV_KEY}` is not set"
259 );
260 }
261
262 if let Some(project_dir) = directories::ProjectDirs::from(DIRS_QUALIFIER, DIRS_ORG, DIRS_APP) {
264 let config_dir = project_dir.config_dir();
265 let example_config_path = config_dir.join(EXAMPLE_CONFIG_FILENAME);
266
267 if !std::fs::exists(config_dir).unwrap_or(false) {
269 std::fs::create_dir_all(config_dir).unwrap_or_else(|e| {
270 tracing::debug!("failed to create app config dir: {e:?}");
271 });
272 }
273
274 if !std::fs::exists(&example_config_path).unwrap_or(false) {
277 tracing::debug!("created example config at {example_config_path:?}");
278
279 std::fs::write(example_config_path, EXAMPLE_CONFIG_TEXT).unwrap_or_else(|e| {
280 tracing::debug!("failed to create example config: {e:?}");
281 });
282 }
283
284 let config_path = config_dir.join(CONFIG_FILENAME);
286
287 if std::fs::exists(&config_path).unwrap_or(false) {
288 tracing::debug!("loading user config at: {config_path:?}");
289 return read_config_from_file(Some(config), config_path);
290 } else {
291 tracing::debug!("no user config found at: {config_path:?}");
292 }
293 } else {
294 tracing::debug!("could not calculate user config dir on this machine");
295 }
296
297 if let Some(base_dirs) = directories::BaseDirs::new() {
299 let home_config_path = base_dirs.home_dir().join(HOME_DIR_CONFIG_FILENAME);
300
301 if std::fs::exists(&home_config_path).unwrap_or(false) {
302 tracing::debug!("loading user config at: {home_config_path:?}");
303 config = read_config_from_file(Some(config), home_config_path)?;
304 } else {
305 tracing::debug!("no user config found at: {home_config_path:?}");
306 }
307 }
308
309 Ok(config)
310}
311
312pub fn read_config_from_env_vars(config: Option<ConfigBuilder>) -> ConfigBuilder {
315 const SESSION_ID_ENV_KEY: &str = "AOC_SESSION";
317 const PASSPHRASE_ENV_KEY: &str = "AOC_PASSPHRASE";
318 const PUZZLE_DIR_KEY: &str = "AOC_PUZZLE_DIR";
319 const SESSIONS_DIR_KEY: &str = "AOC_SESSIONS_DIR";
320
321 let mut config = config.unwrap_or_default();
322
323 fn try_read_env_var<F: FnOnce(String)>(name: &str, setter: F) {
324 if let Ok(v) = std::env::var(name) {
325 tracing::debug!("found env var `{name}` with value `{v}`");
326 setter(v)
327 }
328 }
329
330 try_read_env_var(SESSION_ID_ENV_KEY, |v| {
331 config.session_id = Some(v);
332 });
333
334 try_read_env_var(PASSPHRASE_ENV_KEY, |v| {
335 config.passphrase = Some(v);
336 });
337
338 try_read_env_var(PUZZLE_DIR_KEY, |v| {
339 config.puzzle_dir = Some(PathBuf::from(v));
340 });
341
342 try_read_env_var(SESSIONS_DIR_KEY, |v| {
343 config.sessions_dir = Some(PathBuf::from(v));
344 });
345
346 config
347}
348
349#[cfg(test)]
350mod tests {
351 use std::str::FromStr;
352
353 use super::*;
354
355 #[test]
356 fn client_can_overwrite_options() {
357 let mut options = ConfigBuilder::new().with_passphrase("12345");
358 assert_eq!(options.passphrase, Some("12345".to_string()));
359
360 options = options.with_passphrase("54321");
361 assert_eq!(options.passphrase, Some("54321".to_string()));
362 }
363
364 #[test]
365 fn set_client_options_with_builder_funcs() {
366 let options = ConfigBuilder::new()
367 .with_session_id("MY_SESSION_ID")
368 .with_puzzle_dir("MY_CACHE_DIR")
369 .with_passphrase("MY_PASSWORD");
370
371 assert_eq!(options.session_id, Some("MY_SESSION_ID".to_string()));
372 assert_eq!(
373 options.puzzle_dir,
374 Some(PathBuf::from_str("MY_CACHE_DIR").unwrap())
375 );
376 assert_eq!(options.passphrase, Some("MY_PASSWORD".to_string()));
377 }
378
379 #[test]
380 fn set_client_options_from_toml() {
381 let config_text = r#"
382 [client]
383 session_id = "12345"
384 puzzle_dir = "path/to/puzzle/dir"
385 sessions_dir = "another/path/to/blah"
386 passphrase = "foobar"
387 "#;
388
389 let options = ConfigBuilder::new().use_toml(config_text).unwrap();
390
391 assert_eq!(options.session_id, Some("12345".to_string()));
392 assert_eq!(
393 options.puzzle_dir,
394 Some(PathBuf::from_str("path/to/puzzle/dir").unwrap())
395 );
396 assert_eq!(
397 options.sessions_dir,
398 Some(PathBuf::from_str("another/path/to/blah").unwrap())
399 );
400 assert_eq!(options.passphrase, Some("foobar".to_string()));
401 }
402
403 #[test]
404 fn set_client_options_from_toml_ignores_missing_fields() {
405 let config_text = r#"
406 [client]
407 session_id = "12345"
408 passphrase_XXXX = "foobar"
409 "#;
410
411 let options = ConfigBuilder::new().use_toml(config_text).unwrap();
412
413 assert_eq!(options.session_id, Some("12345".to_string()));
414 assert!(options.passphrase.is_none());
415 }
416
417 #[test]
418 fn set_client_options_from_toml_ignores_replace_me_values() {
419 let config_text = r#"
420 [client]
421 session_id = "REPLACE_ME"
422 puzzle_dir = "path/to/puzzle/dir"
423 passphrase = "REPLACE_ME"
424 "#;
425
426 let options = ConfigBuilder::new().use_toml(config_text).unwrap();
427
428 assert!(options.session_id.is_none());
429 assert!(options.passphrase.is_none());
430 assert_eq!(
431 options.puzzle_dir,
432 Some(PathBuf::from_str("path/to/puzzle/dir").unwrap())
433 );
434 }
435}