all: print stats on benchsave

This changes benchsave to behave like benchstat; in addition to
uploading the files and printing a URL, it also prints the text format
of benchstat. This is fetched from the ViewURL provided by the storage
server, so the analysis can be changed/improved without requiring
users to rebuild benchsave.

Change-Id: I28519a5e3cf89962bd952ff26a8a6a717b9ef636
Reviewed-on: https://go-review.googlesource.com/37532
Reviewed-by: Russ Cox <rsc@golang.org>
diff --git a/analysis/app/app.go b/analysis/app/app.go
index c4d3029..d4bd819 100644
--- a/analysis/app/app.go
+++ b/analysis/app/app.go
@@ -33,6 +33,12 @@
 		http.Error(w, err.Error(), 500)
 		return
 	}
+	if r.Header.Get("Accept") == "text/plain" || r.Header.Get("X-Benchsave") == "1" {
+		// TODO(quentin): Switch to real Accept negotiation when golang/go#19307 is resolved.
+		// Benchsave sends both of these headers.
+		a.textCompare(w, r)
+		return
+	}
 	// TODO(quentin): Intelligently choose an analysis method
 	// based on the results from the query, once there is more
 	// than one analysis method.
diff --git a/analysis/app/compare.go b/analysis/app/compare.go
index 93b8721..f154b1e 100644
--- a/analysis/app/compare.go
+++ b/analysis/app/compare.go
@@ -6,6 +6,7 @@
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"html/template"
 	"io/ioutil"
@@ -221,11 +222,9 @@
 	return strings.Join(parts, "/") + end
 }
 
-func (a *App) compareQuery(q string) *compareData {
-	if len(q) == 0 {
-		return &compareData{}
-	}
-
+// fetchCompareResults fetches the matching results for a given query string.
+// The results will be grouped into one or more groups based on either the query string or heuristics.
+func (a *App) fetchCompareResults(q string) ([]*resultGroup, error) {
 	// Parse query
 	prefix, queries := parseQueryString(q)
 
@@ -250,19 +249,13 @@
 		res.Close()
 		if err != nil {
 			// TODO: If the query is invalid, surface that to the user.
-			return &compareData{
-				Q:     q,
-				Error: err.Error(),
-			}
+			return nil, err
 		}
 		groups = append(groups, group)
 	}
 
 	if found == 0 {
-		return &compareData{
-			Q:     q,
-			Error: "No results matched the query string.",
-		}
+		return nil, errors.New("no results matched the query string")
 	}
 
 	// Attempt to automatically split results.
@@ -274,6 +267,22 @@
 		}
 	}
 
+	return groups, nil
+}
+
+func (a *App) compareQuery(q string) *compareData {
+	if len(q) == 0 {
+		return &compareData{}
+	}
+
+	groups, err := a.fetchCompareResults(q)
+	if err != nil {
+		return &compareData{
+			Q:     q,
+			Error: err.Error(),
+		}
+	}
+
 	var buf bytes.Buffer
 	// Compute benchstat
 	c := new(benchstat.Collection)
@@ -321,3 +330,28 @@
 	}
 	return data
 }
+
+// textCompare is called if benchsave is requesting a text-only analysis.
+func (a *App) textCompare(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+
+	q := r.Form.Get("q")
+
+	groups, err := a.fetchCompareResults(q)
+	if err != nil {
+		// TODO(quentin): Should we serve this with a 500 or 404? This means the query was invalid or had no results.
+		fmt.Fprintf(w, "unable to analyze results: %v", err)
+	}
+
+	// Compute benchstat
+	c := new(benchstat.Collection)
+	for _, g := range groups {
+		c.AddResults(g.Q, g.results)
+	}
+	benchstat.FormatText(w, c.Tables())
+}
diff --git a/benchstat/text.go b/benchstat/text.go
index 36d2d50..b8189f8 100644
--- a/benchstat/text.go
+++ b/benchstat/text.go
@@ -5,13 +5,13 @@
 package benchstat
 
 import (
-	"bytes"
 	"fmt"
+	"io"
 	"unicode/utf8"
 )
 
