1use anyhow::{Context, Result, bail};
11use chrono::Local;
12use colored::Colorize;
13use std::sync::Arc;
14use std::sync::atomic::Ordering;
15use std::time::Duration;
16
17use crate::api::SmugMugClient;
18use crate::dated::{self, RunOptions};
19
20pub fn parse_interval(text: &str) -> Result<Duration> {
22 let text = text.trim();
23 if text.is_empty() {
24 bail!("Empty interval");
25 }
26 let mut total = 0u64;
27 let mut number = String::new();
28 for c in text.chars() {
29 if c.is_ascii_digit() {
30 number.push(c);
31 continue;
32 }
33 let unit = match c.to_ascii_lowercase() {
34 's' => 1,
35 'm' => 60,
36 'h' => 3600,
37 'd' => 86400,
38 ' ' => continue,
39 _ => bail!("Invalid interval '{}': use e.g. 30m, 6h or 1d", text),
40 };
41 let n: u64 = number
42 .parse()
43 .with_context(|| format!("Invalid interval '{}'", text))?;
44 total += n * unit;
45 number.clear();
46 }
47 if !number.is_empty() {
48 bail!("Invalid interval '{}': missing a unit (s, m, h or d)", text);
49 }
50 if total == 0 {
51 bail!("The interval must be more than zero");
52 }
53 Ok(Duration::from_secs(total))
54}
55
56fn describe(duration: Duration) -> String {
57 let secs = duration.as_secs();
58 match secs {
59 s if s % 86400 == 0 => format!("{}d", s / 86400),
60 s if s % 3600 == 0 => format!("{}h", s / 3600),
61 s if s % 60 == 0 => format!("{}m", s / 60),
62 s => format!("{}s", s),
63 }
64}
65
66pub async fn run(
67 client: Arc<SmugMugClient>,
68 options: RunOptions,
69 interval: Option<Duration>,
70) -> Result<()> {
71 let stop = dated::stop_on_signal();
72 println!("Backing up:");
73 for source in &options.sources {
74 println!(" {}", source.display());
75 }
76 println!(
77 "Into: {}/YYYY/MM/YYYY-MM-DD (private albums by the day each file was taken)",
78 options.root_folder
79 );
80 if !options.excludes.is_empty() {
81 println!("Excluding: {}", options.excludes.join(", "));
82 }
83 match interval {
84 Some(i) if !options.dry_run => println!("Every {} (after each run ends)", describe(i)),
85 _ => {}
86 }
87
88 loop {
89 let started = Local::now();
90 println!(
91 "\n{} {}",
92 "Backup started".bold(),
93 started.format("%Y-%m-%d %H:%M:%S")
94 );
95 match dated::run(client.clone(), &options, &stop).await {
96 Ok(stats) => {
97 dated::print_summary(&stats, &options);
98 println!(" Duration: {}", format_secs(stats.duration_secs));
99 if !options.dry_run {
100 write_last_run(&options, &stats, started);
101 }
102 }
103 Err(e) if interval.is_some() && !options.dry_run => {
106 eprintln!("{}", format!("✗ Backup failed: {:#}", e).red());
107 }
108 Err(e) => return Err(e),
109 }
110
111 let Some(interval) = interval.filter(|_| !options.dry_run) else {
112 return Ok(());
113 };
114 if stop.load(Ordering::Relaxed) {
115 return Ok(());
116 }
117 let next = Local::now() + chrono::Duration::from_std(interval)?;
118 println!("Next run at {}", next.format("%Y-%m-%d %H:%M:%S"));
119 let deadline = tokio::time::Instant::now() + interval;
121 while tokio::time::Instant::now() < deadline {
122 if stop.load(Ordering::Relaxed) {
123 return Ok(());
124 }
125 tokio::time::sleep(Duration::from_secs(2).min(deadline - tokio::time::Instant::now()))
126 .await;
127 }
128 }
129}
130
131fn format_secs(secs: u64) -> String {
132 let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
133 if h > 0 {
134 format!("{}h {}m {}s", h, m, s)
135 } else if m > 0 {
136 format!("{}m {}s", m, s)
137 } else {
138 format!("{}s", s)
139 }
140}
141
142fn write_last_run(options: &RunOptions, stats: &dated::RunStats, started: chrono::DateTime<Local>) {
145 #[derive(serde::Serialize)]
146 struct LastRun<'a> {
147 started: String,
148 finished: String,
149 sources: &'a [std::path::PathBuf],
150 folder: &'a str,
151 #[serde(flatten)]
152 stats: &'a dated::RunStats,
153 }
154 let last = LastRun {
155 started: started.to_rfc3339(),
156 finished: Local::now().to_rfc3339(),
157 sources: &options.sources,
158 folder: &options.root_folder,
159 stats,
160 };
161 let path = options.cache_path.join("last_run.json");
162 match serde_json::to_vec_pretty(&last) {
163 Ok(json) => {
164 if let Err(e) = std::fs::write(&path, json) {
165 eprintln!("Couldn't write {}: {}", path.display(), e);
166 }
167 }
168 Err(e) => eprintln!("Couldn't save the run summary: {}", e),
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn intervals() {
178 assert_eq!(parse_interval("90s").unwrap(), Duration::from_secs(90));
179 assert_eq!(parse_interval("30m").unwrap(), Duration::from_secs(1800));
180 assert_eq!(parse_interval("6h").unwrap(), Duration::from_secs(6 * 3600));
181 assert_eq!(parse_interval("1d").unwrap(), Duration::from_secs(86400));
182 assert_eq!(parse_interval("1h30m").unwrap(), Duration::from_secs(5400));
183 assert_eq!(parse_interval(" 2H ").unwrap(), Duration::from_secs(7200));
184 assert!(parse_interval("6").is_err());
185 assert!(parse_interval("0h").is_err());
186 assert!(parse_interval("six hours").is_err());
187 assert!(parse_interval("").is_err());
188 }
189
190 #[test]
191 fn descriptions() {
192 assert_eq!(describe(Duration::from_secs(86400)), "1d");
193 assert_eq!(describe(Duration::from_secs(5400)), "90m");
194 assert_eq!(describe(Duration::from_secs(21600)), "6h");
195 assert_eq!(describe(Duration::from_secs(45)), "45s");
196 }
197}