1use std::path::Path;
20use std::time::Duration;
21
22use serde_json::{Map, Value};
23
24use crate::msg::{ConnectionField, FieldKind};
25use crate::options::{OptionField, OptionType};
26use crate::plugin::{ErrorKind, PluginError};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum IfExists {
31 Overwrite,
33 Error,
35 Number,
37}
38
39impl IfExists {
40 pub fn parse(s: &str) -> Option<IfExists> {
41 match s {
42 "overwrite" => Some(IfExists::Overwrite),
43 "error" => Some(IfExists::Error),
44 "number" => Some(IfExists::Number),
45 _ => None,
46 }
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Rules {
53 pub if_exists: IfExists,
54 pub atomic: bool,
56 pub temp_dir: Option<String>,
59 pub retries: u32,
61 pub connect_timeout: Duration,
63 pub timeout: Duration,
65}
66
67pub const DEFAULT_RETRIES: u32 = 3;
68pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
69pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
70
71impl Default for Rules {
72 fn default() -> Rules {
73 Rules {
74 if_exists: IfExists::Overwrite,
75 atomic: true,
76 temp_dir: None,
77 retries: DEFAULT_RETRIES,
78 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
79 timeout: DEFAULT_TIMEOUT,
80 }
81 }
82}
83
84impl Rules {
85 pub fn legacy() -> Rules {
88 Rules {
89 atomic: false,
90 retries: 0,
91 ..Rules::default()
92 }
93 }
94
95 pub fn from_settings(
100 base: Rules,
101 connection: &Map<String, Value>,
102 options: &Map<String, Value>,
103 aliases: &[(&str, &str)],
104 ) -> Result<(Rules, Vec<String>), String> {
105 let mut warnings = Vec::new();
106 let get = |key: &str, warnings: &mut Vec<String>| -> Option<Value> {
107 for map in [options, connection] {
108 if let Some(v) = map.get(key).filter(|v| !v.is_null()) {
109 return Some(v.clone());
110 }
111 for (old, new) in aliases {
112 if *new == key
113 && let Some(v) = map.get(*old).filter(|v| !v.is_null())
114 {
115 warnings.push(format!("`{old}` is deprecated; use `{new}`"));
116 return Some(v.clone());
117 }
118 }
119 }
120 None
121 };
122 let mut r = base;
123 if let Some(v) = get("if_exists", &mut warnings) {
124 r.if_exists = v.as_str().and_then(IfExists::parse).ok_or_else(|| {
125 format!("`if_exists` must be one of `overwrite`, `error`, `number`, got {v}")
126 })?;
127 }
128 if let Some(v) = get("atomic", &mut warnings) {
129 r.atomic = v
130 .as_bool()
131 .ok_or_else(|| format!("`atomic` must be true or false, got {v}"))?;
132 }
133 if let Some(v) = get("temp_dir", &mut warnings) {
134 let s = v
135 .as_str()
136 .ok_or_else(|| format!("`temp_dir` must be a path, got {v}"))?;
137 r.temp_dir = Some(s.to_string()).filter(|s| !s.is_empty());
138 }
139 if let Some(v) = get("retries", &mut warnings) {
140 r.retries = v
141 .as_u64()
142 .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
143 .and_then(|n| u32::try_from(n).ok())
144 .ok_or_else(|| format!("`retries` must be a whole number, got {v}"))?;
145 }
146 if let Some(v) = get("connect_timeout", &mut warnings) {
147 r.connect_timeout = parse_duration(&v).map_err(|e| format!("`connect_timeout` {e}"))?;
148 }
149 if let Some(v) = get("timeout", &mut warnings) {
150 r.timeout = parse_duration(&v).map_err(|e| format!("`timeout` {e}"))?;
151 }
152 Ok((r, warnings))
153 }
154}
155
156pub fn parse_duration(v: &Value) -> Result<Duration, String> {
158 let bad = || format!("must be a duration such as `30s`, `5m` or a number of seconds, got {v}");
159 if let Some(n) = v.as_f64() {
160 return (n >= 0.0 && n.is_finite())
161 .then(|| Duration::from_secs_f64(n))
162 .ok_or_else(bad);
163 }
164 let s = v.as_str().ok_or_else(bad)?.trim();
165 let (num, unit) = s.split_at(
166 s.find(|c: char| !c.is_ascii_digit() && c != '.')
167 .unwrap_or(s.len()),
168 );
169 let n: f64 = num.parse().map_err(|_| bad())?;
170 let secs = match unit.trim() {
171 "" | "s" => n,
172 "ms" => n / 1000.0,
173 "m" => n * 60.0,
174 "h" => n * 3600.0,
175 _ => return Err(bad()),
176 };
177 Ok(Duration::from_secs_f64(secs))
178}
179
180pub fn timeout_fields() -> Vec<ConnectionField> {
183 connection_fields()
184 .into_iter()
185 .filter(|f| f.name != "retries")
186 .collect()
187}
188
189pub fn connection_fields() -> Vec<ConnectionField> {
191 vec![
192 ConnectionField::new(
193 "connect_timeout",
194 "how long to wait for a connection (`30s`, `2m`, or seconds)",
195 )
196 .default("30s")
197 .kind(FieldKind::Duration)
198 .manual(),
199 ConnectionField::new(
200 "timeout",
201 "how long a read or write may make no progress before it fails",
202 )
203 .default("60s")
204 .kind(FieldKind::Duration)
205 .manual(),
206 ConnectionField::new(
207 "retries",
208 "how many times to try again after a temporary error (0: never)",
209 )
210 .default(DEFAULT_RETRIES)
211 .kind(FieldKind::Integer)
212 .manual(),
213 ]
214}
215
216pub fn option_fields() -> Vec<OptionField> {
218 vec![
219 OptionField::new(
220 "if_exists",
221 OptionType::String,
222 "when a file is already at the path: `overwrite` it, fail with `error`, or `number` the new one",
223 )
224 .choices(&["overwrite", "error", "number"])
225 .default("overwrite"),
226 OptionField::new(
227 "atomic",
228 OptionType::Boolean,
229 "upload under a temporary name, then rename, so no half-written file appears",
230 )
231 .default(true),
232 OptionField::new(
233 "temp_dir",
234 OptionType::String,
235 "where the temporary file goes (on the same server), for receivers that pick up any new file",
236 ),
237 ]
238}
239
240#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
242pub struct Caps {
243 pub create_exclusive: bool,
245 pub rename_no_replace: bool,
247 pub visible_when_complete: bool,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum StoreError {
254 Exists,
256 Temporary {
259 message: String,
260 retry_after: Option<Duration>,
261 },
262 Failed(String),
264}
265
266impl StoreError {
267 pub fn temporary(message: impl Into<String>) -> StoreError {
268 StoreError::Temporary {
269 message: message.into(),
270 retry_after: None,
271 }
272 }
273}
274
275impl std::fmt::Display for StoreError {
276 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277 match self {
278 StoreError::Exists => f.write_str("a file is already there"),
279 StoreError::Temporary { message, .. } | StoreError::Failed(message) => f.write_str(message),
280 }
281 }
282}
283
284pub trait Store {
286 fn caps(&self) -> Caps;
287 fn write(&mut self, local: &Path, remote: &str, exclusive: bool) -> Result<(), StoreError>;
290 fn rename(&mut self, from: &str, to: &str, replace: bool) -> Result<(), StoreError>;
293 fn exists(&mut self, remote: &str) -> Result<bool, StoreError>;
294 fn delete(&mut self, remote: &str) -> Result<(), StoreError>;
296 fn reset(&mut self) {}
299}
300
301static ATTEMPTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
304
305fn note_attempts(n: u32) {
306 ATTEMPTS.fetch_max(n, std::sync::atomic::Ordering::Relaxed);
307}
308
309pub fn take_attempts() -> u32 {
311 ATTEMPTS.swap(0, std::sync::atomic::Ordering::Relaxed).max(1)
312}
313
314#[derive(Debug)]
316pub enum Retry<E> {
317 Temporary(E, Option<Duration>),
320 Fail(E),
323}
324
325pub fn retry<T, E: std::fmt::Display>(
329 retries: u32,
330 what: &str,
331 f: impl FnMut() -> Result<T, Retry<E>>,
332) -> Result<T, E> {
333 retry_with(retries, what, &mut std::thread::sleep, f)
334}
335
336pub fn retry_with<T, E: std::fmt::Display>(
338 retries: u32,
339 what: &str,
340 sleep: &mut dyn FnMut(Duration),
341 mut f: impl FnMut() -> Result<T, Retry<E>>,
342) -> Result<T, E> {
343 let mut attempt = 1;
344 loop {
345 let r = f();
346 note_attempts(attempt);
347 match r {
348 Ok(v) => return Ok(v),
349 Err(Retry::Temporary(e, retry_after)) if attempt <= retries => {
350 let wait = retry_after.unwrap_or_else(|| backoff(attempt));
351 crate::log::info!(
352 "{what}: {e}; trying again in {}s (attempt {} of {})",
353 wait.as_secs(),
354 attempt + 1,
355 retries + 1
356 );
357 sleep(wait);
358 attempt += 1;
359 }
360 Err(Retry::Temporary(e, _) | Retry::Fail(e)) => return Err(e),
361 }
362 }
363}
364
365pub fn is_connection_error(e: &std::io::Error) -> bool {
368 use std::io::ErrorKind::*;
369 matches!(
370 e.kind(),
371 ConnectionRefused
372 | ConnectionReset
373 | ConnectionAborted
374 | NotConnected
375 | BrokenPipe
376 | TimedOut
377 | UnexpectedEof
378 | HostUnreachable
379 | NetworkUnreachable
380 | NetworkDown
381 )
382}
383
384pub fn retry_after(header: Option<&str>) -> Option<Duration> {
386 header?.trim().parse::<u64>().ok().map(Duration::from_secs)
387}
388
389#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct Delivered {
392 pub path: String,
394 pub attempts: u32,
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct DeliveryError {
401 pub message: String,
402 pub exists: bool,
404 pub attempts: u32,
405}
406
407impl std::fmt::Display for DeliveryError {
408 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409 f.write_str(&self.message)
410 }
411}
412
413impl std::error::Error for DeliveryError {}
414
415impl From<DeliveryError> for PluginError {
416 fn from(e: DeliveryError) -> PluginError {
417 let pe = PluginError::new(ErrorKind::Delivery, e.message);
418 if e.exists { pe.code("file-exists") } else { pe }
419 }
420}
421
422pub fn deliver(
424 store: &mut dyn Store,
425 local: &Path,
426 remote: &str,
427 rules: &Rules,
428) -> Result<Delivered, DeliveryError> {
429 deliver_with(store, local, remote, rules, &mut std::thread::sleep)
430}
431
432pub fn deliver_with(
434 store: &mut dyn Store,
435 local: &Path,
436 remote: &str,
437 rules: &Rules,
438 sleep: &mut dyn FnMut(Duration),
439) -> Result<Delivered, DeliveryError> {
440 let mut attempt = 1;
441 loop {
442 let r = try_once(store, local, remote, rules);
443 note_attempts(attempt);
444 match r {
445 Ok(path) => {
446 return Ok(Delivered {
447 path,
448 attempts: attempt,
449 });
450 }
451 Err(StoreError::Temporary { message, retry_after }) if attempt <= rules.retries => {
452 let wait = retry_after.unwrap_or_else(|| backoff(attempt));
453 crate::log::info!(
454 "{remote}: {message}; trying again in {}s (attempt {} of {})",
455 wait.as_secs(),
456 attempt + 1,
457 rules.retries + 1
458 );
459 store.reset();
460 sleep(wait);
461 attempt += 1;
462 }
463 Err(e) => {
464 let exists = e == StoreError::Exists;
465 let message = if exists {
466 format!("{remote} already exists (`if_exists: error`)")
467 } else if attempt > 1 {
468 format!("{e} (after {attempt} tries)")
469 } else {
470 e.to_string()
471 };
472 return Err(DeliveryError {
473 message,
474 exists,
475 attempts: attempt,
476 });
477 }
478 }
479 }
480}
481
482fn backoff(attempt: u32) -> Duration {
484 let base = 4f64.powi(attempt as i32 - 1);
485 let nanos = std::time::SystemTime::now()
486 .duration_since(std::time::UNIX_EPOCH)
487 .map(|d| d.subsec_nanos())
488 .unwrap_or(0);
489 let jitter = 0.75 + (nanos % 1000) as f64 / 2000.0;
490 Duration::from_secs_f64(base * jitter)
491}
492
493fn try_once(store: &mut dyn Store, local: &Path, remote: &str, rules: &Rules) -> Result<String, StoreError> {
495 let caps = store.caps();
496 let candidates = |i: u32| numbered(remote, i);
497 let limit = if rules.if_exists == IfExists::Number {
498 1000
499 } else {
500 1
501 };
502 if rules.atomic && !caps.visible_when_complete {
503 let temp = temp_path(remote, rules.temp_dir.as_deref());
504 if let Err(e) = store.write(local, &temp, false) {
505 let _ = store.delete(&temp);
506 return Err(e);
507 }
508 let replace = rules.if_exists == IfExists::Overwrite;
509 for i in 1..=limit {
510 let to = candidates(i);
511 let r = if !replace && !caps.rename_no_replace {
512 match store.exists(&to) {
514 Ok(true) => Err(StoreError::Exists),
515 Ok(false) => store.rename(&temp, &to, false),
516 Err(e) => Err(e),
517 }
518 } else {
519 store.rename(&temp, &to, replace)
520 };
521 match r {
522 Ok(()) => return Ok(to),
523 Err(StoreError::Exists) if i < limit => continue,
524 Err(e) => {
525 let _ = store.delete(&temp);
526 return Err(e);
527 }
528 }
529 }
530 unreachable!("the loop returns on its last candidate")
531 }
532 let exclusive = rules.if_exists != IfExists::Overwrite;
533 for i in 1..=limit {
534 let to = candidates(i);
535 let r = if exclusive && !caps.create_exclusive {
536 match store.exists(&to) {
537 Ok(true) => Err(StoreError::Exists),
538 Ok(false) => store.write(local, &to, false),
539 Err(e) => Err(e),
540 }
541 } else {
542 store.write(local, &to, exclusive)
543 };
544 match r {
545 Ok(()) => return Ok(to),
546 Err(StoreError::Exists) if i < limit => continue,
547 Err(e) => return Err(e),
548 }
549 }
550 unreachable!("the loop returns on its last candidate")
551}
552
553fn numbered(remote: &str, i: u32) -> String {
555 if i == 1 {
556 return remote.to_string();
557 }
558 let (dir, file) = split(remote);
559 let (stem, ext) = match file.rfind('.') {
560 Some(p) if p > 0 => (&file[..p], &file[p..]),
561 _ => (file, ""),
562 };
563 format!("{dir}{stem}_{i}{ext}")
564}
565
566fn temp_path(remote: &str, temp_dir: Option<&str>) -> String {
568 let (dir, file) = split(remote);
569 let name = format!(".{file}.dre-part");
570 match temp_dir {
571 None => format!("{dir}{name}"),
572 Some(t) if t.starts_with('/') || Path::new(t).is_absolute() => {
573 format!("{}/{name}", t.trim_end_matches(['/', '\\']))
574 }
575 Some(t) => format!("{dir}{}/{name}", t.trim_end_matches(['/', '\\'])),
576 }
577}
578
579fn split(remote: &str) -> (&str, &str) {
581 match remote.rfind(['/', '\\']) {
582 Some(p) => (&remote[..=p], &remote[p + 1..]),
583 None => ("", remote),
584 }
585}
586
587#[derive(Debug, Default)]
589pub struct LocalStore;
590
591fn io_failed(what: &str, path: &str, e: std::io::Error) -> StoreError {
592 if e.kind() == std::io::ErrorKind::AlreadyExists {
593 StoreError::Exists
594 } else {
595 StoreError::Failed(format!("can't {what} {path}: {e}"))
596 }
597}
598
599fn make_parent(path: &str) -> Result<(), StoreError> {
600 match Path::new(path).parent() {
601 Some(p) if !p.as_os_str().is_empty() => std::fs::create_dir_all(p)
602 .map_err(|e| StoreError::Failed(format!("can't create {}: {e}", p.display()))),
603 _ => Ok(()),
604 }
605}
606
607impl Store for LocalStore {
608 fn caps(&self) -> Caps {
609 Caps {
610 create_exclusive: true,
611 rename_no_replace: true,
612 visible_when_complete: false,
613 }
614 }
615
616 fn write(&mut self, local: &Path, remote: &str, exclusive: bool) -> Result<(), StoreError> {
617 make_parent(remote)?;
618 if !exclusive {
619 return std::fs::copy(local, remote)
620 .map(|_| ())
621 .map_err(|e| io_failed("write", remote, e));
622 }
623 let mut src = std::fs::File::open(local)
624 .map_err(|e| StoreError::Failed(format!("can't read {}: {e}", local.display())))?;
625 let mut dst = std::fs::OpenOptions::new()
626 .write(true)
627 .create_new(true)
628 .open(remote)
629 .map_err(|e| io_failed("write", remote, e))?;
630 std::io::copy(&mut src, &mut dst).map_err(|e| {
631 let _ = std::fs::remove_file(remote);
632 io_failed("write", remote, e)
633 })?;
634 Ok(())
635 }
636
637 fn rename(&mut self, from: &str, to: &str, replace: bool) -> Result<(), StoreError> {
638 make_parent(to)?;
639 if replace {
640 return std::fs::rename(from, to).map_err(|e| io_failed("rename to", to, e));
641 }
642 std::fs::hard_link(from, to).map_err(|e| io_failed("rename to", to, e))?;
644 std::fs::remove_file(from).map_err(|e| io_failed("remove", from, e))
645 }
646
647 fn exists(&mut self, remote: &str) -> Result<bool, StoreError> {
648 Ok(Path::new(remote).exists())
649 }
650
651 fn delete(&mut self, remote: &str) -> Result<(), StoreError> {
652 match std::fs::remove_file(remote) {
653 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(io_failed("remove", remote, e)),
654 _ => Ok(()),
655 }
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use serde_json::json;
663 use std::collections::BTreeMap;
664
665 #[derive(Default)]
667 struct Fake {
668 caps: Caps,
669 files: BTreeMap<String, String>,
670 flaky: u32,
672 break_rename: bool,
674 log: Vec<String>,
675 }
676
677 impl Store for Fake {
678 fn caps(&self) -> Caps {
679 self.caps
680 }
681 fn write(&mut self, local: &Path, remote: &str, exclusive: bool) -> Result<(), StoreError> {
682 self.log
683 .push(format!("write {remote}{}", if exclusive { " excl" } else { "" }));
684 if self.flaky > 0 {
685 self.flaky -= 1;
686 return Err(StoreError::temporary("connection reset"));
687 }
688 if exclusive && self.files.contains_key(remote) {
689 return Err(StoreError::Exists);
690 }
691 self.files.insert(remote.into(), local.display().to_string());
692 Ok(())
693 }
694 fn rename(&mut self, from: &str, to: &str, replace: bool) -> Result<(), StoreError> {
695 self.log.push(format!(
696 "rename {from} {to}{}",
697 if replace { " replace" } else { "" }
698 ));
699 if std::mem::take(&mut self.break_rename) {
700 return Err(StoreError::Failed("connection dropped".into()));
701 }
702 if !replace && self.files.contains_key(to) {
703 return Err(StoreError::Exists);
704 }
705 let v = self
706 .files
707 .remove(from)
708 .ok_or(StoreError::Failed("no such file".into()))?;
709 self.files.insert(to.into(), v);
710 Ok(())
711 }
712 fn exists(&mut self, remote: &str) -> Result<bool, StoreError> {
713 self.log.push(format!("exists {remote}"));
714 Ok(self.files.contains_key(remote))
715 }
716 fn delete(&mut self, remote: &str) -> Result<(), StoreError> {
717 self.files.remove(remote);
718 Ok(())
719 }
720 }
721
722 fn all_caps() -> Caps {
723 Caps {
724 create_exclusive: true,
725 rename_no_replace: true,
726 visible_when_complete: false,
727 }
728 }
729
730 fn run(store: &mut Fake, rules: &Rules) -> Result<Delivered, DeliveryError> {
731 deliver_with(store, Path::new("new"), "out/r.xlsx", rules, &mut |_| {})
732 }
733
734 fn rules(if_exists: IfExists, atomic: bool) -> Rules {
735 Rules {
736 if_exists,
737 atomic,
738 ..Rules::default()
739 }
740 }
741
742 #[test]
743 fn legacy_rules_write_straight_to_the_final_name() {
744 let mut s = Fake::default();
745 let d = run(&mut s, &Rules::legacy()).unwrap();
746 assert_eq!((d.path.as_str(), d.attempts), ("out/r.xlsx", 1));
747 assert_eq!(s.log, ["write out/r.xlsx"]);
748 }
749
750 #[test]
751 fn atomic_uploads_under_a_temporary_name_then_renames() {
752 let mut s = Fake {
753 caps: all_caps(),
754 ..Fake::default()
755 };
756 s.files.insert("out/r.xlsx".into(), "old".into());
757 run(&mut s, &rules(IfExists::Overwrite, true)).unwrap();
758 assert_eq!(
759 s.log,
760 [
761 "write out/.r.xlsx.dre-part",
762 "rename out/.r.xlsx.dre-part out/r.xlsx replace"
763 ]
764 );
765 assert_eq!(s.files.keys().collect::<Vec<_>>(), ["out/r.xlsx"]);
766 assert_eq!(s.files["out/r.xlsx"], "new");
767 }
768
769 #[test]
770 fn a_dropped_rename_leaves_no_file_at_the_final_name_or_temporary_one() {
771 let mut s = Fake {
772 caps: all_caps(),
773 break_rename: true,
774 ..Fake::default()
775 };
776 assert!(run(&mut s, &rules(IfExists::Overwrite, true)).is_err());
777 assert!(s.files.is_empty(), "{:?}", s.files);
778 }
779
780 #[test]
781 fn temp_dir_holds_the_temporary_file() {
782 let mut s = Fake {
783 caps: all_caps(),
784 ..Fake::default()
785 };
786 let mut r = rules(IfExists::Overwrite, true);
787 r.temp_dir = Some("../staging".into());
788 run(&mut s, &r).unwrap();
789 assert_eq!(s.log[0], "write out/../staging/.r.xlsx.dre-part");
790 r.temp_dir = Some("/tmp/".into());
791 s.log.clear();
792 run(&mut s, &r).unwrap();
793 assert_eq!(s.log[0], "write /tmp/.r.xlsx.dre-part");
794 }
795
796 #[test]
797 fn error_fails_when_the_file_exists_and_cleans_up() {
798 for atomic in [false, true] {
799 let mut s = Fake {
800 caps: all_caps(),
801 ..Fake::default()
802 };
803 s.files.insert("out/r.xlsx".into(), "old".into());
804 let e = run(&mut s, &rules(IfExists::Error, atomic)).unwrap_err();
805 assert!(e.exists && e.message.contains("already exists"), "{e:?}");
806 assert_eq!(s.files.len(), 1, "atomic={atomic}: {:?}", s.files);
807 assert_eq!(s.files["out/r.xlsx"], "old");
808 let pe = PluginError::from(e);
809 assert_eq!(
810 (pe.kind, pe.code.as_deref()),
811 (ErrorKind::Delivery, Some("file-exists"))
812 );
813 }
814 }
815
816 #[test]
817 fn number_keeps_both_files() {
818 for (atomic, caps) in [
819 (false, all_caps()),
820 (true, all_caps()),
821 (false, Caps::default()),
822 (true, Caps::default()),
823 ] {
824 let mut s = Fake {
825 caps,
826 ..Fake::default()
827 };
828 s.files.insert("out/r.xlsx".into(), "old".into());
829 s.files.insert("out/r_2.xlsx".into(), "old2".into());
830 let d = run(&mut s, &rules(IfExists::Number, atomic)).unwrap();
831 assert_eq!(d.path, "out/r_3.xlsx", "atomic={atomic} caps={caps:?}");
832 assert_eq!(s.files["out/r_3.xlsx"], "new");
833 assert_eq!(s.files.len(), 3, "{:?}", s.files);
834 }
835 }
836
837 #[test]
838 fn without_single_step_checks_it_looks_first() {
839 let mut s = Fake::default();
840 s.files.insert("out/r.xlsx".into(), "old".into());
841 assert!(run(&mut s, &rules(IfExists::Error, false)).unwrap_err().exists);
842 assert_eq!(s.log, ["exists out/r.xlsx"]);
843 }
844
845 #[test]
846 fn temporary_errors_are_retried_then_give_up() {
847 let mut s = Fake {
848 flaky: 2,
849 ..Fake::default()
850 };
851 let mut waits = Vec::new();
852 let d = deliver_with(&mut s, Path::new("new"), "r.csv", &Rules::default(), &mut |w| {
853 waits.push(w)
854 })
855 .unwrap();
856 assert_eq!(d.attempts, 3);
857 assert_eq!(waits.len(), 2);
858 assert!(waits[0] < waits[1]);
859 let mut s = Fake {
860 flaky: 9,
861 ..Fake::default()
862 };
863 let e = deliver_with(&mut s, Path::new("new"), "r.csv", &Rules::default(), &mut |_| {}).unwrap_err();
864 assert_eq!(e.attempts, 4);
865 assert!(e.message.contains("after 4 tries"), "{e}");
866 let no_retry = Rules {
867 retries: 0,
868 ..Rules::default()
869 };
870 let mut s = Fake {
871 flaky: 1,
872 ..Fake::default()
873 };
874 assert_eq!(run(&mut s, &no_retry).unwrap_err().attempts, 1);
875 }
876
877 #[test]
878 fn object_stores_need_no_temporary_name() {
879 let mut s = Fake {
880 caps: Caps {
881 create_exclusive: true,
882 visible_when_complete: true,
883 ..Caps::default()
884 },
885 ..Fake::default()
886 };
887 run(&mut s, &rules(IfExists::Error, true)).unwrap();
888 assert_eq!(s.log, ["write out/r.xlsx excl"]);
889 }
890
891 #[test]
892 fn settings_parse_with_aliases_and_errors() {
893 let conn = json!({"connect_timeout": "2m", "upload_timeout": 90, "retries": "0"});
894 let opts = json!({"if_exists": "number", "atomic": false, "temp_dir": "../in"});
895 let (r, warnings) = Rules::from_settings(
896 Rules::default(),
897 conn.as_object().unwrap(),
898 opts.as_object().unwrap(),
899 &[("upload_timeout", "timeout")],
900 )
901 .unwrap();
902 assert_eq!(r.if_exists, IfExists::Number);
903 assert!(!r.atomic);
904 assert_eq!(r.temp_dir.as_deref(), Some("../in"));
905 assert_eq!(r.retries, 0);
906 assert_eq!(r.connect_timeout, Duration::from_secs(120));
907 assert_eq!(r.timeout, Duration::from_secs(90));
908 assert_eq!(warnings, ["`upload_timeout` is deprecated; use `timeout`"]);
909 let bad = json!({"if_exists": "keep"});
910 assert!(
911 Rules::from_settings(Rules::default(), &Map::new(), bad.as_object().unwrap(), &[])
912 .unwrap_err()
913 .contains("if_exists")
914 );
915 }
916
917 #[test]
918 fn durations_take_units_or_seconds() {
919 for (v, secs) in [
920 (json!("30s"), 30.0),
921 (json!("5m"), 300.0),
922 (json!("1h"), 3600.0),
923 (json!("1500ms"), 1.5),
924 (json!(45), 45.0),
925 (json!("7"), 7.0),
926 ] {
927 assert_eq!(parse_duration(&v).unwrap(), Duration::from_secs_f64(secs), "{v}");
928 }
929 for v in [json!("soon"), json!("5 days"), json!(-1), json!(true)] {
930 assert!(parse_duration(&v).is_err(), "{v}");
931 }
932 }
933
934 #[test]
935 fn names_number_and_hide_the_temporary_file() {
936 assert_eq!(numbered("out/r.xlsx", 2), "out/r_2.xlsx");
937 assert_eq!(numbered("r", 3), "r_3");
938 assert_eq!(numbered("a.b/.env", 2), "a.b/.env_2");
939 assert_eq!(numbered("x.tar.gz", 2), "x.tar_2.gz");
940 assert_eq!(temp_path("r.csv", None), ".r.csv.dre-part");
941 }
942
943 #[test]
944 fn the_local_store_follows_every_rule() {
945 let dir = tempfile::tempdir().unwrap();
946 let src = dir.path().join("src.csv");
947 std::fs::write(&src, "new").unwrap();
948 let dst = dir.path().join("out/r.csv");
949 let dst = dst.to_str().unwrap();
950 let mut s = LocalStore;
951 deliver(&mut s, &src, dst, &Rules::legacy()).unwrap();
952 assert_eq!(std::fs::read_to_string(dst).unwrap(), "new");
953 std::fs::write(&src, "newer").unwrap();
954 deliver(&mut s, &src, dst, &rules(IfExists::Overwrite, true)).unwrap();
955 assert_eq!(std::fs::read_to_string(dst).unwrap(), "newer");
956 let e = deliver(&mut s, &src, dst, &rules(IfExists::Error, true)).unwrap_err();
957 assert!(e.exists);
958 let d = deliver(&mut s, &src, dst, &rules(IfExists::Number, true)).unwrap();
959 assert!(d.path.ends_with("r_2.csv"), "{d:?}");
960 let d = deliver(&mut s, &src, dst, &rules(IfExists::Number, false)).unwrap();
961 assert!(d.path.ends_with("r_3.csv"), "{d:?}");
962 let mut names: Vec<String> = std::fs::read_dir(dir.path().join("out"))
963 .unwrap()
964 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
965 .collect();
966 names.sort();
967 assert_eq!(names, ["r.csv", "r_2.csv", "r_3.csv"]);
968 }
969}