Skip to main content

idb/cli/
validate.rs

1//! CLI implementation for the `inno validate` subcommand.
2//!
3//! Cross-validates on-disk tablespace files against live MySQL metadata.
4//! Requires the `mysql` feature for actual MySQL queries; without it,
5//! provides a helpful error message.
6
7use std::io::Write;
8
9#[cfg(feature = "mysql")]
10use colored::Colorize;
11use serde::Serialize;
12
13use crate::cli::wprintln;
14use crate::IdbError;
15
16/// Options for the `inno validate` subcommand.
17pub struct ValidateOptions {
18    /// Path to MySQL data directory.
19    pub datadir: String,
20    /// MySQL database name to filter.
21    pub database: Option<String>,
22    /// Deep-validate a specific table (format: db.table or db/table).
23    pub table: Option<String>,
24    /// MySQL host for live queries.
25    pub host: Option<String>,
26    /// MySQL port for live queries.
27    pub port: Option<u16>,
28    /// MySQL user for live queries.
29    pub user: Option<String>,
30    /// MySQL password for live queries.
31    pub password: Option<String>,
32    /// Path to MySQL defaults file.
33    pub defaults_file: Option<String>,
34    /// Emit output as JSON.
35    pub json: bool,
36    /// Show detailed output.
37    pub verbose: bool,
38    /// Override page size.
39    pub page_size: Option<u32>,
40    /// Maximum directory recursion depth.
41    pub depth: Option<u32>,
42    /// Use memory-mapped I/O.
43    pub mmap: bool,
44}
45
46/// JSON output for disk-only mode (without MySQL).
47#[derive(Debug, Serialize)]
48struct DiskScanReport {
49    files_scanned: usize,
50    tablespaces: Vec<DiskTablespace>,
51}
52
53#[derive(Debug, Serialize)]
54struct DiskTablespace {
55    file: String,
56    space_id: u32,
57}
58
59/// Execute the validate subcommand.
60pub fn execute(opts: &ValidateOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
61    // Handle --table mode (requires MySQL)
62    #[cfg(feature = "mysql")]
63    {
64        if opts.table.is_some() {
65            if opts.host.is_none() && opts.user.is_none() && opts.defaults_file.is_none() {
66                return Err(IdbError::Argument(
67                    "--table requires MySQL connection options (--host, --user, or --defaults-file)"
68                        .to_string(),
69                ));
70            }
71            return execute_table_validate(opts, writer);
72        }
73    }
74
75    #[cfg(not(feature = "mysql"))]
76    {
77        if opts.table.is_some() {
78            return Err(IdbError::Argument(
79                "MySQL support not compiled. Rebuild with: cargo build --features mysql"
80                    .to_string(),
81            ));
82        }
83    }
84
85    // Scan disk files
86    let files = crate::util::fs::find_tablespace_files(
87        std::path::Path::new(&opts.datadir),
88        &["ibd"],
89        opts.depth,
90    )?;
91
92    let mut disk_entries: Vec<(std::path::PathBuf, u32)> = Vec::new();
93
94    for file_path in &files {
95        let path_str = file_path.to_string_lossy().to_string();
96        match crate::cli::open_tablespace(&path_str, opts.page_size, opts.mmap) {
97            Ok(ts) => {
98                let space_id = ts.fsp_header().map(|h| h.space_id).unwrap_or(0);
99                disk_entries.push((file_path.clone(), space_id));
100            }
101            Err(e) => {
102                if opts.verbose {
103                    eprintln!("Warning: skipping {}: {}", path_str, e);
104                }
105            }
106        }
107    }
108
109    #[cfg(feature = "mysql")]
110    {
111        if opts.host.is_some() || opts.user.is_some() || opts.defaults_file.is_some() {
112            return execute_mysql_validate(opts, &disk_entries, writer);
113        }
114    }
115
116    #[cfg(not(feature = "mysql"))]
117    {
118        if opts.host.is_some() || opts.user.is_some() || opts.defaults_file.is_some() {
119            return Err(IdbError::Argument(
120                "MySQL support not compiled. Rebuild with: cargo build --features mysql"
121                    .to_string(),
122            ));
123        }
124    }
125
126    // Disk-only mode: just list what we found
127    if opts.json {
128        let report = DiskScanReport {
129            files_scanned: disk_entries.len(),
130            tablespaces: disk_entries
131                .iter()
132                .map(|(p, sid)| DiskTablespace {
133                    file: p.to_string_lossy().to_string(),
134                    space_id: *sid,
135                })
136                .collect(),
137        };
138        let json = serde_json::to_string_pretty(&report)
139            .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
140        wprintln!(writer, "{}", json)?;
141    } else {
142        wprintln!(
143            writer,
144            "Disk scan: {} tablespace files found",
145            disk_entries.len()
146        )?;
147        wprintln!(writer)?;
148        wprintln!(writer, "  {:<60} {:>12}", "File", "Space ID")?;
149        wprintln!(writer, "  {}", "-".repeat(74))?;
150        for (path, space_id) in &disk_entries {
151            wprintln!(writer, "  {:<60} {:>12}", path.to_string_lossy(), space_id)?;
152        }
153        wprintln!(writer)?;
154        wprintln!(
155            writer,
156            "  Provide MySQL connection options (--host, --user) to cross-validate against live MySQL."
157        )?;
158    }
159
160    Ok(())
161}
162
163/// Execute MySQL cross-validation (mysql feature only).
164#[cfg(feature = "mysql")]
165fn execute_mysql_validate(
166    opts: &ValidateOptions,
167    disk_entries: &[(std::path::PathBuf, u32)],
168    writer: &mut dyn Write,
169) -> Result<(), IdbError> {
170    use crate::innodb::validate::{cross_validate, TablespaceMapping};
171    use mysql_async::prelude::*;
172
173    // Build MySQL config (same pattern as execute_table_validate)
174    let mut config = crate::util::mysql::MysqlConfig::default();
175
176    if let Some(ref df) = opts.defaults_file {
177        if let Some(parsed) = crate::util::mysql::parse_defaults_file(std::path::Path::new(df)) {
178            config = parsed;
179        }
180    } else if let Some(df) = crate::util::mysql::find_defaults_file() {
181        if let Some(parsed) = crate::util::mysql::parse_defaults_file(&df) {
182            config = parsed;
183        }
184    }
185
186    if let Some(ref h) = opts.host {
187        config.host = h.clone();
188    }
189    if let Some(p) = opts.port {
190        config.port = p;
191    }
192    if let Some(ref u) = opts.user {
193        config.user = u.clone();
194    }
195    if opts.password.is_some() {
196        config.password = opts.password.clone();
197    }
198
199    let rt = tokio::runtime::Builder::new_current_thread()
200        .enable_all()
201        .build()
202        .map_err(|e| IdbError::Io(format!("Failed to create async runtime: {}", e)))?;
203
204    let mysql_mappings = rt.block_on(async {
205        let pool = mysql_async::Pool::new(config.to_opts());
206        let mut conn = pool
207            .get_conn()
208            .await
209            .map_err(|e| IdbError::Io(format!("MySQL connection error: {}", e)))?;
210
211        let mut query = "SELECT NAME, SPACE, ROW_FORMAT FROM INFORMATION_SCHEMA.INNODB_TABLESPACES WHERE SPACE_TYPE = 'Single'".to_string();
212        if let Some(ref db) = opts.database {
213            let escaped = db.replace('\'', "''");
214            query.push_str(&format!(" AND NAME LIKE '{}/%'", escaped));
215        }
216
217        let rows: Vec<(String, u32, String)> = conn
218            .query(&query)
219            .await
220            .map_err(|e| IdbError::Io(format!("MySQL query error: {}", e)))?;
221
222        let mappings: Vec<TablespaceMapping> = rows
223            .into_iter()
224            .map(|(name, space_id, row_format)| TablespaceMapping {
225                name,
226                space_id,
227                row_format: Some(row_format),
228            })
229            .collect();
230
231        pool.disconnect().await.ok();
232        Ok::<_, IdbError>(mappings)
233    })?;
234
235    let report = cross_validate(disk_entries, &mysql_mappings);
236    output_validation_report(&report, opts.json, opts.verbose, writer)
237}
238
239#[cfg(feature = "mysql")]
240fn output_validation_report(
241    report: &crate::innodb::validate::ValidationReport,
242    json: bool,
243    verbose: bool,
244    writer: &mut dyn Write,
245) -> Result<(), IdbError> {
246    if json {
247        let json_str = serde_json::to_string_pretty(report)
248            .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
249        wprintln!(writer, "{}", json_str)?;
250    } else {
251        wprintln!(writer, "Cross-Validation Report")?;
252        wprintln!(writer, "  Disk files:         {}", report.disk_files)?;
253        wprintln!(writer, "  MySQL tablespaces:  {}", report.mysql_tablespaces)?;
254        wprintln!(writer)?;
255
256        if !report.orphans.is_empty() {
257            wprintln!(
258                writer,
259                "  {} (on disk, not in MySQL):",
260                "Orphan files".yellow()
261            )?;
262            for o in &report.orphans {
263                wprintln!(writer, "    {} (space_id={})", o.path, o.space_id)?;
264            }
265            wprintln!(writer)?;
266        }
267
268        if !report.missing.is_empty() {
269            wprintln!(
270                writer,
271                "  {} (in MySQL, not on disk):",
272                "Missing files".red()
273            )?;
274            for m in &report.missing {
275                wprintln!(writer, "    {} (space_id={})", m.name, m.space_id)?;
276            }
277            wprintln!(writer)?;
278        }
279
280        if !report.mismatches.is_empty() {
281            wprintln!(writer, "  {} :", "Space ID mismatches".red())?;
282            for m in &report.mismatches {
283                wprintln!(
284                    writer,
285                    "    {} : disk={}, mysql={} ({})",
286                    m.path,
287                    m.disk_space_id,
288                    m.mysql_space_id,
289                    m.mysql_name
290                )?;
291            }
292            wprintln!(writer)?;
293        }
294
295        if verbose && report.passed {
296            wprintln!(
297                writer,
298                "  All {} files match MySQL metadata.",
299                report.disk_files
300            )?;
301        }
302
303        let status = if report.passed {
304            "PASS".green().to_string()
305        } else {
306            "FAIL".red().to_string()
307        };
308        wprintln!(writer, "  Overall: {}", status)?;
309    }
310
311    if !report.passed {
312        return Err(IdbError::Parse("Validation failed".to_string()));
313    }
314
315    Ok(())
316}
317
318/// Deep table validation via MySQL (feature-gated).
319#[cfg(feature = "mysql")]
320fn execute_table_validate(opts: &ValidateOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
321    use mysql_async::prelude::*;
322
323    use crate::innodb::validate::{deep_validate_table, MysqlIndexInfo, TablespaceMapping};
324
325    let table_spec = opts.table.as_ref().unwrap();
326
327    // Parse table_name: accept "db.table" or "db/table"
328    let table_name = if table_spec.contains('.') {
329        table_spec.replacen('.', "/", 1)
330    } else if table_spec.contains('/') {
331        table_spec.to_string()
332    } else {
333        return Err(IdbError::Argument(format!(
334            "Invalid table format '{}'. Use db.table or db/table",
335            table_spec
336        )));
337    };
338
339    // Build MySQL config
340    let mut config = crate::util::mysql::MysqlConfig::default();
341
342    if let Some(ref df) = opts.defaults_file {
343        if let Some(parsed) = crate::util::mysql::parse_defaults_file(std::path::Path::new(df)) {
344            config = parsed;
345        }
346    } else if let Some(df) = crate::util::mysql::find_defaults_file() {
347        if let Some(parsed) = crate::util::mysql::parse_defaults_file(&df) {
348            config = parsed;
349        }
350    }
351
352    if let Some(ref h) = opts.host {
353        config.host = h.clone();
354    }
355    if let Some(p) = opts.port {
356        config.port = p;
357    }
358    if let Some(ref u) = opts.user {
359        config.user = u.clone();
360    }
361    if opts.password.is_some() {
362        config.password = opts.password.clone();
363    }
364
365    let datadir_path = std::path::Path::new(&opts.datadir);
366    if !datadir_path.is_dir() {
367        return Err(IdbError::Argument(format!(
368            "Data directory does not exist: {}",
369            opts.datadir
370        )));
371    }
372
373    let rt = tokio::runtime::Builder::new_current_thread()
374        .enable_all()
375        .build()
376        .map_err(|e| IdbError::Io(format!("Cannot create async runtime: {}", e)))?;
377
378    rt.block_on(async {
379        let pool = mysql_async::Pool::new(config.to_opts());
380        let mut conn = pool
381            .get_conn()
382            .await
383            .map_err(|e| IdbError::Io(format!("MySQL connection failed: {}", e)))?;
384
385        // Query tablespace mapping
386        let escaped_name = table_name.replace('\'', "''");
387        let ts_query = format!(
388            "SELECT NAME, SPACE, ROW_FORMAT FROM INFORMATION_SCHEMA.INNODB_TABLESPACES WHERE NAME = '{}'",
389            escaped_name
390        );
391        let ts_rows: Vec<(String, u32, String)> =
392            conn.query(&ts_query).await.unwrap_or_default();
393
394        if ts_rows.is_empty() {
395            pool.disconnect().await.ok();
396            return Err(IdbError::Argument(format!(
397                "Table '{}' not found in INFORMATION_SCHEMA.INNODB_TABLESPACES",
398                table_name
399            )));
400        }
401
402        let (name, space_id, row_format) = &ts_rows[0];
403        let mapping = TablespaceMapping {
404            name: name.clone(),
405            space_id: *space_id,
406            row_format: Some(row_format.clone()),
407        };
408
409        // Query index info
410        let idx_query = format!(
411            "SELECT I.NAME, I.TABLE_ID, I.SPACE, I.PAGE_NO \
412             FROM INFORMATION_SCHEMA.INNODB_INDEXES I \
413             JOIN INFORMATION_SCHEMA.INNODB_TABLES T ON I.TABLE_ID = T.TABLE_ID \
414             WHERE T.NAME = '{}'",
415            escaped_name
416        );
417        let idx_rows: Vec<(String, u64, u32, u64)> =
418            conn.query(&idx_query).await.unwrap_or_default();
419
420        let indexes: Vec<MysqlIndexInfo> = idx_rows
421            .into_iter()
422            .map(|(name, table_id, space_id, page_no)| MysqlIndexInfo {
423                name,
424                table_id,
425                space_id,
426                page_no: Some(page_no),
427            })
428            .collect();
429
430        pool.disconnect().await.ok();
431
432        // Run deep validation
433        let report = deep_validate_table(
434            datadir_path,
435            &table_name,
436            &mapping,
437            &indexes,
438            opts.page_size,
439            opts.mmap,
440        );
441
442        // Output results
443        if opts.json {
444            output_table_json(&report, writer)?;
445        } else {
446            output_table_text(&report, writer, opts.verbose)?;
447        }
448
449        Ok(())
450    })
451}
452
453#[cfg(feature = "mysql")]
454fn output_table_json(
455    report: &crate::innodb::validate::TableValidationReport,
456    writer: &mut dyn Write,
457) -> Result<(), IdbError> {
458    let json = serde_json::to_string_pretty(report)
459        .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
460    wprintln!(writer, "{}", json)?;
461    Ok(())
462}
463
464#[cfg(feature = "mysql")]
465fn output_table_text(
466    report: &crate::innodb::validate::TableValidationReport,
467    writer: &mut dyn Write,
468    verbose: bool,
469) -> Result<(), IdbError> {
470    use colored::Colorize;
471
472    wprintln!(
473        writer,
474        "{}",
475        format!("Table Validation: {}", report.table_name).bold()
476    )?;
477    wprintln!(writer)?;
478
479    if let Some(ref path) = report.file_path {
480        wprintln!(writer, "  File:           {}", path)?;
481    } else {
482        wprintln!(writer, "  File:           {}", "NOT FOUND".red())?;
483    }
484
485    wprintln!(writer, "  MySQL Space ID: {}", report.mysql_space_id)?;
486    if let Some(disk_id) = report.disk_space_id {
487        wprintln!(writer, "  Disk Space ID:  {}", disk_id)?;
488    } else {
489        wprintln!(writer, "  Disk Space ID:  {}", "N/A".dimmed())?;
490    }
491
492    if report.space_id_match {
493        wprintln!(writer, "  Space ID Match: {}", "YES".green())?;
494    } else {
495        wprintln!(writer, "  Space ID Match: {}", "NO".red())?;
496    }
497
498    if let Some(ref fmt) = report.mysql_row_format {
499        wprintln!(writer, "  Row Format:     {}", fmt)?;
500    }
501
502    wprintln!(writer)?;
503    wprintln!(
504        writer,
505        "  Indexes Verified: {}/{}",
506        report.indexes_verified,
507        report.indexes.len()
508    )?;
509
510    if verbose || !report.passed {
511        for idx in &report.indexes {
512            let status = if idx.root_page_valid {
513                "OK".green().to_string()
514            } else {
515                "FAIL".red().to_string()
516            };
517
518            let root = idx
519                .root_page
520                .map(|p| p.to_string())
521                .unwrap_or_else(|| "N/A".to_string());
522
523            wprintln!(writer, "    {} (root_page={}) [{}]", idx.name, root, status)?;
524
525            if let Some(ref msg) = idx.message {
526                wprintln!(writer, "      {}", msg)?;
527            }
528        }
529    }
530
531    wprintln!(writer)?;
532    if report.passed {
533        wprintln!(writer, "  Result: {}", "PASSED".green().bold())?;
534    } else {
535        wprintln!(writer, "  Result: {}", "FAILED".red().bold())?;
536    }
537
538    Ok(())
539}