cmd/govulncheck: use text/template for printing to screen

This replaces the current, more error prone, approach of printing
things to screen using fmt functions.

Functions such as indent and wrap are passed to the template and used
within the template. This way, the functionalities for determining what
is printed to screen are kept closer to each other.

This CL will be followed by CLs that refactor the unit tests.

Change-Id: I296d30959f1fc117a95f40183287b5df0f0f5a82
Reviewed-on: https://go-review.googlesource.com/c/vuln/+/459795
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Zvonimir Pavlinovic <zpavlinovic@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/cmd/govulncheck/main.go b/cmd/govulncheck/main.go
index 96c4a2b..d62086e 100644
--- a/cmd/govulncheck/main.go
+++ b/cmd/govulncheck/main.go
@@ -37,6 +37,10 @@
 const (
 	envGOVULNDB = "GOVULNDB"
 	vulndbHost  = "https://vuln.go.dev"
+
+	introMessage = `govulncheck is an experimental tool. Share feedback at https://go.dev/s/govulncheck-feedback.
+
+Scanning for dependencies with known vulnerabilities...`
 )
 
 func main() {
@@ -134,7 +138,9 @@
 		os.Exit(0)
 	}
 
-	printText(res, *verboseFlag, sourceAnalysis)
+	if err := printText(res, *verboseFlag, sourceAnalysis); err != nil {
+		return err
+	}
 	// Return exit status -3 if some vulnerabilities are actually
 	// called in source mode or just present in binary mode.
 	//
diff --git a/cmd/govulncheck/message.go b/cmd/govulncheck/message.go
deleted file mode 100644
index 07e8977..0000000
--- a/cmd/govulncheck/message.go
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright 2022 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package main
-
-const (
-	introMessage = `govulncheck is an experimental tool. Share feedback at https://go.dev/s/govulncheck-feedback.
-
-Scanning for dependencies with known vulnerabilities...`
-
-	informationalMessage = `=== Informational ===
-
-The vulnerabilities below are in packages that you import, but your code
-doesn't appear to call any vulnerable functions. You may not need to take any
-action. See https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck
-for details.`
-)
diff --git a/cmd/govulncheck/print.go b/cmd/govulncheck/print.go
index 0d31cf9..9d74b95 100644
--- a/cmd/govulncheck/print.go
+++ b/cmd/govulncheck/print.go
@@ -10,6 +10,7 @@
 	"os"
 	"sort"
 	"strings"
+	"text/template"
 
 	"golang.org/x/exp/maps"
 	"golang.org/x/vuln/exp/govulncheck"
@@ -32,30 +33,55 @@
 	lineLength = 55
 )
 
