ConsoleRenderer.kt
package com.depanalyzer.report
import com.github.ajalt.mordant.rendering.AnsiLevel
import com.github.ajalt.mordant.rendering.TextColors.*
import com.github.ajalt.mordant.rendering.TextStyle
import com.github.ajalt.mordant.rendering.TextStyles.bold
import com.github.ajalt.mordant.table.table
import com.github.ajalt.mordant.terminal.Terminal
class ConsoleRenderer(
noColor: Boolean = false,
private val useAscii: Boolean = false,
private val treeMaxDepth: Int? = null,
ansiLevel: AnsiLevel? = null
) {
private val terminal = Terminal(
ansiLevel = ansiLevel ?: if (noColor) AnsiLevel.NONE else AnsiLevel.TRUECOLOR
)
fun render(report: DependencyReport, showChains: Boolean = false, detailedChains: Boolean = false) {
terminal.println(bold("===================================================="))
terminal.println(bold("Análisis de Dependencias: ") + blue(report.projectName))
terminal.println(bold("===================================================="))
terminal.println()
report.dependencyTree?.takeIf { it.isNotEmpty() }?.let {
renderDependencyTree(it)
} ?: run {
renderVulnerabilities(report)
renderOutdated(report)
}
if (showChains && report.vulnerabilityChains.isNotEmpty()) {
renderVulnerabilityChains(report, detailedChains)
}
renderSummary(report)
}
fun renderVerbose(report: DependencyReport, showChains: Boolean = false, detailedChains: Boolean = false) {
terminal.println(bold("===================================================="))
terminal.println(bold("Análisis de Dependencias: ") + blue(report.projectName))
terminal.println(bold("===================================================="))
terminal.println()
report.dependencyTree?.takeIf { it.isNotEmpty() }?.let {
renderDependencyTreeVerbose(it)
} ?: run {
renderVulnerabilitiesVerbose(report)
renderOutdated(report)
}
if (showChains && report.vulnerabilityChains.isNotEmpty()) {
renderVulnerabilityChains(report, detailedChains)
}
renderSummary(report)
}
private fun renderVulnerabilities(report: DependencyReport) {
if (report.directVulnerable.isEmpty() && report.transitiveVulnerable.isEmpty()) return
terminal.println(bold(red("VULNERABILIDADES DETECTADAS")))
terminal.println(red("---------------------------"))
val allVulnerabilities = mutableListOf<Triple<String, String, Vulnerability>>()
report.directVulnerable.forEach { dep ->
dep.vulnerabilities.forEach { v ->
allVulnerabilities.add(Triple("${dep.groupId}:${dep.artifactId}:${dep.version}", "Directa", v))
}
}
report.transitiveVulnerable.forEach { dep ->
dep.vulnerabilities.forEach { v ->
allVulnerabilities.add(Triple("${dep.groupId}:${dep.artifactId}:${dep.version}", "Transitiva", v))
}
}
val vulnerabilityTable = table {
header {
row(
bold("CVE ID"),
bold("Severity"),
bold("CVSS"),
bold("Source"),
bold("Retrieved At"),
bold("Affected Dependency")
)
}
body {
allVulnerabilities.forEach { (coord, _, v) ->
val color = severityColor(v.severity)
row(
v.cveId,
color(v.severity.toString()),
v.cvssScore?.toString() ?: "N/A",
v.source.toString(),
v.retrievedAt?.toString()?.substring(0, 19) ?: "N/A",
coord
)
}
}
}
terminal.println(vulnerabilityTable)
terminal.println()
}
private fun renderOutdated(report: DependencyReport) {
if (report.outdated.isEmpty()) return
terminal.println(bold(yellow("DEPENDENCIAS DESACTUALIZADAS")))
terminal.println(yellow("----------------------------"))
val outdatedTable = table {
header { row(bold("Dependencia"), bold("Actual"), bold("Nueva"), bold("Estado")) }
body {
report.outdated.forEach { dep ->
row(
"${dep.groupId}:${dep.artifactId}",
dep.currentVersion,
green(dep.latestVersion),
yellow("OUTDATED")
)
}
}
}
terminal.println(outdatedTable)
terminal.println()
}
private fun renderSummary(report: DependencyReport) {
terminal.println(bold("RESUMEN"))
terminal.println("-------")
terminal.println(" " + green("Al día: ${report.upToDate.size}"))
terminal.println(" " + yellow("Desactualizadas: ${report.outdated.size}"))
val totalVulnerabilities = report.directVulnerable.size + report.transitiveVulnerable.size
if (totalVulnerabilities > 0) {
terminal.println(" " + red("Vulnerabilidades: $totalVulnerabilities"))
} else {
terminal.println(" " + green("Vulnerabilidades: 0"))
}
terminal.println(bold("===================================================="))
}
private fun renderVulnerabilityChains(report: DependencyReport, detailed: Boolean = false) {
terminal.println(bold(cyan("CADENAS DE VULNERABILIDADES")))
terminal.println(cyan("---------------------------"))
if (report.vulnerabilityChains.isEmpty()) {
terminal.println("No vulnerability chains found")
return
}
report.vulnerabilityChains.groupBy { it.directDependency.id }.forEach { (directDepId, chainsForDirect) ->
terminal.println(bold("De: ") + yellow(directDepId))
data class ChainSignature(
val vulnerableNodeId: String,
val cveSet: Set<String>
)
val signatureMap = chainsForDirect.groupBy { chain ->
ChainSignature(
vulnerableNodeId = chain.vulnerableNode.id,
cveSet = chain.cveIds.toSet()
)
}
signatureMap.forEach { (_, pathsWithSameSignature) ->
val shortestPath = pathsWithSameSignature.minByOrNull { it.chain.size }
?: return@forEach
val marker = cyan("✓")
val chainPath = shortestPath.chain.joinToString(" → ") { it.coordinate }
terminal.println(" $marker $chainPath")
if (pathsWithSameSignature.size > 1) {
val alternativeCount = pathsWithSameSignature.size - 1
terminal.println(gray(" 📌 +$alternativeCount alternative path${if (alternativeCount > 1) "s" else ""} (all longer)"))
}
if (detailed) {
shortestPath.vulnerabilities.forEach { vuln ->
val color = severityColor(vuln.severity)
terminal.println(" - " + color("[${vuln.severity}] ${vuln.cveId}"))
}
}
}
terminal.println()
}
}
private fun severityColor(severity: VulnerabilitySeverity): TextStyle {
return when (severity) {
VulnerabilitySeverity.CRITICAL -> (red + bold)
VulnerabilitySeverity.HIGH -> red
VulnerabilitySeverity.MEDIUM -> yellow
VulnerabilitySeverity.LOW -> gray
VulnerabilitySeverity.UNKNOWN -> white
}
}
private fun renderDependencyTree(nodes: List<DependencyTreeNode>, level: Int = 0) {
if (level == 0) {
terminal.println(bold(red("📦 DEPENDENCIAS CON PROBLEMAS")))
terminal.println(red("" + if (useAscii) "----------------------------" else "────────────────────────────"))
}
nodes.forEach { node ->
// Verificar profundidad máxima
if (treeMaxDepth != null && level >= treeMaxDepth) {
return@forEach
}
val prefix = getTreePrefix(level, useAscii)
val marker = if (node.isDirectDependency) {
if (useAscii) "[DIRECT]" else "🔴"
} else {
if (useAscii) "[TRANSITIVE]" else "🟡"
}
val severityColor = node.maxSeverity?.let { severityColor(it) } ?: white
val nodeLabel = "${node.groupId}:${node.artifactId}:${node.currentVersion}"
terminal.println("$prefix $marker ${severityColor(nodeLabel)} ${if (node.isDirectDependency) "" else "[TRANSITIVO]"}")
if (node.latestVersion != null) {
val updatePrefix = getTreeContinuePrefix(level, useAscii)
val updateMarker = if (useAscii) "[UPDATE]" else "⬆️"
terminal.println("$updatePrefix $updateMarker ${cyan("Disponible: ${node.latestVersion}")}")
}
node.vulnerabilities.forEach { vuln ->
val vulnPrefix = getTreeContinuePrefix(level, useAscii)
val vulnMarker = when (vuln.severity) {
VulnerabilitySeverity.CRITICAL -> if (useAscii) "[CRITICAL]" else "🔴"
VulnerabilitySeverity.HIGH -> if (useAscii) "[HIGH]" else "🟠"
VulnerabilitySeverity.MEDIUM -> if (useAscii) "[MEDIUM]" else "🟡"
VulnerabilitySeverity.LOW -> if (useAscii) "[LOW]" else "🟢"
VulnerabilitySeverity.UNKNOWN -> if (useAscii) "[UNKNOWN]" else "⚪"
}
val vulnColor = severityColor(vuln.severity)
val cvssStr = vuln.cvssScore?.let { " (${it})" } ?: ""
terminal.println("$vulnPrefix $vulnMarker ${vulnColor("[${vuln.cveId}] ${vuln.severity}$cvssStr")}")
}
if (node.children.isNotEmpty()) {
renderDependencyTree(node.children, level + 1)
}
}
if (level == 0) {
terminal.println()
}
}
private fun renderDependencyTreeVerbose(nodes: List<DependencyTreeNode>, level: Int = 0) {
if (level == 0) {
terminal.println(bold(red("📦 DEPENDENCIAS CON PROBLEMAS (DETALLADO)")))
terminal.println(red("" + if (useAscii) "-------------------------------------------" else "───────────────────────────────────────────"))
}
nodes.forEach { node ->
if (treeMaxDepth != null && level >= treeMaxDepth) {
return@forEach
}
val prefix = getTreePrefix(level, useAscii)
val marker = if (node.isDirectDependency) {
if (useAscii) "[DIRECT]" else "🔴"
} else {
if (useAscii) "[TRANSITIVE]" else "🟡"
}
val nodeLabel = "${node.groupId}:${node.artifactId}:${node.currentVersion}"
val scopeStr = node.scope?.let { " | $it" } ?: ""
val typeStr = if (node.isDirectDependency) "DIRECTO" else "TRANSITIVO"
terminal.println("$prefix $marker ${bold(nodeLabel)} [$typeStr$scopeStr]")
val detailPrefix = getTreeContinuePrefix(level, useAscii)
terminal.println("$detailPrefix ID: ${node.coordinate}")
node.dependencyChain?.takeIf { it.isNotEmpty() }?.let { chain ->
val chainStr = chain.joinToString(" → ")
terminal.println("$detailPrefix ${gray("Ruta: $chainStr")}")
}
if (node.latestVersion != null) {
val updateMarker = if (useAscii) "[UPDATE]" else "⬆️"
terminal.println("$detailPrefix $updateMarker ${cyan("Actualización: ${node.latestVersion}")}")
}
if (node.vulnerabilities.isNotEmpty()) {
terminal.println("$detailPrefix ${bold("Vulnerabilidades:")} ${node.vulnerabilities.size}")
node.vulnerabilities.forEach { vuln ->
val vulnColor = severityColor(vuln.severity)
terminal.println("$detailPrefix ├─ ${vulnColor("[${vuln.cveId}] ${vuln.severity}")} ${vuln.cvssScore?.let { "(CVSS $it)" } ?: ""}")
vuln.description?.let { desc ->
val truncated = if (desc.length > 80) desc.substring(0, 77) + "..." else desc
terminal.println("$detailPrefix │ $truncated")
}
terminal.println("$detailPrefix │ Fuente: ${vuln.source}")
vuln.retrievedAt?.let {
terminal.println(
"$detailPrefix │ Obtenido: ${
it.toString().substring(0, 19)
}"
)
}
vuln.referenceUrl?.let { terminal.println("$detailPrefix │ ${blue(it)}") }
}
}
terminal.println()
if (node.children.isNotEmpty()) {
renderDependencyTreeVerbose(node.children, level + 1)
}
}
if (level == 0) {
terminal.println()
}
}
private fun getTreePrefix(level: Int, useAscii: Boolean): String {
val indent = " ".repeat(level)
return if (useAscii) {
if (level == 0) "" else "$indent|"
} else {
if (level == 0) "" else "$indent└"
}
}
private fun getTreeContinuePrefix(level: Int, useAscii: Boolean): String {
val indent = " ".repeat(level)
return if (useAscii) {
if (level == 0) "|" else "$indent|"
} else {
if (level == 0) "│" else "$indent│"
}
}
private fun renderVulnerabilitiesVerbose(report: DependencyReport) {
if (report.directVulnerable.isEmpty() && report.transitiveVulnerable.isEmpty()) return
terminal.println(bold(red("VULNERABILIDADES DETECTADAS (DETALLADO)")))
terminal.println(red("------------------------------------------"))
if (report.directVulnerable.isNotEmpty()) {
terminal.println(bold("Directas:"))
report.directVulnerable.forEach { dep ->
terminal.println(" - ${dep.groupId}:${dep.artifactId}:" + yellow(dep.version))
dep.vulnerabilities.forEach { v ->
val color = severityColor(v.severity)
val desc = v.description ?: "No description available"
terminal.println(" * " + color("[${v.severity}] ${v.cveId}: $desc"))
}
}
terminal.println()
}
if (report.transitiveVulnerable.isNotEmpty()) {
terminal.println(bold("Transitivas:"))
report.transitiveVulnerable.forEach { dep ->
terminal.println(" - ${dep.groupId}:${dep.artifactId}:" + yellow(dep.version))
if (dep.dependencyChain != null) {
terminal.println(gray(" Ruta: ${dep.dependencyChain.joinToString(" -> ")}"))
}
dep.vulnerabilities.forEach { v ->
val color = severityColor(v.severity)
val desc = v.description ?: "No description available"
terminal.println(" * " + color("[${v.severity}] ${v.cveId}: $desc"))
}
}
terminal.println()
}
}
}