use crate::tui::app::{AppMode, LicenseGroupBy, LicenseRiskFilter, LicenseSort};
use crate::tui::license_conflicts::{ConflictDetector, ConflictSeverity};
use crate::tui::license_utils::{
LicenseCategory, LicenseInfo, RiskLevel, SpdxExpression, analyze_license_compatibility,
};
use crate::tui::render_context::RenderContext;
use crate::tui::theme::colors;
use crate::tui::widgets;
use ratatui::{
prelude::*,
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState},
};
use std::collections::HashMap;
pub fn render_licenses(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(widgets::FILTER_BAR_HEIGHT),
Constraint::Min(5),
])
.split(area);
render_filter_bar(frame, chunks[0], ctx);
match ctx.mode {
AppMode::Diff | AppMode::View => render_diff_licenses(frame, chunks[1], ctx),
AppMode::MultiDiff | AppMode::Timeline | AppMode::Matrix => {}
}
}
fn render_filter_bar(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let scheme = colors();
let group = &ctx.licenses.group_by;
let sort = &ctx.licenses.sort_by;
let is_diff_mode = ctx.mode == AppMode::Diff;
let group_label = match group {
LicenseGroupBy::License => "License",
LicenseGroupBy::Component => "Component",
LicenseGroupBy::Compatibility => "Category",
LicenseGroupBy::Family => "Family",
LicenseGroupBy::Risk => "Risk",
};
let sort_label = match sort {
LicenseSort::License => "License",
LicenseSort::Count => "Count",
LicenseSort::Permissiveness => "Permissive",
LicenseSort::Risk => "Risk",
};
let risk_filter_label = match ctx.licenses.risk_filter {
None => "All",
Some(LicenseRiskFilter::Low) => "Low+",
Some(LicenseRiskFilter::Medium) => "Medium+",
Some(LicenseRiskFilter::High) => "High+",
Some(LicenseRiskFilter::Critical) => "Critical",
};
let compat_label = if ctx.licenses.show_compatibility {
"On"
} else {
"Off"
};
let mut spans = vec![
Span::styled("Group: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format!(" {group_label} "),
Style::default()
.fg(scheme.badge_fg_dark)
.bg(scheme.primary)
.bold(),
),
Span::raw(" "),
Span::styled("Sort: ", Style::default().fg(scheme.text_muted)),
Span::styled(sort_label, Style::default().fg(scheme.accent).bold()),
Span::styled(" │ ", Style::default().fg(scheme.border)),
Span::styled("Risk: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format!(" {risk_filter_label} "),
Style::default()
.fg(scheme.badge_fg_dark)
.bg(if ctx.licenses.risk_filter.is_some() {
scheme.warning
} else {
scheme.success
})
.bold(),
),
Span::styled(" │ ", Style::default().fg(scheme.border)),
Span::styled("Compat: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format!(" {compat_label} "),
Style::default()
.fg(scheme.badge_fg_dark)
.bg(if ctx.licenses.show_compatibility {
scheme.accent
} else {
scheme.border
})
.bold(),
),
];
if is_diff_mode {
let focus_label = if ctx.licenses.focus_left {
"New"
} else {
"Removed"
};
spans.push(Span::styled(" │ ", Style::default().fg(scheme.border)));
spans.push(Span::styled(
"Focus: ",
Style::default().fg(scheme.text_muted),
));
spans.push(Span::styled(
format!(" {focus_label} "),
Style::default()
.fg(scheme.badge_fg_dark)
.bg(if ctx.licenses.focus_left {
scheme.added
} else {
scheme.removed
})
.bold(),
));
}
spans.push(Span::styled(" │ ", Style::default().fg(scheme.border)));
spans.push(Span::styled("[g]", Style::default().fg(scheme.accent)));
spans.push(Span::styled(
" grp ",
Style::default().fg(scheme.text_muted),
));
spans.push(Span::styled("[s]", Style::default().fg(scheme.accent)));
spans.push(Span::styled(
" sort ",
Style::default().fg(scheme.text_muted),
));
spans.push(Span::styled("[r]", Style::default().fg(scheme.accent)));
spans.push(Span::styled(
" risk ",
Style::default().fg(scheme.text_muted),
));
spans.push(Span::styled("[c]", Style::default().fg(scheme.accent)));
spans.push(Span::styled(
" compat",
Style::default().fg(scheme.text_muted),
));
if is_diff_mode {
spans.push(Span::styled(" [Tab]", Style::default().fg(scheme.accent)));
spans.push(Span::styled(
" panel",
Style::default().fg(scheme.text_muted),
));
}
let paragraph = Paragraph::new(Line::from(spans)).block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(scheme.border)),
);
frame.render_widget(paragraph, area);
}
fn render_diff_licenses(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let Some(result) = ctx.diff_result else {
crate::tui::widgets::render_empty_state_enhanced(
frame,
area,
"--",
"No license data available",
Some("License analysis requires a completed diff"),
None,
);
return;
};
if result.licenses.new_licenses.is_empty() && result.licenses.removed_licenses.is_empty() {
crate::tui::widgets::render_empty_state_enhanced(
frame,
area,
"✓",
"No license changes detected",
Some("All licenses remain the same between both SBOMs"),
None,
);
return;
}
let main_chunks = if ctx.licenses.show_compatibility {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(area)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints(widgets::MASTER_DETAIL_SPLIT)
.split(area)
};
let list_area = if ctx.licenses.show_compatibility {
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_chunks[0]);
render_compatibility_panel(frame, main_chunks[1], ctx);
left_chunks
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_chunks[0])
};
let sort = ctx.licenses.sort_by;
let group = ctx.licenses.group_by;
let risk_filter = ctx.licenses.risk_filter;
let new_licenses = build_license_list(&result.licenses.new_licenses, sort, group, risk_filter);
let removed_licenses =
build_license_list(&result.licenses.removed_licenses, sort, group, risk_filter);
render_license_table(
frame,
list_area[0],
&new_licenses,
" + New Licenses ",
true,
ctx.licenses.focus_left,
if ctx.licenses.focus_left {
Some(ctx.licenses.selected)
} else {
None
},
ctx.licenses.scroll_offset_new,
group,
);
render_license_table(
frame,
list_area[1],
&removed_licenses,
" - Removed Licenses ",
false,
!ctx.licenses.focus_left,
if ctx.licenses.focus_left {
None
} else {
Some(ctx.licenses.selected)
},
ctx.licenses.scroll_offset_removed,
group,
);
if !ctx.licenses.show_compatibility {
let selected_license = if ctx.licenses.focus_left {
new_licenses.get(ctx.licenses.selected)
} else {
removed_licenses.get(ctx.licenses.selected)
};
render_license_details(
frame,
main_chunks[1],
selected_license,
ctx.licenses.focus_left,
ctx.diff_result,
);
}
}
fn render_compatibility_panel(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let scheme = colors();
let Some(result) = ctx.diff_result else {
return;
};
let all_licenses: Vec<&str> = result
.licenses
.new_licenses
.iter()
.map(|l| l.license.as_str())
.chain(
result
.licenses
.removed_licenses
.iter()
.map(|l| l.license.as_str()),
)
.collect();
let report = analyze_license_compatibility(&all_licenses);
let mut lines = vec![];
let score_color = if report.overall_score >= 80 {
scheme.success
} else if report.overall_score >= 50 {
scheme.warning
} else {
scheme.error
};
lines.push(Line::from(vec![
Span::styled(
"Compatibility Score: ",
Style::default().fg(scheme.text_muted),
),
Span::styled(
format!("{}%", report.overall_score),
Style::default().fg(score_color).bold(),
),
]));
lines.push(Line::from(""));
lines.push(Line::styled(
"License Categories:",
Style::default().fg(scheme.primary).bold(),
));
let category_order = [
LicenseCategory::Permissive,
LicenseCategory::WeakCopyleft,
LicenseCategory::StrongCopyleft,
LicenseCategory::NetworkCopyleft,
LicenseCategory::PublicDomain,
LicenseCategory::Proprietary,
LicenseCategory::Unknown,
];
for cat in category_order {
if let Some(licenses) = report.categories.get(&cat) {
let cat_color = crate::tui::shared::licenses::category_color(cat);
lines.push(Line::from(vec![
Span::styled(" • ", Style::default().fg(scheme.text_muted)),
Span::styled(cat.as_str(), Style::default().fg(cat_color)),
Span::styled(
format!(": {}", licenses.len()),
Style::default().fg(scheme.text),
),
]));
}
}
lines.push(Line::from(""));
if report.issues.is_empty() {
lines.push(Line::from(vec![
Span::styled("✓ ", Style::default().fg(scheme.success)),
Span::styled(
"No compatibility issues detected",
Style::default().fg(scheme.success),
),
]));
} else {
lines.push(Line::styled(
"Compatibility Issues:",
Style::default().fg(scheme.error).bold(),
));
for issue in report.issues.iter().take(5) {
let icon = match issue.severity {
crate::tui::license_utils::IssueSeverity::Error => "✗",
crate::tui::license_utils::IssueSeverity::Warning => "⚠",
};
let color = match issue.severity {
crate::tui::license_utils::IssueSeverity::Error => scheme.error,
crate::tui::license_utils::IssueSeverity::Warning => scheme.warning,
};
lines.push(Line::from(vec![
Span::styled(format!(" {icon} "), Style::default().fg(color)),
Span::raw(issue.message.clone()),
]));
}
if report.issues.len() > 5 {
lines.push(Line::from(vec![Span::styled(
format!(" ... and {} more issues", report.issues.len() - 5),
Style::default().fg(scheme.text_muted),
)]));
}
}
let detector = ConflictDetector::new();
let mut license_map: HashMap<String, Vec<String>> = HashMap::new();
for lic in &result.licenses.new_licenses {
license_map
.entry(lic.license.clone())
.or_default()
.extend(lic.components.clone());
}
for lic in &result.licenses.removed_licenses {
license_map
.entry(lic.license.clone())
.or_default()
.extend(lic.components.clone());
}
let conflicts = detector.detect_conflicts(&license_map);
if !conflicts.is_empty() {
lines.push(Line::from(""));
lines.push(Line::styled(
format!("License Conflicts ({}):", conflicts.len()),
Style::default().fg(scheme.critical).bold(),
));
for conflict in conflicts.iter().take(4) {
let (icon, color) = match conflict.rule.severity {
ConflictSeverity::Error => ("✗", scheme.critical),
ConflictSeverity::Warning => ("⚠", scheme.warning),
ConflictSeverity::Info => ("ℹ", scheme.info),
};
lines.push(Line::from(vec![
Span::styled(format!(" {icon} "), Style::default().fg(color)),
Span::styled(
format!("{} + {}", conflict.license_a, conflict.license_b),
Style::default().fg(color).bold(),
),
]));
lines.push(Line::from(vec![Span::styled(
format!(" {}: ", conflict.rule.conflict_type),
Style::default().fg(scheme.text_muted),
)]));
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
conflict.rule.description.clone(),
Style::default().fg(scheme.text).italic(),
),
]));
}
if conflicts.len() > 4 {
lines.push(Line::from(vec![Span::styled(
format!(" ... and {} more conflicts", conflicts.len() - 4),
Style::default().fg(scheme.text_muted),
)]));
}
}
lines.push(Line::from(""));
lines.push(Line::styled(
"License Families:",
Style::default().fg(scheme.primary).bold(),
));
let mut families: Vec<_> = report.families.iter().collect();
families.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
for (family, licenses) in families.iter().take(6) {
lines.push(Line::from(vec![
Span::styled(" • ", Style::default().fg(scheme.text_muted)),
Span::styled(family.to_string(), Style::default().fg(scheme.accent)),
Span::styled(
format!(": {}", licenses.len()),
Style::default().fg(scheme.text),
),
]));
}
let block = Block::default()
.title(" Compatibility Analysis ")
.title_style(Style::default().fg(scheme.primary).bold())
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.border));
let paragraph = Paragraph::new(lines)
.block(block)
.wrap(ratatui::widgets::Wrap { trim: true });
frame.render_widget(paragraph, area);
}
struct LicenseEntry {
license: String,
components: Vec<String>,
category: LicenseCategory,
risk_level: RiskLevel,
family: String,
is_dual_licensed: bool,
}
fn build_license_list(
licenses: &[crate::diff::LicenseChange],
sort: LicenseSort,
group: LicenseGroupBy,
risk_filter: Option<LicenseRiskFilter>,
) -> Vec<LicenseEntry> {
let mut entries: Vec<LicenseEntry> = licenses
.iter()
.map(|lic| {
let info = LicenseInfo::from_spdx(&lic.license);
let parsed = SpdxExpression::parse(&lic.license);
LicenseEntry {
license: lic.license.clone(),
components: lic.components.clone(),
category: info.category,
risk_level: info.risk_level,
family: info.family.to_string(),
is_dual_licensed: parsed.is_choice(),
}
})
.collect();
if let Some(min_risk) = risk_filter {
let min_level = match min_risk {
LicenseRiskFilter::Low => RiskLevel::Low,
LicenseRiskFilter::Medium => RiskLevel::Medium,
LicenseRiskFilter::High => RiskLevel::High,
LicenseRiskFilter::Critical => RiskLevel::Critical,
};
entries.retain(|e| e.risk_level >= min_level);
}
match sort {
LicenseSort::License => entries.sort_by(|a, b| a.license.cmp(&b.license)),
LicenseSort::Count => entries.sort_by(|a, b| b.components.len().cmp(&a.components.len())),
LicenseSort::Permissiveness => {
entries.sort_by(|a, b| {
a.category
.copyleft_strength()
.cmp(&b.category.copyleft_strength())
});
}
LicenseSort::Risk => {
entries.sort_by(|a, b| b.risk_level.cmp(&a.risk_level));
}
}
match group {
LicenseGroupBy::Family => {
entries.sort_by(|a, b| a.family.cmp(&b.family));
}
LicenseGroupBy::Compatibility => {
entries.sort_by(|a, b| {
a.category
.copyleft_strength()
.cmp(&b.category.copyleft_strength())
});
}
LicenseGroupBy::Risk => {
entries.sort_by(|a, b| b.risk_level.cmp(&a.risk_level));
}
_ => {} }
entries
}
#[allow(clippy::too_many_arguments)]
fn render_license_table(
frame: &mut Frame,
area: Rect,
licenses: &[LicenseEntry],
title: &str,
is_new: bool,
is_focused: bool,
selected: Option<usize>,
scroll_offset: usize,
group: LicenseGroupBy,
) {
let scheme = colors();
let border_color = if is_new { scheme.added } else { scheme.removed };
let focus_border = if is_focused {
scheme.border_focused
} else {
scheme.border
};
let (headers, widths, rows) = match group {
LicenseGroupBy::Family => {
let headers = vec!["License", "Family", "Risk"];
let widths = [
Constraint::Min(15),
Constraint::Length(10),
Constraint::Length(8),
];
let rows: Vec<Row> = licenses
.iter()
.map(|entry| {
let risk_color = match entry.risk_level {
RiskLevel::Low => scheme.success,
RiskLevel::Medium => scheme.info,
RiskLevel::High => scheme.warning,
RiskLevel::Critical => scheme.error,
};
Row::new(vec![
Cell::from(entry.license.as_str()),
Cell::from(entry.family.as_str()),
Cell::from(Span::styled(
entry.risk_level.as_str(),
Style::default().fg(risk_color),
)),
])
})
.collect();
(headers, widths, rows)
}
LicenseGroupBy::Risk => {
let headers = vec!["License", "Risk", "Count"];
let widths = [
Constraint::Min(15),
Constraint::Length(8),
Constraint::Length(6),
];
let rows: Vec<Row> = licenses
.iter()
.map(|entry| {
let risk_color = match entry.risk_level {
RiskLevel::Low => scheme.success,
RiskLevel::Medium => scheme.info,
RiskLevel::High => scheme.warning,
RiskLevel::Critical => scheme.error,
};
Row::new(vec![
Cell::from(entry.license.as_str()),
Cell::from(Span::styled(
entry.risk_level.as_str(),
Style::default().fg(risk_color),
)),
Cell::from(entry.components.len().to_string()),
])
})
.collect();
(headers, widths, rows)
}
_ => {
let headers = vec!["License", "Count", "Category"];
let widths = [
Constraint::Min(15),
Constraint::Length(6),
Constraint::Length(14),
];
let rows: Vec<Row> = licenses
.iter()
.map(|entry| {
let cat_color = crate::tui::shared::licenses::category_color(entry.category);
let license_display = if entry.is_dual_licensed {
format!("{} ⊕", entry.license)
} else {
entry.license.clone()
};
Row::new(vec![
Cell::from(license_display),
Cell::from(entry.components.len().to_string()),
Cell::from(Span::styled(
entry.category.as_str(),
Style::default().fg(cat_color),
)),
])
})
.collect();
(headers, widths, rows)
}
};
let header = Row::new(headers).style(Style::default().fg(scheme.accent).bold());
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.title(format!("{} ({}) ", title, licenses.len()))
.title_style(Style::default().fg(border_color).bold())
.borders(Borders::ALL)
.border_style(Style::default().fg(focus_border)),
)
.row_highlight_style(
Style::default()
.bg(scheme.selection)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(if is_focused { "▶ " } else { " " });
let mut state = TableState::default()
.with_offset(scroll_offset)
.with_selected(selected);
frame.render_stateful_widget(table, area, &mut state);
if licenses.len() > area.height.saturating_sub(3) as usize {
crate::tui::widgets::render_scrollbar(
frame,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
licenses.len(),
scroll_offset,
);
}
}
fn render_license_details(
frame: &mut Frame,
area: Rect,
entry: Option<&LicenseEntry>,
is_new: bool,
diff_result: Option<&crate::diff::DiffResult>,
) {
let scheme = colors();
let Some(entry) = entry else {
crate::tui::shared::components::render_empty_detail_panel(
frame,
area,
" License Details ",
"",
"Select a license to view details",
&[],
false,
);
return;
};
let (status_text, status_color) = if is_new {
("+ NEW LICENSE", scheme.added)
} else {
("- REMOVED LICENSE", scheme.removed)
};
let mut lines = vec![Line::from(vec![Span::styled(
status_text,
Style::default().fg(status_color).bold(),
)])];
lines.extend(crate::tui::shared::licenses::render_license_metadata_lines(
&entry.license,
entry.category,
entry.risk_level,
&entry.family,
entry.components.len(),
entry.is_dual_licensed,
));
if entry.is_dual_licensed || entry.license.contains(" AND ") {
lines.push(Line::from(""));
let structure = if entry.license.contains(" OR ") {
let parts: Vec<&str> = entry.license.split(" OR ").collect();
format!("Choice: {}", parts.join(" | "))
} else {
let parts: Vec<&str> = entry.license.split(" AND ").collect();
format!("All required: {}", parts.join(" + "))
};
lines.push(Line::from(vec![
Span::styled("Expression: ", Style::default().fg(scheme.text_muted)),
Span::styled(structure, Style::default().fg(scheme.accent)),
]));
}
lines.push(Line::from(""));
lines
.extend(crate::tui::shared::licenses::render_license_characteristics_lines(&entry.license));
lines.push(Line::from(""));
lines.push(Line::styled(
"Affected Components:",
Style::default().fg(scheme.primary).bold(),
));
let max_components = (area.height as usize).saturating_sub(24).max(3);
for comp in entry.components.iter().take(max_components) {
let has_vulns = diff_result.is_some_and(|r| {
r.vulnerabilities
.introduced
.iter()
.chain(r.vulnerabilities.resolved.iter())
.any(|v| v.component_name == *comp)
});
let vuln_indicator = if has_vulns {
Span::styled(" ⚠", Style::default().fg(scheme.critical))
} else {
Span::raw("")
};
let display_name = crate::tui::widgets::extract_display_name(comp);
lines.push(Line::from(vec![
Span::styled(" • ", Style::default().fg(scheme.text_muted)),
Span::styled(display_name, Style::default().fg(scheme.text)),
vuln_indicator,
]));
}
if entry.components.len() > max_components {
lines.push(Line::from(vec![Span::styled(
format!(" ... and {} more", entry.components.len() - max_components),
Style::default().fg(scheme.text_muted),
)]));
}
let border_color = if is_new { scheme.added } else { scheme.removed };
let detail = Paragraph::new(lines)
.block(
Block::default()
.title(" License Details ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(ratatui::widgets::Wrap { trim: true });
frame.render_widget(detail, area);
}
pub fn categorize_license(license: &str) -> String {
LicenseInfo::from_spdx(license)
.category
.as_str()
.to_string()
}