1use std::io;
2use std::io::Write;
3use std::path::Path;
4
5use crate::backends::Backend;
6use crate::backends::local::LocalBackend;
7use crate::backends::server::ServerBackend;
8use crate::paths::{CONFIG_FILE_NAME, DEFAULT_FOLDER_NAME, find_repo_root};
9use crate::progress::ProgressReader;
10use crate::utils::parse_size;
11use anyhow::Result;
12use fs_err as fs;
13use serde::{Deserialize, Deserializer, Serialize};
14use url::Url;
15
16const DEFAULT_PROGRESS_BYTE_SIZE_THRESHOLD: u64 = 524_288_000;
17
18fn deserialize_size_option<'de, D: Deserializer<'de>>(
19 deserializer: D,
20) -> Result<Option<u64>, D::Error> {
21 let s: Option<String> = Option::deserialize(deserializer)?;
22 match s {
23 None => Ok(None),
24 Some(s) => parse_size(&s).map(Some).map_err(serde::de::Error::custom),
25 }
26}
27
28#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
29#[serde(rename_all = "lowercase")]
30pub enum Compression {
31 None,
32 #[default]
33 Zstd,
34}
35
36impl std::fmt::Display for Compression {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 Compression::None => write!(f, "none"),
40 Compression::Zstd => write!(f, "zstd"),
41 }
42 }
43}
44
45impl Compression {
46 pub fn compress(
47 &self,
48 source: &Path,
49 dest: &Path,
50 on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
51 ) -> Result<u64> {
52 match self {
53 Compression::None => {
54 if let Some(cb) = on_bytes {
55 let input = fs::File::open(source)?;
56 let output = fs::File::create(dest)?;
57 let mut reader = ProgressReader::new(input, cb);
58 let mut writer = io::BufWriter::new(output);
59 let bytes = io::copy(&mut reader, &mut writer)?;
60 writer.flush()?;
61 Ok(bytes)
62 } else {
63 let bytes = fs::copy(source, dest)?;
64 Ok(bytes)
65 }
66 }
67 Compression::Zstd => {
68 let input = fs::File::open(source)?;
69 let output = fs::File::create(dest)?;
70
71 if let Some(cb) = on_bytes {
72 let tracked = ProgressReader::new(input, cb);
73 let mut encoder = zstd::stream::read::Encoder::new(tracked, 0)?;
74 let mut writer = io::BufWriter::new(output);
75 let bytes = io::copy(&mut encoder, &mut writer)?;
76 writer.flush()?;
77 Ok(bytes)
78 } else {
79 let mut encoder = zstd::stream::read::Encoder::new(input, 0)?;
80 let mut writer = io::BufWriter::new(output);
81 let bytes = io::copy(&mut encoder, &mut writer)?;
82 writer.flush()?;
83 Ok(bytes)
84 }
85 }
86 }
87 }
88
89 pub fn decompress(
90 &self,
91 source: &Path,
92 dest: &Path,
93 on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
94 ) -> Result<()> {
95 match self {
96 Compression::None => {
97 if let Some(cb) = on_bytes {
98 let input = fs::File::open(source)?;
99 let output = fs::File::create(dest)?;
100 let mut reader = ProgressReader::new(input, cb);
101 let mut writer = io::BufWriter::new(output);
102 io::copy(&mut reader, &mut writer)?;
103 writer.flush()?;
104 } else {
105 let mut input = fs::File::open(source)?;
106 let mut output = io::BufWriter::new(fs::File::create(dest)?);
107 io::copy(&mut input, &mut output)?;
108 output.flush()?;
109 }
110 Ok(())
111 }
112 Compression::Zstd => {
113 let input = fs::File::open(source)?;
114 let output = fs::File::create(dest)?;
115
116 let mut decoder = zstd::stream::read::Decoder::new(input)?;
117 let mut writer = io::BufWriter::new(output);
118 if let Some(cb) = on_bytes {
119 let mut reader = ProgressReader::new(&mut decoder, cb);
120 io::copy(&mut reader, &mut writer)?;
121 } else {
122 io::copy(&mut decoder, &mut writer)?;
123 }
124 writer.flush()?;
125 Ok(())
126 }
127 }
128 }
129}
130
131#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
132pub struct CliConfig {
133 #[serde(
135 default,
136 skip_serializing_if = "Option::is_none",
137 deserialize_with = "deserialize_size_option"
138 )]
139 progress_threshold: Option<u64>,
140}
141
142#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
145#[serde(rename_all = "lowercase")]
146pub enum OnConflict {
147 #[default]
149 Abort,
150 Skip,
152 Overwrite,
154}
155
156impl OnConflict {
157 pub const CHOICES: [&'static str; 3] = ["abort", "skip", "overwrite"];
158
159 pub fn as_str(&self) -> &'static str {
160 match self {
161 OnConflict::Abort => "abort",
162 OnConflict::Skip => "skip",
163 OnConflict::Overwrite => "overwrite",
164 }
165 }
166}
167
168impl std::fmt::Display for OnConflict {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 f.write_str(self.as_str())
171 }
172}
173
174impl std::str::FromStr for OnConflict {
175 type Err = String;
176
177 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
178 match s {
179 "abort" => Ok(OnConflict::Abort),
180 "skip" => Ok(OnConflict::Skip),
181 "overwrite" => Ok(OnConflict::Overwrite),
182 other => Err(format!(
183 "invalid on_conflict value {other:?}: expected one of abort, skip, overwrite"
184 )),
185 }
186 }
187}
188
189#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
191#[serde(rename_all = "lowercase")]
192pub enum SettingSource {
193 Argument,
194 Env,
195 Config,
196 Default,
197}
198
199impl SettingSource {
200 pub fn as_str(&self) -> &'static str {
202 match self {
203 SettingSource::Argument => "argument",
204 SettingSource::Env => "env",
205 SettingSource::Config => "config",
206 SettingSource::Default => "default",
207 }
208 }
209}
210
211impl std::fmt::Display for SettingSource {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 f.write_str(match self {
215 SettingSource::Argument => "command line",
216 SettingSource::Env => GET_ON_CONFLICT_ENV,
217 SettingSource::Config => "dvs.toml",
218 SettingSource::Default => "default",
219 })
220 }
221}
222
223#[derive(Debug)]
226pub struct ConfigError(pub String);
227
228impl std::fmt::Display for ConfigError {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 f.write_str(&self.0)
231 }
232}
233
234impl std::error::Error for ConfigError {}
235
236pub const GET_ON_CONFLICT_ENV: &str = "DVS_GET_ON_CONFLICT";
237
238#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
239#[serde(deny_unknown_fields)]
240pub struct GetConfig {
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 on_conflict: Option<OnConflict>,
243}
244
245#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
246pub struct Config {
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 compression: Option<Compression>,
250 metadata_folder_name: Option<String>,
254 pub(crate) backend: Backend,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 cli: Option<CliConfig>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 get: Option<GetConfig>,
259}
260
261impl Config {
262 pub fn new_local(path: impl AsRef<Path>, group: Option<String>) -> Result<Config> {
263 let backend = LocalBackend::new(path.as_ref(), group)?;
264 Ok(Config {
265 compression: Some(Compression::Zstd),
266 metadata_folder_name: None,
267 backend: Backend::Local(backend),
268 cli: None,
269 get: None,
270 })
271 }
272
273 pub fn new_server(name: String, url: Url, group: Option<String>) -> Config {
274 let backend = ServerBackend::new(name, group, url);
275 Config {
276 compression: Some(Compression::Zstd),
277 metadata_folder_name: None,
278 backend: Backend::Server(backend),
279 cli: None,
280 get: None,
281 }
282 }
283
284 pub fn save(&self, directory: impl AsRef<Path>) -> Result<()> {
285 let config_path = directory.as_ref().join(CONFIG_FILE_NAME);
286 let content = toml::to_string_pretty(&self)?;
287 fs::write(&config_path, content)?;
288 log::info!("Configuration saved to {}", config_path.display());
289 Ok(())
290 }
291
292 pub fn find(current_directory: impl AsRef<Path>) -> Option<Result<Self>> {
293 let repo_root = find_repo_root(current_directory);
294 let config_path = repo_root.join(CONFIG_FILE_NAME);
295 log::debug!("Looking for config at {}", config_path.display());
296 if config_path.exists() {
297 let content = match fs::read_to_string(&config_path) {
298 Ok(c) => c,
299 Err(e) => return Some(Err(e.into())),
300 };
301 Some(toml::from_str(&content).map_err(|e| {
302 ConfigError(format!("Failed to parse {}: {e}", config_path.display())).into()
303 }))
304 } else {
305 log::debug!("No config file found at {}", config_path.display());
306 None
307 }
308 }
309
310 pub fn set_metadata_folder_name(&mut self, name: String) {
311 self.metadata_folder_name = Some(name);
312 }
313
314 pub fn metadata_folder_name(&self) -> &str {
315 if let Some(name) = &self.metadata_folder_name {
316 name.as_str()
317 } else {
318 DEFAULT_FOLDER_NAME
319 }
320 }
321
322 pub fn compression(&self) -> Compression {
323 self.compression.unwrap_or_default()
324 }
325
326 pub fn set_compression(&mut self, compression: Option<Compression>) {
327 self.compression = compression;
328 }
329
330 pub fn backend(&self) -> &Backend {
331 &self.backend
332 }
333
334 pub fn server_url(&self) -> Option<&Url> {
335 match &self.backend {
336 Backend::Local(_) => None,
337 Backend::Server(s) => Some(&s.url),
338 }
339 }
340
341 pub fn get_on_conflict(
344 &self,
345 explicit: Option<OnConflict>,
346 ) -> std::result::Result<(OnConflict, SettingSource), ConfigError> {
347 resolve_on_conflict(
348 explicit,
349 std::env::var(GET_ON_CONFLICT_ENV).ok().as_deref(),
350 self.get.as_ref().and_then(|g| g.on_conflict),
351 )
352 }
353
354 pub fn progress_bytes_threshold(&self) -> u64 {
355 self.cli
356 .as_ref()
357 .and_then(|x| x.progress_threshold)
358 .unwrap_or(DEFAULT_PROGRESS_BYTE_SIZE_THRESHOLD)
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::testutil::create_temp_git_repo;
366
367 #[test]
368 fn config_save_and_find_roundtrip() {
369 let (_tmp, root) = create_temp_git_repo();
370 let storage = root.join(".storage");
371
372 let original = Config::new_local(&storage, None).unwrap();
373 original.save(&root).unwrap();
374
375 let loaded = Config::find(&root).unwrap().unwrap();
376 assert_eq!(original, loaded);
377 }
378
379 #[test]
380 fn config_find_returns_none_without_config_file() {
381 let (_tmp, root) = create_temp_git_repo();
382 assert!(Config::find(&root).is_none());
383 }
384
385 #[cfg(unix)]
386 #[test]
387 fn new_local_validates_group_exists() {
388 let tmp = tempfile::tempdir().unwrap();
389 let storage = tmp.path().join(".storage");
390
391 let result = Config::new_local(&storage, Some("nonexistent_group_12345".to_string()));
393 assert!(result.is_err());
394 assert!(result.unwrap_err().to_string().contains("not found"));
395 }
396
397 #[test]
398 fn config_with_custom_metadata_folder() {
399 let (_tmp, root) = create_temp_git_repo();
400 let storage = root.join(".storage");
401
402 let mut config = Config::new_local(&storage, None).unwrap();
403 config.set_metadata_folder_name(".custom_dvs".to_string());
404 config.save(&root).unwrap();
405
406 let loaded = Config::find(&root).unwrap().unwrap();
407 assert_eq!(loaded.metadata_folder_name(), ".custom_dvs");
408 }
409}
410
411fn resolve_on_conflict(
418 explicit: Option<OnConflict>,
419 env: Option<&str>,
420 project: Option<OnConflict>,
421) -> std::result::Result<(OnConflict, SettingSource), ConfigError> {
422 let env = env
423 .map(|v| {
424 v.parse::<OnConflict>()
425 .map_err(|e| ConfigError(format!("{GET_ON_CONFLICT_ENV}: {e}")))
426 })
427 .transpose()?;
428 if project == Some(OnConflict::Overwrite) {
429 return Err(ConfigError(format!(
430 "{CONFIG_FILE_NAME}: [get] on_conflict = \"overwrite\" is not allowed in project \
431 configuration, because it would discard collaborators' local changes. Use \
432 \"abort\" or \"skip\" here; pass --force (or on_conflict = \"overwrite\" in R, \
433 or {GET_ON_CONFLICT_ENV}=overwrite) when you mean it."
434 )));
435 }
436 Ok(explicit
437 .map(|p| (p, SettingSource::Argument))
438 .or(env.map(|p| (p, SettingSource::Env)))
439 .or(project.map(|p| (p, SettingSource::Config)))
440 .unwrap_or((OnConflict::default(), SettingSource::Default)))
441}
442
443#[cfg(test)]
444mod get_config_tests {
445 use super::*;
446 use crate::testutil::create_temp_git_repo;
447 use OnConflict::*;
448
449 fn config_with(get_section: &str) -> std::result::Result<Config, String> {
450 let (_tmp, root) = create_temp_git_repo();
451 let storage = root.parent().unwrap().join(".storage");
452 Config::new_local(&storage, None)
453 .unwrap()
454 .save(&root)
455 .unwrap();
456 let path = root.join(CONFIG_FILE_NAME);
457 let mut text = fs::read_to_string(&path).unwrap();
458 text.push_str(get_section);
459 fs::write(&path, text).unwrap();
460 Config::find(&root).unwrap().map_err(|e| e.to_string())
461 }
462
463 #[test]
464 fn precedence_is_argument_env_project_default() {
465 use SettingSource as S;
466 let r = resolve_on_conflict;
467 assert_eq!(
468 r(Some(Overwrite), Some("skip"), Some(Skip)).unwrap(),
469 (Overwrite, S::Argument)
470 );
471 assert_eq!(
472 r(None, Some("overwrite"), Some(Skip)).unwrap(),
473 (Overwrite, S::Env)
474 );
475 assert_eq!(r(None, None, Some(Skip)).unwrap(), (Skip, S::Config));
476 assert_eq!(r(None, None, None).unwrap(), (Abort, S::Default));
477 }
478
479 #[test]
480 fn bad_settings_are_errors_even_when_overridden() {
481 let err = resolve_on_conflict(Some(Skip), Some("sometimes"), None).unwrap_err();
482 assert!(err.0.contains(GET_ON_CONFLICT_ENV), "{err}");
483 let err = resolve_on_conflict(Some(Abort), Some("overwrite"), Some(Overwrite)).unwrap_err();
484 assert!(err.0.contains("not allowed"), "{err}");
485 }
486
487 #[test]
488 fn dvs_toml_get_section_is_read_and_checked() {
489 let config = config_with("\n[get]\non_conflict = \"skip\"\n").unwrap();
490 assert_eq!(config.get.unwrap().on_conflict, Some(Skip));
491 let err = config_with("\n[get]\non_conflicts = \"skip\"\n").unwrap_err();
492 assert!(err.contains("unknown field"), "{err}");
493 let err = config_with("\n[get]\non_conflict = \"sometimes\"\n").unwrap_err();
494 assert!(err.contains("sometimes"), "{err}");
495 }
496}