diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a55091d..590d346 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,6 +10,7 @@ Brokli (a play on "broken links") is a CLI tool for checking broken links on web - `pkg/fetcher/`: HTTP operations (`GetHTML`) - `pkg/parser/`: HTML/XML parsing (`html.go`, `sitemap.go`) - `pkg/resolver/`: URL resolution logic (`ResolveAbsoluteUrl`, `IsSpecialLink`) +- `pkg/output/`: Output formatting with interface-based design (`Formatter` interface, `DefaultFormatter`, `VerboseFormatter`, `GitHubFormatter`) **Target Use Case**: Local development workflow - developers run `brokli check url https://localhost:3000` or `brokli check sitemap https://localhost:3000/sitemap.xml` to validate links before deployment. @@ -18,7 +19,8 @@ Brokli (a play on "broken links") is a CLI tool for checking broken links on web - ✅ **Concurrent HTTP Checking** - Worker pool with configurable workers (default: 10) - ✅ **Progress Indication** - Real-time counter with thread-safe serial callback - ✅ **Colored Terminal Output** - Status code coloring (green/red/cyan/yellow) -- ✅ **Verbose Mode** - `--verbose/-v` flag to show all links vs broken only +- ✅ **Multiple Output Formats** - `--output-format` flag with `default`, `verbose`, and `github` options +- ✅ **GitHub Actions Integration** - Native support with workflow annotations and step outputs - ✅ **Smart Filtering** - Display broken links (4xx/5xx) by default - ✅ **URL & Sitemap Support** - Check single pages or entire sitemaps - ✅ **Comprehensive Testing** - 94%+ test coverage with race detection @@ -33,7 +35,8 @@ Brokli (a play on "broken links") is a CLI tool for checking broken links on web - `pkg/fetcher`: Only HTTP fetching - `pkg/parser`: Only parsing (HTML/XML → data structures) - `pkg/resolver`: Only URL resolution -- Import packages with descriptive names: `fetcher.GetHTML()`, `parser.ParseHTML()`, `resolver.ResolveAbsoluteUrl()` + - `pkg/output`: Only output formatting (interface-based design) +- Import packages with descriptive names: `fetcher.GetHTML()`, `parser.ParseHTML()`, `resolver.ResolveAbsoluteUrl()`, `output.NewFormatter()` ### Error Handling - Functions return errors wrapped with context: `fmt.Errorf("failed to parse URL '%s': %w", urlStr, err)` @@ -60,19 +63,39 @@ Brokli (a play on "broken links") is a CLI tool for checking broken links on web - Checker package operates on `link.AnchorTag` and `link.SitemapUrl` to set Status fields ### Output & Display +- **Output Formatting** uses interface-based design in `pkg/output/`: + - `Formatter` interface defines `FormatPageResults()` and `FormatSitemapResults()` + - Three implementations: `DefaultFormatter`, `VerboseFormatter`, `GitHubFormatter` + - Factory function: `output.NewFormatter(format)` creates appropriate formatter + - String constants for format names: `output.FormatNameDefault`, `output.FormatNameVerbose`, `output.FormatNameGitHub` +- **CLI Flags**: + - `--output-format` (or `-o`): Choose output format: `default`, `verbose`, or `github` + - `-v/--verbose`: Deprecated but maintained for backward compatibility, sets format to verbose +- **Default Format** (`output.FormatDefault`): + - Shows only broken links (4xx/5xx status codes) + - Color-coded status codes + - Summary with count of broken links +- **Verbose Format** (`output.FormatVerbose`): + - Shows all links with their status codes + - Includes working links (2xx) and redirects (3xx) + - Full summary statistics +- **GitHub Actions Format** (`output.FormatGitHub`): + - Emits workflow annotations: `::error title="Broken Link (Status 404)"::Link 'text' to url returned status 404` + - Writes to `GITHUB_OUTPUT` environment file (if set) with secure 0600 permissions + - Step outputs: `broken_links_count`, `total_links_count`, `has_broken_links` + - Simplified text summary for stdout - Color-coded status using `github.com/fatih/color`: - Green: 2xx success codes - Cyan: 3xx redirect codes - Red: 4xx client errors - Bold Red: 5xx server errors - Yellow: -1 unchecked/error state -- Smart filtering: By default shows only broken links (4xx/5xx) -- Verbose mode (`--verbose/-v`): Shows all links with status codes -- Helper functions in `cmd/check.go`: +- Helper functions in `pkg/output/utils.go`: - `getStatusIcon()`: Returns emoji/symbol for status - `getColoredStatus()`: Returns colored status code string - `isBrokenLink()`: Determines if link should be displayed by default -- Per-command flag retrieval (no global variables) + - `countBrokenLinks()`: Counts broken links in slice + - `countBrokenSitemapUrls()`: Counts broken sitemap URLs ### Testing Patterns - Test files mirror source files: `fetcher_test.go`, `resolver_test.go`, `html_test.go`, `sitemap_test.go` @@ -155,6 +178,15 @@ Use VS Code launch configurations (`.vscode/launch.json`): 2. Add tests in `resolver_test.go` with expected empty URL behavior 3. Update integration tests in parser package if needed +**Adding a new output format:** +1. Create new formatter struct in `pkg/output/` (e.g., `JSONFormatter`) +2. Implement the `Formatter` interface with `FormatPageResults()` and `FormatSitemapResults()` methods +3. Add new format constant to `pkg/output/output.go` (e.g., `FormatJSON Format = "json"`, `FormatNameJSON = "json"`) +4. Update `NewFormatter()` factory function to handle new format +5. Add comprehensive tests in `output_test.go` for the new formatter +6. Update CLI flag help text in `cmd/check.go` to include new format option +7. Document the new format in README.md + **Modifying data structures:** - Data types are in `pkg/link/link.go` - keep them pure (no business logic) - Add validation in constructor functions in `pkg/parser/` (e.g., `newAnchorTag`, `newSitemapUrl`) @@ -163,6 +195,13 @@ Use VS Code launch configurations (`.vscode/launch.json`): **Adding colored output to new commands:** - Import `github.com/fatih/color` for terminal coloring - Define color schemes: `color.New(color.FgGreen)`, `color.New(color.FgRed, color.Bold)` -- Use helper functions: `getStatusIcon()`, `getColoredStatus()` for consistency +- Use helper functions from `pkg/output/utils.go`: `getStatusIcon()`, `getColoredStatus()` for consistency - Show summary statistics: total links, broken links, redirects -- Follow existing pattern in `cmd/check.go` for consistent UX +- Follow existing formatter pattern in `pkg/output/` for consistent UX + +**Working with output formatters:** +- All formatters write to `io.Writer` (typically `os.Stdout`) +- Use `output.NewFormatter(format)` factory to get appropriate formatter instance +- Format selection logic in `cmd/check.go` uses string constants from `output` package +- Backward compatibility: `-v` flag still sets format to verbose (deprecated pattern) +- GitHub formatter handles `GITHUB_OUTPUT` environment variable automatically diff --git a/README.md b/README.md index 1208ca3..f303e13 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Brokli (a play on "broken links") is a CLI tool that helps developers validate a - 📊 **Progress Indication** - Real-time progress counter shows checking status - 🎯 **Smart Filtering** - Shows only broken links by default to reduce noise - 🔍 **Verbose Mode** - Optional flag to display all links with their status codes +- 🔄 **GitHub Actions Integration** - Native support with workflow annotations and step outputs - ⚙️ **Configurable** - Adjust workers, timeouts, redirects, and user agent - 🌐 **Sitemap Support** - Check entire sitemaps with metadata display - 🧪 **Well Tested** - 94%+ test coverage with comprehensive test suite @@ -80,8 +81,10 @@ Checking links... 10/10 Show all links with their status codes: ```bash -brokli check url https://example.com --verbose +brokli check url https://example.com --output-format=verbose # or use the short flag +brokli check url https://example.com -o verbose +# backward compatible with the old -v flag brokli check url https://example.com -v ``` @@ -100,6 +103,27 @@ All Links: Summary: 1 broken link found out of 10 total ``` +### Output Formats + +Brokli supports multiple output formats via the `--output-format` (or `-o`) flag: + +- **`default`** - Shows only broken links with color-coded status (default behavior) +- **`verbose`** - Shows all links with their status codes +- **`github`** - GitHub Actions compatible format with workflow annotations and step outputs + +**Examples:** + +```bash +# Default format (broken links only) +brokli check url https://example.com + +# Verbose format (all links) +brokli check url https://example.com -o verbose + +# GitHub Actions format +brokli check url https://example.com -o github +``` + ### Check a Sitemap Check all URLs in a sitemap: @@ -121,6 +145,59 @@ Broken URLs: Summary: 2 broken URLs found out of 70 total ``` +### GitHub Actions Integration + +Use Brokli in GitHub Actions CI/CD workflows with the `--output-format=github` flag: + +```bash +brokli check url https://example.com --output-format=github +brokli check sitemap https://example.com/sitemap.xml --output-format=github +# Or use the short form +brokli check url https://example.com -o github +``` + +This formats output specifically for GitHub Actions: +- **Workflow Annotations**: Broken links appear as errors in the GitHub Actions UI +- **Step Outputs**: Summary statistics are written to `GITHUB_OUTPUT` for use in downstream steps +- **Exit Code**: Standard exit codes for CI integration + +**Example GitHub Actions Workflow:** + +```yaml +name: Check Links +on: [push, pull_request] + +jobs: + check-links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Brokli + run: | + curl -L https://github.com/EndlessTrax/brokli/releases/latest/download/brokli_linux_amd64.tar.gz | tar xz + sudo mv brokli /usr/local/bin/ + + - name: Check site links + id: check + run: brokli check url https://yoursite.com --output-format=github + continue-on-error: true + + - name: Check results + run: | + echo "Total links: ${{ steps.check.outputs.total_links_count }}" + echo "Broken links: ${{ steps.check.outputs.broken_links_count }}" + if [ "${{ steps.check.outputs.has_broken_links }}" == "true" ]; then + echo "⚠️ Broken links found!" + exit 1 + fi +``` + +**Step Outputs Available:** +- `broken_links_count` - Number of broken links found +- `total_links_count` - Total number of links checked +- `has_broken_links` - Boolean (`true`/`false`) indicating if any broken links were found + ### Status Code Colors - 🟢 **Green** `[200]` - Success (2xx status codes) @@ -149,6 +226,17 @@ Quick validation before deploying to production: brokli check sitemap https://staging.example.com/sitemap.xml -v ``` +### CI/CD Integration + +Automate link checking in your GitHub Actions workflow: + +```bash +# In GitHub Actions workflow +brokli check url https://example.com --output-format=github +``` + +See the [GitHub Actions Integration](#github-actions-integration) section for complete workflow examples. + ## Roadmap ### Planned Features diff --git a/cmd/check.go b/cmd/check.go index cde8175..98a82ea 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -4,61 +4,29 @@ import ( "context" "fmt" "net/url" + "os" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/endlesstrax/brokli/pkg/checker" "github.com/endlesstrax/brokli/pkg/fetcher" "github.com/endlesstrax/brokli/pkg/link" + "github.com/endlesstrax/brokli/pkg/output" "github.com/endlesstrax/brokli/pkg/parser" ) -// getStatusIcon returns an icon based on the HTTP status code -func getStatusIcon(status int) string { - if status == -1 { - return "⚠️" // Warning for unchecked - } else if status >= 200 && status < 300 { - return "✓" // Success - } else if status >= 300 && status < 400 { - return "→" // Redirect - } else if status >= 400 { - return "✗" // Error - } - return "?" // Unknown -} - -// getColoredStatus returns a colored status code string -func getColoredStatus(status int) string { - statusStr := fmt.Sprintf("[%d]", status) - if status == -1 { - return color.YellowString(statusStr) - } else if status >= 200 && status < 300 { - return color.GreenString(statusStr) - } else if status >= 300 && status < 400 { - return color.CyanString(statusStr) - } else if status >= 400 && status < 500 { - return color.RedString(statusStr) - } else if status >= 500 { - return color.New(color.FgRed, color.Bold).Sprint(statusStr) - } - return statusStr -} - -// isBrokenLink returns true if the status code indicates a broken link -func isBrokenLink(status int) bool { - // A link is broken if it's unchecked (-1) or has a 4xx/5xx status code - return status == -1 || status >= 400 -} - func init() { rootCmd.AddCommand(checkCmd) checkCmd.AddCommand(checkUrlCmd) checkCmd.AddCommand(checkSitemapCmd) - // Add verbose flag to both subcommands (each has its own flag instance) - checkUrlCmd.Flags().BoolP("verbose", "v", false, "Show all links, not just broken ones") - checkSitemapCmd.Flags().BoolP("verbose", "v", false, "Show all URLs, not just broken ones") + // Add output-format flag to both subcommands + checkUrlCmd.Flags().StringP("output-format", "o", output.FormatNameDefault, "Output format: default, verbose, or github") + checkSitemapCmd.Flags().StringP("output-format", "o", output.FormatNameDefault, "Output format: default, verbose, or github") + + // Keep verbose flag for backward compatibility (deprecated) + checkUrlCmd.Flags().BoolP("verbose", "v", false, "Show all links, not just broken ones (deprecated: use --output-format=verbose)") + checkSitemapCmd.Flags().BoolP("verbose", "v", false, "Show all URLs, not just broken ones (deprecated: use --output-format=verbose)") } var checkCmd = &cobra.Command{ @@ -131,55 +99,31 @@ var checkUrlCmd = &cobra.Command{ fmt.Printf("Warning: Some links could not be checked: %v\n", err) } - // Count broken links - brokenCount := 0 - for _, linkPtr := range linkPointers { - if isBrokenLink(linkPtr.Status) { - brokenCount++ - } + // Determine output format + formatStr, _ := cmd.Flags().GetString("output-format") + verbose, _ := cmd.Flags().GetBool("verbose") + + // Handle backward compatibility with --verbose flag + if verbose && formatStr == output.FormatNameDefault { + formatStr = output.FormatNameVerbose } - // Get verbose flag from command - verbose, _ := cmd.Flags().GetBool("verbose") + // Get the appropriate formatter + var format output.Format + switch formatStr { + case output.FormatNameGitHub: + format = output.FormatGitHub + case output.FormatNameVerbose: + format = output.FormatVerbose + default: + format = output.FormatDefault + } - // Display results based on verbose flag - if verbose { - // Show all links in verbose mode - fmt.Println("\nAll Links:") - for i, linkPtr := range linkPointers { - statusIcon := getStatusIcon(linkPtr.Status) - coloredStatus := getColoredStatus(linkPtr.Status) - fmt.Printf("%s %d. %s %s -> %s\n", statusIcon, i+1, coloredStatus, linkPtr.Text, linkPtr.AbsoluteUrl.String()) - } - // Display summary - fmt.Printf("\n") - if brokenCount > 0 { - color.Red("Summary: %d broken links found out of %d total", brokenCount, len(linkPointers)) - } else { - color.Green("Summary: All %d links are working", len(linkPointers)) - } - fmt.Println() - } else { - // Show only broken links by default - if brokenCount > 0 { - fmt.Println("\nBroken Links:") - count := 1 - for _, linkPtr := range linkPointers { - if isBrokenLink(linkPtr.Status) { - statusIcon := getStatusIcon(linkPtr.Status) - coloredStatus := getColoredStatus(linkPtr.Status) - fmt.Printf("%s %d. %s %s -> %s\n", statusIcon, count, coloredStatus, linkPtr.Text, linkPtr.AbsoluteUrl.String()) - count++ - } - } - // Display summary for broken links - fmt.Printf("\n") - color.Red("Summary: %d broken links found out of %d total", brokenCount, len(linkPointers)) - fmt.Println() - } else { - color.Green("\n✓ All links are working!") - fmt.Println() - } + formatter := output.NewFormatter(format) + + // Format and display results + if err := formatter.FormatPageResults(os.Stdout, &results, linkPointers); err != nil { + fmt.Printf("Error formatting output: %v\n", err) } }, } @@ -238,69 +182,31 @@ var checkSitemapCmd = &cobra.Command{ fmt.Printf("Warning: Some URLs could not be checked: %v\n", err) } - // Count broken URLs - brokenCount := 0 - for _, urlPtr := range urlPointers { - if isBrokenLink(urlPtr.Status) { - brokenCount++ - } + // Determine output format + formatStr, _ := cmd.Flags().GetString("output-format") + verbose, _ := cmd.Flags().GetBool("verbose") + + // Handle backward compatibility with --verbose flag + if verbose && formatStr == output.FormatNameDefault { + formatStr = output.FormatNameVerbose + } + + // Get the appropriate formatter + var format output.Format + switch formatStr { + case output.FormatNameGitHub: + format = output.FormatGitHub + case output.FormatNameVerbose: + format = output.FormatVerbose + default: + format = output.FormatDefault } - // Get verbose flag from command - verbose, _ := cmd.Flags().GetBool("verbose") + formatter := output.NewFormatter(format) - // Display results based on verbose flag - if verbose { - // Show all URLs in verbose mode - fmt.Println("\nAll URLs:") - for i, urlPtr := range urlPointers { - statusIcon := getStatusIcon(urlPtr.Status) - coloredStatus := getColoredStatus(urlPtr.Status) - fmt.Printf("%s %d. %s %s", statusIcon, i+1, coloredStatus, urlPtr.AbsoluteUrl.String()) - if urlPtr.LastMod != "" { - fmt.Printf(" (modified: %s)", color.CyanString(urlPtr.LastMod)) - } - if urlPtr.Priority != "" { - fmt.Printf(" (priority: %s)", color.YellowString(urlPtr.Priority)) - } - fmt.Println() - } - // Display summary - fmt.Printf("\n") - if brokenCount > 0 { - color.Red("Summary: %d broken URLs found out of %d total", brokenCount, len(urlPointers)) - } else { - color.Green("Summary: All %d URLs are working", len(urlPointers)) - } - fmt.Println() - } else { - // Show only broken URLs by default - if brokenCount > 0 { - fmt.Println("\nBroken URLs:") - count := 1 - for _, urlPtr := range urlPointers { - if isBrokenLink(urlPtr.Status) { - statusIcon := getStatusIcon(urlPtr.Status) - coloredStatus := getColoredStatus(urlPtr.Status) - fmt.Printf("%s %d. %s %s", statusIcon, count, coloredStatus, urlPtr.AbsoluteUrl.String()) - if urlPtr.LastMod != "" { - fmt.Printf(" (modified: %s)", color.CyanString(urlPtr.LastMod)) - } - if urlPtr.Priority != "" { - fmt.Printf(" (priority: %s)", color.YellowString(urlPtr.Priority)) - } - fmt.Println() - count++ - } - } - // Display summary for broken URLs - fmt.Printf("\n") - color.Red("Summary: %d broken URLs found out of %d total", brokenCount, len(urlPointers)) - fmt.Println() - } else { - color.Green("\n✓ All URLs are working!") - fmt.Println() - } + // Format and display results + if err := formatter.FormatSitemapResults(os.Stdout, &results, urlPointers); err != nil { + fmt.Printf("Error formatting output: %v\n", err) } }, } diff --git a/pkg/output/default.go b/pkg/output/default.go new file mode 100644 index 0000000..a3aaa3a --- /dev/null +++ b/pkg/output/default.go @@ -0,0 +1,74 @@ +package output + +import ( + "fmt" + "io" + + "github.com/fatih/color" + + "github.com/endlesstrax/brokli/pkg/link" +) + +// DefaultFormatter outputs only broken links with color-coded status +type DefaultFormatter struct{} + +// FormatPageResults formats page results showing only broken links +func (f *DefaultFormatter) FormatPageResults(writer io.Writer, results *link.PageResults, links []*link.AnchorTag) error { + brokenCount := countBrokenLinks(links) + + if brokenCount > 0 { + fmt.Fprintln(writer, "\nBroken Links:") + count := 1 + for _, linkPtr := range links { + if isBrokenLink(linkPtr.Status) { + statusIcon := getStatusIcon(linkPtr.Status) + coloredStatus := getColoredStatus(linkPtr.Status) + fmt.Fprintf(writer, "%s %d. %s %s -> %s\n", statusIcon, count, coloredStatus, linkPtr.Text, linkPtr.AbsoluteUrl.String()) + count++ + } + } + // Display summary for broken links + fmt.Fprintln(writer) + color.New(color.FgRed).Fprintf(writer, "Summary: %d broken links found out of %d total\n", brokenCount, len(links)) + fmt.Fprintln(writer) + } else { + color.New(color.FgGreen).Fprintln(writer, "\n✓ All links are working!") + fmt.Fprintln(writer) + } + + return nil +} + +// FormatSitemapResults formats sitemap results showing only broken URLs +func (f *DefaultFormatter) FormatSitemapResults(writer io.Writer, results *link.SitemapResults, urls []*link.SitemapUrl) error { + brokenCount := countBrokenSitemapUrls(urls) + + if brokenCount > 0 { + fmt.Fprintln(writer, "\nBroken URLs:") + count := 1 + for _, urlPtr := range urls { + if isBrokenLink(urlPtr.Status) { + statusIcon := getStatusIcon(urlPtr.Status) + coloredStatus := getColoredStatus(urlPtr.Status) + fmt.Fprintf(writer, "%s %d. %s %s", statusIcon, count, coloredStatus, urlPtr.AbsoluteUrl.String()) + if urlPtr.LastMod != "" { + fmt.Fprintf(writer, " (modified: %s)", color.CyanString(urlPtr.LastMod)) + } + if urlPtr.Priority != "" { + fmt.Fprintf(writer, " (priority: %s)", color.YellowString(urlPtr.Priority)) + } + fmt.Fprintln(writer) + count++ + } + } + // Display summary for broken URLs + fmt.Fprintln(writer) + color.New(color.FgRed).Fprintf(writer, "Summary: %d broken URLs found out of %d total\n", brokenCount, len(urls)) + fmt.Fprintln(writer) + } else { + color.New(color.FgGreen).Fprintln(writer, "\n✓ All URLs are working!") + fmt.Fprintln(writer) + } + + return nil +} diff --git a/pkg/output/github.go b/pkg/output/github.go new file mode 100644 index 0000000..7905a79 --- /dev/null +++ b/pkg/output/github.go @@ -0,0 +1,105 @@ +package output + +import ( + "fmt" + "io" + "os" + + "github.com/endlesstrax/brokli/pkg/link" +) + +// GitHubFormatter outputs GitHub Actions compatible format with workflow annotations +type GitHubFormatter struct{} + +// FormatPageResults formats page results as GitHub Actions workflow commands +func (f *GitHubFormatter) FormatPageResults(writer io.Writer, results *link.PageResults, links []*link.AnchorTag) error { + brokenCount := countBrokenLinks(links) + + // Print workflow annotations for broken links + for _, linkPtr := range links { + if isBrokenLink(linkPtr.Status) { + printGitHubAnnotation(writer, linkPtr.AbsoluteUrl.String(), linkPtr.Text, linkPtr.Status) + } + } + + // Write summary to GITHUB_OUTPUT if available + if err := writeGitHubOutput(brokenCount, len(links)); err != nil { + fmt.Fprintf(writer, "Warning: Failed to write to GITHUB_OUTPUT: %v\n", err) + } + + // Print summary to stdout + fmt.Fprintf(writer, "Found %d broken links out of %d total\n", brokenCount, len(links)) + + return nil +} + +// FormatSitemapResults formats sitemap results as GitHub Actions workflow commands +func (f *GitHubFormatter) FormatSitemapResults(writer io.Writer, results *link.SitemapResults, urls []*link.SitemapUrl) error { + brokenCount := countBrokenSitemapUrls(urls) + + // Print workflow annotations for broken URLs + for _, urlPtr := range urls { + if isBrokenLink(urlPtr.Status) { + printGitHubAnnotation(writer, urlPtr.AbsoluteUrl.String(), "", urlPtr.Status) + } + } + + // Write summary to GITHUB_OUTPUT if available + if err := writeGitHubOutput(brokenCount, len(urls)); err != nil { + fmt.Fprintf(writer, "Warning: Failed to write to GITHUB_OUTPUT: %v\n", err) + } + + // Print summary to stdout + fmt.Fprintf(writer, "Found %d broken URLs out of %d total\n", brokenCount, len(urls)) + + return nil +} + +// printGitHubAnnotation outputs a GitHub Actions workflow annotation for a broken link +func printGitHubAnnotation(writer io.Writer, url, text string, status int) { + // Use ::error for broken links to make them highly visible in GitHub Actions + annotationType := "error" + title := fmt.Sprintf("Broken Link (Status %d)", status) + message := fmt.Sprintf("Link to %s returned status %d", url, status) + if text != "" { + message = fmt.Sprintf("Link '%s' to %s returned status %d", text, url, status) + } + fmt.Fprintf(writer, "::%s title=\"%s\"::%s\n", annotationType, title, message) +} + +// writeGitHubOutput writes summary data to GITHUB_OUTPUT if the environment variable is set +func writeGitHubOutput(brokenCount, totalCount int) error { + outputFile := os.Getenv("GITHUB_OUTPUT") + if outputFile == "" { + // GITHUB_OUTPUT not set, skip writing + return nil + } + + f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open GITHUB_OUTPUT file: %w", err) + } + defer f.Close() + + // Write summary statistics as GitHub Actions step outputs + _, err = fmt.Fprintf(f, "broken_links_count=%d\n", brokenCount) + if err != nil { + return fmt.Errorf("failed to write broken_links_count: %w", err) + } + + _, err = fmt.Fprintf(f, "total_links_count=%d\n", totalCount) + if err != nil { + return fmt.Errorf("failed to write total_links_count: %w", err) + } + + hasBrokenLinks := "false" + if brokenCount > 0 { + hasBrokenLinks = "true" + } + _, err = fmt.Fprintf(f, "has_broken_links=%s\n", hasBrokenLinks) + if err != nil { + return fmt.Errorf("failed to write has_broken_links: %w", err) + } + + return nil +} diff --git a/pkg/output/output.go b/pkg/output/output.go new file mode 100644 index 0000000..8f06d07 --- /dev/null +++ b/pkg/output/output.go @@ -0,0 +1,47 @@ +package output + +import ( + "io" + + "github.com/endlesstrax/brokli/pkg/link" +) + +// Format represents an output format type +type Format string + +const ( + // FormatDefault is the standard terminal output showing only broken links + FormatDefault Format = "default" + // FormatVerbose is terminal output showing all links + FormatVerbose Format = "verbose" + // FormatGitHub is GitHub Actions compatible output with annotations + FormatGitHub Format = "github" +) + +// String constants for format names (for use in CLI flags and comparisons) +const ( + FormatNameDefault = "default" + FormatNameVerbose = "verbose" + FormatNameGitHub = "github" +) + +// Formatter is the interface for all output formatters +type Formatter interface { + // FormatPageResults formats and writes the results of checking links on a page + FormatPageResults(writer io.Writer, results *link.PageResults, links []*link.AnchorTag) error + + // FormatSitemapResults formats and writes the results of checking a sitemap + FormatSitemapResults(writer io.Writer, results *link.SitemapResults, urls []*link.SitemapUrl) error +} + +// NewFormatter creates a formatter based on the specified format type +func NewFormatter(format Format) Formatter { + switch format { + case FormatGitHub: + return &GitHubFormatter{} + case FormatVerbose: + return &VerboseFormatter{} + default: + return &DefaultFormatter{} + } +} diff --git a/pkg/output/output_test.go b/pkg/output/output_test.go new file mode 100644 index 0000000..1b393b1 --- /dev/null +++ b/pkg/output/output_test.go @@ -0,0 +1,234 @@ +package output + +import ( + "bytes" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/endlesstrax/brokli/pkg/link" +) + +func TestNewFormatter(t *testing.T) { + tests := []struct { + name string + format Format + expectedType string + }{ + {"default format", FormatDefault, "*output.DefaultFormatter"}, + {"verbose format", FormatVerbose, "*output.VerboseFormatter"}, + {"github format", FormatGitHub, "*output.GitHubFormatter"}, + {"unknown format defaults", "unknown", "*output.DefaultFormatter"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + formatter := NewFormatter(tt.format) + if formatter == nil { + t.Error("NewFormatter() returned nil") + } + }) + } +} + +func TestDefaultFormatter_FormatPageResults(t *testing.T) { + tests := []struct { + name string + links []*link.AnchorTag + expectedOutput []string + }{ + { + name: "all working links", + links: []*link.AnchorTag{ + {Status: 200, Text: "Home", AbsoluteUrl: mustParseURL("http://example.com")}, + {Status: 200, Text: "About", AbsoluteUrl: mustParseURL("http://example.com/about")}, + }, + expectedOutput: []string{"All links are working"}, + }, + { + name: "some broken links", + links: []*link.AnchorTag{ + {Status: 200, Text: "Home", AbsoluteUrl: mustParseURL("http://example.com")}, + {Status: 404, Text: "Missing", AbsoluteUrl: mustParseURL("http://example.com/404")}, + }, + expectedOutput: []string{"Broken Links:", "Missing", "404", "Summary: 1 broken links found"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + formatter := &DefaultFormatter{} + results := &link.PageResults{} + + err := formatter.FormatPageResults(&buf, results, tt.links) + if err != nil { + t.Errorf("FormatPageResults() error = %v", err) + return + } + + output := buf.String() + for _, expected := range tt.expectedOutput { + if !strings.Contains(output, expected) { + t.Errorf("FormatPageResults() output missing expected string %q\nGot: %s", expected, output) + } + } + }) + } +} + +func TestVerboseFormatter_FormatPageResults(t *testing.T) { + links := []*link.AnchorTag{ + {Status: 200, Text: "Home", AbsoluteUrl: mustParseURL("http://example.com")}, + {Status: 404, Text: "Missing", AbsoluteUrl: mustParseURL("http://example.com/404")}, + } + + var buf bytes.Buffer + formatter := &VerboseFormatter{} + results := &link.PageResults{} + + err := formatter.FormatPageResults(&buf, results, links) + if err != nil { + t.Errorf("FormatPageResults() error = %v", err) + return + } + + output := buf.String() + expectedStrings := []string{"All Links:", "Home", "Missing", "Summary:"} + for _, expected := range expectedStrings { + if !strings.Contains(output, expected) { + t.Errorf("FormatPageResults() output missing expected string %q", expected) + } + } +} + +func TestGitHubFormatter_FormatPageResults(t *testing.T) { + links := []*link.AnchorTag{ + {Status: 200, Text: "Home", AbsoluteUrl: mustParseURL("http://example.com")}, + {Status: 404, Text: "Missing", AbsoluteUrl: mustParseURL("http://example.com/404")}, + } + + var buf bytes.Buffer + formatter := &GitHubFormatter{} + results := &link.PageResults{} + + err := formatter.FormatPageResults(&buf, results, links) + if err != nil { + t.Errorf("FormatPageResults() error = %v", err) + return + } + + output := buf.String() + expectedStrings := []string{"::error", "Broken Link", "404", "Found 1 broken links"} + for _, expected := range expectedStrings { + if !strings.Contains(output, expected) { + t.Errorf("FormatPageResults() output missing expected string %q\nGot: %s", expected, output) + } + } +} + +func TestGitHubFormatter_WriteGitHubOutput(t *testing.T) { + // Setup temp file for GITHUB_OUTPUT + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "github_output.txt") + os.Setenv("GITHUB_OUTPUT", tmpFile) + defer os.Unsetenv("GITHUB_OUTPUT") + + err := writeGitHubOutput(5, 10) + if err != nil { + t.Errorf("writeGitHubOutput() error = %v", err) + return + } + + // Read the file and verify contents + content, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + output := string(content) + expectedStrings := []string{ + "broken_links_count=5", + "total_links_count=10", + "has_broken_links=true", + } + + for _, expected := range expectedStrings { + if !strings.Contains(output, expected) { + t.Errorf("Output missing expected string %q\nGot: %s", expected, output) + } + } +} + +func TestGitHubFormatter_WriteGitHubOutput_NoBrokenLinks(t *testing.T) { + // Setup temp file for GITHUB_OUTPUT + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "github_output.txt") + os.Setenv("GITHUB_OUTPUT", tmpFile) + defer os.Unsetenv("GITHUB_OUTPUT") + + err := writeGitHubOutput(0, 10) + if err != nil { + t.Errorf("writeGitHubOutput() error = %v", err) + return + } + + // Read the file and verify contents + content, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + output := string(content) + if !strings.Contains(output, "has_broken_links=false") { + t.Errorf("Expected has_broken_links=false when brokenCount == 0, got: %s", output) + } +} + +func TestCountBrokenLinks(t *testing.T) { + links := []*link.AnchorTag{ + {Status: 200}, + {Status: 404}, + {Status: 500}, + {Status: 301}, + {Status: -1}, + } + + count := countBrokenLinks(links) + if count != 3 { // 404, 500, and -1 are broken + t.Errorf("countBrokenLinks() = %d, want 3", count) + } +} + +func TestIsBrokenLink(t *testing.T) { + tests := []struct { + name string + status int + want bool + }{ + {"200 OK", 200, false}, + {"301 Redirect", 301, false}, + {"404 Not Found", 404, true}, + {"500 Server Error", 500, true}, + {"-1 Unchecked", -1, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isBrokenLink(tt.status); got != tt.want { + t.Errorf("isBrokenLink(%d) = %v, want %v", tt.status, got, tt.want) + } + }) + } +} + +// Helper function to parse URLs in tests +func mustParseURL(rawURL string) url.URL { + u, err := url.Parse(rawURL) + if err != nil { + panic(err) + } + return *u +} diff --git a/pkg/output/utils.go b/pkg/output/utils.go new file mode 100644 index 0000000..a504415 --- /dev/null +++ b/pkg/output/utils.go @@ -0,0 +1,68 @@ +package output + +import ( + "fmt" + + "github.com/fatih/color" + + "github.com/endlesstrax/brokli/pkg/link" +) + +// getStatusIcon returns an icon based on the HTTP status code +func getStatusIcon(status int) string { + if status == -1 { + return "⚠️" // Warning for unchecked + } else if status >= 200 && status < 300 { + return "✓" // Success + } else if status >= 300 && status < 400 { + return "→" // Redirect + } else if status >= 400 { + return "✗" // Error + } + return "?" // Unknown +} + +// getColoredStatus returns a colored status code string +func getColoredStatus(status int) string { + statusStr := fmt.Sprintf("[%d]", status) + if status == -1 { + return color.YellowString(statusStr) + } else if status >= 200 && status < 300 { + return color.GreenString(statusStr) + } else if status >= 300 && status < 400 { + return color.CyanString(statusStr) + } else if status >= 400 && status < 500 { + return color.RedString(statusStr) + } else if status >= 500 { + return color.New(color.FgRed, color.Bold).Sprint(statusStr) + } + return statusStr +} + +// isBrokenLink returns true if the status code indicates a broken link +func isBrokenLink(status int) bool { + // A link is broken if it's unchecked (-1) or has a 4xx/5xx status code + return status == -1 || status >= 400 +} + +// countBrokenLinks counts the number of broken links in a slice of AnchorTag pointers +func countBrokenLinks(links []*link.AnchorTag) int { + count := 0 + for _, linkPtr := range links { + if isBrokenLink(linkPtr.Status) { + count++ + } + } + return count +} + +// countBrokenSitemapUrls counts the number of broken URLs in a slice of SitemapUrl pointers +func countBrokenSitemapUrls(urls []*link.SitemapUrl) int { + count := 0 + for _, urlPtr := range urls { + if isBrokenLink(urlPtr.Status) { + count++ + } + } + return count +} diff --git a/pkg/output/verbose.go b/pkg/output/verbose.go new file mode 100644 index 0000000..eeb14aa --- /dev/null +++ b/pkg/output/verbose.go @@ -0,0 +1,68 @@ +package output + +import ( + "fmt" + "io" + + "github.com/fatih/color" + + "github.com/endlesstrax/brokli/pkg/link" +) + +// VerboseFormatter outputs all links with their status codes +type VerboseFormatter struct{} + +// FormatPageResults formats page results showing all links +func (f *VerboseFormatter) FormatPageResults(writer io.Writer, results *link.PageResults, links []*link.AnchorTag) error { + brokenCount := countBrokenLinks(links) + + // Show all links in verbose mode + fmt.Fprintln(writer, "\nAll Links:") + for i, linkPtr := range links { + statusIcon := getStatusIcon(linkPtr.Status) + coloredStatus := getColoredStatus(linkPtr.Status) + fmt.Fprintf(writer, "%s %d. %s %s -> %s\n", statusIcon, i+1, coloredStatus, linkPtr.Text, linkPtr.AbsoluteUrl.String()) + } + + // Display summary + fmt.Fprintln(writer) + if brokenCount > 0 { + color.New(color.FgRed).Fprintf(writer, "Summary: %d broken links found out of %d total\n", brokenCount, len(links)) + } else { + color.New(color.FgGreen).Fprintf(writer, "Summary: All %d links are working\n", len(links)) + } + fmt.Fprintln(writer) + + return nil +} + +// FormatSitemapResults formats sitemap results showing all URLs +func (f *VerboseFormatter) FormatSitemapResults(writer io.Writer, results *link.SitemapResults, urls []*link.SitemapUrl) error { + brokenCount := countBrokenSitemapUrls(urls) + + // Show all URLs in verbose mode + fmt.Fprintln(writer, "\nAll URLs:") + for i, urlPtr := range urls { + statusIcon := getStatusIcon(urlPtr.Status) + coloredStatus := getColoredStatus(urlPtr.Status) + fmt.Fprintf(writer, "%s %d. %s %s", statusIcon, i+1, coloredStatus, urlPtr.AbsoluteUrl.String()) + if urlPtr.LastMod != "" { + fmt.Fprintf(writer, " (modified: %s)", color.CyanString(urlPtr.LastMod)) + } + if urlPtr.Priority != "" { + fmt.Fprintf(writer, " (priority: %s)", color.YellowString(urlPtr.Priority)) + } + fmt.Fprintln(writer) + } + + // Display summary + fmt.Fprintln(writer) + if brokenCount > 0 { + color.New(color.FgRed).Fprintf(writer, "Summary: %d broken URLs found out of %d total\n", brokenCount, len(urls)) + } else { + color.New(color.FgGreen).Fprintf(writer, "Summary: All %d URLs are working\n", len(urls)) + } + fmt.Fprintln(writer) + + return nil +}