1use crate::planning::plan::explain::{PlanDescription, PlanNodeDescription};
9
10pub fn format_plan_as_table(plan_desc: &PlanDescription) -> String {
12 let mut output = String::new();
13
14 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 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 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 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 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 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 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 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
139pub 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"); 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 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 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 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
222pub fn format_plan_as_tree(plan_desc: &PlanDescription) -> String {
224 let mut output = String::new();
225
226 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 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 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 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 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
287fn 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 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 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 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 let mut new_visited = visited.clone();
335 new_visited.insert(node.id);
336
337 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
355fn 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
387fn escape_dot_label(s: &str) -> String {
389 format!("\"{}\"", s.replace('"', "\\\""))
390}
391
392fn 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
401pub fn format_plan_with_output_table(
403 plan_desc: &PlanDescription,
404) -> Result<String, serde_json::Error> {
405 let mut output = String::new();
407
408 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 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 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 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 output.push_str(&"-".repeat(total_width));
539 output.push('\n');
540
541 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 output.push_str(&"-".repeat(total_width));
552 output.push('\n');
553
554 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 output.push_str(&"-".repeat(total_width));
567 output.push('\n');
568
569 Ok(output)
570}
571
572pub 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}