Skip to main content

oxirs_arq/
query_plan_export.rs

1//! # Query Plan Export
2//!
3//! This module provides functionality to export SPARQL query execution plans
4//! to various formats for analysis, visualization, and debugging.
5//!
6//! ## Supported Formats
7//!
8//! - **JSON**: Machine-readable format for tooling integration
9//! - **DOT (Graphviz)**: Graph visualization for plan structure
10//! - **Mermaid**: Web-friendly diagram format
11//! - **Text**: Human-readable text representation
12//! - **YAML**: Configuration-friendly format
13//! - **HTML**: Interactive web visualization
14//!
15//! ## Quick Start
16//!
17//! ```rust,ignore
18//! use oxirs_arq::query_plan_export::{
19//!     QueryPlanExporter, ExportFormat, PlanNode,
20//! };
21//!
22//! // Create a plan exporter
23//! let exporter = QueryPlanExporter::new();
24//!
25//! // Build a plan tree
26//! let plan = PlanNode::scan("?s ?p ?o");
27//!
28//! // Export to various formats
29//! let json = exporter.export(&plan, ExportFormat::Json)?;
30//! let dot = exporter.export(&plan, ExportFormat::Dot)?;
31//! ```
32
33use std::collections::HashMap;
34use std::fmt;
35
36/// Supported export formats
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum ExportFormat {
39    /// JSON format
40    Json,
41    /// DOT (Graphviz) format
42    Dot,
43    /// Mermaid diagram format
44    Mermaid,
45    /// Plain text format
46    Text,
47    /// YAML format
48    Yaml,
49    /// HTML format with embedded visualization
50    Html,
51}
52
53impl fmt::Display for ExportFormat {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        match self {
56            Self::Json => write!(f, "JSON"),
57            Self::Dot => write!(f, "DOT"),
58            Self::Mermaid => write!(f, "Mermaid"),
59            Self::Text => write!(f, "Text"),
60            Self::Yaml => write!(f, "YAML"),
61            Self::Html => write!(f, "HTML"),
62        }
63    }
64}
65
66impl ExportFormat {
67    /// Get file extension for format
68    pub fn extension(&self) -> &'static str {
69        match self {
70            Self::Json => "json",
71            Self::Dot => "dot",
72            Self::Mermaid => "md",
73            Self::Text => "txt",
74            Self::Yaml => "yaml",
75            Self::Html => "html",
76        }
77    }
78
79    /// Get MIME type for format
80    pub fn mime_type(&self) -> &'static str {
81        match self {
82            Self::Json => "application/json",
83            Self::Dot => "text/vnd.graphviz",
84            Self::Mermaid => "text/markdown",
85            Self::Text => "text/plain",
86            Self::Yaml => "application/x-yaml",
87            Self::Html => "text/html",
88        }
89    }
90}
91
92/// Plan node operator type
93#[derive(Debug, Clone, PartialEq, Eq, Hash)]
94pub enum OperatorType {
95    /// Triple pattern scan
96    Scan,
97    /// Hash join
98    HashJoin,
99    /// Merge join
100    MergeJoin,
101    /// Nested loop join
102    NestedLoopJoin,
103    /// Index join
104    IndexJoin,
105    /// Filter operation
106    Filter,
107    /// Projection (SELECT)
108    Project,
109    /// Distinct operation
110    Distinct,
111    /// Order by operation
112    OrderBy,
113    /// Limit operation
114    Limit,
115    /// Offset operation
116    Offset,
117    /// Group by operation
118    GroupBy,
119    /// Aggregation
120    Aggregate,
121    /// Union operation
122    Union,
123    /// Optional pattern
124    Optional,
125    /// Minus operation
126    Minus,
127    /// Service (federation)
128    Service,
129    /// Graph pattern
130    Graph,
131    /// Bind operation
132    Bind,
133    /// Values clause
134    Values,
135    /// Property path
136    PropertyPath,
137    /// Subquery
138    Subquery,
139    /// Custom operator
140    Custom(String),
141}
142
143impl fmt::Display for OperatorType {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            Self::Scan => write!(f, "Scan"),
147            Self::HashJoin => write!(f, "HashJoin"),
148            Self::MergeJoin => write!(f, "MergeJoin"),
149            Self::NestedLoopJoin => write!(f, "NestedLoopJoin"),
150            Self::IndexJoin => write!(f, "IndexJoin"),
151            Self::Filter => write!(f, "Filter"),
152            Self::Project => write!(f, "Project"),
153            Self::Distinct => write!(f, "Distinct"),
154            Self::OrderBy => write!(f, "OrderBy"),
155            Self::Limit => write!(f, "Limit"),
156            Self::Offset => write!(f, "Offset"),
157            Self::GroupBy => write!(f, "GroupBy"),
158            Self::Aggregate => write!(f, "Aggregate"),
159            Self::Union => write!(f, "Union"),
160            Self::Optional => write!(f, "Optional"),
161            Self::Minus => write!(f, "Minus"),
162            Self::Service => write!(f, "Service"),
163            Self::Graph => write!(f, "Graph"),
164            Self::Bind => write!(f, "Bind"),
165            Self::Values => write!(f, "Values"),
166            Self::PropertyPath => write!(f, "PropertyPath"),
167            Self::Subquery => write!(f, "Subquery"),
168            Self::Custom(name) => write!(f, "{}", name),
169        }
170    }
171}
172
173/// Cost estimate for a plan node
174#[derive(Debug, Clone, Default)]
175pub struct CostEstimate {
176    /// Estimated row count
177    pub estimated_rows: f64,
178    /// Estimated cost (abstract units)
179    pub estimated_cost: f64,
180    /// Estimated memory usage in bytes
181    pub estimated_memory: usize,
182    /// Estimated I/O operations
183    pub estimated_io: usize,
184}
185
186/// Execution statistics for a plan node (actual execution)
187#[derive(Debug, Clone, Default)]
188pub struct ExecutionStats {
189    /// Actual rows processed
190    pub actual_rows: usize,
191    /// Actual execution time in milliseconds
192    pub execution_time_ms: f64,
193    /// Actual memory used in bytes
194    pub memory_used: usize,
195    /// Number of iterations (for nested loops)
196    pub iterations: usize,
197}
198
199/// A node in the query plan tree
200#[derive(Debug, Clone)]
201pub struct PlanNode {
202    /// Unique node ID
203    pub id: String,
204    /// Operator type
205    pub operator: OperatorType,
206    /// Human-readable description
207    pub description: String,
208    /// Variables involved
209    pub variables: Vec<String>,
210    /// Child nodes
211    pub children: Vec<PlanNode>,
212    /// Cost estimates
213    pub cost: Option<CostEstimate>,
214    /// Actual execution statistics
215    pub stats: Option<ExecutionStats>,
216    /// Additional properties
217    pub properties: HashMap<String, String>,
218}
219
220impl PlanNode {
221    /// Create a new plan node
222    pub fn new(operator: OperatorType, description: impl Into<String>) -> Self {
223        Self {
224            id: Self::generate_id(),
225            operator,
226            description: description.into(),
227            variables: Vec::new(),
228            children: Vec::new(),
229            cost: None,
230            stats: None,
231            properties: HashMap::new(),
232        }
233    }
234
235    /// Create a scan node
236    pub fn scan(pattern: impl Into<String>) -> Self {
237        Self::new(OperatorType::Scan, pattern)
238    }
239
240    /// Create a hash join node
241    pub fn hash_join(description: impl Into<String>) -> Self {
242        Self::new(OperatorType::HashJoin, description)
243    }
244
245    /// Create a filter node
246    pub fn filter(condition: impl Into<String>) -> Self {
247        Self::new(OperatorType::Filter, condition)
248    }
249
250    /// Create a project node
251    pub fn project(vars: impl Into<String>) -> Self {
252        Self::new(OperatorType::Project, vars)
253    }
254
255    /// Create a distinct node
256    pub fn distinct() -> Self {
257        Self::new(OperatorType::Distinct, "DISTINCT")
258    }
259
260    /// Create an order by node
261    pub fn order_by(ordering: impl Into<String>) -> Self {
262        Self::new(OperatorType::OrderBy, ordering)
263    }
264
265    /// Create a limit node
266    pub fn limit(n: usize) -> Self {
267        Self::new(OperatorType::Limit, format!("LIMIT {}", n))
268    }
269
270    /// Create an offset node
271    pub fn offset(n: usize) -> Self {
272        Self::new(OperatorType::Offset, format!("OFFSET {}", n))
273    }
274
275    /// Create a union node
276    pub fn union() -> Self {
277        Self::new(OperatorType::Union, "UNION")
278    }
279
280    /// Create an optional node
281    pub fn optional() -> Self {
282        Self::new(OperatorType::Optional, "OPTIONAL")
283    }
284
285    /// Create a group by node
286    pub fn group_by(vars: impl Into<String>) -> Self {
287        Self::new(OperatorType::GroupBy, vars)
288    }
289
290    /// Create an aggregate node
291    pub fn aggregate(agg: impl Into<String>) -> Self {
292        Self::new(OperatorType::Aggregate, agg)
293    }
294
295    /// Add a child node
296    pub fn with_child(mut self, child: PlanNode) -> Self {
297        self.children.push(child);
298        self
299    }
300
301    /// Add multiple children
302    pub fn with_children(mut self, children: Vec<PlanNode>) -> Self {
303        self.children.extend(children);
304        self
305    }
306
307    /// Add variables
308    pub fn with_variables(mut self, vars: Vec<String>) -> Self {
309        self.variables = vars;
310        self
311    }
312
313    /// Add cost estimate
314    pub fn with_cost(mut self, cost: CostEstimate) -> Self {
315        self.cost = Some(cost);
316        self
317    }
318
319    /// Add execution stats
320    pub fn with_stats(mut self, stats: ExecutionStats) -> Self {
321        self.stats = Some(stats);
322        self
323    }
324
325    /// Add a property
326    pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
327        self.properties.insert(key.into(), value.into());
328        self
329    }
330
331    /// Count total nodes in tree
332    pub fn node_count(&self) -> usize {
333        1 + self.children.iter().map(|c| c.node_count()).sum::<usize>()
334    }
335
336    /// Get tree depth
337    pub fn depth(&self) -> usize {
338        if self.children.is_empty() {
339            1
340        } else {
341            1 + self.children.iter().map(|c| c.depth()).max().unwrap_or(0)
342        }
343    }
344
345    fn generate_id() -> String {
346        use std::sync::atomic::{AtomicU64, Ordering};
347        static COUNTER: AtomicU64 = AtomicU64::new(0);
348        format!("node_{}", COUNTER.fetch_add(1, Ordering::Relaxed))
349    }
350}
351
352/// Export configuration
353#[derive(Debug, Clone)]
354pub struct ExportConfig {
355    /// Include cost estimates
356    pub include_costs: bool,
357    /// Include execution statistics
358    pub include_stats: bool,
359    /// Include node properties
360    pub include_properties: bool,
361    /// Include variables
362    pub include_variables: bool,
363    /// Pretty print output
364    pub pretty_print: bool,
365    /// Indentation string (for text/yaml)
366    pub indent: String,
367    /// Include header/metadata
368    pub include_metadata: bool,
369    /// Graph direction for DOT/Mermaid (TB, BT, LR, RL)
370    pub graph_direction: String,
371}
372
373impl Default for ExportConfig {
374    fn default() -> Self {
375        Self {
376            include_costs: true,
377            include_stats: true,
378            include_properties: true,
379            include_variables: true,
380            pretty_print: true,
381            indent: "  ".to_string(),
382            include_metadata: true,
383            graph_direction: "TB".to_string(),
384        }
385    }
386}
387
388impl ExportConfig {
389    /// Minimal configuration
390    pub fn minimal() -> Self {
391        Self {
392            include_costs: false,
393            include_stats: false,
394            include_properties: false,
395            include_variables: false,
396            pretty_print: false,
397            indent: "  ".to_string(),
398            include_metadata: false,
399            graph_direction: "TB".to_string(),
400        }
401    }
402
403    /// Full configuration with all details
404    pub fn full() -> Self {
405        Self {
406            include_costs: true,
407            include_stats: true,
408            include_properties: true,
409            include_variables: true,
410            pretty_print: true,
411            indent: "    ".to_string(),
412            include_metadata: true,
413            graph_direction: "TB".to_string(),
414        }
415    }
416}
417
418/// Query plan exporter
419#[derive(Debug)]
420pub struct QueryPlanExporter {
421    /// Export configuration
422    config: ExportConfig,
423    /// Statistics
424    stats: ExporterStats,
425}
426
427/// Exporter statistics
428#[derive(Debug, Clone, Default)]
429pub struct ExporterStats {
430    /// Total exports performed
431    pub total_exports: usize,
432    /// Exports by format
433    pub exports_by_format: HashMap<String, usize>,
434    /// Total nodes exported
435    pub total_nodes_exported: usize,
436}
437
438impl QueryPlanExporter {
439    /// Create a new exporter with default configuration
440    pub fn new() -> Self {
441        Self {
442            config: ExportConfig::default(),
443            stats: ExporterStats::default(),
444        }
445    }
446
447    /// Create with custom configuration
448    pub fn with_config(config: ExportConfig) -> Self {
449        Self {
450            config,
451            stats: ExporterStats::default(),
452        }
453    }
454
455    /// Export plan to specified format
456    pub fn export(&mut self, plan: &PlanNode, format: ExportFormat) -> Result<String, ExportError> {
457        self.stats.total_exports += 1;
458        *self
459            .stats
460            .exports_by_format
461            .entry(format.to_string())
462            .or_insert(0) += 1;
463        self.stats.total_nodes_exported += plan.node_count();
464
465        match format {
466            ExportFormat::Json => self.export_json(plan),
467            ExportFormat::Dot => self.export_dot(plan),
468            ExportFormat::Mermaid => self.export_mermaid(plan),
469            ExportFormat::Text => self.export_text(plan),
470            ExportFormat::Yaml => self.export_yaml(plan),
471            ExportFormat::Html => self.export_html(plan),
472        }
473    }
474
475    /// Get exporter statistics
476    pub fn statistics(&self) -> &ExporterStats {
477        &self.stats
478    }
479
480    /// Get configuration
481    pub fn config(&self) -> &ExportConfig {
482        &self.config
483    }
484
485    // Private export methods
486
487    fn export_json(&self, plan: &PlanNode) -> Result<String, ExportError> {
488        let mut output = String::new();
489
490        if self.config.pretty_print {
491            self.json_node_pretty(&mut output, plan, 0);
492        } else {
493            self.json_node(&mut output, plan);
494        }
495
496        Ok(output)
497    }
498
499    fn json_node(&self, output: &mut String, node: &PlanNode) {
500        output.push('{');
501        output.push_str(&format!("\"id\":\"{}\"", node.id));
502        output.push_str(&format!(",\"operator\":\"{}\"", node.operator));
503        output.push_str(&format!(
504            ",\"description\":\"{}\"",
505            Self::escape_json(&node.description)
506        ));
507
508        if self.config.include_variables && !node.variables.is_empty() {
509            output.push_str(",\"variables\":[");
510            for (i, var) in node.variables.iter().enumerate() {
511                if i > 0 {
512                    output.push(',');
513                }
514                output.push_str(&format!("\"{}\"", var));
515            }
516            output.push(']');
517        }
518
519        if self.config.include_costs {
520            if let Some(ref cost) = node.cost {
521                output.push_str(&format!(
522                    ",\"cost\":{{\"estimated_rows\":{},\"estimated_cost\":{}}}",
523                    cost.estimated_rows, cost.estimated_cost
524                ));
525            }
526        }
527
528        if self.config.include_stats {
529            if let Some(ref stats) = node.stats {
530                output.push_str(&format!(
531                    ",\"stats\":{{\"actual_rows\":{},\"execution_time_ms\":{}}}",
532                    stats.actual_rows, stats.execution_time_ms
533                ));
534            }
535        }
536
537        if !node.children.is_empty() {
538            output.push_str(",\"children\":[");
539            for (i, child) in node.children.iter().enumerate() {
540                if i > 0 {
541                    output.push(',');
542                }
543                self.json_node(output, child);
544            }
545            output.push(']');
546        }
547
548        output.push('}');
549    }
550
551    fn json_node_pretty(&self, output: &mut String, node: &PlanNode, depth: usize) {
552        let indent = self.config.indent.repeat(depth);
553        let child_indent = self.config.indent.repeat(depth + 1);
554
555        output.push_str(&format!("{}{{\n", indent));
556        output.push_str(&format!("{}\"id\": \"{}\",\n", child_indent, node.id));
557        output.push_str(&format!(
558            "{}\"operator\": \"{}\",\n",
559            child_indent, node.operator
560        ));
561        output.push_str(&format!(
562            "{}\"description\": \"{}\"",
563            child_indent,
564            Self::escape_json(&node.description)
565        ));
566
567        if self.config.include_variables && !node.variables.is_empty() {
568            output.push_str(&format!(
569                ",\n{}\"variables\": {:?}",
570                child_indent, node.variables
571            ));
572        }
573
574        if self.config.include_costs {
575            if let Some(ref cost) = node.cost {
576                output.push_str(&format!(
577                    ",\n{}\"cost\": {{\n{}\"estimated_rows\": {},\n{}\"estimated_cost\": {}\n{}}}",
578                    child_indent,
579                    self.config.indent.repeat(depth + 2),
580                    cost.estimated_rows,
581                    self.config.indent.repeat(depth + 2),
582                    cost.estimated_cost,
583                    child_indent
584                ));
585            }
586        }
587
588        if !node.children.is_empty() {
589            output.push_str(&format!(",\n{}\"children\": [\n", child_indent));
590            for (i, child) in node.children.iter().enumerate() {
591                if i > 0 {
592                    output.push_str(",\n");
593                }
594                self.json_node_pretty(output, child, depth + 2);
595            }
596            output.push_str(&format!("\n{}]", child_indent));
597        }
598
599        output.push_str(&format!("\n{}}}", indent));
600    }
601
602    fn export_dot(&self, plan: &PlanNode) -> Result<String, ExportError> {
603        let mut output = String::new();
604
605        output.push_str("digraph QueryPlan {\n");
606        output.push_str(&format!(
607            "{}rankdir={};\n",
608            self.config.indent, self.config.graph_direction
609        ));
610        output.push_str(&format!(
611            "{}node [shape=box, style=rounded];\n",
612            self.config.indent
613        ));
614
615        self.dot_node(&mut output, plan);
616
617        output.push_str("}\n");
618        Ok(output)
619    }
620
621    fn dot_node(&self, output: &mut String, node: &PlanNode) {
622        let label = self.dot_label(node);
623        output.push_str(&format!(
624            "{}\"{}\" [label=\"{}\"];\n",
625            self.config.indent, node.id, label
626        ));
627
628        for child in &node.children {
629            output.push_str(&format!(
630                "{}\"{}\" -> \"{}\";\n",
631                self.config.indent, node.id, child.id
632            ));
633            self.dot_node(output, child);
634        }
635    }
636
637    fn dot_label(&self, node: &PlanNode) -> String {
638        let mut label = format!("{}\\n{}", node.operator, node.description);
639
640        if self.config.include_costs {
641            if let Some(ref cost) = node.cost {
642                label.push_str(&format!(
643                    "\\n[rows: {:.0}, cost: {:.2}]",
644                    cost.estimated_rows, cost.estimated_cost
645                ));
646            }
647        }
648
649        if self.config.include_stats {
650            if let Some(ref stats) = node.stats {
651                label.push_str(&format!(
652                    "\\n(actual: {} rows, {:.2}ms)",
653                    stats.actual_rows, stats.execution_time_ms
654                ));
655            }
656        }
657
658        label
659    }
660
661    fn export_mermaid(&self, plan: &PlanNode) -> Result<String, ExportError> {
662        let mut output = String::new();
663
664        output.push_str("```mermaid\n");
665        output.push_str(&format!("graph {}\n", self.config.graph_direction));
666
667        self.mermaid_node(&mut output, plan);
668
669        output.push_str("```\n");
670        Ok(output)
671    }
672
673    fn mermaid_node(&self, output: &mut String, node: &PlanNode) {
674        let label = self.mermaid_label(node);
675        output.push_str(&format!(
676            "{}{}[\"{}\"]\n",
677            self.config.indent, node.id, label
678        ));
679
680        for child in &node.children {
681            output.push_str(&format!(
682                "{}{} --> {}\n",
683                self.config.indent, node.id, child.id
684            ));
685            self.mermaid_node(output, child);
686        }
687    }
688
689    fn mermaid_label(&self, node: &PlanNode) -> String {
690        let mut label = format!("{}: {}", node.operator, node.description);
691
692        if self.config.include_costs {
693            if let Some(ref cost) = node.cost {
694                label.push_str(&format!(" [rows: {:.0}]", cost.estimated_rows));
695            }
696        }
697
698        label
699    }
700
701    fn export_text(&self, plan: &PlanNode) -> Result<String, ExportError> {
702        let mut output = String::new();
703
704        if self.config.include_metadata {
705            output.push_str("Query Plan\n");
706            output.push_str(&format!("Nodes: {}\n", plan.node_count()));
707            output.push_str(&format!("Depth: {}\n", plan.depth()));
708            output.push_str("─".repeat(40).as_str());
709            output.push('\n');
710        }
711
712        self.text_node(&mut output, plan, 0);
713        Ok(output)
714    }
715
716    fn text_node(&self, output: &mut String, node: &PlanNode, depth: usize) {
717        let prefix = if depth == 0 {
718            "".to_string()
719        } else {
720            format!("{}├── ", "│   ".repeat(depth - 1))
721        };
722
723        output.push_str(&format!(
724            "{}{}: {}",
725            prefix, node.operator, node.description
726        ));
727
728        if self.config.include_costs {
729            if let Some(ref cost) = node.cost {
730                output.push_str(&format!(
731                    " (rows: {:.0}, cost: {:.2})",
732                    cost.estimated_rows, cost.estimated_cost
733                ));
734            }
735        }
736
737        if self.config.include_stats {
738            if let Some(ref stats) = node.stats {
739                output.push_str(&format!(
740                    " [actual: {} rows, {:.2}ms]",
741                    stats.actual_rows, stats.execution_time_ms
742                ));
743            }
744        }
745
746        output.push('\n');
747
748        for child in &node.children {
749            self.text_node(output, child, depth + 1);
750        }
751    }
752
753    fn export_yaml(&self, plan: &PlanNode) -> Result<String, ExportError> {
754        let mut output = String::new();
755
756        if self.config.include_metadata {
757            output.push_str("# Query Plan Export\n");
758            output.push_str(&format!("# Nodes: {}\n", plan.node_count()));
759            output.push_str(&format!("# Depth: {}\n\n", plan.depth()));
760        }
761
762        self.yaml_node(&mut output, plan, 0);
763        Ok(output)
764    }
765
766    fn yaml_node(&self, output: &mut String, node: &PlanNode, depth: usize) {
767        let indent = self.config.indent.repeat(depth);
768
769        output.push_str(&format!("{}id: {}\n", indent, node.id));
770        output.push_str(&format!("{}operator: {}\n", indent, node.operator));
771        output.push_str(&format!(
772            "{}description: \"{}\"\n",
773            indent, node.description
774        ));
775
776        if self.config.include_variables && !node.variables.is_empty() {
777            output.push_str(&format!("{}variables:\n", indent));
778            for var in &node.variables {
779                output.push_str(&format!(
780                    "{}- {}\n",
781                    self.config.indent.repeat(depth + 1),
782                    var
783                ));
784            }
785        }
786
787        if self.config.include_costs {
788            if let Some(ref cost) = node.cost {
789                output.push_str(&format!("{}cost:\n", indent));
790                output.push_str(&format!(
791                    "{}estimated_rows: {}\n",
792                    self.config.indent.repeat(depth + 1),
793                    cost.estimated_rows
794                ));
795                output.push_str(&format!(
796                    "{}estimated_cost: {}\n",
797                    self.config.indent.repeat(depth + 1),
798                    cost.estimated_cost
799                ));
800            }
801        }
802
803        if !node.children.is_empty() {
804            output.push_str(&format!("{}children:\n", indent));
805            for child in &node.children {
806                output.push_str(&format!("{}- \n", self.config.indent.repeat(depth + 1)));
807                self.yaml_node(output, child, depth + 2);
808            }
809        }
810    }
811
812    fn export_html(&self, plan: &PlanNode) -> Result<String, ExportError> {
813        let mut output = String::new();
814
815        output.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
816        output.push_str("<title>Query Plan</title>\n");
817        output.push_str("<style>\n");
818        output.push_str(
819            ".node { border: 1px solid #ccc; border-radius: 8px; padding: 10px; margin: 5px; background: #f9f9f9; }\n",
820        );
821        output.push_str(".operator { font-weight: bold; color: #333; }\n");
822        output.push_str(".description { color: #666; font-family: monospace; }\n");
823        output.push_str(".cost { color: #999; font-size: 0.9em; }\n");
824        output.push_str(".children { margin-left: 20px; border-left: 2px solid #ddd; }\n");
825        output.push_str("</style>\n</head>\n<body>\n");
826
827        if self.config.include_metadata {
828            output.push_str("<h1>Query Plan</h1>\n");
829            output.push_str(&format!(
830                "<p>Nodes: {} | Depth: {}</p>\n",
831                plan.node_count(),
832                plan.depth()
833            ));
834        }
835
836        self.html_node(&mut output, plan);
837
838        output.push_str("</body>\n</html>\n");
839        Ok(output)
840    }
841
842    fn html_node(&self, output: &mut String, node: &PlanNode) {
843        output.push_str("<div class=\"node\">\n");
844        output.push_str(&format!(
845            "<span class=\"operator\">{}</span>: ",
846            node.operator
847        ));
848        output.push_str(&format!(
849            "<span class=\"description\">{}</span>\n",
850            Self::escape_html(&node.description)
851        ));
852
853        if self.config.include_costs {
854            if let Some(ref cost) = node.cost {
855                output.push_str(&format!(
856                    "<div class=\"cost\">Est. rows: {:.0}, Cost: {:.2}</div>\n",
857                    cost.estimated_rows, cost.estimated_cost
858                ));
859            }
860        }
861
862        if !node.children.is_empty() {
863            output.push_str("<div class=\"children\">\n");
864            for child in &node.children {
865                self.html_node(output, child);
866            }
867            output.push_str("</div>\n");
868        }
869
870        output.push_str("</div>\n");
871    }
872
873    fn escape_json(s: &str) -> String {
874        s.replace('\\', "\\\\")
875            .replace('"', "\\\"")
876            .replace('\n', "\\n")
877            .replace('\r', "\\r")
878            .replace('\t', "\\t")
879    }
880
881    fn escape_html(s: &str) -> String {
882        s.replace('&', "&amp;")
883            .replace('<', "&lt;")
884            .replace('>', "&gt;")
885            .replace('"', "&quot;")
886    }
887}
888
889impl Default for QueryPlanExporter {
890    fn default() -> Self {
891        Self::new()
892    }
893}
894
895/// Export error
896#[derive(Debug, Clone)]
897pub struct ExportError {
898    /// Error message
899    pub message: String,
900    /// Format that caused the error
901    pub format: ExportFormat,
902}
903
904impl fmt::Display for ExportError {
905    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
906        write!(f, "Export error ({}): {}", self.format, self.message)
907    }
908}
909
910impl std::error::Error for ExportError {}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915
916    #[test]
917    fn test_plan_node_creation() {
918        let node = PlanNode::scan("?s ?p ?o");
919        assert_eq!(node.operator, OperatorType::Scan);
920        assert_eq!(node.description, "?s ?p ?o");
921    }
922
923    #[test]
924    fn test_plan_node_with_children() {
925        let plan = PlanNode::hash_join("?s = ?s")
926            .with_child(PlanNode::scan("?s :knows ?o"))
927            .with_child(PlanNode::scan("?s :name ?n"));
928
929        assert_eq!(plan.children.len(), 2);
930        assert_eq!(plan.node_count(), 3);
931        assert_eq!(plan.depth(), 2);
932    }
933
934    #[test]
935    fn test_plan_node_with_cost() {
936        let cost = CostEstimate {
937            estimated_rows: 100.0,
938            estimated_cost: 50.0,
939            ..Default::default()
940        };
941        let node = PlanNode::scan("?s ?p ?o").with_cost(cost);
942
943        assert!(node.cost.is_some());
944        assert_eq!(node.cost.as_ref().unwrap().estimated_rows, 100.0);
945    }
946
947    #[test]
948    fn test_export_json() {
949        let plan = PlanNode::scan("?s ?p ?o");
950        let mut exporter = QueryPlanExporter::new();
951
952        let json = exporter.export(&plan, ExportFormat::Json).unwrap();
953        // JSON may be pretty-printed with spaces after colons
954        assert!(json.contains("\"operator\""));
955        assert!(json.contains("Scan"));
956        assert!(json.contains("?s ?p ?o"));
957    }
958
959    #[test]
960    fn test_export_dot() {
961        let plan = PlanNode::hash_join("join").with_child(PlanNode::scan("?s ?p ?o"));
962
963        let mut exporter = QueryPlanExporter::new();
964        let dot = exporter.export(&plan, ExportFormat::Dot).unwrap();
965
966        assert!(dot.contains("digraph QueryPlan"));
967        assert!(dot.contains("->"));
968    }
969
970    #[test]
971    fn test_export_mermaid() {
972        let plan = PlanNode::scan("?s ?p ?o");
973        let mut exporter = QueryPlanExporter::new();
974
975        let mermaid = exporter.export(&plan, ExportFormat::Mermaid).unwrap();
976        assert!(mermaid.contains("```mermaid"));
977        assert!(mermaid.contains("graph TB"));
978    }
979
980    #[test]
981    fn test_export_text() {
982        let plan = PlanNode::project("?s ?o").with_child(PlanNode::scan("?s ?p ?o"));
983
984        let mut exporter = QueryPlanExporter::new();
985        let text = exporter.export(&plan, ExportFormat::Text).unwrap();
986
987        assert!(text.contains("Project"));
988        assert!(text.contains("Scan"));
989    }
990
991    #[test]
992    fn test_export_yaml() {
993        let plan = PlanNode::scan("?s ?p ?o");
994        let mut exporter = QueryPlanExporter::new();
995
996        let yaml = exporter.export(&plan, ExportFormat::Yaml).unwrap();
997        assert!(yaml.contains("operator: Scan"));
998    }
999
1000    #[test]
1001    fn test_export_html() {
1002        let plan = PlanNode::scan("?s ?p ?o");
1003        let mut exporter = QueryPlanExporter::new();
1004
1005        let html = exporter.export(&plan, ExportFormat::Html).unwrap();
1006        assert!(html.contains("<!DOCTYPE html>"));
1007        assert!(html.contains("Scan"));
1008    }
1009
1010    #[test]
1011    fn test_export_format_extension() {
1012        assert_eq!(ExportFormat::Json.extension(), "json");
1013        assert_eq!(ExportFormat::Dot.extension(), "dot");
1014        assert_eq!(ExportFormat::Html.extension(), "html");
1015    }
1016
1017    #[test]
1018    fn test_export_format_mime_type() {
1019        assert_eq!(ExportFormat::Json.mime_type(), "application/json");
1020        assert_eq!(ExportFormat::Html.mime_type(), "text/html");
1021    }
1022
1023    #[test]
1024    fn test_exporter_statistics() {
1025        let mut exporter = QueryPlanExporter::new();
1026        let plan = PlanNode::scan("?s ?p ?o");
1027
1028        exporter.export(&plan, ExportFormat::Json).unwrap();
1029        exporter.export(&plan, ExportFormat::Dot).unwrap();
1030
1031        assert_eq!(exporter.statistics().total_exports, 2);
1032        assert_eq!(exporter.statistics().total_nodes_exported, 2);
1033    }
1034
1035    #[test]
1036    fn test_config_presets() {
1037        let minimal = ExportConfig::minimal();
1038        assert!(!minimal.include_costs);
1039        assert!(!minimal.include_stats);
1040
1041        let full = ExportConfig::full();
1042        assert!(full.include_costs);
1043        assert!(full.include_stats);
1044    }
1045
1046    #[test]
1047    fn test_operator_types() {
1048        assert_eq!(format!("{}", OperatorType::Scan), "Scan");
1049        assert_eq!(format!("{}", OperatorType::HashJoin), "HashJoin");
1050        assert_eq!(
1051            format!("{}", OperatorType::Custom("MyOp".to_string())),
1052            "MyOp"
1053        );
1054    }
1055
1056    #[test]
1057    fn test_complex_plan() {
1058        let plan = PlanNode::project("?name ?age")
1059            .with_child(
1060                PlanNode::filter("?age > 18").with_child(
1061                    PlanNode::hash_join("?person = ?person")
1062                        .with_child(PlanNode::scan("?person :name ?name"))
1063                        .with_child(PlanNode::scan("?person :age ?age")),
1064                ),
1065            )
1066            .with_cost(CostEstimate {
1067                estimated_rows: 50.0,
1068                estimated_cost: 120.0,
1069                ..Default::default()
1070            });
1071
1072        assert_eq!(plan.node_count(), 5);
1073        assert_eq!(plan.depth(), 4);
1074
1075        let mut exporter = QueryPlanExporter::new();
1076        let json = exporter.export(&plan, ExportFormat::Json).unwrap();
1077        assert!(json.contains("Project"));
1078        assert!(json.contains("Filter"));
1079        assert!(json.contains("HashJoin"));
1080    }
1081}