rustic_rs/commands/
snapshots.rs

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