Skip to main content

idb/cli/
timeline.rs

1//! CLI implementation for the `inno timeline` subcommand.
2
3use std::io::Write;
4
5use crate::cli::{csv_escape, wprintln};
6use crate::innodb::timeline::{
7    extract_binlog_timeline, extract_redo_timeline, extract_undo_timeline, merge_timeline,
8    TimelineAction, TimelineReport,
9};
10use crate::IdbError;
11
12/// Options for the `inno timeline` subcommand.
13pub struct TimelineOptions {
14    pub redo_log: Option<String>,
15    pub undo_file: Option<String>,
16    pub binlog: Option<String>,
17    pub file: Option<String>,
18    pub datadir: Option<String>,
19    pub space_id: Option<u32>,
20    pub page: Option<u64>,
21    pub table: Option<String>,
22    pub limit: Option<usize>,
23    pub verbose: bool,
24    pub json: bool,
25    pub page_size: Option<u32>,
26    pub keyring: Option<String>,
27}
28
29pub fn execute(opts: &TimelineOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
30    if opts.redo_log.is_none() && opts.undo_file.is_none() && opts.binlog.is_none() {
31        return Err(IdbError::Argument(
32            "At least one of --redo-log, --undo-file, or --binlog is required".to_string(),
33        ));
34    }
35
36    // Extract from each source
37    let redo_entries = if let Some(ref path) = opts.redo_log {
38        let mut log = crate::innodb::log::LogFile::open(path)?;
39        extract_redo_timeline(&mut log)?
40    } else {
41        Vec::new()
42    };
43
44    let undo_entries = if let Some(ref path) = opts.undo_file {
45        let mut ts = crate::cli::open_tablespace(path, opts.page_size, false)?;
46        if let Some(ref keyring_path) = opts.keyring {
47            crate::cli::setup_decryption(&mut ts, keyring_path)?;
48        }
49        extract_undo_timeline(&mut ts)?
50    } else {
51        Vec::new()
52    };
53
54    let binlog_entries = if let Some(ref path) = opts.binlog {
55        if opts.file.is_some() {
56            // Use enriched extraction for B+Tree correlation
57            let file = std::fs::File::open(path)
58                .map_err(|e| IdbError::Io(format!("Cannot open {}: {}", path, e)))?;
59            let reader = std::io::BufReader::new(file);
60            let mut result = crate::innodb::timeline::extract_binlog_timeline_enriched(reader)?;
61
62            // Correlate with tablespace if provided
63            if let Some(ref ibd_path) = opts.file {
64                let mut ts = crate::cli::open_tablespace(ibd_path, opts.page_size, false)?;
65                if let Some(ref keyring_path) = opts.keyring {
66                    crate::cli::setup_decryption(&mut ts, keyring_path)?;
67                }
68                let _correlated = crate::innodb::timeline::correlate_binlog_pages(
69                    &mut result.entries,
70                    &mut ts,
71                    &result.table_maps,
72                    &result.row_data,
73                )?;
74            }
75            result.entries
76        } else {
77            let file = std::fs::File::open(path)
78                .map_err(|e| IdbError::Io(format!("Cannot open {}: {}", path, e)))?;
79            let reader = std::io::BufReader::new(file);
80            extract_binlog_timeline(reader)?
81        }
82    } else {
83        Vec::new()
84    };
85
86    let mut report = merge_timeline(redo_entries, undo_entries, binlog_entries);
87
88    // Annotate binlog entries with space_id if a data directory is available
89    if let Some(ref datadir) = opts.datadir {
90        if let Ok(space_map) = crate::innodb::timeline::build_space_table_map(datadir) {
91            // Build reverse map: table_name -> space_id
92            let reverse: std::collections::HashMap<String, u32> = space_map
93                .into_iter()
94                .map(|(sid, name)| (name, sid))
95                .collect();
96            for entry in &mut report.entries {
97                if let crate::innodb::timeline::TimelineAction::Binlog {
98                    database, table, ..
99                } = &entry.action
100                {
101                    if let (Some(db), Some(tbl)) = (database, table) {
102                        let full = format!("{}.{}", db, tbl);
103                        if let Some(&sid) = reverse.get(&full) {
104                            entry.space_id = Some(sid);
105                        }
106                    }
107                }
108            }
109        }
110    }
111
112    // Apply filters
113    apply_filters(&mut report, opts);
114
115    if opts.json {
116        let json = serde_json::to_string_pretty(&report)
117            .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
118        wprintln!(writer, "{}", json)?;
119    } else {
120        write_text(&report, opts, writer)?;
121    }
122
123    Ok(())
124}
125
126fn apply_filters(report: &mut TimelineReport, opts: &TimelineOptions) {
127    if let Some(sid) = opts.space_id {
128        report.entries.retain(|e| e.space_id == Some(sid));
129    }
130    if let Some(pno) = opts.page {
131        report.entries.retain(|e| e.page_no == Some(pno as u32));
132    }
133    if let Some(ref table) = opts.table {
134        let lower = table.to_lowercase();
135        report.entries.retain(|e| match &e.action {
136            TimelineAction::Binlog {
137                database, table, ..
138            } => {
139                let db = database.as_deref().unwrap_or("");
140                let tbl = table.as_deref().unwrap_or("");
141                db.to_lowercase().contains(&lower) || tbl.to_lowercase().contains(&lower)
142            }
143            _ => true, // redo/undo entries don't have table names; keep them
144        });
145    }
146    if let Some(limit) = opts.limit {
147        report.entries.truncate(limit);
148    }
149}
150
151fn write_text(
152    report: &TimelineReport,
153    opts: &TimelineOptions,
154    writer: &mut dyn Write,
155) -> Result<(), IdbError> {
156    wprintln!(writer, "Transaction Timeline")?;
157
158    // Sources line
159    let mut sources = Vec::new();
160    if let Some(ref p) = opts.redo_log {
161        sources.push(format!("redo log ({})", short_name(p)));
162    }
163    if let Some(ref p) = opts.undo_file {
164        sources.push(format!("undo ({})", short_name(p)));
165    }
166    if let Some(ref p) = opts.binlog {
167        sources.push(format!("binlog ({})", short_name(p)));
168    }
169    wprintln!(writer, "  Sources: {}", sources.join(", "))?;
170    wprintln!(
171        writer,
172        "  Redo entries: {}  |  Undo entries: {}  |  Binlog entries: {}  |  Correlated: {}",
173        report.redo_count,
174        report.undo_count,
175        report.binlog_count,
176        report.correlated_count
177    )?;
178    wprintln!(writer)?;
179
180    // Entry table
181    wprintln!(
182        writer,
183        "  {:<6} {:<18} {:<8} {:<12} {}",
184        "SEQ",
185        "LSN",
186        "SOURCE",
187        "SPACE:PAGE",
188        "ACTION"
189    )?;
190
191    for entry in &report.entries {
192        let lsn_str = entry
193            .lsn
194            .map(|l| l.to_string())
195            .unwrap_or_else(|| "-".to_string());
196        let page_str = match (entry.space_id, entry.page_no) {
197            (Some(s), Some(p)) => format!("{}:{}", s, p),
198            (None, Some(p)) => format!("-:{}", p),
199            _ => "-".to_string(),
200        };
201        let action_str = format_action(&entry.action, opts.verbose);
202
203        wprintln!(
204            writer,
205            "  {:<6} {:<18} {:<8} {:<12} {}",
206            entry.seq,
207            lsn_str,
208            entry.source,
209            page_str,
210            action_str
211        )?;
212    }
213
214    // Page summary
215    if !report.page_summaries.is_empty() {
216        wprintln!(writer)?;
217        wprintln!(writer, "Page Summary:")?;
218        wprintln!(
219            writer,
220            "  {:<12} {:<6} {:<6} {:<8} {:<18} {}",
221            "SPACE:PAGE",
222            "REDO",
223            "UNDO",
224            "BINLOG",
225            "FIRST_LSN",
226            "LAST_LSN"
227        )?;
228        for ps in &report.page_summaries {
229            let first = ps
230                .first_lsn
231                .map(|l| l.to_string())
232                .unwrap_or_else(|| "-".to_string());
233            let last = ps
234                .last_lsn
235                .map(|l| l.to_string())
236                .unwrap_or_else(|| "-".to_string());
237            wprintln!(
238                writer,
239                "  {:<12} {:<6} {:<6} {:<8} {:<18} {}",
240                format!("{}:{}", ps.space_id, ps.page_no),
241                ps.redo_entries,
242                ps.undo_entries,
243                ps.binlog_entries,
244                first,
245                last
246            )?;
247        }
248    }
249
250    Ok(())
251}
252
253pub fn write_csv(report: &TimelineReport, writer: &mut dyn Write) -> Result<(), IdbError> {
254    wprintln!(
255        writer,
256        "seq,lsn,timestamp,source,space_id,page_no,action_type,details"
257    )?;
258    for entry in &report.entries {
259        let lsn = entry.lsn.map(|l| l.to_string()).unwrap_or_default();
260        let ts = entry.timestamp.map(|t| t.to_string()).unwrap_or_default();
261        let sid = entry.space_id.map(|s| s.to_string()).unwrap_or_default();
262        let pno = entry.page_no.map(|p| p.to_string()).unwrap_or_default();
263        let (action_type, details) = csv_action(&entry.action);
264
265        wprintln!(
266            writer,
267            "{},{},{},{},{},{},{},{}",
268            entry.seq,
269            lsn,
270            ts,
271            entry.source,
272            sid,
273            pno,
274            csv_escape(&action_type),
275            csv_escape(&details)
276        )?;
277    }
278    Ok(())
279}
280
281fn format_action(action: &TimelineAction, verbose: bool) -> String {
282    match action {
283        TimelineAction::Redo {
284            mlog_type,
285            single_rec,
286        } => {
287            if verbose {
288                format!("{} (single_rec={})", mlog_type, single_rec)
289            } else {
290                mlog_type.clone()
291            }
292        }
293        TimelineAction::Undo {
294            record_type,
295            trx_id,
296            table_id,
297            ..
298        } => {
299            if verbose {
300                format!("trx={} {} table_id={}", trx_id, record_type, table_id)
301            } else {
302                format!("trx={} {}", trx_id, record_type)
303            }
304        }
305        TimelineAction::Binlog {
306            event_type,
307            database,
308            table,
309            xid,
310            pk_values,
311        } => {
312            let mut s = event_type.clone();
313            if let Some(db) = database {
314                if let Some(tbl) = table {
315                    s.push_str(&format!(" {}.{}", db, tbl));
316                }
317            }
318            if let Some(x) = xid {
319                s.push_str(&format!(" (xid={})", x));
320            }
321            if let Some(pks) = pk_values {
322                s.push_str(&format!(" PK=({})", pks.join(", ")));
323            }
324            s
325        }
326    }
327}
328
329fn csv_action(action: &TimelineAction) -> (String, String) {
330    match action {
331        TimelineAction::Redo {
332            mlog_type,
333            single_rec,
334        } => (mlog_type.clone(), format!("single_rec={}", single_rec)),
335        TimelineAction::Undo {
336            record_type,
337            trx_id,
338            undo_no,
339            table_id,
340        } => (
341            record_type.clone(),
342            format!(
343                "trx_id={} undo_no={} table_id={}",
344                trx_id, undo_no, table_id
345            ),
346        ),
347        TimelineAction::Binlog {
348            event_type,
349            database,
350            table,
351            xid,
352            ..
353        } => {
354            let mut detail = String::new();
355            if let Some(db) = database {
356                detail.push_str(&format!("db={}", db));
357            }
358            if let Some(tbl) = table {
359                if !detail.is_empty() {
360                    detail.push(' ');
361                }
362                detail.push_str(&format!("table={}", tbl));
363            }
364            if let Some(x) = xid {
365                if !detail.is_empty() {
366                    detail.push(' ');
367                }
368                detail.push_str(&format!("xid={}", x));
369            }
370            (event_type.clone(), detail)
371        }
372    }
373}
374
375fn short_name(path: &str) -> &str {
376    std::path::Path::new(path)
377        .file_name()
378        .and_then(|s| s.to_str())
379        .unwrap_or(path)
380}