From 76e17f9c934c5a6eb33bb4e99b680bb6c036853c Mon Sep 17 00:00:00 2001 From: Sacha Telgenhof Date: Thu, 12 Jun 2025 22:42:38 +0900 Subject: [PATCH 1/2] Add CSV output Signed-off-by: Sacha Telgenhof --- .snapshots/TestHelp | 2 +- arguments.go | 2 +- command.go | 29 +++++++++++++ csv_page_result.go | 61 +++++++++++++++++++++++++++ csv_page_result_test.go | 92 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 csv_page_result.go create mode 100644 csv_page_result_test.go diff --git a/.snapshots/TestHelp b/.snapshots/TestHelp index fbddea17..4c63f694 100644 --- a/.snapshots/TestHelp +++ b/.snapshots/TestHelp @@ -24,7 +24,7 @@ Application Options: --header=
... Custom headers -f, --ignore-fragments Ignore URL fragments --dns-resolver=
Custom DNS resolver - --format=[text|json|junit] Output format (default: text) + --format=[text|json|junit|csv] Output format (default: text) --json Output results in JSON (deprecated) --experimental-verbose-json Include successful results in JSON (deprecated) diff --git a/arguments.go b/arguments.go index 71195fb1..cd3bebf1 100644 --- a/arguments.go +++ b/arguments.go @@ -24,7 +24,7 @@ type arguments struct { // TODO Remove a short option. IgnoreFragments bool `short:"f" long:"ignore-fragments" description:"Ignore URL fragments"` DnsResolver string `long:"dns-resolver" value-name:"
" description:"Custom DNS resolver"` - Format string `long:"format" description:"Output format" default:"text" choice:"text" choice:"json" choice:"junit"` + Format string `long:"format" description:"Output format" default:"text" choice:"text" choice:"json" choice:"junit" choice:"csv"` // TODO Remove this option. JSONOutput bool `long:"json" description:"Output results in JSON (deprecated)"` // TODO Remove this option. diff --git a/command.go b/command.go index bea32cb2..285c5bbe 100644 --- a/command.go +++ b/command.go @@ -121,6 +121,8 @@ func (c *command) runWithError(ss []string) (bool, error) { return c.printResultsInJSON(checker.Results(), args.Verbose) case "junit": return c.printResultsInJUnitXML(checker.Results()) + case "csv": + return c.printResultsInCSV(checker.Results(), args.Verbose) } formatter := newPageResultFormatter( @@ -196,6 +198,33 @@ func (c *command) printResultsInJUnitXML(rc <-chan *pageResult) (bool, error) { return ok, nil } +func (c *command) printResultsInCSV(rc <-chan *pageResult, verbose bool) (bool, error) { + first := true + ok := true + + for r := range rc { + if !r.OK() || verbose { + csvResult := newCSVPageResult(r, verbose) + output := csvResult.String() + + if first { + c.print(output) + first = false + } else { + // Skip header for subsequent results + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) > 1 { + c.print(strings.Join(lines[1:], "\n")) + } + } + } + + ok = ok && r.OK() + } + + return ok, nil +} + func (c *command) print(xs ...any) { if _, err := fmt.Fprintln(c.stdout, strings.TrimSpace(fmt.Sprint(xs...))); err != nil { panic(err) diff --git a/csv_page_result.go b/csv_page_result.go new file mode 100644 index 00000000..56418cde --- /dev/null +++ b/csv_page_result.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/csv" + "strconv" + "strings" +) + +type csvPageResult struct { + URL string + Links []csvLinkResult +} + +type csvLinkResult struct { + URL string + Status string +} + +func newCSVPageResult(r *pageResult, verbose bool) *csvPageResult { + c := len(r.ErrorLinkResults) + + if verbose { + c += len(r.SuccessLinkResults) + } + + ls := make([]csvLinkResult, 0, c) + + if verbose { + for _, r := range r.SuccessLinkResults { + ls = append(ls, csvLinkResult{ + URL: r.URL, + Status: strconv.Itoa(r.StatusCode), + }) + } + } + + for _, r := range r.ErrorLinkResults { + ls = append(ls, csvLinkResult{ + URL: r.URL, + Status: r.Error.Error(), + }) + } + + return &csvPageResult{r.URL, ls} +} + +func (r *csvPageResult) String() string { + var buf strings.Builder + writer := csv.NewWriter(&buf) + + // Write header + writer.Write([]string{"Page URL", "Link URL", "Status"}) + + // Write data rows + for _, link := range r.Links { + writer.Write([]string{r.URL, link.URL, link.Status}) + } + + writer.Flush() + return buf.String() +} diff --git a/csv_page_result_test.go b/csv_page_result_test.go new file mode 100644 index 00000000..67a61860 --- /dev/null +++ b/csv_page_result_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "errors" + "strings" + "testing" +) + +func TestNewCSVPageResult(t *testing.T) { + r := &pageResult{ + URL: "http://foo.com", + SuccessLinkResults: []*successLinkResult{ + {URL: "http://foo.com/success", StatusCode: 200}, + }, + ErrorLinkResults: []*errorLinkResult{ + {URL: "http://foo.com/error", Error: errors.New("404")}, + }, + } + + t.Run("verbose mode", func(t *testing.T) { + result := newCSVPageResult(r, true) + + if result.URL != "http://foo.com" { + t.Errorf("expected URL to be 'http://foo.com', got '%s'", result.URL) + } + + if len(result.Links) != 2 { + t.Errorf("expected 2 links, got %d", len(result.Links)) + } + + successLink := result.Links[0] + if successLink.URL != "http://foo.com/success" { + t.Errorf("expected success URL to be 'http://foo.com/success', got '%s'", successLink.URL) + } + if successLink.Status != "200" { + t.Errorf("expected status to be '200', got '%s'", successLink.Status) + } + + errorLink := result.Links[1] + if errorLink.URL != "http://foo.com/error" { + t.Errorf("expected error URL to be 'http://foo.com/error', got '%s'", errorLink.URL) + } + if errorLink.Status != "404" { + t.Errorf("expected status to be '404', got '%s'", errorLink.Status) + } + }) + + t.Run("non-verbose mode", func(t *testing.T) { + result := newCSVPageResult(r, false) + + if len(result.Links) != 1 { + t.Errorf("expected 1 link, got %d", len(result.Links)) + } + + errorLink := result.Links[0] + if errorLink.URL != "http://foo.com/error" { + t.Errorf("expected error URL to be 'http://foo.com/error', got '%s'", errorLink.URL) + } + }) +} + +func TestCSVPageResultString(t *testing.T) { + result := &csvPageResult{ + URL: "http://foo.com", + Links: []csvLinkResult{ + {URL: "http://foo.com/success", Status: "200"}, + {URL: "http://foo.com/error", Status: "404"}, + }, + } + + output := result.String() + lines := strings.Split(strings.TrimSpace(output), "\n") + + if len(lines) != 3 { + t.Errorf("expected 3 lines (header + 2 data), got %d", len(lines)) + } + + expectedHeader := "Page URL,Link URL,Status" + if lines[0] != expectedHeader { + t.Errorf("expected header '%s', got '%s'", expectedHeader, lines[0]) + } + + expectedRow1 := "http://foo.com,http://foo.com/success,200" + if lines[1] != expectedRow1 { + t.Errorf("expected first row '%s', got '%s'", expectedRow1, lines[1]) + } + + expectedRow2 := "http://foo.com,http://foo.com/error,404" + if lines[2] != expectedRow2 { + t.Errorf("expected second row '%s', got '%s'", expectedRow2, lines[2]) + } +} From c8cdb2a7d4607ba9e6fb831822cdf2b55f8327cc Mon Sep 17 00:00:00 2001 From: Sacha Telgenhof Date: Sun, 15 Jun 2025 15:34:51 +0900 Subject: [PATCH 2/2] Refactor so that fields with strings are quoted Signed-off-by: Sacha Telgenhof --- csv_page_result.go | 11 ++++------- csv_page_result_test.go | 6 +++--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/csv_page_result.go b/csv_page_result.go index 56418cde..be5ab905 100644 --- a/csv_page_result.go +++ b/csv_page_result.go @@ -1,7 +1,6 @@ package main import ( - "encoding/csv" "strconv" "strings" ) @@ -46,16 +45,14 @@ func newCSVPageResult(r *pageResult, verbose bool) *csvPageResult { func (r *csvPageResult) String() string { var buf strings.Builder - writer := csv.NewWriter(&buf) - // Write header - writer.Write([]string{"Page URL", "Link URL", "Status"}) + buf.WriteString(`"Page URL","Link URL",Status` + "\n") - // Write data rows for _, link := range r.Links { - writer.Write([]string{r.URL, link.URL, link.Status}) + buf.WriteString(`"` + strings.ReplaceAll(r.URL, `"`, `""`) + `","` + + strings.ReplaceAll(link.URL, `"`, `""`) + `",` + + link.Status + "\n") } - writer.Flush() return buf.String() } diff --git a/csv_page_result_test.go b/csv_page_result_test.go index 67a61860..2e3df127 100644 --- a/csv_page_result_test.go +++ b/csv_page_result_test.go @@ -75,17 +75,17 @@ func TestCSVPageResultString(t *testing.T) { t.Errorf("expected 3 lines (header + 2 data), got %d", len(lines)) } - expectedHeader := "Page URL,Link URL,Status" + expectedHeader := `"Page URL","Link URL",Status` if lines[0] != expectedHeader { t.Errorf("expected header '%s', got '%s'", expectedHeader, lines[0]) } - expectedRow1 := "http://foo.com,http://foo.com/success,200" + expectedRow1 := `"http://foo.com","http://foo.com/success",200` if lines[1] != expectedRow1 { t.Errorf("expected first row '%s', got '%s'", expectedRow1, lines[1]) } - expectedRow2 := "http://foo.com,http://foo.com/error,404" + expectedRow2 := `"http://foo.com","http://foo.com/error",404` if lines[2] != expectedRow2 { t.Errorf("expected second row '%s', got '%s'", expectedRow2, lines[2]) }