Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ description = "Soroban Transaction Debugger — From cryptic error to root cause

[workspace.dependencies]
# Stellar / Soroban — Critical Path
stellar-xdr = { version = "20.0.0", features = ["std", "serde"] }
stellar-xdr = { version = "21.2.0", features = ["std", "serde"] }
stellar-strkey = "0.0.9"
soroban-env-host = "21"
soroban-spec = "21"
soroban-env-host = "21.2.0"
soroban-spec = "21.0.0"
soroban-spec-tools = "21.5.3"

# Networking & Serialization
Expand Down Expand Up @@ -48,6 +48,7 @@ redb = "2"
directories = "5"
flate2 = "1"
xz2 = "0.1"
base64 = "0.22"

# TUI Debugger (Tier 3)
ratatui = "0.29"
Expand All @@ -58,4 +59,4 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

# Shared internal crates
prism-core = { path = "crates/core" }
prism-core = { path = "crates/core" }
6 changes: 5 additions & 1 deletion crates/cli/src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

use prism_core::types::report::DiagnosticReport;

use crate::output::renderers::{render_section_header, BudgetBar};
use crate::output::renderers::{render_section_header, render_error_card, BudgetBar};

/// Print a diagnostic report in human-readable colored format.
pub fn print_report(report: &DiagnosticReport) -> anyhow::Result<()> {
// Display the error card prominently at the top
println!("{}", render_error_card(report));
println!();

println!("{}", render_section_header("Transaction Summary"));
println!(
"Error: {} ({}:{})",
Expand Down
206 changes: 186 additions & 20 deletions crates/cli/src/output/renderers.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Shared terminal renderers for CLI output.

use colored::Colorize;
use prism_core::types::report::{DiagnosticReport, SuggestedFix};
use prism_core::types::report::DiagnosticReport;

const BAR_WIDTH: usize = 10;

Expand All @@ -10,6 +10,11 @@ pub fn render_section_header(title: &str) -> String {
SectionHeader::new(title).render()
}

/// Render an error card to display transaction errors prominently.
pub fn render_error_card(report: &DiagnosticReport) -> String {
ErrorCard::new(report).render()
}

/// Utility for rendering a clearly separated section heading.
pub struct SectionHeader<'a> {
title: &'a str,
Expand All @@ -33,6 +38,54 @@ impl<'a> SectionHeader<'a> {
}
}

/// Displays transaction errors with a bold red border and categorical labels.
pub struct ErrorCard<'a> {
report: &'a DiagnosticReport,
}

impl<'a> ErrorCard<'a> {
pub fn new(report: &'a DiagnosticReport) -> Self {
Self { report }
}

pub fn render(&self) -> String {
let mut output = String::new();

// Create the border and content structure
let category_badge = format!("[{}]", self.report.error_category.to_uppercase());
let error_line = format!(
" {} ({})",
self.report.error_name, self.report.error_code
);

// Calculate width based on content
let max_width = error_line.len().max(self.report.summary.len()).max(category_badge.len()) + 4;
let border = "█".repeat(max_width);

// Render with red color
let border_colored = border.red().bold().to_string();
let category_colored = category_badge.red().bold().to_string();
let error_colored = error_line.red().bold().to_string();
let summary_colored = self.report.summary.white().to_string();

// Build the card
output.push_str(&format!("{}\n", border_colored));
output.push_str(&format!("{} {}\n", "█".red().bold(), category_colored));
output.push_str(&format!("{} {}\n", "█".red().bold(), error_colored));

// Add component info if it's a contract error
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(), summary_colored));
output.push_str(&format!("{}\n", border_colored));

output
}
}

/// Renders a colored budget utilization bar for Soroban resource usage.
pub struct BudgetBar {
label: &'static str,
Expand All @@ -46,6 +99,7 @@ impl BudgetBar {
}

pub fn render(&self) -> String {
<<<<<<< HEAD
let ratio = if self.limit == 0 {
0.0f64
} else {
Expand Down Expand Up @@ -104,33 +158,44 @@ pub fn render_fix_list(report: &DiagnosticReport) -> String {
output.push('\n');
}
}
=======
let percentage = if self.limit > 0 {
(self.used as f64 / self.limit as f64 * 100.0) as u64
} else {
0
};

output
}
let filled = (percentage / 10).min(BAR_WIDTH as u64) as usize;
let empty = BAR_WIDTH - filled;
let bar = format!(
"[{}{}]",
"█".repeat(filled),
"░".repeat(empty)
);
>>>>>>> 71d530f ( Implement ErrorCard for terminal)

/// Returns the appropriate icon for a suggested fix based on its characteristics.
fn get_fix_icon(fix: &SuggestedFix) -> &'static str {
if fix.requires_upgrade {
"🔒"
} else if fix.example.is_some() {
"📋"
} else {
"🔧"
}
}
let bar_colored = if percentage > 90 {
bar.red().to_string()
} else if percentage > 70 {
bar.yellow().to_string()
} else {
bar.green().to_string()
};

