docs: extract goget manpage to standalone troff source

This commit is contained in:
2026-06-29 21:16:28 +02:00
parent 9ef62520c7
commit 9867e2817e
8 changed files with 1163 additions and 206 deletions
+3
View File
@@ -0,0 +1,3 @@
Placeholder so the directory is tracked by git. The actual file
generated here by `just build` is `goget.1`, copied from
`../../../man/goget.1`.
+13 -206
View File
@@ -1,217 +1,24 @@
//go:build linux || freebsd
// +build linux freebsd
package main
import (
_ "embed"
"fmt"
"codeberg.org/petrbalvin/goget/internal/core"
)
// manPage holds the canonical goget(1) manual page in troff/groff format.
// The source of truth is ../../man/goget.1; a copy is kept here so it can
// be embedded into the binary at build time (//go:embed cannot reference
// files outside the embedding package). The copy is refreshed by the
// `cp` step in `just build`.
//
//go:embed man-page-data/goget.1
var manPage string
// handleGenManPage implements the --generate-man-page flag. It writes the
// embedded manpage to standard output and exits 0.
func handleGenManPage() int {
fmt.Printf(`.TH GOGET 1 "2026-06" "goget %s" "User Commands"
.SH NAME
goget \- modern IPv6-first command-line download utility
.SH SYNOPSIS
.B goget
\fB\-\-url\fR \fIURL\fR [\fIOPTIONS\fR]
.SH DESCRIPTION
goget is a modern replacement for curl and wget built on Go stdlib.
Features IPv6-first connections, parallel downloads, resume support,
compression, checksum verification, and recursive mirroring.
.SH OPTIONS
.SS "Target"
.TP
\fB\-\-url\fR=\fIURL\fR
URL to download (can be specified multiple times)
.SS "Basic Options"
.TP
\fB\-\-output\fR=\fIFILE\fR
Output filename (default: auto from URL)
.TP
\fB\-\-verbose\fR
Verbose output with progress bar (default: enabled)
.TP
\fB\-\-no-progress\fR
Hide progress bar (keep other output)
.TP
\fB\-\-quiet\fR
Suppress all output except errors
.TP
\fB\-\-debug\fR
Debug mode with detailed info
.TP
\fB\-\-resume\fR
Resume interrupted download
.TP
\fB\-\-restart\fR
Delete existing file and restart download
.TP
\fB\-\-overwrite\fR
Overwrite existing file
.TP
\fB\-\-no-decompress\fR
Disable automatic decompression
.TP
\fB\-\-parallel\fR=\fIN\fR
Parallel connections (0=auto, 1=sequential)
.TP
\fB\-\-json\fR
Output progress as JSON (for scripting)
.TP
\fB\-\-info\fR
Show file information without downloading
.TP
\fB\-\-benchmark\fR=\fIURL\fR
Benchmark download speed
.SS "Network"
.TP
\fB\-\-timeout\fR=\fIDURATION\fR
Connection timeout (0 = auto based on size)
.TP
\fB\-\-proxy\fR=\fIURL\fR
Proxy server URL (http://, https://, socks5://, socks5h://)
.TP
\fB\-\-dns-servers\fR=\fIIPs\fR
Custom DNS servers (comma-separated)
.TP
\fB\-\-max-retries\fR=\fIN\fR
Maximum number of retries on failure
.TP
\fB\-\-no-ipv4\fR
Disable IPv4 fallback
.TP
\fB\-\-no-redirect\fR
Disable following HTTP redirects
.TP
\fB\-\-show-headers\fR
Print HTTP response headers
.TP
\fB\-\-keep-timestamps\fR
Set file modification time from Last-Modified header
.TP
\fB\-\-content-disposition\fR
Use server-provided filename from Content-Disposition header
.SS "Authentication"
.TP
Credentials are loaded automatically from \fB~/.netrc\fR when no \fB\-\-username\fR is given.
.TP
\fB\-\-username\fR=\fIUSER\fR
Username for HTTP Basic/Digest Auth
.TP
\fB\-\-password\fR=\fIPASS\fR
Password for HTTP Basic/Digest Auth
.TP
\fB\-\-password-file\fR=\fIFILE\fR
Read password from file
.TP
\fB\-\-auth-type\fR=\fITYPE\fR
Auth type: basic, digest, auto
.TP
\fB\-\-cookie-jar\fR=\fIFILE\fR
File for loading/saving cookies
.TP
\fB\-\-cookie\fR=\fINAME=VALUE\fR
Set cookie from CLI (repeatable, curl-compatible)
.TP
\fB\-\-retry-all-errors\fR
Retry on any HTTP 4xx/5xx response
.TP
\fB\-\-ssl-key-log\fR=\fIFILE\fR
Log TLS session keys to file (SSLKEYLOGFILE for Wireshark)
.SS "Config"
.TP
\fB\-\-config\fR=\fIFILE\fR
Path to config file
.TP
\fB\-\-config-get\fR=\fIKEY\fR
Get a config value
.TP
\fB\-\-config-set\fR=\fIKEY\fR \fIVALUE\fR
Set a config value
.TP
\fB\-\-config-list\fR
List all config values
.TP
\fB\-\-config-unset\fR=\fIKEY\fR
Unset a config value
.TP
\fB\-\-init-config\fR
Generate default config file
.SS "Mirror & Recursive"
.TP
\fB\-\-mirror\fR
Mirror entire website (infinite depth)
.TP
\fB\-\-recursive\fR
Enable recursive downloading
.TP
\fB\-\-max-depth\fR=\fIN\fR
Maximum recursion depth
.SS "Scheduling & Hooks"
.TP
\fB\-\-schedule\fR=\fITIME\fR
Schedule download ("YYYY-MM-DD HH:MM" or cron "0 2 * * *")
.TP
\fB\-\-on-complete\fR=\fICMD\fR
Run shell command after successful download
.TP
\fB\-\-bind-interface\fR=\fIIFACE\fR
Bind to specific network interface
.SS "Other"
.TP
\fB\-\-batch-file\fR=\fIFILE\fR
Download multiple URLs from a file
.TP
\fB\-\-checksum-file\fR=\fIFILE\fR
Read expected checksum from SHA256SUMS file
.TP
\fB\-\-generate-man-page\fR
Generate this man page
.TP
\fB\-\-help\fR
Show help
.TP
\fB\-\-version\fR
Show version
.SH EXAMPLES
.TP
Download a file:
goget --url https://example.com/file.zip
.TP
Parallel download:
goget --url https://example.com/large.iso --parallel 4
.TP
Resume interrupted download:
goget --url https://example.com/file.zip --resume
.TP
Mirror website:
goget --url https://example.com --mirror --output ./mirror
.TP
Recursive download:
goget --url https://example.com --recursive --max-depth 2
.TP
JSON progress (for scripting):
goget --url https://example.com/file.zip --json --no-progress > progress.jsonl
.TP
Scheduled download at 2 AM:
goget --url https://example.com/daily.zip --schedule "0 2 * * *"
.TP
Run import script after download:
goget --url https://example.com/data.csv --on-complete "import.sh"
.SH FILES
.TP
~/.config/goget/config.json
Configuration file
.TP
<file>.goget.meta
Resume metadata file
.SH BUGS
Report bugs at https://codeberg.org/petrbalvin/goget/issues
.SH AUTHOR
Petr Balvin
.SH LICENSE
MIT License`, core.Version)
fmt.Print(manPage)
return 0
}
+134
View File
@@ -0,0 +1,134 @@
//go:build linux || freebsd
package main
import (
"flag"
"os/exec"
"sort"
"strings"
"testing"
"codeberg.org/petrbalvin/goget/internal/config"
)
// manPageRequiredSections lists the conventional manpage sections that
// goget(1) must include. Every section in this list must be present
// in the manpage source as a ".SH <NAME>" header. Adding a new section
// here forces the manpage author to document it.
var manPageRequiredSections = []string{
"NAME",
"SYNOPSIS",
"DESCRIPTION",
"OPTIONS",
"EXAMPLES",
"EXIT STATUS",
"FILES",
"ENVIRONMENT",
"SEE ALSO",
"HISTORY",
"AUTHORS",
"REPORTING BUGS",
"COPYRIGHT",
}
// TestManPageEmbedded is the simplest smoke test: the embed succeeded
// and the variable is not empty. Any first line beginning with `.`
// (either a `.\"` comment or a macro such as `.TH`) is accepted as
// proof the embedded blob is troff source.
func TestManPageEmbedded(t *testing.T) {
if manPage == "" {
t.Fatal("embedded manPage is empty — //go:embed failed at build time")
}
if !strings.HasPrefix(manPage, ".") {
t.Errorf("embedded manPage does not look like troff (got prefix %q)", firstLine(manPage))
}
}
// TestManPageHasTHHeader verifies the .TH macro is present with the
// expected program name, section, and version. The .TH line is the
// entry point that `man` uses to identify the page.
func TestManPageHasTHHeader(t *testing.T) {
if !strings.Contains(manPage, ".TH GOGET 1 ") {
t.Error("manpage is missing the .TH GOGET 1 header")
}
}
// TestManPageHasMandatorySections asserts that every section in
// manPageRequiredSections exists as a .SH heading. This keeps the page
// discoverable in `man goget` and ensures consistency with project
// documentation conventions.
//
// In troff, multi-word section names must be quoted to keep the
// spaces inside a single argument: `.SH "SEE ALSO"`. The matcher
// accepts both the bare form (`.SH SEE ALSO`) and the quoted form
// (`.SH "SEE ALSO"`), so authors can use either convention.
func TestManPageHasMandatorySections(t *testing.T) {
for _, sec := range manPageRequiredSections {
bare := "\n.SH " + sec + "\n"
quoted := "\n.SH \"" + sec + "\"\n"
if !strings.Contains(manPage, bare) && !strings.Contains(manPage, quoted) {
t.Errorf("manpage is missing required section: .SH %s (or .SH \"%s\")", sec, sec)
}
}
}
// TestManPageDocumentsEveryFlag is a guard against drift between the
// CLI surface (defineFlags) and the manpage. For every flag
// registered on the FlagSet, the manpage must contain the
// corresponding option entry. The expected on-page form escapes
// every hyphen in the flag name as `\-` (per the convention used
// throughout the manpage) and prefixes the entry with `\-\-`.
//
// Adding a new flag in defineFlags but forgetting the manpage (or
// vice versa) makes this test fail with a clear missing-flag list.
func TestManPageDocumentsEveryFlag(t *testing.T) {
cfg := config.DefaultConfig()
fs := newTestFlagSet("goget-test-man-page")
defineFlags(fs, cfg)
var missing []string
fs.VisitAll(func(f *flag.Flag) {
escaped := strings.ReplaceAll(f.Name, "-", `\-`)
needle := `\-\-` + escaped
if !strings.Contains(manPage, needle) {
missing = append(missing, f.Name)
}
})
if len(missing) > 0 {
sort.Strings(missing)
t.Errorf("manpage does not document %d flag(s):\n --%s",
len(missing), strings.Join(missing, "\n --"))
}
}
// TestManPageRendersWithGroff runs `groff -man -ww -Kutf-8 -z` on the
// embedded manpage and fails if it reports any warnings or errors.
// The `-z` flag suppresses output; `-ww` enables maximum warning
// verbosity; `-Kutf-8` accepts UTF-8 source (em-dash, etc.).
//
// If `groff` is not installed on the test machine the test is
// skipped — we still want it to pass on minimal CI images — but a
// CI image with `groff` available will catch regressions in the
// troff source.
func TestManPageRendersWithGroff(t *testing.T) {
if _, err := exec.LookPath("groff"); err != nil {
t.Skip("groff not installed; skipping manpage render check")
}
cmd := exec.Command("groff", "-man", "-ww", "-Kutf-8", "-z")
cmd.Stdin = strings.NewReader(manPage)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("groff rejected the manpage: %v\n%s", err, out)
} else if len(out) > 0 {
t.Fatalf("groff produced unexpected output:\n%s", out)
}
}
// firstLine returns the first line of s, trimmed of the trailing
// newline. Used only for error messages.
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}