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 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 } } 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 { 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() } // 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 && o.set[name] { val = appendList(o.vals[name], 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 }