/// Returns a badge indicating the difficulty level of the fix.
fn get_difficulty_badge(difficulty: &str) -> String {
match difficulty.to_lowercase().as_str() {
"easy" => " [easy]".to_string(),
"medium" => " [medium]".to_string(),
"hard" => " [hard]".to_string(),
_ => String::new(),
format!(
"{:8} {} {}/{} ({:3}%)",
self.label,
bar_colored,
self.used,
self.limit,
percentage
)
}
}

#[cfg(test)]
mod tests {
<<<<<<< HEAD
use super::{get_difficulty_badge, get_fix_icon, render_fix_list};
use super::{render_section_header, BudgetBar, SectionHeader};
use prism_core::types::report::{DiagnosticReport, SuggestedFix};
Expand Down Expand Up @@ -199,6 +264,30 @@ mod tests {
assert_eq!(get_difficulty_badge("medium"), " [medium]");
assert_eq!(get_difficulty_badge("hard"), " [hard]");
assert_eq!(get_difficulty_badge("unknown"), "");
=======
use super::*;
use prism_core::types::report::{ContractErrorInfo, Severity};

fn create_test_report() -> DiagnosticReport {
DiagnosticReport {
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(),
detailed_explanation: String::new(),
severity: Severity::Error,
root_causes: Vec::new(),
suggested_fixes: Vec::new(),
contract_error: Some(ContractErrorInfo {
contract_id: "CBDLTOJWR2YX2U6BR3P5C4UXKWHE5DJW3JPSIOEXTW2E7D5JUDPQULE7".to_string(),
error_code: 1,
error_name: Some("InsufficientBalance".to_string()),
doc_comment: Some("User attempted transfer with insufficient balance".to_string()),
}),
transaction_context: None,
related_errors: Vec::new(),
}
>>>>>>> 71d530f ( Implement ErrorCard for terminal)
}

#[test]
Expand All @@ -216,15 +305,92 @@ mod tests {
}

#[test]
<<<<<<< HEAD
fn budget_bar_renders_green_when_low_usage() {
let bar = BudgetBar::new("CPU", 100, 1000).render();
assert!(bar.contains("CPU"));
assert!(bar.contains("10%"));
=======
fn error_card_renders_basic_error() {
let report = create_test_report();
let rendered = render_error_card(&report);

assert!(rendered.contains("❌"));
assert!(rendered.contains("InsufficientBalance"));
assert!(rendered.contains("1"));
assert!(rendered.contains("[CONTRACT]"));
assert!(rendered.contains("does not have enough balance"));
}

#[test]
fn error_card_includes_contract_component() {
let report = create_test_report();
let rendered = render_error_card(&report);

assert!(rendered.contains("Component:"));
assert!(rendered.contains("CBDLTOJWR2YX2U6BR3P5C4UXKWHE5DJW3JPSIOEXTW2E7D5JUDPQULE7"));
}

#[test]
fn error_card_excludes_component_without_contract_error() {
let mut report = create_test_report();
report.contract_error = None;
let rendered = render_error_card(&report);

assert!(!rendered.contains("Component:"));
}

#[test]
fn error_card_uses_red_styling() {
let report = create_test_report();
let rendered = render_error_card(&report);

// The card should contain the border characters
assert!(rendered.contains("█"));
}

#[test]
fn budget_bar_renders_low_usage() {
let bar = BudgetBar::new("CPU", 100, 1000);
let rendered = bar.render();

assert!(rendered.contains("CPU"));
assert!(rendered.contains("100/1000"));
assert!(rendered.contains("10%"));
}

#[test]
fn budget_bar_renders_high_usage() {
let bar = BudgetBar::new("Memory", 950, 1000);
let rendered = bar.render();

assert!(rendered.contains("Memory"));
assert!(rendered.contains("950/1000"));
assert!(rendered.contains("95%"));
>>>>>>> 71d530f ( Implement ErrorCard for terminal)
}

#[test]
fn budget_bar_handles_zero_limit() {
<<<<<<< HEAD
let bar = BudgetBar::new("MEM", 0, 0).render();
assert!(bar.contains("MEM"));
=======
let bar = BudgetBar::new("CPU", 100, 0);
let rendered = bar.render();

assert!(rendered.contains("CPU"));
assert!(rendered.contains("0%"));
}

#[test]
fn budget_bar_shows_full_usage() {
let bar = BudgetBar::new("Disk", 1000, 1000);
let rendered = bar.render();

assert!(rendered.contains("Disk"));
assert!(rendered.contains("1000/1000"));
assert!(rendered.contains("100%"));
>>>>>>> 71d530f ( Implement ErrorCard for terminal)
}
}
2 changes: 1 addition & 1 deletion crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ anyhow = { workspace = true }

[dev-dependencies]
tokio-test = "0.4"
insta = { version = "1", features = ["json"] }
insta = { version = "1", features = ["json"] }