package config import ( "flag" "fmt" "io" "os" "strings" "time" ) // Set wraps flag.FlagSet to enforce this project's option convention: // a single hyphen always introduces a single letter (-s), a double hyphen // always introduces a full word (--max-size). The stdlib flag package treats // the two forms as interchangeable, so the convention is enforced here rather // than by the package. type Set struct { fs *flag.FlagSet envPrefix string specs []*spec longs map[string]bool shorts map[string]bool fromEnv map[string]bool } type spec struct { long, short string placeholder string usage string def string } func NewSet(name, envPrefix string) *Set { s := &Set{ fs: flag.NewFlagSet(name, flag.ContinueOnError), envPrefix: envPrefix, longs: map[string]bool{}, shorts: map[string]bool{}, fromEnv: map[string]bool{}, } s.fs.Usage = func() {} return s } func (s *Set) SetOutput(w io.Writer) { s.fs.SetOutput(w) } func (s *Set) register(sp *spec) { s.specs = append(s.specs, sp) } func (s *Set) note(long, short string) { s.longs[long] = true if short != "" { s.shorts[short] = true } } // envName maps --max-size to UNCENSORED_SEND_MAX_SIZE. func (s *Set) envName(long string) string { return s.envPrefix + strings.ToUpper(strings.ReplaceAll(long, "-", "_")) } func (s *Set) String(p *string, long, short, def, placeholder, usage string) { *p = def s.fs.StringVar(p, long, def, usage) if short != "" { s.fs.StringVar(p, short, def, usage) } s.note(long, short) s.register(&spec{long, short, placeholder, usage, def}) } func (s *Set) Bool(p *bool, long, short string, def bool, usage string) { *p = def s.fs.BoolVar(p, long, def, usage) if short != "" { s.fs.BoolVar(p, short, def, usage) } s.note(long, short) s.register(&spec{long, short, "", usage, fmt.Sprint(def)}) } func (s *Set) Int(p *int, long, short string, def int, usage string) { *p = def s.fs.IntVar(p, long, def, usage) if short != "" { s.fs.IntVar(p, short, def, usage) } s.note(long, short) s.register(&spec{long, short, "N", usage, fmt.Sprint(def)}) } type sizeValue struct{ p *int64 } func (v sizeValue) String() string { if v.p == nil { return "" } return FormatSize(*v.p) } func (v sizeValue) Set(s string) error { n, err := ParseSize(s) if err != nil { return err } *v.p = n return nil } // Size registers a byte-count option accepting "2GiB", "500MB" or "unlimited". func (s *Set) Size(p *int64, long, short, def, usage string) { n, err := ParseSize(def) if err != nil { panic("config: bad default size for --" + long + ": " + err.Error()) } *p = n v := sizeValue{p} s.fs.Var(v, long, usage) if short != "" { s.fs.Var(v, short, usage) } s.note(long, short) s.register(&spec{long, short, "SIZE", usage, def}) } type durationValue struct{ p *time.Duration } func (v durationValue) String() string { if v.p == nil { return "" } return FormatDuration(*v.p) } func (v durationValue) Set(s string) error { d, err := ParseDuration(s) if err != nil { return err } *v.p = d return nil } // Duration registers a time option accepting "3d", "90m" or "never". func (s *Set) Duration(p *time.Duration, long, short, def, usage string) { d, err := ParseDuration(def) if err != nil { panic("config: bad default duration for --" + long + ": " + err.Error()) } *p = d v := durationValue{p} s.fs.Var(v, long, usage) if short != "" { s.fs.Var(v, short, usage) } s.note(long, short) s.register(&spec{long, short, "DURATION", usage, def}) } // checkConvention rejects a long name given with one hyphen, or a short name // given with two. Only names actually registered are considered, so an option // *value* that happens to start with a hyphen is never mistaken for a flag. func (s *Set) checkConvention(args []string) error { for _, a := range args { if a == "--" { return nil } if len(a) < 2 || a[0] != '-' { continue } hyphens := 1 name := a[1:] if name[0] == '-' { hyphens = 2 name = name[1:] } name, _, _ = strings.Cut(name, "=") switch { case hyphens == 1 && len(name) > 1 && s.longs[name]: return fmt.Errorf("use --%s, not -%s: long options take two hyphens", name, name) case hyphens == 2 && len(name) == 1 && s.shorts[name]: return fmt.Errorf("use -%s, not --%s: short options take one hyphen", name, name) } } return nil } // applyEnv seeds every option from its UNCENSORED_SEND_-prefixed environment variable. // Called before parsing so an explicit command-line flag always wins. func (s *Set) applyEnv() error { for _, sp := range s.specs { v, ok := os.LookupEnv(s.envName(sp.long)) if !ok { continue } if err := s.fs.Set(sp.long, v); err != nil { return fmt.Errorf("%s: %w", s.envName(sp.long), err) } s.fromEnv[sp.long] = true } return nil } // Parse applies environment fallbacks, checks the hyphen convention and then // parses args (which must exclude the program name). func (s *Set) Parse(args []string) error { if err := s.applyEnv(); err != nil { return err } if err := s.checkConvention(args); err != nil { return err } if err := s.fs.Parse(args); err != nil { return err } // A stray word is never harmless: the flag package stops parsing at the // first non-flag argument, so every option after it is dropped in silence. // Writing a boolean as "--vanity false" is the way this bites - it sets // the flag to true and then discards the --data that says which file to // write, leaving a cheerful message about the opposite of what was meant. if extra := s.fs.Args(); len(extra) > 0 { return fmt.Errorf("unexpected argument %q: an option's value attaches with %q, as in --name=value, and any positional argument comes first", extra[0], "=") } return nil } // Changed reports whether an option was given on the command line. // // Values picked up from the environment do not count: a command that acts only // on the options you actually typed must not quietly act on a UNCENSORED_SEND_ variable // exported for the server. func (s *Set) Changed(long string) bool { if s.fromEnv[long] { return false } short := "" for _, sp := range s.specs { if sp.long == long { short = sp.short break } } given := false s.fs.Visit(func(f *flag.Flag) { if f.Name == long || (short != "" && f.Name == short) { given = true } }) return given } // PrintUsage renders the options in the "-x, --xxx" form the convention implies. func (s *Set) PrintUsage(w io.Writer, header string) { fmt.Fprint(w, header) for _, sp := range s.specs { lead := " " if sp.short != "" { lead = " -" + sp.short + "," } name := lead + " --" + sp.long if sp.placeholder != "" { name += " " + sp.placeholder } fmt.Fprintf(w, "%s\n", name) fmt.Fprintf(w, " %s", sp.usage) // A zero default means "unset" for the options that have one, and // printing it would read as a real value. if sp.def != "" && sp.def != "false" && sp.def != "0" { fmt.Fprintf(w, " (default %s)", sp.def) } fmt.Fprintf(w, "\n [%s]\n", s.envName(sp.long)) } }