1use crate::{
4 Application, RUSTIC_APP,
5 helpers::{bold_cell, bytes_size_to_string, table, table_right_from},
6 repository::{OpenRepo, get_global_grouped_snapshots},
7 status_err,
8};
9
10use abscissa_core::{Command, Runnable, Shutdown};
11use anyhow::Result;
12use comfy_table::Cell;
13use derive_more::From;
14use itertools::Itertools;
15use jiff::SignedDuration;
16
17use rustic_core::{
18 Group, ProgressBars, ProgressType, SnapshotGroup,
19 repofile::{DeleteOption, SnapshotFile},
20};
21use serde::Serialize;
22
23#[cfg(feature = "tui")]
24use crate::commands::tui;
25
26#[derive(clap::Parser, Command, Debug)]
28pub(crate) struct SnapshotCmd {
29 #[clap(value_name = "ID")]
33 ids: Vec<String>,
34
35 #[arg(long)]
37 long: bool,
38
39 #[clap(long, conflicts_with = "long")]
41 json: bool,
42
43 #[clap(long, conflicts_with_all = &["long", "json"])]
45 all: bool,
46
47 #[cfg(feature = "tui")]
48 #[clap(long, short)]
50 pub interactive: bool,
51}
52
53impl Runnable for SnapshotCmd {
54 fn run(&self) {
55 if let Err(err) = RUSTIC_APP
56 .config()
57 .repository
58 .run_open(|repo| self.inner_run(repo))
59 {
60 status_err!("{}", err);
61 RUSTIC_APP.shutdown(Shutdown::Crash);
62 };
63 }
64}
65
66impl SnapshotCmd {
67 fn inner_run(&self, repo: OpenRepo) -> Result<()> {
68 #[cfg(feature = "tui")]
69 if self.interactive {
70 return tui::run(|progress| {
71 let config = RUSTIC_APP.config();
72 config
73 .repository
74 .run_indexed_with_progress(progress.clone(), |repo| {
75 let p = progress.progress(
76 ProgressType::Spinner,
77 "starting rustic in interactive mode...",
78 );
79 p.finish();
80 let snapshots = tui::Snapshots::new(
82 &repo,
83 config.snapshot_filter.clone(),
84 config.global.group_by.unwrap_or_default(),
85 )?;
86 tui::run_app(progress.terminal, snapshots)
87 })
88 });
89 }
90
91 let groups = get_global_grouped_snapshots(&repo, &self.ids)?.groups;
92
93 if self.json {
94 let mut stdout = std::io::stdout();
95 if groups.len() == 1 && groups[0].group_key.is_empty() {
96 serde_json::to_writer_pretty(&mut stdout, &groups[0].items)?;
98 } else {
99 #[derive(Serialize, From)]
100 struct SnapshotsGroup {
101 group_key: SnapshotGroup,
102 snapshots: Vec<SnapshotFile>,
103 }
104 let groups: Vec<SnapshotsGroup> = groups
105 .into_iter()
106 .map(|g| (g.group_key, g.items).into())
107 .collect();
108 serde_json::to_writer_pretty(&mut stdout, &groups)?;
109 }
110 return Ok(());
111 }
112
113 let mut total_count = 0;
114 for Group { group_key, items } in groups {
115 if !group_key.is_empty() {
116 println!("\nsnapshots for {group_key}");
117 }
118 total_count += items.len();
119 print_snapshots(items, self.long, self.all);
120 }
121 println!();
122 println!("total: {total_count} snapshot(s)");
123
124 Ok(())
125 }
126}
127
128pub fn print_snapshots(snapshots: Vec<SnapshotFile>, long: bool, all: bool) {
129 let count = snapshots.len();
130 if long {
131 for snap in snapshots {
132 let mut table = table();
133
134 let add_entry = |title: &str, value: String| {
135 _ = table.add_row([bold_cell(title), Cell::new(value)]);
136 };
137 fill_table(&snap, add_entry);
138
139 println!("{table}");
140 println!();
141 }
142 } else {
143 let mut table = table_right_from(
144 6,
145 [
146 "ID", "Time", "Host", "Label", "Tags", "Paths", "Files", "Dirs", "Size",
147 ],
148 );
149
150 if all {
151 _ = table.add_rows(snapshots.into_iter().map(|sn| snap_to_table(&sn, 0)));
153 } else {
154 _ = table.add_rows(
156 snapshots
157 .into_iter()
158 .chunk_by(|sn| sn.tree)
159 .into_iter()
160 .map(|(_, mut g)| snap_to_table(&g.next().unwrap(), g.count())),
161 );
162 }
163 println!("{table}");
164 }
165 println!("{count} snapshot(s)");
166}
167
168pub fn snap_to_table(sn: &SnapshotFile, count: usize) -> [String; 9] {
169 let config = RUSTIC_APP.config();
170 let tags = sn.tags.formatln();
171 let paths = sn.paths.formatln();
172 let time = config.global.format_time(&sn.time);
173 let (files, dirs, size) = sn.summary.as_ref().map_or_else(
174 || ("?".to_string(), "?".to_string(), "?".to_string()),
175 |s| {
176 (
177 s.total_files_processed.to_string(),
178 s.total_dirs_processed.to_string(),
179 bytes_size_to_string(s.total_bytes_processed),
180 )
181 },
182 );
183 let id = match count {
184 0 => format!("{}", sn.id),
185 count => format!("{} (+{})", sn.id, count),
186 };
187 [
188 id,
189 time.to_string(),
190 sn.hostname.clone(),
191 sn.label.clone(),
192 tags,
193 paths,
194 files,
195 dirs,
196 size,
197 ]
198}
199
200pub fn fill_table(snap: &SnapshotFile, mut add_entry: impl FnMut(&str, String)) {
201 let config = RUSTIC_APP.config();
202 add_entry("Snapshot", snap.id.to_hex().to_string());
203 if let Some(original) = snap.original
205 && original != snap.id
206 {
207 add_entry("Original ID", original.to_hex().to_string());
208 }
209 add_entry("Time", config.global.format_time(&snap.time).to_string());
210 add_entry("Generated by", snap.program_version.clone());
211 add_entry("Host", snap.hostname.clone());
212 add_entry("Label", snap.label.clone());
213 add_entry("Tags", snap.tags.formatln());
214 let delete = match &snap.delete {
215 DeleteOption::NotSet => "not set".to_string(),
216 DeleteOption::Never => "never".to_string(),
217 DeleteOption::After(t) => format!("after {}", config.global.format_time(t)),
218 };
219 add_entry("Delete", delete);
220 add_entry("Paths", snap.paths.formatln());
221 let parent = snap.parent.map_or_else(
222 || "no parent snapshot".to_string(),
223 |p| p.to_hex().to_string(),
224 );
225 add_entry("Parent", parent);
226 if let Some(ref summary) = snap.summary {
227 add_entry("", String::new());
228 add_entry("Command", summary.command.clone());
229
230 let source = format!(
231 "files: {} / dirs: {} / size: {}",
232 summary.total_files_processed,
233 summary.total_dirs_processed,
234 bytes_size_to_string(summary.total_bytes_processed)
235 );
236 add_entry("Source", source);
237 add_entry("", String::new());
238
239 let files = format!(
240 "new: {:>10} / changed: {:>10} / unchanged: {:>10}",
241 summary.files_new, summary.files_changed, summary.files_unmodified,
242 );
243 add_entry("Files", files);
244
245 let trees = format!(
246 "new: {:>10} / changed: {:>10} / unchanged: {:>10}",
247 summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified,
248 );
249 add_entry("Dirs", trees);
250 add_entry("", String::new());
251
252 let written = format!(
253 "data: {:>10} blobs / raw: {:>10} / packed: {:>10}\n\
254 tree: {:>10} blobs / raw: {:>10} / packed: {:>10}\n\
255 total: {:>10} blobs / raw: {:>10} / packed: {:>10}",
256 summary.data_blobs,
257 bytes_size_to_string(summary.data_added_files),
258 bytes_size_to_string(summary.data_added_files_packed),
259 summary.tree_blobs,
260 bytes_size_to_string(summary.data_added_trees),
261 bytes_size_to_string(summary.data_added_trees_packed),
262 summary.tree_blobs + summary.data_blobs,
263 bytes_size_to_string(summary.data_added),
264 bytes_size_to_string(summary.data_added_packed),
265 );
266 add_entry("Added to repo", written);
267
268 let duration = format!(
269 "backup start: {} / backup end: {} / backup duration: {:#}\n\
270 total duration: {:#}",
271 config.global.format_time(&summary.backup_start),
272 config.global.format_time(&summary.backup_end),
273 SignedDuration::from_secs_f64(summary.backup_duration),
274 SignedDuration::from_secs_f64(summary.total_duration),
275 );
276 add_entry("Duration", duration);
277 }
278 if let Some(ref description) = snap.description {
279 add_entry("Description", description.clone());
280 }
281}