Skip to main content

idb/cli/
diff.rs

1use std::io::Write;
2
3use colored::Colorize;
4use serde::Serialize;
5
6use crate::cli::{create_progress_bar, wprintln};
7use crate::innodb::constants::SIZE_FIL_HEAD;
8use crate::innodb::page::FilHeader;
9use crate::innodb::tablespace::Tablespace;
10use crate::IdbError;
11
12/// Options for the `inno diff` subcommand.
13pub struct DiffOptions {
14    /// Path to the first InnoDB tablespace file.
15    pub file1: String,
16    /// Path to the second InnoDB tablespace file.
17    pub file2: String,
18    /// Show per-page header field diffs.
19    pub verbose: bool,
20    /// Show byte-range diffs (requires verbose).
21    pub byte_ranges: bool,
22    /// Compare a single page only.
23    pub page: Option<u64>,
24    /// Emit output as JSON.
25    pub json: bool,
26    /// Override the auto-detected page size.
27    pub page_size: Option<u32>,
28}
29
30// ── JSON output structs ─────────────────────────────────────────────
31
32#[derive(Serialize)]
33struct DiffReport {
34    file1: FileInfo,
35    file2: FileInfo,
36    page_size_mismatch: bool,
37    summary: DiffSummary,
38    #[serde(skip_serializing_if = "Vec::is_empty")]
39    modified_pages: Vec<PageDiff>,
40}
41
42#[derive(Serialize)]
43struct FileInfo {
44    path: String,
45    page_count: u64,
46    page_size: u32,
47}
48
49#[derive(Serialize)]
50struct DiffSummary {
51    identical: u64,
52    modified: u64,
53    only_in_file1: u64,
54    only_in_file2: u64,
55}
56
57#[derive(Serialize)]
58struct PageDiff {
59    page_number: u64,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    file1_header: Option<HeaderFields>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    file2_header: Option<HeaderFields>,
64    #[serde(skip_serializing_if = "Vec::is_empty")]
65    changed_fields: Vec<FieldChange>,
66    #[serde(skip_serializing_if = "Vec::is_empty")]
67    byte_ranges: Vec<ByteRange>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    total_bytes_changed: Option<usize>,
70}
71
72#[derive(Serialize)]
73struct HeaderFields {
74    checksum: String,
75    page_number: u32,
76    prev_page: String,
77    next_page: String,
78    lsn: u64,
79    page_type: String,
80    flush_lsn: u64,
81    space_id: u32,
82}
83
84#[derive(Serialize)]
85struct FieldChange {
86    field: String,
87    old_value: String,
88    new_value: String,
89}
90
91#[derive(Serialize)]
92struct ByteRange {
93    start: usize,
94    end: usize,
95    length: usize,
96}
97
98// ── Helpers ─────────────────────────────────────────────────────────
99
100fn header_to_fields(h: &FilHeader) -> HeaderFields {
101    HeaderFields {
102        checksum: format!("0x{:08X}", h.checksum),
103        page_number: h.page_number,
104        prev_page: format!("0x{:08X}", h.prev_page),
105        next_page: format!("0x{:08X}", h.next_page),
106        lsn: h.lsn,
107        page_type: h.page_type.name().to_string(),
108        flush_lsn: h.flush_lsn,
109        space_id: h.space_id,
110    }
111}
112
113fn compare_headers(h1: &FilHeader, h2: &FilHeader) -> Vec<FieldChange> {
114    let mut changes = Vec::new();
115
116    if h1.checksum != h2.checksum {
117        changes.push(FieldChange {
118            field: "Checksum".to_string(),
119            old_value: format!("0x{:08X}", h1.checksum),
120            new_value: format!("0x{:08X}", h2.checksum),
121        });
122    }
123    if h1.page_number != h2.page_number {
124        changes.push(FieldChange {
125            field: "Page Number".to_string(),
126            old_value: h1.page_number.to_string(),
127            new_value: h2.page_number.to_string(),
128        });
129    }
130    if h1.prev_page != h2.prev_page {
131        changes.push(FieldChange {
132            field: "Prev Page".to_string(),
133            old_value: format!("0x{:08X}", h1.prev_page),
134            new_value: format!("0x{:08X}", h2.prev_page),
135        });
136    }
137    if h1.next_page != h2.next_page {
138        changes.push(FieldChange {
139            field: "Next Page".to_string(),
140            old_value: format!("0x{:08X}", h1.next_page),
141            new_value: format!("0x{:08X}", h2.next_page),
142        });
143    }
144    if h1.lsn != h2.lsn {
145        changes.push(FieldChange {
146            field: "LSN".to_string(),
147            old_value: h1.lsn.to_string(),
148            new_value: h2.lsn.to_string(),
149        });
150    }
151    if h1.page_type != h2.page_type {
152        changes.push(FieldChange {
153            field: "Page Type".to_string(),
154            old_value: h1.page_type.name().to_string(),
155            new_value: h2.page_type.name().to_string(),
156        });
157    }
158    if h1.flush_lsn != h2.flush_lsn {
159        changes.push(FieldChange {
160            field: "Flush LSN".to_string(),
161            old_value: h1.flush_lsn.to_string(),
162            new_value: h2.flush_lsn.to_string(),
163        });
164    }
165    if h1.space_id != h2.space_id {
166        changes.push(FieldChange {
167            field: "Space ID".to_string(),
168            old_value: h1.space_id.to_string(),
169            new_value: h2.space_id.to_string(),
170        });
171    }
172
173    changes
174}
175
176fn find_diff_ranges(data1: &[u8], data2: &[u8]) -> Vec<ByteRange> {
177    let len = data1.len().min(data2.len());
178    let mut ranges = Vec::new();
179    let mut in_diff = false;
180    let mut start = 0;
181
182    for i in 0..len {
183        if data1[i] != data2[i] {
184            if !in_diff {
185                in_diff = true;
186                start = i;
187            }
188        } else if in_diff {
189            in_diff = false;
190            ranges.push(ByteRange {
191                start,
192                end: i,
193                length: i - start,
194            });
195        }
196    }
197    if in_diff {
198        ranges.push(ByteRange {
199            start,
200            end: len,
201            length: len - start,
202        });
203    }
204
205    ranges
206}
207
208/// Compare two InnoDB tablespace files page-by-page.
209pub fn execute(opts: &DiffOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
210    let mut ts1 = match opts.page_size {
211        Some(ps) => Tablespace::open_with_page_size(&opts.file1, ps)?,
212        None => Tablespace::open(&opts.file1)?,
213    };
214    let mut ts2 = match opts.page_size {
215        Some(ps) => Tablespace::open_with_page_size(&opts.file2, ps)?,
216        None => Tablespace::open(&opts.file2)?,
217    };
218
219    let ps1 = ts1.page_size();
220    let ps2 = ts2.page_size();
221    let pc1 = ts1.page_count();
222    let pc2 = ts2.page_count();
223
224    let page_size_mismatch = ps1 != ps2;
225
226    if opts.json {
227        return execute_json(opts, &mut ts1, &mut ts2, page_size_mismatch, writer);
228    }
229
230    // Text output
231    wprintln!(writer, "Comparing:")?;
232    wprintln!(
233        writer,
234        "  File 1: {} ({} pages, {} bytes/page)",
235        opts.file1, pc1, ps1
236    )?;
237    wprintln!(
238        writer,
239        "  File 2: {} ({} pages, {} bytes/page)",
240        opts.file2, pc2, ps2
241    )?;
242    wprintln!(writer)?;
243
244    if page_size_mismatch {
245        wprintln!(
246            writer,
247            "{}",
248            format!(
249                "WARNING: Page size mismatch ({} vs {}). Comparing FIL headers only.",
250                ps1, ps2
251            )
252            .yellow()
253        )?;
254        wprintln!(writer)?;
255    }
256
257    // Determine comparison range
258    let (start_page, end_page) = match opts.page {
259        Some(p) => {
260            if p >= pc1 && p >= pc2 {
261                return Err(IdbError::Argument(format!(
262                    "Page {} out of range (file1 has {} pages, file2 has {} pages)",
263                    p, pc1, pc2
264                )));
265            }
266            (p, p + 1)
267        }
268        None => (0, pc1.max(pc2)),
269    };
270
271    let common_pages = pc1.min(pc2);
272    let mut identical = 0u64;
273    let mut modified = 0u64;
274    let mut only_in_file1 = 0u64;
275    let mut only_in_file2 = 0u64;
276    let mut modified_page_nums: Vec<u64> = Vec::new();
277
278    let total = end_page - start_page;
279    let pb = create_progress_bar(total, "pages");
280
281    for page_num in start_page..end_page {
282        pb.inc(1);
283
284        // Pages only in one file
285        if page_num >= pc1 {
286            only_in_file2 += 1;
287            continue;
288        }
289        if page_num >= pc2 {
290            only_in_file1 += 1;
291            continue;
292        }
293
294        let data1 = ts1.read_page(page_num)?;
295        let data2 = ts2.read_page(page_num)?;
296
297        if page_size_mismatch {
298            // Compare only FIL headers (first 38 bytes)
299            let cmp_len = SIZE_FIL_HEAD.min(data1.len()).min(data2.len());
300            if data1[..cmp_len] == data2[..cmp_len] {
301                identical += 1;
302            } else {
303                modified += 1;
304                modified_page_nums.push(page_num);
305
306                if opts.verbose {
307                    print_page_diff(writer, page_num, &data1, &data2, opts.byte_ranges, true)?;
308                }
309            }
310        } else {
311            // Full page comparison
312            if data1 == data2 {
313                identical += 1;
314            } else {
315                modified += 1;
316                modified_page_nums.push(page_num);
317
318                if opts.verbose {
319                    print_page_diff(writer, page_num, &data1, &data2, opts.byte_ranges, false)?;
320                }
321            }
322        }
323    }
324
325    pb.finish_and_clear();
326
327    // Count pages beyond common range for non-single-page mode
328    if opts.page.is_none() {
329        if pc1 > common_pages {
330            only_in_file1 = pc1 - common_pages;
331        }
332        if pc2 > common_pages {
333            only_in_file2 = pc2 - common_pages;
334        }
335    }
336
337    // Print summary
338    wprintln!(writer, "Summary:")?;
339    wprintln!(writer, "  Identical pages:  {}", identical)?;
340    if modified > 0 {
341        wprintln!(
342            writer,
343            "  Modified pages:   {}",
344            format!("{}", modified).red()
345        )?;
346    } else {
347        wprintln!(writer, "  Modified pages:   {}", modified)?;
348    }
349    wprintln!(writer, "  Only in file 1:   {}", only_in_file1)?;
350    wprintln!(writer, "  Only in file 2:   {}", only_in_file2)?;
351
352    if !modified_page_nums.is_empty() {
353        wprintln!(writer)?;
354        let nums: Vec<String> = modified_page_nums.iter().map(|n| n.to_string()).collect();
355        wprintln!(writer, "Modified pages: {}", nums.join(", "))?;
356    }
357
358    Ok(())
359}
360
361fn print_page_diff(
362    writer: &mut dyn Write,
363    page_num: u64,
364    data1: &[u8],
365    data2: &[u8],
366    show_byte_ranges: bool,
367    header_only: bool,
368) -> Result<(), IdbError> {
369    wprintln!(writer, "Page {}: {}", page_num, "MODIFIED".red())?;
370
371    let h1 = FilHeader::parse(data1);
372    let h2 = FilHeader::parse(data2);
373
374    match (h1, h2) {
375        (Some(h1), Some(h2)) => {
376            let changes = compare_headers(&h1, &h2);
377            if changes.is_empty() {
378                wprintln!(writer, "  FIL header: identical (data content differs)")?;
379            } else {
380                for c in &changes {
381                    wprintln!(writer, "  {}: {} -> {}", c.field, c.old_value, c.new_value)?;
382                }
383            }
384
385            // Report unchanged page type for context
386            if h1.page_type == h2.page_type
387                && !changes.iter().any(|c| c.field == "Page Type")
388            {
389                wprintln!(writer, "  Page Type: {} (unchanged)", h1.page_type.name())?;
390            }
391        }
392        _ => {
393            wprintln!(writer, "  (could not parse one or both FIL headers)")?;
394        }
395    }
396
397    if show_byte_ranges && !header_only {
398        let ranges = find_diff_ranges(data1, data2);
399        if !ranges.is_empty() {
400            wprintln!(writer, "  Byte diff ranges:")?;
401            for r in &ranges {
402                wprintln!(writer, "    {}-{} ({} bytes)", r.start, r.end, r.length)?;
403            }
404            let total_changed: usize = ranges.iter().map(|r| r.length).sum();
405            let page_size = data1.len();
406            let pct = (total_changed as f64 / page_size as f64) * 100.0;
407            wprintln!(
408                writer,
409                "  Total: {} bytes changed ({:.1}% of page)",
410                total_changed,
411                pct
412            )?;
413        }
414    }
415
416    wprintln!(writer)?;
417    Ok(())
418}
419
420fn execute_json(
421    opts: &DiffOptions,
422    ts1: &mut Tablespace,
423    ts2: &mut Tablespace,
424    page_size_mismatch: bool,
425    writer: &mut dyn Write,
426) -> Result<(), IdbError> {
427    let ps1 = ts1.page_size();
428    let ps2 = ts2.page_size();
429    let pc1 = ts1.page_count();
430    let pc2 = ts2.page_count();
431
432    let (start_page, end_page) = match opts.page {
433        Some(p) => {
434            if p >= pc1 && p >= pc2 {
435                return Err(IdbError::Argument(format!(
436                    "Page {} out of range (file1 has {} pages, file2 has {} pages)",
437                    p, pc1, pc2
438                )));
439            }
440            (p, p + 1)
441        }
442        None => (0, pc1.max(pc2)),
443    };
444
445    let mut identical = 0u64;
446    let mut modified = 0u64;
447    let mut only_in_file1 = 0u64;
448    let mut only_in_file2 = 0u64;
449    let mut modified_pages: Vec<PageDiff> = Vec::new();
450
451    for page_num in start_page..end_page {
452        if page_num >= pc1 {
453            only_in_file2 += 1;
454            continue;
455        }
456        if page_num >= pc2 {
457            only_in_file1 += 1;
458            continue;
459        }
460
461        let data1 = ts1.read_page(page_num)?;
462        let data2 = ts2.read_page(page_num)?;
463
464        let is_equal = if page_size_mismatch {
465            let cmp_len = SIZE_FIL_HEAD.min(data1.len()).min(data2.len());
466            data1[..cmp_len] == data2[..cmp_len]
467        } else {
468            data1 == data2
469        };
470
471        if is_equal {
472            identical += 1;
473        } else {
474            modified += 1;
475
476            let h1 = FilHeader::parse(&data1);
477            let h2 = FilHeader::parse(&data2);
478
479            let (file1_header, file2_header, changed_fields) = match (&h1, &h2) {
480                (Some(h1), Some(h2)) => {
481                    let changes = compare_headers(h1, h2);
482                    (
483                        Some(header_to_fields(h1)),
484                        Some(header_to_fields(h2)),
485                        changes,
486                    )
487                }
488                _ => (
489                    h1.as_ref().map(header_to_fields),
490                    h2.as_ref().map(header_to_fields),
491                    Vec::new(),
492                ),
493            };
494
495            let (byte_ranges, total_bytes_changed) =
496                if opts.byte_ranges && !page_size_mismatch {
497                    let ranges = find_diff_ranges(&data1, &data2);
498                    let total: usize = ranges.iter().map(|r| r.length).sum();
499                    (ranges, Some(total))
500                } else {
501                    (Vec::new(), None)
502                };
503
504            modified_pages.push(PageDiff {
505                page_number: page_num,
506                file1_header,
507                file2_header,
508                changed_fields,
509                byte_ranges,
510                total_bytes_changed,
511            });
512        }
513    }
514
515    // For non-single-page mode, count pages beyond common range
516    if opts.page.is_none() {
517        let common = pc1.min(pc2);
518        if pc1 > common {
519            only_in_file1 = pc1 - common;
520        }
521        if pc2 > common {
522            only_in_file2 = pc2 - common;
523        }
524    }
525
526    let report = DiffReport {
527        file1: FileInfo {
528            path: opts.file1.clone(),
529            page_count: pc1,
530            page_size: ps1,
531        },
532        file2: FileInfo {
533            path: opts.file2.clone(),
534            page_count: pc2,
535            page_size: ps2,
536        },
537        page_size_mismatch,
538        summary: DiffSummary {
539            identical,
540            modified,
541            only_in_file1,
542            only_in_file2,
543        },
544        modified_pages,
545    };
546
547    let json = serde_json::to_string_pretty(&report)
548        .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
549    wprintln!(writer, "{}", json)?;
550
551    Ok(())
552}