package cli import ( "bufio" "errors" "fmt" "io" "slices" "strings" ) // Commands that change something run in two modes, and which one is meant is // read off the command line: bare means interactive, any flag means automatic. // // The two modes address two different callers. A person types the command and // is walked through the fields with a hint for each; an agent or a script // passes every field at once and must never be blocked waiting on a terminal // that is not there. Neither mode guesses: a field that is required and absent // stops the run in both. // Field is one thing a command asks for. type Field struct { // Flag is the name the field carries on the command line. Flag string // Ask is the question put to a person. Ask string // Hint is the one line explaining what belongs here and why. Hint string // Default is offered as the answer when the person just presses enter. Default string // Optional fields may stay empty. Optional bool // Options, when given, are the only answers accepted. Options []string // Check validates an answer; it is applied in both modes. Check func(string) error } // errStop ends the dialogue on end of input. var errStop = errors.New("input ended") // dialogue asks the fields one by one, re-asking what did not pass validation. type dialogue struct { in *bufio.Reader out io.Writer } func newDialogue(env Env) *dialogue { return &dialogue{in: bufio.NewReader(env.In), out: env.Out} } // ask puts one question and returns the answer, looping until it validates. func (d *dialogue) ask(f Field) (string, error) { for { if f.Hint != "" { fmt.Fprintf(d.out, "\n%s\n", f.Hint) } prompt := f.Ask switch { case len(f.Options) > 0: prompt += fmt.Sprintf(" (%s)", strings.Join(f.Options, " / ")) if f.Default != "" { prompt += fmt.Sprintf(" [%s]", f.Default) } case f.Default != "": prompt += fmt.Sprintf(" [%s]", f.Default) case f.Optional: prompt += " [may stay empty]" } fmt.Fprintf(d.out, "%s: ", prompt) line, err := d.in.ReadString('\n') answer := strings.TrimSpace(line) if answer == "" && err != nil { return "", errStop } if answer == "" { answer = f.Default } if answer == "" && !f.Optional { fmt.Fprintln(d.out, " the field is required") continue } if err := validate(f, answer); err != nil { fmt.Fprintf(d.out, " %s\n", err) continue } return answer, nil } } // confirm asks a yes-or-no question before something irreversible, defaulting // to no: a retirement that went through because the reader pressed enter is the // one mistake the model cannot undo. func (d *dialogue) confirm(question string) (bool, error) { fmt.Fprintf(d.out, "\n%s [y/N]: ", question) line, err := d.in.ReadString('\n') answer := strings.ToLower(strings.TrimSpace(line)) if answer == "" && err != nil { return false, errStop } return answer == "y" || answer == "yes", nil } // validate applies a field's check to a value that is not empty. func validate(f Field, value string) error { if value == "" { return nil } if len(f.Options) > 0 && !slices.Contains(f.Options, value) { return fmt.Errorf("one of: %s", strings.Join(f.Options, ", ")) } if f.Check == nil { return nil } return f.Check(value) } // resolve settles the fields in automatic mode: what came on the command line // is validated, what is required and missing is named. Every missing field is // reported at once — being sent back one flag at a time is the worst way to // learn what a command wants. func resolve(fields []Field, given map[string]string) error { var missing []string var problems []string for _, f := range fields { value := given[f.Flag] if value == "" { value = f.Default given[f.Flag] = value } if value == "" { if !f.Optional { missing = append(missing, "--"+f.Flag) } continue } if err := validate(f, value); err != nil { problems = append(problems, fmt.Sprintf("--%s: %s", f.Flag, err)) } } if len(missing) > 0 { problems = append(problems, "required and missing: "+strings.Join(missing, ", ")) } if len(problems) > 0 { return errors.New(strings.Join(problems, "\n")) } return nil }