1use 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("an passphrase for encrypting puzzle inputs is required")]
29 PassphraseRequired,
30 #[error("failed to get the default cache directory for puzzles - this OS is not supported by the `directories` crate")]
31 DefaultPuzzleDirError,
32 #[error("failed to get the default cache directory for sessions - this OS is not supported by the `directories` crate")]
33 DefaultSessonsDirError,
34 #[error("{}", .0)]
35 IoError(#[from] std::io::Error),
36 #[error("{}", .0)]
37 TomlError(#[from] toml::de::Error),
38}
39
40#[derive(Clone, Default, Debug)]
46pub struct Config {
47 pub session_id: Option<String>,
49 pub puzzle_dir: PathBuf,
51 pub sessions_dir: PathBuf,
53 pub passphrase: String,
55 pub start_time: chrono::DateTime<chrono::Utc>,
57}
58
59pub struct ConfigBuilder {
71 pub session_id: Option<String>,
72 pub puzzle_dir: Option<PathBuf>,
73 pub sessions_dir: Option<PathBuf>,
74 pub passphrase: Option<String>,
75 pub fake_time: Option<chrono::DateTime<chrono::Utc>>,
76}
77
78impl ConfigBuilder {
79 pub fn new() -> Self {
81 Self {
82 session_id: None,
83 puzzle_dir: None,
84 sessions_dir: None,
85 passphrase: None,
86 fake_time: None,
87 }
88 }
89
90 pub fn use_toml(mut self, config_text: &str) -> Result<Self, ConfigError> {
93 const CLIENT_TABLE_NAME: &str = "client";
94 const SESSIONS_DIR_KEY: &str = "sessions_dir";
95 const SESSION_ID_KEY: &str = "session_id";
96 const PUZZLE_DIR_KEY: &str = "puzzle_dir";
97 const PASSPHRASE_KEY: &str = "passphrase";
98 const REPLACE_ME: &str = "REPLACE_ME";
99
100 fn try_read_key<F: FnOnce(&str)>(table: &toml::Table, key: &str, setter: F) {
101 match table.get(key).as_ref() {
102 Some(toml::Value::String(s)) => {
103 if s == REPLACE_ME {
104 tracing::debug!("ignoring TOML key {key} because value is `{REPLACE_ME}`");
105 } else {
106 tracing::debug!("found TOML key `{key}` with value `{s}`");
107 setter(s)
108 }
109 }
110 None => {
111 tracing::debug!("TOML key {key} not present, or its value was not a string");
112 }
113 _ => {
114 tracing::warn!("TOML key {key} must be string value");
115 }
116 };
117 }
118
119 let toml: toml::Table = config_text.parse::<toml::Table>()?;
120
121 match toml.get(CLIENT_TABLE_NAME) {
122 Some(toml::Value::Table(client_config)) => {
123 try_read_key(client_config, PASSPHRASE_KEY, |v| {
124 self.passphrase = Some(v.to_string())
125 });
126
127 try_read_key(client_config, SESSION_ID_KEY, |v| {
128 self.session_id = Some(v.to_string())
129 });
130
131 try_read_key(client_config, PUZZLE_DIR_KEY, |v| {
132 self.puzzle_dir = Some(PathBuf::from(v))
133 });
134
135 try_read_key(client_config, SESSIONS_DIR_KEY, |v| {
136 self.sessions_dir = Some(PathBuf::from(v))
137 });
138 }
139 _ => {
140 tracing::warn!(
141 "TOML table {CLIENT_TABLE_NAME} was missing; this config will be skipped!"
142 );
143 }
144 }
145
146 Ok(self)
147 }
148
149 pub fn with_session_id<S: Into<String>>(mut self, session_id: S) -> Self {
150 self.session_id = Some(session_id.into());
151 self
152 }
153
154 pub fn with_puzzle_dir<P: Into<PathBuf>>(mut self, puzzle_dir: P) -> Self {
155 self.puzzle_dir = Some(puzzle_dir.into());
156 self
157 }
158
159 pub fn with_sessions_dir<P: Into<PathBuf>>(mut self, sessions_dir: P) -> Self {
160 self.sessions_dir = Some(sessions_dir.into());
161 self
162 }
163
164 pub fn with_passphrase<S: Into<String>>(mut self, passphrase: S) -> Self {
165 self.passphrase = Some(passphrase.into());
166 self
167 }
168
169 pub fn with_fake_time(mut self, fake_time: chrono::DateTime<chrono::Utc>) -> Self {
170 self.fake_time = Some(fake_time);
171 self
172 }
173
174 pub fn build(self) -> Result<Config, ConfigError> {
176 let passphrase = self.passphrase.unwrap_or_else(|| {
178 if self.puzzle_dir.is_none() {
179 gethostname::gethostname().to_string_lossy().to_string()
180 } else {
181 String::new()
182 }
183 });
184
185 if passphrase.is_empty() {
187 Err(ConfigError::PassphraseRequired)
188 } else {
189 let maybe_project_dir =
190 directories::ProjectDirs::from(DIRS_QUALIFIER, DIRS_ORG, DIRS_APP);
191
192 Ok(Config {
193 session_id: self.session_id,
194 puzzle_dir: self
195 .puzzle_dir
196 .or(maybe_project_dir
197 .as_ref()
198 .map(|p| p.cache_dir().join("puzzles").to_path_buf()))
199 .ok_or(ConfigError::DefaultPuzzleDirError)?,
200 sessions_dir: self
201 .sessions_dir
202 .or(maybe_project_dir
203 .as_ref()
204 .map(|p| p.cache_dir().join("sessions").to_path_buf()))
205 .ok_or(ConfigError::DefaultPuzzleDirError)?,
206 start_time: self.fake_time.unwrap_or(chrono::Utc::now()),
207 passphrase,
208 })
209 }
210 }
211}
212
213impl Default for ConfigBuilder {
214 fn default() -> Self {
215 Self::new()
216 }
217}
218
219pub fn load_config() -> Result<ConfigBuilder, ConfigError> {
223 let mut config: ConfigBuilder = Default::default();
224
225 config = read_config_from_user_config_dirs(Some(config))?;
226 config = read_config_from_current_dir(Some(config))?;
227 config = read_config_from_env_vars(Some(config));
228
229 Ok(config)
230}
231
232pub fn read_config_from_file<P: AsRef<Path>>(
234 config: Option<ConfigBuilder>,
235 path: P,
236) -> Result<ConfigBuilder, ConfigError> {
237 let config = config.unwrap_or_default();
238 let config_text = fs::read_to_string(&path)?;
239
240 config.use_toml(&config_text)
241}
242
243pub fn read_config_from_current_dir(
245 config: Option<ConfigBuilder>,
246) -> Result<ConfigBuilder, ConfigError> {
247 let mut config = config.unwrap_or_default();
248
249 match std::env::current_dir() {
250 Ok(current_dir) => {
251 let local_config_path = current_dir.join(CONFIG_FILENAME);
252 tracing::debug!("loading current directory config values from: {local_config_path:?}");
253
254 if local_config_path.exists() {
255 config = read_config_from_file(Some(config), local_config_path)?;
256 } else {
257 tracing::warn!("loading config from current directory will be skipped because {local_config_path:?} does not exist")
258 }
259 }
260 Err(e) => {
261 tracing::error!("loading config from current directory will be skipped because {e}")
262 }
263 }
264
265 Ok(config)
266}
267
268pub fn read_config_from_user_config_dirs(
271 config: Option<ConfigBuilder>,
272) -> Result<ConfigBuilder, ConfigError> {
273 let mut config = config.unwrap_or_default();
274
275 const CUSTOM_CONFIG_ENV_KEY: &str = "AOC_CONFIG_FILE";
280
281 if let Ok(custom_config_path) = std::env::var(CUSTOM_CONFIG_ENV_KEY) {
282 if std::fs::exists(&custom_config_path).unwrap_or(false) {
283 tracing::debug!("loading user config at: {custom_config_path:?}");
284 config = read_config_from_file(Some(config), custom_config_path)?;
285 } else {
286 tracing::debug!("no user config found at: {custom_config_path:?}");
287 }
288
289 return Ok(config);
290 } else {
291 tracing::debug!(
292 "skipping custom user config because env var `{CUSTOM_CONFIG_ENV_KEY}` is not set"
293 );
294 }
295
296 if let Some(project_dir) = directories::ProjectDirs::from(DIRS_QUALIFIER, DIRS_ORG, DIRS_APP) {
298 let config_dir = project_dir.config_dir();
299 let example_config_path = config_dir.join(EXAMPLE_CONFIG_FILENAME);
300
301 if !std::fs::exists(config_dir).unwrap_or(false) {
303 std::fs::create_dir_all(config_dir).unwrap_or_else(|e| {
304 tracing::debug!("failed to create app config dir: {e:?}");
305 });
306 }
307
308 if !std::fs::exists(&example_config_path).unwrap_or(false) {
311 tracing::debug!("created example config at {example_config_path:?}");
312
313 std::fs::write(example_config_path, EXAMPLE_CONFIG_TEXT).unwrap_or_else(|e| {
314 tracing::debug!("failed to create example config: {e:?}");
315 });
316 }
317
318 let config_path = config_dir.join(CONFIG_FILENAME);
320
321 if std::fs::exists(&config_path).unwrap_or(false) {
322 tracing::debug!("loading user config at: {config_path:?}");
323 return read_config_from_file(Some(config), config_path);
324 } else {
325 tracing::debug!("no user config found at: {config_path:?}");
326 }
327 } else {
328 tracing::debug!("could not calculate user config dir on this machine");
329 }
330
331 if let Some(base_dirs) = directories::BaseDirs::new() {
333 let home_config_path = base_dirs.home_dir().join(HOME_DIR_CONFIG_FILENAME);
334
335 if std::fs::exists(&home_config_path).unwrap_or(false) {
336 tracing::debug!("loading user config at: {home_config_path:?}");
337 config = read_config_from_file(Some(config), home_config_path)?;
338 } else {
339 tracing::debug!("no user config found at: {home_config_path:?}");
340 }
341 }
342
343 Ok(config)
344}
345
346pub fn read_config_from_env_vars(config: Option<ConfigBuilder>) -> ConfigBuilder {
349 const SESSION_ID_ENV_KEY: &str = "AOC_SESSION";
351 const PASSPHRASE_ENV_KEY: &str = "AOC_PASSPHRASE";
352 const PUZZLE_DIR_KEY: &str = "AOC_PUZZLE_DIR";
353 const SESSIONS_DIR_KEY: &str = "AOC_SESSIONS_DIR";
354
355 let mut config = config.unwrap_or_default();
356
357 fn try_read_env_var<F: FnOnce(String)>(name: &str, setter: F) {
358 if let Ok(v) = std::env::var(name) {
359 tracing::debug!("found env var `{name}` with value `{v}`");
360 setter(v)
361 }
362 }
363
364 try_read_env_var(SESSION_ID_ENV_KEY, |v| {
365 config.session_id = Some(v);
366 });
367
368 try_read_env_var(PASSPHRASE_ENV_KEY, |v| {
369 config.passphrase = Some(v);
370 });
371
372 try_read_env_var(PUZZLE_DIR_KEY, |v| {
373 config.puzzle_dir = Some(PathBuf::from(v));
374 });
375
376 try_read_env_var(SESSIONS_DIR_KEY, |v| {
377 config.sessions_dir = Some(PathBuf::from(v));
378 });
379
380 config
381}
382
383#[cfg(test)]
384mod tests {
385 use std::str::FromStr;
386
387 use super::*;
388
389 #[test]
390 fn config_uses_hostname_default_passphrase() {
391 let config: Config = ConfigBuilder::new()
392 .with_session_id("54321")
393 .build()
394 .unwrap();
395 assert_eq!(
396 config.passphrase,
397 gethostname::gethostname().into_string().unwrap()
398 );
399 }
400
401 #[test]
402 fn config_must_specify_passphrase_if_puzzle_dir_changed() {
403 let config = ConfigBuilder::new().with_session_id("my_session");
404 assert!(config.build().is_ok());
405
406 let config = ConfigBuilder::new()
407 .with_session_id("my_session")
408 .with_puzzle_dir("/tmp/puzzles");
409 assert!(matches!(
410 config.build(),
411 Err(ConfigError::PassphraseRequired)
412 ));
413 }
414
415 #[test]
416 fn configs_are_built_with_config_builder() {
417 let config: Config = ConfigBuilder::new()
418 .with_session_id("54321")
419 .with_puzzle_dir("/tmp/puzzle/dir")
420 .with_sessions_dir("/tmp/path/to/sessions")
421 .with_passphrase("this is my password")
422 .build()
423 .unwrap();
424
425 assert_eq!(config.session_id, Some("54321".to_string()));
426 assert_eq!(&config.passphrase, "this is my password");
427
428 assert_eq!(
429 config.puzzle_dir,
430 PathBuf::from_str("/tmp/puzzle/dir").unwrap()
431 );
432
433 assert_eq!(
434 config.sessions_dir,
435 PathBuf::from_str("/tmp/path/to/sessions").unwrap()
436 );
437 }
438
439 #[test]
440 fn client_can_overwrite_options() {
441 let mut options = ConfigBuilder::new().with_passphrase("12345");
442 assert_eq!(options.passphrase, Some("12345".to_string()));
443
444 options = options.with_passphrase("54321");
445 assert_eq!(options.passphrase, Some("54321".to_string()));
446 }
447
448 #[test]
449 fn set_client_options_with_builder_funcs() {
450 let options = ConfigBuilder::new()
451 .with_session_id("MY_SESSION_ID")
452 .with_puzzle_dir("MY_CACHE_DIR")
453 .with_passphrase("MY_PASSWORD");
454
455 assert_eq!(options.session_id, Some("MY_SESSION_ID".to_string()));
456 assert_eq!(
457 options.puzzle_dir,
458 Some(PathBuf::from_str("MY_CACHE_DIR").unwrap())
459 );
460 assert_eq!(options.passphrase, Some("MY_PASSWORD".to_string()));
461 }
462
463 #[test]
464 fn set_client_options_from_toml() {
465 let config_text = r#"
466 [client]
467 session_id = "12345"
468 puzzle_dir = "path/to/puzzle/dir"
469 sessions_dir = "another/path/to/blah"
470 passphrase = "foobar"
471 "#;
472
473 let options = ConfigBuilder::new().use_toml(config_text).unwrap();
474
475 assert_eq!(options.session_id, Some("12345".to_string()));
476 assert_eq!(
477 options.puzzle_dir,
478 Some(PathBuf::from_str("path/to/puzzle/dir").unwrap())
479 );
480 assert_eq!(
481 options.sessions_dir,
482 Some(PathBuf::from_str("another/path/to/blah").unwrap())
483 );
484 assert_eq!(options.passphrase, Some("foobar".to_string()));
485 }
486
487 #[test]
488 fn set_client_options_from_toml_ignores_missing_fields() {
489 let config_text = r#"
490 [client]
491 session_idX = "12345"
492 "#;
493
494 let options = ConfigBuilder::new().use_toml(config_text).unwrap();
495
496 assert!(options.session_id.is_none());
497 }
498
499 #[test]
500 fn set_client_options_from_toml_ignores_replace_me_values() {
501 let config_text = r#"
502 [client]
503 session_id = "REPLACE_ME"
504 puzzle_dir = "path/to/puzzle/dir"
505 "#;
506
507 let options = ConfigBuilder::new().use_toml(config_text).unwrap();
508
509 assert!(options.session_id.is_none());
510 assert_eq!(
511 options.puzzle_dir,
512 Some(PathBuf::from_str("path/to/puzzle/dir").unwrap())
513 );
514 }
515}