oxirs_federate/
query_plan_explainer.rs

1//! # Query Plan Explainer and Visualizer
2//!
3//! This module provides detailed explanation and visualization of federated query execution plans,
4//! helping users understand how their queries are decomposed, distributed, and executed across
5//! multiple services.
6//!
7//! ## Features
8//!
9//! - Detailed plan structure breakdown
10//! - Cost and performance analysis per step
11//! - Service dependency visualization
12//! - Optimization suggestions
13//! - JSON and human-readable output formats
14//!
15//! ## Usage
16//!
17//! ```rust,ignore
18//! use crate::query_plan_explainer::{QueryPlanExplainer, ExplainFormat};
19//!
20//! let explainer = QueryPlanExplainer::new();
21//! let explanation = explainer.explain_plan(&execution_plan, ExplainFormat::Detailed)?;
22//! println!("{}", explanation);
23//! ```
24
25use anyhow::Result;
26use serde::{Deserialize, Serialize};
27use std::collections::{HashMap, HashSet};
28use std::fmt;
29
30use crate::planner::planning::types::{ExecutionPlan, ExecutionStep, StepType};
31
32/// Format for plan explanation output
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ExplainFormat {
35    /// Brief summary
36    Brief,
37    /// Detailed step-by-step explanation
38    Detailed,
39    /// JSON format for programmatic access
40    Json,
41    /// Tree structure visualization
42    Tree,
43    /// Cost analysis focused
44    CostAnalysis,
45}
46
47/// Query plan explainer
48pub struct QueryPlanExplainer {
49    config: ExplainerConfig,
50}
51
52/// Configuration for plan explanation
53#[derive(Debug, Clone)]
54pub struct ExplainerConfig {
55    /// Include cost estimates
56    pub include_costs: bool,
57    /// Include service details
58    pub include_services: bool,
59    /// Include optimization suggestions
60    pub include_suggestions: bool,
61    /// Maximum depth for tree visualization
62    pub max_tree_depth: usize,
63    /// Show timing estimates
64    pub show_timing_estimates: bool,
65}
66
67impl Default for ExplainerConfig {
68    fn default() -> Self {
69        Self {
70            include_costs: true,
71            include_services: true,
72            include_suggestions: true,
73            max_tree_depth: 10,
74            show_timing_estimates: true,
75        }
76    }
77}
78
79/// Explanation of an execution plan
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct PlanExplanation {
82    /// Summary of the plan
83    pub summary: String,
84    /// Total estimated cost
85    pub total_cost: u64,
86    /// Number of steps
87    pub step_count: usize,
88    /// Number of services involved
89    pub service_count: usize,
90    /// Steps breakdown
91    pub steps: Vec<StepExplanation>,
92    /// Service dependencies
93    pub service_dependencies: HashMap<String, Vec<String>>,
94    /// Parallelization opportunities (step IDs)
95    pub parallel_steps: Vec<Vec<String>>,
96    /// Optimization suggestions
97    pub suggestions: Vec<OptimizationSuggestion>,
98    /// Critical path (longest execution sequence) (step IDs)
99    pub critical_path: Vec<String>,
100    /// Estimated total execution time (milliseconds)
101    pub estimated_duration_ms: u64,
102}
103
104/// Explanation of a single execution step
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct StepExplanation {
107    /// Step ID
108    pub step_id: String,
109    /// Step type description
110    pub step_type: String,
111    /// Service ID if applicable
112    pub service_id: Option<String>,
113    /// Query fragment or operation
114    pub operation: String,
115    /// Estimated cost
116    pub estimated_cost: f64,
117    /// Estimated duration (milliseconds)
118    pub estimated_duration_ms: u64,
119    /// Dependencies on other steps (step IDs)
120    pub dependencies: Vec<String>,
121    /// Whether this step can run in parallel
122    pub can_parallelize: bool,
123    /// Detailed description
124    pub description: String,
125}
126
127/// Optimization suggestion
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct OptimizationSuggestion {
130    /// Suggestion category
131    pub category: SuggestionCategory,
132    /// Severity level
133    pub severity: SuggestionSeverity,
134    /// Description of the suggestion
135    pub description: String,
136    /// Steps affected (step IDs)
137    pub affected_steps: Vec<String>,
138    /// Potential improvement
139    pub potential_improvement: String,
140}
141
142/// Category of optimization suggestion
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144pub enum SuggestionCategory {
145    /// Join ordering optimization
146    JoinOrdering,
147    /// Service selection
148    ServiceSelection,
149    /// Caching opportunity
150    Caching,
151    /// Parallelization
152    Parallelization,
153    /// Filter pushdown
154    FilterPushdown,
155    /// Data transfer reduction
156    DataTransfer,
157    /// Index usage
158    IndexUsage,
159}
160
161/// Severity of optimization suggestion
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163pub enum SuggestionSeverity {
164    /// Low impact
165    Low,
166    /// Medium impact
167    Medium,
168    /// High impact
169    High,
170    /// Critical impact
171    Critical,
172}
173
174impl QueryPlanExplainer {
175    /// Create a new query plan explainer with default configuration
176    pub fn new() -> Self {
177        Self {
178            config: ExplainerConfig::default(),
179        }
180    }
181
182    /// Create a new query plan explainer with custom configuration
183    pub fn with_config(config: ExplainerConfig) -> Self {
184        Self { config }
185    }
186
187    /// Explain an execution plan
188    pub fn explain_plan(&self, plan: &ExecutionPlan, format: ExplainFormat) -> Result<String> {
189        let explanation = self.analyze_plan(plan)?;
190
191        match format {
192            ExplainFormat::Brief => Ok(self.format_brief(&explanation)),
193            ExplainFormat::Detailed => Ok(self.format_detailed(&explanation)),
194            ExplainFormat::Json => Ok(serde_json::to_string_pretty(&explanation)?),
195            ExplainFormat::Tree => Ok(self.format_tree(&explanation)),
196            ExplainFormat::CostAnalysis => Ok(self.format_cost_analysis(&explanation)),
197        }
198    }
199
200    /// Analyze an execution plan and generate explanation
201    fn analyze_plan(&self, plan: &ExecutionPlan) -> Result<PlanExplanation> {
202        let steps = plan
203            .steps
204            .iter()
205            .map(|step| self.explain_step(step, plan))
206            .collect::<Vec<_>>();
207
208        let service_count = steps
209            .iter()
210            .filter_map(|s| s.service_id.as_ref())
211            .collect::<HashSet<_>>()
212            .len();
213
214        let service_dependencies = self.analyze_service_dependencies(&steps);
215        let parallel_steps = self.identify_parallel_steps(&steps);
216        let critical_path = self.compute_critical_path(&steps);
217        let suggestions = if self.config.include_suggestions {
218            self.generate_suggestions(plan, &steps)
219        } else {
220            Vec::new()
221        };
222
223        let total_cost = steps.iter().map(|s| s.estimated_cost as u64).sum();
224        let estimated_duration_ms = self.estimate_total_duration(&steps, &critical_path);
225
226        let summary = self.generate_summary(steps.len(), service_count, &parallel_steps);
227
228        Ok(PlanExplanation {
229            summary,
230            total_cost,
231            step_count: steps.len(),
232            service_count,
233            steps,
234            service_dependencies,
235            parallel_steps,
236            suggestions,
237            critical_path,
238            estimated_duration_ms,
239        })
240    }
241
242    /// Explain a single execution step
243    fn explain_step(&self, step: &ExecutionStep, _plan: &ExecutionPlan) -> StepExplanation {
244        let step_id = step.step_id.clone();
245        let step_type = format!("{:?}", step.step_type);
246        let service_id = step.service_id.clone();
247
248        let operation = match &step.step_type {
249            StepType::ServiceQuery => {
250                format!("Execute query on service: {}", step.query_fragment)
251            }
252            StepType::GraphQLQuery => {
253                format!("Execute GraphQL query: {}", step.query_fragment)
254            }
255            StepType::Join => "Join results from previous steps".to_string(),
256            StepType::Filter => "Apply filters".to_string(),
257            StepType::Aggregate => "Aggregate results".to_string(),
258            StepType::Sort => "Sort results".to_string(),
259            StepType::EntityResolution => "Resolve entities across services".to_string(),
260            StepType::ResultStitching => "Stitch results together".to_string(),
261            StepType::SchemaStitch => "Stitch schemas together".to_string(),
262            StepType::Union => "Union results from multiple sources".to_string(),
263        };
264
265        let estimated_cost = step.estimated_cost;
266        let estimated_duration_ms = self.estimate_step_duration(step);
267
268        let dependencies = step.dependencies.clone();
269        let can_parallelize = dependencies.is_empty() || dependencies.len() <= 1;
270
271        let description = self.generate_step_description(step);
272
273        StepExplanation {
274            step_id,
275            step_type,
276            service_id,
277            operation,
278            estimated_cost,
279            estimated_duration_ms,
280            dependencies,
281            can_parallelize,
282            description,
283        }
284    }
285
286    /// Generate detailed description for a step
287    fn generate_step_description(&self, step: &ExecutionStep) -> String {
288        let mut desc = String::new();
289
290        match &step.step_type {
291            StepType::ServiceQuery | StepType::GraphQLQuery => {
292                if let Some(service_id) = &step.service_id {
293                    desc.push_str(&format!("Query service '{}' ", service_id));
294                }
295                if step.query_fragment.len() > 100 {
296                    desc.push_str(&format!(
297                        "with query ({}... characters)",
298                        step.query_fragment.len()
299                    ));
300                } else {
301                    desc.push_str(&format!("with query: {}", step.query_fragment));
302                }
303            }
304            StepType::Join => {
305                desc.push_str("Perform join operation ");
306                if !step.dependencies.is_empty() {
307                    desc.push_str(&format!(
308                        "combining results from steps {:?}",
309                        step.dependencies
310                    ));
311                }
312            }
313            StepType::Filter => {
314                desc.push_str("Apply filtering conditions to reduce result set");
315            }
316            StepType::Aggregate => {
317                desc.push_str("Aggregate data (COUNT, SUM, AVG, etc.)");
318            }
319            StepType::Sort => {
320                desc.push_str("Sort results by specified criteria");
321            }
322            StepType::EntityResolution => {
323                desc.push_str("Resolve entity references across different services");
324            }
325            StepType::ResultStitching => {
326                desc.push_str("Combine and stitch results from multiple sources");
327            }
328            StepType::SchemaStitch => {
329                desc.push_str("Stitch schemas together for federation");
330            }
331            StepType::Union => {
332                desc.push_str("Union results from multiple query branches");
333            }
334        }
335
336        // Note: estimated_result_size not available on ExecutionStep
337        // Could be added in future enhancement
338
339        desc
340    }
341
342    /// Estimate duration for a step
343    fn estimate_step_duration(&self, step: &ExecutionStep) -> u64 {
344        if !self.config.show_timing_estimates {
345            return 0;
346        }
347
348        // Base duration estimates (in milliseconds)
349        let base_duration = match &step.step_type {
350            StepType::ServiceQuery => 50, // Network call
351            StepType::GraphQLQuery => 40,
352            StepType::Join => 10,
353            StepType::Filter => 2,
354            StepType::Aggregate => 5,
355            StepType::Sort => 8,
356            StepType::EntityResolution => 15,
357            StepType::ResultStitching => 5,
358            StepType::SchemaStitch => 8,
359            StepType::Union => 3,
360        };
361
362        // Use estimated cost as a size proxy
363        let cost_multiplier = 1.0 + (step.estimated_cost / 100.0).min(10.0);
364
365        (base_duration as f64 * cost_multiplier) as u64
366    }
367
368    /// Analyze service dependencies
369    fn analyze_service_dependencies(
370        &self,
371        steps: &[StepExplanation],
372    ) -> HashMap<String, Vec<String>> {
373        let mut dependencies: HashMap<String, HashSet<String>> = HashMap::new();
374
375        // Create step ID to step map
376        let step_map: HashMap<&String, &StepExplanation> =
377            steps.iter().map(|s| (&s.step_id, s)).collect();
378
379        for step in steps {
380            if let Some(service_id) = &step.service_id {
381                // Find services this step depends on
382                for dep_id in &step.dependencies {
383                    if let Some(dep_step) = step_map.get(dep_id) {
384                        if let Some(dep_service) = &dep_step.service_id {
385                            if dep_service != service_id {
386                                dependencies
387                                    .entry(service_id.clone())
388                                    .or_default()
389                                    .insert(dep_service.clone());
390                            }
391                        }
392                    }
393                }
394            }
395        }
396
397        dependencies
398            .into_iter()
399            .map(|(k, v)| (k, v.into_iter().collect()))
400            .collect()
401    }
402
403    /// Identify steps that can run in parallel
404    fn identify_parallel_steps(&self, steps: &[StepExplanation]) -> Vec<Vec<String>> {
405        let mut parallel_groups = Vec::new();
406        let mut processed = HashSet::new();
407
408        for step in steps {
409            if processed.contains(&step.step_id) {
410                continue;
411            }
412
413            if step.dependencies.is_empty() {
414                // This step has no dependencies, find others like it
415                let mut group = vec![step.step_id.clone()];
416                processed.insert(step.step_id.clone());
417
418                for other_step in steps {
419                    if other_step.step_id != step.step_id
420                        && !processed.contains(&other_step.step_id)
421                        && other_step.dependencies.is_empty()
422                    {
423                        group.push(other_step.step_id.clone());
424                        processed.insert(other_step.step_id.clone());
425                    }
426                }
427
428                if group.len() > 1 {
429                    parallel_groups.push(group);
430                }
431            }
432        }
433
434        parallel_groups
435    }
436
437    /// Compute the critical path (longest execution sequence)
438    fn compute_critical_path(&self, steps: &[StepExplanation]) -> Vec<String> {
439        if steps.is_empty() {
440            return Vec::new();
441        }
442
443        // Simple heuristic: follow the path with highest cumulative cost
444        let mut path = Vec::new();
445        let current_id = steps.first().map(|s| s.step_id.clone());
446        if current_id.is_none() {
447            return path;
448        }
449
450        let mut current_id = current_id.unwrap();
451        let mut visited = HashSet::new();
452
453        path.push(current_id.clone());
454        visited.insert(current_id.clone());
455
456        // Find steps that depend on current step
457        loop {
458            let mut next_step: Option<&StepExplanation> = None;
459            let mut max_cost = 0.0;
460
461            for step in steps {
462                if !visited.contains(&step.step_id)
463                    && step.dependencies.contains(&current_id)
464                    && step.estimated_cost > max_cost
465                {
466                    max_cost = step.estimated_cost;
467                    next_step = Some(step);
468                }
469            }
470
471            if let Some(step) = next_step {
472                path.push(step.step_id.clone());
473                visited.insert(step.step_id.clone());
474                current_id = step.step_id.clone();
475            } else {
476                break;
477            }
478        }
479
480        path
481    }
482
483    /// Estimate total execution duration
484    fn estimate_total_duration(&self, steps: &[StepExplanation], critical_path: &[String]) -> u64 {
485        let step_map: HashMap<&String, &StepExplanation> =
486            steps.iter().map(|s| (&s.step_id, s)).collect();
487
488        critical_path
489            .iter()
490            .filter_map(|id| step_map.get(id))
491            .map(|step| step.estimated_duration_ms)
492            .sum()
493    }
494
495    /// Generate summary of the plan
496    fn generate_summary(
497        &self,
498        step_count: usize,
499        service_count: usize,
500        parallel_steps: &[Vec<String>],
501    ) -> String {
502        format!(
503            "Federated query execution plan with {} steps across {} services. {} parallelization opportunities identified.",
504            step_count,
505            service_count,
506            parallel_steps.len()
507        )
508    }
509
510    /// Generate optimization suggestions
511    fn generate_suggestions(
512        &self,
513        _plan: &ExecutionPlan,
514        steps: &[StepExplanation],
515    ) -> Vec<OptimizationSuggestion> {
516        let mut suggestions = Vec::new();
517
518        // Check for sequential service queries that could be parallelized
519        let sequential_queries: Vec<String> = steps
520            .iter()
521            .filter(|s| matches!(s.step_type.as_str(), "ServiceQuery" | "GraphQLQuery"))
522            .map(|s| s.step_id.clone())
523            .collect();
524
525        if sequential_queries.len() > 1 {
526            suggestions.push(OptimizationSuggestion {
527                category: SuggestionCategory::Parallelization,
528                severity: SuggestionSeverity::High,
529                description: format!(
530                    "Found {} service queries that could potentially run in parallel",
531                    sequential_queries.len()
532                ),
533                affected_steps: sequential_queries,
534                potential_improvement: "Reduce total execution time by up to 50%".to_string(),
535            });
536        }
537
538        // Check for high-cost steps that might benefit from caching
539        for step in steps {
540            if step.estimated_cost > 100.0 {
541                suggestions.push(OptimizationSuggestion {
542                    category: SuggestionCategory::Caching,
543                    severity: SuggestionSeverity::Medium,
544                    description: format!(
545                        "Step {} has high cost ({:.0}) - consider caching",
546                        step.step_id, step.estimated_cost
547                    ),
548                    affected_steps: vec![step.step_id.clone()],
549                    potential_improvement: "Reduce repeated query overhead".to_string(),
550                });
551            }
552        }
553
554        // Check for joins that could be optimized
555        let join_steps: Vec<_> = steps.iter().filter(|s| s.step_type == "Join").collect();
556
557        if join_steps.len() > 2 {
558            suggestions.push(OptimizationSuggestion {
559                category: SuggestionCategory::JoinOrdering,
560                severity: SuggestionSeverity::High,
561                description: "Multiple joins detected - verify join ordering is optimal"
562                    .to_string(),
563                affected_steps: join_steps.iter().map(|s| s.step_id.clone()).collect(),
564                potential_improvement: "Optimize join order to process smaller result sets first"
565                    .to_string(),
566            });
567        }
568
569        suggestions
570    }
571
572    /// Format brief explanation
573    fn format_brief(&self, explanation: &PlanExplanation) -> String {
574        format!(
575            "{}\n\nTotal Cost: {}\nSteps: {}\nServices: {}\nEstimated Duration: {}ms\nParallel Opportunities: {}",
576            explanation.summary,
577            explanation.total_cost,
578            explanation.step_count,
579            explanation.service_count,
580            explanation.estimated_duration_ms,
581            explanation.parallel_steps.len()
582        )
583    }
584
585    /// Format detailed explanation
586    fn format_detailed(&self, explanation: &PlanExplanation) -> String {
587        let mut output = String::new();
588
589        output.push_str("=== QUERY EXECUTION PLAN ===\n\n");
590        output.push_str(&format!("{}\n\n", explanation.summary));
591
592        output.push_str(&format!(
593            "Total Estimated Cost: {}\n",
594            explanation.total_cost
595        ));
596        output.push_str(&format!(
597            "Total Estimated Duration: {}ms\n",
598            explanation.estimated_duration_ms
599        ));
600        output.push_str(&format!("Number of Steps: {}\n", explanation.step_count));
601        output.push_str(&format!(
602            "Services Involved: {}\n\n",
603            explanation.service_count
604        ));
605
606        output.push_str("--- EXECUTION STEPS ---\n\n");
607        for step in &explanation.steps {
608            output.push_str(&format!("Step {}: {}\n", step.step_id, step.step_type));
609            output.push_str(&format!("  {}\n", step.description));
610            if let Some(service_id) = &step.service_id {
611                output.push_str(&format!("  Service: {}\n", service_id));
612            }
613            output.push_str(&format!("  Cost: {:.2}\n", step.estimated_cost));
614            output.push_str(&format!("  Duration: {}ms\n", step.estimated_duration_ms));
615            if !step.dependencies.is_empty() {
616                output.push_str(&format!("  Depends on steps: {:?}\n", step.dependencies));
617            }
618            if step.can_parallelize {
619                output.push_str("  ✓ Can run in parallel\n");
620            }
621            output.push('\n');
622        }
623
624        if !explanation.parallel_steps.is_empty() {
625            output.push_str("--- PARALLELIZATION OPPORTUNITIES ---\n\n");
626            for (idx, group) in explanation.parallel_steps.iter().enumerate() {
627                output.push_str(&format!("Parallel Group {}: Steps {:?}\n", idx + 1, group));
628            }
629            output.push('\n');
630        }
631
632        if !explanation.critical_path.is_empty() {
633            output.push_str("--- CRITICAL PATH ---\n\n");
634            output.push_str(&format!("Steps: {:?}\n", explanation.critical_path));
635            output.push_str(&format!(
636                "Total Duration: {}ms\n\n",
637                explanation.estimated_duration_ms
638            ));
639        }
640
641        if !explanation.suggestions.is_empty() {
642            output.push_str("--- OPTIMIZATION SUGGESTIONS ---\n\n");
643            for (idx, suggestion) in explanation.suggestions.iter().enumerate() {
644                output.push_str(&format!(
645                    "{}. [{:?}] {:?}: {}\n",
646                    idx + 1,
647                    suggestion.severity,
648                    suggestion.category,
649                    suggestion.description
650                ));
651                output.push_str(&format!(
652                    "   Potential: {}\n",
653                    suggestion.potential_improvement
654                ));
655                output.push_str(&format!(
656                    "   Affects steps: {:?}\n\n",
657                    suggestion.affected_steps
658                ));
659            }
660        }
661
662        output
663    }
664
665    /// Format tree structure
666    fn format_tree(&self, explanation: &PlanExplanation) -> String {
667        let mut output = String::new();
668        output.push_str("Query Execution Tree:\n\n");
669
670        let mut printed = HashSet::new();
671
672        // Start with steps that have no dependencies
673        for step in &explanation.steps {
674            if step.dependencies.is_empty() && !printed.contains(&step.step_id) {
675                self.format_tree_recursive(
676                    &explanation.steps,
677                    &step.step_id,
678                    "",
679                    &mut output,
680                    &mut printed,
681                    0,
682                );
683            }
684        }
685
686        output
687    }
688
689    /// Recursively format tree structure
690    fn format_tree_recursive(
691        &self,
692        steps: &[StepExplanation],
693        step_id: &str,
694        prefix: &str,
695        output: &mut String,
696        printed: &mut HashSet<String>,
697        depth: usize,
698    ) {
699        if depth >= self.config.max_tree_depth || printed.contains(step_id) {
700            return;
701        }
702
703        if let Some(step) = steps.iter().find(|s| s.step_id == step_id) {
704            printed.insert(step_id.to_string());
705
706            output.push_str(&format!(
707                "{}├─ [{}] {} (cost: {:.2}, {}ms)\n",
708                prefix,
709                step.step_id,
710                step.step_type,
711                step.estimated_cost,
712                step.estimated_duration_ms
713            ));
714
715            if let Some(service_id) = &step.service_id {
716                output.push_str(&format!("{}│  Service: {}\n", prefix, service_id));
717            }
718
719            // Find steps that depend on this one
720            let dependents: Vec<String> = steps
721                .iter()
722                .filter(|s| s.dependencies.contains(&step.step_id))
723                .map(|s| s.step_id.clone())
724                .collect();
725
726            for (idx, dependent) in dependents.iter().enumerate() {
727                let is_last = idx == dependents.len() - 1;
728                let new_prefix = if is_last {
729                    format!("{}   ", prefix)
730                } else {
731                    format!("{}│  ", prefix)
732                };
733                self.format_tree_recursive(
734                    steps,
735                    dependent,
736                    &new_prefix,
737                    output,
738                    printed,
739                    depth + 1,
740                );
741            }
742        }
743    }
744
745    /// Format cost analysis
746    fn format_cost_analysis(&self, explanation: &PlanExplanation) -> String {
747        let mut output = String::new();
748
749        output.push_str("=== COST ANALYSIS ===\n\n");
750        output.push_str(&format!(
751            "Total Estimated Cost: {}\n\n",
752            explanation.total_cost
753        ));
754
755        // Group by step type
756        let mut cost_by_type: HashMap<String, f64> = HashMap::new();
757        for step in &explanation.steps {
758            *cost_by_type.entry(step.step_type.clone()).or_insert(0.0) += step.estimated_cost;
759        }
760
761        output.push_str("Cost Breakdown by Operation Type:\n");
762        let mut sorted_types: Vec<_> = cost_by_type.iter().collect();
763        sorted_types.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
764
765        for (step_type, cost) in sorted_types {
766            let percentage = (*cost / explanation.total_cost as f64) * 100.0;
767            output.push_str(&format!(
768                "  {}: {:.2} ({:.1}%)\n",
769                step_type, cost, percentage
770            ));
771        }
772
773        output.push('\n');
774
775        // Group by service
776        let mut cost_by_service: HashMap<String, f64> = HashMap::new();
777        for step in &explanation.steps {
778            if let Some(service_id) = &step.service_id {
779                *cost_by_service.entry(service_id.clone()).or_insert(0.0) += step.estimated_cost;
780            }
781        }
782
783        if !cost_by_service.is_empty() {
784            output.push_str("Cost Breakdown by Service:\n");
785            let mut sorted_services: Vec<_> = cost_by_service.iter().collect();
786            sorted_services
787                .sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
788
789            for (service_id, cost) in sorted_services {
790                let percentage = (*cost / explanation.total_cost as f64) * 100.0;
791                output.push_str(&format!(
792                    "  {}: {:.2} ({:.1}%)\n",
793                    service_id, cost, percentage
794                ));
795            }
796        }
797
798        output
799    }
800}
801
802impl Default for QueryPlanExplainer {
803    fn default() -> Self {
804        Self::new()
805    }
806}
807
808impl fmt::Display for SuggestionSeverity {
809    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
810        match self {
811            Self::Low => write!(f, "LOW"),
812            Self::Medium => write!(f, "MEDIUM"),
813            Self::High => write!(f, "HIGH"),
814            Self::Critical => write!(f, "CRITICAL"),
815        }
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use crate::planner::planning::types::ExecutionStep;
823    use crate::planner::planning::types::StepType;
824
825    fn create_test_plan() -> ExecutionPlan {
826        use std::collections::HashMap;
827        use std::time::Duration;
828
829        ExecutionPlan {
830            query_id: "test_query_1".to_string(),
831            steps: vec![
832                ExecutionStep {
833                    step_id: "step_0".to_string(),
834                    step_type: StepType::ServiceQuery,
835                    service_id: Some("service1".to_string()),
836                    service_url: Some("http://service1.example.com/sparql".to_string()),
837                    auth_config: None,
838                    query_fragment: "SELECT ?s WHERE { ?s rdf:type foaf:Person }".to_string(),
839                    dependencies: vec![],
840                    estimated_cost: 100.0,
841                    timeout: Duration::from_secs(30),
842                    retry_config: None,
843                },
844                ExecutionStep {
845                    step_id: "step_1".to_string(),
846                    step_type: StepType::ServiceQuery,
847                    service_id: Some("service2".to_string()),
848                    service_url: Some("http://service2.example.com/sparql".to_string()),
849                    auth_config: None,
850                    query_fragment: "SELECT ?name WHERE { ?s foaf:name ?name }".to_string(),
851                    dependencies: vec![],
852                    estimated_cost: 80.0,
853                    timeout: Duration::from_secs(30),
854                    retry_config: None,
855                },
856                ExecutionStep {
857                    step_id: "step_2".to_string(),
858                    step_type: StepType::Join,
859                    service_id: None,
860                    service_url: None,
861                    auth_config: None,
862                    query_fragment: String::new(),
863                    dependencies: vec!["step_0".to_string(), "step_1".to_string()],
864                    estimated_cost: 50.0,
865                    timeout: Duration::from_secs(30),
866                    retry_config: None,
867                },
868            ],
869            estimated_total_cost: 230.0,
870            max_parallelism: 2,
871            planning_time: Duration::from_millis(100),
872            cache_key: Some("test_cache_key".to_string()),
873            metadata: HashMap::new(),
874            parallelizable_steps: vec![vec!["step_0".to_string(), "step_1".to_string()]],
875        }
876    }
877
878    #[test]
879    fn test_explainer_creation() {
880        let explainer = QueryPlanExplainer::new();
881        assert!(explainer.config.include_costs);
882        assert!(explainer.config.include_services);
883    }
884
885    #[test]
886    fn test_explain_plan_brief() {
887        let explainer = QueryPlanExplainer::new();
888        let plan = create_test_plan();
889
890        let explanation = explainer.explain_plan(&plan, ExplainFormat::Brief);
891        assert!(explanation.is_ok());
892
893        let output = explanation.unwrap();
894        assert!(output.contains("Total Cost"));
895        assert!(output.contains("Steps: 3"));
896        assert!(output.contains("Services: 2"));
897    }
898
899    #[test]
900    fn test_explain_plan_detailed() {
901        let explainer = QueryPlanExplainer::new();
902        let plan = create_test_plan();
903
904        let explanation = explainer.explain_plan(&plan, ExplainFormat::Detailed);
905        assert!(explanation.is_ok());
906
907        let output = explanation.unwrap();
908        assert!(output.contains("EXECUTION STEPS"));
909        assert!(output.contains("ServiceQuery"));
910        assert!(output.contains("Join"));
911    }
912
913    #[test]
914    fn test_explain_plan_json() {
915        let explainer = QueryPlanExplainer::new();
916        let plan = create_test_plan();
917
918        let explanation = explainer.explain_plan(&plan, ExplainFormat::Json);
919        assert!(explanation.is_ok());
920
921        let output = explanation.unwrap();
922        let parsed: Result<PlanExplanation, _> = serde_json::from_str(&output);
923        assert!(parsed.is_ok());
924    }
925
926    #[test]
927    fn test_explain_plan_tree() {
928        let explainer = QueryPlanExplainer::new();
929        let plan = create_test_plan();
930
931        let explanation = explainer.explain_plan(&plan, ExplainFormat::Tree);
932        assert!(explanation.is_ok());
933
934        let output = explanation.unwrap();
935        assert!(output.contains("Query Execution Tree"));
936        assert!(output.contains("├─"));
937    }
938
939    #[test]
940    fn test_explain_plan_cost_analysis() {
941        let explainer = QueryPlanExplainer::new();
942        let plan = create_test_plan();
943
944        let explanation = explainer.explain_plan(&plan, ExplainFormat::CostAnalysis);
945        assert!(explanation.is_ok());
946
947        let output = explanation.unwrap();
948        assert!(output.contains("COST ANALYSIS"));
949        assert!(output.contains("Cost Breakdown"));
950    }
951
952    #[test]
953    fn test_parallel_step_identification() {
954        let explainer = QueryPlanExplainer::new();
955        let plan = create_test_plan();
956
957        let explanation = explainer.analyze_plan(&plan).unwrap();
958        assert!(!explanation.parallel_steps.is_empty());
959        assert_eq!(
960            explanation.parallel_steps[0],
961            vec!["step_0".to_string(), "step_1".to_string()]
962        );
963    }
964
965    #[test]
966    fn test_optimization_suggestions() {
967        let explainer = QueryPlanExplainer::new();
968        let plan = create_test_plan();
969
970        let explanation = explainer.analyze_plan(&plan).unwrap();
971        assert!(!explanation.suggestions.is_empty());
972
973        // Should suggest parallelization
974        let has_parallel_suggestion = explanation
975            .suggestions
976            .iter()
977            .any(|s| s.category == SuggestionCategory::Parallelization);
978        assert!(has_parallel_suggestion);
979    }
980
981    #[test]
982    fn test_critical_path() {
983        let explainer = QueryPlanExplainer::new();
984        let plan = create_test_plan();
985
986        let explanation = explainer.analyze_plan(&plan).unwrap();
987        assert!(!explanation.critical_path.is_empty());
988
989        // Critical path should contain the join step
990        assert!(explanation.critical_path.contains(&"step_2".to_string()));
991    }
992
993    #[test]
994    fn test_service_dependencies() {
995        let explainer = QueryPlanExplainer::new();
996        let plan = create_test_plan();
997
998        let explanation = explainer.analyze_plan(&plan).unwrap();
999        // Two services, no cross-service dependencies in this simple case
1000        assert_eq!(explanation.service_count, 2);
1001    }
1002
1003    #[test]
1004    fn test_estimated_duration() {
1005        let explainer = QueryPlanExplainer::new();
1006        let plan = create_test_plan();
1007
1008        let explanation = explainer.analyze_plan(&plan).unwrap();
1009        assert!(explanation.estimated_duration_ms > 0);
1010    }
1011}