rustic-rs 0.11.2

rustic - fast, encrypted, deduplicated backups powered by Rust
Documentation
//! `snapshot` subcommand

use crate::{
    Application, RUSTIC_APP,
    helpers::{bold_cell, bytes_size_to_string, table, table_right_from},
    repository::{OpenRepo, get_global_grouped_snapshots},
    status_err,
};

use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::Result;
use comfy_table::Cell;
use derive_more::From;
use itertools::Itertools;
use jiff::SignedDuration;

use rustic_core::{
    Group, ProgressBars, ProgressType, SnapshotGroup,
    repofile::{DeleteOption, SnapshotFile},
};
use serde::Serialize;

#[cfg(feature = "tui")]
use crate::commands::tui;

/// `snapshot` subcommand
#[derive(clap::Parser, Command, Debug)]
pub(crate) struct SnapshotCmd {
    /// Snapshots to show. If none is given, use filter options to filter from all snapshots
    ///
    /// Snapshots can be identified the following ways: "01a2b3c4" or "latest" or "latest~N" (N >= 0)
    #[clap(value_name = "ID")]
    ids: Vec<String>,

    /// Show detailed information about snapshots
    #[arg(long)]
    long: bool,

    /// Show snapshots in json format
    #[clap(long, conflicts_with = "long")]
    json: bool,

    /// Show all snapshots instead of summarizing identical follow-up snapshots
    #[clap(long, conflicts_with_all = &["long", "json"])]
    all: bool,

    #[cfg(feature = "tui")]
    /// Run in interactive UI mode
    #[clap(long, short)]
    pub interactive: bool,
}

impl Runnable for SnapshotCmd {
    fn run(&self) {
        if let Err(err) = RUSTIC_APP
            .config()
            .repository
            .run_open(|repo| self.inner_run(repo))
        {
            status_err!("{}", err);
            RUSTIC_APP.shutdown(Shutdown::Crash);
        };
    }
}

impl SnapshotCmd {
    fn inner_run(&self, repo: OpenRepo) -> Result<()> {
        #[cfg(feature = "tui")]
        if self.interactive {
            return tui::run(|progress| {
                let config = RUSTIC_APP.config();
                config
                    .repository
                    .run_indexed_with_progress(progress.clone(), |repo| {
                        let p = progress.progress(
                            ProgressType::Spinner,
                            "starting rustic in interactive mode...",
                        );
                        p.finish();
                        // create app and run it
                        let snapshots = tui::Snapshots::new(
                            &repo,
                            config.snapshot_filter.clone(),
                            config.global.group_by.unwrap_or_default(),
                        )?;
                        tui::run_app(progress.terminal, snapshots)
                    })
            });
        }

        let groups = get_global_grouped_snapshots(&repo, &self.ids)?.groups;

        if self.json {
            let mut stdout = std::io::stdout();
            if groups.len() == 1 && groups[0].group_key.is_empty() {
                // we don't use grouping, only output snapshots list
                serde_json::to_writer_pretty(&mut stdout, &groups[0].items)?;
            } else {
                #[derive(Serialize, From)]
                struct SnapshotsGroup {
                    group_key: SnapshotGroup,
                    snapshots: Vec<SnapshotFile>,
                }
                let groups: Vec<SnapshotsGroup> = groups
                    .into_iter()
                    .map(|g| (g.group_key, g.items).into())
                    .collect();
                serde_json::to_writer_pretty(&mut stdout, &groups)?;
            }
            return Ok(());
        }

        let mut total_count = 0;
        for Group { group_key, items } in groups {
            if !group_key.is_empty() {
                println!("\nsnapshots for {group_key}");
            }
            total_count += items.len();
            print_snapshots(items, self.long, self.all);
        }
        println!();
        println!("total: {total_count} snapshot(s)");

        Ok(())
    }
}

pub fn print_snapshots(snapshots: Vec<SnapshotFile>, long: bool, all: bool) {
    let count = snapshots.len();
    if long {
        for snap in snapshots {
            let mut table = table();

            let add_entry = |title: &str, value: String| {
                _ = table.add_row([bold_cell(title), Cell::new(value)]);
            };
            fill_table(&snap, add_entry);

            println!("{table}");
            println!();
        }
    } else {
        let mut table = table_right_from(
            6,
            [
                "ID", "Time", "Host", "Label", "Tags", "Paths", "Files", "Dirs", "Size",
            ],
        );

        if all {
            // Add all snapshots to output table
            _ = table.add_rows(snapshots.into_iter().map(|sn| snap_to_table(&sn, 0)));
        } else {
            // Group snapshts by treeid and output into table
            _ = table.add_rows(
                snapshots
                    .into_iter()
                    .chunk_by(|sn| sn.tree)
                    .into_iter()
                    .map(|(_, mut g)| snap_to_table(&g.next().unwrap(), g.count())),
            );
        }
        println!("{table}");
    }
    println!("{count} snapshot(s)");
}

