first commit

This commit is contained in:
2026-06-19 12:32:14 +09:00
commit eb8d9b9460
36 changed files with 6502 additions and 0 deletions

107
cli/help.go Normal file
View File

@@ -0,0 +1,107 @@
package cli
import (
"fmt"
"io"
"strings"
)
// Version is the program version, overridable at build time via -ldflags.
var Version = "0.1.0"
// PrintVersion writes the version banner.
func PrintVersion(w io.Writer) {
fmt.Fprintf(w, "got %s — a Rob-Pike-style command-line downloader in Go\n", Version)
}
// PrintHelp writes usage. A bare -h/--help (empty tag) shows only the Basic
// group, keeping the default terse; --help=all shows every group and
// --help=<group> (basic/http/bittorrent/advanced) filters to one.
func PrintHelp(w io.Writer, tag string) {
all := tag == "all"
// Empty tag means a bare -h/--help: default to the Basic group only.
bare := tag == ""
want, filtered := tagByName(tag)
if bare {
want, filtered = Basic, true
}
if !all && !bare && !filtered {
fmt.Fprintf(w, "got: unknown help tag %q (use all|basic|http|bittorrent|advanced)\n\n", tag)
}
fmt.Fprint(w, `Usage: got [OPTIONS] URI | magnet:... | file.torrent ...
A multi-protocol download tool: segmented HTTP(S) and BitTorrent.
`)
for _, group := range []Tag{Basic, HTTP, BitTorrent, Advanced} {
if filtered && !all && group != want {
continue
}
fmt.Fprintf(w, "%s options:\n", title(group.String()))
for i := range options {
o := &options[i]
if o.Tag != group {
continue
}
fmt.Fprintf(w, " %s %s\n", flagLabel(o), o.Help)
}
fmt.Fprintln(w)
}
// Only the full listing (all groups) repeats the meta flags.
if all || !filtered {
fmt.Fprint(w, " -h, --help[=TAG] show help (TAG: all|basic|http|bittorrent|advanced)\n")
fmt.Fprint(w, " -v, --version show version\n\n")
}
if bare {
fmt.Fprint(w, "Showing basic options. Use --help=all for every option, or --help=advanced for advanced ones.\n")
}
}
// title upper-cases the first letter of a single word.
func title(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func tagByName(name string) (Tag, bool) {
switch name {
case "basic":
return Basic, true
case "http":
return HTTP, true
case "bittorrent", "bt":
return BitTorrent, true
case "advanced":
return Advanced, true
default:
return Basic, false
}
}
// flagLabel renders the "-x, --long=ARG" column for one option.
func flagLabel(o *Opt) string {
var b strings.Builder
if o.Short != 0 {
fmt.Fprintf(&b, "-%c, ", o.Short)
} else {
b.WriteString(" ")
}
b.WriteString("--" + o.Long)
switch o.Kind {
case Bool:
// no argument shown
case Size:
b.WriteString("=SIZE")
case Int:
b.WriteString("=N")
case Float:
b.WriteString("=RATIO")
case Enum:
b.WriteString("=" + strings.Join(o.Enum, "|"))
default:
b.WriteString("=VAL")
}
return b.String()
}

143
cli/option.go Normal file
View File

@@ -0,0 +1,143 @@
// Package cli turns command-line arguments and config files into a resolved set
// of options. The whole design is one flat table of option descriptors; help
// text, defaults, parsing and validation all derive from it. The whole option
// model is plain data rather than a class hierarchy.
package cli
// Kind is the value type of an option. It drives parsing and how the value is
// later read back.
type Kind int
const (
Bool Kind = iota // bare flag means true; --flag=false also accepted
Int // integer, optionally bounded by Min/Max
Size // byte size with optional K/M/G suffix
Float // floating point
Str // free-form string
Enum // one of Opt.Enum
List // repeatable; values accumulate, newline-joined
)
// Tag groups options for --help.
type Tag int
const (
Basic Tag = iota
HTTP
BitTorrent
Advanced
)
func (t Tag) String() string {
switch t {
case Basic:
return "basic"
case HTTP:
return "http"
case BitTorrent:
return "bittorrent"
case Advanced:
return "advanced"
default:
return "?"
}
}
// Opt describes one option: its names, value kind, default and help text.
type Opt struct {
Long string // long name without dashes, e.g. "max-connection-per-server"
Short byte // short flag byte, e.g. 'x'; 0 if none
Kind Kind
Default string
Help string
Tag Tag
Enum []string // valid values when Kind == Enum
// Min is the inclusive lower Int/Size bound. The zero value enforces a
// floor of 0 (negative inputs are rejected).
Min int64
// Max is the inclusive upper Int/Size bound; the zero value means no upper
// bound. For Size options Min/Max are byte counts.
Max int64
}
// options is the single source of truth: a focused, practical subset.
var options = []Opt{
// --- basic ---
{Long: "dir", Short: 'd', Kind: Str, Help: "directory to store downloaded files", Tag: Basic},
{Long: "out", Short: 'o', Kind: Str, Help: "output filename for the download", Tag: Basic},
{Long: "input-file", Short: 'i', Kind: Str, Help: "read URIs line by line from FILE (- for stdin)", Tag: Basic},
{Long: "continue", Short: 'c', Kind: Bool, Default: "false", Help: "resume a partially downloaded file", Tag: Basic},
{Long: "max-concurrent-downloads", Short: 'j', Kind: Int, Default: "5", Min: 1, Help: "max number of parallel downloads", Tag: Basic},
{Long: "check-integrity", Short: 'V', Kind: Bool, Default: "false", Help: "re-verify torrent data against piece hashes (BitTorrent)", Tag: Basic},
{Long: "show-files", Short: 'S', Kind: Bool, Default: "false", Help: "list files in a torrent and exit", Tag: Basic},
{Long: "allow-overwrite", Kind: Bool, Default: "false", Help: "overwrite an existing file", Tag: Basic},
{Long: "auto-file-renaming", Kind: Bool, Default: "true", Help: "rename file (.1, .2, ...) if it already exists", Tag: Basic},
{Long: "max-overall-download-limit", Kind: Size, Default: "0", Help: "global download speed limit (0 = unlimited)", Tag: Basic},
{Long: "max-download-limit", Kind: Size, Default: "0", Help: "per-download speed limit (0 = unlimited)", Tag: Basic},
{Long: "quiet", Short: 'q', Kind: Bool, Default: "false", Help: "suppress the progress readout", Tag: Basic},
{Long: "human-readable", Kind: Bool, Default: "true", Help: "show sizes as Ki/Mi/Gi", Tag: Basic},
// --- http ---
{Long: "max-connection-per-server", Short: 'x', Kind: Int, Default: "1", Min: 1, Max: 16, Help: "max connections to one server (1-16)", Tag: HTTP},
{Long: "split", Short: 's', Kind: Int, Default: "5", Min: 1, Help: "split a download into N connections; actual connections are min(max-connection-per-server, split), and -x defaults to 1", Tag: HTTP},
{Long: "min-split-size", Short: 'k', Kind: Size, Default: "20M", Min: 1 << 20, Max: 1 << 30, Help: "do not split a piece smaller than SIZE (1M-1024M)", Tag: HTTP},
{Long: "force-sequential", Short: 'Z', Kind: Bool, Default: "false", Help: "download each command-line URI as its own file instead of mirroring them", Tag: HTTP},
{Long: "max-tries", Short: 'm', Kind: Int, Default: "5", Min: 0, Help: "max retries per segment (0 = unlimited)", Tag: HTTP},
{Long: "timeout", Short: 't', Kind: Int, Default: "60", Min: 1, Help: "connection timeout in seconds", Tag: HTTP},
{Long: "header", Kind: List, Help: "append an extra HTTP header (repeatable)", Tag: HTTP},
{Long: "user-agent", Short: 'U', Kind: Str, Default: "got/1.0", Help: "HTTP User-Agent", Tag: HTTP},
{Long: "referer", Kind: Str, Help: "HTTP Referer header", Tag: HTTP},
{Long: "all-proxy", Kind: Str, Help: "proxy for all protocols (host:port)", Tag: HTTP},
{Long: "check-certificate", Kind: Bool, Default: "true", Help: "verify the server's TLS certificate", Tag: HTTP},
{Long: "ca-certificate", Kind: Str, Help: "verify HTTPS servers against the CA certificates in FILE (PEM)", Tag: HTTP},
{Long: "retry-wait", Kind: Int, Default: "0", Min: 0, Max: 600, Help: "seconds to wait between retries (0 = no wait)", Tag: HTTP},
{Long: "connect-timeout", Kind: Int, Default: "60", Min: 1, Help: "connection establishment timeout in seconds", Tag: HTTP},
{Long: "load-cookies", Kind: Str, Help: "load Cookies from FILE (Netscape/Mozilla format)", Tag: HTTP},
{Long: "save-cookies", Kind: Str, Help: "save Cookies to FILE on exit", Tag: HTTP},
{Long: "conditional-get", Kind: Bool, Default: "false", Help: "download only if the remote file is newer than the local one", Tag: HTTP},
{Long: "remote-time", Kind: Bool, Default: "false", Help: "set the local file's mtime from the server", Tag: HTTP},
{Long: "http-user", Kind: Str, Help: "HTTP basic-auth user", Tag: HTTP},
{Long: "http-passwd", Kind: Str, Help: "HTTP basic-auth password", Tag: HTTP},
{Long: "lowest-speed-limit", Kind: Size, Default: "0", Help: "abort if speed stays below SIZE bytes/sec (0 = off)", Tag: HTTP},
{Long: "checksum", Kind: Str, Help: "verify the finished file: TYPE=DIGEST, e.g. sha-256=<hex> (md5, sha-1, sha-224, sha-256, sha-384, sha-512)", Tag: HTTP},
// --- bittorrent ---
{Long: "torrent-file", Short: 'T', Kind: Str, Help: "path to a .torrent file", Tag: BitTorrent},
{Long: "seed-time", Kind: Float, Help: "minutes to seed after completion (0 = no seeding)", Tag: BitTorrent},
{Long: "seed-ratio", Kind: Float, Default: "1.0", Help: "stop seeding at this share ratio (0 = unlimited)", Tag: BitTorrent},
{Long: "bt-stop-timeout", Kind: Int, Default: "0", Min: 0, Help: "stop a torrent with no download progress for N seconds (0 = off)", Tag: BitTorrent},
{Long: "bt-metadata-timeout", Kind: Int, Default: "60", Min: 0, Help: "stop a magnet that can't fetch metadata in N seconds (0 = off)", Tag: BitTorrent},
{Long: "listen-port", Kind: Str, Default: "6881-6999", Help: "TCP port (range) for incoming peers", Tag: BitTorrent},
{Long: "enable-dht", Kind: Bool, Default: "true", Help: "enable the BitTorrent DHT", Tag: BitTorrent},
{Long: "bt-max-peers", Kind: Int, Default: "55", Min: 0, Help: "max peers per torrent (0 = unlimited)", Tag: BitTorrent},
{Long: "max-overall-upload-limit", Kind: Size, Default: "0", Help: "global upload speed limit (0 = unlimited)", Tag: BitTorrent},
{Long: "max-upload-limit", Short: 'u', Kind: Size, Default: "0", Help: "per-torrent upload speed limit", Tag: BitTorrent},
{Long: "select-file", Kind: Str, Help: "download only these file indexes, e.g. 1,3-5", Tag: BitTorrent},
{Long: "follow-torrent", Kind: Bool, Default: "true", Help: "after fetching a .torrent over HTTP, download its content", Tag: BitTorrent},
// --- advanced ---
{Long: "file-allocation", Short: 'a', Kind: Enum, Default: "prealloc", Enum: []string{"none", "prealloc", "trunc", "falloc"}, Help: "how to allocate disk space", Tag: Advanced},
{Long: "dry-run", Kind: Bool, Default: "false", Help: "check that the file is available but do not download it", Tag: Advanced},
{Long: "stop", Kind: Int, Default: "0", Min: 0, Help: "stop the program after N seconds (0 = off)", Tag: Advanced},
{Long: "disable-ipv6", Kind: Bool, Default: "false", Help: "disable IPv6 (force IPv4-only connections)", Tag: Advanced},
{Long: "save-session", Kind: Str, Help: "on exit, write unfinished downloads to FILE (reload with -i)", Tag: Advanced},
{Long: "auto-save-interval", Kind: Int, Default: "60", Min: 0, Max: 600, Help: "save the session file every N seconds (0 = only on exit)", Tag: Advanced},
{Long: "conf-path", Kind: Str, Help: "path to the config file", Tag: Advanced},
{Long: "no-conf", Kind: Bool, Default: "false", Help: "do not read a config file", Tag: Advanced},
}
// byLong and byShort index the table. Built once at init.
var (
byLong = map[string]*Opt{}
byShort = map[byte]*Opt{}
)
func init() {
for i := range options {
o := &options[i]
byLong[o.Long] = o
if o.Short != 0 {
byShort[o.Short] = o
}
}
}

109
cli/options.go Normal file
View File

@@ -0,0 +1,109 @@
package cli
import (
"fmt"
"strconv"
"strings"
)
// Options is the resolved configuration: a flat name->value map plus a record
// of which names were explicitly set (so callers can tell a default from a
// chosen value). Values are kept as strings end-to-end and converted only at
// the point of use by the typed getters below.
type Options struct {
vals map[string]string
set map[string]bool
}
func newOptions() *Options {
return &Options{vals: map[string]string{}, set: map[string]bool{}}
}
// IsSet reports whether name was given on the command line or in the config
// (as opposed to coming from the built-in default).
func (o *Options) IsSet(name string) bool { return o.set[name] }
// Str returns the raw string value (empty if unset and no default).
func (o *Options) Str(name string) string { return o.vals[name] }
// boolWords is the single accepted vocabulary for boolean options, mapping each
// recognised spelling to its truth value. The Bool reader, truthy, and
// validate() all consult it, so exactly the words that validate are honoured
// (no "accepted by the reader but rejected by validate" surprises like --x=on).
var boolWords = map[string]bool{
"true": true, "yes": true, "1": true, "on": true,
"false": false, "no": false, "0": false, "off": false,
}
// boolWord reports a value's truth and whether it is a recognised boolean word.
func boolWord(s string) (val, ok bool) {
val, ok = boolWords[strings.ToLower(strings.TrimSpace(s))]
return val, ok
}
// Bool reports whether the value is a truthy boolean word.
func (o *Options) Bool(name string) bool {
val, _ := boolWord(o.vals[name])
return val
}
// Int returns the value as an int, or 0 if empty/invalid.
func (o *Options) Int(name string) int { return int(o.Int64(name)) }
// Int64 returns the value as an int64, or 0 if empty/invalid.
func (o *Options) Int64(name string) int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(o.vals[name]), 10, 64)
return n
}
// Float returns the value as a float64, or 0 if empty/invalid.
func (o *Options) Float(name string) float64 {
f, _ := strconv.ParseFloat(strings.TrimSpace(o.vals[name]), 64)
return f
}
// Size returns a byte count parsed from a value like "20M" or "512K".
func (o *Options) Size(name string) int64 {
n, _ := parseSize(o.vals[name])
return n
}
// List returns a repeatable option's accumulated values.
func (o *Options) List(name string) []string {
v := o.vals[name]
if v == "" {
return nil
}
return strings.Split(v, "\n")
}
// parseSize converts a size string into a byte count: the first 'K'/'k' or
// 'M'/'m' in the string selects the multiplier (1024 or 1024*1024) and
// everything from that byte on is discarded; with no such unit the whole string
// is the byte count. There is no gigabyte unit, so "1G" is rejected; "1Mi" and
// "10MB" are 1M and 10M (the trailing bytes are dropped). An empty string is 0
// bytes; a negative value is rejected.
func parseSize(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, nil
}
mult := int64(1)
if i := strings.IndexAny(s, "KkMm"); i >= 0 {
if c := s[i]; c == 'M' || c == 'm' {
mult = 1 << 20
} else {
mult = 1 << 10
}
s = s[:i]
}
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
return 0, fmt.Errorf("bad size %q", s)
}
// Size parsing rejects negative sizes outright.
if n < 0 {
return 0, fmt.Errorf("negative size %q", s)
}
return n * mult, nil
}

