Files
pubcli/cmd/cli_tolerance.go
teernisse 7dd963141a Add compare and tui commands with shared sort/filter CLI wiring
- add pubcli compare command to rank nearby stores by filtered deal coverage, bogo count, aggregate score, and distance tie-breaks
- support --count (1-10) for comparison breadth and emit structured JSON/text output with ranked entries
- add robust distance token parsing to tolerate upstream distance string formatting differences
- add pubcli tui interactive terminal browser with paging, deal detail drill-in, and explicit TTY validation for stdin/stdout
- share deal-filter flag registration across root/tui/compare and add --sort support in root execution path
- validate sort mode early and allow canonical aliases (end, expiry, expiration) while preserving explicit invalid-arg guidance
- expand tolerant CLI normalization for new commands/flags and aliases (orderby, sortby, count, bare-flag rewrite for compare/tui)
- update quick-start flag list and integration tests to cover compare help and normalization behavior
2026-02-23 00:27:18 -05:00

326 lines
7.7 KiB
Go

package cmd
import (
"fmt"
"strings"
)
type flagSpec struct {
name string
requiresValue bool
}
var knownFlags = map[string]flagSpec{
"store": {name: "store", requiresValue: true},
"zip": {name: "zip", requiresValue: true},
"json": {name: "json", requiresValue: false},
"category": {name: "category", requiresValue: true},
"department": {name: "department", requiresValue: true},
"bogo": {name: "bogo", requiresValue: false},
"query": {name: "query", requiresValue: true},
"sort": {name: "sort", requiresValue: true},
"limit": {name: "limit", requiresValue: true},
"count": {name: "count", requiresValue: true},
"help": {name: "help", requiresValue: false},
}
var knownCommands = []string{
"categories",
"stores",
"compare",
"tui",
"completion",
"help",
}
var flagAliases = map[string]string{
"zipcode": "zip",
"postal-code": "zip",
"store-number": "store",
"storeno": "store",
"dept": "department",
"search": "query",
"orderby": "sort",
"sortby": "sort",
"max": "limit",
}
func normalizeCLIArgs(args []string) ([]string, []string) {
out := make([]string, 0, len(args))
notes := make([]string, 0, 2)
commandChosen := false
activeCommand := ""
nestedCommandAllowed := false
nestedCommandChosen := false
allowBareFlagRewrite := true
expectingValue := false
afterDoubleDash := false
for i, tok := range args {
if afterDoubleDash {
out = append(out, tok)
continue
}
if expectingValue {
out = append(out, tok)
expectingValue = false
continue
}
if tok == "--" {
out = append(out, tok)
afterDoubleDash = true
continue
}
canBeCommand := !commandChosen || (nestedCommandAllowed && !nestedCommandChosen)
normalized, note, isFlag, needsValue, isCommand := normalizeToken(tok, canBeCommand, allowBareFlagRewrite)
if note != "" {
notes = append(notes, note)
}
out = append(out, normalized)
if isCommand {
if !commandChosen {
commandChosen = true
activeCommand = normalized
allowBareFlagRewrite = bareFlagRewriteAllowed(activeCommand)
nestedCommandAllowed = allowsNestedCommandArg(activeCommand)
continue
}
if nestedCommandAllowed && !nestedCommandChosen {
nestedCommandChosen = true
}
}
if isFlag && needsValue && !strings.Contains(normalized, "=") && i < len(args)-1 {
expectingValue = true
}
}
return out, notes
}
func normalizeToken(tok string, canBeCommand bool, allowBareFlagRewrite bool) (normalized, note string, isFlag, needsValue, isCommand bool) {
if tok == "--" {
return tok, "", false, false, false
}
if strings.HasPrefix(tok, "--") {
flagName, rest := splitFlag(strings.TrimPrefix(tok, "--"))
canonical, ok := resolveFlagName(flagName)
if ok {
newTok := "--" + canonical + rest
if newTok != tok {
return newTok, fmt.Sprintf("interpreted `%s` as `%s`; use `%s` next time.", tok, newTok, newTok), true, knownFlags[canonical].requiresValue, false
}
return newTok, "", true, knownFlags[canonical].requiresValue, false
}
return tok, "", true, false, false
}
if strings.HasPrefix(tok, "-") && len(tok) > 2 {
flagName, rest := splitFlag(strings.TrimPrefix(tok, "-"))
canonical, ok := resolveFlagName(flagName)
if ok {
newTok := "--" + canonical + rest
return newTok, fmt.Sprintf("interpreted `%s` as `%s`; use `%s` next time.", tok, newTok, newTok), true, knownFlags[canonical].requiresValue, false
}
return tok, "", true, false, false
}
if strings.Contains(tok, "=") && !strings.HasPrefix(tok, "-") {
flagName, rest := splitFlag(tok)
canonical, ok := resolveFlagName(flagName)
if ok {
newTok := "--" + canonical + rest
return newTok, fmt.Sprintf("interpreted `%s` as `%s`; use `%s` next time.", tok, newTok, newTok), true, knownFlags[canonical].requiresValue, false
}
}
if canBeCommand && !strings.HasPrefix(tok, "-") {
if corrected, ok := resolveCommand(tok); ok {
if corrected != tok {
return corrected, fmt.Sprintf("interpreted command `%s` as `%s`; use `%s` next time.", tok, corrected, corrected), false, false, true
}
return tok, "", false, false, true
}
}
if allowBareFlagRewrite && !strings.HasPrefix(tok, "-") {
canonical, ok := resolveFlagName(tok)
if ok {
newTok := "--" + canonical
return newTok, fmt.Sprintf("interpreted `%s` as `%s`; use `%s` next time.", tok, newTok, newTok), true, knownFlags[canonical].requiresValue, false
}
}
return tok, "", false, false, false
}
func bareFlagRewriteAllowed(command string) bool {
// Some commands (for example `stores` and `categories`) are flag-only, so
// rewriting bare tokens like `zip` -> `--zip` is helpful there.
switch command {
case "stores", "categories", "compare", "tui":
return true
default:
return false
}
}
func allowsNestedCommandArg(command string) bool {
// These commands accept another command token as a positional argument.
switch command {
case "help", "completion":
return true
default:
return false
}
}
func resolveFlagName(raw string) (string, bool) {
name := strings.ToLower(strings.TrimSpace(raw))
name = strings.ReplaceAll(name, "_", "-")
if canonical, ok := flagAliases[name]; ok {
return canonical, true
}
if _, ok := knownFlags[name]; ok {
return name, true
}
if suggestion, ok := closestMatch(name, mapKeys(knownFlags), 2); ok {
return suggestion, true
}
return "", false
}
func resolveCommand(raw string) (string, bool) {
name := strings.ToLower(strings.TrimSpace(raw))
for _, cmd := range knownCommands {
if name == cmd {
return cmd, true
}
}
if suggestion, ok := closestMatch(name, knownCommands, 2); ok {
return suggestion, true
}
return "", false
}
func explainCLIError(err error) string {
return formatCLIErrorText(classifyCLIError(err))
}
func splitFlag(value string) (string, string) {
parts := strings.SplitN(value, "=", 2)
if len(parts) == 2 {
return parts[0], "=" + parts[1]
}
return value, ""
}
func extractUnknownValue(msg, marker string) string {
idx := strings.Index(msg, marker)
if idx == -1 {
return ""
}
remaining := strings.TrimSpace(msg[idx+len(marker):])
remaining = strings.TrimPrefix(remaining, ":")
remaining = strings.TrimSpace(remaining)
if strings.HasPrefix(remaining, "\"") {
remaining = strings.TrimPrefix(remaining, "\"")
end := strings.Index(remaining, "\"")
if end >= 0 {
return remaining[:end]
}
}
if strings.HasPrefix(remaining, "`") {
remaining = strings.TrimPrefix(remaining, "`")
end := strings.Index(remaining, "`")
if end >= 0 {
return remaining[:end]
}
}
if fields := strings.Fields(remaining); len(fields) > 0 {
return strings.Trim(fields[0], "\"`")
}
return ""
}
func mapKeys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for key := range m {
keys = append(keys, key)
}
return keys
}
func closestMatch(target string, candidates []string, maxDistance int) (string, bool) {
best := ""
bestDist := maxDistance + 1
for _, candidate := range candidates {
d := levenshtein(target, candidate)
if d < bestDist {
bestDist = d
best = candidate
}
}
if bestDist <= maxDistance {
return best, true
}
return "", false
}
func levenshtein(a, b string) int {
if a == b {
return 0
}
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
prev := make([]int, len(b)+1)
curr := make([]int, len(b)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(a); i++ {
curr[0] = i
for j := 1; j <= len(b); j++ {
cost := 0
if a[i-1] != b[j-1] {
cost = 1
}
del := prev[j] + 1
ins := curr[j-1] + 1
sub := prev[j-1] + cost
curr[j] = minInt(del, ins, sub)
}
prev, curr = curr, prev
}
return prev[len(b)]
}
func minInt(vals ...int) int {
best := vals[0]
for _, v := range vals[1:] {
if v < best {
best = v
}
}
return best
}