| 1 | package report |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | |
| 11 | "go.bigb.es/auxilia/culpa" |
| 12 | "go.bigb.es/auxilia/legatus" |
| 13 | ) |
| 14 | |
| 15 | var ( |
| 16 | _ legatus.Prompter = (*CLIPrompter)(nil) |
| 17 | _ legatus.Prompter = AutoPrompter(false) |
| 18 | ) |
| 19 | |
| 20 | // CLIPrompter answers interactive questions by reading from In and writing |
| 21 | // prompts to Out. It never touches os.Stdin/os.Stdout directly (PC2). |
| 22 | type CLIPrompter struct { |
| 23 | In io.Reader |
| 24 | Out io.Writer |
| 25 | |
| 26 | reader *bufio.Reader |
| 27 | } |
| 28 | |
| 29 | // readLine lazily initializes a buffered reader and returns the next trimmed |
| 30 | // line of input. |
| 31 | func (p *CLIPrompter) readLine() (string, error) { |
| 32 | if p.reader == nil { |
| 33 | p.reader = bufio.NewReader(p.In) |
| 34 | } |
| 35 | line, err := p.reader.ReadString('\n') |
| 36 | if err != nil && line == "" { |
| 37 | return "", err |
| 38 | } |
| 39 | return strings.TrimSpace(line), nil |
| 40 | } |
| 41 | |
| 42 | // Confirm asks a yes/no question. An empty or unrecognized answer is treated as |
| 43 | // "no". A read error before any input is returned. |
| 44 | func (p *CLIPrompter) Confirm(_ context.Context, question string) (bool, error) { |
| 45 | fmt.Fprintf(p.Out, "%s [y/N] ", question) |
| 46 | line, err := p.readLine() |
| 47 | if err != nil { |
| 48 | return false, err |
| 49 | } |
| 50 | switch strings.ToLower(line) { |
| 51 | case "y", "yes": |
| 52 | return true, nil |
| 53 | default: |
| 54 | return false, nil |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | // Select presents numbered options and reads a 1-based choice, returning the |
| 59 | // 0-based index. A non-numeric or out-of-range answer is an error. |
| 60 | func (p *CLIPrompter) Select(_ context.Context, question string, options []string) (int, error) { |
| 61 | fmt.Fprintf(p.Out, "%s\n", question) |
| 62 | for i, opt := range options { |
| 63 | fmt.Fprintf(p.Out, " %d) %s\n", i+1, opt) |
| 64 | } |
| 65 | fmt.Fprintf(p.Out, "select [1-%d]: ", len(options)) |
| 66 | line, err := p.readLine() |
| 67 | if err != nil { |
| 68 | return -1, err |
| 69 | } |
| 70 | n, err := strconv.Atoi(line) |
| 71 | if err != nil { |
| 72 | return -1, culpa.WithCode(culpa.Wrapf(err, "report: invalid selection %q", line), "LEGATUS_BAD_SELECTION") |
| 73 | } |
| 74 | if n < 1 || n > len(options) { |
| 75 | return -1, culpa.WithCode(culpa.New(fmt.Sprintf("report: selection %d out of range [1-%d]", n, len(options))), "LEGATUS_BAD_SELECTION") |
| 76 | } |
| 77 | return n - 1, nil |
| 78 | } |
| 79 | |
| 80 | // AutoPrompter is a non-interactive prompter that answers every Confirm with a |
| 81 | // fixed boolean and every Select with the first option. |
| 82 | type AutoPrompter bool |
| 83 | |
| 84 | // Confirm returns the configured fixed answer. |
| 85 | func (a AutoPrompter) Confirm(context.Context, string) (bool, error) { |
| 86 | return bool(a), nil |
| 87 | } |
| 88 | |
| 89 | // Select always picks the first option. |
| 90 | func (AutoPrompter) Select(_ context.Context, _ string, options []string) (int, error) { |
| 91 | if len(options) == 0 { |
| 92 | return -1, culpa.WithCode(culpa.New("report: select with no options"), "LEGATUS_BAD_SELECTION") |
| 93 | } |
| 94 | return 0, nil |
| 95 | } |
| 96 | |