use crate::tui::security::LicenseRisk;
use crate::tui::theme::colors;
use ratatui::{
prelude::*,
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::collections::{HashMap, HashSet, VecDeque};
pub fn compute_blast_radius(
component_name: &str,
reverse_graph: &HashMap<String, Vec<String>>,
) -> (usize, usize) {
let direct_deps = reverse_graph
.get(component_name)
.map_or(0, std::vec::Vec::len);
let mut transitive_count = 0usize;
if direct_deps > 0 {
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
if let Some(deps) = reverse_graph.get(component_name) {
for d in deps {
queue.push_back(d.clone());
}
}
while let Some(node) = queue.pop_front() {
if visited.insert(node.clone()) {
transitive_count += 1;
if let Some(deps) = reverse_graph.get(&node) {
for d in deps {
if !visited.contains(d) {
queue.push_back(d.clone());
}
}
}
}
}
}
(direct_deps, transitive_count)
}
pub fn determine_risk_level(vuln_count: usize, transitive_count: usize) -> (&'static str, Color) {
if vuln_count > 0 && transitive_count > 10 {
("Critical", colors().critical)
} else if vuln_count > 0 || transitive_count > 20 {
("High", colors().high)
} else if transitive_count > 5 {
("Medium", colors().medium)
} else {
("Low", colors().low)
}
}
pub fn render_security_analysis_lines(
vuln_count: usize,
direct_deps: usize,
transitive_count: usize,
license_text: &str,
) -> Vec<Line<'static>> {
let mut lines = vec![];
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("━━━ ", Style::default().fg(colors().border)),
Span::styled(
"Security Analysis",
Style::default().fg(colors().accent).bold(),
),
Span::styled(" ━━━", Style::default().fg(colors().border)),
]));
let (risk_level, risk_color) = determine_risk_level(vuln_count, transitive_count);
lines.push(Line::from(vec![
Span::styled(" Risk Level: ", Style::default().fg(colors().text_muted)),
Span::styled(
format!(" {risk_level} "),
Style::default()
.fg(colors().badge_fg_dark)
.bg(risk_color)
.bold(),
),
]));
if direct_deps == 0 && transitive_count == 0 {
lines.push(Line::from(vec![
Span::styled(" Blast Radius: ", Style::default().fg(colors().text_muted)),
Span::styled(
"None (no dependents)",
Style::default().fg(colors().text_muted),
),
]));
} else {
lines.push(Line::from(vec![
Span::styled(" Blast Radius: ", Style::default().fg(colors().text_muted)),
Span::styled(
format!("{direct_deps} direct"),
Style::default().fg(if direct_deps > 5 {
colors().warning
} else {
colors().text
}),
),
Span::styled(", ", Style::default().fg(colors().text_muted)),
Span::styled(
format!("{transitive_count} transitive"),
Style::default().fg(if transitive_count > 10 {
colors().warning
} else {
colors().text
}),
),
]));
}
if transitive_count > 0 {
let impact = if transitive_count > 50 {
"Critical - affects many components"
} else if transitive_count > 20 {
"Significant impact"
} else if transitive_count > 5 {
"Moderate impact"
} else {
"Limited impact"
};
lines.push(Line::from(vec![
Span::styled(" Impact: ", Style::default().fg(colors().text_muted)),
Span::styled(impact, Style::default().fg(colors().text).italic()),
]));
}
let license_risk = LicenseRisk::from_license(license_text);
let license_risk_color = match license_risk {
LicenseRisk::High => colors().high,
LicenseRisk::Medium => colors().medium,
LicenseRisk::Low => colors().permissive,
LicenseRisk::None => colors().text_muted,
};
lines.push(Line::from(vec![
Span::styled(" License Risk: ", Style::default().fg(colors().text_muted)),
Span::styled(
license_risk.as_str(),
Style::default().fg(license_risk_color),
),
]));
lines
}
pub fn render_quick_actions_hint(has_vulns: bool) -> Vec<Line<'static>> {
let mut spans = vec![
Span::styled("[y]", Style::default().fg(colors().accent)),
Span::styled(" copy ", Style::default().fg(colors().text_muted)),
Span::styled("[F]", Style::default().fg(colors().accent)),
Span::styled(" flag ", Style::default().fg(colors().text_muted)),
Span::styled("[n]", Style::default().fg(colors().accent)),
Span::styled(" note", Style::default().fg(colors().text_muted)),
];
if has_vulns {
spans.push(Span::styled(" ", Style::default()));
spans.push(Span::styled("[o]", Style::default().fg(colors().accent)));
spans.push(Span::styled(
" CVE",
Style::default().fg(colors().text_muted),
));
}
vec![Line::from(""), Line::from(spans)]
}
pub fn render_vulnerability_list_lines(
vulns: &[(&str, &str, Option<&str>)],
max_display: usize,
total_count: usize,
area_width: u16,
) -> Vec<Line<'static>> {
let mut lines = vec![];
for (severity, id, description) in vulns.iter().take(max_display) {
let sev_color = colors().severity_color(severity);
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
format!(" {} ", severity.chars().next().unwrap_or('?')),
Style::default()
.fg(colors().badge_fg_dark)
.bg(sev_color)
.bold(),
),
Span::raw(" "),
Span::styled(id.to_string(), Style::default().fg(sev_color).bold()),
]));
if let Some(desc) = description {
lines.push(Line::from(vec![Span::styled(
format!(
" {}",
crate::tui::widgets::truncate_str(desc, area_width as usize - 6)
),
Style::default().fg(colors().text_muted).italic(),
)]));
}
}
if total_count > max_display {
lines.push(Line::styled(
format!(" ... and {} more", total_count - max_display),
Style::default().fg(colors().text_muted),
));
}
lines
}
pub fn render_flagged_lines(
is_flagged: bool,
note: Option<&str>,
area_width: u16,
suffix: &str,
) -> Vec<Line<'static>> {
if !is_flagged {
return vec![];
}
let scheme = colors();
let mut lines = vec![];
let mut badge_spans = vec![
Span::styled(" ", Style::default()),
Span::styled(
" ! FLAGGED ",
Style::default()
.fg(scheme.badge_fg_dark)
.bg(scheme.warning)
.bold(),
),
];
if !suffix.is_empty() {
badge_spans.push(Span::styled(
suffix.to_string(),
Style::default().fg(scheme.warning),
));
}
lines.push(Line::from(badge_spans));
if let Some(note) = note {
lines.push(Line::from(vec![
Span::styled(" Note: ", Style::default().fg(scheme.text_muted)),
Span::styled(
crate::tui::widgets::truncate_str(note, area_width as usize - 10),
Style::default().fg(scheme.text).italic(),
),
]));
}
lines
}
#[must_use]
pub fn render_component_info_lines(
component: &crate::model::Component,
depth: Option<usize>,
deps_out: usize,
deps_in: usize,
) -> Vec<Line<'static>> {
let scheme = colors();
let mut lines = vec![];
lines.push(Line::from(vec![
Span::styled("Name: ", Style::default().fg(scheme.text_muted)),
Span::styled(
component.name.clone(),
Style::default().fg(scheme.text).bold(),
),
]));
if let Some(ref ver) = component.version {
lines.push(Line::from(vec![
Span::styled("Version: ", Style::default().fg(scheme.text_muted)),
Span::styled(ver.clone(), Style::default().fg(scheme.text)),
]));
}
if let Some(ref eco) = component.ecosystem {
lines.push(Line::from(vec![
Span::styled("Ecosystem: ", Style::default().fg(scheme.text_muted)),
Span::styled(format!("{eco:?}"), Style::default().fg(scheme.accent)),
]));
}
lines.push(Line::from(vec![
Span::styled("Type: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format!("{:?}", component.component_type),
Style::default().fg(scheme.text),
),
]));
if let Some(d) = depth {
let label = match d {
0 => "Root",
1 => "Direct",
_ => "Transitive",
};
let depth_color = match d {
0 => scheme.primary,
1 => scheme.accent,
_ => scheme.text_muted,
};
lines.push(Line::from(vec![
Span::styled("Depth: ", Style::default().fg(scheme.text_muted)),
Span::styled(format!("D{d}"), Style::default().fg(depth_color).bold()),
Span::styled(
format!(" ({label})"),
Style::default().fg(scheme.text_muted),
),
]));
}
if deps_out > 0 || deps_in > 0 {
lines.push(Line::from(vec![
Span::styled("Dependencies: ", Style::default().fg(scheme.text_muted)),
Span::styled(deps_out.to_string(), Style::default().fg(scheme.primary)),
Span::styled(" Dependents: ", Style::default().fg(scheme.text_muted)),
Span::styled(deps_in.to_string(), Style::default().fg(scheme.primary)),
]));
}
if !component.licenses.declared.is_empty() {
let license_strs: Vec<String> = component
.licenses
.declared
.iter()
.take(3)
.map(|l| l.expression.clone())
.collect();
lines.push(Line::from(vec![
Span::styled("Licenses: ", Style::default().fg(scheme.text_muted)),
Span::styled(license_strs.join(", "), Style::default().fg(scheme.text)),
]));
if component.licenses.declared.len() > 3 {
lines.push(Line::styled(
format!(" ... and {} more", component.licenses.declared.len() - 3),
Style::default().fg(scheme.text_muted),
));
}
}
if !component.hashes.is_empty() {
for hash in component.hashes.iter().take(2) {
let truncated_value = if hash.value.len() > 16 {
format!("{}...", &hash.value[..16])
} else {
hash.value.clone()
};
lines.push(Line::from(vec![
Span::styled(
format!("{}: ", hash.algorithm),
Style::default().fg(scheme.text_muted),
),
Span::styled(truncated_value, Style::default().fg(scheme.text)),
]));
}
if component.hashes.len() > 2 {
lines.push(Line::styled(
format!(" ... and {} more", component.hashes.len() - 2),
Style::default().fg(scheme.text_muted),
));
}
}
if let Some(ref supplier) = component.supplier {
lines.push(Line::from(vec![
Span::styled("Supplier: ", Style::default().fg(scheme.text_muted)),
Span::styled(supplier.name.clone(), Style::default().fg(scheme.text)),
]));
}
if !component.vulnerabilities.is_empty() {
let count = component.vulnerabilities.len();
let mut vuln_spans = vec![
Span::styled("Vulns: ", Style::default().fg(scheme.text_muted)),
Span::styled(
count.to_string(),
Style::default().fg(scheme.critical).bold(),
),
];
let ids: Vec<String> = component
.vulnerabilities
.iter()
.take(3)
.map(|v| v.id.clone())
.collect();
if !ids.is_empty() {
vuln_spans.push(Span::styled(
format!(" ({})", ids.join(", ")),
Style::default().fg(scheme.text_muted),
));
}
lines.push(Line::from(vuln_spans));
}
if let Some(ref purl) = component.identifiers.purl {
lines.push(Line::from(vec![
Span::styled("PURL: ", Style::default().fg(scheme.text_muted)),
Span::styled(purl.clone(), Style::default().fg(scheme.accent)),
]));
}
lines
}
pub fn render_detail_block(
frame: &mut Frame,
area: Rect,
lines: Vec<Line<'_>>,
title: &str,
focused: bool,
) {
let scheme = colors();
let border_color = if focused {
scheme.accent
} else {
scheme.border
};
let title_style = if focused {
Style::default().fg(scheme.accent).bold()
} else {
Style::default().fg(scheme.text_muted)
};
let detail = Paragraph::new(lines)
.block(
Block::default()
.title(title)
.title_style(title_style)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: true });
frame.render_widget(detail, area);
}
pub fn render_empty_detail_panel(
frame: &mut Frame,
area: Rect,
title: &str,
icon: &str,
message: &str,
hints: &[(&str, &str)],
focused: bool,
) {
let scheme = colors();
let border_color = if focused {
scheme.accent
} else {
scheme.border
};
let title_style = if focused {
Style::default().fg(scheme.accent).bold()
} else {
Style::default().fg(scheme.text_muted)
};
let mut text = vec![
Line::from(""),
Line::styled(icon.to_string(), Style::default().fg(scheme.text_muted)),
Line::from(""),
Line::styled(message.to_string(), Style::default().fg(scheme.text)),
Line::from(""),
];
if !hints.is_empty() {
let mut hint_spans = Vec::new();
for (key, desc) in hints {
hint_spans.push(Span::styled(
key.to_string(),
Style::default().fg(scheme.accent),
));
hint_spans.push(Span::styled(
desc.to_string(),
Style::default().fg(scheme.text_muted),
));
}
text.push(Line::from(hint_spans));
}
let detail = Paragraph::new(text)
.block(
Block::default()
.title(title)
.title_style(title_style)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.alignment(Alignment::Center);
frame.render_widget(detail, area);
}