Files
got/cli/parse.go
Hojun-Cho cf9082ff72 all: cut bloated HTTP-client and tuning options
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.
2026-06-22 08:30:12 +09:00

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
}