advent_of_code_data/
config.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4};
5use thiserror::Error;
6
7// TODO: in the documentation for ClientOptions, explain the builder pattern used.
8// TODO: in the documentation for ClientOptions, explain that with_* calls overwrite previous values.
9// TODO: need to write tests.
10// TODO: config for custom http endpoint.
11
12const 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/// Errors that can occur when configuring client settings.
26#[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/// Configuration for the Advent of Code client.
41///
42/// Most users of this crate do not need to worry about how to initialize `Config`, or how to use
43/// `ConfigBuilder` to create new `Config`s. Just use the `load_config()` function in this module to
44/// get the behavior that is detailed in this crate's README.md.
45#[derive(Clone, Default, Debug)]
46pub struct Config {
47    /// Your Advent of Code session cookie. TODO: rename all instances to `session`.
48    pub session_id: Option<String>,
49    /// Directory where puzzle inputs and answers are stored.
50    pub puzzle_dir: PathBuf,
51    /// Directory where per-session state (e.g., submission timeouts) is cached.
52    pub sessions_dir: PathBuf,
53    /// Passphrase used to encrypt puzzle inputs on disk.
54    pub passphrase: String,
55    /// Current time (usually UTC now, but can be overridden for testing).
56    pub start_time: chrono::DateTime<chrono::Utc>,
57}
58
59/// A builder interface for specifying configuration settings to the Advent of Client client.
60/// Configuration settings have sensible default values, and should only be changed when the
61/// user wants custom behavior.
62///
63/// # Default Values
64/// - `session_id`: The user's Advent of Code session cookie. **This is required for getting input
65///   and submitting answers.**
66/// - `passphrase`: The hostname of the current machine. **A custom passphrase is required if
67///   `puzzle_dir` is changed.**
68/// - `puzzle_dir`: A directory in the local user's cache dir (e.g., XDG_CACHE_HOME on Linux).
69/// - `sessions_dir`: A directory in the local user's cache dir (e.g., XDG_CACHE_HOME on Linux).
70pub 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    /// Create a new `ConfigBuilder` object with all fields initialized to `None`.`
80    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    /// Loads configuration values from string containing TOML formatted text. Configuration values
91    /// loaded here will overwrite previously loaded values.
92    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    /// Generate a `Config` object from the settings in this `ConfigBuilder` object.
175    pub fn build(self) -> Result<Config, ConfigError> {
176        // Use a default passphrase if the puzzle directory and the passphrase was not specified.
177        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        // There must be a passphrase given when building the config.
186        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
219/// Loads client options from the local machine.
220///
221/// The behavior of this function is covered in the `advent-of-code-data` [README.md](../README.md).
222pub 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
232/// Loads configuration values from a TOML file.
233pub 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
243/// Loads configuration values from a TOML file in the working directory.
244pub 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
268/// Loads configuration data from a user's config directory relative to their home directory.
269/// Any option values loaded here will overwrite values loaded previously.
270pub 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    // Read custom configuration path from `AOC_CONFIG_FILE` if it is set. Skip searching other
276    // config paths if this environment variable is set.
277    //
278    // NOTE: Please keep this name consistent with README.md!
279    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    // Try reading from the $XDG_CONFIG_HOME / %LOCALAPPDATA%.
297    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        // Create the application's config dir if its missing.
302        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        // Create an example config file in the user config dir to illustrate some of the
309        // configuration options users can set.
310        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        // Load the user config if it exists.
319        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    // Try reading from the home directory.
332    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
346/// Returns a copy of `config` with settings that match any non-empty Advent of Code environment
347/// variables.
348pub fn read_config_from_env_vars(config: Option<ConfigBuilder>) -> ConfigBuilder {
349    /// NOTE: Keep these environment variable names in sync with the README and other documentation!
350    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}