Compare commits
11 Commits
4f483c82e5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e3d2e9306b | |||
| da327fe759 | |||
| f486150c06 | |||
| 28479071ae | |||
| 11a90d2fd1 | |||
| 7dd963141a | |||
| eb2328b768 | |||
| b91c44c4ed | |||
| 53d65c2148 | |||
| df0af4a5f8 | |||
| 4310375dc9 |
38
AGENTS.md
38
AGENTS.md
@@ -2,16 +2,52 @@
|
||||
|
||||
The `pubcli` CLI is intentionally tolerant of minor syntax mistakes when intent is clear.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose | Requires |
|
||||
|---------|---------|----------|
|
||||
| `pubcli` | Fetch deals | `--store` or `--zip` |
|
||||
| `pubcli stores` | List nearby stores | `--zip` |
|
||||
| `pubcli categories` | List categories with counts | `--store` or `--zip` |
|
||||
| `pubcli compare` | Rank nearby stores by deal quality | `--zip` |
|
||||
| `pubcli tui` | Interactive deal browser | `--store` or `--zip`, interactive terminal |
|
||||
|
||||
## Input Tolerance
|
||||
|
||||
Accepted flexible forms include:
|
||||
- `-zip 33101` -> interpreted as `--zip 33101`
|
||||
- `zip=33101` -> interpreted as `--zip=33101`
|
||||
- `--ziip 33101` -> interpreted as `--zip 33101`
|
||||
- `categoriess` -> interpreted as `categories`
|
||||
|
||||
Flag aliases: `zipcode`/`postal-code` -> `--zip`, `dept` -> `--department`, `search` -> `--query`, `sortby`/`orderby` -> `--sort`, `max` -> `--limit`.
|
||||
|
||||
The CLI prints a `note:` line when it auto-corrects input. Use canonical syntax in future commands:
|
||||
- `pubcli --zip 33101`
|
||||
- `pubcli --store 1425 --bogo`
|
||||
- `pubcli categories --zip 33101`
|
||||
- `pubcli stores --zip 33101 --json`
|
||||
- `pubcli compare --zip 33101 --category produce`
|
||||
- `pubcli compare --zip 33101 --bogo --count 3 --json`
|
||||
|
||||
When intent is unclear, errors include a direct explanation and relevant examples.
|
||||
## Filtering and Sorting
|
||||
|
||||
Deal filter flags (`--bogo`, `--category`, `--department`, `--query`, `--sort`, `--limit`) are available on `pubcli`, `compare`, and `tui`.
|
||||
|
||||
Sort accepts: `relevance` (default), `savings`, `ending`. Aliases `end`, `expiry`, `expiration` map to `ending`.
|
||||
|
||||
Category synonyms: `veggies` -> `produce`, `chicken` -> `meat`, `bread` -> `bakery`, `cheese` -> `dairy`, `cold cuts` -> `deli`, etc.
|
||||
|
||||
## Auto JSON
|
||||
|
||||
When stdout is not a TTY, JSON output is enabled automatically. This means piping to `jq` or another process produces JSON without requiring `--json`.
|
||||
|
||||
## Errors
|
||||
|
||||
When intent is unclear, errors include a direct explanation and relevant examples. In JSON mode, errors are structured:
|
||||
|
||||
```json
|
||||
{"error":{"code":"INVALID_ARGS","message":"...","suggestions":["..."],"exitCode":2}}
|
||||
```
|
||||
|
||||
Exit codes: `0` success, `1` not found, `2` invalid args, `3` upstream error, `4` internal error.
|
||||
|
||||
103
README.md
103
README.md
@@ -7,7 +7,10 @@
|
||||
- Fetch weekly ad deals for a specific store
|
||||
- Resolve nearest Publix store from a ZIP code
|
||||
- Filter deals by category, department, keyword, and BOGO status
|
||||
- Sort deals by estimated savings or ending date
|
||||
- List weekly categories with deal counts
|
||||
- Compare nearby stores for best filtered deal coverage
|
||||
- Browse deals interactively in terminal (`tui`)
|
||||
- Output data as formatted terminal text or JSON
|
||||
- Generate shell completions (`bash`, `zsh`, `fish`, `powershell`)
|
||||
- Tolerate minor CLI syntax mistakes when intent is clear
|
||||
@@ -90,33 +93,96 @@ pubcli categories --store 1425
|
||||
pubcli categories -z 33101 --json
|
||||
```
|
||||
|
||||
### `pubcli compare`
|
||||
|
||||
Compare nearby stores and rank them by filtered deal quality. Requires `--zip`. Stores are ranked by number of matched deals, then deal score, then distance.
|
||||
|
||||
```bash
|
||||
pubcli compare --zip 33101
|
||||
pubcli compare --zip 33101 --category produce --sort savings
|
||||
pubcli compare --zip 33101 --bogo --count 3 --json
|
||||
```
|
||||
|
||||
### `pubcli tui`
|
||||
|
||||
Full-screen interactive browser for deal lists with a responsive two-pane layout:
|
||||
- async startup loading spinner + skeleton while store/deals are fetched
|
||||
- visual deal sections (BOGO/category grouped) with jump navigation
|
||||
|
||||
Controls:
|
||||
|
||||
- `tab` — switch focus between list and detail panes
|
||||
- `/` — fuzzy filter deals in the list pane
|
||||
- `s` — cycle sort mode (`relevance` -> `savings` -> `ending`)
|
||||
- `g` — toggle BOGO-only inline filter
|
||||
- `c` — cycle category inline filter
|
||||
- `a` — cycle department inline filter
|
||||
- `l` — cycle result limit inline filter
|
||||
- `r` — reset inline sort/filter options back to CLI-start defaults
|
||||
- `j` / `k` or arrows — navigate list and scroll detail
|
||||
- `u` / `d` — half-page detail scroll
|
||||
- `b` / `f` or `pgup` / `pgdown` — full-page detail scroll
|
||||
- `[` / `]` — jump to previous/next section
|
||||
- `1..9` — jump directly to a numbered section
|
||||
- `?` — toggle inline help
|
||||
- `q` — quit
|
||||
|
||||
```bash
|
||||
pubcli tui --zip 33101
|
||||
pubcli tui --store 1425 --category meat --sort ending
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
Global flags:
|
||||
Global flags (available on all commands):
|
||||
|
||||
- `-s, --store string` Publix store number (example: `1425`)
|
||||
- `-z, --zip string` ZIP code for store lookup
|
||||
- `--json` Output JSON instead of styled terminal output
|
||||
|
||||
Deal filtering flags (`pubcli` root command):
|
||||
Deal filtering flags (available on `pubcli`, `compare`, and `tui`):
|
||||
|
||||
- `--bogo` Show only BOGO deals
|
||||
- `-c, --category string` Filter by category (example: `bogo`, `meat`, `produce`)
|
||||
- `-d, --department string` Filter by department (substring match, case-insensitive)
|
||||
- `-q, --query string` Search title/description (case-insensitive)
|
||||
- `--sort string` Sort by `relevance` (default), `savings`, or `ending`
|
||||
- `-n, --limit int` Limit results (`0` means no limit)
|
||||
|
||||
Compare-specific flags:
|
||||
|
||||
- `--count int` Number of nearby stores to compare, 1-10 (default `5`)
|
||||
|
||||
Sort accepts aliases: `end`, `expiry`, and `expiration` are equivalent to `ending`.
|
||||
|
||||
## Behavior Notes
|
||||
|
||||
- Either `--store` or `--zip` is required for deal and category lookups.
|
||||
- Either `--store` or `--zip` is required for deal and category lookups. `compare` requires `--zip`.
|
||||
- If only `--zip` is provided, the nearest store is selected automatically.
|
||||
- When using text output and ZIP-based store resolution, the selected store is shown.
|
||||
- Filtering is applied in this order: `bogo`, `category`, `department`, `query`, `limit`.
|
||||
- Category matching is exact and case-insensitive.
|
||||
- Filtering is applied in this order: `bogo` + `category`, `department`, `query`, `sort`, `limit`.
|
||||
- Category matching is case-insensitive and supports synonym groups (see below).
|
||||
- Department and query filters use case-insensitive substring matching.
|
||||
- Running `pubcli` with no args prints compact quick-start help.
|
||||
- When stdout is not a TTY (for example piping to another process), JSON output is enabled automatically unless explicitly set.
|
||||
|
||||
### Category Synonyms
|
||||
|
||||
Category filtering recognizes synonyms so common names map to the right deals:
|
||||
|
||||
| Category | Also matches |
|
||||
|----------|-------------|
|
||||
| `bogo` | `bogof`, `buy one get one`, `buy1get1`, `2 for 1`, `two for one` |
|
||||
| `produce` | `fruit`, `fruits`, `vegetable`, `vegetables`, `veggie`, `veggies` |
|
||||
| `meat` | `beef`, `chicken`, `poultry`, `pork`, `seafood` |
|
||||
| `dairy` | `milk`, `cheese`, `yogurt` |
|
||||
| `bakery` | `bread`, `pastry`, `pastries` |
|
||||
| `deli` | `delicatessen`, `cold cuts`, `lunch meat` |
|
||||
| `frozen` | `frozen foods` |
|
||||
| `grocery` | `pantry`, `shelf` |
|
||||
|
||||
Synonym matching is bidirectional — using `chicken` as a category filter matches deals tagged `meat`, and vice versa.
|
||||
|
||||
## CLI Input Tolerance
|
||||
|
||||
The CLI auto-corrects common input mistakes and prints a `note:` describing the normalization:
|
||||
@@ -125,6 +191,18 @@ The CLI auto-corrects common input mistakes and prints a `note:` describing the
|
||||
- `zip=33101` -> `--zip=33101`
|
||||
- `--ziip 33101` -> `--zip 33101`
|
||||
- `stores zip 33101` -> `stores --zip 33101`
|
||||
- `categoriess` -> `categories`
|
||||
|
||||
Flag aliases are recognized and rewritten:
|
||||
|
||||
| Alias | Resolves to |
|
||||
|-------|------------|
|
||||
| `zipcode`, `postal-code` | `--zip` |
|
||||
| `store-number`, `storeno` | `--store` |
|
||||
| `dept` | `--department` |
|
||||
| `search` | `--query` |
|
||||
| `sortby`, `orderby` | `--sort` |
|
||||
| `max` | `--limit` |
|
||||
|
||||
Command argument tokens are preserved for command workflows like:
|
||||
|
||||
@@ -170,6 +248,21 @@ Object map of category name to deal count:
|
||||
}
|
||||
```
|
||||
|
||||
### Compare (`pubcli compare ... --json`)
|
||||
|
||||
Array of objects ranked by deal quality:
|
||||
|
||||
- `rank` (number)
|
||||
- `number` (string) — store number
|
||||
- `name` (string)
|
||||
- `city` (string)
|
||||
- `state` (string)
|
||||
- `distance` (string)
|
||||
- `matchedDeals` (number)
|
||||
- `bogoDeals` (number)
|
||||
- `score` (number)
|
||||
- `topDeal` (string)
|
||||
|
||||
## Structured Errors
|
||||
|
||||
When command execution fails, errors include:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
198
cmd/compare.go
Normal file
198
cmd/compare.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -175,10 +175,9 @@ func classifyCLIError(err error) *cliError {
|
||||
}
|
||||
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, "decoding response"),
|
||||
strings.Contains(lowerMsg, "fetching deals"),
|
||||
strings.Contains(lowerMsg, "fetching savings"),
|
||||
strings.Contains(lowerMsg, "fetching stores"),
|
||||
strings.Contains(lowerMsg, "finding stores"):
|
||||
return &cliError{
|
||||
@@ -299,7 +298,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],
|
||||
|
||||
43
cmd/root.go
43
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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
150
cmd/tui.go
Normal file
150
cmd/tui.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"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"
|
||||
)
|
||||
|
||||
var tuiCmd = &cobra.Command{
|
||||
Use: "tui",
|
||||
Short: "Browse deals in a full-screen interactive terminal UI",
|
||||
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
|
||||
}
|
||||
|
||||
initialOpts := filter.Options{
|
||||
BOGO: flagBogo,
|
||||
Category: flagCategory,
|
||||
Department: flagDepartment,
|
||||
Query: flagQuery,
|
||||
Sort: flagSort,
|
||||
Limit: flagLimit,
|
||||
}
|
||||
|
||||
if flagJSON {
|
||||
_, _, rawItems, err := loadTUIData(cmd.Context(), flagStore, flagZip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := filter.Apply(rawItems, initialOpts)
|
||||
if len(items) == 0 {
|
||||
return notFoundError(
|
||||
"no deals match your filters",
|
||||
"Relax filters like --category/--department/--query.",
|
||||
)
|
||||
}
|
||||
return display.PrintDealsJSON(cmd.OutOrStdout(), items)
|
||||
}
|
||||
|
||||
if !isInteractiveSession(cmd.InOrStdin(), cmd.OutOrStdout()) {
|
||||
return invalidArgsError(
|
||||
"`pubcli tui` requires an interactive terminal",
|
||||
"Use `pubcli --zip 33101 --json` in pipelines.",
|
||||
)
|
||||
}
|
||||
|
||||
model := newLoadingDealsTUIModel(tuiLoadConfig{
|
||||
ctx: cmd.Context(),
|
||||
storeNumber: flagStore,
|
||||
zipCode: flagZip,
|
||||
initialOpts: initialOpts,
|
||||
})
|
||||
|
||||
program := tea.NewProgram(
|
||||
model,
|
||||
tea.WithAltScreen(),
|
||||
tea.WithInput(cmd.InOrStdin()),
|
||||
tea.WithOutput(cmd.OutOrStdout()),
|
||||
)
|
||||
|
||||
finalModel, err := program.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("running tui: %w", err)
|
||||
}
|
||||
if finalState, ok := finalModel.(dealsTUIModel); ok && finalState.fatalErr != nil {
|
||||
return finalState.fatalErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveStoreForTUI(ctx context.Context, client *api.Client, storeNumber, zipCode string) (resolvedStoreNumber, storeLabel string, err error) {
|
||||
if storeNumber != "" {
|
||||
return storeNumber, "#" + storeNumber, nil
|
||||
}
|
||||
if zipCode == "" {
|
||||
return "", "", invalidArgsError(
|
||||
"please provide --store NUMBER or --zip ZIPCODE",
|
||||
"pubcli tui --zip 33101",
|
||||
"pubcli tui --store 1425",
|
||||
)
|
||||
}
|
||||
|
||||
stores, err := client.FetchStores(ctx, zipCode, 1)
|
||||
if err != nil {
|
||||
return "", "", upstreamError("finding stores", err)
|
||||
}
|
||||
if len(stores) == 0 {
|
||||
return "", "", notFoundError(
|
||||
fmt.Sprintf("no Publix stores found near %s", zipCode),
|
||||
"Try a nearby ZIP code.",
|
||||
)
|
||||
}
|
||||
|
||||
store := stores[0]
|
||||
resolvedStoreNumber = api.StoreNumber(store.Key)
|
||||
storeLabel = fmt.Sprintf("#%s — %s (%s, %s)", resolvedStoreNumber, store.Name, store.City, store.State)
|
||||
return resolvedStoreNumber, storeLabel, nil
|
||||
}
|
||||
|
||||
func loadTUIData(ctx context.Context, storeNumber, zipCode string) (resolvedStoreNumber, storeLabel string, items []api.SavingItem, err error) {
|
||||
client := api.NewClient()
|
||||
|
||||
resolvedStoreNumber, storeLabel, err = resolveStoreForTUI(ctx, client, storeNumber, zipCode)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
resp, err := client.FetchSavings(ctx, resolvedStoreNumber)
|
||||
if err != nil {
|
||||
return "", "", nil, upstreamError("fetching deals", err)
|
||||
}
|
||||
if len(resp.Savings) == 0 {
|
||||
return "", "", nil, notFoundError(
|
||||
fmt.Sprintf("no deals found for store #%s", resolvedStoreNumber),
|
||||
"Try another store with --store.",
|
||||
)
|
||||
}
|
||||
|
||||
return resolvedStoreNumber, storeLabel, resp.Savings, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
1128
cmd/tui_model.go
Normal file
1128
cmd/tui_model.go
Normal file
File diff suppressed because it is too large
Load Diff
62
cmd/tui_model_test.go
Normal file
62
cmd/tui_model_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tayloree/publix-deals/internal/api"
|
||||
)
|
||||
|
||||
func strPtr(value string) *string { return &value }
|
||||
|
||||
func TestCanonicalSortMode(t *testing.T) {
|
||||
assert.Equal(t, "savings", canonicalSortMode("savings"))
|
||||
assert.Equal(t, "ending", canonicalSortMode("end"))
|
||||
assert.Equal(t, "ending", canonicalSortMode("expiry"))
|
||||
assert.Equal(t, "ending", canonicalSortMode("expiration"))
|
||||
assert.Equal(t, "", canonicalSortMode("relevance"))
|
||||
assert.Equal(t, "", canonicalSortMode("unknown"))
|
||||
}
|
||||
|
||||
func TestBuildGroupedListItems_BogoFirstAndNumberedHeaders(t *testing.T) {
|
||||
deals := []api.SavingItem{
|
||||
{ID: "1", Title: strPtr("Bananas"), Categories: []string{"produce"}},
|
||||
{ID: "2", Title: strPtr("Chicken"), Categories: []string{"meat", "bogo"}},
|
||||
{ID: "3", Title: strPtr("Apples"), Categories: []string{"produce"}},
|
||||
{ID: "4", Title: strPtr("Ground Beef"), Categories: []string{"meat"}},
|
||||
}
|
||||
|
||||
items, starts := buildGroupedListItems(deals)
|
||||
|
||||
assert.NotEmpty(t, items)
|
||||
assert.Equal(t, []int{0, 2, 5}, starts)
|
||||
|
||||
header, ok := items[0].(tuiGroupItem)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "BOGO", header.name)
|
||||
assert.Equal(t, 1, header.ordinal)
|
||||
|
||||
header2, ok := items[2].(tuiGroupItem)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Produce", header2.name)
|
||||
assert.Equal(t, 2, header2.count)
|
||||
|
||||
header3, ok := items[5].(tuiGroupItem)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Meat", header3.name)
|
||||
assert.Equal(t, 1, header3.count)
|
||||
}
|
||||
|
||||
func TestBuildCategoryChoices_AlwaysIncludesCurrent(t *testing.T) {
|
||||
deals := []api.SavingItem{
|
||||
{Categories: []string{"produce"}},
|
||||
{Categories: []string{"meat"}},
|
||||
}
|
||||
|
||||
choices := buildCategoryChoices(deals, "seafood")
|
||||
|
||||
assert.Contains(t, choices, "")
|
||||
assert.Contains(t, choices, "produce")
|
||||
assert.Contains(t, choices, "meat")
|
||||
assert.Contains(t, choices, "seafood")
|
||||
}
|
||||
28
go.mod
28
go.mod
@@ -3,28 +3,40 @@ module github.com/tayloree/publix-deals
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/term v0.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
64
go.sum
64
go.sum
@@ -1,34 +1,61 @@
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
@@ -38,13 +65,16 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -42,10 +43,10 @@ func NewClientWithBaseURLs(savingsURL, storeURL string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, reqURL, storeNumber string) ([]byte, error) {
|
||||
func (c *Client) getAndDecode(ctx context.Context, reqURL, storeNumber string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
@@ -56,19 +57,22 @@ func (c *Client) get(ctx context.Context, reqURL, storeNumber string) ([]byte, e
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("executing request: %w", err)
|
||||
return fmt.Errorf("executing request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, reqURL)
|
||||
return fmt.Errorf("unexpected status %d from %s", resp.StatusCode, reqURL)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", err)
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
if err := dec.Decode(out); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
return body, nil
|
||||
if err := dec.Decode(new(struct{})); !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("decoding response: trailing JSON content")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchStores finds Publix stores near the given zip code.
|
||||
@@ -81,14 +85,9 @@ func (c *Client) FetchStores(ctx context.Context, zipCode string, count int) ([]
|
||||
"zipCode": {zipCode},
|
||||
}
|
||||
|
||||
body, err := c.get(ctx, c.storeURL+"?"+params.Encode(), "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching stores: %w", err)
|
||||
}
|
||||
|
||||
var resp StoreResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("decoding stores: %w", err)
|
||||
if err := c.getAndDecode(ctx, c.storeURL+"?"+params.Encode(), "", &resp); err != nil {
|
||||
return nil, fmt.Errorf("fetching stores: %w", err)
|
||||
}
|
||||
return resp.Stores, nil
|
||||
}
|
||||
@@ -104,14 +103,9 @@ func (c *Client) FetchSavings(ctx context.Context, storeNumber string) (*Savings
|
||||
"getSavingType": {"WeeklyAd"},
|
||||
}
|
||||
|
||||
body, err := c.get(ctx, c.savingsURL+"?"+params.Encode(), storeNumber)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching savings: %w", err)
|
||||
}
|
||||
|
||||
var resp SavingsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("decoding savings: %w", err)
|
||||
if err := c.getAndDecode(ctx, c.savingsURL+"?"+params.Encode(), storeNumber, &resp); err != nil {
|
||||
return nil, fmt.Errorf("fetching savings: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
@@ -127,6 +127,34 @@ func TestFetchStores_NoResults(t *testing.T) {
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestFetchSavings_TrailingJSONIsRejected(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"Savings":[],"LanguageId":1} {"extra":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := api.NewClientWithBaseURLs(srv.URL, "")
|
||||
_, err := client.FetchSavings(context.Background(), "1425")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decoding")
|
||||
}
|
||||
|
||||
func TestFetchStores_MalformedJSONReturnsDecodeError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"Stores":`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := api.NewClientWithBaseURLs("", srv.URL)
|
||||
_, err := client.FetchStores(context.Background(), "37042", 5)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decoding")
|
||||
}
|
||||
|
||||
func TestStoreNumber(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
@@ -151,10 +151,7 @@ func PrintWarning(w io.Writer, msg string) {
|
||||
}
|
||||
|
||||
func printDeal(w io.Writer, item api.SavingItem) {
|
||||
title := filter.CleanText(filter.Deref(item.Title))
|
||||
if title == "" {
|
||||
title = "Unknown"
|
||||
}
|
||||
title := fallbackDealTitle(item)
|
||||
savings := filter.CleanText(filter.Deref(item.Savings))
|
||||
desc := filter.CleanText(filter.Deref(item.Description))
|
||||
dept := filter.CleanText(filter.Deref(item.Department))
|
||||
@@ -198,6 +195,37 @@ func printDeal(w io.Writer, item api.SavingItem) {
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackDealTitle(item api.SavingItem) string {
|
||||
if title := filter.CleanText(filter.Deref(item.Title)); title != "" {
|
||||
return title
|
||||
}
|
||||
|
||||
brand := filter.CleanText(filter.Deref(item.Brand))
|
||||
dept := filter.CleanText(filter.Deref(item.Department))
|
||||
switch {
|
||||
case brand != "" && dept != "":
|
||||
return fmt.Sprintf("%s deal (%s)", brand, dept)
|
||||
case brand != "":
|
||||
return brand + " deal"
|
||||
case dept != "":
|
||||
return dept + " deal"
|
||||
}
|
||||
|
||||
if desc := filter.CleanText(filter.Deref(item.Description)); desc != "" {
|
||||
const max = 48
|
||||
if len(desc) > max {
|
||||
return desc[:max-3] + "..."
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
if item.ID != "" {
|
||||
return "Deal " + item.ID
|
||||
}
|
||||
|
||||
return "Untitled deal"
|
||||
}
|
||||
|
||||
func toDealJSON(item api.SavingItem) DealJSON {
|
||||
categories := item.Categories
|
||||
if categories == nil {
|
||||
|
||||
@@ -55,6 +55,41 @@ func TestPrintDeals_ContainsExpectedContent(t *testing.T) {
|
||||
assert.NotContains(t, output, "&")
|
||||
}
|
||||
|
||||
func TestPrintDeals_FallbackTitleFromBrandAndDepartment(t *testing.T) {
|
||||
items := []api.SavingItem{
|
||||
{
|
||||
ID: "fallback-1",
|
||||
Title: nil,
|
||||
Brand: ptr("Publix"),
|
||||
Department: ptr("Meat"),
|
||||
Categories: []string{"meat"},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
display.PrintDeals(&buf, items)
|
||||
output := buf.String()
|
||||
|
||||
assert.Contains(t, output, "Publix deal (Meat)")
|
||||
assert.NotContains(t, output, "Unknown")
|
||||
}
|
||||
|
||||
func TestPrintDeals_FallbackTitleFromID(t *testing.T) {
|
||||
items := []api.SavingItem{
|
||||
{
|
||||
ID: "fallback-2",
|
||||
Title: nil,
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
display.PrintDeals(&buf, items)
|
||||
output := buf.String()
|
||||
|
||||
assert.Contains(t, output, "Deal fallback-2")
|
||||
assert.NotContains(t, output, "Unknown")
|
||||
}
|
||||
|
||||
func TestPrintDealsJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := display.PrintDealsJSON(&buf, sampleDeals())
|
||||
|
||||
119
internal/filter/category_synonyms.go
Normal file
119
internal/filter/category_synonyms.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package filter
|
||||
|
||||
import "strings"
|
||||
|
||||
var categorySynonyms = map[string][]string{
|
||||
"bogo": {"bogof", "buy one get one", "buy1get1", "2 for 1", "two for one"},
|
||||
"produce": {"fruit", "fruits", "vegetable", "vegetables", "veggie", "veggies"},
|
||||
"meat": {"beef", "chicken", "poultry", "pork", "seafood"},
|
||||
"dairy": {"milk", "cheese", "yogurt"},
|
||||
"bakery": {"bread", "pastry", "pastries"},
|
||||
"deli": {"delicatessen", "cold cuts", "lunch meat"},
|
||||
"frozen": {"frozen foods"},
|
||||
"grocery": {"pantry", "shelf"},
|
||||
}
|
||||
|
||||
type categoryMatcher struct {
|
||||
exactAliases []string
|
||||
normalized map[string]struct{}
|
||||
}
|
||||
|
||||
func newCategoryMatcher(wanted string) categoryMatcher {
|
||||
aliases := categoryAliasList(wanted)
|
||||
if len(aliases) == 0 {
|
||||
return categoryMatcher{}
|
||||
}
|
||||
|
||||
normalized := make(map[string]struct{}, len(aliases))
|
||||
for _, alias := range aliases {
|
||||
normalized[normalizeCategory(alias)] = struct{}{}
|
||||
}
|
||||
|
||||
return categoryMatcher{
|
||||
exactAliases: aliases,
|
||||
normalized: normalized,
|
||||
}
|
||||
}
|
||||
|
||||
func categoryAliasList(wanted string) []string {
|
||||
raw := strings.TrimSpace(wanted)
|
||||
group := resolveCategoryGroup(wanted)
|
||||
if raw == "" && group == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, 1+len(categorySynonyms[group]))
|
||||
addAlias := func(alias string) {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias == "" {
|
||||
return
|
||||
}
|
||||
for _, existing := range out {
|
||||
if strings.EqualFold(existing, alias) {
|
||||
return
|
||||
}
|
||||
}
|
||||
out = append(out, alias)
|
||||
}
|
||||
|
||||
addAlias(raw)
|
||||
addAlias(group)
|
||||
|
||||
if synonyms, ok := categorySynonyms[group]; ok {
|
||||
for _, s := range synonyms {
|
||||
addAlias(s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveCategoryGroup(wanted string) string {
|
||||
norm := normalizeCategory(wanted)
|
||||
if norm == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if _, ok := categorySynonyms[norm]; ok {
|
||||
return norm
|
||||
}
|
||||
for key, synonyms := range categorySynonyms {
|
||||
for _, s := range synonyms {
|
||||
if normalizeCategory(s) == norm {
|
||||
return key
|
||||
}
|
||||
}
|
||||
}
|
||||
return norm
|
||||
}
|
||||
|
||||
func (m categoryMatcher) matches(category string) bool {
|
||||
trimmed := strings.TrimSpace(category)
|
||||
for _, alias := range m.exactAliases {
|
||||
if strings.EqualFold(trimmed, alias) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
norm := normalizeCategory(trimmed)
|
||||
_, ok := m.normalized[norm]
|
||||
return ok
|
||||
}
|
||||
|
||||
func normalizeCategory(raw string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(raw))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.ContainsAny(s, "_-") {
|
||||
s = strings.ReplaceAll(s, "_", " ")
|
||||
s = strings.ReplaceAll(s, "-", " ")
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
switch {
|
||||
case len(s) > 4 && strings.HasSuffix(s, "ies"):
|
||||
s = s[:len(s)-3] + "y"
|
||||
case len(s) > 3 && strings.HasSuffix(s, "s") && !strings.HasSuffix(s, "ss"):
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package filter
|
||||
|
||||
import (
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tayloree/publix-deals/internal/api"
|
||||
@@ -13,45 +14,88 @@ type Options struct {
|
||||
Category string
|
||||
Department string
|
||||
Query string
|
||||
Sort string
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Apply filters a slice of SavingItems according to the given options.
|
||||
func Apply(items []api.SavingItem, opts Options) []api.SavingItem {
|
||||
result := items
|
||||
wantCategory := opts.Category != ""
|
||||
wantDepartment := opts.Department != ""
|
||||
wantQuery := opts.Query != ""
|
||||
needsFiltering := opts.BOGO || wantCategory || wantDepartment || wantQuery
|
||||
sortMode := normalizeSortMode(opts.Sort)
|
||||
hasSort := sortMode != ""
|
||||
|
||||
if opts.BOGO {
|
||||
result = where(result, func(i api.SavingItem) bool {
|
||||
return ContainsIgnoreCase(i.Categories, "bogo")
|
||||
})
|
||||
if !needsFiltering && !hasSort {
|
||||
if opts.Limit > 0 && opts.Limit < len(items) {
|
||||
return items[:opts.Limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
if opts.Category != "" {
|
||||
result = where(result, func(i api.SavingItem) bool {
|
||||
return ContainsIgnoreCase(i.Categories, opts.Category)
|
||||
})
|
||||
var result []api.SavingItem
|
||||
if opts.Limit > 0 && opts.Limit < len(items) {
|
||||
result = make([]api.SavingItem, 0, opts.Limit)
|
||||
} else {
|
||||
result = make([]api.SavingItem, 0, len(items))
|
||||
}
|
||||
|
||||
if opts.Department != "" {
|
||||
dept := strings.ToLower(opts.Department)
|
||||
result = where(result, func(i api.SavingItem) bool {
|
||||
return strings.Contains(strings.ToLower(Deref(i.Department)), dept)
|
||||
})
|
||||
department := strings.ToLower(opts.Department)
|
||||
query := strings.ToLower(opts.Query)
|
||||
applyLimitWhileFiltering := !hasSort && opts.Limit > 0
|
||||
categoryMatcher := newCategoryMatcher(opts.Category)
|
||||
|
||||
for _, item := range items {
|
||||
if opts.BOGO || wantCategory {
|
||||
hasBogo := !opts.BOGO
|
||||
hasCategory := !wantCategory
|
||||
|
||||
for _, c := range item.Categories {
|
||||
if !hasBogo && strings.EqualFold(c, "bogo") {
|
||||
hasBogo = true
|
||||
}
|
||||
if !hasCategory && categoryMatcher.matches(c) {
|
||||
hasCategory = true
|
||||
}
|
||||
if hasBogo && hasCategory {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Query != "" {
|
||||
q := strings.ToLower(opts.Query)
|
||||
result = where(result, func(i api.SavingItem) bool {
|
||||
title := strings.ToLower(CleanText(Deref(i.Title)))
|
||||
desc := strings.ToLower(CleanText(Deref(i.Description)))
|
||||
return strings.Contains(title, q) || strings.Contains(desc, q)
|
||||
})
|
||||
if !hasBogo || !hasCategory {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if wantDepartment && !strings.Contains(strings.ToLower(Deref(item.Department)), department) {
|
||||
continue
|
||||
}
|
||||
|
||||
if wantQuery {
|
||||
title := strings.ToLower(CleanText(Deref(item.Title)))
|
||||
desc := strings.ToLower(CleanText(Deref(item.Description)))
|
||||
if !strings.Contains(title, query) && !strings.Contains(desc, query) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, item)
|
||||
if applyLimitWhileFiltering && len(result) >= opts.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasSort && len(result) > 1 {
|
||||
sortItems(result, sortMode)
|
||||
}
|
||||
if opts.Limit > 0 && opts.Limit < len(result) {
|
||||
result = result[:opts.Limit]
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -76,23 +120,21 @@ func Deref(s *string) string {
|
||||
|
||||
// CleanText unescapes HTML entities and normalizes whitespace.
|
||||
func CleanText(s string) string {
|
||||
if !strings.ContainsAny(s, "&\r\n") {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
s = html.UnescapeString(s)
|
||||
if !strings.ContainsAny(s, "\r\n") {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
s = strings.ReplaceAll(s, "\r\n", " ")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func where(items []api.SavingItem, fn func(api.SavingItem) bool) []api.SavingItem {
|
||||
var result []api.SavingItem
|
||||
for _, item := range items {
|
||||
if fn(item) {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ContainsIgnoreCase reports whether any element in slice matches val case-insensitively.
|
||||
func ContainsIgnoreCase(slice []string, val string) bool {
|
||||
for _, s := range slice {
|
||||
@@ -102,3 +144,35 @@ func ContainsIgnoreCase(slice []string, val string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sortItems(items []api.SavingItem, mode string) {
|
||||
switch mode {
|
||||
case "savings":
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
left := DealScore(items[i])
|
||||
right := DealScore(items[j])
|
||||
if left == right {
|
||||
return strings.ToLower(CleanText(Deref(items[i].Title))) < strings.ToLower(CleanText(Deref(items[j].Title)))
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
case "ending":
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
leftDate, leftOK := parseDealDate(items[i].EndFormatted)
|
||||
rightDate, rightOK := parseDealDate(items[j].EndFormatted)
|
||||
switch {
|
||||
case leftOK && rightOK:
|
||||
if leftDate.Equal(rightDate) {
|
||||
return DealScore(items[i]) > DealScore(items[j])
|
||||
}
|
||||
return leftDate.Before(rightDate)
|
||||
case leftOK:
|
||||
return true
|
||||
case rightOK:
|
||||
return false
|
||||
default:
|
||||
return DealScore(items[i]) > DealScore(items[j])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
195
internal/filter/filter_equivalence_test.go
Normal file
195
internal/filter/filter_equivalence_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package filter_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tayloree/publix-deals/internal/api"
|
||||
"github.com/tayloree/publix-deals/internal/filter"
|
||||
)
|
||||
|
||||
func referenceApply(items []api.SavingItem, opts filter.Options) []api.SavingItem {
|
||||
result := items
|
||||
|
||||
if opts.BOGO {
|
||||
result = referenceWhere(result, func(i api.SavingItem) bool {
|
||||
return filter.ContainsIgnoreCase(i.Categories, "bogo")
|
||||
})
|
||||
}
|
||||
|
||||
if opts.Category != "" {
|
||||
result = referenceWhere(result, func(i api.SavingItem) bool {
|
||||
return filter.ContainsIgnoreCase(i.Categories, opts.Category)
|
||||
})
|
||||
}
|
||||
|
||||
if opts.Department != "" {
|
||||
dept := strings.ToLower(opts.Department)
|
||||
result = referenceWhere(result, func(i api.SavingItem) bool {
|
||||
return strings.Contains(strings.ToLower(filter.Deref(i.Department)), dept)
|
||||
})
|
||||
}
|
||||
|
||||
if opts.Query != "" {
|
||||
q := strings.ToLower(opts.Query)
|
||||
result = referenceWhere(result, func(i api.SavingItem) bool {
|
||||
title := strings.ToLower(filter.CleanText(filter.Deref(i.Title)))
|
||||
desc := strings.ToLower(filter.CleanText(filter.Deref(i.Description)))
|
||||
return strings.Contains(title, q) || strings.Contains(desc, q)
|
||||
})
|
||||
}
|
||||
|
||||
if opts.Limit > 0 && opts.Limit < len(result) {
|
||||
result = result[:opts.Limit]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func referenceWhere(items []api.SavingItem, fn func(api.SavingItem) bool) []api.SavingItem {
|
||||
var result []api.SavingItem
|
||||
for _, item := range items {
|
||||
if fn(item) {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func randomItem(rng *rand.Rand, idx int) api.SavingItem {
|
||||
makePtr := func(v string) *string { return &v }
|
||||
|
||||
var title *string
|
||||
if rng.Intn(4) != 0 {
|
||||
title = makePtr(fmt.Sprintf("Fresh Deal %d", idx))
|
||||
}
|
||||
|
||||
var desc *string
|
||||
if rng.Intn(3) != 0 {
|
||||
desc = makePtr(fmt.Sprintf("Weekly offer %d", idx))
|
||||
}
|
||||
|
||||
var dept *string
|
||||
deptOptions := []*string{
|
||||
nil,
|
||||
makePtr("Grocery"),
|
||||
makePtr("Produce"),
|
||||
makePtr("Meat"),
|
||||
makePtr("Frozen"),
|
||||
}
|
||||
dept = deptOptions[rng.Intn(len(deptOptions))]
|
||||
|
||||
catPool := []string{"bogo", "grocery", "produce", "meat", "frozen", "dairy"}
|
||||
catCount := rng.Intn(4)
|
||||
cats := make([]string, 0, catCount)
|
||||
for range catCount {
|
||||
cats = append(cats, catPool[rng.Intn(len(catPool))])
|
||||
}
|
||||
|
||||
return api.SavingItem{
|
||||
ID: fmt.Sprintf("id-%d", idx),
|
||||
Title: title,
|
||||
Description: desc,
|
||||
Department: dept,
|
||||
Categories: cats,
|
||||
}
|
||||
}
|
||||
|
||||
func randomOptions(rng *rand.Rand) filter.Options {
|
||||
categories := []string{"", "bogo", "grocery", "produce", "meat"}
|
||||
departments := []string{"", "groc", "prod", "meat"}
|
||||
queries := []string{"", "fresh", "offer", "deal"}
|
||||
limits := []int{0, 1, 3, 5, 10}
|
||||
return filter.Options{
|
||||
BOGO: rng.Intn(2) == 0,
|
||||
Category: categories[rng.Intn(len(categories))],
|
||||
Department: departments[rng.Intn(len(departments))],
|
||||
Query: queries[rng.Intn(len(queries))],
|
||||
Limit: limits[rng.Intn(len(limits))],
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_ReferenceEquivalence(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
|
||||
for caseNum := 0; caseNum < 500; caseNum++ {
|
||||
itemCount := rng.Intn(60)
|
||||
items := make([]api.SavingItem, 0, itemCount)
|
||||
for i := range itemCount {
|
||||
items = append(items, randomItem(rng, i))
|
||||
}
|
||||
|
||||
opts := randomOptions(rng)
|
||||
got := filter.Apply(items, opts)
|
||||
want := referenceApply(items, opts)
|
||||
|
||||
assert.Equal(t, want, got, "mismatch for opts=%+v case=%d", opts, caseNum)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkApply_ReferenceWorkload_1kDeals(b *testing.B) {
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
items := make([]api.SavingItem, 0, 1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
items = append(items, randomItem(rng, i))
|
||||
}
|
||||
opts := filter.Options{
|
||||
BOGO: true,
|
||||
Category: "grocery",
|
||||
Department: "groc",
|
||||
Query: "deal",
|
||||
Limit: 50,
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
_ = filter.Apply(items, opts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_AllocationBudget(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
items := make([]api.SavingItem, 0, 1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
items = append(items, randomItem(rng, i))
|
||||
}
|
||||
opts := filter.Options{
|
||||
BOGO: true,
|
||||
Category: "grocery",
|
||||
Department: "groc",
|
||||
Query: "deal",
|
||||
Limit: 50,
|
||||
}
|
||||
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
_ = filter.Apply(items, opts)
|
||||
})
|
||||
|
||||
// Guardrail for accidental reintroduction of multi-pass intermediate slices.
|
||||
assert.LessOrEqual(t, allocs, 80.0)
|
||||
}
|
||||
|
||||
func BenchmarkApply_LegacyReference_1kDeals(b *testing.B) {
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
items := make([]api.SavingItem, 0, 1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
items = append(items, randomItem(rng, i))
|
||||
}
|
||||
opts := filter.Options{
|
||||
BOGO: true,
|
||||
Category: "grocery",
|
||||
Department: "groc",
|
||||
Query: "deal",
|
||||
Limit: 50,
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
_ = referenceApply(items, opts)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package filter_test
|
||||
|
||||
import (
|
||||
"html"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -71,6 +73,25 @@ func TestApply_CategoryCaseInsensitive(t *testing.T) {
|
||||
assert.Len(t, result, 2)
|
||||
}
|
||||
|
||||
func TestApply_CategorySynonym(t *testing.T) {
|
||||
result := filter.Apply(sampleItems(), filter.Options{Category: "veggies"})
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, "3", result[0].ID)
|
||||
}
|
||||
|
||||
func TestApply_CategoryHyphenatedExactMatch(t *testing.T) {
|
||||
result := filter.Apply(sampleItems(), filter.Options{Category: "pet-bogos"})
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, "4", result[0].ID)
|
||||
}
|
||||
|
||||
func TestApply_CategoryUnknownPluralStillMatchesExact(t *testing.T) {
|
||||
items := []api.SavingItem{{ID: "x", Categories: []string{"snacks"}}}
|
||||
result := filter.Apply(items, filter.Options{Category: "snacks"})
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, "x", result[0].ID)
|
||||
}
|
||||
|
||||
func TestApply_Department(t *testing.T) {
|
||||
result := filter.Apply(sampleItems(), filter.Options{Department: "produce"})
|
||||
assert.Len(t, result, 1)
|
||||
@@ -114,6 +135,33 @@ func TestApply_CombinedFilters(t *testing.T) {
|
||||
assert.Equal(t, "2", result[0].ID)
|
||||
}
|
||||
|
||||
func TestApply_SortSavings(t *testing.T) {
|
||||
items := []api.SavingItem{
|
||||
{ID: "a", Title: ptr("A"), Savings: ptr("$1.00 off")},
|
||||
{ID: "b", Title: ptr("B"), Savings: ptr("$4.00 off")},
|
||||
{ID: "c", Title: ptr("C"), Categories: []string{"bogo"}},
|
||||
}
|
||||
result := filter.Apply(items, filter.Options{Sort: "savings"})
|
||||
|
||||
assert.Len(t, result, 3)
|
||||
assert.Equal(t, "c", result[0].ID)
|
||||
assert.Equal(t, "b", result[1].ID)
|
||||
}
|
||||
|
||||
func TestApply_SortEnding(t *testing.T) {
|
||||
items := []api.SavingItem{
|
||||
{ID: "late", EndFormatted: "12/31/2026"},
|
||||
{ID: "soon", EndFormatted: "01/02/2026"},
|
||||
{ID: "unknown"},
|
||||
}
|
||||
result := filter.Apply(items, filter.Options{Sort: "ending"})
|
||||
|
||||
assert.Len(t, result, 3)
|
||||
assert.Equal(t, "soon", result[0].ID)
|
||||
assert.Equal(t, "late", result[1].ID)
|
||||
assert.Equal(t, "unknown", result[2].ID)
|
||||
}
|
||||
|
||||
func TestApply_NilFields(t *testing.T) {
|
||||
// Item 5 has nil title/department/categories — should not panic
|
||||
result := filter.Apply(sampleItems(), filter.Options{Query: "anything"})
|
||||
@@ -152,3 +200,47 @@ func TestCleanText(t *testing.T) {
|
||||
assert.Equal(t, tt.want, filter.CleanText(tt.input), "CleanText(%q)", tt.input)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyCleanText(s string) string {
|
||||
s = html.UnescapeString(s)
|
||||
s = strings.ReplaceAll(s, "\r\n", " ")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func BenchmarkCleanText_Plain_New(b *testing.B) {
|
||||
const input = "Simple Weekly Deal Title 123"
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
_ = filter.CleanText(input)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCleanText_Plain_Legacy(b *testing.B) {
|
||||
const input = "Simple Weekly Deal Title 123"
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
_ = legacyCleanText(input)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCleanText_Escaped_New(b *testing.B) {
|
||||
const input = " Eight O'Clock & Tea\r\nSpecial "
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
_ = filter.CleanText(input)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCleanText_Escaped_Legacy(b *testing.B) {
|
||||
const input = " Eight O'Clock & Tea\r\nSpecial "
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
_ = legacyCleanText(input)
|
||||
}
|
||||
}
|
||||
|
||||
85
internal/filter/sort.go
Normal file
85
internal/filter/sort.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tayloree/publix-deals/internal/api"
|
||||
)
|
||||
|
||||
var (
|
||||
reDollar = regexp.MustCompile(`\$(\d+(?:\.\d{1,2})?)`)
|
||||
rePercent = regexp.MustCompile(`(\d{1,3})\s*%`)
|
||||
)
|
||||
|
||||
// DealScore estimates relative deal value for ranking.
|
||||
func DealScore(item api.SavingItem) float64 {
|
||||
score := 0.0
|
||||
|
||||
if ContainsIgnoreCase(item.Categories, "bogo") {
|
||||
score += 8
|
||||
}
|
||||
|
||||
text := strings.ToLower(
|
||||
CleanText(Deref(item.Savings) + " " + Deref(item.AdditionalDealInfo)),
|
||||
)
|
||||
for _, m := range reDollar.FindAllStringSubmatch(text, -1) {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
if amount, err := strconv.ParseFloat(m[1], 64); err == nil {
|
||||
score += amount
|
||||
}
|
||||
}
|
||||
for _, m := range rePercent.FindAllStringSubmatch(text, -1) {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
if pct, err := strconv.ParseFloat(m[1], 64); err == nil {
|
||||
score += pct / 20.0
|
||||
}
|
||||
}
|
||||
|
||||
if score == 0 {
|
||||
return 0.01
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func normalizeSortMode(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", "relevance":
|
||||
return ""
|
||||
case "savings":
|
||||
return "savings"
|
||||
case "ending", "end", "expiry", "expiration":
|
||||
return "ending"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseDealDate(raw string) (time.Time, bool) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
"1/2/2006",
|
||||
"01/02/2006",
|
||||
"1/2/06",
|
||||
"01/02/06",
|
||||
"2006-01-02",
|
||||
"Jan 2, 2006",
|
||||
"January 2, 2006",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
133
internal/perf/pipeline_bench_test.go
Normal file
133
internal/perf/pipeline_bench_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package perf_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/tayloree/publix-deals/internal/api"
|
||||
"github.com/tayloree/publix-deals/internal/display"
|
||||
"github.com/tayloree/publix-deals/internal/filter"
|
||||
)
|
||||
|
||||
func strPtr(v string) *string { return &v }
|
||||
|
||||
func benchmarkDeals(count int) []api.SavingItem {
|
||||
items := make([]api.SavingItem, 0, count)
|
||||
for i := range count {
|
||||
title := fmt.Sprintf("Fresh item %d", i)
|
||||
desc := fmt.Sprintf("Fresh weekly deal %d with great savings", i)
|
||||
savings := fmt.Sprintf("$%d.99", (i%9)+1)
|
||||
dept := "Grocery"
|
||||
if i%4 == 0 {
|
||||
dept = "Produce"
|
||||
}
|
||||
if i%7 == 0 {
|
||||
dept = "Meat"
|
||||
}
|
||||
cats := []string{"grocery"}
|
||||
if i%3 == 0 {
|
||||
cats = append(cats, "bogo")
|
||||
}
|
||||
if i%5 == 0 {
|
||||
cats = append(cats, "produce")
|
||||
}
|
||||
if i%7 == 0 {
|
||||
cats = append(cats, "meat")
|
||||
}
|
||||
items = append(items, api.SavingItem{
|
||||
ID: fmt.Sprintf("id-%d", i),
|
||||
Title: strPtr(title),
|
||||
Description: strPtr(desc),
|
||||
Savings: strPtr(savings),
|
||||
Department: strPtr(dept),
|
||||
Categories: cats,
|
||||
StartFormatted: "2/18",
|
||||
EndFormatted: "2/24",
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func setupPipelineServer(b *testing.B, dealCount int) (*httptest.Server, *api.Client) {
|
||||
b.Helper()
|
||||
|
||||
storesPayload, err := json.Marshal(api.StoreResponse{
|
||||
Stores: []api.Store{
|
||||
{Key: "01425", Name: "Peachers Mill", Addr: "1490 Tiny Town Rd", City: "Clarksville", State: "TN", Zip: "37042", Distance: "5"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatalf("marshal stores payload: %v", err)
|
||||
}
|
||||
|
||||
savingsPayload, err := json.Marshal(api.SavingsResponse{
|
||||
Savings: benchmarkDeals(dealCount),
|
||||
LanguageID: 1,
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatalf("marshal savings payload: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/stores":
|
||||
_, _ = w.Write(storesPayload)
|
||||
case "/savings":
|
||||
_, _ = w.Write(savingsPayload)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
b.Cleanup(server.Close)
|
||||
|
||||
client := api.NewClientWithBaseURLs(server.URL+"/savings", server.URL+"/stores")
|
||||
return server, client
|
||||
}
|
||||
|
||||
func runPipeline(b *testing.B, client *api.Client) {
|
||||
b.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
stores, err := client.FetchStores(ctx, "33101", 1)
|
||||
if err != nil {
|
||||
b.Fatalf("fetch stores: %v", err)
|
||||
}
|
||||
if len(stores) == 0 {
|
||||
b.Fatalf("fetch stores: empty result")
|
||||
}
|
||||
|
||||
resp, err := client.FetchSavings(ctx, api.StoreNumber(stores[0].Key))
|
||||
if err != nil {
|
||||
b.Fatalf("fetch savings: %v", err)
|
||||
}
|
||||
|
||||
filtered := filter.Apply(resp.Savings, filter.Options{
|
||||
BOGO: true,
|
||||
Category: "grocery",
|
||||
Department: "grocery",
|
||||
Query: "fresh",
|
||||
Limit: 50,
|
||||
})
|
||||
if len(filtered) == 0 {
|
||||
b.Fatalf("filter returned no deals")
|
||||
}
|
||||
if err := display.PrintDealsJSON(io.Discard, filtered); err != nil {
|
||||
b.Fatalf("print deals json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkZipPipeline_1kDeals(b *testing.B) {
|
||||
_, client := setupPipelineServer(b, 1000)
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
runPipeline(b, client)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user