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(
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/// Configuration for the Advent of Code client.
45///
46/// Created from `ClientOptions` and used internally by `WebClient`. All fields are public to allow
47/// inspection and advanced use cases, but typically you should not modify these directly after
48/// client creation.
49#[derive(Default, Debug)]
50pub struct Config {
51    /// Your Advent of Code session cookie (from the browser's cookies).
52    pub session_id: String,
53    /// Directory where puzzle inputs and answers are stored.
54    pub puzzle_dir: PathBuf,
55    /// Directory where per-session state (e.g., submission timeouts) is cached.
56    pub sessions_dir: PathBuf,
57    /// Passphrase used to encrypt puzzle inputs on disk.
58    pub passphrase: String,
59    /// Current time (usually UTC now, but can be overridden for testing).
60    pub start_time: chrono::DateTime<chrono::Utc>,
61}
62
63//
64pub 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        // TODO: new should set these to empty, and then there should be a check at the end to
75        //       validate cache dirs were provided.
76        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    /// Loads configuration values from string containing TOML formatted text. Configuration values
89    /// loaded here will overwrite previously loaded values.
90    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
186/// Loads client options in the following order:
187///   1. User's shared configuration directory (ie, XDG_CONFIG_HOME or %LOCALAPPDATA%).
188///   2. Current directory.
189///   3. Environment variables.
190pub 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
200/// Loads configuration values from a TOML file.
201pub 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
211/// Loads configuration values from a TOML file in the working directory.
212pub 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
236/// Loads configuration data from a user's config directory relative to their home directory.
237/// Any option values loaded here will overwrite values loaded previously.
238pub 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    // Read custom configuration path from `AOC_CONFIG_PATH` if it is set. Do not continue
244    // looking for user config if this was set.
245    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    // Try reading from the $XDG_CONFIG_HOME / %LOCALAPPDATA%.
263    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        // Create the application's config dir if its missing.
268        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        // Create an example config file in the user config dir to illustrate some of the
275        // configuration options users can set.
276        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        // Load the user config if it exists.
285        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    // Try reading from the home directory.
298    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
312/// Returns a copy of `config` with settings that match any non-empty Advent of Code environment
313/// variables.
314pub fn read_config_from_env_vars(config: Option<ConfigBuilder>) -> ConfigBuilder {
315    /// NOTE: Keep these environment variable names in sync with the README and other documentation!
316    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}