dead_man_switch/
config.rs

1//! Configuration module for the Dead Man's Switch
2//! Contains functions and structs to handle the configuration.
3use std::env;
4
5use std::fs::{self, File};
6use std::io::{self, Write};
7use std::path::PathBuf;
8
9use directories_next::BaseDirs;
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12use toml::{de::Error as DerTomlError, ser::Error as SerTomlError};
13use zeroize::{Zeroize, ZeroizeOnDrop};
14
15/// Configuration struct used for the application
16///
17/// # Default
18///
19/// If the configuration file does not exist, it will be created with
20/// the default values.
21#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
22pub struct Config {
23    /// The username for the email account.
24    pub username: String,
25    /// The password for the email account.
26    pub password: String,
27    /// The SMTP server to use
28    pub smtp_server: String,
29    /// The port to use for the SMTP server.
30    pub smtp_port: u16,
31    /// The message to send in the email if you fail to check in
32    /// after the `timer_warning` with the additional `timer_dead_man`
33    /// seconds have passed.
34    pub message: String,
35    /// The warning message if you fail to check in `timer_warning` seconds.
36    pub message_warning: String,
37    /// The subject of the email if you fail to check in
38    /// after the `timer_warning` with the additional `timer_dead_man`
39    /// seconds have passed.
40    pub subject: String,
41    /// The subject of the email if you fail to check in `timer_warning` seconds.
42    pub subject_warning: String,
43    /// The email address to send the email to.
44    pub to: String,
45    /// The email address to send the email from.
46    pub from: String,
47    /// Attachment to send with the email.
48    pub attachment: Option<String>,
49    /// Timer in seconds for the warning email.
50    pub timer_warning: u64,
51    /// Timer in seconds for the dead man's email.
52    pub timer_dead_man: u64,
53    /// Web interface password
54    pub web_password: String,
55    /// Cookie expiration to avoid need for login
56    pub cookie_exp_days: u64,
57    /// Log level for the web interface.
58    pub log_level: Option<String>,
59}
60
61impl Default for Config {
62    fn default() -> Self {
63        let web_password = env::var("WEB_PASSWORD")
64            .ok()
65            .unwrap_or("password".to_string());
66        Self {
67            username: "me@example.com".to_string(),
68            password: "".to_string(),
69            smtp_server: "smtp.example.com".to_string(),
70            smtp_port: 587,
71            message: "I'm probably dead, go to Central Park NY under bench #137 you'll find an age-encrypted drive. Password is our favorite music in Pascal case.".to_string(),
72            message_warning: "Hey, you haven't checked in for a while. Are you okay?".to_string(),
73            subject: "[URGENT] Something Happened to Me!".to_string(),
74            subject_warning: "[URGENT] You need to check in!".to_string(),
75            to: "someone@example.com".to_string(),
76            from: "me@example.com".to_string(),
77            attachment: None,
78            timer_warning: 60 * 60 * 24 * 14, // 2 weeks
79            timer_dead_man: 60 * 60 * 24 * 7, // 1 week
80            web_password,
81            cookie_exp_days: 7,
82            log_level: None,
83        }
84    }
85}
86
87/// Configuration errors
88#[derive(Error, Debug)]
89pub enum ConfigError {
90    /// IO operations on config module.
91    #[error(transparent)]
92    Io(#[from] std::io::Error),
93
94    /// TOML serialization.
95    #[error(transparent)]
96    TomlSerialization(#[from] SerTomlError),
97
98    /// TOML deserialization.
99    #[error(transparent)]
100    TomlDeserialization(#[from] DerTomlError),
101
102    /// Attachment not found.
103    #[error("Attachment not found")]
104    AttachmentNotFound,
105}
106
107/// Enum to represent the type of email to send.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum Email {
110    /// Send the warning email.
111    Warning,
112    /// Send the dead man's email.
113    DeadMan,
114}
115
116/// Load the configuration from the OS-agnostic config directory.
117///
118/// Under the hood uses the [`directories_next`] crate to find the
119/// home directory and the config.
120///
121/// # Errors
122///
123/// - Fails if the home directory cannot be found
124/// - Fails if the config directory cannot be created
125///
126/// # Notes
127///
128/// This function handles testing and non-testing environments.
129pub fn config_path() -> Result<PathBuf, ConfigError> {
130    let base_dir = if cfg!(test) {
131        // Use a temporary directory for tests
132        std::env::temp_dir()
133    } else {
134        BaseDirs::new()
135            .ok_or_else(|| {
136                ConfigError::Io(io::Error::new(
137                    io::ErrorKind::NotFound,
138                    "Failed to find home directory",
139                ))
140            })?
141            .config_dir()
142            .to_path_buf()
143    };
144
145    let config_dir = base_dir.join(if cfg!(test) {
146        "deadman_test"
147    } else {
148        "deadman"
149    });
150
151    fs::create_dir_all(&config_dir)?;
152    Ok(config_dir.join("config.toml"))
153}
154
155/// Save the configuration to the OS-agnostic config directory.
156///
157/// Under the hood uses the [`directories_next`] crate to find the
158/// home directory and the config.
159///
160/// # Errors
161///
162/// - Fails if the home directory cannot be found
163/// - Fails if the config directory cannot be created
164pub fn save_config(config: &Config) -> Result<(), ConfigError> {
165    let config_path = config_path()?;
166    let mut file = File::create(config_path)?;
167    let config = toml::to_string(config)?;
168
169    file.write_all(config.as_bytes())?;
170
171    Ok(())
172}
173
174/// Load the configuration from the OS-agnostic config directory.
175///
176/// Under the hood uses the [`directories_next`] crate to find the
177/// home directory and the config.
178///
179/// # Errors
180///
181/// - Fails if the home directory cannot be found
182/// - Fails if the config directory cannot be created
183///
184/// # Example
185///
186/// ```rust
187/// use dead_man_switch::config::load_or_initialize_config;
188/// let config = load_or_initialize_config().unwrap();
189/// ```
190pub fn load_or_initialize_config() -> Result<Config, ConfigError> {
191    let config_path = config_path()?;
192    if !config_path.exists() {
193        let config = Config::default();
194        save_config(&config)?;
195
196        Ok(config)
197    } else {
198        let config = fs::read_to_string(&config_path)?;
199        let config: Config = toml::from_str(&config)?;
200
201        Ok(config)
202    }
203}
204
205/// Parses the attachment path from the [`Config`].
206///
207/// # Errors
208///
209/// - If the attachment path is not found.
210/// - If the attachment path is not a valid path.
211pub fn attachment_path(config: &Config) -> Result<PathBuf, ConfigError> {
212    let attachment_path = config
213        .attachment
214        .as_ref()
215        .ok_or(ConfigError::AttachmentNotFound)?;
216    Ok(PathBuf::from(attachment_path))
217}
218
219#[cfg(test)]
220mod test {
221    use super::*;
222    use std::{
223        env, file, process, thread,
224        time::{SystemTime, UNIX_EPOCH},
225    };
226
227    /// Use a temporary directory approach that's more resilient.
228    fn get_isolated_test_dir() -> PathBuf {
229        let thread_id = format!("{:?}", thread::current().id());
230        let timestamp = SystemTime::now()
231            .duration_since(UNIX_EPOCH)
232            .unwrap()
233            .as_nanos();
234        let pid = process::id();
235
236        let test_dir = env::temp_dir().join("deadman_test_isolated").join(format!(
237            "{}_{}_{}_{}",
238            file!().replace(['/', '\\'], "_"),
239            pid,
240            thread_id.replace([':', '(', ')'], "_"),
241            timestamp
242        ));
243
244        fs::create_dir_all(&test_dir).expect("Failed to create test directory");
245        test_dir
246    }
247
248    /// Save the configuration to the given path.
249    fn save_config_with_path(config: &Config, path: &PathBuf) -> Result<(), ConfigError> {
250        // Ensure parent directory exists
251        if let Some(parent) = path.parent() {
252            fs::create_dir_all(parent)?;
253        }
254        let mut file = File::create(path)?;
255        let config_str = toml::to_string(config)?;
256        file.write_all(config_str.as_bytes())?;
257        file.sync_all()?; // Ensure data is written to disk
258        Ok(())
259    }
260
261    fn load_config_from_path(path: &PathBuf) -> Result<Config, ConfigError> {
262        let config_str = fs::read_to_string(path)?;
263        let config: Config = toml::from_str(&config_str)?;
264        Ok(config)
265    }
266
267    #[test]
268    fn save_config() {
269        let test_dir = get_isolated_test_dir();
270        let test_path = test_dir.join("config.toml");
271        let config = Config::default();
272
273        // Test saving and loading with our isolated functions
274        save_config_with_path(&config, &test_path).unwrap();
275
276        let loaded_config = load_config_from_path(&test_path).unwrap();
277        assert_eq!(loaded_config, Config::default());
278
279        // Cleanup - remove the entire test directory
280        let _ = fs::remove_dir_all(&test_dir);
281    }
282
283    #[test]
284    fn load_or_initialize_config_with_existing_file() {
285        let test_dir = get_isolated_test_dir();
286        let test_path = test_dir.join("config.toml");
287        let config = Config::default();
288
289        // Save config first
290        save_config_with_path(&config, &test_path).unwrap();
291
292        // Load it back
293        let loaded_config = load_config_from_path(&test_path).unwrap();
294        assert_eq!(loaded_config, Config::default());
295
296        // Cleanup - remove the entire test directory
297        let _ = fs::remove_dir_all(&test_dir);
298    }
299
300    #[test]
301    fn config_path_in_test_mode() {
302        // This test verifies that config_path() uses temp directory in test mode
303        let path = config_path().unwrap();
304        assert!(path.to_string_lossy().contains("deadman_test"));
305
306        // Cleanup any created directories
307        if let Some(parent) = path.parent() {
308            let _ = fs::remove_dir_all(parent);
309        }
310    }
311
312    #[test]
313    fn example_config_is_valid() {
314        let example_config = fs::read_to_string("../../config.example.toml").unwrap();
315        let config: Result<Config, toml::de::Error> = toml::from_str(&example_config);
316        assert!(config.is_ok());
317    }
318}