diff --git a/crates/cli/src/output/human.rs b/crates/cli/src/output/human.rs index fb0e3fbc..1cf02742 100644 --- a/crates/cli/src/output/human.rs +++ b/crates/cli/src/output/human.rs @@ -1,8 +1,8 @@ - - use prism_core::types::report::DiagnosticReport; -use crate::output::renderers::{render_section_header, render_error_card, render_fix_list, BudgetBar}; +use crate::output::renderers::{ + render_cause_list, render_error_card, render_fix_list, render_section_header, BudgetBar, +}; pub fn print_report(report: &DiagnosticReport) -> anyhow::Result<()> { println!("{}", render_error_card(report)); @@ -38,6 +38,11 @@ pub fn print_report(report: &DiagnosticReport) -> anyhow::Result<()> { ); } + if !report.root_causes.is_empty() { + println!(); + println!("{}", render_cause_list(&report.root_causes)); + } + if !report.suggested_fixes.is_empty() { println!(); println!("{}", render_fix_list(&report.suggested_fixes)); diff --git a/crates/cli/src/output/renderers.rs b/crates/cli/src/output/renderers.rs index 15f5830e..4c6bb764 100644 --- a/crates/cli/src/output/renderers.rs +++ b/crates/cli/src/output/renderers.rs @@ -1,12 +1,10 @@ - - #![allow(dead_code)] +use crate::output::theme::ColorPalette; use colored::Colorize; -use prism_core::types::report::{DiagnosticReport, TransactionContext}; +use prism_core::types::report::{DiagnosticReport, RootCause, TransactionContext}; use prism_core::types::trace::ResourceProfile; use tabled::{Table, Tabled}; -use crate::output::theme::ColorPalette; const BAR_WIDTH: usize = 10; const HEAT_BLOCKS: [&str; 4] = ["░", "▒", "▓", "█"]; @@ -23,6 +21,10 @@ pub fn render_fix_list(fixes: &[prism_core::types::report::SuggestedFix]) -> Str FixList::new(fixes).render() } +pub fn render_cause_list(causes: &[RootCause]) -> String { + CauseList::new(causes).render() +} + pub fn render_state_diff_table(diff: &prism_core::types::trace::StateDiff) -> String { StateDiffTable::new(diff).render() } @@ -64,12 +66,13 @@ impl<'a> ErrorCard<'a> { let mut output = String::new(); let category_badge = format!("[{}]", self.report.error_category.to_uppercase()); - let error_line = format!( - " {} ({})", - self.report.error_name, self.report.error_code - ); + let error_line = format!(" {} ({})", self.report.error_name, self.report.error_code); - let max_width = error_line.len().max(self.report.summary.len()).max(category_badge.len()) + 4; + let max_width = error_line + .len() + .max(self.report.summary.len()) + .max(category_badge.len()) + + 4; let border = "█".repeat(max_width); let border_colored = border.red().bold().to_string(); @@ -83,7 +86,11 @@ impl<'a> ErrorCard<'a> { if let Some(contract_error) = &self.report.contract_error { let component_line = format!("Component: {}", contract_error.contract_id); - output.push_str(&format!("{} {}\n", "█".red().bold(), component_line.white())); + output.push_str(&format!( + "{} {}\n", + "█".red().bold(), + component_line.white() + )); } output.push_str(&format!("{} {}\n", "█".red().bold(), summary_colored)); @@ -128,6 +135,34 @@ impl<'a> FixList<'a> { } } +pub struct CauseList<'a> { + causes: &'a [RootCause], +} + +impl<'a> CauseList<'a> { + pub fn new(causes: &'a [RootCause]) -> Self { + Self { causes } + } + + pub fn render(&self) -> String { + if self.causes.is_empty() { + return String::new(); + } + + let mut output = String::new(); + let palette = ColorPalette::default(); + + output.push_str(&palette.accent_text("COMMON CAUSES\n")); + + for cause in self.causes { + let likelihood = format!("[{}]", cause.likelihood).cyan(); + output.push_str(&format!(" - {} {}\n", likelihood, cause.description)); + } + + output + } +} + /// Renders a colored budget utilization bar for Soroban resource usage. pub struct BudgetBar { label: &'static str, @@ -414,7 +449,8 @@ mod tests { error_category: "Contract".to_string(), error_code: 1, error_name: "InsufficientBalance".to_string(), - summary: "The account does not have enough balance to complete this transaction.".to_string(), + summary: "The account does not have enough balance to complete this transaction." + .to_string(), detailed_explanation: String::new(), severity: Severity::Error, root_causes: Vec::new(), @@ -448,15 +484,13 @@ mod tests { #[test] fn heatmap_renders_function_names() { - let profile = make_profile(vec![ - ResourceHotspot { - location: "transfer::invoke".to_string(), - cpu_instructions: 800_000, - cpu_percentage: 80.0, - memory_bytes: 300_000, - memory_percentage: 30.0, - }, - ]); + let profile = make_profile(vec![ResourceHotspot { + location: "transfer::invoke".to_string(), + cpu_instructions: 800_000, + cpu_percentage: 80.0, + memory_bytes: 300_000, + memory_percentage: 30.0, + }]); let output = render_heatmap(&profile); assert!(output.contains("transfer::invoke")); } @@ -470,6 +504,21 @@ mod tests { assert!(rendered.contains("does not have enough balance")); } + #[test] + fn cause_list_renders_common_causes() { + let causes = vec![RootCause { + description: "The transaction was submitted with an undersized resource budget." + .to_string(), + likelihood: "high".to_string(), + }]; + + let rendered = render_cause_list(&causes); + + assert!(rendered.contains("COMMON CAUSES")); + assert!(rendered.contains("undersized resource budget")); + assert!(rendered.contains("high")); + } + #[test] fn render_context_table_with_arguments() { let context = TransactionContext { diff --git a/crates/core/src/decode/report.rs b/crates/core/src/decode/report.rs index b221c8ed..e4141c43 100644 --- a/crates/core/src/decode/report.rs +++ b/crates/core/src/decode/report.rs @@ -1,8 +1,6 @@ - - use crate::decode::host_error::ClassifiedError; -use crate::taxonomy::loader::TaxonomyDatabase; use crate::error::PrismResult; +use crate::taxonomy::loader::TaxonomyDatabase; use crate::types::report::{DiagnosticReport, RootCause, Severity, SuggestedFix}; pub fn build_report(error: &ClassifiedError) -> PrismResult { @@ -59,3 +57,34 @@ pub fn build_report(error: &ClassifiedError) -> PrismResult { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::taxonomy::schema::ErrorCategory; + + #[test] + fn tier1_common_causes_surface_in_decoded_report() { + let classified = ClassifiedError { + category: ErrorCategory::Budget, + error_code: 0, + is_contract_error: false, + contract_id: None, + raw_data: serde_json::Value::Null, + }; + + let report = build_report(&classified).expect("Report should build"); + + assert!( + !report.root_causes.is_empty(), + "Decoded report should include at least one common cause" + ); + assert!( + report + .root_causes + .iter() + .any(|cause| cause.description.contains("loops")), + "Decoded report should include taxonomy common cause descriptions" + ); + } +} diff --git a/crates/core/src/taxonomy/loader.rs b/crates/core/src/taxonomy/loader.rs index 7fa83fd6..7905ebc0 100644 --- a/crates/core/src/taxonomy/loader.rs +++ b/crates/core/src/taxonomy/loader.rs @@ -1,29 +1,23 @@ - - -use crate::taxonomy::schema::{ErrorCategory, TaxonomyEntry, TaxonomySchema}; use crate::error::{PrismError, PrismResult}; +use crate::taxonomy::schema::{ErrorCategory, TaxonomyEntry, TaxonomySchema}; use std::collections::HashMap; pub struct TaxonomyParser; impl TaxonomyParser { - pub fn parse(input: &str) -> PrismResult { - toml::from_str(input).map_err(|e| { - PrismError::TaxonomyError(format!("TOML parse error: {e}")) - }) + toml::from_str(input) + .map_err(|e| PrismError::TaxonomyError(format!("TOML parse error: {e}"))) } } pub struct TaxonomyDatabase { - entries: HashMap<(ErrorCategory, u32), TaxonomyEntry>, all_entries: Vec, } impl TaxonomyDatabase { - pub fn load_embedded() -> PrismResult { let mut db = Self { entries: HashMap::new(), @@ -105,6 +99,10 @@ impl TaxonomyDatabase { .collect() } + pub fn all_entries(&self) -> &[TaxonomyEntry] { + &self.all_entries + } + pub fn len(&self) -> usize { self.entries.len() } @@ -153,4 +151,35 @@ mod tests { let result = TaxonomyParser::parse(toml); assert!(result.is_err()); } + + #[test] + fn tier1_entries_with_fixes_have_common_causes() { + let db = TaxonomyDatabase::load_embedded().expect("Taxonomy should load"); + assert!(!db.is_empty(), "Taxonomy should contain entries"); + + for entry in db.all_entries() { + if entry.suggested_fixes.is_empty() { + continue; + } + + assert!( + !entry.common_causes.is_empty(), + "{} has suggested fixes but no common causes", + entry.id + ); + assert!( + entry.common_causes.len() <= 3, + "{} has more than three common causes", + entry.id + ); + assert!( + entry + .common_causes + .iter() + .all(|cause| !cause.description.trim().is_empty()), + "{} has an empty common cause description", + entry.id + ); + } + } } diff --git a/crates/core/src/taxonomy/schema.rs b/crates/core/src/taxonomy/schema.rs index d59cce2f..fb41fa04 100644 --- a/crates/core/src/taxonomy/schema.rs +++ b/crates/core/src/taxonomy/schema.rs @@ -1,10 +1,7 @@ - - use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaxonomyEntry { - pub id: String, pub category: ErrorCategory, @@ -23,10 +20,13 @@ pub struct TaxonomyEntry { pub detailed_explanation: String, + #[serde(default)] pub common_causes: Vec, + #[serde(default)] pub suggested_fixes: Vec, + #[serde(default)] pub related_errors: Vec, pub source_file: Option, @@ -38,7 +38,6 @@ pub struct TaxonomyEntry { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaxonomyCause { - pub description: String, pub likelihood: String, @@ -46,7 +45,6 @@ pub struct TaxonomyCause { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaxonomyFix { - pub description: String, pub difficulty: String,