Skip to main content
← dvs documentation Rust API reference

dvs/
config.rs

1use std::io;
2use std::io::Write;
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use fs_err as fs;
7use serde::{Deserialize, Deserializer, Serialize};
8
9use crate::backends::Backend as BackendTrait;
10use crate::backends::local::LocalBackend;
11use crate::paths::{CONFIG_FILE_NAME, DEFAULT_FOLDER_NAME, find_repo_root};
12use crate::progress::ProgressReader;
13use crate::utils::parse_size;
14
15const DEFAULT_PROGRESS_BYTE_SIZE_THRESHOLD: u64 = 524_288_000;
16
17fn deserialize_size_option<'de, D: Deserializer<'de>>(
18    deserializer: D,
19) -> Result<Option<u64>, D::Error> {
20    let s: Option<String> = Option::deserialize(deserializer)?;
21    match s {
22        None => Ok(None),
23        Some(s) => parse_size(&s).map(Some).map_err(serde::de::Error::custom),
24    }
25}
26
27#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
28#[serde(rename_all = "lowercase")]
29pub enum Compression {
30    None,
31    #[default]
32    Zstd,
33}
34
35impl std::fmt::Display for Compression {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Compression::None => write!(f, "none"),
39            Compression::Zstd => write!(f, "zstd"),
40        }
41    }
42}
43
44impl Compression {
45    pub fn compress(
46        &self,
47        source: &Path,
48        dest: &Path,
49        on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
50    ) -> Result<u64> {
51        match self {
52            Compression::None => {
53                if let Some(cb) = on_bytes {
54                    let input = fs::File::open(source)?;
55                    let output = fs::File::create(dest)?;
56                    let mut reader = ProgressReader::new(input, cb);
57                    let mut writer = io::BufWriter::new(output);
58                    let bytes = io::copy(&mut reader, &mut writer)?;
59                    writer.flush()?;
60                    Ok(bytes)
61                } else {
62                    let bytes = fs::copy(source, dest)?;
63                    Ok(bytes)
64                }
65            }
66            Compression::Zstd => {
67                let input = fs::File::open(source)?;
68                let output = fs::File::create(dest)?;
69
70                if let Some(cb) = on_bytes {
71                    let tracked = ProgressReader::new(input, cb);
72                    let mut encoder = zstd::stream::read::Encoder::new(tracked, 0)?;
73                    let mut writer = io::BufWriter::new(output);
74                    let bytes = io::copy(&mut encoder, &mut writer)?;
75                    writer.flush()?;
76                    Ok(bytes)
77                } else {
78                    let mut encoder = zstd::stream::read::Encoder::new(input, 0)?;
79                    let mut writer = io::BufWriter::new(output);
80                    let bytes = io::copy(&mut encoder, &mut writer)?;
81                    writer.flush()?;
82                    Ok(bytes)
83                }
84            }
85        }
86    }
87
88    pub fn decompress(
89        &self,
90        source: &Path,
91        dest: &Path,
92        on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
93    ) -> Result<()> {
94        match self {
95            Compression::None => {
96                if let Some(cb) = on_bytes {
97                    let input = fs::File::open(source)?;
98                    let output = fs::File::create(dest)?;
99                    let mut reader = ProgressReader::new(input, cb);
100                    let mut writer = io::BufWriter::new(output);
101                    io::copy(&mut reader, &mut writer)?;
102                    writer.flush()?;
103                } else {
104                    let mut input = fs::File::open(source)?;
105                    let mut output = io::BufWriter::new(fs::File::create(dest)?);
106                    io::copy(&mut input, &mut output)?;
107                    output.flush()?;
108                }
109                Ok(())
110            }
111            Compression::Zstd => {
112                let input = fs::File::open(source)?;
113                let output = fs::File::create(dest)?;
114
115                let mut decoder = zstd::stream::read::Decoder::new(input)?;
116                let mut writer = io::BufWriter::new(output);
117                if let Some(cb) = on_bytes {
118                    let mut reader = ProgressReader::new(&mut decoder, cb);
119                    io::copy(&mut reader, &mut writer)?;
120                } else {
121                    io::copy(&mut decoder, &mut writer)?;
122                }
123                writer.flush()?;
124                Ok(())
125            }
126        }
127    }
128}
129
130#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
131#[serde(untagged)]
132pub enum Backend {
133    Local(LocalBackend),
134}
135
136#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
137pub struct CliConfig {
138    /// Defaults to 500MB if not set in the config file
139    #[serde(
140        default,
141        skip_serializing_if = "Option::is_none",
142        deserialize_with = "deserialize_size_option"
143    )]
144    progress_threshold: Option<u64>,
145}
146
147#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
148pub struct Config {
149    /// Compression algorithm to use for files in the storage directory
150    compression: Compression,
151    /// By default, all the metadata files (the .dvs files) will be stored in a `.dvs` folder
152    /// at the root of the repository
153    /// If this option is set, dvs will use that folder name instead of `.dvs`
154    metadata_folder_name: Option<String>,
155    pub(crate) backend: Backend,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    cli: Option<CliConfig>,
158}
159
160impl Config {
161    pub fn new_local(path: impl AsRef<Path>, group: Option<String>) -> Result<Config> {
162        let backend = LocalBackend::new(path.as_ref(), group)?;
163        Ok(Config {
164            compression: Compression::Zstd,
165            metadata_folder_name: None,
166            backend: Backend::Local(backend),
167            cli: None,
168        })
169    }
170
171    pub fn save(&self, directory: impl AsRef<Path>) -> Result<()> {
172        let config_path = directory.as_ref().join(CONFIG_FILE_NAME);
173        let content = toml::to_string_pretty(&self)?;
174        fs::write(&config_path, content)?;
175        log::info!("Configuration saved to {}", config_path.display());
176        Ok(())
177    }
178
179    pub fn find(current_directory: impl AsRef<Path>) -> Option<Result<Self>> {
180        let repo_root = find_repo_root(current_directory);
181        let config_path = repo_root.join(CONFIG_FILE_NAME);
182        log::debug!("Looking for config at {}", config_path.display());
183        if config_path.exists() {
184            let content = match fs::read_to_string(&config_path) {
185                Ok(c) => c,
186                Err(e) => return Some(Err(e.into())),
187            };
188            Some(
189                toml::from_str(&content)
190                    .with_context(|| format!("Failed to parse {}", config_path.display())),
191            )
192        } else {
193            log::debug!("No config file found at {}", config_path.display());
194            None
195        }
196    }
197
198    pub fn set_metadata_folder_name(&mut self, name: String) {
199        self.metadata_folder_name = Some(name);
200    }
201
202    pub fn metadata_folder_name(&self) -> &str {
203        if let Some(name) = &self.metadata_folder_name {
204            name.as_str()
205        } else {
206            DEFAULT_FOLDER_NAME
207        }
208    }
209
210    pub fn compression(&self) -> Compression {
211        self.compression
212    }
213
214    pub fn set_compression(&mut self, compression: Compression) {
215        self.compression = compression;
216    }
217
218    pub fn backend(&self) -> &dyn BackendTrait {
219        match &self.backend {
220            Backend::Local(b) => b,
221        }
222    }
223
224    pub fn progress_bytes_threshold(&self) -> u64 {
225        self.cli
226            .as_ref()
227            .and_then(|x| x.progress_threshold)
228            .unwrap_or(DEFAULT_PROGRESS_BYTE_SIZE_THRESHOLD)
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::testutil::create_temp_git_repo;
236
237    #[test]
238    fn config_save_and_find_roundtrip() {
239        let (_tmp, root) = create_temp_git_repo();
240        let storage = root.join(".storage");
241
242        let original = Config::new_local(&storage, None).unwrap();
243        original.save(&root).unwrap();
244
245        let loaded = Config::find(&root).unwrap().unwrap();
246        assert_eq!(original, loaded);
247    }
248
249    #[test]
250    fn config_find_returns_none_without_config_file() {
251        let (_tmp, root) = create_temp_git_repo();
252        assert!(Config::find(&root).is_none());
253    }
254
255    #[cfg(unix)]
256    #[test]
257    fn new_local_validates_group_exists() {
258        let tmp = tempfile::tempdir().unwrap();
259        let storage = tmp.path().join(".storage");
260
261        // Non-existent group should fail
262        let result = Config::new_local(&storage, Some("nonexistent_group_12345".to_string()));
263        assert!(result.is_err());
264        assert!(result.unwrap_err().to_string().contains("not found"));
265    }
266
267    #[test]
268    fn config_with_custom_metadata_folder() {
269        let (_tmp, root) = create_temp_git_repo();
270        let storage = root.join(".storage");
271
272        let mut config = Config::new_local(&storage, None).unwrap();
273        config.set_metadata_folder_name(".custom_dvs".to_string());
274        config.save(&root).unwrap();
275
276        let loaded = Config::find(&root).unwrap().unwrap();
277        assert_eq!(loaded.metadata_folder_name(), ".custom_dvs");
278    }
279}