ilo_config/
lib.rs

1//! Library for maintaining configs on disk in a simple, ergonomic way.
2//!
3//! # Quickstart
4//!
5//! TODO
6//!
7//! For more examples, see the `examples/` directory.
8//!
9//! # Features
10//!
11//! - Configs are stored in JSON format.
12//! - Config files are created with user-only permissions (0600) in case they contain sensitive
13//!   data.
14
15use std::{
16    any,
17    fmt::{self, Debug},
18    fs::{self, File, OpenOptions},
19    io::{self, BufReader, BufWriter},
20    os::unix::fs::OpenOptionsExt,
21    path::PathBuf,
22};
23
24use serde::{de::DeserializeOwned, Serialize};
25use thiserror::Error as ThisError;
26
27mod environment;
28
29/// Generic struct for managing an app's chunk of config data on disk.
30///
31/// Saves config files in $ILO_CONFIG_HOME, or ~/.config/ilo/ if the former is not set.
32///
33/// About the DeserializeOwned trait bound: see https://serde.rs/lifetimes.html.
34/// Since the struct itself is loading the data from a file, it's in command of its own deserializer
35/// lifetimes.
36pub struct Config<TConfigData: Serialize + DeserializeOwned + Default> {
37    config_data: TConfigData,
38    config_file_key: String, // e.g. `jira` for ~/.config/ilo/jira.json
39}
40
41// If the config_data type is Debug, also implement Debug for the Config wrapper.
42impl<TConfigData: Serialize + DeserializeOwned + Default + Debug> Debug for Config<TConfigData> {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(
45            f,
46            "Config<{}> {{ config_data: {:?}, config_file_key: {} }}",
47            any::type_name::<TConfigData>(),
48            self.config_data,
49            self.config_file_key,
50        )
51    }
52}
53
54impl<TConfigData: Serialize + DeserializeOwned + Default> Config<TConfigData> {
55    /// Load a config based on a key.
56    ///
57    /// The file and directory creation is lazy, i.e. if the JSON file does not exist, a default
58    /// config will be loaded and the file will not actually be created until there is a write.
59    pub fn load(config_file_key: &str) -> Result<Self, ConfigError> {
60        let config_path = Self::get_config_path(config_file_key)?;
61
62        let config_data = if config_path.is_file() {
63            let file = File::open(&config_path)
64                .map_err(|e| ConfigError::ConfigFileLoadError(config_path.clone(), e))?;
65            let reader = BufReader::new(file);
66            serde_json::from_reader(reader)
67                .map_err(|e| ConfigError::ConfigFileParseError(config_path, e))?
68        } else {
69            TConfigData::default()
70        };
71
72        Ok(Self {
73            config_data,
74            config_file_key: config_file_key.to_string(),
75        })
76    }
77
78    /// Flush config changes to disk.
79    pub fn save(&self) -> Result<(), ConfigError> {
80        // First check the directory
81        let config_root = Self::get_config_root()?;
82        match config_root.try_exists() {
83            Ok(true) => (),
84            Ok(false) => {
85                fs::create_dir_all(config_root.clone())
86                    .map_err(|e| ConfigError::ConfigRootCreateError(config_root, e))?;
87            }
88            Err(e) => {
89                return Err(ConfigError::ConfigRootLoadError(config_root, e));
90            }
91        }
92
93        let config_path = Self::get_config_path(&self.config_file_key)?;
94        match config_path.try_exists() {
95            Ok(exists) => {
96                let mut options = OpenOptions::new();
97                options.create(true).write(true).truncate(true);
98
99                // If file needs to be created and we are on UNIX, set permissions to user-only
100                #[cfg(unix)]
101                {
102                    if !exists {
103                        options.mode(0o600);
104                    }
105                }
106
107                match options.open(config_path.clone()) {
108                    Ok(f) => {
109                        let writer = BufWriter::new(f);
110                        serde_json::to_writer_pretty(writer, &self.config_data)
111                            .map_err(ConfigError::ConfigFileSerializeError)
112                    }
113                    Err(e) => Err(ConfigError::ConfigFileWriteError(config_path, e)),
114                }
115            }
116            Err(e) => Err(ConfigError::ConfigFileWriteError(config_path, e)),
117        }
118    }
119
120    #[inline]
121    pub fn data(&self) -> &TConfigData {
122        &self.config_data
123    }
124
125    #[inline]
126    pub fn data_mut(&mut self) -> &mut TConfigData {
127        &mut self.config_data
128    }
129
130    fn get_config_root() -> Result<PathBuf, ConfigError> {
131        let environment = environment::load_env();
132        let config_root = environment
133            .ilo_config_home
134            .as_deref()
135            .map(PathBuf::from)
136            .or(home::home_dir().map(|d| d.join(".config").join("ilo")));
137
138        match config_root {
139            None => Err(ConfigError::NoHome),
140            Some(root) => Ok(root),
141        }
142    }
143
144    fn get_config_path(config_file_key: &str) -> Result<PathBuf, ConfigError> {
145        Self::get_config_root().map(|root| root.join(format!("{}.json", config_file_key)))
146    }
147}
148
149#[derive(ThisError, Debug)]
150pub enum ConfigError {
151    #[error("$ILO_CONFIG_HOME is not set and user home directory could not be determined")]
152    NoHome,
153
154    #[error("Config root dir {0} could not be loaded: {1}")]
155    ConfigRootLoadError(PathBuf, io::Error),
156
157    #[error("Config root dir does not exist at {0} and could not be created: {1}")]
158    ConfigRootCreateError(PathBuf, io::Error),
159
160    #[error("Config path exists at {0} but config could not be loaded: {1}")]
161    ConfigFileLoadError(PathBuf, io::Error),
162
163    #[error("Config path exists at {0} but JSON could not be parsed: {1}")]
164    ConfigFileParseError(PathBuf, serde_json::Error),
165
166    #[error("Config path location {0} could not be opened for writing: {1}")]
167    ConfigFileWriteError(PathBuf, io::Error),
168
169    #[error("There was an error serializing config to disk: {0}")]
170    ConfigFileSerializeError(serde_json::Error),
171}