1use std::io::Write;
8
9#[cfg(feature = "mysql")]
10use colored::Colorize;
11use serde::Serialize;
12
13use crate::cli::wprintln;
14use crate::IdbError;
15
16pub struct ValidateOptions {
18 pub datadir: String,
20 pub database: Option<String>,
22 pub table: Option<String>,
24 pub host: Option<String>,
26 pub port: Option<u16>,
28 pub user: Option<String>,
30 pub password: Option<String>,
32 pub defaults_file: Option<String>,
34 pub json: bool,
36 pub verbose: bool,
38 pub page_size: Option<u32>,
40 pub depth: Option<u32>,
42 pub mmap: bool,
44}
45
46#[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
59pub fn execute(opts: &ValidateOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
61 #[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 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 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#[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 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#[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 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 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 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 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 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 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}