1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
//! Catalog of all backups.
use std::borrow::Cow;
use std::ops::RangeInclusive;
use std::path::{Path, PathBuf};
use std::{env, iter};
use async_fs as fs;
use chrono::{Local, NaiveDateTime};
use futures::future::join_all;
use futures::stream::StreamExt;
use regex::Regex;
use si_scale::helpers::bytes2;
use smol;
use crate::{
Result,
management::{
archive::v1,
backup::{Backup, BackupStatus},
compaction::{Plan, Strategy},
},
};
/// Catalog of all backups.
pub struct Catalog {
/// Location of the catalog.
pub dirpath: PathBuf,
/// Compaction strategy.
pub strategy: Strategy,
/// Sorted list of all backups (oldest to newest).
pub backups: Vec<Backup>,
}
// Public API
impl Catalog {
/// Return a new `Catalog` by listing the backups in `dirpath`.
///
/// # Notes
///
/// - The folder is created if missing.
/// - The catalog only manages backup files such as `backup-20220804T221153.tar.zst`, other
/// files are simply ignored (and in principle, should not be present).
pub async fn new<P: AsRef<Path>>(dirpath: P, strategy: Strategy) -> Result<Catalog> {
let dirpath = dirpath.as_ref();
fs::create_dir_all(dirpath).await?;
let backup_files = Self::parse_backup_filenames(dirpath).await?;
let catalog = Catalog {
dirpath: dirpath.to_path_buf(),
strategy,
backups: backup_files,
};
Ok(catalog)
}
/// Update the catalog's list of backups with the current content of `dirpath`.
///
/// This returns a new catalog with the updated content.
pub async fn refresh(self) -> Result<Catalog> {
let backups = Self::parse_backup_filenames(self.dirpath.as_path()).await?;
Ok(Catalog {
dirpath: self.dirpath,
strategy: self.strategy,
backups,
})
}
/// Update the catalog's list of backups with the current content of `dirpath`.
pub async fn refresh_mut(&mut self) -> Result<()> {
self.backups = Self::parse_backup_filenames(self.dirpath.as_path()).await?;
Ok(())
}
/// Total number of backups in the catalog.
pub fn len(&self) -> usize {
self.backups.len()
}
/// Return `true` if the catalog has no backups.
pub fn is_empty(&self) -> bool {
self.backups.is_empty()
}
/// Filepath of the most recent backup.
///
/// Because backups are sorted from oldest to most recent, both strategies agree on this.
pub fn latest(&self) -> Option<&Backup> {
self.backups.last()
}
/// Simulate the compaction strategy: list the backup files to delete, and the ones to keep.
pub fn plan(&self) -> Plan<'_> {
self.strategy.plan(&self.backups)
}
/// Apply the compaction strategy.
///
/// # Important
///
/// This will probably delete files in the `dirpath` folder.
pub async fn compact(&self) -> Result<usize> {
let Plan {
purgeable,
retainable: _retainable,
..
} = self.plan();
let n = purgeable.len();
for backup in purgeable {
fs::remove_file(&backup.filepath).await?;
}
Ok(n)
}
/// Apply the compaction strategy and update the catalog.
///
/// # Important
///
/// This will probably delete files in the `dirpath` folder.
pub async fn compact_mut(&mut self) {
self.compact()
.await
.expect("Error when compacting the catalog");
self.refresh_mut()
.await
.expect("Error when refreshing the catalog");
}
/// List backups.
///
/// By default, this prints a table of backups, age and status with colors. If `details_flag`
/// is `true`, the table has additional columns:
///
/// - version of the archive's format
/// - number of sessions
/// - number of windows
/// - number of panes
///
/// but this requires to read partially each backup file.
///
/// If `filepaths_flag` is `true`, only absolute filepaths are printed. This can be used in
/// scripting scenarios.
///
/// If `only_status` is a `Some(..)`, this lists only the corresponding backup filepaths,
/// acting as if `filepaths_flag` is `true`.
pub async fn list(
&self,
details_flag: bool,
only_status: Option<BackupStatus>,
filepaths_flag: bool,
) {
if filepaths_flag || only_status.is_some() {
match only_status {
Some(BackupStatus::Purgeable) => {
let Plan { purgeable, .. } = self.plan();
for backup in purgeable {
println!("{}", backup.filepath.to_string_lossy());
}
}
Some(BackupStatus::Retainable) => {
let Plan { retainable, .. } = self.plan();
for backup in retainable {
println!("{}", backup.filepath.to_string_lossy());
}
}
None => {
for backup in self.backups.iter() {
println!("{}", backup.filepath.to_string_lossy());
}
}
}
} else {
self.print_table(details_flag).await;
}
}
}
// Private functions
impl Catalog {
/// Return the list of `Backup` in `dirpath`.
async fn parse_backup_filenames<P: AsRef<Path>>(dirpath: P) -> Result<Vec<Backup>> {
let mut backups: Vec<Backup> = vec![];
let pattern = v1::backup_filepath_pattern();
let matcher = Regex::new(pattern).unwrap();
let mut entries = fs::read_dir(dirpath.as_ref()).await?;
while let Some(entry) = entries.next().await {
let entry = entry?;
let path = entry.path();
if let Some(captures) = matcher.captures(&path.to_string_lossy()) {
let date_str = &captures[1];
let creation_date =
NaiveDateTime::parse_from_str(date_str, "%Y%m%dT%H%M%S%.f").unwrap();
let backup = Backup {
filepath: path,
creation_date,
};
backups.push(backup);
}
}
backups.sort_unstable_by_key(|b| b.creation_date);
Ok(backups)
}
async fn print_table(&self, details_flag: bool) {
println!("Strategy: {}", &self.strategy);
// Try to strip the HOME prefix from self.dirpath, otherwise return self.dirpath.
let location: Cow<Path> = {
if let Some(remainder) = env::var("HOME")
.ok()
.and_then(|home_dir| self.dirpath.strip_prefix(home_dir).ok())
{
Cow::Owned(PathBuf::from("$HOME").join(remainder))
} else {
Cow::Borrowed(&self.dirpath)
}
};
println!("Location: `{}`\n", location.to_string_lossy());
let Plan {
purgeable,
retainable,
statuses,
} = self.plan();
let now = Local::now().naive_local();
let reset = "\u{001b}[0m";
let green = "\u{001b}[32m";
let yellow = "\u{001b}[33m";
// 45, 44, ..., 1
let indices = RangeInclusive::new(1, statuses.len()).rev();
if details_flag {
// Table header
println!(
"{:4} {:37} {:11} {:12} {:11} {:8} {:8}",
"", "NAME", "AGE", "STATUS", "FILESIZE", "VERSION", "CONTENT"
);
// Read all metadata concurrently
let tasks: Vec<_> = statuses
.iter()
.map(|&(backup, _)| {
let backup_filepath = backup.filepath.clone();
smol::spawn(async move { v1::Metadata::read_file(backup_filepath).await })
})
.collect();
let metadatas: Result<Vec<_>> = join_all(tasks).await.into_iter().collect();
let metadatas = metadatas.expect("Cannot read metadata files");
// Build & print table rows
for (index, ((backup, status), metadata)) in
iter::zip(indices, iter::zip(statuses, metadatas))
{
let filename = backup.filepath.file_name().unwrap().to_string_lossy();
let filesize = fs::metadata(backup.filepath.as_path()).await.unwrap().len();
let filesize = bytes2(filesize as f64);
let color = match status {
BackupStatus::Purgeable => yellow,
BackupStatus::Retainable => green,
};
let age = backup.age(now);
let overview = metadata.overview();
let version = &metadata.version;
println!(
"{index:3}. {color}{filename:32}{reset} {age:11} {color}{status:12}{reset} {filesize:11} {version:8} {overview:8}"
);
}
} else {
// Table header
println!("{:4} {:37} {:11} {:11}", "", "NAME", "AGE", "STATUS");
// Build & print table rows
for (index, (backup, status)) in iter::zip(indices, statuses) {
let filename = backup.filepath.file_name().unwrap().to_string_lossy();
let color = match status {
BackupStatus::Purgeable => yellow,
BackupStatus::Retainable => green,
};
let age = backup.age(now);
println!(
"{index:3}. {color}{filename:32}{reset} {age:11} {color}{status:6}{reset}"
);
}
}
println!(
"\n{} backups: {} retainable, {} purgeable",
self.len(),
retainable.len(),
purgeable.len(),
);
}
}