pingap_config/
file.rs

1// Copyright 2024-2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use super::{
16    read_all_toml_files, ConfigStorage, Error, LoadConfigOptions, PingapConf,
17    Result,
18};
19use async_trait::async_trait;
20use futures_util::TryFutureExt;
21use std::path::Path;
22use tokio::fs;
23
24pub struct FileStorage {
25    // Path to the configuration file or directory
26    path: String,
27    // Whether to separate config files by category/name
28    separation: bool,
29}
30impl FileStorage {
31    /// Create a new file storage for config.
32    pub fn new(path: &str) -> Result<Self> {
33        let mut separation = false;
34        let mut filepath = pingap_util::resolve_path(path);
35        // Parse query parameters if present (e.g., "path/to/config?separation=true")
36        if let Some((path, query)) = path.split_once('?') {
37            let m = pingap_core::convert_query_map(query);
38            separation = m.contains_key("separation");
39            filepath = pingap_util::resolve_path(path);
40        }
41        // Validate path is not empty
42        if filepath.is_empty() {
43            return Err(Error::Invalid {
44                message: "Config path is empty".to_string(),
45            });
46        }
47
48        Ok(Self {
49            path: filepath,
50            separation,
51        })
52    }
53}
54
55#[async_trait]
56impl ConfigStorage for FileStorage {
57    /// Load config from file.
58    async fn load_config(&self, opts: LoadConfigOptions) -> Result<PingapConf> {
59        let filepath = self.path.clone();
60        let dir = Path::new(&filepath);
61        // Return default config if admin mode and path doesn't exist
62        if opts.admin && !dir.exists() {
63            return Ok(PingapConf::default());
64        }
65        // Create directory if needed for non-TOML paths
66        if !filepath.ends_with(".toml") && !dir.exists() {
67            fs::create_dir_all(&filepath)
68                .map_err(|e| Error::Io {
69                    source: e,
70                    file: filepath.clone(),
71                })
72                .await?;
73        }
74
75        let mut data = vec![];
76        // Handle directory of TOML files
77        if dir.is_dir() {
78            let mut result = read_all_toml_files(&filepath).await?;
79            data.append(&mut result);
80        } else {
81            // Handle single TOML file
82            let mut buf = fs::read(&filepath).await.map_err(|e| Error::Io {
83                source: e,
84                file: filepath,
85            })?;
86            data.append(&mut buf);
87        }
88        PingapConf::new(data.as_slice(), opts.replace_include)
89    }
90    /// Save config to file by category.
91    async fn save_config(
92        &self,
93        conf: &PingapConf,
94        category: &str,
95        name: Option<&str>,
96    ) -> Result<()> {
97        let filepath = self.path.clone();
98        conf.validate()?;
99        let path = Path::new(&filepath);
100        if !path.exists() && path.extension().unwrap_or_default() == "toml" {
101            fs::File::create(&path).await.map_err(|e| Error::Io {
102                source: e,
103                file: filepath.clone(),
104            })?;
105        }
106
107        if path.is_file() {
108            // For single file storage:
109            // 1. Convert config to TOML
110            // 2. Remove empty sections
111            // 3. Write back to file
112            let ping_conf = toml::to_string_pretty(&conf)
113                .map_err(|e| Error::Ser { source: e })?;
114            let mut values: toml::Table = toml::from_str(&ping_conf)
115                .map_err(|e| Error::De { source: e })?;
116            // Remove empty sections
117            let mut omit_keys = vec![];
118            for key in values.keys() {
119                if let Some(value) = values.get(key) {
120                    if value.to_string() == "{}" {
121                        omit_keys.push(key.clone());
122                    }
123                }
124            }
125            for key in omit_keys {
126                values.remove(&key);
127            }
128            let ping_conf = toml::to_string_pretty(&values)
129                .map_err(|e| Error::Ser { source: e })?;
130            return fs::write(path, ping_conf).await.map_err(|e| Error::Io {
131                source: e,
132                file: filepath,
133            });
134        }
135
136        // For directory storage:
137        // Get TOML content based on category and optional name
138        let (path, toml_value) = if self.separation && name.is_some() {
139            conf.get_toml(category, name)?
140        } else {
141            conf.get_toml(category, None)?
142        };
143
144        let filepath = pingap_util::path_join(&filepath, &path);
145        let target_file = Path::new(&filepath);
146        if let Some(p) = Path::new(&target_file).parent() {
147            fs::create_dir_all(p).await.map_err(|e| Error::Io {
148                source: e,
149                file: filepath.clone(),
150            })?;
151        }
152        if toml_value.is_empty() {
153            if target_file.exists() {
154                fs::remove_file(&filepath).await.map_err(|e| Error::Io {
155                    source: e,
156                    file: filepath,
157                })?;
158            }
159        } else {
160            fs::write(&filepath, toml_value)
161                .await
162                .map_err(|e| Error::Io {
163                    source: e,
164                    file: filepath,
165                })?;
166        }
167
168        Ok(())
169    }
170    async fn save(&self, key: &str, data: &[u8]) -> Result<()> {
171        let key = pingap_util::path_join(&self.path, key);
172        let path = Path::new(&key);
173        if let Some(p) = path.parent() {
174            fs::create_dir_all(p).await.map_err(|e| Error::Io {
175                source: e,
176                file: key.to_string(),
177            })?;
178        }
179        fs::write(path, data).await.map_err(|e| Error::Io {
180            source: e,
181            file: key.to_string(),
182        })?;
183        Ok(())
184    }
185    async fn load(&self, key: &str) -> Result<Vec<u8>> {
186        let key = pingap_util::path_join(&self.path, key);
187        let path = Path::new(&key);
188        let buf = fs::read(path).await.map_err(|e| Error::Io {
189            source: e,
190            file: key.to_string(),
191        })?;
192        Ok(buf)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::FileStorage;
199    use crate::*;
200    use nanoid::nanoid;
201    use pretty_assertions::assert_eq;
202
203    #[tokio::test]
204    async fn test_file_storage() {
205        let result = FileStorage::new("");
206        assert_eq!(
207            "Invalid error Config path is empty",
208            result.err().unwrap().to_string()
209        );
210
211        let path = format!("/tmp/{}", nanoid!(16));
212        let file = format!("/tmp/{}.toml", nanoid!(16));
213        tokio::fs::write(&file, b"").await.unwrap();
214        let storage = FileStorage::new(&path).unwrap();
215        let file_storage = FileStorage::new(&file).unwrap();
216        let result = storage.load_config(LoadConfigOptions::default()).await;
217        assert_eq!(true, result.is_ok());
218
219        let toml_data = read_all_toml_files("../../conf").await.unwrap();
220        let conf =
221            PingapConf::new(toml_data.to_vec().as_slice(), false).unwrap();
222        for category in [
223            CATEGORY_CERTIFICATE.to_string(),
224            CATEGORY_UPSTREAM.to_string(),
225            CATEGORY_LOCATION.to_string(),
226            CATEGORY_SERVER.to_string(),
227            CATEGORY_PLUGIN.to_string(),
228            CATEGORY_STORAGE.to_string(),
229            CATEGORY_BASIC.to_string(),
230        ]
231        .iter()
232        {
233            storage.save_config(&conf, category, None).await.unwrap();
234            file_storage
235                .save_config(&conf, category, None)
236                .await
237                .unwrap();
238        }
239
240        let current_conf = storage
241            .load_config(LoadConfigOptions::default())
242            .await
243            .unwrap();
244        assert_eq!(current_conf.hash().unwrap(), conf.hash().unwrap());
245
246        let current_conf = file_storage
247            .load_config(LoadConfigOptions::default())
248            .await
249            .unwrap();
250        assert_eq!(current_conf.hash().unwrap(), conf.hash().unwrap());
251    }
252}