1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
//! Library for maintaining configs on disk in a simple, ergonomic way.
//!
//! # Quickstart
//!
//! TODO
//!
//! For more examples, see the `examples/` directory.
//!
//! # Features
//!
//! - Configs are stored in JSON format.
//! - Config files are created with user-only permissions (0600) in case they contain sensitive
//!   data.

use std::{
    any,
    fmt::{self, Debug},
    fs::{self, File, OpenOptions},
    io::{self, BufReader, BufWriter},
    os::unix::fs::OpenOptionsExt,
    path::PathBuf,
};

use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error as ThisError;

mod environment;

/// Generic struct for managing an app's chunk of config data on disk.
///
/// Saves config files in $ILO_CONFIG_HOME, or ~/.config/ilo/ if the former is not set.
///
/// About the DeserializeOwned trait bound: see https://serde.rs/lifetimes.html.
/// Since the struct itself is loading the data from a file, it's in command of its own deserializer
/// lifetimes.
pub struct Config<TConfigData: Serialize + DeserializeOwned + Default> {
    config_data: TConfigData,
    config_file_key: String, // e.g. `jira` for ~/.config/ilo/jira.json
}

// If the config_data type is Debug, also implement Debug for the Config wrapper.
impl<TConfigData: Serialize + DeserializeOwned + Default + Debug> Debug for Config<TConfigData> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "Config<{}> {{ config_data: {:?}, config_file_key: {} }}",
            any::type_name::<TConfigData>(),
            self.config_data,
            self.config_file_key,
        )
    }
}

impl<TConfigData: Serialize + DeserializeOwned + Default> Config<TConfigData> {
    /// Load a config based on a key.
    ///
    /// The file and directory creation is lazy, i.e. if the JSON file does not exist, a default
    /// config will be loaded and the file will not actually be created until there is a write.
    pub fn load(config_file_key: &str) -> Result<Self, ConfigError> {
        let config_path = Self::get_config_path(config_file_key)?;

        let config_data = if config_path.is_file() {
            let file = File::open(&config_path)
                .map_err(|e| ConfigError::ConfigFileLoadError(config_path.clone(), e))?;
            let reader = BufReader::new(file);
            serde_json::from_reader(reader)
                .map_err(|e| ConfigError::ConfigFileParseError(config_path, e))?
        } else {
            TConfigData::default()
        };

        Ok(Self {
            config_data,
            config_file_key: config_file_key.to_string(),
        })
    }

    /// Flush config changes to disk.
    pub fn save(&self) -> Result<(), ConfigError> {
        // First check the directory
        let config_root = Self::get_config_root()?;
        match config_root.try_exists() {
            Ok(true) => (),
            Ok(false) => {
                fs::create_dir_all(config_root.clone())
                    .map_err(|e| ConfigError::ConfigRootCreateError(config_root, e))?;
            }
            Err(e) => {
                return Err(ConfigError::ConfigRootLoadError(config_root, e));
            }
        }

        let config_path = Self::get_config_path(&self.config_file_key)?;
        match config_path.try_exists() {
            Ok(exists) => {
                let mut options = OpenOptions::new();
                options.create(true).write(true).truncate(true);

                // If file needs to be created and we are on UNIX, set permissions to user-only
                #[cfg(unix)]
                {
                    if !exists {
                        options.mode(0o600);
                    }
                }

                match options.open(config_path.clone()) {
                    Ok(f) => {
                        let writer = BufWriter::new(f);
                        serde_json::to_writer_pretty(writer, &self.config_data)
                            .map_err(ConfigError::ConfigFileSerializeError)
                    }
                    Err(e) => Err(ConfigError::ConfigFileWriteError(config_path, e)),
                }
            }
            Err(e) => Err(ConfigError::ConfigFileWriteError(config_path, e)),
        }
    }

    #[inline]
    pub fn data(&self) -> &TConfigData {
        &self.config_data
    }

    #[inline]
    pub fn data_mut(&mut self) -> &mut TConfigData {
        &mut self.config_data
    }

    fn get_config_root() -> Result<PathBuf, ConfigError> {
        let environment = environment::load_env();
        let config_root = environment
            .ilo_config_home
            .as_deref()
            .map(PathBuf::from)
            .or(home::home_dir().map(|d| d.join(".config").join("ilo")));

        match config_root {
            None => Err(ConfigError::NoHome),
            Some(root) => Ok(root),
        }
    }

    fn get_config_path(config_file_key: &str) -> Result<PathBuf, ConfigError> {
        Self::get_config_root().map(|root| root.join(format!("{}.json", config_file_key)))
    }
}

#[derive(ThisError, Debug)]
pub enum ConfigError {
    #[error("$ILO_CONFIG_HOME is not set and user home directory could not be determined")]
    NoHome,

    #[error("Config root dir {0} could not be loaded: {1}")]
    ConfigRootLoadError(PathBuf, io::Error),

    #[error("Config root dir does not exist at {0} and could not be created: {1}")]
    ConfigRootCreateError(PathBuf, io::Error),

    #[error("Config path exists at {0} but config could not be loaded: {1}")]
    ConfigFileLoadError(PathBuf, io::Error),

    #[error("Config path exists at {0} but JSON could not be parsed: {1}")]
    ConfigFileParseError(PathBuf, serde_json::Error),

    #[error("Config path location {0} could not be opened for writing: {1}")]
    ConfigFileWriteError(PathBuf, io::Error),

    #[error("There was an error serializing config to disk: {0}")]
    ConfigFileSerializeError(serde_json::Error),
}