-func printText(r *govulncheck.Result, verbose, source bool) {
+func printText(r *govulncheck.Result, verbose, source bool) error {
+	lineWidth := 80 - labelWidth
+	funcMap := template.FuncMap{
+		// used in template for counting vulnerabilities
+		"inc": func(i int) int {
+			return i + 1
+		},
+		// indent reversed to support template pipelining
+		"indent": func(n int, s string) string {
+			return indent(s, n)
+		},
+		"wrap": func(s string) string {
+			return wrap(s, lineWidth)
+		},
+	}
+
+	tmplRes := createTmplResult(r, verbose, source)
+	tmpl, err := template.New("govulncheck").Funcs(funcMap).Parse(outputTemplate)
+	if err != nil {
+		return err
+	}
+	return tmpl.Execute(os.Stdout, tmplRes)
+}
+
+// createTmplResult transforms govulncheck.Result r into a
+// template structure for printing.
+func createTmplResult(r *govulncheck.Result, verbose, source bool) tmplResult {
 	// unaffected are (imported) OSVs none of
 	// which vulnerabilities are called.
-	var unaffected []*govulncheck.Vuln
+	var unaffected []tmplVulnInfo
 	uniqueVulns := 0
 	for _, v := range r.Vulns {
 		if !source || v.IsCalled() {
 			uniqueVulns++
 		} else {
 			// save arbitrary Vuln for informational message
-			unaffected = append(unaffected, v)
+			m := v.Modules[0]
+			p := m.Packages[0]
+			unaffected = append(unaffected, tmplVulnInfo{
+				ID:        v.OSV.ID,
+				Details:   v.OSV.Details,
+				Found:     packageVersionString(p.Path, m.FoundVersion),
+				Fixed:     packageVersionString(p.Path, m.FixedVersion),
+				Platforms: platforms(v.OSV),
+			})
 		}
 	}
-	switch uniqueVulns {
-	case 0:
-		fmt.Println("No vulnerabilities found.")
-	case 1:
-		fmt.Println("Found 1 known vulnerability.")
-	default:
-		fmt.Printf("Found %d known vulnerabilities.\n", uniqueVulns)
-	}
 
-	lineWidth := 80 - labelWidth
-	idx := 0
+	var affected []tmplVulnInfo
 	for _, v := range r.Vulns {
 		for _, m := range v.Modules {
 			for _, p := range m.Packages {
@@ -63,62 +89,33 @@
 				if source && len(p.CallStacks) == 0 {
 					continue
 				}
-				fmt.Println()
 
-				id := v.OSV.ID
-				details := wrap(v.OSV.Details, lineWidth)
-				found := packageVersionString(p.Path, m.FoundVersion)
-				fixed := packageVersionString(p.Path, m.FixedVersion)
-
-				var stacksBuilder strings.Builder
+				var stacks string
 				if source { // there are no call stacks in binary mode
-					var stacks string
 					if !verbose {
 						stacks = defaultCallStacks(p.CallStacks)
 					} else {
 						stacks = verboseCallStacks(p.CallStacks)
 					}
-					if len(stacks) > 0 {
-						stacksBuilder.WriteString(indent("\n\nCall stacks in your code:\n", 2))
-						stacksBuilder.WriteString(indent(stacks, 6))
-					}
 				}
-				printVulnerability(idx+1, id, details, stacksBuilder.String(), found, fixed, platforms(v.OSV))
-				idx++
+
+				affected = append(affected, tmplVulnInfo{
+					ID:        v.OSV.ID,
+					Details:   v.OSV.Details,
+					Found:     packageVersionString(p.Path, m.FoundVersion),
+					Fixed:     packageVersionString(p.Path, m.FixedVersion),
+					Platforms: platforms(v.OSV),
+					Stacks:    stacks,
+				})
 			}
 		}
 	}
-	if len(unaffected) > 0 {
-		fmt.Println()
-		fmt.Println(informationalMessage)
-		idx = 0
-		for idx, un := range unaffected {
-			// We pick random module and package info for
-			// unaffected OSVs.
-			m := un.Modules[0]
-			p := m.Packages[0]
-			found := packageVersionString(p.Path, m.FoundVersion)
-			fixed := packageVersionString(p.Path, m.FixedVersion)
-			fmt.Println()
-			details := wrap(un.OSV.Details, lineWidth)
-			printVulnerability(idx+1, un.OSV.ID, details, "", found, fixed, platforms(un.OSV))
-		}
-	}
-}
 
-func printVulnerability(idx int, id, details, callstack, found, fixed, platforms string) {
-	if fixed == "" {
-		fixed = "N/A"
+	return tmplResult{
+		UniqueVulns: uniqueVulns,
+		Unaffected:  unaffected,
+		Affected:    affected,
 	}
-	if platforms != "" {
-		platforms = "  Platforms: " + platforms + "\n"
-	}
-	fmt.Printf(`Vulnerability #%d: %s
-%s%s
-  Found in: %s
-  Fixed in: %s
-%s  More info: https://pkg.go.dev/vuln/%s
-`, idx, id, indent(details, 2), callstack, found, fixed, platforms, id)
 }
 
 func defaultCallStacks(css []govulncheck.CallStack) string {
diff --git a/cmd/govulncheck/template.go b/cmd/govulncheck/template.go
new file mode 100644
index 0000000..050a5fc
--- /dev/null
+++ b/cmd/govulncheck/template.go
@@ -0,0 +1,76 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+// outputTemplate is a text template used to print out
+// govulncheck output. It consists of three sections showing
+// 1) the number of vulnerabilities detected, 2) callstacks
+// detected for each pair of module and vulnerability, and
+// 3) vulnerabilities that are only imported but not called.
+const outputTemplate = `
+{{- define "VulnCount" -}}
+{{if eq .UniqueVulns 0}}No vulnerabilities found.
+{{else if eq .UniqueVulns 1}}Found 1 known vulnerability.
+{{else}}Found {{ .UniqueVulns }} known vulnerabilities.
+{{end}}
+{{- end -}}
+
+{{- define "Affected" -}}
+{{if len .Affected}}{{range $idx, $vulnInfo := .Affected}}
+Vulnerability #{{inc $idx}}: {{$vulnInfo.ID}}
+{{wrap $vulnInfo.Details | indent 2}}
+{{if $vulnInfo.Stacks}}
+  Call stacks in your code:
+{{indent 6 $vulnInfo.Stacks}}
+{{end}}  Found in: {{$vulnInfo.Found}}
+  Fixed in: {{if $vulnInfo.Fixed}}{{$vulnInfo.Fixed}}{{else}}N/A{{end}}
+  {{if $vulnInfo.Platforms}}Platforms: {{$vulnInfo.Platforms}}
+  {{end -}}
+  More info: https://pkg.go.dev/vuln/{{$vulnInfo.ID}}
+{{end}}
+{{- end -}}
+{{- end -}}
+
+{{- define "Informational" -}}
+{{if len .Unaffected}}
+=== Informational ===
+
+The vulnerabilities below are in packages that you import, but your code
+doesn't appear to call any vulnerable functions. You may not need to take any
+action. See https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck
+for details.
+{{range $idx, $vulnInfo := .Unaffected}}
+Vulnerability #{{inc $idx}}: {{$vulnInfo.ID}}
+{{wrap $vulnInfo.Details | indent 2}}
+  Found in: {{$vulnInfo.Found}}
+  Fixed in: {{if $vulnInfo.Fixed}}{{$vulnInfo.Fixed}}{{else}}N/A{{end}}
+  {{if $vulnInfo.Platforms}}Platforms: {{$vulnInfo.Platforms}}
+  {{end -}}
+  More info: https://pkg.go.dev/vuln/{{$vulnInfo.ID}}
+{{end}}
+{{- end -}}
+{{- end -}}
+
+{{template "VulnCount" .}}{{template "Affected" .}}{{template "Informational" . -}}
+`
+
+// tmplResult is a structure containing summarized
+// govulncheck.Result, passed to outputTemplate.
+type tmplResult struct {
+	UniqueVulns int
+	Unaffected  []tmplVulnInfo
+	Affected    []tmplVulnInfo
+}
+
+// tmplVulnInfo is a vulnerability info
+// structure used by the outputTemplate.
+type tmplVulnInfo struct {
+	ID        string
+	Details   string
+	Found     string
+	Fixed     string
+	Platforms string
+	Stacks    string
+}