-// FormatText appends a fixed-width text formatting of the tables to buf.
-func FormatText(buf *bytes.Buffer, tables []*Table) {
+// FormatText appends a fixed-width text formatting of the tables to w.
+func FormatText(w io.Writer, tables []*Table) {
 	var textTables [][]*textRow
 	for _, t := range tables {
 		textTables = append(textTables, toText(t))
@@ -34,7 +34,7 @@
 
 	for i, table := range textTables {
 		if i > 0 {
-			fmt.Fprintf(buf, "\n")
+			fmt.Fprintf(w, "\n")
 		}
 
 		// headings
@@ -42,11 +42,11 @@
 		for i, s := range row.cols {
 			switch i {
 			case 0:
-				fmt.Fprintf(buf, "%-*s", max[i], s)
+				fmt.Fprintf(w, "%-*s", max[i], s)
 			default:
-				fmt.Fprintf(buf, "  %-*s", max[i], s)
+				fmt.Fprintf(w, "  %-*s", max[i], s)
 			case len(row.cols) - 1:
-				fmt.Fprintf(buf, "  %s\n", s)
+				fmt.Fprintf(w, "  %s\n", s)
 			}
 		}
 
@@ -55,17 +55,17 @@
 			for i, s := range row.cols {
 				switch i {
 				case 0:
-					fmt.Fprintf(buf, "%-*s", max[i], s)
+					fmt.Fprintf(w, "%-*s", max[i], s)
 				default:
 					if i == len(row.cols)-1 && len(s) > 0 && s[0] == '(' {
 						// Left-align p value.
-						fmt.Fprintf(buf, "  %s", s)
+						fmt.Fprintf(w, "  %s", s)
 						break
 					}
-					fmt.Fprintf(buf, "  %*s", max[i], s)
+					fmt.Fprintf(w, "  %*s", max[i], s)
 				}
 			}
-			fmt.Fprintf(buf, "\n")
+			fmt.Fprintf(w, "\n")
 		}
 	}
 }
diff --git a/cmd/benchsave/benchsave.go b/cmd/benchsave/benchsave.go
index 5913e41..50c70f0 100644
--- a/cmd/benchsave/benchsave.go
+++ b/cmd/benchsave/benchsave.go
@@ -24,7 +24,9 @@
 	"io"
 	"io/ioutil"
 	"log"
+	"mime"
 	"mime/multipart"
+	"net/http"
 	"os"
 	"path/filepath"
 	"time"
@@ -38,6 +40,8 @@
 	header  = flag.String("header", "", "insert `file` at the beginning of each uploaded file")
 )
 
+const userAgent = "Benchsave/1.0"
+
 type uploadStatus struct {
 	// UploadID is the upload ID assigned to the upload.
 	UploadID string `json:"uploadid"`
@@ -126,7 +130,13 @@
 
 	start := time.Now()
 
-	resp, err := hc.Post(*server+"/upload", mpw.FormDataContentType(), pr)
+	req, err := http.NewRequest("POST", *server+"/upload", pr)
+	if err != nil {
+		log.Fatalf("NewRequest failed: %v\n", err)
+	}
+	req.Header.Set("Content-Type", mpw.FormDataContentType())
+	req.Header.Set("User-Agent", userAgent)
+	resp, err := hc.Do(req)
 	if err != nil {
 		log.Fatalf("upload failed: %v\n", err)
 	}
@@ -151,7 +161,23 @@
 		log.Printf("%d file%s uploaded in %.2f seconds.\n", len(files), s, time.Since(start).Seconds())
 	}
 	if status.ViewURL != "" {
+		// New servers will serve a text/plain response to the view URL when given these headers.
+		// Old servers will not, so only show the response if it is a 200 and text/plain.
+		req, err := http.NewRequest("GET", status.ViewURL, nil)
+		if err == nil {
+			req.Header.Set("User-Agent", userAgent)
+			req.Header.Set("Accept", "text/plain")
+			req.Header.Set("X-Benchsave", "1")
+			resp, err := hc.Do(req)
+			if err == nil {
+				defer resp.Body.Close()
+				mt, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
+				if resp.StatusCode == http.StatusOK && err == nil && mt == "text/plain" {
+					io.Copy(os.Stdout, resp.Body)
+					fmt.Println()
+				}
+			}
+		}
 		fmt.Printf("%s\n", status.ViewURL)
 	}
-	// TODO(quentin): Print benchstat-style output, either computed client-side or fetched from a server.
 }