Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .snapshots/TestHelp
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Application Options:
--header=<header>... Custom headers
-f, --ignore-fragments Ignore URL fragments
--dns-resolver=<address> 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)
Expand Down
2 changes: 1 addition & 1 deletion arguments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"<address>" 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.
Expand Down
29 changes: 29 additions & 0 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@
return c.printResultsInJSON(checker.Results(), args.Verbose)
case "junit":
return c.printResultsInJUnitXML(checker.Results())
case "csv":
return c.printResultsInCSV(checker.Results(), args.Verbose)

Check warning on line 125 in command.go

View check run for this annotation

Codecov / codecov/patch

command.go#L124-L125

Added lines #L124 - L125 were not covered by tests
}

formatter := newPageResultFormatter(
Expand Down Expand Up @@ -196,6 +198,33 @@
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"))
}

Check warning on line 218 in command.go

View check run for this annotation

Codecov / codecov/patch

command.go#L201-L218

Added lines #L201 - L218 were not covered by tests
}
}

ok = ok && r.OK()

Check warning on line 222 in command.go

View check run for this annotation

Codecov / codecov/patch

command.go#L222

Added line #L222 was not covered by tests
}

return ok, nil

Check warning on line 225 in command.go

View check run for this annotation

Codecov / codecov/patch

command.go#L225

Added line #L225 was not covered by tests
}

func (c *command) print(xs ...any) {
if _, err := fmt.Fprintln(c.stdout, strings.TrimSpace(fmt.Sprint(xs...))); err != nil {
panic(err)
Expand Down
58 changes: 58 additions & 0 deletions csv_page_result.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"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

buf.WriteString(`"Page URL","Link URL",Status` + "\n")

for _, link := range r.Links {
buf.WriteString(`"` + strings.ReplaceAll(r.URL, `"`, `""`) + `","` +

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the encoding/csv package?

@stelgenhof stelgenhof Jun 18, 2025

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to be accurate and ensure that string fields are always quoted, since URL's technically can have comma's (as per RFC 3986). For example in the query string. My first attempt was actually using the encode/csv package, however read somewhere that it doesn't quote by default or otherwise quote every field, so opted for this approach.

However, after some more reading, I think my understanding may have been wrong and can actually use the encoding/csv instead. I'll do some refactoring 😄

strings.ReplaceAll(link.URL, `"`, `""`) + `",` +
link.Status + "\n")
}

return buf.String()
}
92 changes: 92 additions & 0 deletions csv_page_result_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
Loading