first commit
This commit is contained in:
107
cli/help.go
Normal file
107
cli/help.go
Normal 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
143
cli/option.go
Normal 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
109
cli/options.go
Normal 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
311
cli/parse.go
Normal 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
231
cli/parse_test.go
Normal 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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user