Skip to main content

idb/cli/
backup.rs

1//! CLI implementation for the `inno backup` subcommand.
2//!
3//! Two modes: `diff` compares page LSNs between a base and current tablespace
4//! to detect changed pages; `chain` validates XtraBackup backup chain LSN continuity.
5
6use std::io::Write;
7use std::path::Path;
8
9use crate::cli::{csv_escape, open_tablespace, setup_decryption, wprintln};
10use crate::innodb::backup;
11use crate::IdbError;
12
13/// Options for `inno backup diff`.
14pub struct BackupDiffOptions {
15    pub base: String,
16    pub current: String,
17    pub verbose: bool,
18    pub json: bool,
19    pub csv: bool,
20    pub page_size: Option<u32>,
21    pub keyring: Option<String>,
22    pub mmap: bool,
23}
24
25/// Options for `inno backup chain`.
26pub struct BackupChainOptions {
27    pub dir: String,
28    pub verbose: bool,
29    pub json: bool,
30    pub csv: bool,
31}
32
33/// Execute the `inno backup diff` subcommand.
34pub fn execute_diff(opts: &BackupDiffOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
35    let mut base = open_tablespace(&opts.base, opts.page_size, opts.mmap)?;
36    if let Some(ref kp) = opts.keyring {
37        setup_decryption(&mut base, kp)?;
38    }
39    let mut current = open_tablespace(&opts.current, opts.page_size, opts.mmap)?;
40    if let Some(ref kp) = opts.keyring {
41        setup_decryption(&mut current, kp)?;
42    }
43
44    let report = backup::diff_backup_lsn(
45        &mut base,
46        &mut current,
47        &opts.base,
48        &opts.current,
49        opts.verbose,
50    )?;
51
52    if opts.json {
53        let json =
54            serde_json::to_string_pretty(&report).map_err(|e| IdbError::Parse(e.to_string()))?;
55        wprintln!(writer, "{}", json)?;
56    } else if opts.csv {
57        print_diff_csv(writer, &report)?;
58    } else {
59        print_diff_text(writer, &report)?;
60    }
61
62    Ok(())
63}
64
65/// Execute the `inno backup chain` subcommand.
66pub fn execute_chain(opts: &BackupChainOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
67    let report = backup::scan_backup_chain(Path::new(&opts.dir))?;
68
69    if opts.json {
70        let json =
71            serde_json::to_string_pretty(&report).map_err(|e| IdbError::Parse(e.to_string()))?;
72        wprintln!(writer, "{}", json)?;
73    } else if opts.csv {
74        print_chain_csv(writer, &report)?;
75    } else {
76        print_chain_text(writer, &report, opts.verbose)?;
77    }
78
79    Ok(())
80}
81
82// ---------------------------------------------------------------------------
83// Text output
84// ---------------------------------------------------------------------------
85
86fn print_diff_text(
87    writer: &mut dyn Write,
88    report: &backup::BackupDiffReport,
89) -> Result<(), IdbError> {
90    wprintln!(
91        writer,
92        "Backup Delta: {} -> {} (Space ID: {})",
93        report.base_file,
94        report.current_file,
95        report.space_id
96    )?;
97    wprintln!(
98        writer,
99        "  Base max LSN: {:>14}  ->  Current max LSN: {:>14}",
100        report.base_max_lsn,
101        report.current_max_lsn
102    )?;
103    if report.current_max_lsn > report.base_max_lsn {
104        wprintln!(
105            writer,
106            "  LSN advance: {}",
107            report.current_max_lsn - report.base_max_lsn
108        )?;
109    }
110    wprintln!(writer)?;
111
112    let total = report.base_page_count.max(report.current_page_count);
113    let s = &report.summary;
114    wprintln!(writer, "  Pages: {} total", total)?;
115    wprintln!(
116        writer,
117        "    Unchanged:  {:>6} ({:>5.1}%)",
118        s.unchanged,
119        pct(s.unchanged, total)
120    )?;
121    wprintln!(
122        writer,
123        "    Modified:   {:>6} ({:>5.1}%)",
124        s.modified,
125        pct(s.modified, total)
126    )?;
127    if s.added > 0 {
128        wprintln!(
129            writer,
130            "    Added:      {:>6} ({:>5.1}%)",
131            s.added,
132            pct(s.added, total)
133        )?;
134    }
135    if s.removed > 0 {
136        wprintln!(
137            writer,
138            "    Removed:    {:>6} ({:>5.1}%)",
139            s.removed,
140            pct(s.removed, total)
141        )?;
142    }
143    if s.regressed > 0 {
144        wprintln!(
145            writer,
146            "    Regressed:  {:>6} ({:>5.1}%)",
147            s.regressed,
148            pct(s.regressed, total)
149        )?;
150    }
151    wprintln!(writer)?;
152
153    if !report.modified_page_types.is_empty() {
154        wprintln!(writer, "  Modified by type:")?;
155        for (pt, count) in &report.modified_page_types {
156            wprintln!(writer, "    {:<16} {}", pt, count)?;
157        }
158        wprintln!(writer)?;
159    }
160
161    // Verbose: per-page details
162    if !report.pages.is_empty() {
163        wprintln!(
164            writer,
165            "  {:>8}  {:>12}  {:>14}  {:>14}  {:>10}",
166            "Page",
167            "Type",
168            "Base LSN",
169            "Current LSN",
170            "Status"
171        )?;
172        wprintln!(
173            writer,
174            "  {:>8}  {:>12}  {:>14}  {:>14}  {:>10}",
175            "--------",
176            "------------",
177            "--------------",
178            "--------------",
179            "----------"
180        )?;
181        for p in &report.pages {
182            if p.status == backup::PageChangeStatus::Unchanged {
183                continue; // skip unchanged in verbose output
184            }
185            let status_str = match p.status {
186                backup::PageChangeStatus::Unchanged => "unchanged",
187                backup::PageChangeStatus::Modified => "modified",
188                backup::PageChangeStatus::Added => "added",
189                backup::PageChangeStatus::Removed => "removed",
190                backup::PageChangeStatus::Regressed => "regressed",
191            };
192            wprintln!(
193                writer,
194                "  {:>8}  {:>12}  {:>14}  {:>14}  {:>10}",
195                p.page_number,
196                p.page_type,
197                p.base_lsn
198                    .map(|l| l.to_string())
199                    .unwrap_or_else(|| "-".to_string()),
200                p.current_lsn
201                    .map(|l| l.to_string())
202                    .unwrap_or_else(|| "-".to_string()),
203                status_str,
204            )?;
205        }
206        wprintln!(writer)?;
207    }
208
209    Ok(())
210}
211
212fn print_chain_text(
213    writer: &mut dyn Write,
214    report: &backup::BackupChainReport,
215    verbose: bool,
216) -> Result<(), IdbError> {
217    wprintln!(writer, "Backup Chain: {}", report.chain_dir)?;
218    wprintln!(writer, "  Backups found: {}", report.backups.len())?;
219    wprintln!(writer)?;
220
221    if !report.backups.is_empty() {
222        wprintln!(
223            writer,
224            "  {:>3}  {:<16}  {:>14}  {:>14}  {:>8}",
225            "#",
226            "Type",
227            "From LSN",
228            "To LSN",
229            "Status"
230        )?;
231        wprintln!(
232            writer,
233            "  {:>3}  {:<16}  {:>14}  {:>14}  {:>8}",
234            "---",
235            "----------------",
236            "--------------",
237            "--------------",
238            "--------"
239        )?;
240
241        for (i, b) in report.backups.iter().enumerate() {
242            let status = chain_entry_status(report, i);
243            wprintln!(
244                writer,
245                "  {:>3}  {:<16}  {:>14}  {:>14}  {:>8}",
246                i + 1,
247                b.backup_type,
248                b.from_lsn,
249                b.to_lsn,
250                status,
251            )?;
252            if verbose {
253                wprintln!(writer, "       Path: {}", b.path.display())?;
254                if let Some(last) = b.last_lsn {
255                    wprintln!(writer, "       Last LSN: {}", last)?;
256                }
257            }
258        }
259        wprintln!(writer)?;
260    }
261
262    // Chain verdict
263    if report.chain_valid {
264        if let Some((min, max)) = report.total_lsn_range {
265            wprintln!(writer, "  Chain: VALID (LSN range {} -> {})", min, max)?;
266        } else {
267            wprintln!(writer, "  Chain: VALID")?;
268        }
269    } else {
270        wprintln!(writer, "  Chain: INVALID")?;
271    }
272
273    // Anomalies
274    if !report.anomalies.is_empty() {
275        wprintln!(writer)?;
276        wprintln!(writer, "  Anomalies:")?;
277        for a in &report.anomalies {
278            let kind = match a.kind {
279                backup::ChainAnomalyKind::Gap => "GAP",
280                backup::ChainAnomalyKind::Overlap => "OVERLAP",
281                backup::ChainAnomalyKind::MissingFull => "MISSING_FULL",
282            };
283            wprintln!(writer, "    [{}] {}", kind, a.message)?;
284        }
285    }
286
287    wprintln!(writer)?;
288    Ok(())
289}
290
291/// Determine the status label for a backup entry in the chain.
292fn chain_entry_status(report: &backup::BackupChainReport, index: usize) -> &'static str {
293    // Check if any per-entry anomaly involves this entry.
294    // MissingFull is a chain-level anomaly (not specific to any entry),
295    // so it is reported in the anomalies section, not per-entry status.
296    for a in &report.anomalies {
297        if a.kind == backup::ChainAnomalyKind::MissingFull {
298            continue;
299        }
300        if a.between.0 == index || a.between.1 == index {
301            return match a.kind {
302                backup::ChainAnomalyKind::Gap => "GAP",
303                backup::ChainAnomalyKind::Overlap => "OVERLAP",
304                backup::ChainAnomalyKind::MissingFull => unreachable!(),
305            };
306        }
307    }
308    "OK"
309}
310
311// ---------------------------------------------------------------------------
312// CSV output
313// ---------------------------------------------------------------------------
314
315fn print_diff_csv(
316    writer: &mut dyn Write,
317    report: &backup::BackupDiffReport,
318) -> Result<(), IdbError> {
319    wprintln!(
320        writer,
321        "page_number,status,page_type,base_lsn,current_lsn,checksum_valid"
322    )?;
323    for p in &report.pages {
324        let status = match p.status {
325            backup::PageChangeStatus::Unchanged => "unchanged",
326            backup::PageChangeStatus::Modified => "modified",
327            backup::PageChangeStatus::Added => "added",
328            backup::PageChangeStatus::Removed => "removed",
329            backup::PageChangeStatus::Regressed => "regressed",
330        };
331        wprintln!(
332            writer,
333            "{},{},{},{},{},{}",
334            p.page_number,
335            status,
336            csv_escape(&p.page_type),
337            p.base_lsn.map(|l| l.to_string()).unwrap_or_default(),
338            p.current_lsn.map(|l| l.to_string()).unwrap_or_default(),
339            p.checksum_valid,
340        )?;
341    }
342    Ok(())
343}
344
345fn print_chain_csv(
346    writer: &mut dyn Write,
347    report: &backup::BackupChainReport,
348) -> Result<(), IdbError> {
349    wprintln!(writer, "index,backup_type,from_lsn,to_lsn,last_lsn,path")?;
350    for (i, b) in report.backups.iter().enumerate() {
351        wprintln!(
352            writer,
353            "{},{},{},{},{},{}",
354            i + 1,
355            csv_escape(&b.backup_type),
356            b.from_lsn,
357            b.to_lsn,
358            b.last_lsn.map(|l| l.to_string()).unwrap_or_default(),
359            csv_escape(&b.path.to_string_lossy()),
360        )?;
361    }
362    Ok(())
363}
364
365fn pct(part: u64, total: u64) -> f64 {
366    if total == 0 {
367        0.0
368    } else {
369        (part as f64 / total as f64) * 100.0
370    }
371}