Add CLI commands with structured errors and robot-mode behavior
Three cobra commands forming the CLI surface: - root: fetch and filter weekly deals (--store/--zip with BOGO, category, department, query, and limit filters) - stores: list nearby Publix locations by ZIP code - categories: show available deal categories with counts Structured error system with typed error codes (INVALID_ARGS, NOT_FOUND, UPSTREAM_ERROR, INTERNAL_ERROR) and semantic exit codes (0-4). Errors render as human-readable text or JSON depending on output mode. Robot-mode features: auto-JSON when stdout is not a TTY, compact quick-start help when invoked with no args, and JSON error payloads for programmatic consumers.
This commit is contained in:
51
cmd/categories.go
Normal file
51
cmd/categories.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/tayloree/publix-deals/internal/api"
|
||||||
|
"github.com/tayloree/publix-deals/internal/display"
|
||||||
|
"github.com/tayloree/publix-deals/internal/filter"
|
||||||
|
)
|
||||||
|
|
||||||
|
var categoriesCmd = &cobra.Command{
|
||||||
|
Use: "categories",
|
||||||
|
Short: "List available categories for the current week",
|
||||||
|
Example: ` pubcli categories --store 1425
|
||||||
|
pubcli categories -z 33101 --json`,
|
||||||
|
RunE: runCategories,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(categoriesCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCategories(cmd *cobra.Command, _ []string) error {
|
||||||
|
client := api.NewClient()
|
||||||
|
|
||||||
|
storeNumber, err := resolveStore(cmd, client)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := client.FetchSavings(cmd.Context(), storeNumber)
|
||||||
|
if err != nil {
|
||||||
|
return upstreamError("fetching deals", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data.Savings) == 0 {
|
||||||
|
return notFoundError(
|
||||||
|
fmt.Sprintf("no deals found for store #%s", storeNumber),
|
||||||
|
"Try another store with --store.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cats := filter.Categories(data.Savings)
|
||||||
|
|
||||||
|
if flagJSON {
|
||||||
|
return display.PrintCategoriesJSON(cmd.OutOrStdout(), cats)
|
||||||
|
}
|
||||||
|
display.PrintCategories(cmd.OutOrStdout(), cats, storeNumber)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
310
cmd/robot_mode.go
Normal file
310
cmd/robot_mode.go
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ExitSuccess is returned when the command succeeds.
|
||||||
|
ExitSuccess = 0
|
||||||
|
// ExitNotFound is returned when the requested stores/deals are not available.
|
||||||
|
ExitNotFound = 1
|
||||||
|
// ExitInvalidArgs is returned when the command input is invalid.
|
||||||
|
ExitInvalidArgs = 2
|
||||||
|
// ExitUpstream is returned when an external dependency fails.
|
||||||
|
ExitUpstream = 3
|
||||||
|
// ExitInternal is returned for unexpected internal failures.
|
||||||
|
ExitInternal = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
type cliError struct {
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
Suggestions []string
|
||||||
|
ExitCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *cliError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidArgsError(message string, suggestions ...string) error {
|
||||||
|
return &cliError{
|
||||||
|
Code: "INVALID_ARGS",
|
||||||
|
Message: message,
|
||||||
|
Suggestions: suggestions,
|
||||||
|
ExitCode: ExitInvalidArgs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notFoundError(message string, suggestions ...string) error {
|
||||||
|
return &cliError{
|
||||||
|
Code: "NOT_FOUND",
|
||||||
|
Message: message,
|
||||||
|
Suggestions: suggestions,
|
||||||
|
ExitCode: ExitNotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upstreamError(action string, err error) error {
|
||||||
|
return &cliError{
|
||||||
|
Code: "UPSTREAM_ERROR",
|
||||||
|
Message: fmt.Sprintf("%s: %v", action, err),
|
||||||
|
Suggestions: []string{"Retry in a moment."},
|
||||||
|
ExitCode: ExitUpstream,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonErrorPayload struct {
|
||||||
|
Error jsonErrorBody `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonErrorBody struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Suggestions []string `json:"suggestions,omitempty"`
|
||||||
|
ExitCode int `json:"exitCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func printCLIErrorJSON(w io.Writer, err *cliError) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload := jsonErrorPayload{
|
||||||
|
Error: jsonErrorBody{
|
||||||
|
Code: err.Code,
|
||||||
|
Message: err.Message,
|
||||||
|
Suggestions: err.Suggestions,
|
||||||
|
ExitCode: err.ExitCode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCLIErrorText(err *cliError) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
fmt.Sprintf("error[%s]: %s", strings.ToLower(err.Code), err.Message),
|
||||||
|
}
|
||||||
|
if len(err.Suggestions) > 0 {
|
||||||
|
lines = append(lines, "suggestions:")
|
||||||
|
for _, suggestion := range err.Suggestions {
|
||||||
|
lines = append(lines, " "+suggestion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyCLIError(err error) *cliError {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var typed *cliError
|
||||||
|
if errors.As(err, &typed) {
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := strings.TrimSpace(err.Error())
|
||||||
|
lowerMsg := strings.ToLower(msg)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(msg, "unknown command"):
|
||||||
|
suggestions := []string{
|
||||||
|
"pubcli stores --zip 33101",
|
||||||
|
"pubcli categories --zip 33101",
|
||||||
|
}
|
||||||
|
if bad := extractUnknownValue(msg, "unknown command"); bad != "" {
|
||||||
|
if suggestion, ok := closestMatch(strings.ToLower(bad), knownCommands, 2); ok {
|
||||||
|
suggestions = append([]string{fmt.Sprintf("Did you mean `%s`?", suggestion)}, suggestions...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &cliError{
|
||||||
|
Code: "INVALID_ARGS",
|
||||||
|
Message: msg,
|
||||||
|
Suggestions: suggestions,
|
||||||
|
ExitCode: ExitInvalidArgs,
|
||||||
|
}
|
||||||
|
case strings.Contains(msg, "unknown flag"):
|
||||||
|
suggestions := []string{
|
||||||
|
"pubcli --zip 33101",
|
||||||
|
"pubcli --store 1425 --bogo",
|
||||||
|
}
|
||||||
|
if bad := extractUnknownValue(msg, "unknown flag"); bad != "" {
|
||||||
|
trimmed := strings.TrimLeft(bad, "-")
|
||||||
|
if suggestion, ok := resolveFlagName(trimmed); ok {
|
||||||
|
suggestions = append([]string{fmt.Sprintf("Try `--%s`.", suggestion)}, suggestions...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &cliError{
|
||||||
|
Code: "INVALID_ARGS",
|
||||||
|
Message: msg,
|
||||||
|
Suggestions: suggestions,
|
||||||
|
ExitCode: ExitInvalidArgs,
|
||||||
|
}
|
||||||
|
case strings.Contains(msg, "requires an argument for flag"),
|
||||||
|
strings.Contains(msg, "flag needs an argument"),
|
||||||
|
strings.Contains(msg, "required flag(s)"):
|
||||||
|
return &cliError{
|
||||||
|
Code: "INVALID_ARGS",
|
||||||
|
Message: msg,
|
||||||
|
Suggestions: []string{"pubcli --zip 33101", "pubcli --store 1425"},
|
||||||
|
ExitCode: ExitInvalidArgs,
|
||||||
|
}
|
||||||
|
case strings.Contains(lowerMsg, "no publix stores found"),
|
||||||
|
strings.Contains(lowerMsg, "no stores found near"),
|
||||||
|
strings.Contains(lowerMsg, "no deals found"),
|
||||||
|
strings.Contains(lowerMsg, "no deals match"):
|
||||||
|
return &cliError{
|
||||||
|
Code: "NOT_FOUND",
|
||||||
|
Message: msg,
|
||||||
|
ExitCode: ExitNotFound,
|
||||||
|
}
|
||||||
|
case strings.Contains(lowerMsg, "unexpected status"),
|
||||||
|
strings.Contains(lowerMsg, "executing request"),
|
||||||
|
strings.Contains(lowerMsg, "reading response"),
|
||||||
|
strings.Contains(lowerMsg, "decoding savings"),
|
||||||
|
strings.Contains(lowerMsg, "decoding stores"),
|
||||||
|
strings.Contains(lowerMsg, "fetching deals"),
|
||||||
|
strings.Contains(lowerMsg, "fetching stores"),
|
||||||
|
strings.Contains(lowerMsg, "finding stores"):
|
||||||
|
return &cliError{
|
||||||
|
Code: "UPSTREAM_ERROR",
|
||||||
|
Message: msg,
|
||||||
|
Suggestions: []string{"Retry in a moment."},
|
||||||
|
ExitCode: ExitUpstream,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return &cliError{
|
||||||
|
Code: "INTERNAL_ERROR",
|
||||||
|
Message: msg,
|
||||||
|
Suggestions: []string{"Run `pubcli --help` for usage details."},
|
||||||
|
ExitCode: ExitInternal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTTY(w io.Writer) bool {
|
||||||
|
file, ok := w.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return term.IsTerminal(int(file.Fd()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasJSONPreference(args []string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "--json" || strings.HasPrefix(arg, "--json=") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasHelpRequest(args []string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "-h" || arg == "--help" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldAutoJSON(args []string, stdoutIsTTY bool) bool {
|
||||||
|
if stdoutIsTTY || len(args) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if hasJSONPreference(args) || hasHelpRequest(args) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch firstCommand(args) {
|
||||||
|
case "completion", "help":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownShorthands maps single-character shorthands to whether they require a value.
|
||||||
|
var knownShorthands = map[byte]bool{
|
||||||
|
's': true, // --store
|
||||||
|
'z': true, // --zip
|
||||||
|
'c': true, // --category
|
||||||
|
'd': true, // --department
|
||||||
|
'q': true, // --query
|
||||||
|
'n': true, // --limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstCommand(args []string) string {
|
||||||
|
expectingValue := false
|
||||||
|
for _, arg := range args {
|
||||||
|
if expectingValue {
|
||||||
|
expectingValue = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if arg == "--" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(arg, "-") {
|
||||||
|
return arg
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(arg, "--") {
|
||||||
|
name, rest := splitFlag(strings.TrimPrefix(arg, "--"))
|
||||||
|
if spec, ok := knownFlags[name]; ok && spec.requiresValue && rest == "" {
|
||||||
|
expectingValue = true
|
||||||
|
}
|
||||||
|
} else if len(arg) == 2 && arg[0] == '-' {
|
||||||
|
// Single-char shorthand like -z, -s, -n
|
||||||
|
if needsVal, ok := knownShorthands[arg[1]]; ok && needsVal {
|
||||||
|
expectingValue = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type quickStartJSON struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Usage string `json:"usage"`
|
||||||
|
Examples []string `json:"examples"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func printQuickStart(w io.Writer, asJSON bool) error {
|
||||||
|
help := quickStartJSON{
|
||||||
|
Name: "pubcli",
|
||||||
|
Usage: "pubcli [flags] | [stores|categories] [flags]",
|
||||||
|
Examples: []string{
|
||||||
|
"pubcli --zip 33101 --limit 10",
|
||||||
|
"pubcli stores --zip 33101",
|
||||||
|
"pubcli categories --store 1425",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if asJSON {
|
||||||
|
return json.NewEncoder(w).Encode(help)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
"%s\nusage: %s\nexamples:\n %s\n %s\n %s\nflags: --zip --store --json --bogo --category --department --query --limit\n",
|
||||||
|
help.Name,
|
||||||
|
help.Usage,
|
||||||
|
help.Examples[0],
|
||||||
|
help.Examples[1],
|
||||||
|
help.Examples[2],
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
52
cmd/robot_mode_test.go
Normal file
52
cmd/robot_mode_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldAutoJSON(t *testing.T) {
|
||||||
|
assert.True(t, shouldAutoJSON([]string{"stores", "--zip", "33101"}, false))
|
||||||
|
assert.False(t, shouldAutoJSON([]string{"stores", "--zip", "33101", "--json"}, false))
|
||||||
|
assert.False(t, shouldAutoJSON([]string{"completion", "zsh"}, false))
|
||||||
|
assert.False(t, shouldAutoJSON([]string{"--help"}, false))
|
||||||
|
assert.False(t, shouldAutoJSON([]string{"stores", "--zip", "33101"}, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstCommand_SkipsFlagValues(t *testing.T) {
|
||||||
|
cmd := firstCommand([]string{"--zip", "33101", "stores"})
|
||||||
|
assert.Equal(t, "stores", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintQuickStart_JSON(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := printQuickStart(&buf, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload quickStartJSON
|
||||||
|
err = json.Unmarshal(buf.Bytes(), &payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "pubcli", payload.Name)
|
||||||
|
assert.NotEmpty(t, payload.Usage)
|
||||||
|
assert.Len(t, payload.Examples, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintCLIErrorJSON(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := printCLIErrorJSON(&buf, classifyCLIError(invalidArgsError("bad flag", "pubcli --zip 33101")))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
err = json.Unmarshal(buf.Bytes(), &payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
errorObject, ok := payload["error"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "INVALID_ARGS", errorObject["code"])
|
||||||
|
assert.Equal(t, "bad flag", errorObject["message"])
|
||||||
|
}
|
||||||
190
cmd/root.go
Normal file
190
cmd/root.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/tayloree/publix-deals/internal/api"
|
||||||
|
"github.com/tayloree/publix-deals/internal/display"
|
||||||
|
"github.com/tayloree/publix-deals/internal/filter"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
flagStore string
|
||||||
|
flagZip string
|
||||||
|
flagCategory string
|
||||||
|
flagDepartment string
|
||||||
|
flagBogo bool
|
||||||
|
flagQuery string
|
||||||
|
flagLimit int
|
||||||
|
flagJSON bool
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "pubcli",
|
||||||
|
Short: "Fetch current Publix weekly ad deals",
|
||||||
|
Long: "CLI tool that fetches the current week's sale items from the Publix API.\n" +
|
||||||
|
"Requires a store number or zip code to find deals for your local store.\n\n" +
|
||||||
|
"Agent-friendly mode: minor syntax issues are auto-corrected when intent is clear " +
|
||||||
|
"(for example: -zip 33101, zip=33101, --ziip 33101).",
|
||||||
|
Example: ` pubcli --zip 33101
|
||||||
|
pubcli --store 1425 --bogo
|
||||||
|
pubcli categories --zip 33101
|
||||||
|
pubcli stores --zip 33101 --json`,
|
||||||
|
RunE: runDeals,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.SilenceErrors = true
|
||||||
|
rootCmd.SilenceUsage = true
|
||||||
|
|
||||||
|
pf := rootCmd.PersistentFlags()
|
||||||
|
pf.StringVarP(&flagStore, "store", "s", "", "Publix store number (e.g., 1425)")
|
||||||
|
pf.StringVarP(&flagZip, "zip", "z", "", "Zip code to find nearby stores")
|
||||||
|
pf.BoolVar(&flagJSON, "json", false, "Output as JSON")
|
||||||
|
|
||||||
|
f := rootCmd.Flags()
|
||||||
|
f.StringVarP(&flagCategory, "category", "c", "", "Filter by category (e.g., bogo, meat, produce)")
|
||||||
|
f.StringVarP(&flagDepartment, "department", "d", "", "Filter by department (e.g., Meat, Deli)")
|
||||||
|
f.BoolVar(&flagBogo, "bogo", false, "Show only BOGO deals")
|
||||||
|
f.StringVarP(&flagQuery, "query", "q", "", "Search deals by keyword in title/description")
|
||||||
|
f.IntVarP(&flagLimit, "limit", "n", 0, "Limit number of results (0 = all)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs the root command.
|
||||||
|
func Execute() {
|
||||||
|
os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCLI(args []string, stdout, stderr io.Writer) int {
|
||||||
|
resetCLIState()
|
||||||
|
|
||||||
|
normalizedArgs, notes := normalizeCLIArgs(args)
|
||||||
|
for _, note := range notes {
|
||||||
|
fmt.Fprintf(stderr, "note: %s\n", note)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(normalizedArgs) == 0 {
|
||||||
|
if err := printQuickStart(stdout, !isTTY(stdout)); err != nil {
|
||||||
|
cliErr := classifyCLIError(err)
|
||||||
|
fmt.Fprintln(stderr, formatCLIErrorText(cliErr))
|
||||||
|
return cliErr.ExitCode
|
||||||
|
}
|
||||||
|
return ExitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldAutoJSON(normalizedArgs, isTTY(stdout)) {
|
||||||
|
normalizedArgs = append(normalizedArgs, "--json")
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommandIO(rootCmd, stdout, stderr)
|
||||||
|
rootCmd.SetArgs(normalizedArgs)
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
cliErr := classifyCLIError(err)
|
||||||
|
if hasJSONPreference(normalizedArgs) {
|
||||||
|
if jerr := printCLIErrorJSON(stderr, cliErr); jerr != nil {
|
||||||
|
fmt.Fprintln(stderr, formatCLIErrorText(classifyCLIError(jerr)))
|
||||||
|
return ExitInternal
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(stderr, formatCLIErrorText(cliErr))
|
||||||
|
}
|
||||||
|
return cliErr.ExitCode
|
||||||
|
}
|
||||||
|
return ExitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCommandIO(cmd *cobra.Command, stdout, stderr io.Writer) {
|
||||||
|
cmd.SetOut(stdout)
|
||||||
|
cmd.SetErr(stderr)
|
||||||
|
for _, child := range cmd.Commands() {
|
||||||
|
setCommandIO(child, stdout, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetCLIState() {
|
||||||
|
flagStore = ""
|
||||||
|
flagZip = ""
|
||||||
|
flagCategory = ""
|
||||||
|
flagDepartment = ""
|
||||||
|
flagBogo = false
|
||||||
|
flagQuery = ""
|
||||||
|
flagLimit = 0
|
||||||
|
flagJSON = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStore(cmd *cobra.Command, client *api.Client) (string, error) {
|
||||||
|
if flagStore != "" {
|
||||||
|
return flagStore, nil
|
||||||
|
}
|
||||||
|
if flagZip == "" {
|
||||||
|
return "", invalidArgsError(
|
||||||
|
"please provide --store NUMBER or --zip ZIPCODE",
|
||||||
|
"pubcli --zip 33101",
|
||||||
|
"pubcli --store 1425",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
stores, err := client.FetchStores(cmd.Context(), flagZip, 1)
|
||||||
|
if err != nil {
|
||||||
|
return "", upstreamError("finding stores", err)
|
||||||
|
}
|
||||||
|
if len(stores) == 0 {
|
||||||
|
return "", notFoundError(
|
||||||
|
fmt.Sprintf("no Publix stores found near %s", flagZip),
|
||||||
|
"Try a nearby ZIP code.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
num := api.StoreNumber(stores[0].Key)
|
||||||
|
if !flagJSON {
|
||||||
|
display.PrintStoreContext(cmd.OutOrStdout(), stores[0])
|
||||||
|
}
|
||||||
|
return num, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDeals(cmd *cobra.Command, _ []string) error {
|
||||||
|
client := api.NewClient()
|
||||||
|
|
||||||
|
storeNumber, err := resolveStore(cmd, client)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := client.FetchSavings(cmd.Context(), storeNumber)
|
||||||
|
if err != nil {
|
||||||
|
return upstreamError("fetching deals", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := data.Savings
|
||||||
|
if len(items) == 0 {
|
||||||
|
return notFoundError(
|
||||||
|
fmt.Sprintf("no deals found for store #%s", storeNumber),
|
||||||
|
"Try another store with --store.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items = filter.Apply(items, filter.Options{
|
||||||
|
BOGO: flagBogo,
|
||||||
|
Category: flagCategory,
|
||||||
|
Department: flagDepartment,
|
||||||
|
Query: flagQuery,
|
||||||
|
Limit: flagLimit,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return notFoundError(
|
||||||
|
"no deals match your filters",
|
||||||
|
"Relax filters like --category/--department/--query.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagJSON {
|
||||||
|
return display.PrintDealsJSON(cmd.OutOrStdout(), items)
|
||||||
|
}
|
||||||
|
display.PrintDeals(cmd.OutOrStdout(), items)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
50
cmd/stores.go
Normal file
50
cmd/stores.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/tayloree/publix-deals/internal/api"
|
||||||
|
"github.com/tayloree/publix-deals/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
var storesCmd = &cobra.Command{
|
||||||
|
Use: "stores",
|
||||||
|
Short: "List nearby Publix stores",
|
||||||
|
Long: "Find Publix stores near a zip code. Use this to discover store numbers for fetching deals.",
|
||||||
|
Example: ` pubcli stores --zip 33101
|
||||||
|
pubcli stores -z 32801 --json`,
|
||||||
|
RunE: runStores,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(storesCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStores(cmd *cobra.Command, _ []string) error {
|
||||||
|
if flagZip == "" {
|
||||||
|
return invalidArgsError(
|
||||||
|
"--zip is required for store lookup",
|
||||||
|
"pubcli stores --zip 33101",
|
||||||
|
"pubcli stores -z 33101 --json",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewClient()
|
||||||
|
stores, err := client.FetchStores(cmd.Context(), flagZip, 5)
|
||||||
|
if err != nil {
|
||||||
|
return upstreamError("fetching stores", err)
|
||||||
|
}
|
||||||
|
if len(stores) == 0 {
|
||||||
|
return notFoundError(
|
||||||
|
fmt.Sprintf("no stores found near %s", flagZip),
|
||||||
|
"Try a nearby ZIP code.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagJSON {
|
||||||
|
return display.PrintStoresJSON(cmd.OutOrStdout(), stores)
|
||||||
|
}
|
||||||
|
display.PrintStores(cmd.OutOrStdout(), stores, flagZip)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user