// ©AngelaMos | 2026 // output.go package cli import ( "fmt" "sort" "strings" "github.com/CarterPerez-dev/angela/internal/pypi" "github.com/CarterPerez-dev/angela/pkg/types" "github.com/fatih/color" ) var ( bold = color.New(color.Bold).SprintFunc() dim = color.New(color.Faint).SprintFunc() cyan = color.New(color.FgCyan, color.Bold).SprintFunc() green = color.New(color.FgGreen).SprintFunc() yellow = color.New(color.FgYellow).SprintFunc() red = color.New(color.FgRed).SprintFunc() redBold = color.New(color.FgRed, color.Bold).SprintFunc() greenBold = color.New(color.FgGreen, color.Bold).SprintFunc() white = color.New(color.FgWhite, color.Bold).SprintFunc() ) // PrintScanning announces the start of a scan func PrintScanning(count int) { fmt.Printf( "\n %s %d dependencies...\n\n", cyan("Scanning"), count, ) } // PrintUpdates displays a formatted table of available dependency updates func PrintUpdates(updates []types.UpdateResult) { var actionable []types.UpdateResult for _, u := range updates { if !u.Skipped { actionable = append(actionable, u) } } if len(actionable) == 0 { fmt.Printf(" %s\n\n", dim("All dependencies are up to date.")) return } fmt.Printf(" %s\n", cyan("Updates available:")) nameWidth := 0 oldWidth := 0 for _, u := range actionable { if len(u.Name) > nameWidth { nameWidth = len(u.Name) } if len(u.OldVer) > oldWidth { oldWidth = len(u.OldVer) } } for _, u := range actionable { changeColor := changeColorFn(u.Change) fmt.Printf( " %-*s %s %s %s %s\n", nameWidth, white(u.Name), dim(padRight(u.OldVer, oldWidth)), dim("->"), changeColor(u.NewVer), dim("("+u.Change+")"), ) } fmt.Println() } // PrintSkipped displays dependencies that were not updated with reasons func PrintSkipped(updates []types.UpdateResult) { var skipped []types.UpdateResult for _, u := range updates { if u.Skipped { skipped = append(skipped, u) } } if len(skipped) == 0 { return } fmt.Printf(" %s\n", dim("Skipped:")) for _, u := range skipped { fmt.Printf(" %s %s\n", dim(u.Name), dim(u.Reason)) } fmt.Println() } // PrintVulnerabilities displays security advisories in compact or verbose mode func PrintVulnerabilities( vulns map[string][]types.Vulnerability, ) { if len(vulns) == 0 { return } total := 0 for _, vl := range vulns { total += len(vl) } fmt.Printf( " %s %d across %d %s\n", redBold("Vulnerabilities:"), total, len(vulns), pluralize("package", len(vulns)), ) pkgs := sortedVulnPackages(vulns) if verbose { printVulnsVerbose(pkgs, vulns) } else { printVulnsCompact(pkgs, vulns) } } const maxVulnsPerPackage = 5 func printVulnsCompact( pkgs []string, vulns map[string][]types.Vulnerability, ) { for _, pkg := range pkgs { vlist := vulns[pkg] sortVulnsBySeverity(vlist) fmt.Printf( "\n %s (%d %s: %s)\n", white(pkg), len(vlist), pluralize("vuln", len(vlist)), severityBreakdown(vlist), ) limit := min(maxVulnsPerPackage, len(vlist)) for i := range limit { printVulnLine(vlist[i]) } if len(vlist) > maxVulnsPerPackage { fmt.Printf( " %s\n", dim(fmt.Sprintf( "...and %d more", len(vlist)-maxVulnsPerPackage, )), ) } } fmt.Printf( "\n %s\n\n", dim("Run angela scan -v for full details."), ) } func printVulnsVerbose( pkgs []string, vulns map[string][]types.Vulnerability, ) { for _, pkg := range pkgs { vlist := vulns[pkg] sortVulnsBySeverity(vlist) fmt.Printf( "\n %s (%d %s: %s)\n", white(pkg), len(vlist), pluralize("vuln", len(vlist)), severityBreakdown(vlist), ) for _, v := range vlist { id := preferredID(v) sevColor := severityColorFn(v.Severity) fmt.Printf( "\n %s %s\n", sevColor( padRight(strings.ToUpper(v.Severity), 8), ), bold(id), ) fmt.Printf(" %s\n", truncate(v.Summary, 72)) if v.FixedIn != "" { fmt.Printf( " %s %s\n", dim("Fixed in:"), green(v.FixedIn), ) } if v.Link != "" { fmt.Printf(" %s\n", dim(v.Link)) } } } fmt.Println() } func printVulnLine(v types.Vulnerability) { id := preferredID(v) sevColor := severityColorFn(v.Severity) fixedStr := "" if v.FixedIn != "" { fixedStr = dim("Fixed: ") + green(v.FixedIn) } fmt.Printf( " %s %s %s %s\n", sevColor(padRight(strings.ToUpper(v.Severity), 8)), bold(padRight(id, 16)), truncate(v.Summary, 38), fixedStr, ) } func sortedVulnPackages( vulns map[string][]types.Vulnerability, ) []string { pkgs := make([]string, 0, len(vulns)) for k := range vulns { pkgs = append(pkgs, k) } sort.Slice(pkgs, func(i, j int) bool { li := len(vulns[pkgs[i]]) lj := len(vulns[pkgs[j]]) if li != lj { return li > lj } return pkgs[i] < pkgs[j] }) return pkgs } func sortVulnsBySeverity(vulns []types.Vulnerability) { sort.Slice(vulns, func(i, j int) bool { ri := severityRank(vulns[i].Severity) rj := severityRank(vulns[j].Severity) if ri != rj { return ri < rj } return vulns[i].ID < vulns[j].ID }) } func severityRank(sev string) int { switch strings.ToUpper(sev) { case "CRITICAL": return 0 case "HIGH": return 1 case "MODERATE": return 2 case "LOW": return 3 default: return 4 } } func severityBreakdown( vulns []types.Vulnerability, ) string { counts := make(map[string]int) for _, v := range vulns { counts[strings.ToUpper(v.Severity)]++ } order := []string{ "CRITICAL", "HIGH", "MODERATE", "LOW", "UNKNOWN", } var parts []string for _, sev := range order { if n := counts[sev]; n > 0 { parts = append(parts, fmt.Sprintf( "%d %s", n, strings.ToLower(sev), )) } } return strings.Join(parts, ", ") } func preferredID(v types.Vulnerability) string { for _, alias := range v.Aliases { if strings.HasPrefix(alias, "CVE-") { return alias } } return v.ID } func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen-3] + "..." } func filterIgnoredVulns( vulns map[string][]types.Vulnerability, ignoreIDs []string, ) map[string][]types.Vulnerability { if len(ignoreIDs) == 0 { return vulns } ignored := make(map[string]bool, len(ignoreIDs)) for _, id := range ignoreIDs { ignored[id] = true } filtered := make(map[string][]types.Vulnerability) for pkg, vlist := range vulns { var kept []types.Vulnerability for _, v := range vlist { if isVulnIgnored(v, ignored) { continue } kept = append(kept, v) } if len(kept) > 0 { filtered[pkg] = kept } } return filtered } func isVulnIgnored(v types.Vulnerability, ignored map[string]bool) bool { if ignored[v.ID] { return true } for _, alias := range v.Aliases { if ignored[alias] { return true } } return false } func filterVulnsBySeverity( vulns map[string][]types.Vulnerability, minSev string, ) map[string][]types.Vulnerability { threshold := severityRank(minSev) filtered := make(map[string][]types.Vulnerability) for pkg, vlist := range vulns { var kept []types.Vulnerability for _, v := range vlist { if severityRank(v.Severity) <= threshold { kept = append(kept, v) } } if len(kept) > 0 { filtered[pkg] = kept } } return filtered } // PrintSummary displays final counts after an update or scan operation func PrintSummary(result types.ScanResult, updated bool) { if updated { fmt.Printf(" %s\n", greenBold("Updated pyproject.toml")) } fmt.Printf( " %s packages checked\n", bold(fmt.Sprintf("%d", result.TotalPackages)), ) if result.TotalUpdated > 0 { fmt.Printf( " %s updated\n", bold(fmt.Sprintf("%d", result.TotalUpdated)), ) } if result.VulnsScanned { if result.TotalVulns > 0 { fmt.Printf( " %s\n", red(fmt.Sprintf( "%d %s found", result.TotalVulns, pluralize("vulnerability", result.TotalVulns), )), ) } else { fmt.Printf( " %s\n", green("No vulnerabilities found"), ) } } fmt.Printf( " %s %s\n\n", dim("Done in"), dim(result.Duration.Round(1e6).String()), ) } // PrintError displays a user-friendly error message func PrintError(msg string) { fmt.Printf("\n %s %s\n\n", red("error:"), msg) } func changeColorFn(kind string) func(a ...any) string { switch kind { case pypi.Major.String(): return red case pypi.Minor.String(): return yellow default: return green } } func severityColorFn(sev string) func(a ...any) string { switch strings.ToUpper(sev) { case "CRITICAL": return redBold case "HIGH": return red case "MODERATE": return yellow case "LOW": return cyan default: return dim } } func padRight(s string, width int) string { if len(s) >= width { return s } return s + strings.Repeat(" ", width-len(s)) } func pluralize(word string, n int) string { if n == 1 { return word } if strings.HasSuffix(word, "y") { return word[:len(word)-1] + "ies" } return word + "s" }