311
cli/parse.go Normal file
View File

@@ -0,0 +1,311 @@
package cli
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// Action tells main what the user asked for.
type Action int
const (
Run Action = iota
ShowHelp
ShowVersion
)
// Result is everything Parse extracts from the command line.
type Result struct {
Action Action
HelpTag string // when Action == ShowHelp; "" means all
Options *Options
URIs []string
}
// Parse resolves args into options and URIs, applying the layered override
// order: built-in defaults, then the config file, then proxy
// environment variables, then the command line (last wins).
func Parse(args []string) (*Result, error) {
cmdVals, uris, action, helpTag, err := parseArgs(args)
if err != nil {
return nil, err
}
if action != Run {
return &Result{Action: action, HelpTag: helpTag}, nil
}
opts := newOptions()
applyDefaults(opts)
if !truthy(cmdVals["no-conf"]) {
explicit := cmdVals["conf-path"]
path, fromUser := confPath(explicit)
if err := applyConfig(opts, path, fromUser); err != nil {
return nil, err
}
}
applyEnv(opts)
for name, val := range cmdVals {
if err := set(opts, name, val); err != nil {
return nil, err
}
}
return &Result{Action: Run, Options: opts, URIs: uris}, nil
}
// parseArgs walks the argument list once. It recognises --long, --long=val,
// -x, -xval, -x val and short bool bundles like -cq. Like getopt_long, it
// permutes: a non-flag token becomes a URI but scanning continues so flags may
// follow URIs. A bare "--" is a hard terminator forcing the rest to URIs.
// --help/-h and --version/-v short-circuit. Values are returned raw; layering
// happens in Parse.
func parseArgs(args []string) (vals map[string]string, uris []string, action Action, helpTag string, err error) {
vals = map[string]string{}
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--": // end of options: the rest are URIs
uris = append(uris, args[i+1:]...)
return
case a == "-" || !strings.HasPrefix(a, "-"): // URI (or stdin); keep scanning
uris = append(uris, a)
continue
case strings.HasPrefix(a, "--"):
name, val, hasVal := strings.Cut(a[2:], "=")
if name == "help" {
return vals, uris, ShowHelp, val, nil
}
if name == "version" {
return vals, uris, ShowVersion, "", nil
}
o := byLong[name]
if o == nil {
return nil, nil, Run, "", fmt.Errorf("unknown option --%s", name)
}
if !hasVal {
if o.Kind == Bool {
val = "true"
} else {
if i+1 >= len(args) {
return nil, nil, Run, "", fmt.Errorf("option --%s needs a value", name)
}
i++
val = args[i]
}
}
accumulate(vals, o, val)
default: // short flags: -x, -xval, -x val, bundles -cq
j := 1
for j < len(a) {
c := a[j]
if c == 'h' {
return vals, uris, ShowHelp, "", nil
}
if c == 'v' {
return vals, uris, ShowVersion, "", nil
}
o := byShort[c]
if o == nil {
return nil, nil, Run, "", fmt.Errorf("unknown option -%c", c)
}
if o.Kind == Bool {
accumulate(vals, o, "true")
j++
continue
}
// value-taking short: rest of token, else next arg.
val := a[j+1:]
if val == "" {
if i+1 >= len(args) {
return nil, nil, Run, "", fmt.Errorf("option -%c needs a value", c)
}
i++
val = args[i]
}
accumulate(vals, o, val)
break // consumed the rest of the token as the value
}
}
}
return
}
// accumulate stores a value, joining repeatable List options with newlines.
func accumulate(vals map[string]string, o *Opt, val string) {
if o.Kind == List {
if prev, ok := vals[o.Long]; ok {
vals[o.Long] = prev + "\n" + val
return
}
}
vals[o.Long] = val
}
func applyDefaults(o *Options) {
for i := range options {
opt := &options[i]
if opt.Default != "" {
o.vals[opt.Long] = opt.Default
}
}
}
// applyConfig reads "name=value" lines (with # comments) into o. A missing
// file is silently ignored for the default path, but is fatal when the user
// named it explicitly via --conf-path: an explicitly requested config file
// that cannot be read is an error.
func applyConfig(o *Options, path string, explicit bool) error {
if path == "" {
return nil
}
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) && !explicit {
return nil // a missing default config file is not an error
}
return err
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
name, val, _ := strings.Cut(line, "=")
name = strings.TrimSpace(strings.TrimPrefix(name, "--"))
opt := byLong[name]
if opt == nil {
return fmt.Errorf("%s: unknown option %q", path, name)
}
if err := set(o, name, strings.TrimSpace(val)); err != nil {
return err
}
}
return sc.Err()
}
// applyEnv folds the conventional proxy environment variables into all-proxy
// unless it was already set explicitly.
func applyEnv(o *Options) {
if o.set["all-proxy"] {
return
}
for _, key := range []string{"all_proxy", "https_proxy", "http_proxy"} {
if v := os.Getenv(key); v != "" {
o.vals["all-proxy"] = v
return
}
}
}
// set validates a value against its option descriptor and stores it, marking
// the option as explicitly set.
func set(o *Options, name, val string) error {
opt := byLong[name]
if opt == nil {
return fmt.Errorf("unknown option %q", name)
}
if err := validate(opt, val); err != nil {
return err
}
if opt.Kind == List {
if prev, ok := o.vals[name]; ok && o.set[name] {
val = prev + "\n" + val
}
}
o.vals[name] = val
o.set[name] = true
return nil
}
func validate(o *Opt, val string) error {
switch o.Kind {
case Bool:
// Booleans accept true/false plus the yes/no/1/0/on/off spellings, but
// nothing else, so e.g. --enable-dht=flase is a parse error. The accepted
// set is boolWords, the same vocabulary the readers honour.
if _, ok := boolWord(val); !ok {
return fmt.Errorf("--%s: %q is not a boolean (true/false)", o.Long, val)
}
case Int:
n, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
if err != nil {
return fmt.Errorf("--%s: %q is not an integer", o.Long, val)
}
if err := checkBounds(o, n); err != nil {
return err
}
case Size:
n, err := parseSize(val)
if err != nil {
return fmt.Errorf("--%s: %v", o.Long, err)
}
if err := checkBounds(o, n); err != nil {
return err
}
case Float:
// seed-time and seed-ratio are both rates/durations that must be
// non-negative; a negative SeedTime would fire the seed timer immediately
// and a negative SeedRatio is silently ignored. Reject negatives here.
// Opt.Min/Max are int64, so we only enforce the floor, not full bounds.
f, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
if err != nil {
return fmt.Errorf("--%s: %q is not a number", o.Long, val)
}
if f < 0 {
return fmt.Errorf("--%s: %v below minimum 0", o.Long, f)
}
case Enum:
for _, e := range o.Enum {
if val == e {
return nil
}
}
return fmt.Errorf("--%s: %q not one of %s", o.Long, val, strings.Join(o.Enum, "|"))
}
return nil
}
// checkBounds enforces an Int/Size option's numeric range. Min defaults to a
// floor of 0 (negative sizes and counts are rejected); a zero Max means "no
// upper bound". For Size options n and the bounds are byte counts.
func checkBounds(o *Opt, n int64) error {
if n < o.Min {
return fmt.Errorf("--%s: %d below minimum %d", o.Long, n, o.Min)
}
if o.Max > 0 && n > o.Max {
return fmt.Errorf("--%s: %d above maximum %d", o.Long, n, o.Max)
}
return nil
}
// confPath picks the config file and reports whether the user named it. An
// explicit --conf-path wins (and is treated as user-requested, so a missing
// file is fatal). Otherwise it falls back to $XDG_CONFIG_HOME/got/got.conf,
// then ~/.config/got/got.conf.
func confPath(explicit string) (path string, fromUser bool) {
if explicit != "" {
return explicit, true
}
// XDG_CONFIG_HOME is only honoured when it is an absolute path.
if x := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(x) {
return filepath.Join(x, "got", "got.conf"), false
}
home, err := os.UserHomeDir()
if err != nil {
return "", false
}
return filepath.Join(home, ".config", "got", "got.conf"), false
}
func truthy(s string) bool {
val, _ := boolWord(s)
return val
}

