remote_files/
configuration.rs

1use crate::{
2    buckets::{GCSBucket, S3Bucket},
3    client::Client,
4    error::{self, StoredError},
5};
6use serde::{de::DeserializeOwned, Deserialize, Serialize};
7use std::{
8    collections::HashMap,
9    path::{Path, PathBuf},
10};
11use tokio::{
12    fs::{File, OpenOptions},
13    io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt},
14};
15
16pub static CONFIGURATION_FILEPATH_ENV_VAR: &str = "RF_CFG_FILEPATH";
17
18pub fn get_default_folder() -> Result<PathBuf, StoredError> {
19    dirs::config_dir()
20        .map(|pb| pb.join("rf"))
21        .ok_or(StoredError::Initialization(
22            "cannot access configuration directory".to_string(),
23        ))
24}
25
26async fn open_rw_fd<P>(path: P) -> Result<File, StoredError>
27where
28    P: AsRef<Path>,
29{
30    let fd = OpenOptions::new()
31        .read(true)
32        .write(true)
33        .create(true)
34        .truncate(false)
35        .open(path)
36        .await?;
37
38    Ok(fd)
39}
40
41async fn read<D>(file: &mut File) -> Result<D, StoredError>
42where
43    D: DeserializeOwned,
44{
45    let mut buffer = String::new();
46    file.read_to_string(&mut buffer).await?;
47
48    if buffer.is_empty() {
49        buffer.clear();
50        buffer.push_str("{}");
51    }
52
53    let configuration = serde_json::from_str(&buffer)?;
54
55    Ok(configuration)
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize)]
59pub struct Persistence {
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub current: Option<String>,
62}
63
64#[derive(Debug, Clone, Deserialize, Serialize)]
65#[serde(tag = "type")]
66pub enum Bucket {
67    #[serde(rename = "gcs")]
68    Gcs(GCSBucket),
69    #[serde(rename = "s3")]
70    S3(S3Bucket),
71}
72
73pub type Configuration = HashMap<String, Bucket>;
74
75pub fn create_client(profile: &str, cfg: &Configuration) -> Result<Option<Client>, error::Client> {
76    if let Some(bucket) = cfg.get(profile) {
77        let client: Client = match bucket {
78            Bucket::Gcs(gcs) => gcs.configuration.clone().try_into()?,
79            // .map_err(|err| ClientError::Initialization(err))?,
80            Bucket::S3(s3) => s3.configuration.clone().try_into()?, // .map_err(|err| ClientError::Initialization(err))?,
81        };
82
83        Ok(Some(client))
84    } else {
85        Ok(None)
86    }
87}
88
89pub struct Stored<T>
90where
91    T: DeserializeOwned + Serialize,
92{
93    inner: T,
94    fd: File,
95}
96
97impl<T> Stored<T>
98where
99    T: DeserializeOwned + Serialize,
100{
101    pub fn get(&self) -> &T {
102        &self.inner
103    }
104
105    pub fn get_mut(&mut self) -> &mut T {
106        &mut self.inner
107    }
108
109    pub async fn persist(mut self) -> Result<(), StoredError> {
110        let content = serde_json::to_string_pretty(&self.inner)?;
111        let bytes = content.as_bytes();
112
113        self.fd.rewind().await?;
114        self.fd.set_len(content.len() as u64).await?;
115        self.fd.write_all(bytes).await?;
116        self.fd.flush().await?;
117
118        Ok(())
119    }
120}
121
122pub type PersistenceLayer = Stored<Persistence>;
123
124impl PersistenceLayer {
125    pub async fn try_init(value: Option<&Path>) -> Result<Self, StoredError> {
126        let main_folder = get_default_folder()?;
127
128        let default_persistence_filepath = main_folder.join("rf.json");
129        let persistence_filepath = value
130            .map(PathBuf::from)
131            .unwrap_or(default_persistence_filepath);
132
133        let mut persistence_fd = open_rw_fd(persistence_filepath.as_path()).await?;
134
135        Ok(PersistenceLayer {
136            inner: read(&mut persistence_fd).await?,
137            fd: persistence_fd,
138        })
139    }
140}
141
142pub type ConfigurationLayer = Stored<Configuration>;
143
144impl ConfigurationLayer {
145    pub async fn try_init(value: Option<&Path>) -> Result<Self, StoredError> {
146        let main_folder = get_default_folder()?;
147
148        let default_cfg_filepath = main_folder.join("configuration.json");
149        let cfg_filepath = value.map(PathBuf::from).unwrap_or(default_cfg_filepath);
150
151        let mut cfg_fd = open_rw_fd(cfg_filepath.as_path()).await?;
152
153        Ok(ConfigurationLayer {
154            inner: read(&mut cfg_fd).await?,
155            fd: cfg_fd,
156        })
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use std::{
164        env, error, fs,
165        path::{Path, PathBuf},
166    };
167    use tokio::fs::OpenOptions;
168    use uuid::Uuid;
169
170    struct TmpDir(PathBuf);
171
172    impl TmpDir {
173        fn create_tmp_dir() -> Self {
174            let id = Uuid::new_v4().to_string();
175            let tmp_dir = env::temp_dir().join(id.as_str());
176
177            fs::create_dir(&tmp_dir).unwrap();
178
179            TmpDir(tmp_dir)
180        }
181
182        async fn add_file<P>(
183            &self,
184            path: P,
185            content: &str,
186        ) -> Result<PathBuf, Box<dyn error::Error>>
187        where
188            P: AsRef<Path>,
189        {
190            let dst = self.0.join(path);
191            let mut fd = OpenOptions::new()
192                .create(true)
193                .truncate(true)
194                .write(true)
195                .open(&dst)
196                .await?;
197
198            fd.write_all(content.as_bytes()).await?;
199            fd.flush().await?;
200
201            Ok(dst)
202        }
203    }
204
205    impl Drop for TmpDir {
206        fn drop(&mut self) {
207            fs::remove_dir_all(&self.0)
208                .unwrap_or_else(|_| panic!("cannot cleanup temp dir '{}'", self.0.display()));
209        }
210    }
211
212    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
213    async fn should_load_a_valid_configuration() {
214        let dir = TmpDir::create_tmp_dir();
215        let cfg = r#"{
216            "gcs":{
217                "type": "gcs",
218                "configuration": {
219                    "name": "my-gcs-bucket"
220                }
221            }
222        }"#;
223
224        let cfg_path = dir.add_file("configuration.json", cfg).await.unwrap();
225        let cfg_layer = ConfigurationLayer::try_init(Some(&cfg_path)).await.unwrap();
226        let cfg = cfg_layer.get();
227
228        assert!(cfg.contains_key("gcs"));
229    }
230
231    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
232    async fn should_persist_new_configuration() {
233        let dir = TmpDir::create_tmp_dir();
234        let cfg_path = dir.add_file("configuration.json", r#"{}"#).await.unwrap();
235
236        // initialize configuration
237        let mut state_layer = PersistenceLayer::try_init(Some(&cfg_path)).await.unwrap();
238        let state = state_layer.get_mut();
239
240        // in-place edit & persistence
241        state.current = Some(String::from("gcs"));
242        state_layer.persist().await.unwrap();
243
244        let new_state_layer = PersistenceLayer::try_init(Some(&cfg_path)).await.unwrap();
245        assert_eq!(Some("gcs"), new_state_layer.get().current.as_deref());
246    }
247}