pub fn snap_to_table(sn: &SnapshotFile, count: usize) -> [String; 9] {
    let config = RUSTIC_APP.config();
    let tags = sn.tags.formatln();
    let paths = sn.paths.formatln();
    let time = config.global.format_time(&sn.time);
    let (files, dirs, size) = sn.summary.as_ref().map_or_else(
        || ("?".to_string(), "?".to_string(), "?".to_string()),
        |s| {
            (
                s.total_files_processed.to_string(),
                s.total_dirs_processed.to_string(),
                bytes_size_to_string(s.total_bytes_processed),
            )
        },
    );
    let id = match count {
        0 => format!("{}", sn.id),
        count => format!("{} (+{})", sn.id, count),
    };
    [
        id,
        time.to_string(),
        sn.hostname.clone(),
        sn.label.clone(),
        tags,
        paths,
        files,
        dirs,
        size,
    ]
}

pub fn fill_table(snap: &SnapshotFile, mut add_entry: impl FnMut(&str, String)) {
    let config = RUSTIC_APP.config();
    add_entry("Snapshot", snap.id.to_hex().to_string());
    // note that if original was not set, it is set to snap.id by the load process
    if let Some(original) = snap.original
        && original != snap.id
    {
        add_entry("Original ID", original.to_hex().to_string());
    }
    add_entry("Time", config.global.format_time(&snap.time).to_string());
    add_entry("Generated by", snap.program_version.clone());
    add_entry("Host", snap.hostname.clone());
    add_entry("Label", snap.label.clone());
    add_entry("Tags", snap.tags.formatln());
    let delete = match &snap.delete {
        DeleteOption::NotSet => "not set".to_string(),
        DeleteOption::Never => "never".to_string(),
        DeleteOption::After(t) => format!("after {}", config.global.format_time(t)),
    };
    add_entry("Delete", delete);
    add_entry("Paths", snap.paths.formatln());
    let parent = snap.parent.map_or_else(
        || "no parent snapshot".to_string(),
        |p| p.to_hex().to_string(),
    );
    add_entry("Parent", parent);
    if let Some(ref summary) = snap.summary {
        add_entry("", String::new());
        add_entry("Command", summary.command.clone());

        let source = format!(
            "files: {} / dirs: {} / size: {}",
            summary.total_files_processed,
            summary.total_dirs_processed,
            bytes_size_to_string(summary.total_bytes_processed)
        );
        add_entry("Source", source);
        add_entry("", String::new());

        let files = format!(
            "new: {:>10} / changed: {:>10} / unchanged: {:>10}",
            summary.files_new, summary.files_changed, summary.files_unmodified,
        );
        add_entry("Files", files);

        let trees = format!(
            "new: {:>10} / changed: {:>10} / unchanged: {:>10}",
            summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified,
        );
        add_entry("Dirs", trees);
        add_entry("", String::new());

        let written = format!(
            "data:  {:>10} blobs / raw: {:>10} / packed: {:>10}\n\
            tree:  {:>10} blobs / raw: {:>10} / packed: {:>10}\n\
            total: {:>10} blobs / raw: {:>10} / packed: {:>10}",
            summary.data_blobs,
            bytes_size_to_string(summary.data_added_files),
            bytes_size_to_string(summary.data_added_files_packed),
            summary.tree_blobs,
            bytes_size_to_string(summary.data_added_trees),
            bytes_size_to_string(summary.data_added_trees_packed),
            summary.tree_blobs + summary.data_blobs,
            bytes_size_to_string(summary.data_added),
            bytes_size_to_string(summary.data_added_packed),
        );
        add_entry("Added to repo", written);

        let duration = format!(
            "backup start: {} / backup end: {} / backup duration: {:#}\n\
            total duration: {:#}",
            config.global.format_time(&summary.backup_start),
            config.global.format_time(&summary.backup_end),
            SignedDuration::from_secs_f64(summary.backup_duration),
            SignedDuration::from_secs_f64(summary.total_duration),
        );
        add_entry("Duration", duration);
    }
    if let Some(ref description) = snap.description {
        add_entry("Description", description.clone());
    }
}