Skip to main content

rustic_rs/commands/
snapshots.rs

1//! `snapshot` subcommand
2
3use 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/// `snapshot` subcommand
27#[derive(clap::Parser, Command, Debug)]
28pub(crate) struct SnapshotCmd {
29    /// Snapshots to show. If none is given, use filter options to filter from all snapshots
30    ///
31    /// Snapshots can be identified the following ways: "01a2b3c4" or "latest" or "latest~N" (N >= 0)
32    #[clap(value_name = "ID")]
33    ids: Vec<String>,
34
35    /// Show detailed information about snapshots
36    #[arg(long)]
37    long: bool,
38
39    /// Show snapshots in json format
40    #[clap(long, conflicts_with = "long")]
41    json: bool,
42
43    /// Show all snapshots instead of summarizing identical follow-up snapshots
44    #[clap(long, conflicts_with_all = &["long", "json"])]
45    all: bool,
46
47    #[cfg(feature = "tui")]
48    /// Run in interactive UI mode
49    #[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                        // create app and run it
81                        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                // we don't use grouping, only output snapshots list
97                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            // Add all snapshots to output table
152            _ = table.add_rows(snapshots.into_iter().map(|sn| snap_to_table(&sn, 0)));
153        } else {
154            // Group snapshts by treeid and output into table
155            _ = 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    // note that if original was not set, it is set to snap.id by the load process
204    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}