Files
convy/internal/cli/prompt.go
T
av 8331aa1ca5 suite rule и suite list
- suite rule дописывает правило: номер берётся следующим за наибольшим, блоки
  раскладываются в порядке норма, ПОЧЕМУ, ПРИМЕРЫ, --after ставит правило рядом
  с тем, которое оно уточняет
- ступень называется категорией (requirement, prohibition, ...), а не словом
  языка, поэтому вызывающему не нужно знать, на каком языке записан набор
- suite list показывает темы со слоями, а с осью — что возьмёт компонент; отбор
  слоёв вынесен в suite.Assemble, откуда его возьмут проектные команды
- слои темы теперь всегда возвращаются базовым вперёд
2026-07-27 11:36:19 +03:00

154 lines
4.4 KiB
Go

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
}
// Options are what the dialogue offers; a check, where there is one, is
// what decides. A field may well accept more than it suggests — naming a
// step by its category is the suggestion, naming it by the word of the
// suite is accepted all the same.
if f.Check != nil {
return f.Check(value)
}
if len(f.Options) > 0 && !slices.Contains(f.Options, value) {
return fmt.Errorf("one of: %s", strings.Join(f.Options, ", "))
}
return nil
}
// 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
}