This is a personal torrent client, not a web client or daemon, so the rarely-used HTTP-client knobs and redundant tuning flags are gone — flag, config field, implementation, and tests removed for each. The option surface drops from 54 to 28. Web-client cluster removed: --all-proxy (HTTP_PROXY env still works), load/save-cookies, --referer, -U/--user-agent, --http-user/--http-passwd, --conditional-get, --remote-time, --check-certificate, --ca-certificate, --lowest-speed-limit. Pass --header for a one-off auth/cookie/UA header. Redundant knobs removed: -x and -k (folded into -s), --connect-timeout, --retry-wait, per-item --max-download-limit/-u, --bt-max-peers, --bt-stop-timeout, --disable-ipv6, --auto-save-interval, --human-readable, --stop, -T (a positional .torrent works). follow-torrent and human-readable are now unconditional defaults.
327 lines
8.9 KiB
Go
327 lines
8.9 KiB
Go
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 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 noConf, _ := boolWord(cmdVals["no-conf"]); !noConf {
|
|
explicit := cmdVals["conf-path"]
|
|
path, fromUser := confPath(explicit)
|
|
if err := applyConfig(opts, path, fromUser); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
for name, val := range cmdVals {
|
|
if err := set(opts, name, val); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := opts.finalize(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Result{Action: Run, Options: opts, URIs: uris}, nil
|
|
}
|
|
|
|
// finalize parses every resolved value once into its typed form, validating it
|
|
// in the same pass; the numeric/bool getters then just read the result. It runs
|
|
// after all layering, so each option is converted exactly once, from its final
|
|
// (last-wins) string — not once per layer and again per read.
|
|
func (o *Options) finalize() error {
|
|
for i := range options {
|
|
opt := &options[i]
|
|
raw, ok := o.vals[opt.Long]
|
|
if !ok {
|
|
continue
|
|
}
|
|
v, err := parseValue(opt, raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if v != nil {
|
|
o.typed[opt.Long] = v
|
|
}
|
|
}
|
|
return 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 {
|
|
vals[o.Long] = appendList(vals[o.Long], val)
|
|
return
|
|
}
|
|
vals[o.Long] = val
|
|
}
|
|
|
|
// appendList is the single home of the List newline-join rule, shared by the
|
|
// raw-parse layer (accumulate) and the resolved layer (set). An empty prev (the
|
|
// option not yet seen) yields val unchanged, so the first value carries no
|
|
// leading newline.
|
|
func appendList(prev, val string) string {
|
|
if prev == "" {
|
|
return val
|
|
}
|
|
return prev + "\n" + 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()
|
|
}
|
|
|
|
// set stores a raw value, marking the option as explicitly set. Validation and
|
|
// conversion happen later, once, in finalize(); set only records the string.
|
|
func set(o *Options, name, val string) error {
|
|
opt := byLong[name]
|
|
if opt == nil {
|
|
return fmt.Errorf("unknown option %q", name)
|
|
}
|
|
if opt.Kind == List && o.set[name] {
|
|
val = appendList(o.vals[name], val)
|
|
}
|
|
o.vals[name] = val
|
|
o.set[name] = true
|
|
return nil
|
|
}
|
|
|
|
// parseValue converts a raw string to the typed value for o.Kind, validating it
|
|
// in the process: it is the single place a value is parsed. Bool/Int/Size/Float
|
|
// return their typed value (read back by the getters); Str/List/Enum need no
|
|
// stored conversion and return nil, with Enum still checked for membership.
|
|
func parseValue(o *Opt, val string) (any, error) {
|
|
switch o.Kind {
|
|
case Bool:
|
|
// Booleans accept exactly true/false (the boolWords vocabulary the Bool
|
|
// getter honours); --enable-dht=flase is a parse error.
|
|
v, ok := boolWord(val)
|
|
if !ok {
|
|
return nil, fmt.Errorf("--%s: %q is not a boolean (true/false)", o.Long, val)
|
|
}
|
|
return v, nil
|
|
case Int:
|
|
n, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("--%s: %q is not an integer", o.Long, val)
|
|
}
|
|
if err := checkBounds(o, n); err != nil {
|
|
return nil, err
|
|
}
|
|
return n, nil
|
|
case Size:
|
|
n, err := parseSize(val)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("--%s: %v", o.Long, err)
|
|
}
|
|
if err := checkBounds(o, n); err != nil {
|
|
return nil, err
|
|
}
|
|
return n, nil
|
|
case Float:
|
|
// seed-time and seed-ratio must be non-negative: a negative SeedTime would
|
|
// fire the seed timer immediately and a negative SeedRatio is meaningless.
|
|
f, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("--%s: %q is not a number", o.Long, val)
|
|
}
|
|
if f < 0 {
|
|
return nil, fmt.Errorf("--%s: %v below minimum 0", o.Long, f)
|
|
}
|
|
return f, nil
|
|
case Enum:
|
|
for _, e := range o.Enum {
|
|
if val == e {
|
|
return nil, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("--%s: %q not one of %s", o.Long, val, strings.Join(o.Enum, "|"))
|
|
}
|
|
return nil, 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
|
|
}
|