diff --git a/README.md b/README.md
index 4708c0d..7e964c1 100644
--- a/README.md
+++ b/README.md
@@ -14,10 +14,14 @@ Use `-buildvcs=false` when building outside a Git checkout.
```sh
./passctl --cpu "Ryzen 3600x"
+./passctl --cpu "Ryzen 3600x" "Core i7-10700"
+./passctl --cpu "Ryzen 3600x" "Core i7-10700" "Core i5-12400"
./passctl --gpu "Radeon RX 6650 XT"
+./passctl --gpu "Radeon RX 6650 XT" "GeForce RTX 3060"
```
- `--cpu`: search PassMark CPU benchmarks
- `--gpu`: search PassMark GPU benchmarks
+- pass two or more CPU or GPU queries to print a color-coded comparison table
PassMark may return anti-bot or challenge pages from some networks. When that happens, `passctl` exits with a clear error to let you know you have been rate limited.
diff --git a/main.go b/main.go
index 4027966..57b6a0f 100644
--- a/main.go
+++ b/main.go
@@ -11,6 +11,7 @@ import (
"os"
"regexp"
"sort"
+ "strconv"
"strings"
"time"
)
@@ -21,8 +22,8 @@ const (
)
type options struct {
- kind string
- query string
+ kind string
+ queries []string
}
type result struct {
@@ -45,12 +46,21 @@ var (
letterNumberRe = regexp.MustCompile(`([a-z])([0-9])`)
numberLetterRe = regexp.MustCompile(`([0-9])([a-z])`)
nonWordRe = regexp.MustCompile(`[^a-z0-9]+`)
+ ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
scriptRe = regexp.MustCompile(`(?is)`)
styleRe = regexp.MustCompile(`(?is)`)
tagRe = regexp.MustCompile(`(?s)<[^>]+>`)
tokenReplacer = strings.NewReplacer("+", " plus ", "-", " ", "_", " ", "(", " ", ")", " ", "/", " ")
)
+const (
+ colorReset = "\x1b[0m"
+ colorBold = "\x1b[1m"
+ colorDim = "\x1b[2m"
+ colorGreen = "\x1b[32m"
+ colorRed = "\x1b[31m"
+)
+
func main() {
opts, err := parseArgs(os.Args[1:])
if err != nil {
@@ -62,12 +72,21 @@ func main() {
defer cancel()
c := &client{http: &http.Client{Timeout: 15 * time.Second}}
- res, err := c.passmark(ctx, opts.kind, opts.query)
- if err != nil {
- exitErr(err)
+ results := make([]result, 0, len(opts.queries))
+ for _, query := range opts.queries {
+ res, err := c.passmark(ctx, opts.kind, query)
+ if err != nil {
+ exitErr(err)
+ }
+ results = append(results, res)
}
- printResult(os.Stdout, res)
+ if len(results) > 1 {
+ printComparison(os.Stdout, results...)
+ return
+ }
+
+ printResult(os.Stdout, results[0])
}
func parseArgs(args []string) (options, error) {
@@ -86,8 +105,8 @@ func parseArgs(args []string) (options, error) {
os.Exit(0)
}
rest := fs.Args()
- if len(rest) != 1 {
- return options{}, errors.New("expected exactly one CPU or GPU search query")
+ if len(rest) < 1 {
+ return options{}, errors.New("expected at least one search query")
}
var opts options
if *cpu && *gpu {
@@ -102,9 +121,12 @@ func parseArgs(args []string) (options, error) {
if opts.kind == "" {
return options{}, errors.New("missing target: use --cpu or --gpu")
}
- opts.query = strings.TrimSpace(rest[0])
- if opts.query == "" {
- return options{}, errors.New("query cannot be empty")
+ for _, query := range rest {
+ query = strings.TrimSpace(query)
+ if query == "" {
+ return options{}, errors.New("query cannot be empty")
+ }
+ opts.queries = append(opts.queries, query)
}
return opts, nil
}
@@ -112,11 +134,16 @@ func parseArgs(args []string) (options, error) {
func printUsage(w io.Writer) {
fmt.Fprintln(w, "Usage:")
fmt.Fprintln(w, ` passctl --cpu "Ryzen 3600x"`)
+ fmt.Fprintln(w, ` passctl --cpu "Ryzen 3600x" "Core i7-10700"`)
+ fmt.Fprintln(w, ` passctl --cpu "Ryzen 3600x" "Core i7-10700" "Core i5-12400"`)
fmt.Fprintln(w, ` passctl --gpu "Radeon RX 6650 XT"`)
+ fmt.Fprintln(w, ` passctl --gpu "Radeon RX 6650 XT" "GeForce RTX 3060"`)
fmt.Fprintln(w)
fmt.Fprintln(w, "Options:")
fmt.Fprintln(w, " --cpu Search PassMark CPU benchmarks")
fmt.Fprintln(w, " --gpu Search PassMark GPU benchmarks")
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "Passing multiple queries prints a color-coded comparison.")
}
func exitErr(err error) {
@@ -141,6 +168,444 @@ func printResult(w io.Writer, res result) {
}
}
+func printComparison(w io.Writer, results ...result) {
+ if len(results) == 0 {
+ return
+ }
+ fmt.Fprintf(w, "%sPassMark %s Comparison%s\n", colorBold, strings.ToUpper(results[0].Kind), colorReset)
+ names := make([]string, 0, len(results))
+ for _, res := range results {
+ names = append(names, res.Name)
+ }
+ fmt.Fprintf(w, "%s\n", strings.Join(names, " vs "))
+ for i, res := range results {
+ if res.URL != "" {
+ fmt.Fprintf(w, "%s%s:%s %s\n", colorDim, resultLabel(i), colorReset, res.URL)
+ }
+ }
+ fmt.Fprintln(w)
+
+ rows := comparisonRows(results)
+ if len(rows) == 0 {
+ fmt.Fprintln(w, "No comparable stats found.")
+ return
+ }
+
+ headers := []string{"Metric"}
+ for _, res := range results {
+ headers = append(headers, res.Name)
+ }
+ headers = append(headers, "Better")
+ widths := make([]int, len(headers))
+ for i, header := range headers {
+ widths[i] = visibleLen(header)
+ }
+ for _, row := range rows {
+ for i, cell := range row {
+ if l := visibleLen(cell); l > widths[i] {
+ widths[i] = l
+ }
+ }
+ }
+
+ printTableRule(w, widths)
+ printTableRow(w, headers, widths)
+ printTableRule(w, widths)
+ for _, row := range rows {
+ printTableRow(w, row, widths)
+ }
+ printTableRule(w, widths)
+}
+
+func resultLabel(i int) string {
+ if i < 26 {
+ return string(rune('A' + i))
+ }
+ return fmt.Sprintf("Q%d", i+1)
+}
+
+func comparisonRows(results []result) [][]string {
+ stats := make([]map[string]string, 0, len(results))
+ for _, res := range results {
+ stats = append(stats, statsMap(res.Stats))
+ }
+ ordered := comparisonStatOrder(stats...)
+ var rows [][]string
+ for _, name := range ordered {
+ row, ok := compareRow(name, stats, results)
+ if !ok {
+ continue
+ }
+ rows = append(rows, row)
+ }
+ return rows
+}
+
+func statsMap(stats []stat) map[string]string {
+ out := make(map[string]string)
+ for _, s := range stats {
+ out[strings.ToLower(s.Name)] = s.Value
+ }
+ addAlias := func(from, to string) {
+ if v, ok := out[from]; ok {
+ if _, exists := out[to]; !exists {
+ out[to] = v
+ }
+ }
+ }
+ addAlias("average cpu mark", "passmark score")
+ addAlias("average g3d mark", "passmark score")
+ addAlias("average g2d mark", "passmark score")
+ addAlias("cpumark/$price", "value")
+ addAlias("single thread", "single thread rating")
+ return out
+}
+
+func comparisonStatOrder(stats ...map[string]string) []string {
+ preferred := []string{
+ "PassMark Score",
+ "Single Thread Rating",
+ "Value",
+ "Price",
+ "Rank",
+ "Samples",
+ "CPU First Seen on Charts",
+ "CPU Released",
+ "Release Date",
+ "Launched",
+ }
+ seen := make(map[string]bool)
+ var out []string
+ for _, name := range preferred {
+ key := strings.ToLower(name)
+ for _, statMap := range stats {
+ if _, ok := statMap[key]; ok {
+ out = append(out, name)
+ seen[key] = true
+ break
+ }
+ }
+ }
+
+ var rest []string
+ for _, statMap := range stats {
+ for key := range statMap {
+ if !seen[key] && !representedAlias(key, seen) {
+ rest = append(rest, key)
+ }
+ }
+ }
+ sort.Strings(rest)
+ for _, key := range rest {
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ out = append(out, titleStatName(key, stats...))
+ }
+ return out
+}
+
+func representedAlias(key string, seen map[string]bool) bool {
+ aliases := map[string]string{
+ "average cpu mark": "passmark score",
+ "average g3d mark": "passmark score",
+ "average g2d mark": "passmark score",
+ "cpumark/$price": "value",
+ "single thread": "single thread rating",
+ }
+ canonical, ok := aliases[key]
+ return ok && seen[canonical]
+}
+
+func titleStatName(key string, stats ...map[string]string) string {
+ for _, s := range stats {
+ for name := range s {
+ if name == key {
+ parts := strings.Fields(name)
+ for i := range parts {
+ parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
+ }
+ return strings.Join(parts, " ")
+ }
+ }
+ }
+ return key
+}
+
+func compareRow(name string, stats []map[string]string, results []result) ([]string, bool) {
+ key := strings.ToLower(name)
+ values := make([]string, len(stats))
+ present := make([]bool, len(stats))
+ anyPresent := false
+ for i, statMap := range stats {
+ values[i], present[i] = statMap[key]
+ anyPresent = anyPresent || present[i]
+ }
+ if !anyPresent {
+ return nil, false
+ }
+
+ cells := make([]string, len(values))
+ copy(cells, values)
+ for i := range cells {
+ if !present[i] {
+ cells[i] = colorDim + "n/a" + colorReset
+ }
+ }
+
+ winners, comparable := winningIndexes(name, values, present)
+ better := colorDim + "n/a" + colorReset
+ if comparable {
+ if len(winners) == countPresent(present) {
+ better = colorDim + "same" + colorReset
+ } else {
+ for i := range cells {
+ if !present[i] {
+ continue
+ }
+ if containsIndex(winners, i) {
+ cells[i] = colorGreen + cells[i] + colorReset
+ } else {
+ cells[i] = colorRed + cells[i] + colorReset
+ }
+ }
+ names := make([]string, 0, len(winners))
+ for _, idx := range winners {
+ names = append(names, results[idx].Name)
+ }
+ better = strings.Join(names, ", ")
+ }
+ }
+
+ row := []string{name}
+ row = append(row, cells...)
+ row = append(row, better)
+ return row, true
+}
+
+func winningIndexes(name string, values []string, present []bool) ([]int, bool) {
+ best := -1
+ for i := range values {
+ if !present[i] {
+ continue
+ }
+ if best == -1 {
+ best = i
+ continue
+ }
+ cmp, ok := compareStat(name, values[i], values[best])
+ if !ok {
+ return nil, false
+ }
+ if cmp > 0 {
+ best = i
+ }
+ }
+ if best == -1 {
+ return nil, false
+ }
+ winners := []int{best}
+ for i := range values {
+ if i == best || !present[i] {
+ continue
+ }
+ cmp, ok := compareStat(name, values[i], values[best])
+ if !ok {
+ return nil, false
+ }
+ if cmp == 0 {
+ winners = append(winners, i)
+ }
+ }
+ sort.Ints(winners)
+ return winners, true
+}
+
+func countPresent(present []bool) int {
+ count := 0
+ for _, ok := range present {
+ if ok {
+ count++
+ }
+ }
+ return count
+}
+
+func containsIndex(indexes []int, needle int) bool {
+ for _, idx := range indexes {
+ if idx == needle {
+ return true
+ }
+ }
+ return false
+}
+
+func compareCells(name, lv, rv, leftName, rightName string, lok, rok bool) (string, string, string) {
+ if !lok {
+ return colorDim + "n/a" + colorReset, rv, colorDim + "n/a" + colorReset
+ }
+ if !rok {
+ return lv, colorDim + "n/a" + colorReset, colorDim + "n/a" + colorReset
+ }
+
+ cmp, ok := compareStat(name, lv, rv)
+ if !ok || cmp == 0 {
+ better := colorDim + "n/a" + colorReset
+ if strings.EqualFold(lv, rv) {
+ better = colorDim + "same" + colorReset
+ }
+ return lv, rv, better
+ }
+ if cmp > 0 {
+ return colorGreen + lv + colorReset, colorRed + rv + colorReset, leftName
+ }
+ return colorRed + lv + colorReset, colorGreen + rv + colorReset, rightName
+}
+
+func compareStat(name, left, right string) (int, bool) {
+ lower := strings.ToLower(name)
+ if strings.Contains(lower, "date") || strings.Contains(lower, "released") || strings.Contains(lower, "launched") || strings.Contains(lower, "first seen") {
+ lv, lok := parseDateValue(left)
+ rv, rok := parseDateValue(right)
+ if lok && rok {
+ return compareTime(lv, rv), true
+ }
+ }
+ if !numericComparableStat(lower) {
+ return 0, false
+ }
+
+ lv, lok := parseNumberValue(left)
+ rv, rok := parseNumberValue(right)
+ if !lok || !rok {
+ return 0, false
+ }
+ cmp := compareFloat(lv, rv)
+ if lowerBetterStat(lower) {
+ cmp = -cmp
+ }
+ return cmp, true
+}
+
+func numericComparableStat(lower string) bool {
+ terms := []string{
+ "score",
+ "mark",
+ "rating",
+ "value",
+ "price",
+ "rank",
+ "samples",
+ "clock",
+ "speed",
+ "core",
+ "thread",
+ "tdp",
+ "power",
+ "compression",
+ "encryption",
+ "instructions",
+ "math",
+ "physics",
+ "sorting",
+ "prime",
+ "fps",
+ "frames",
+ "watt",
+ }
+ for _, term := range terms {
+ if strings.Contains(lower, term) {
+ return true
+ }
+ }
+ return false
+}
+
+func lowerBetterStat(lower string) bool {
+ return strings.Contains(lower, "price") ||
+ strings.Contains(lower, "rank") ||
+ strings.Contains(lower, "cost") ||
+ strings.Contains(lower, "tdp") ||
+ strings.Contains(lower, "power")
+}
+
+func parseNumberValue(s string) (float64, bool) {
+ re := regexp.MustCompile(`-?\d[\d,]*(?:\.\d+)?`)
+ match := re.FindString(s)
+ if match == "" {
+ return 0, false
+ }
+ match = strings.ReplaceAll(match, ",", "")
+ v, err := strconv.ParseFloat(match, 64)
+ return v, err == nil
+}
+
+func parseDateValue(s string) (time.Time, bool) {
+ s = strings.TrimSpace(s)
+ quarterRe := regexp.MustCompile(`(?i)^q([1-4])\s+(\d{4})$`)
+ if m := quarterRe.FindStringSubmatch(s); len(m) == 3 {
+ quarter, _ := strconv.Atoi(m[1])
+ year, _ := strconv.Atoi(m[2])
+ month := time.Month(((quarter - 1) * 3) + 1)
+ return time.Date(year, month, 1, 0, 0, 0, 0, time.UTC), true
+ }
+ layouts := []string{
+ "Jan 2006",
+ "January 2006",
+ "Jan 2, 2006",
+ "January 2, 2006",
+ "2006-01-02",
+ "2006",
+ }
+ for _, layout := range layouts {
+ if t, err := time.Parse(layout, s); err == nil {
+ return t, true
+ }
+ }
+ return time.Time{}, false
+}
+
+func compareFloat(left, right float64) int {
+ if left > right {
+ return 1
+ }
+ if left < right {
+ return -1
+ }
+ return 0
+}
+
+func compareTime(left, right time.Time) int {
+ if left.After(right) {
+ return 1
+ }
+ if left.Before(right) {
+ return -1
+ }
+ return 0
+}
+
+func printTableRule(w io.Writer, widths []int) {
+ fmt.Fprint(w, "+")
+ for _, width := range widths {
+ fmt.Fprint(w, strings.Repeat("-", width+2), "+")
+ }
+ fmt.Fprintln(w)
+}
+
+func printTableRow(w io.Writer, cells []string, widths []int) {
+ fmt.Fprint(w, "|")
+ for i, cell := range cells {
+ fmt.Fprintf(w, " %s%s |", cell, strings.Repeat(" ", widths[i]-visibleLen(cell)))
+ }
+ fmt.Fprintln(w)
+}
+
+func visibleLen(s string) int {
+ return len(ansiRe.ReplaceAllString(s, ""))
+}
+
func (c *client) passmark(ctx context.Context, kind, query string) (result, error) {
baseHost := "www.cpubenchmark.net"
listPath := "/cpu-list/all"
@@ -171,7 +636,20 @@ func (c *client) passmark(ctx context.Context, kind, query string) (result, erro
return result{}, fmt.Errorf("no PassMark %s match found for %q", kind, query)
}
if len(best.Stats) > 0 {
- return result{Kind: kind, Name: best.Name, URL: best.URL, Stats: best.Stats}, nil
+ detailURL := canonicalDetailURL(kind, best.URL)
+ detail, err := c.get(ctx, detailURL)
+ if err != nil {
+ detailURL = best.URL
+ detail, err = c.get(ctx, detailURL)
+ if err != nil {
+ return result{Kind: kind, Name: best.Name, URL: best.URL, Stats: best.Stats}, nil
+ }
+ }
+ name, stats := parsePassMarkDetail(detail)
+ if name == "" {
+ name = best.Name
+ }
+ return result{Kind: kind, Name: name, URL: detailURL, Stats: mergeStats(best.Stats, stats)}, nil
}
detail, err := c.get(ctx, best.URL)
@@ -188,6 +666,20 @@ func (c *client) passmark(ctx context.Context, kind, query string) (result, erro
return result{Kind: kind, Name: name, URL: best.URL, Stats: stats}, nil
}
+func canonicalDetailURL(kind, rawURL string) string {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return rawURL
+ }
+ switch kind {
+ case kindCPU:
+ u.Path = "/cpu.php"
+ case kindGPU:
+ u.Path = "/gpu.php"
+ }
+ return u.String()
+}
+
func (c *client) get(ctx context.Context, rawURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
@@ -522,3 +1014,11 @@ func appendUnique(stats []stat, next stat) []stat {
}
return append(stats, next)
}
+
+func mergeStats(first, second []stat) []stat {
+ out := append([]stat{}, first...)
+ for _, s := range second {
+ out = appendUnique(out, s)
+ }
+ return out
+}
diff --git a/main_test.go b/main_test.go
index e4cfcd5..5f6bb8a 100644
--- a/main_test.go
+++ b/main_test.go
@@ -7,7 +7,7 @@ func TestParseArgsCPU(t *testing.T) {
if err != nil {
t.Fatalf("parseArgs returned error: %v", err)
}
- if opts.kind != kindCPU || opts.query != "Ryzen 3600x" {
+ if opts.kind != kindCPU || len(opts.queries) != 1 || opts.queries[0] != "Ryzen 3600x" {
t.Fatalf("unexpected options: %+v", opts)
}
}
@@ -17,11 +17,115 @@ func TestParseArgsGPU(t *testing.T) {
if err != nil {
t.Fatalf("parseArgs returned error: %v", err)
}
- if opts.kind != kindGPU || opts.query != "Radeon RX 6650 XT" {
+ if opts.kind != kindGPU || len(opts.queries) != 1 || opts.queries[0] != "Radeon RX 6650 XT" {
t.Fatalf("unexpected options: %+v", opts)
}
}
+func TestParseArgsCPUCompare(t *testing.T) {
+ opts, err := parseArgs([]string{"--cpu", "Ryzen 3600x", "Core i7-10700"})
+ if err != nil {
+ t.Fatalf("parseArgs returned error: %v", err)
+ }
+ if opts.kind != kindCPU || len(opts.queries) != 2 || opts.queries[0] != "Ryzen 3600x" || opts.queries[1] != "Core i7-10700" {
+ t.Fatalf("unexpected options: %+v", opts)
+ }
+}
+
+func TestParseArgsCPUMany(t *testing.T) {
+ opts, err := parseArgs([]string{"--cpu", "Ryzen 3600x", "Core i7-10700", "Core i5-12400", "Ryzen 5800X3D"})
+ if err != nil {
+ t.Fatalf("parseArgs returned error: %v", err)
+ }
+ if opts.kind != kindCPU || len(opts.queries) != 4 {
+ t.Fatalf("unexpected options: %+v", opts)
+ }
+ want := []string{"Ryzen 3600x", "Core i7-10700", "Core i5-12400", "Ryzen 5800X3D"}
+ for i := range want {
+ if opts.queries[i] != want[i] {
+ t.Fatalf("query %d = %q, want %q; all options: %+v", i, opts.queries[i], want[i], opts)
+ }
+ }
+}
+
+func TestComparisonRowsMany(t *testing.T) {
+ results := []result{
+ {Kind: kindCPU, Name: "A", Stats: []stat{{Name: "PassMark Score", Value: "10"}, {Name: "Price", Value: "$300"}}},
+ {Kind: kindCPU, Name: "B", Stats: []stat{{Name: "PassMark Score", Value: "20"}, {Name: "Price", Value: "$200"}}},
+ {Kind: kindCPU, Name: "C", Stats: []stat{{Name: "PassMark Score", Value: "15"}, {Name: "Price", Value: "$250"}}},
+ }
+
+ rows := comparisonRows(results)
+ if len(rows) < 2 {
+ t.Fatalf("expected comparison rows, got %+v", rows)
+ }
+ score := findRow(rows, "PassMark Score")
+ if len(score) != 5 {
+ t.Fatalf("score row has %d cells, want 5: %+v", len(score), score)
+ }
+ if score[4] != "B" {
+ t.Fatalf("score winner = %q, want B; row: %+v", score[4], score)
+ }
+ price := findRow(rows, "Price")
+ if price[4] != "B" {
+ t.Fatalf("price winner = %q, want B; row: %+v", price[4], price)
+ }
+}
+
+func TestCompareStatPriceLowerIsBetter(t *testing.T) {
+ cmp, ok := compareStat("Price", "$97.71", "$299.99")
+ if !ok {
+ t.Fatal("expected price to compare")
+ }
+ if cmp <= 0 {
+ t.Fatalf("cmp = %d, want left price to win", cmp)
+ }
+}
+
+func TestCompareStatPassMarkHigherIsBetter(t *testing.T) {
+ cmp, ok := compareStat("PassMark Score", "18,123", "17,111")
+ if !ok {
+ t.Fatal("expected score to compare")
+ }
+ if cmp <= 0 {
+ t.Fatalf("cmp = %d, want left score to win", cmp)
+ }
+}
+
+func TestCompareStatReleaseDateNewerIsBetter(t *testing.T) {
+ cmp, ok := compareStat("CPU First Seen on Charts", "Jan 2024", "January 2021")
+ if !ok {
+ t.Fatal("expected release date to compare")
+ }
+ if cmp <= 0 {
+ t.Fatalf("cmp = %d, want newer date to win", cmp)
+ }
+}
+
+func TestCompareStatQuarterDateNewerIsBetter(t *testing.T) {
+ cmp, ok := compareStat("CPU First Seen on Charts", "Q3 2019", "Q2 2018")
+ if !ok {
+ t.Fatal("expected quarter date to compare")
+ }
+ if cmp <= 0 {
+ t.Fatalf("cmp = %d, want newer quarter to win", cmp)
+ }
+}
+
+func TestCompareStatSocketIsNotNumeric(t *testing.T) {
+ if _, ok := compareStat("Socket", "AM4", "FCLGA1200"); ok {
+ t.Fatal("socket values should not be treated as numeric comparisons")
+ }
+}
+
+func TestCanonicalDetailURLCPU(t *testing.T) {
+ got := canonicalDetailURL(kindCPU, "https://www.cpubenchmark.net/cpu_lookup.php?cpu=Intel+Core+i7&id=3747")
+ want := "https://www.cpubenchmark.net/cpu.php?cpu=Intel+Core+i7&id=3747"
+ if got != want {
+ t.Fatalf("canonicalDetailURL = %q, want %q", got, want)
+ }
+}
+
func TestPassMarkSelectedStatsCPU(t *testing.T) {
html := `
@@ -81,3 +185,12 @@ func assertStats(t *testing.T, stats []stat, want map[string]string) {
}
}
}
+
+func findRow(rows [][]string, name string) []string {
+ for _, row := range rows {
+ if row[0] == name {
+ return row
+ }
+ }
+ return nil
+}