Skip to main content

linkrs_query/executor/explain/
format.rs

1//! Plan Formatting Utilities
2//!
3//! Provides formatting for query plan descriptions in different output formats:
4//! - Table format (default): Human-readable tabular output
5//! - Dot format: Graphviz DOT format for visualization
6//! - Tree format: Hierarchical tree structure showing plan relationships
7
8use crate::planning::plan::explain::{PlanDescription, PlanNodeDescription};
9
10/// Format plan description as a table
11pub fn format_plan_as_table(plan_desc: &PlanDescription) -> String {
12    let mut output = String::new();
13
14    // Header with plan-level metadata
15    output.push_str("Execution Model: Batch Pull\n");
16    if let Some(ref part) = plan_desc.partition_spec_description {
17        output.push_str(&format!("Partitioning: {}\n", part));
18    }
19    // parallel configuration
20    output.push_str(&format!(
21        "Parallel Workers: requested={}",
22        plan_desc.requested_workers,
23    ));
24    if plan_desc.actual_workers > 0 {
25        output.push_str(&format!(
26            ", actual={}, wall_time={}us, work_time={}us, buffered_chunks_peak={}, buffered_bytes_peak={}",
27            plan_desc.actual_workers,
28            plan_desc.parallel_wall_time_us,
29            plan_desc.parallel_work_time_us,
30            plan_desc.parallel_buffered_chunks_peak,
31            plan_desc.parallel_buffered_bytes_peak,
32        ));
33    }
34    if !plan_desc.parallel_fallback_reason.is_empty() {
35        output.push_str(&format!(
36            ", fallback_reason=\"{}\"",
37            plan_desc.parallel_fallback_reason,
38        ));
39    }
40    if !plan_desc.cbo_notes.is_empty() {
41        output.push_str("\nCBO decisions: ");
42        output.push_str(&plan_desc.cbo_notes.join("; "));
43    }
44    if !plan_desc.statistics_summary.is_empty() {
45        output.push_str(&format!("\nStatistics: {}", plan_desc.statistics_summary));
46    }
47    if !plan_desc.columnar_summary.is_empty() {
48        output.push_str(&format!("\nColumnar: {}", plan_desc.columnar_summary));
49    }
50    output.push('\n');
51    output.push('\n');
52
53    // Header with clear column names
54    output.push_str("+------+------------------+------------+--------------------------------------------------+--------------------------------------------------+------------------+\n");
55    output.push_str("| id   | name             | deps       | profiling_data                                    | operator_info                                    | output_var       |\n");
56    output.push_str("+------+------------------+------------+--------------------------------------------------+--------------------------------------------------+------------------+\n");
57
58    // Rows
59    for node in &plan_desc.plan_node_descs {
60        let id = format!("{:>4}", node.id);
61        let name = truncate_or_pad(&node.name, 16);
62
63        // Format dependencies
64        let deps = node
65            .dependencies
66            .as_ref()
67            .map(|d| {
68                if d.is_empty() {
69                    "-".to_string()
70                } else {
71                    d.iter()
72                        .map(|id| id.to_string())
73                        .collect::<Vec<_>>()
74                        .join(",")
75                }
76            })
77            .unwrap_or_else(|| "-".to_string());
78        let deps = truncate_or_pad(&deps, 10);
79
80        // Format profiling data
81        let profile = if let Some(ref profiles) = node.profiles {
82            profiles
83                .iter()
84                .map(|p| {
85                    let base = format!("rows:{},time:{}us", p.rows, p.exec_duration_in_us);
86                    match (
87                        p.other_stats.get("spilled_bytes"),
88                        p.other_stats.get("spill_count"),
89                        p.other_stats.get("spilled_rows"),
90                    ) {
91                        (Some(b), Some(c), Some(r)) if b != "0" || c != "0" || r != "0" => {
92                            format!(
93                                "{},spilled_rows:{},spilled_bytes:{},spill_count:{}",
94                                base, r, b, c
95                            )
96                        }
97                        _ => base,
98                    }
99                })
100                .collect::<Vec<_>>()
101                .join(";")
102        } else {
103            "-".to_string()
104        };
105        let profile = truncate_or_pad(&profile, 48);
106
107        // Format operator info
108        let info = node
109            .description
110            .as_ref()
111            .map(|descs| {
112                descs
113                    .iter()
114                    .map(|p| format!("{}:{}", p.key, p.value))
115                    .collect::<Vec<_>>()
116                    .join(",")
117            })
118            .unwrap_or_else(|| "-".to_string());
119        let info = truncate_or_pad(&info, 48);
120
121        // Format output variable
122        let output_var_str = if node.output_var.is_empty() {
123            "-"
124        } else {
125            &node.output_var
126        };
127        let output_var = truncate_or_pad(output_var_str, 16);
128
129        output.push_str(&format!(
130            "| {} | {} | {} | {} | {} | {} |\n",
131            id, name, deps, profile, info, output_var
132        ));
133        output.push_str("+------+------------------+------------+------------------+--------------------------------------------------+------------------+\n");
134    }
135
136    output
137}
138
139/// Format plan description as DOT (Graphviz) format
140pub fn format_plan_as_dot(plan_desc: &PlanDescription) -> String {
141    let mut output = String::new();
142
143    output.push_str("digraph G {\n");
144    output.push_str("    rankdir=BT;\n"); // Bottom to top layout for better flow visualization
145    let parallel_str = if plan_desc.actual_workers > 0 {
146        format!(
147            "Parallel: {} workers, wall={}us",
148            plan_desc.actual_workers, plan_desc.parallel_wall_time_us,
149        )
150    } else if !plan_desc.parallel_fallback_reason.is_empty() {
151        format!(
152            "Parallel: fallback (requested={}, {})",
153            plan_desc.requested_workers, plan_desc.parallel_fallback_reason,
154        )
155    } else {
156        format!("Parallel: requested={}", plan_desc.requested_workers)
157    };
158    output.push_str(&format!(
159        "    label=\"Execution Model: Batch Pull | {} | {}\";\n",
160        plan_desc
161            .partition_spec_description
162            .as_deref()
163            .unwrap_or("no partitioning"),
164        parallel_str,
165    ));
166    output.push_str("    labelloc=t;\n");
167    output.push_str("    fontsize=12;\n");
168    output
169        .push_str("    node[shape=box, style=filled, fillcolor=lightblue, fontname=\"Arial\"];\n");
170    output.push_str("    edge[arrowhead=none, fontname=\"Arial\"];\n\n");
171
172    // Find root nodes (nodes that are not dependencies of any other node)
173    let mut all_deps = std::collections::HashSet::new();
174    for node in &plan_desc.plan_node_descs {
175        if let Some(ref deps) = node.dependencies {
176            for dep_id in deps {
177                all_deps.insert(*dep_id);
178            }
179        }
180    }
181
182    // Nodes
183    for node in &plan_desc.plan_node_descs {
184        let label = format_plan_node_label(node);
185        let is_root = !all_deps.contains(&node.id);
186        let fillcolor = if is_root { "lightgreen" } else { "lightblue" };
187        output.push_str(&format!(
188            "    {}[label={}, fillcolor={}];\n",
189            node.id,
190            escape_dot_label(&label),
191            fillcolor
192        ));
193    }
194
195    output.push('\n');
196
197    // Edges with labels showing the relationship
198    for node in &plan_desc.plan_node_descs {
199        if let Some(ref deps) = node.dependencies {
200            for (idx, dep_id) in deps.iter().enumerate() {
201                let edge_label = if deps.len() > 1 {
202                    format!("label=\"input{}\"", idx + 1)
203                } else {
204                    "".to_string()
205                };
206                if edge_label.is_empty() {
207                    output.push_str(&format!("    {} -> {};\n", node.id, dep_id));
208                } else {
209                    output.push_str(&format!(
210                        "    {} -> {} [{}];\n",
211                        node.id, dep_id, edge_label
212                    ));
213                }
214            }
215        }
216    }
217
218    output.push('}');
219    output
220}
221
222/// Format plan description as a tree structure
223pub fn format_plan_as_tree(plan_desc: &PlanDescription) -> String {
224    let mut output = String::new();
225
226    // Build a map of node id to node for quick lookup
227    let node_map: std::collections::HashMap<i64, &PlanNodeDescription> = plan_desc
228        .plan_node_descs
229        .iter()
230        .map(|n| (n.id, n))
231        .collect();
232
233    // Build parent relationships
234    let mut parent_map: std::collections::HashMap<i64, Vec<i64>> = std::collections::HashMap::new();
235    for node in &plan_desc.plan_node_descs {
236        if let Some(ref deps) = node.dependencies {
237            for dep_id in deps {
238                parent_map.entry(*dep_id).or_default().push(node.id);
239            }
240        }
241    }
242
243    // Find root nodes (nodes that are not dependencies of any other node)
244    let mut root_nodes: Vec<&PlanNodeDescription> = Vec::new();
245    for node in &plan_desc.plan_node_descs {
246        let is_root = parent_map
247            .get(&node.id)
248            .map(|v| v.is_empty())
249            .unwrap_or(true)
250            && node
251                .dependencies
252                .as_ref()
253                .map(|d| !d.is_empty())
254                .unwrap_or(false);
255        if is_root || !parent_map.contains_key(&node.id) {
256            root_nodes.push(node);
257        }
258    }
259
260    // If no clear root found, use nodes with no parents
261    if root_nodes.is_empty() {
262        for node in &plan_desc.plan_node_descs {
263            if !parent_map.contains_key(&node.id) {
264                root_nodes.push(node);
265            }
266        }
267    }
268
269    // Format tree starting from root nodes
270    for (idx, root) in root_nodes.iter().enumerate() {
271        if idx > 0 {
272            output.push('\n');
273        }
274        format_tree_node(
275            &mut output,
276            root,
277            &node_map,
278            0,
279            true,
280            &std::collections::HashSet::new(),
281        );
282    }
283
284    output
285}
286
287/// Recursively format a tree node
288fn format_tree_node(
289    output: &mut String,
290    node: &PlanNodeDescription,
291    node_map: &std::collections::HashMap<i64, &PlanNodeDescription>,
292    depth: usize,
293    is_last: bool,
294    visited: &std::collections::HashSet<i64>,
295) {
296    // Check for cycles
297    if visited.contains(&node.id) {
298        let indent = "  ".repeat(depth);
299        output.push_str(&format!(
300            "{}[{}] {} (cycle detected)\n",
301            indent, node.id, node.name
302        ));
303        return;
304    }
305
306    // Format current node
307    let _indent = if depth > 0 {
308        let prefix = "  ".repeat(depth - 1);
309        if is_last {
310            format!("{}└── ", prefix)
311        } else {
312            format!("{}├── ", prefix)
313        }
314    } else {
315        String::new()
316    };
317
318    // Build node info string
319    let mut info_parts = vec![format!("[{}] {}", node.id, node.name)];
320
321    if let Some(ref desc) = node.description {
322        for pair in desc.iter().take(3) {
323            info_parts.push(format!("{}:{}", pair.key, pair.value));
324        }
325    }
326
327    if !node.output_var.is_empty() {
328        info_parts.push(format!("-> {}", node.output_var));
329    }
330
331    output.push_str(&format!("{}\n", info_parts.join(" | ")));
332
333    // Mark as visited
334    let mut new_visited = visited.clone();
335    new_visited.insert(node.id);
336
337    // Format children (dependencies)
338    if let Some(ref deps) = node.dependencies {
339        let dep_count = deps.len();
340        for (idx, dep_id) in deps.iter().enumerate() {
341            if let Some(dep_node) = node_map.get(dep_id) {
342                format_tree_node(
343                    output,
344                    dep_node,
345                    node_map,
346                    depth + 1,
347                    idx == dep_count - 1,
348                    &new_visited,
349                );
350            }
351        }
352    }
353}
354
355/// Format a single plan node label for DOT output
356fn format_plan_node_label(node: &PlanNodeDescription) -> String {
357    let mut lines = vec![node.name.clone()];
358
359    if let Some(ref profiles) = node.profiles {
360        for profile in profiles {
361            lines.push(format!("rows: {}", profile.rows));
362            lines.push(format!("time: {}us", profile.exec_duration_in_us));
363            match (
364                profile.other_stats.get("spilled_bytes"),
365                profile.other_stats.get("spill_count"),
366                profile.other_stats.get("spilled_rows"),
367            ) {
368                (Some(b), Some(c), Some(r)) if b != "0" || c != "0" || r != "0" => {
369                    lines.push(format!("spilled_rows: {}", r));
370                    lines.push(format!("spilled_bytes: {}", b));
371                    lines.push(format!("spill_count: {}", c));
372                }
373                _ => {}
374            }
375        }
376    }
377
378    if let Some(ref desc) = node.description {
379        for pair in desc {
380            lines.push(format!("{}: {}", pair.key, pair.value));
381        }
382    }
383
384    lines.join("\\n")
385}
386
387/// Escape a string for use in DOT label
388fn escape_dot_label(s: &str) -> String {
389    format!("\"{}\"", s.replace('"', "\\\""))
390}
391
392/// Truncate or pad a string to fit in a fixed-width column
393fn truncate_or_pad(s: &str, width: usize) -> String {
394    if s.len() > width {
395        format!("{}...", &s[..width.saturating_sub(3)])
396    } else {
397        format!("{:width$}", s, width = width)
398    }
399}
400
401/// Format plan description using simple table formatter
402pub fn format_plan_with_output_table(
403    plan_desc: &PlanDescription,
404) -> Result<String, serde_json::Error> {
405    // Use the built-in table formatter
406    let mut output = String::new();
407
408    // Plan-level metadata header
409    output.push_str("Execution Model: Batch Pull\n");
410    if let Some(ref part) = plan_desc.partition_spec_description {
411        output.push_str(&format!("Partitioning: {}\n", part));
412    }
413    output.push_str(&format!(
414        "Parallel Workers: requested={}",
415        plan_desc.requested_workers,
416    ));
417    if plan_desc.actual_workers > 0 {
418        output.push_str(&format!(
419            ", actual={}, wall_time={}us, work_time={}us, buffered_chunks_peak={}, buffered_bytes_peak={}",
420            plan_desc.actual_workers,
421            plan_desc.parallel_wall_time_us,
422            plan_desc.parallel_work_time_us,
423            plan_desc.parallel_buffered_chunks_peak,
424            plan_desc.parallel_buffered_bytes_peak,
425        ));
426    }
427    if !plan_desc.parallel_fallback_reason.is_empty() {
428        output.push_str(&format!(
429            ", fallback_reason=\"{}\"",
430            plan_desc.parallel_fallback_reason,
431        ));
432    }
433    if !plan_desc.statistics_summary.is_empty() {
434        output.push_str(&format!("\nStatistics: {}", plan_desc.statistics_summary));
435    }
436    output.push('\n');
437    output.push('\n');
438
439    // Calculate column widths
440    let headers = [
441        "id",
442        "name",
443        "deps",
444        "profiling_data",
445        "operator_info",
446        "output_var",
447    ];
448    let mut widths = headers.iter().map(|h| h.len()).collect::<Vec<_>>();
449
450    let rows: Vec<Vec<String>> = plan_desc
451        .plan_node_descs
452        .iter()
453        .map(|node| {
454            let id = node.id.to_string();
455            let deps = node
456                .dependencies
457                .as_ref()
458                .map(|d| {
459                    if d.is_empty() {
460                        "-".to_string()
461                    } else {
462                        d.iter()
463                            .map(|id| id.to_string())
464                            .collect::<Vec<_>>()
465                            .join(",")
466                    }
467                })
468                .unwrap_or_else(|| "-".to_string());
469            let profile = if let Some(ref profiles) = node.profiles {
470                profiles
471                    .iter()
472                    .map(|p| {
473                        let base = format!("rows:{},time:{}us", p.rows, p.exec_duration_in_us);
474                        match (
475                            p.other_stats.get("spilled_bytes"),
476                            p.other_stats.get("spill_count"),
477                            p.other_stats.get("spilled_rows"),
478                        ) {
479                            (Some(b), Some(c), Some(r)) if b != "0" || c != "0" || r != "0" => {
480                                format!(
481                                    "{},spilled_rows:{},spilled_bytes:{},spill_count:{}",
482                                    base, r, b, c
483                                )
484                            }
485                            _ => base,
486                        }
487                    })
488                    .collect::<Vec<_>>()
489                    .join(";")
490            } else {
491                "-".to_string()
492            };
493            let info = node
494                .description
495                .as_ref()
496                .map(|descs| {
497                    descs
498                        .iter()
499                        .map(|p| format!("{}:{}", p.key, p.value))
500                        .collect::<Vec<_>>()
501                        .join(",")
502                })
503                .unwrap_or_else(|| "-".to_string());
504            let output_var_str = if node.output_var.is_empty() {
505                "-".to_string()
506            } else {
507                node.output_var.clone()
508            };
509            vec![id, node.name.clone(), deps, profile, info, output_var_str]
510        })
511        .collect();
512
513    // Update column widths based on content
514    for row in &rows {
515        for (i, cell) in row.iter().enumerate() {
516            if i < widths.len() {
517                widths[i] = widths[i].max(cell.len());
518            }
519        }
520    }
521
522    let total_width = widths.iter().sum::<usize>() + widths.len() * 3 + 1;
523
524    // Helper function to format a cell
525    fn format_cell(content: &str, width: usize) -> String {
526        if content.len() > width {
527            if width > 3 {
528                format!("{}...", &content[..width - 3])
529            } else {
530                content[..width].to_string()
531            }
532        } else {
533            format!("{:width$}", content, width = width)
534        }
535    }
536
537    // Top border
538    output.push_str(&"-".repeat(total_width));
539    output.push('\n');
540
541    // Headers
542    output.push_str("| ");
543    for (i, header) in headers.iter().enumerate() {
544        let width = widths.get(i).copied().unwrap_or(10);
545        output.push_str(&format_cell(header, width));
546        output.push_str(" | ");
547    }
548    output.push('\n');
549
550    // Header separator
551    output.push_str(&"-".repeat(total_width));
552    output.push('\n');
553
554    // Rows
555    for row in &rows {
556        output.push_str("| ");
557        for (i, cell) in row.iter().enumerate() {
558            let width = widths.get(i).copied().unwrap_or(10);
559            output.push_str(&format_cell(cell, width));
560            output.push_str(" | ");
561        }
562        output.push('\n');
563    }
564
565    // Bottom border
566    output.push_str(&"-".repeat(total_width));
567    output.push('\n');
568
569    Ok(output)
570}
571
572/// Format plan description as JSON
573pub fn format_plan_as_json(
574    plan_desc: &PlanDescription,
575    pretty: bool,
576) -> Result<String, serde_json::Error> {
577    use serde::Serialize;
578
579    #[derive(Serialize)]
580    struct SerializablePlanDescription {
581        format: String,
582        execution_model: &'static str,
583        partition_spec_description: Option<String>,
584        requested_workers: usize,
585        actual_workers: usize,
586        parallel_wall_time_us: u64,
587        parallel_work_time_us: u64,
588        parallel_buffered_chunks_peak: usize,
589        parallel_buffered_bytes_peak: usize,
590        parallel_fallback_reason: String,
591        statistics_summary: String,
592        plan_node_descs: Vec<SerializablePlanNodeDescription>,
593    }
594
595    #[derive(Serialize)]
596    struct SerializablePlanNodeDescription {
597        id: i64,
598        name: String,
599        dependencies: Option<Vec<i64>>,
600        profiles: Option<Vec<SerializableProfilingStats>>,
601        description: Option<Vec<SerializableKeyValue>>,
602        output_var: String,
603    }
604
605    #[derive(Serialize)]
606    struct SerializableProfilingStats {
607        rows: i64,
608        exec_duration_in_us: i64,
609        total_duration_in_us: i64,
610        spilled_rows: u64,
611        spilled_bytes: u64,
612        spill_count: u64,
613    }
614
615    #[derive(Serialize)]
616    struct SerializableKeyValue {
617        key: String,
618        value: String,
619    }
620
621    let serializable_nodes: Vec<SerializablePlanNodeDescription> = plan_desc
622        .plan_node_descs
623        .iter()
624        .map(|node| SerializablePlanNodeDescription {
625            id: node.id,
626            name: node.name.clone(),
627            dependencies: node.dependencies.clone(),
628            profiles: node.profiles.as_ref().map(|profiles| {
629                profiles
630                    .iter()
631                    .map(|p| SerializableProfilingStats {
632                        rows: p.rows,
633                        exec_duration_in_us: p.exec_duration_in_us,
634                        total_duration_in_us: p.total_duration_in_us,
635                        spilled_rows: p
636                            .other_stats
637                            .get("spilled_rows")
638                            .and_then(|v| v.parse().ok())
639                            .unwrap_or(0),
640                        spilled_bytes: p
641                            .other_stats
642                            .get("spilled_bytes")
643                            .and_then(|v| v.parse().ok())
644                            .unwrap_or(0),
645                        spill_count: p
646                            .other_stats
647                            .get("spill_count")
648                            .and_then(|v| v.parse().ok())
649                            .unwrap_or(0),
650                    })
651                    .collect()
652            }),
653            description: node.description.as_ref().map(|descs| {
654                descs
655                    .iter()
656                    .map(|p| SerializableKeyValue {
657                        key: p.key.clone(),
658                        value: p.value.clone(),
659                    })
660                    .collect()
661            }),
662            output_var: node.output_var.clone(),
663        })
664        .collect();
665
666    let serializable_plan = SerializablePlanDescription {
667        format: plan_desc.format.clone(),
668        execution_model: "batch_pull",
669        partition_spec_description: plan_desc.partition_spec_description.clone(),
670        requested_workers: plan_desc.requested_workers,
671        actual_workers: plan_desc.actual_workers,
672        parallel_wall_time_us: plan_desc.parallel_wall_time_us,
673        parallel_work_time_us: plan_desc.parallel_work_time_us,
674        parallel_buffered_chunks_peak: plan_desc.parallel_buffered_chunks_peak,
675        parallel_buffered_bytes_peak: plan_desc.parallel_buffered_bytes_peak,
676        parallel_fallback_reason: plan_desc.parallel_fallback_reason.clone(),
677        statistics_summary: plan_desc.statistics_summary.clone(),
678        plan_node_descs: serializable_nodes,
679    };
680
681    if pretty {
682        serde_json::to_string_pretty(&serializable_plan)
683    } else {
684        serde_json::to_string(&serializable_plan)
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use crate::planning::plan::explain::PlanNodeDescription;
692
693    #[test]
694    fn test_format_plan_as_table() {
695        let mut plan_desc = PlanDescription::new();
696        let node = PlanNodeDescription::new("ScanVertices", 1).with_description("table", "Person");
697        plan_desc.add_node_desc(node);
698
699        let output = format_plan_as_table(&plan_desc);
700        assert!(output.contains("ScanVertices"));
701        assert!(output.contains("Person"));
702        assert!(output.contains("Parallel Workers: requested=1"));
703    }
704
705    #[test]
706    fn test_format_plan_as_table_with_p8_profile() {
707        let mut plan_desc = PlanDescription::new();
708        plan_desc.requested_workers = 4;
709        plan_desc.actual_workers = 2;
710        plan_desc.parallel_wall_time_us = 12345;
711        plan_desc.parallel_work_time_us = 23456;
712        plan_desc.parallel_buffered_chunks_peak = 5;
713        plan_desc.parallel_buffered_bytes_peak = 65536;
714        let node = PlanNodeDescription::new("ScanVertices", 1);
715        plan_desc.add_node_desc(node);
716
717        let output = format_plan_as_table(&plan_desc);
718        assert!(output.contains("requested=4"));
719        assert!(output.contains("actual=2"));
720        assert!(output.contains("wall_time=12345us"));
721        assert!(output.contains("work_time=23456us"));
722        assert!(output.contains("buffered_chunks_peak=5"));
723        assert!(output.contains("buffered_bytes_peak=65536"));
724    }
725
726    #[test]
727    fn test_format_plan_as_table_with_p8_fallback() {
728        let mut plan_desc = PlanDescription::new();
729        plan_desc.requested_workers = 4;
730        plan_desc.actual_workers = 0;
731        plan_desc.parallel_fallback_reason = "parallel disabled (max_workers=4)".to_string();
732        let node = PlanNodeDescription::new("ScanVertices", 1);
733        plan_desc.add_node_desc(node);
734
735        let output = format_plan_as_table(&plan_desc);
736        assert!(output.contains("requested=4"));
737        assert!(output.contains("fallback_reason"));
738        assert!(output.contains("max_workers"));
739    }
740
741    #[test]
742    fn test_format_plan_as_dot() {
743        let mut plan_desc = PlanDescription::new();
744        let node = PlanNodeDescription::new("ScanVertices", 1).with_description("table", "Person");
745        plan_desc.add_node_desc(node);
746
747        let output = format_plan_as_dot(&plan_desc);
748        assert!(output.contains("digraph G"));
749        assert!(output.contains("ScanVertices"));
750    }
751
752    #[test]
753    fn test_format_plan_as_dot_with_p8() {
754        let mut plan_desc = PlanDescription::new();
755        plan_desc.requested_workers = 2;
756        plan_desc.actual_workers = 2;
757        plan_desc.parallel_wall_time_us = 9999;
758        let node = PlanNodeDescription::new("ScanVertices", 1);
759        plan_desc.add_node_desc(node);
760
761        let output = format_plan_as_dot(&plan_desc);
762        assert!(output.contains("Parallel: 2 workers"));
763        assert!(output.contains("wall=9999us"));
764    }
765
766    #[test]
767    fn test_format_json_includes_p8_fields() {
768        let mut plan_desc = PlanDescription::new();
769        plan_desc.requested_workers = 2;
770        plan_desc.actual_workers = 2;
771        plan_desc.parallel_wall_time_us = 100;
772        plan_desc.parallel_work_time_us = 200;
773        plan_desc.parallel_buffered_chunks_peak = 3;
774        plan_desc.parallel_buffered_bytes_peak = 4096;
775        let node = PlanNodeDescription::new("Scan", 1);
776        plan_desc.add_node_desc(node);
777
778        let json = format_plan_as_json(&plan_desc, false).expect("json output");
779        assert!(json.contains("\"requested_workers\":2"));
780        assert!(json.contains("\"actual_workers\":2"));
781        assert!(json.contains("\"parallel_wall_time_us\":100"));
782        assert!(json.contains("\"parallel_work_time_us\":200"));
783        assert!(json.contains("\"parallel_buffered_chunks_peak\":3"));
784        assert!(json.contains("\"parallel_buffered_bytes_peak\":4096"));
785    }
786
787    #[test]
788    fn test_format_plan_as_table_with_statistics_summary() {
789        let mut plan_desc = PlanDescription::new();
790        plan_desc.statistics_summary = "sample_limit=500, min_epoch_delta=10".to_string();
791        let node = PlanNodeDescription::new("ScanVertices", 1);
792        plan_desc.add_node_desc(node);
793
794        let output = format_plan_as_table(&plan_desc);
795        assert!(output.contains("Statistics: sample_limit=500, min_epoch_delta=10"));
796
797        let empty = format_plan_as_table(&PlanDescription::new());
798        assert!(!empty.contains("Statistics:"));
799    }
800
801    #[test]
802    fn test_truncate_or_pad() {
803        assert_eq!(truncate_or_pad("short", 10), "short     ");
804        assert_eq!(truncate_or_pad("very long string", 10), "very lo...");
805    }
806}