diff --git a/cmd/cli_tolerance.go b/cmd/cli_tolerance.go index 8081c56..48da43d 100644 --- a/cmd/cli_tolerance.go +++ b/cmd/cli_tolerance.go @@ -18,13 +18,17 @@ var knownFlags = map[string]flagSpec{ "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", } @@ -36,6 +40,8 @@ var flagAliases = map[string]string{ "storeno": "store", "dept": "department", "search": "query", + "orderby": "sort", + "sortby": "sort", "max": "limit", } @@ -156,7 +162,7 @@ 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": + case "stores", "categories", "compare", "tui": return true default: return false diff --git a/cmd/cli_tolerance_test.go b/cmd/cli_tolerance_test.go index f21e5ce..be5e6b6 100644 --- a/cmd/cli_tolerance_test.go +++ b/cmd/cli_tolerance_test.go @@ -21,6 +21,13 @@ func TestNormalizeCLIArgs_RewritesTypoFlag(t *testing.T) { assert.NotEmpty(t, notes) } +func TestNormalizeCLIArgs_RewritesSortAlias(t *testing.T) { + args, notes := normalizeCLIArgs([]string{"orderby=savings"}) + + assert.Equal(t, []string{"--sort=savings"}, args) + assert.NotEmpty(t, notes) +} + func TestNormalizeCLIArgs_RewritesCommandTypo(t *testing.T) { args, notes := normalizeCLIArgs([]string{"categoriess", "--zip", "33101"}) @@ -49,6 +56,13 @@ func TestNormalizeCLIArgs_RespectsDoubleDashBoundary(t *testing.T) { assert.Empty(t, notes) } +func TestNormalizeCLIArgs_RewritesBareFlagsForCompare(t *testing.T) { + args, notes := normalizeCLIArgs([]string{"compare", "zip", "33101"}) + + assert.Equal(t, []string{"compare", "--zip", "33101"}, args) + assert.NotEmpty(t, notes) +} + func TestNormalizeCLIArgs_LeavesKnownShorthandUntouched(t *testing.T) { args, notes := normalizeCLIArgs([]string{"-z", "33101", "-n", "5"}) diff --git a/cmd/compare.go b/cmd/compare.go new file mode 100644 index 0000000..917ebaf --- /dev/null +++ b/cmd/compare.go @@ -0,0 +1,198 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/tayloree/publix-deals/internal/api" + "github.com/tayloree/publix-deals/internal/filter" +) + +var flagCompareCount int + +type compareStoreResult struct { + Rank int `json:"rank"` + Number string `json:"number"` + Name string `json:"name"` + City string `json:"city"` + State string `json:"state"` + Distance string `json:"distance"` + MatchedDeals int `json:"matchedDeals"` + BogoDeals int `json:"bogoDeals"` + Score float64 `json:"score"` + TopDeal string `json:"topDeal"` +} + +var compareCmd = &cobra.Command{ + Use: "compare", + Short: "Compare nearby stores by filtered deal quality", + Example: ` pubcli compare --zip 33101 + pubcli compare --zip 33101 --category produce --sort savings + pubcli compare --zip 33101 --bogo --json`, + RunE: runCompare, +} + +func init() { + rootCmd.AddCommand(compareCmd) + + registerDealFilterFlags(compareCmd.Flags()) + compareCmd.Flags().IntVar(&flagCompareCount, "count", 5, "Number of nearby stores to compare (1-10)") +} + +func runCompare(cmd *cobra.Command, _ []string) error { + if err := validateSortMode(); err != nil { + return err + } + if flagZip == "" { + return invalidArgsError( + "--zip is required for compare", + "pubcli compare --zip 33101", + "pubcli compare --zip 33101 --category produce", + ) + } + if flagCompareCount < 1 || flagCompareCount > 10 { + return invalidArgsError( + "--count must be between 1 and 10", + "pubcli compare --zip 33101 --count 5", + ) + } + + client := api.NewClient() + stores, err := client.FetchStores(cmd.Context(), flagZip, flagCompareCount) + 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.", + ) + } + + results := make([]compareStoreResult, 0, len(stores)) + errCount := 0 + for _, store := range stores { + storeNumber := api.StoreNumber(store.Key) + resp, fetchErr := client.FetchSavings(cmd.Context(), storeNumber) + if fetchErr != nil { + errCount++ + continue + } + + items := filter.Apply(resp.Savings, filter.Options{ + BOGO: flagBogo, + Category: flagCategory, + Department: flagDepartment, + Query: flagQuery, + Sort: flagSort, + Limit: flagLimit, + }) + if len(items) == 0 { + continue + } + + bogoDeals := 0 + score := 0.0 + for _, item := range items { + if filter.ContainsIgnoreCase(item.Categories, "bogo") { + bogoDeals++ + } + score += filter.DealScore(item) + } + + results = append(results, compareStoreResult{ + Number: storeNumber, + Name: store.Name, + City: store.City, + State: store.State, + Distance: strings.TrimSpace(store.Distance), + MatchedDeals: len(items), + BogoDeals: bogoDeals, + Score: score, + TopDeal: topDealTitle(items[0]), + }) + } + + if len(results) == 0 { + if errCount == len(stores) { + return upstreamError("fetching deals", fmt.Errorf("all %d store lookups failed", len(stores))) + } + return notFoundError( + "no stores have deals matching your filters", + "Relax filters like --category/--department/--query.", + ) + } + + sort.SliceStable(results, func(i, j int) bool { + if results[i].MatchedDeals != results[j].MatchedDeals { + return results[i].MatchedDeals > results[j].MatchedDeals + } + if results[i].Score != results[j].Score { + return results[i].Score > results[j].Score + } + return parseDistance(results[i].Distance) < parseDistance(results[j].Distance) + }) + for i := range results { + results[i].Rank = i + 1 + } + + if flagJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(results) + } + + fmt.Fprintf(cmd.OutOrStdout(), "\nStore comparison near %s (%d matching store(s))\n\n", flagZip, len(results)) + for _, r := range results { + fmt.Fprintf( + cmd.OutOrStdout(), + "%d. #%s %s (%s, %s)\n matches: %d | bogo: %d | score: %.1f | distance: %s mi\n top: %s\n\n", + r.Rank, + r.Number, + r.Name, + r.City, + r.State, + r.MatchedDeals, + r.BogoDeals, + r.Score, + emptyIf(r.Distance, "?"), + r.TopDeal, + ) + } + if errCount > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "note: skipped %d store(s) due to upstream fetch errors.\n", errCount) + } + return nil +} + +func topDealTitle(item api.SavingItem) string { + if title := filter.CleanText(filter.Deref(item.Title)); title != "" { + return title + } + if desc := filter.CleanText(filter.Deref(item.Description)); desc != "" { + return desc + } + if item.ID != "" { + return "Deal " + item.ID + } + return "Untitled deal" +} + +func parseDistance(raw string) float64 { + for _, token := range strings.Fields(raw) { + clean := strings.Trim(token, ",") + if d, err := strconv.ParseFloat(clean, 64); err == nil { + return d + } + } + return 999999 +} + +func emptyIf(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} diff --git a/cmd/robot_mode.go b/cmd/robot_mode.go index 1548a39..610200a 100644 --- a/cmd/robot_mode.go +++ b/cmd/robot_mode.go @@ -240,12 +240,12 @@ func shouldAutoJSON(args []string, stdoutIsTTY bool) bool { // 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 + 's': true, // --store + 'z': true, // --zip + 'c': true, // --category + 'd': true, // --department + 'q': true, // --query + 'n': true, // --limit } func firstCommand(args []string) string { @@ -299,7 +299,7 @@ func printQuickStart(w io.Writer, asJSON bool) error { _, err := fmt.Fprintf( w, - "%s\nusage: %s\nexamples:\n %s\n %s\n %s\nflags: --zip --store --json --bogo --category --department --query --limit\n", + "%s\nusage: %s\nexamples:\n %s\n %s\n %s\nflags: --zip --store --json --bogo --category --department --query --sort --limit\n", help.Name, help.Usage, help.Examples[0], diff --git a/cmd/root.go b/cmd/root.go index 264ed72..e39fd9e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,8 +4,10 @@ import ( "fmt" "io" "os" + "strings" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/tayloree/publix-deals/internal/api" "github.com/tayloree/publix-deals/internal/display" "github.com/tayloree/publix-deals/internal/filter" @@ -18,6 +20,7 @@ var ( flagDepartment string flagBogo bool flagQuery string + flagSort string flagLimit int flagJSON bool ) @@ -31,8 +34,10 @@ var rootCmd = &cobra.Command{ "(for example: -zip 33101, zip=33101, --ziip 33101).", Example: ` pubcli --zip 33101 pubcli --store 1425 --bogo + pubcli --zip 33101 --sort savings pubcli categories --zip 33101 - pubcli stores --zip 33101 --json`, + pubcli stores --zip 33101 --json + pubcli compare --zip 33101 --category produce`, RunE: runDeals, } @@ -45,12 +50,7 @@ func init() { 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)") + registerDealFilterFlags(rootCmd.Flags()) } // Execute runs the root command. @@ -112,10 +112,34 @@ func resetCLIState() { flagDepartment = "" flagBogo = false flagQuery = "" + flagSort = "" flagLimit = 0 + flagCompareCount = 5 flagJSON = false } +func registerDealFilterFlags(f *pflag.FlagSet) { + 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.StringVar(&flagSort, "sort", "", "Sort deals by relevance, savings, or ending") + f.IntVarP(&flagLimit, "limit", "n", 0, "Limit number of results (0 = all)") +} + +func validateSortMode() error { + switch strings.ToLower(strings.TrimSpace(flagSort)) { + case "", "relevance", "savings", "ending", "end", "expiry", "expiration": + return nil + default: + return invalidArgsError( + "invalid value for --sort (use relevance, savings, or ending)", + "pubcli --zip 33101 --sort savings", + "pubcli --zip 33101 --sort ending", + ) + } +} + func resolveStore(cmd *cobra.Command, client *api.Client) (string, error) { if flagStore != "" { return flagStore, nil @@ -147,6 +171,10 @@ func resolveStore(cmd *cobra.Command, client *api.Client) (string, error) { } func runDeals(cmd *cobra.Command, _ []string) error { + if err := validateSortMode(); err != nil { + return err + } + client := api.NewClient() storeNumber, err := resolveStore(cmd, client) @@ -172,6 +200,7 @@ func runDeals(cmd *cobra.Command, _ []string) error { Category: flagCategory, Department: flagDepartment, Query: flagQuery, + Sort: flagSort, Limit: flagLimit, }) diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index 1543eab..a8e87d3 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -30,6 +30,17 @@ func TestRunCLI_HelpStores(t *testing.T) { assert.Empty(t, stderr.String()) } +func TestRunCLI_HelpCompare(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runCLI([]string{"help", "compare"}, &stdout, &stderr) + + assert.Equal(t, 0, code) + assert.Contains(t, stdout.String(), "pubcli compare [flags]") + assert.Empty(t, stderr.String()) +} + func TestRunCLI_TolerantRewriteWithoutNetworkCall(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/cmd/tui.go b/cmd/tui.go new file mode 100644 index 0000000..d762a4c --- /dev/null +++ b/cmd/tui.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "bufio" + "fmt" + "io" + "os" + "strconv" + "strings" + + "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" + "golang.org/x/term" +) + +const tuiPageSize = 10 + +var tuiCmd = &cobra.Command{ + Use: "tui", + Short: "Browse deals interactively in the terminal", + Example: ` pubcli tui --zip 33101 + pubcli tui --store 1425 --category produce --sort ending`, + RunE: runTUI, +} + +func init() { + rootCmd.AddCommand(tuiCmd) + registerDealFilterFlags(tuiCmd.Flags()) +} + +func runTUI(cmd *cobra.Command, _ []string) error { + if err := validateSortMode(); err != nil { + return err + } + if !flagJSON && !isInteractiveSession(cmd.InOrStdin(), cmd.OutOrStdout()) { + return invalidArgsError( + "`pubcli tui` requires an interactive terminal", + "Use `pubcli --zip 33101 --json` in pipelines.", + ) + } + + client := api.NewClient() + storeNumber, err := resolveStore(cmd, client) + if err != nil { + return err + } + + resp, err := client.FetchSavings(cmd.Context(), storeNumber) + if err != nil { + return upstreamError("fetching deals", err) + } + if len(resp.Savings) == 0 { + return notFoundError( + fmt.Sprintf("no deals found for store #%s", storeNumber), + "Try another store with --store.", + ) + } + + items := filter.Apply(resp.Savings, filter.Options{ + BOGO: flagBogo, + Category: flagCategory, + Department: flagDepartment, + Query: flagQuery, + Sort: flagSort, + 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) + } + + return runTUILoop(cmd.OutOrStdout(), cmd.InOrStdin(), storeNumber, items) +} + +func isInteractiveSession(stdin io.Reader, stdout io.Writer) bool { + inputFile, ok := stdin.(*os.File) + if !ok { + return false + } + if !term.IsTerminal(int(inputFile.Fd())) { + return false + } + return isTTY(stdout) +} + +func runTUILoop(out io.Writer, in io.Reader, storeNumber string, items []api.SavingItem) error { + reader := bufio.NewReader(in) + page := 0 + totalPages := (len(items)-1)/tuiPageSize + 1 + + for { + renderTUIPage(out, storeNumber, items, page, totalPages) + + line, err := reader.ReadString('\n') + if err != nil { + return nil + } + cmd := strings.TrimSpace(strings.ToLower(line)) + + switch cmd { + case "q", "quit", "exit": + return nil + case "n", "next": + if page < totalPages-1 { + page++ + } + case "p", "prev", "previous": + if page > 0 { + page-- + } + default: + idx, convErr := strconv.Atoi(cmd) + if convErr != nil || idx < 1 || idx > len(items) { + continue + } + renderDealDetail(out, items[idx-1], idx, len(items)) + if _, err := reader.ReadString('\n'); err != nil { + return nil + } + } + } +} + +func renderTUIPage(out io.Writer, storeNumber string, items []api.SavingItem, page, totalPages int) { + fmt.Fprint(out, "\033[H\033[2J") + fmt.Fprintf(out, "pubcli tui | store #%s | %d deals | page %d/%d\n\n", storeNumber, len(items), page+1, totalPages) + + start := page * tuiPageSize + end := minInt(start+tuiPageSize, len(items)) + for i := start; i < end; i++ { + item := items[i] + title := topDealTitle(item) + savings := filter.CleanText(filter.Deref(item.Savings)) + if savings == "" { + savings = "-" + } + fmt.Fprintf(out, "%2d. %s [%s]\n", i+1, title, savings) + } + fmt.Fprintf(out, "\ncommands: number=details | n=next | p=prev | q=quit\n> ") +} + +func renderDealDetail(out io.Writer, item api.SavingItem, index, total int) { + fmt.Fprint(out, "\033[H\033[2J") + fmt.Fprintf(out, "deal %d/%d\n\n", index, total) + display.PrintDeals(out, []api.SavingItem{item}) + fmt.Fprint(out, "press Enter to return\n") +}