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 #[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: Compression,
151 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 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}