Skip to main content

idb/cli/
audit.rs

1use std::io::Write;
2use std::path::Path;
3use std::time::Instant;
4
5use colored::Colorize;
6use rayon::prelude::*;
7use serde::Serialize;
8
9use crate::cli::{create_progress_bar, csv_escape, wprintln};
10use crate::innodb::checksum::{validate_checksum, validate_lsn};
11use crate::innodb::health;
12use crate::util::fs::find_tablespace_files;
13use crate::util::prometheus as prom;
14use crate::IdbError;
15
16/// Options for the `inno audit` subcommand.
17pub struct AuditOptions {
18    /// MySQL data directory path to scan.
19    pub datadir: String,
20    /// Show per-tablespace health metrics instead of integrity validation.
21    pub health: bool,
22    /// List only pages with checksum mismatches.
23    pub checksum_mismatch: bool,
24    /// Show additional details.
25    pub verbose: bool,
26    /// Emit output as JSON.
27    pub json: bool,
28    /// Output as CSV.
29    pub csv: bool,
30    /// Output in Prometheus exposition format.
31    pub prometheus: bool,
32    /// Override the auto-detected page size.
33    pub page_size: Option<u32>,
34    /// Path to MySQL keyring file for decrypting encrypted tablespaces.
35    pub keyring: Option<String>,
36    /// Use memory-mapped I/O for file access.
37    pub mmap: bool,
38    /// Show tables with fill factor below this threshold (0-100).
39    pub min_fill_factor: Option<f64>,
40    /// Show tables with fragmentation above this threshold (0-100).
41    pub max_fragmentation: Option<f64>,
42    /// Enable bloat scoring in health mode.
43    pub bloat: bool,
44    /// Filter: show tables with worst bloat grade at or worse than threshold (A-F).
45    pub max_bloat_grade: Option<String>,
46    /// Maximum directory recursion depth (None = default 2, Some(0) = unlimited).
47    pub depth: Option<u32>,
48    /// Scan every tablespace for a literal byte pattern (data-residue mode).
49    pub compliance: bool,
50    /// Literal needle for compliance mode: UTF-8 text or `hex:...`.
51    pub pattern: Option<String>,
52}
53
54// ---------------------------------------------------------------------------
55// JSON output structs — default integrity mode
56// ---------------------------------------------------------------------------
57
58#[derive(Serialize)]
59struct AuditReport {
60    datadir: String,
61    files: Vec<FileIntegrityResult>,
62    summary: AuditSummary,
63}
64
65#[derive(Serialize, Clone)]
66struct FileIntegrityResult {
67    file: String,
68    status: String,
69    page_size: u32,
70    total_pages: u64,
71    empty_pages: u64,
72    valid_pages: u64,
73    invalid_pages: u64,
74    lsn_mismatches: u64,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    error: Option<String>,
77    #[serde(skip_serializing_if = "Vec::is_empty")]
78    corrupt_pages: Vec<u64>,
79}
80
81#[derive(Serialize)]
82struct AuditSummary {
83    total_files: usize,
84    files_passed: usize,
85    files_failed: usize,
86    files_error: usize,
87    total_pages: u64,
88    corrupt_pages: u64,
89    integrity_pct: f64,
90}
91
92// ---------------------------------------------------------------------------
93// JSON output structs - compliance (data-residue) mode (#182)
94// ---------------------------------------------------------------------------
95
96/// Per-file residue result. Caps stored matches; `capped` flags truncation so a
97/// large residue count is never silently under-reported.
98#[derive(Serialize, Clone)]
99struct FileComplianceResult {
100    file: String,
101    match_count: usize,
102    pages_with_matches: usize,
103    capped: bool,
104    #[serde(skip_serializing_if = "Vec::is_empty")]
105    sample_matches: Vec<crate::innodb::compliance::ResidueMatch>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    error: Option<String>,
108}
109
110#[derive(Serialize)]
111struct ComplianceReport {
112    datadir: String,
113    pattern: String,
114    files: Vec<FileComplianceResult>,
115    summary: ComplianceSummary,
116}
117
118#[derive(Serialize)]
119struct ComplianceSummary {
120    total_files: usize,
121    files_with_residue: usize,
122    total_matches: usize,
123}
124
125// ---------------------------------------------------------------------------
126// JSON output structs — health mode
127// ---------------------------------------------------------------------------
128
129#[derive(Serialize, Clone)]
130struct FileHealthResult {
131    file: String,
132    avg_fill_factor: f64,
133    avg_fragmentation: f64,
134    avg_garbage_ratio: f64,
135    index_count: u64,
136    total_index_pages: u64,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    worst_bloat_grade: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    worst_bloat_score: Option<f64>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    error: Option<String>,
143}
144
145#[derive(Serialize)]
146struct HealthAuditReport {
147    datadir: String,
148    tablespaces: Vec<FileHealthResult>,
149    summary: DirectoryHealthSummary,
150}
151
152#[derive(Serialize)]
153struct DirectoryHealthSummary {
154    total_files: usize,
155    total_index_pages: u64,
156    avg_fill_factor: f64,
157    avg_fragmentation: f64,
158    avg_garbage_ratio: f64,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    worst_bloat_grade: Option<String>,
161}
162
163// ---------------------------------------------------------------------------
164// JSON output structs — mismatch mode
165// ---------------------------------------------------------------------------
166
167#[derive(Serialize, Clone)]
168struct MismatchEntry {
169    file: String,
170    page_number: u64,
171    stored_checksum: u32,
172    calculated_checksum: u32,
173    algorithm: String,
174}
175
176#[derive(Serialize)]
177struct MismatchReport {
178    datadir: String,
179    mismatches: Vec<MismatchEntry>,
180    total_files_scanned: usize,
181    total_pages_scanned: u64,
182}
183
184// ---------------------------------------------------------------------------
185// Per-file worker functions
186// ---------------------------------------------------------------------------
187
188/// Audit a single file for integrity. Returns `None` if the file cannot be opened.
189fn audit_file(
190    path: &Path,
191    datadir: &Path,
192    page_size_override: Option<u32>,
193    keyring: &Option<String>,
194    use_mmap: bool,
195) -> FileIntegrityResult {
196    let display = path.strip_prefix(datadir).unwrap_or(path);
197    let display_str = display.display().to_string();
198    let path_str = path.to_string_lossy();
199
200    let mut ts = match crate::cli::open_tablespace(&path_str, page_size_override, use_mmap) {
201        Ok(t) => t,
202        Err(e) => {
203            return FileIntegrityResult {
204                file: display_str,
205                status: "error".to_string(),
206                page_size: 0,
207                total_pages: 0,
208                empty_pages: 0,
209                valid_pages: 0,
210                invalid_pages: 0,
211                lsn_mismatches: 0,
212                error: Some(e.to_string()),
213                corrupt_pages: Vec::new(),
214            };
215        }
216    };
217
218    if let Some(ref kp) = keyring {
219        if crate::cli::setup_decryption(&mut ts, kp).is_err() {
220            // Not encrypted or bad keyring — continue without decryption
221        }
222    }
223
224    let page_size = ts.page_size();
225    let page_count = ts.page_count();
226    let vendor_info = ts.vendor_info().clone();
227
228    // Read all pages for parallel validation
229    let all_data = match ts.read_all_pages() {
230        Ok(d) => d,
231        Err(e) => {
232            return FileIntegrityResult {
233                file: display_str,
234                status: "error".to_string(),
235                page_size,
236                total_pages: page_count,
237                empty_pages: 0,
238                valid_pages: 0,
239                invalid_pages: 0,
240                lsn_mismatches: 0,
241                error: Some(e.to_string()),
242                corrupt_pages: Vec::new(),
243            };
244        }
245    };
246
247    let ps = page_size as usize;
248
249    // Validate pages in parallel
250    let results: Vec<(u64, bool, bool, bool, Option<u64>)> = (0..page_count)
251        .into_par_iter()
252        .map(|page_num| {
253            let offset = page_num as usize * ps;
254            if offset + ps > all_data.len() {
255                return (page_num, false, false, false, None);
256            }
257            let page_data = &all_data[offset..offset + ps];
258
259            // Check for empty page
260            if page_data.iter().all(|&b| b == 0) {
261                return (page_num, true, true, true, None); // empty=true
262            }
263
264            let csum = validate_checksum(page_data, page_size, Some(&vendor_info));
265            let lsn_ok = validate_lsn(page_data, page_size);
266
267            let corrupt_page = if !csum.valid { Some(page_num) } else { None };
268            // (page_num, is_valid_or_empty, is_empty, lsn_ok, corrupt_page_num)
269            (page_num, csum.valid, false, lsn_ok, corrupt_page)
270        })
271        .collect();
272
273    let mut valid = 0u64;
274    let mut invalid = 0u64;
275    let mut empty = 0u64;
276    let mut lsn_mismatches = 0u64;
277    let mut corrupt_pages = Vec::new();
278
279    for &(_, is_valid, is_empty, lsn_ok, ref corrupt) in &results {
280        if is_empty {
281            empty += 1;
282        } else if is_valid {
283            valid += 1;
284        } else {
285            invalid += 1;
286        }
287        if !lsn_ok && !is_empty && is_valid {
288            lsn_mismatches += 1;
289        }
290        if let Some(pn) = corrupt {
291            corrupt_pages.push(*pn);
292        }
293    }
294
295    let status = if invalid > 0 { "FAIL" } else { "PASS" };
296
297    FileIntegrityResult {
298        file: display_str,
299        status: status.to_string(),
300        page_size,
301        total_pages: page_count,
302        empty_pages: empty,
303        valid_pages: valid,
304        invalid_pages: invalid,
305        lsn_mismatches,
306        error: None,
307        corrupt_pages,
308    }
309}
310
311/// Audit a single file for health metrics.
312fn audit_file_health(
313    path: &Path,
314    datadir: &Path,
315    page_size_override: Option<u32>,
316    keyring: &Option<String>,
317    use_mmap: bool,
318    bloat: bool,
319) -> FileHealthResult {
320    let display = path.strip_prefix(datadir).unwrap_or(path);
321    let display_str = display.display().to_string();
322    let path_str = path.to_string_lossy();
323
324    let mut ts = match crate::cli::open_tablespace(&path_str, page_size_override, use_mmap) {
325        Ok(t) => t,
326        Err(e) => {
327            return FileHealthResult {
328                file: display_str,
329                avg_fill_factor: 0.0,
330                avg_fragmentation: 0.0,
331                avg_garbage_ratio: 0.0,
332                index_count: 0,
333                total_index_pages: 0,
334                worst_bloat_grade: None,
335                worst_bloat_score: None,
336                error: Some(e.to_string()),
337            };
338        }
339    };
340
341    if let Some(ref kp) = keyring {
342        let _ = crate::cli::setup_decryption(&mut ts, kp);
343    }
344
345    let page_size = ts.page_size();
346    let total_pages = ts.page_count();
347
348    let mut snapshots = Vec::new();
349    let mut empty_pages = 0u64;
350    // Per-index delete-marked record counts for bloat scoring: (deleted, total)
351    let mut delete_counts: std::collections::HashMap<u64, (u64, u64)> =
352        std::collections::HashMap::new();
353
354    let scan_result = ts.for_each_page(|page_num, data| {
355        if data.iter().all(|&b| b == 0) {
356            empty_pages += 1;
357        } else if let Some(snap) = health::extract_index_page_snapshot(data, page_num) {
358            // Count delete-marked records for bloat scoring (leaf pages only)
359            if snap.level == 0 && bloat {
360                let recs = crate::innodb::record::walk_compact_records(data);
361                let total = recs.len() as u64;
362                let deleted = recs.iter().filter(|r| r.header.delete_mark()).count() as u64;
363                let entry = delete_counts.entry(snap.index_id).or_insert((0, 0));
364                entry.0 += deleted;
365                entry.1 += total;
366            }
367            snapshots.push(snap);
368        }
369        Ok(())
370    });
371
372    if let Err(e) = scan_result {
373        return FileHealthResult {
374            file: display_str,
375            avg_fill_factor: 0.0,
376            avg_fragmentation: 0.0,
377            avg_garbage_ratio: 0.0,
378            index_count: 0,
379            total_index_pages: 0,
380            worst_bloat_grade: None,
381            worst_bloat_score: None,
382            error: Some(e.to_string()),
383        };
384    }
385
386    let mut report =
387        health::analyze_health(snapshots, page_size, total_pages, empty_pages, &path_str);
388
389    // Bloat scoring
390    let (worst_grade, worst_score) = if bloat {
391        let mut worst_g: Option<String> = None;
392        let mut worst_s: f64 = 0.0;
393        for idx in &mut report.indexes {
394            let (deleted, total) = delete_counts.get(&idx.index_id).copied().unwrap_or((0, 0));
395            let delete_mark_ratio = if total > 0 {
396                deleted as f64 / total as f64
397            } else {
398                0.0
399            };
400            let bloat_score = health::score_bloat(idx, delete_mark_ratio);
401            if bloat_score.score > worst_s {
402                worst_s = bloat_score.score;
403                worst_g = Some(format!("{}", bloat_score.grade));
404            }
405            idx.bloat = Some(bloat_score);
406        }
407        let has_score = worst_g.is_some();
408        (worst_g, if has_score { Some(worst_s) } else { None })
409    } else {
410        (None, None)
411    };
412
413    FileHealthResult {
414        file: display_str,
415        avg_fill_factor: report.summary.avg_fill_factor,
416        avg_fragmentation: report.summary.avg_fragmentation,
417        avg_garbage_ratio: report.summary.avg_garbage_ratio,
418        index_count: report.summary.index_count,
419        total_index_pages: report.summary.index_pages,
420        worst_bloat_grade: worst_grade,
421        worst_bloat_score: worst_score,
422        error: None,
423    }
424}
425
426/// Scan a single file for checksum mismatches only.
427fn audit_file_mismatches(
428    path: &Path,
429    datadir: &Path,
430    page_size_override: Option<u32>,
431    keyring: &Option<String>,
432    use_mmap: bool,
433) -> (Vec<MismatchEntry>, u64) {
434    let display = path.strip_prefix(datadir).unwrap_or(path);
435    let display_str = display.display().to_string();
436    let path_str = path.to_string_lossy();
437
438    let mut ts = match crate::cli::open_tablespace(&path_str, page_size_override, use_mmap) {
439        Ok(t) => t,
440        Err(_) => return (Vec::new(), 0),
441    };
442
443    if let Some(ref kp) = keyring {
444        let _ = crate::cli::setup_decryption(&mut ts, kp);
445    }
446
447    let page_size = ts.page_size();
448    let page_count = ts.page_count();
449    let vendor_info = ts.vendor_info().clone();
450
451    let all_data = match ts.read_all_pages() {
452        Ok(d) => d,
453        Err(_) => return (Vec::new(), page_count),
454    };
455
456    let ps = page_size as usize;
457
458    let mismatches: Vec<MismatchEntry> = (0..page_count)
459        .into_par_iter()
460        .filter_map(|page_num| {
461            let offset = page_num as usize * ps;
462            if offset + ps > all_data.len() {
463                return None;
464            }
465            let page_data = &all_data[offset..offset + ps];
466
467            if page_data.iter().all(|&b| b == 0) {
468                return None;
469            }
470
471            let csum = validate_checksum(page_data, page_size, Some(&vendor_info));
472            if csum.valid {
473                return None;
474            }
475
476            Some(MismatchEntry {
477                file: display_str.clone(),
478                page_number: page_num,
479                stored_checksum: csum.stored_checksum,
480                calculated_checksum: csum.calculated_checksum,
481                algorithm: algorithm_name(csum.algorithm).to_string(),
482            })
483        })
484        .collect();
485
486    (mismatches, page_count)
487}
488
489fn algorithm_name(algo: crate::innodb::checksum::ChecksumAlgorithm) -> &'static str {
490    match algo {
491        crate::innodb::checksum::ChecksumAlgorithm::Crc32c => "crc32c",
492        crate::innodb::checksum::ChecksumAlgorithm::InnoDB => "innodb",
493        crate::innodb::checksum::ChecksumAlgorithm::MariaDbFullCrc32 => "mariadb_full_crc32",
494        crate::innodb::checksum::ChecksumAlgorithm::None => "none",
495    }
496}
497
498// ---------------------------------------------------------------------------
499// Entry point
500// ---------------------------------------------------------------------------
501
502/// Audit a MySQL data directory for integrity, health metrics, or corrupt pages.
503pub fn execute(opts: &AuditOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
504    if opts.prometheus && (opts.json || opts.csv) {
505        return Err(IdbError::Argument(
506            "--prometheus cannot be combined with JSON or CSV output".to_string(),
507        ));
508    }
509
510    // Validate mutually exclusive flags
511    if opts.health && opts.checksum_mismatch {
512        return Err(IdbError::Argument(
513            "--health and --checksum-mismatch are mutually exclusive".to_string(),
514        ));
515    }
516    if opts.compliance {
517        if opts.health || opts.checksum_mismatch {
518            return Err(IdbError::Argument(
519                "--compliance cannot be combined with --health or --checksum-mismatch".to_string(),
520            ));
521        }
522        if opts.prometheus {
523            return Err(IdbError::Argument(
524                "--compliance does not support --prometheus output".to_string(),
525            ));
526        }
527        if opts.pattern.is_none() {
528            return Err(IdbError::Argument(
529                "--compliance requires --pattern <text|hex:...>".to_string(),
530            ));
531        }
532    }
533
534    let datadir = Path::new(&opts.datadir);
535    if !datadir.is_dir() {
536        return Err(IdbError::Argument(format!(
537            "Data directory does not exist: {}",
538            opts.datadir
539        )));
540    }
541
542    let ibd_files = find_tablespace_files(datadir, &["ibd"], opts.depth)?;
543
544    if ibd_files.is_empty() {
545        if opts.prometheus {
546            // Empty Prometheus output — valid exposition format (no metrics emitted)
547            return Ok(());
548        } else if opts.json {
549            if opts.compliance {
550                let report = ComplianceReport {
551                    datadir: opts.datadir.clone(),
552                    pattern: opts.pattern.clone().unwrap_or_default(),
553                    files: Vec::new(),
554                    summary: ComplianceSummary {
555                        total_files: 0,
556                        files_with_residue: 0,
557                        total_matches: 0,
558                    },
559                };
560                let json = serde_json::to_string_pretty(&report)
561                    .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
562                wprintln!(writer, "{}", json)?;
563            } else if opts.health {
564                let report = HealthAuditReport {
565                    datadir: opts.datadir.clone(),
566                    tablespaces: Vec::new(),
567                    summary: DirectoryHealthSummary {
568                        total_files: 0,
569                        total_index_pages: 0,
570                        avg_fill_factor: 0.0,
571                        avg_fragmentation: 0.0,
572                        avg_garbage_ratio: 0.0,
573                        worst_bloat_grade: None,
574                    },
575                };
576                let json = serde_json::to_string_pretty(&report)
577                    .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
578                wprintln!(writer, "{}", json)?;
579            } else if opts.checksum_mismatch {
580                let report = MismatchReport {
581                    datadir: opts.datadir.clone(),
582                    mismatches: Vec::new(),
583                    total_files_scanned: 0,
584                    total_pages_scanned: 0,
585                };
586                let json = serde_json::to_string_pretty(&report)
587                    .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
588                wprintln!(writer, "{}", json)?;
589            } else {
590                let report = AuditReport {
591                    datadir: opts.datadir.clone(),
592                    files: Vec::new(),
593                    summary: AuditSummary {
594                        total_files: 0,
595                        files_passed: 0,
596                        files_failed: 0,
597                        files_error: 0,
598                        total_pages: 0,
599                        corrupt_pages: 0,
600                        integrity_pct: 100.0,
601                    },
602                };
603                let json = serde_json::to_string_pretty(&report)
604                    .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
605                wprintln!(writer, "{}", json)?;
606            }
607        } else if opts.csv {
608            if opts.compliance {
609                wprintln!(writer, "file,match_count,pages_with_matches,capped,error")?;
610            } else if opts.health {
611                let do_bloat = opts.bloat || opts.max_bloat_grade.is_some();
612                if do_bloat {
613                    wprintln!(
614                        writer,
615                        "file,avg_fill_factor,avg_fragmentation,avg_garbage_ratio,index_count,total_index_pages,worst_bloat_grade,worst_bloat_score"
616                    )?;
617                } else {
618                    wprintln!(
619                        writer,
620                        "file,avg_fill_factor,avg_fragmentation,avg_garbage_ratio,index_count,total_index_pages"
621                    )?;
622                }
623            } else if opts.checksum_mismatch {
624                wprintln!(
625                    writer,
626                    "file,page_number,stored_checksum,calculated_checksum,algorithm"
627                )?;
628            } else {
629                wprintln!(
630                    writer,
631                    "file,status,total_pages,empty_pages,valid_pages,invalid_pages,lsn_mismatches"
632                )?;
633            }
634        } else {
635            wprintln!(writer, "No .ibd files found in {}", opts.datadir)?;
636        }
637        return Ok(());
638    }
639
640    if opts.compliance {
641        execute_compliance(opts, &ibd_files, datadir, writer)
642    } else if opts.health {
643        execute_health(opts, &ibd_files, datadir, writer)
644    } else if opts.checksum_mismatch {
645        execute_mismatch(opts, &ibd_files, datadir, writer)
646    } else {
647        execute_integrity(opts, &ibd_files, datadir, writer)
648    }
649}
650
651// ---------------------------------------------------------------------------
652// Compliance / data-residue mode (#182)
653// ---------------------------------------------------------------------------
654
655/// Per-file cap on stored residue matches. Beyond this the `capped` flag is set.
656const COMPLIANCE_MATCH_CAP: usize = 10_000;
657/// How many matches to keep for the JSON `sample_matches` field.
658const COMPLIANCE_SAMPLE: usize = 25;
659
660fn compliance_file(
661    path: &Path,
662    datadir: &Path,
663    page_size_override: Option<u32>,
664    keyring: &Option<String>,
665    use_mmap: bool,
666    pattern: &crate::innodb::compliance::Pattern,
667) -> FileComplianceResult {
668    let display = path.strip_prefix(datadir).unwrap_or(path);
669    let display_str = display.display().to_string();
670    let path_str = path.to_string_lossy();
671
672    let mut ts = match crate::cli::open_tablespace(&path_str, page_size_override, use_mmap) {
673        Ok(t) => t,
674        Err(e) => {
675            return FileComplianceResult {
676                file: display_str,
677                match_count: 0,
678                pages_with_matches: 0,
679                capped: false,
680                sample_matches: Vec::new(),
681                error: Some(e.to_string()),
682            }
683        }
684    };
685
686    if let Some(ref kp) = keyring {
687        let _ = crate::cli::setup_decryption(&mut ts, kp);
688    }
689
690    match crate::innodb::compliance::scan_residue(&mut ts, pattern, COMPLIANCE_MATCH_CAP) {
691        Ok(matches) => {
692            let match_count = matches.len();
693            let mut pages: std::collections::HashSet<u64> = std::collections::HashSet::new();
694            for m in &matches {
695                pages.insert(m.page_number);
696            }
697            let sample_matches = matches.into_iter().take(COMPLIANCE_SAMPLE).collect();
698            FileComplianceResult {
699                file: display_str,
700                match_count,
701                pages_with_matches: pages.len(),
702                capped: match_count >= COMPLIANCE_MATCH_CAP,
703                sample_matches,
704                error: None,
705            }
706        }
707        Err(e) => FileComplianceResult {
708            file: display_str,
709            match_count: 0,
710            pages_with_matches: 0,
711            capped: false,
712            sample_matches: Vec::new(),
713            error: Some(e.to_string()),
714        },
715    }
716}
717
718fn execute_compliance(
719    opts: &AuditOptions,
720    ibd_files: &[std::path::PathBuf],
721    datadir: &Path,
722    writer: &mut dyn Write,
723) -> Result<(), IdbError> {
724    let pattern_str = opts.pattern.as_deref().ok_or_else(|| {
725        IdbError::Argument("--compliance requires --pattern <text|hex:...>".to_string())
726    })?;
727    let pattern = crate::innodb::compliance::Pattern::parse(pattern_str)?;
728
729    let pb = if !opts.json && !opts.csv {
730        Some(create_progress_bar(ibd_files.len() as u64, "files"))
731    } else {
732        None
733    };
734
735    let page_size = opts.page_size;
736    let keyring = opts.keyring.clone();
737    let use_mmap = opts.mmap;
738
739    let mut results: Vec<FileComplianceResult> = ibd_files
740        .par_iter()
741        .map(|path| {
742            let r = compliance_file(path, datadir, page_size, &keyring, use_mmap, &pattern);
743            if let Some(ref pb) = pb {
744                pb.inc(1);
745            }
746            r
747        })
748        .collect();
749
750    if let Some(ref pb) = pb {
751        pb.finish_and_clear();
752    }
753
754    results.sort_by(|a, b| a.file.cmp(&b.file));
755
756    let total_matches: usize = results.iter().map(|r| r.match_count).sum();
757    let files_with_residue = results.iter().filter(|r| r.match_count > 0).count();
758    let summary = ComplianceSummary {
759        total_files: results.len(),
760        files_with_residue,
761        total_matches,
762    };
763
764    if opts.json {
765        let report = ComplianceReport {
766            datadir: opts.datadir.clone(),
767            pattern: pattern_str.to_string(),
768            files: results,
769            summary,
770        };
771        let json = serde_json::to_string_pretty(&report)
772            .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
773        wprintln!(writer, "{}", json)?;
774        return Ok(());
775    }
776
777    if opts.csv {
778        wprintln!(writer, "file,match_count,pages_with_matches,capped,error")?;
779        for r in &results {
780            wprintln!(
781                writer,
782                "{},{},{},{},{}",
783                csv_escape(&r.file),
784                r.match_count,
785                r.pages_with_matches,
786                if r.capped { "Y" } else { "N" },
787                csv_escape(r.error.as_deref().unwrap_or("")),
788            )?;
789        }
790        return Ok(());
791    }
792
793    // Human-readable.
794    wprintln!(
795        writer,
796        "Data-residue audit for pattern '{}' across {} file(s):",
797        pattern_str,
798        results.len()
799    )?;
800    for r in &results {
801        if let Some(ref e) = r.error {
802            wprintln!(writer, "  {}: error: {}", r.file, e)?;
803        } else if r.match_count == 0 {
804            if opts.verbose {
805                wprintln!(writer, "  {}: clean", r.file)?;
806            }
807        } else {
808            wprintln!(
809                writer,
810                "  {}: {} match(es) across {} page(s){}",
811                r.file,
812                r.match_count,
813                r.pages_with_matches,
814                if r.capped { " (capped)" } else { "" }
815            )?;
816        }
817    }
818    wprintln!(
819        writer,
820        "\n{} match(es) in {} of {} file(s).",
821        total_matches,
822        files_with_residue,
823        results.len()
824    )?;
825
826    Ok(())
827}
828
829// ---------------------------------------------------------------------------
830// Default integrity mode (#83)
831// ---------------------------------------------------------------------------
832
833fn execute_integrity(
834    opts: &AuditOptions,
835    ibd_files: &[std::path::PathBuf],
836    datadir: &Path,
837    writer: &mut dyn Write,
838) -> Result<(), IdbError> {
839    let start = Instant::now();
840
841    let pb = if !opts.json && !opts.csv && !opts.prometheus {
842        Some(create_progress_bar(ibd_files.len() as u64, "files"))
843    } else {
844        None
845    };
846
847    let page_size = opts.page_size;
848    let keyring = opts.keyring.clone();
849    let use_mmap = opts.mmap;
850
851    let mut results: Vec<FileIntegrityResult> = ibd_files
852        .par_iter()
853        .map(|path| {
854            let r = audit_file(path, datadir, page_size, &keyring, use_mmap);
855            if let Some(ref pb) = pb {
856                pb.inc(1);
857            }
858            r
859        })
860        .collect();
861
862    if let Some(ref pb) = pb {
863        pb.finish_and_clear();
864    }
865
866    // Sort by file path for deterministic output
867    results.sort_by(|a, b| a.file.cmp(&b.file));
868
869    // Compute summary
870    let total_files = results.len();
871    let files_passed = results.iter().filter(|r| r.status == "PASS").count();
872    let files_failed = results.iter().filter(|r| r.status == "FAIL").count();
873    let files_error = results.iter().filter(|r| r.status == "error").count();
874    let total_pages: u64 = results.iter().map(|r| r.total_pages).sum();
875    let corrupt_pages: u64 = results.iter().map(|r| r.invalid_pages).sum();
876    let valid_pages: u64 = results.iter().map(|r| r.valid_pages).sum();
877    let checked_pages = valid_pages + corrupt_pages;
878    let integrity_pct = if checked_pages > 0 {
879        (valid_pages as f64 / checked_pages as f64) * 100.0
880    } else {
881        100.0
882    };
883    let integrity_pct = (integrity_pct * 100.0).round() / 100.0;
884
885    if opts.prometheus {
886        let duration_secs = start.elapsed().as_secs_f64();
887        print_prometheus_integrity(
888            writer,
889            &IntegrityPrometheusParams {
890                datadir: &opts.datadir,
891                results: &results,
892                total_pages,
893                corrupt_pages,
894                integrity_pct,
895                duration_secs,
896            },
897        )?;
898
899        if corrupt_pages > 0 {
900            return Err(IdbError::Parse(format!(
901                "{} corrupt pages found across {} files",
902                corrupt_pages, files_failed
903            )));
904        }
905        return Ok(());
906    }
907
908    if opts.json {
909        let report = AuditReport {
910            datadir: opts.datadir.clone(),
911            files: results,
912            summary: AuditSummary {
913                total_files,
914                files_passed,
915                files_failed,
916                files_error,
917                total_pages,
918                corrupt_pages,
919                integrity_pct,
920            },
921        };
922        let json = serde_json::to_string_pretty(&report)
923            .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
924        wprintln!(writer, "{}", json)?;
925    } else if opts.csv {
926        wprintln!(
927            writer,
928            "file,status,total_pages,empty_pages,valid_pages,invalid_pages,lsn_mismatches"
929        )?;
930        for r in &results {
931            wprintln!(
932                writer,
933                "{},{},{},{},{},{},{}",
934                csv_escape(&r.file),
935                r.status,
936                r.total_pages,
937                r.empty_pages,
938                r.valid_pages,
939                r.invalid_pages,
940                r.lsn_mismatches
941            )?;
942        }
943    } else {
944        wprintln!(
945            writer,
946            "Auditing {} ({} files)...\n",
947            opts.datadir,
948            total_files
949        )?;
950
951        for r in &results {
952            let status_colored = match r.status.as_str() {
953                "PASS" => "PASS".green().to_string(),
954                "FAIL" => "FAIL".red().to_string(),
955                _ => "ERROR".yellow().to_string(),
956            };
957
958            if r.status == "error" {
959                wprintln!(
960                    writer,
961                    "  {:<40} {}   {}",
962                    r.file,
963                    status_colored,
964                    r.error.as_deref().unwrap_or("unknown error")
965                )?;
966            } else if r.invalid_pages > 0 {
967                wprintln!(
968                    writer,
969                    "  {:<40} {}   {} pages, {} corrupt",
970                    r.file,
971                    status_colored,
972                    r.total_pages,
973                    r.invalid_pages
974                )?;
975            } else {
976                wprintln!(
977                    writer,
978                    "  {:<40} {}   {} pages",
979                    r.file,
980                    status_colored,
981                    r.total_pages
982                )?;
983            }
984
985            if opts.verbose && !r.corrupt_pages.is_empty() {
986                wprintln!(writer, "    Corrupt pages: {:?}", r.corrupt_pages)?;
987            }
988        }
989
990        wprintln!(writer)?;
991        wprintln!(writer, "Summary:")?;
992        wprintln!(
993            writer,
994            "  Files: {} ({} passed, {} failed{})",
995            total_files,
996            files_passed,
997            files_failed,
998            if files_error > 0 {
999                format!(", {} error", files_error)
1000            } else {
1001                String::new()
1002            }
1003        )?;
1004        wprintln!(
1005            writer,
1006            "  Pages: {} total, {} corrupt",
1007            total_pages,
1008            corrupt_pages
1009        )?;
1010        wprintln!(writer, "  Integrity: {:.2}%", integrity_pct)?;
1011    }
1012
1013    if corrupt_pages > 0 {
1014        return Err(IdbError::Parse(format!(
1015            "{} corrupt pages found across {} files",
1016            corrupt_pages, files_failed
1017        )));
1018    }
1019
1020    Ok(())
1021}
1022
1023// ---------------------------------------------------------------------------
1024// Health mode (#84)
1025// ---------------------------------------------------------------------------
1026
1027fn execute_health(
1028    opts: &AuditOptions,
1029    ibd_files: &[std::path::PathBuf],
1030    datadir: &Path,
1031    writer: &mut dyn Write,
1032) -> Result<(), IdbError> {
1033    let start = Instant::now();
1034
1035    let pb = if !opts.json && !opts.csv && !opts.prometheus {
1036        Some(create_progress_bar(ibd_files.len() as u64, "files"))
1037    } else {
1038        None
1039    };
1040
1041    let page_size = opts.page_size;
1042    let keyring = opts.keyring.clone();
1043    let use_mmap = opts.mmap;
1044
1045    let do_bloat = opts.bloat || opts.max_bloat_grade.is_some();
1046
1047    let mut results: Vec<FileHealthResult> = ibd_files
1048        .par_iter()
1049        .map(|path| {
1050            let r = audit_file_health(path, datadir, page_size, &keyring, use_mmap, do_bloat);
1051            if let Some(ref pb) = pb {
1052                pb.inc(1);
1053            }
1054            r
1055        })
1056        .collect();
1057
1058    if let Some(ref pb) = pb {
1059        pb.finish_and_clear();
1060    }
1061
1062    // Compute directory-wide summary from ALL results (before filtering)
1063    let total_files = results.len();
1064    let total_index_pages: u64 = results.iter().map(|r| r.total_index_pages).sum();
1065
1066    let valid_results: Vec<&FileHealthResult> =
1067        results.iter().filter(|r| r.error.is_none()).collect();
1068    let n = valid_results.len() as f64;
1069
1070    let avg_fill = if n > 0.0 {
1071        valid_results.iter().map(|r| r.avg_fill_factor).sum::<f64>() / n
1072    } else {
1073        0.0
1074    };
1075    let avg_frag = if n > 0.0 {
1076        valid_results
1077            .iter()
1078            .map(|r| r.avg_fragmentation)
1079            .sum::<f64>()
1080            / n
1081    } else {
1082        0.0
1083    };
1084    let avg_garbage = if n > 0.0 {
1085        valid_results
1086            .iter()
1087            .map(|r| r.avg_garbage_ratio)
1088            .sum::<f64>()
1089            / n
1090    } else {
1091        0.0
1092    };
1093
1094    // Prometheus output uses unfiltered results to avoid stale markers
1095    if opts.prometheus {
1096        let duration_secs = start.elapsed().as_secs_f64();
1097        print_prometheus_health(
1098            writer,
1099            &HealthPrometheusParams {
1100                datadir: &opts.datadir,
1101                results: &results,
1102                total_files,
1103                total_index_pages,
1104                avg_fill,
1105                avg_frag,
1106                avg_garbage,
1107                duration_secs,
1108            },
1109        )?;
1110        return Ok(());
1111    }
1112
1113    // Filter by thresholds (values are 0-100 from CLI, compare as 0.0-1.0)
1114    if let Some(min_ff) = opts.min_fill_factor {
1115        let threshold = min_ff / 100.0;
1116        results.retain(|r| r.error.is_some() || r.avg_fill_factor < threshold);
1117    }
1118    if let Some(max_frag) = opts.max_fragmentation {
1119        let threshold = max_frag / 100.0;
1120        results.retain(|r| r.error.is_some() || r.avg_fragmentation > threshold);
1121    }
1122    // Compute directory-wide worst bloat grade from ALL results (before filtering)
1123    let dir_worst_bloat = if do_bloat {
1124        results
1125            .iter()
1126            .filter_map(|r| r.worst_bloat_grade.as_ref())
1127            .max_by_key(|g| bloat_grade_ord(g).unwrap_or(0))
1128            .cloned()
1129    } else {
1130        None
1131    };
1132
1133    if let Some(ref grade_str) = opts.max_bloat_grade {
1134        let threshold = bloat_grade_ord(grade_str).ok_or_else(|| {
1135            crate::IdbError::Argument(format!(
1136                "Invalid bloat grade '{}': must be one of A, B, C, D, F",
1137                grade_str
1138            ))
1139        })?;
1140        results.retain(|r| {
1141            r.error.is_some()
1142                || r.worst_bloat_grade
1143                    .as_ref()
1144                    .and_then(|g| bloat_grade_ord(g))
1145                    .map(|ord| ord >= threshold)
1146                    .unwrap_or(false)
1147        });
1148    }
1149
1150    // Sort worst-first by fragmentation (descending)
1151    results.sort_by(|a, b| {
1152        b.avg_fragmentation
1153            .partial_cmp(&a.avg_fragmentation)
1154            .unwrap_or(std::cmp::Ordering::Equal)
1155    });
1156
1157    if opts.json {
1158        let report = HealthAuditReport {
1159            datadir: opts.datadir.clone(),
1160            tablespaces: results,
1161            summary: DirectoryHealthSummary {
1162                total_files,
1163                total_index_pages,
1164                avg_fill_factor: round2(avg_fill),
1165                avg_fragmentation: round2(avg_frag),
1166                avg_garbage_ratio: round2(avg_garbage),
1167                worst_bloat_grade: dir_worst_bloat,
1168            },
1169        };
1170        let json = serde_json::to_string_pretty(&report)
1171            .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
1172        wprintln!(writer, "{}", json)?;
1173    } else if opts.csv {
1174        if do_bloat {
1175            wprintln!(
1176                writer,
1177                "file,avg_fill_factor,avg_fragmentation,avg_garbage_ratio,index_count,total_index_pages,worst_bloat_grade,worst_bloat_score"
1178            )?;
1179        } else {
1180            wprintln!(
1181                writer,
1182                "file,avg_fill_factor,avg_fragmentation,avg_garbage_ratio,index_count,total_index_pages"
1183            )?;
1184        }
1185        for r in &results {
1186            if r.error.is_some() {
1187                continue;
1188            }
1189            if do_bloat {
1190                wprintln!(
1191                    writer,
1192                    "{},{:.1},{:.1},{:.1},{},{},{},{}",
1193                    csv_escape(&r.file),
1194                    r.avg_fill_factor * 100.0,
1195                    r.avg_fragmentation * 100.0,
1196                    r.avg_garbage_ratio * 100.0,
1197                    r.index_count,
1198                    r.total_index_pages,
1199                    r.worst_bloat_grade.as_deref().unwrap_or(""),
1200                    r.worst_bloat_score
1201                        .map(|s| format!("{:.3}", s))
1202                        .unwrap_or_default()
1203                )?;
1204            } else {
1205                wprintln!(
1206                    writer,
1207                    "{},{:.1},{:.1},{:.1},{},{}",
1208                    csv_escape(&r.file),
1209                    r.avg_fill_factor * 100.0,
1210                    r.avg_fragmentation * 100.0,
1211                    r.avg_garbage_ratio * 100.0,
1212                    r.index_count,
1213                    r.total_index_pages
1214                )?;
1215            }
1216        }
1217    } else {
1218        wprintln!(writer, "Directory Health: {}\n", opts.datadir)?;
1219        if do_bloat {
1220            wprintln!(
1221                writer,
1222                "  {:<40} {:>6} {:>6} {:>6} {:>8} {:>6} {:>6}",
1223                "File",
1224                "Fill%",
1225                "Frag%",
1226                "Garb%",
1227                "Indexes",
1228                "Pages",
1229                "Bloat"
1230            )?;
1231        } else {
1232            wprintln!(
1233                writer,
1234                "  {:<40} {:>6} {:>6} {:>6} {:>8} {:>6}",
1235                "File",
1236                "Fill%",
1237                "Frag%",
1238                "Garb%",
1239                "Indexes",
1240                "Pages"
1241            )?;
1242        }
1243
1244        for r in &results {
1245            if let Some(ref err) = r.error {
1246                wprintln!(
1247                    writer,
1248                    "  {:<40} {}",
1249                    r.file,
1250                    format!("ERROR: {}", err).yellow()
1251                )?;
1252            } else if do_bloat {
1253                let grade_str = r.worst_bloat_grade.as_deref().unwrap_or("-");
1254                let grade_colored = match grade_str {
1255                    "A" | "B" => grade_str.green().to_string(),
1256                    "C" => grade_str.yellow().to_string(),
1257                    "D" | "F" => grade_str.red().to_string(),
1258                    _ => grade_str.to_string(),
1259                };
1260                wprintln!(
1261                    writer,
1262                    "  {:<40} {:>5.1}  {:>5.1}  {:>5.1}  {:>7}  {:>5}  {:>5}",
1263                    r.file,
1264                    r.avg_fill_factor * 100.0,
1265                    r.avg_fragmentation * 100.0,
1266                    r.avg_garbage_ratio * 100.0,
1267                    r.index_count,
1268                    r.total_index_pages,
1269                    grade_colored
1270                )?;
1271            } else {
1272                wprintln!(
1273                    writer,
1274                    "  {:<40} {:>5.1}  {:>5.1}  {:>5.1}  {:>7}  {:>5}",
1275                    r.file,
1276                    r.avg_fill_factor * 100.0,
1277                    r.avg_fragmentation * 100.0,
1278                    r.avg_garbage_ratio * 100.0,
1279                    r.index_count,
1280                    r.total_index_pages
1281                )?;
1282            }
1283        }
1284
1285        wprintln!(writer)?;
1286        wprintln!(
1287            writer,
1288            "Summary: {} files, avg fill {:.1}%, avg frag {:.1}%, avg garbage {:.1}%",
1289            total_files,
1290            avg_fill * 100.0,
1291            avg_frag * 100.0,
1292            avg_garbage * 100.0
1293        )?;
1294    }
1295
1296    Ok(())
1297}
1298
1299// ---------------------------------------------------------------------------
1300// Mismatch mode (#85)
1301// ---------------------------------------------------------------------------
1302
1303fn execute_mismatch(
1304    opts: &AuditOptions,
1305    ibd_files: &[std::path::PathBuf],
1306    datadir: &Path,
1307    writer: &mut dyn Write,
1308) -> Result<(), IdbError> {
1309    let pb = if !opts.json && !opts.csv {
1310        Some(create_progress_bar(ibd_files.len() as u64, "files"))
1311    } else {
1312        None
1313    };
1314
1315    let page_size = opts.page_size;
1316    let keyring = opts.keyring.clone();
1317    let use_mmap = opts.mmap;
1318
1319    let all_results: Vec<(Vec<MismatchEntry>, u64)> = ibd_files
1320        .par_iter()
1321        .map(|path| {
1322            let r = audit_file_mismatches(path, datadir, page_size, &keyring, use_mmap);
1323            if let Some(ref pb) = pb {
1324                pb.inc(1);
1325            }
1326            r
1327        })
1328        .collect();
1329
1330    if let Some(ref pb) = pb {
1331        pb.finish_and_clear();
1332    }
1333
1334    let total_files_scanned = ibd_files.len();
1335    let total_pages_scanned: u64 = all_results.iter().map(|(_, count)| count).sum();
1336
1337    let mut mismatches: Vec<MismatchEntry> = all_results
1338        .into_iter()
1339        .flat_map(|(entries, _)| entries)
1340        .collect();
1341    mismatches.sort_by(|a, b| (&a.file, a.page_number).cmp(&(&b.file, b.page_number)));
1342
1343    if opts.json {
1344        let report = MismatchReport {
1345            datadir: opts.datadir.clone(),
1346            mismatches: mismatches.clone(),
1347            total_files_scanned,
1348            total_pages_scanned,
1349        };
1350        let json = serde_json::to_string_pretty(&report)
1351            .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
1352        wprintln!(writer, "{}", json)?;
1353    } else if opts.csv {
1354        wprintln!(
1355            writer,
1356            "file,page_number,stored_checksum,calculated_checksum,algorithm"
1357        )?;
1358        for m in &mismatches {
1359            wprintln!(
1360                writer,
1361                "{},{},{},{},{}",
1362                csv_escape(&m.file),
1363                m.page_number,
1364                m.stored_checksum,
1365                m.calculated_checksum,
1366                m.algorithm
1367            )?;
1368        }
1369    } else if mismatches.is_empty() {
1370        wprintln!(
1371            writer,
1372            "No checksum mismatches found ({} files, {} pages scanned).",
1373            total_files_scanned,
1374            total_pages_scanned
1375        )?;
1376    } else {
1377        wprintln!(
1378            writer,
1379            "{:<40} {:>6} {:>12} {:>12} {:>12}",
1380            "FILE",
1381            "PAGE",
1382            "STORED",
1383            "CALCULATED",
1384            "ALGORITHM"
1385        )?;
1386        for m in &mismatches {
1387            wprintln!(
1388                writer,
1389                "{:<40} {:>6} {:>12} {:>12} {:>12}",
1390                m.file,
1391                m.page_number,
1392                format!("0x{:08X}", m.stored_checksum),
1393                format!("0x{:08X}", m.calculated_checksum),
1394                m.algorithm
1395            )?;
1396        }
1397    }
1398
1399    if !mismatches.is_empty() {
1400        return Err(IdbError::Parse(format!(
1401            "{} checksum mismatches found",
1402            mismatches.len()
1403        )));
1404    }
1405
1406    Ok(())
1407}
1408
1409fn round2(v: f64) -> f64 {
1410    (v * 100.0).round() / 100.0
1411}
1412
1413/// Convert bloat grade string to ordinal for comparison (higher = worse).
1414/// Returns `None` for unrecognised input.
1415fn bloat_grade_ord(grade: &str) -> Option<u8> {
1416    match grade {
1417        "A" => Some(0),
1418        "B" => Some(1),
1419        "C" => Some(2),
1420        "D" => Some(3),
1421        "F" => Some(4),
1422        _ => None,
1423    }
1424}
1425
1426// ---------------------------------------------------------------------------
1427// Prometheus exposition format output
1428// ---------------------------------------------------------------------------
1429
1430struct IntegrityPrometheusParams<'a> {
1431    datadir: &'a str,
1432    results: &'a [FileIntegrityResult],
1433    total_pages: u64,
1434    corrupt_pages: u64,
1435    integrity_pct: f64,
1436    duration_secs: f64,
1437}
1438
1439/// Print integrity audit results in Prometheus exposition format.
1440fn print_prometheus_integrity(
1441    writer: &mut dyn Write,
1442    params: &IntegrityPrometheusParams<'_>,
1443) -> Result<(), IdbError> {
1444    let datadir = params.datadir;
1445    let results = params.results;
1446    let total_pages = params.total_pages;
1447    let corrupt_pages = params.corrupt_pages;
1448    let integrity_pct = params.integrity_pct;
1449    let duration_secs = params.duration_secs;
1450    // innodb_pages per file
1451    wprintln!(
1452        writer,
1453        "{}",
1454        prom::help_line("innodb_pages", "Total pages in tablespace")
1455    )?;
1456    wprintln!(writer, "{}", prom::type_line("innodb_pages", "gauge"))?;
1457    for r in results {
1458        if r.status == "error" {
1459            continue;
1460        }
1461        wprintln!(
1462            writer,
1463            "{}",
1464            prom::format_gauge_int(
1465                "innodb_pages",
1466                &[("datadir", datadir), ("file", &r.file)],
1467                r.total_pages
1468            )
1469        )?;
1470    }
1471
1472    // innodb_corrupt_pages per file
1473    wprintln!(
1474        writer,
1475        "{}",
1476        prom::help_line(
1477            "innodb_corrupt_pages",
1478            "Number of corrupt pages in tablespace"
1479        )
1480    )?;
1481    wprintln!(
1482        writer,
1483        "{}",
1484        prom::type_line("innodb_corrupt_pages", "gauge")
1485    )?;
1486    for r in results {
1487        if r.status == "error" {
1488            continue;
1489        }
1490        wprintln!(
1491            writer,
1492            "{}",
1493            prom::format_gauge_int(
1494                "innodb_corrupt_pages",
1495                &[("datadir", datadir), ("file", &r.file)],
1496                r.invalid_pages
1497            )
1498        )?;
1499    }
1500
1501    // innodb_empty_pages per file
1502    wprintln!(
1503        writer,
1504        "{}",
1505        prom::help_line("innodb_empty_pages", "Number of empty pages in tablespace")
1506    )?;
1507    wprintln!(writer, "{}", prom::type_line("innodb_empty_pages", "gauge"))?;
1508    for r in results {
1509        if r.status == "error" {
1510            continue;
1511        }
1512        wprintln!(
1513            writer,
1514            "{}",
1515            prom::format_gauge_int(
1516                "innodb_empty_pages",
1517                &[("datadir", datadir), ("file", &r.file)],
1518                r.empty_pages
1519            )
1520        )?;
1521    }
1522
1523    // innodb_audit_integrity_pct — directory-wide
1524    wprintln!(
1525        writer,
1526        "{}",
1527        prom::help_line(
1528            "innodb_audit_integrity_pct",
1529            "Directory-wide integrity percentage"
1530        )
1531    )?;
1532    wprintln!(
1533        writer,
1534        "{}",
1535        prom::type_line("innodb_audit_integrity_pct", "gauge")
1536    )?;
1537    wprintln!(
1538        writer,
1539        "{}",
1540        prom::format_gauge(
1541            "innodb_audit_integrity_pct",
1542            &[("datadir", datadir)],
1543            integrity_pct
1544        )
1545    )?;
1546
1547    // innodb_audit_pages — directory-wide
1548    wprintln!(
1549        writer,
1550        "{}",
1551        prom::help_line(
1552            "innodb_audit_pages",
1553            "Total pages scanned across data directory"
1554        )
1555    )?;
1556    wprintln!(writer, "{}", prom::type_line("innodb_audit_pages", "gauge"))?;
1557    wprintln!(
1558        writer,
1559        "{}",
1560        prom::format_gauge_int("innodb_audit_pages", &[("datadir", datadir)], total_pages)
1561    )?;
1562
1563    // innodb_audit_corrupt_pages — directory-wide
1564    wprintln!(
1565        writer,
1566        "{}",
1567        prom::help_line(
1568            "innodb_audit_corrupt_pages",
1569            "Total corrupt pages across data directory"
1570        )
1571    )?;
1572    wprintln!(
1573        writer,
1574        "{}",
1575        prom::type_line("innodb_audit_corrupt_pages", "gauge")
1576    )?;
1577    wprintln!(
1578        writer,
1579        "{}",
1580        prom::format_gauge_int(
1581            "innodb_audit_corrupt_pages",
1582            &[("datadir", datadir)],
1583            corrupt_pages
1584        )
1585    )?;
1586
1587    // innodb_scan_duration_seconds — directory-wide
1588    wprintln!(
1589        writer,
1590        "{}",
1591        prom::help_line(
1592            "innodb_scan_duration_seconds",
1593            "Time spent scanning the data directory"
1594        )
1595    )?;
1596    wprintln!(
1597        writer,
1598        "{}",
1599        prom::type_line("innodb_scan_duration_seconds", "gauge")
1600    )?;
1601    wprintln!(
1602        writer,
1603        "{}",
1604        prom::format_gauge(
1605            "innodb_scan_duration_seconds",
1606            &[("datadir", datadir)],
1607            duration_secs
1608        )
1609    )?;
1610
1611    Ok(())
1612}
1613
1614struct HealthPrometheusParams<'a> {
1615    datadir: &'a str,
1616    results: &'a [FileHealthResult],
1617    total_files: usize,
1618    total_index_pages: u64,
1619    avg_fill: f64,
1620    avg_frag: f64,
1621    avg_garbage: f64,
1622    duration_secs: f64,
1623}
1624
1625/// Print health audit results in Prometheus exposition format.
1626fn print_prometheus_health(
1627    writer: &mut dyn Write,
1628    params: &HealthPrometheusParams<'_>,
1629) -> Result<(), IdbError> {
1630    let datadir = params.datadir;
1631    let results = params.results;
1632    let total_files = params.total_files;
1633    let total_index_pages = params.total_index_pages;
1634    let avg_fill = params.avg_fill;
1635    let avg_frag = params.avg_frag;
1636    let avg_garbage = params.avg_garbage;
1637    let duration_secs = params.duration_secs;
1638    // Per-file fill factor
1639    wprintln!(
1640        writer,
1641        "{}",
1642        prom::help_line(
1643            "innodb_fill_factor",
1644            "Average B+Tree fill factor for tablespace"
1645        )
1646    )?;
1647    wprintln!(writer, "{}", prom::type_line("innodb_fill_factor", "gauge"))?;
1648    for r in results {
1649        if r.error.is_some() {
1650            continue;
1651        }
1652        wprintln!(
1653            writer,
1654            "{}",
1655            prom::format_gauge(
1656                "innodb_fill_factor",
1657                &[("datadir", datadir), ("file", &r.file)],
1658                r.avg_fill_factor
1659            )
1660        )?;
1661    }
1662
1663    // Per-file fragmentation
1664    wprintln!(
1665        writer,
1666        "{}",
1667        prom::help_line(
1668            "innodb_fragmentation_ratio",
1669            "Average fragmentation ratio for tablespace"
1670        )
1671    )?;
1672    wprintln!(
1673        writer,
1674        "{}",
1675        prom::type_line("innodb_fragmentation_ratio", "gauge")
1676    )?;
1677    for r in results {
1678        if r.error.is_some() {
1679            continue;
1680        }
1681        wprintln!(
1682            writer,
1683            "{}",
1684            prom::format_gauge(
1685                "innodb_fragmentation_ratio",
1686                &[("datadir", datadir), ("file", &r.file)],
1687                r.avg_fragmentation
1688            )
1689        )?;
1690    }
1691
1692    // Per-file garbage ratio
1693    wprintln!(
1694        writer,
1695        "{}",
1696        prom::help_line(
1697            "innodb_garbage_ratio",
1698            "Average garbage ratio for tablespace"
1699        )
1700    )?;
1701    wprintln!(
1702        writer,
1703        "{}",
1704        prom::type_line("innodb_garbage_ratio", "gauge")
1705    )?;
1706    for r in results {
1707        if r.error.is_some() {
1708            continue;
1709        }
1710        wprintln!(
1711            writer,
1712            "{}",
1713            prom::format_gauge(
1714                "innodb_garbage_ratio",
1715                &[("datadir", datadir), ("file", &r.file)],
1716                r.avg_garbage_ratio
1717            )
1718        )?;
1719    }
1720
1721    // Per-file index page count
1722    wprintln!(
1723        writer,
1724        "{}",
1725        prom::help_line("innodb_index_pages", "Total INDEX pages in tablespace")
1726    )?;
1727    wprintln!(writer, "{}", prom::type_line("innodb_index_pages", "gauge"))?;
1728    for r in results {
1729        if r.error.is_some() {
1730            continue;
1731        }
1732        wprintln!(
1733            writer,
1734            "{}",
1735            prom::format_gauge_int(
1736                "innodb_index_pages",
1737                &[("datadir", datadir), ("file", &r.file)],
1738                r.total_index_pages
1739            )
1740        )?;
1741    }
1742
1743    // Per-file bloat score (only emitted when bloat data is available)
1744    let has_bloat = results.iter().any(|r| r.worst_bloat_score.is_some());
1745    if has_bloat {
1746        wprintln!(
1747            writer,
1748            "{}",
1749            prom::help_line(
1750                "innodb_bloat_score",
1751                "Worst bloat score across indexes in tablespace (0-1)"
1752            )
1753        )?;
1754        wprintln!(writer, "{}", prom::type_line("innodb_bloat_score", "gauge"))?;
1755        for r in results {
1756            if let Some(score) = r.worst_bloat_score {
1757                wprintln!(
1758                    writer,
1759                    "{}",
1760                    prom::format_gauge(
1761                        "innodb_bloat_score",
1762                        &[
1763                            ("datadir", datadir),
1764                            ("file", &r.file),
1765                            ("grade", r.worst_bloat_grade.as_deref().unwrap_or(""))
1766                        ],
1767                        score
1768                    )
1769                )?;
1770            }
1771        }
1772    }
1773
1774    // Directory-wide summary metrics
1775    wprintln!(
1776        writer,
1777        "{}",
1778        prom::help_line("innodb_audit_files", "Total tablespace files scanned")
1779    )?;
1780    wprintln!(writer, "{}", prom::type_line("innodb_audit_files", "gauge"))?;
1781    wprintln!(
1782        writer,
1783        "{}",
1784        prom::format_gauge_int(
1785            "innodb_audit_files",
1786            &[("datadir", datadir)],
1787            total_files as u64
1788        )
1789    )?;
1790
1791    wprintln!(
1792        writer,
1793        "{}",
1794        prom::help_line(
1795            "innodb_audit_index_pages",
1796            "Total INDEX pages across data directory"
1797        )
1798    )?;
1799    wprintln!(
1800        writer,
1801        "{}",
1802        prom::type_line("innodb_audit_index_pages", "gauge")
1803    )?;
1804    wprintln!(
1805        writer,
1806        "{}",
1807        prom::format_gauge_int(
1808            "innodb_audit_index_pages",
1809            &[("datadir", datadir)],
1810            total_index_pages
1811        )
1812    )?;
1813
1814    wprintln!(
1815        writer,
1816        "{}",
1817        prom::help_line(
1818            "innodb_audit_avg_fill_factor",
1819            "Directory-wide average fill factor"
1820        )
1821    )?;
1822    wprintln!(
1823        writer,
1824        "{}",
1825        prom::type_line("innodb_audit_avg_fill_factor", "gauge")
1826    )?;
1827    wprintln!(
1828        writer,
1829        "{}",
1830        prom::format_gauge(
1831            "innodb_audit_avg_fill_factor",
1832            &[("datadir", datadir)],
1833            avg_fill
1834        )
1835    )?;
1836
1837    wprintln!(
1838        writer,
1839        "{}",
1840        prom::help_line(
1841            "innodb_audit_avg_fragmentation",
1842            "Directory-wide average fragmentation"
1843        )
1844    )?;
1845    wprintln!(
1846        writer,
1847        "{}",
1848        prom::type_line("innodb_audit_avg_fragmentation", "gauge")
1849    )?;
1850    wprintln!(
1851        writer,
1852        "{}",
1853        prom::format_gauge(
1854            "innodb_audit_avg_fragmentation",
1855            &[("datadir", datadir)],
1856            avg_frag
1857        )
1858    )?;
1859
1860    wprintln!(
1861        writer,
1862        "{}",
1863        prom::help_line(
1864            "innodb_audit_avg_garbage_ratio",
1865            "Directory-wide average garbage ratio"
1866        )
1867    )?;
1868    wprintln!(
1869        writer,
1870        "{}",
1871        prom::type_line("innodb_audit_avg_garbage_ratio", "gauge")
1872    )?;
1873    wprintln!(
1874        writer,
1875        "{}",
1876        prom::format_gauge(
1877            "innodb_audit_avg_garbage_ratio",
1878            &[("datadir", datadir)],
1879            avg_garbage
1880        )
1881    )?;
1882
1883    // innodb_scan_duration_seconds — directory-wide
1884    wprintln!(
1885        writer,
1886        "{}",
1887        prom::help_line(
1888            "innodb_scan_duration_seconds",
1889            "Time spent scanning the data directory"
1890        )
1891    )?;
1892    wprintln!(
1893        writer,
1894        "{}",
1895        prom::type_line("innodb_scan_duration_seconds", "gauge")
1896    )?;
1897    wprintln!(
1898        writer,
1899        "{}",
1900        prom::format_gauge(
1901            "innodb_scan_duration_seconds",
1902            &[("datadir", datadir)],
1903            duration_secs
1904        )
1905    )?;
1906
1907    Ok(())
1908}