231
cli/parse_test.go Normal file
View File

@@ -0,0 +1,231 @@
package cli
import (
"path/filepath"
"testing"
)
func TestParseArgs(t *testing.T) {
tests := []struct {
name string
args []string
action Action
wantVal map[string]string
uris []string
}{
{"short attached", []string{"-x16", "https://e.com"}, Run, map[string]string{"max-connection-per-server": "16"}, []string{"https://e.com"}},
{"long equals + bool", []string{"--split=8", "-c", "u"}, Run, map[string]string{"split": "8", "continue": "true"}, []string{"u"}},
{"long separate value", []string{"--out", "f.bin", "u"}, Run, map[string]string{"out": "f.bin"}, []string{"u"}},
{"bool bundle", []string{"-cq", "u"}, Run, map[string]string{"continue": "true", "quiet": "true"}, []string{"u"}},
{"repeatable header", []string{"--header", "A: 1", "--header", "B: 2", "u"}, Run, map[string]string{"header": "A: 1\nB: 2"}, []string{"u"}},
{"two uris", []string{"a", "b"}, Run, map[string]string{}, []string{"a", "b"}},
{"permute flag after uri", []string{"u", "--split=8"}, Run, map[string]string{"split": "8"}, []string{"u"}},
{"permute interleaved", []string{"a", "-c", "b", "--quiet"}, Run, map[string]string{"continue": "true", "quiet": "true"}, []string{"a", "b"}},
{"dash dash terminator", []string{"--split=8", "--", "--not-a-flag", "u"}, Run, map[string]string{"split": "8"}, []string{"--not-a-flag", "u"}},
{"bt metadata timeout", []string{"--bt-metadata-timeout=120", "magnet:x"}, Run, map[string]string{"bt-metadata-timeout": "120"}, []string{"magnet:x"}},
{"help", []string{"--help"}, ShowHelp, nil, nil},
{"help short", []string{"-h"}, ShowHelp, nil, nil},
{"version", []string{"-v"}, ShowVersion, nil, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
vals, uris, action, _, err := parseArgs(tc.args)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if action != tc.action {
t.Fatalf("action = %v, want %v", action, tc.action)
}
if tc.action != Run {
return
}
for k, want := range tc.wantVal {
if vals[k] != want {
t.Errorf("vals[%q] = %q, want %q", k, vals[k], want)
}
}
if len(uris) != len(tc.uris) {
t.Fatalf("uris = %v, want %v", uris, tc.uris)
}
for i := range uris {
if uris[i] != tc.uris[i] {
t.Errorf("uris[%d] = %q, want %q", i, uris[i], tc.uris[i])
}
}
})
}
}
func TestParseArgsErrors(t *testing.T) {
for _, args := range [][]string{
{"--unknown-flag"},
{"-Q"}, // -Q is not a defined short flag
{"--out"}, // missing value
} {
if _, _, _, _, err := parseArgs(args); err == nil {
t.Errorf("parseArgs(%v) = nil error, want error", args)
}
}
}
// got bounds the magnet metadata fetch by default rather than waiting forever;
// guard the default value and its zero floor.
func TestBTMetadataTimeoutDefault(t *testing.T) {
o, ok := byLong["bt-metadata-timeout"]
if !ok {
t.Fatal("bt-metadata-timeout option is missing")
}
if o.Default != "60" {
t.Errorf("bt-metadata-timeout default = %q, want %q", o.Default, "60")
}
if o.Min != 0 {
t.Errorf("bt-metadata-timeout Min = %d, want 0 (reject negatives, allow 0=off)", o.Min)
}
}
func TestParseSize(t *testing.T) {
tests := []struct {
in string
want int64
}{
{"", 0},
{"100", 100},
{"512K", 512 << 10},
{"20M", 20 << 20},
{"1024M", 1 << 30}, // there is no G unit; the 1G bound is typed as 1024M
{"1Mi", 1 << 20}, // the first K/M is taken and the rest discarded
{"10MB", 10 << 20},
{"2k", 2 << 10},
}
for _, tc := range tests {
got, err := parseSize(tc.in)
if err != nil {
t.Errorf("parseSize(%q) error: %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("parseSize(%q) = %d, want %d", tc.in, got, tc.want)
}
}
}
func TestValidateRange(t *testing.T) {
x := byLong["max-connection-per-server"]
if err := validate(x, "16"); err != nil {
t.Errorf("validate 16: %v", err)
}
if err := validate(x, "99"); err == nil {
t.Errorf("validate 99: want out-of-range error")
}
a := byLong["file-allocation"]
if err := validate(a, "bogus"); err == nil {
t.Errorf("validate enum bogus: want error")
}
}
func TestValidateIntFloor(t *testing.T) {
// Min:0 now enforces a floor of 0: negatives are rejected.
m := byLong["max-tries"] // Min 0, "0 = unlimited" runtime meaning preserved
if err := validate(m, "0"); err != nil {
t.Errorf("validate max-tries 0: %v, want nil (0 stays a valid input)", err)
}
if err := validate(m, "-1"); err == nil {
t.Errorf("validate max-tries -1: want below-minimum error")
}
}
func TestValidateFloatNonNegative(t *testing.T) {
// Float options (seed-time, seed-ratio) reject negatives: a negative
// SeedTime would fire the seed timer immediately and a negative SeedRatio
// is silently ignored, so neither is a meaningful input.
for _, name := range []string{"seed-ratio", "seed-time"} {
o := byLong[name]
if err := validate(o, "-1"); err == nil {
t.Errorf("validate %s -1: want below-minimum error", name)
}
if err := validate(o, "0"); err != nil {
t.Errorf("validate %s 0: %v, want nil", name, err)
}
if err := validate(o, "1.5"); err != nil {
t.Errorf("validate %s 1.5: %v, want nil", name, err)
}
}
}
func TestValidateBool(t *testing.T) {
b := byLong["enable-dht"]
for _, ok := range []string{"true", "false", "yes", "no", "1", "0", "on", "off"} {
if err := validate(b, ok); err != nil {
t.Errorf("validate bool %q: %v", ok, err)
}
}
if err := validate(b, "flase"); err == nil {
t.Errorf("validate bool flase: want error")
}
}
func TestValidateSizeBounds(t *testing.T) {
k := byLong["min-split-size"] // 1M..1024M (no G unit)
if err := validate(k, "20M"); err != nil {
t.Errorf("validate min-split-size 20M: %v", err)
}
if err := validate(k, "512K"); err == nil {
t.Errorf("validate min-split-size 512K: want below-minimum error")
}
// 1025M exceeds the 1<<30 cap and still parses, so it tests the bounds path
// (where "2G" would have failed earlier as an unknown unit).
if err := validate(k, "1025M"); err == nil {
t.Errorf("validate min-split-size 1025M: want above-maximum error")
}
}
func TestParseSizeNegative(t *testing.T) {
if _, err := parseSize("-1"); err == nil {
t.Errorf("parseSize(-1): want error (size parsing rejects negative values)")
}
if _, err := parseSize("-5M"); err == nil {
t.Errorf("parseSize(-5M): want error")
}
}
// size parsing knows only K and M; a "G" suffix is not a unit and must be
// rejected rather than silently treated as 2^30.
func TestParseSizeNoGigaUnit(t *testing.T) {
for _, in := range []string{"1G", "1g", "1Gi", "5GB"} {
if _, err := parseSize(in); err == nil {
t.Errorf("parseSize(%q): want error (no G unit)", in)
}
}
}
func TestExplicitConfMissingFatal(t *testing.T) {
missing := filepath.Join(t.TempDir(), "no-such-file.conf")
// Default (non-explicit) missing path is silently ignored.
if err := applyConfig(newOptions(), missing, false); err != nil {
t.Errorf("applyConfig default missing: %v, want nil", err)
}
// Explicit missing path is fatal.
if err := applyConfig(newOptions(), missing, true); err == nil {
t.Errorf("applyConfig explicit missing: want error")
}
}
func TestOptionsGetters(t *testing.T) {
o := newOptions()
o.vals["split"] = "8"
o.vals["min-split-size"] = "20M"
o.vals["continue"] = "true"
o.vals["seed-ratio"] = "1.5"
if o.Int("split") != 8 {
t.Errorf("Int split = %d", o.Int("split"))
}
if o.Size("min-split-size") != 20<<20 {
t.Errorf("Size min-split-size = %d", o.Size("min-split-size"))
}
if !o.Bool("continue") {
t.Errorf("Bool continue = false")
}
if o.Float("seed-ratio") != 1.5 {
t.Errorf("Float seed-ratio = %v", o.Float("seed-ratio"))
}
}