Compare commits

..

11 Commits

Author SHA1 Message Date
e3d2e9306b Document TUI controls, category synonyms, flag aliases, and compare JSON
AGENTS.md:
- Add command table covering all five commands
- Expand input tolerance section with flag alias table
- Add filtering/sorting, auto JSON, and structured error sections
- Include canonical examples for compare

README.md:
- Expand compare command docs with ranking explanation and --count flag
- Replace tui stub with full keybinding reference (tab, /, s, g, c, a,
  l, r, [/], 1-9, u/d, b/f, ?, q)
- Add compare-specific flags section and sort alias note
- Add category synonym table showing all bidirectional mappings
- Add flag alias resolution table (zipcode->zip, dept->department, etc.)
- Add compare JSON schema documenting all response fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:32:18 -05:00
da327fe759 Rewrite TUI as full-screen Bubble Tea two-pane interactive browser
Replace the line-based stdin pagination loop with a charmbracelet/bubbletea
application providing a responsive two-pane layout: a filterable deal list
on the left and a scrollable detail viewport on the right.

Key capabilities:

- Async startup: spinner + skeleton while store/deals are fetched, then
  transition to the interactive view. Fatal load errors propagate cleanly.
- Grouped sections: deals are bucketed by category (BOGO first, then by
  count descending) with numbered section headers. Jump with [/] for
  prev/next and 1-9 for direct section access.
- Inline filter cycling: s (sort: relevance/savings/ending), g (bogo
  toggle), c (category cycle), a (department cycle), l (limit cycle),
  r (reset to CLI-start defaults). Filters rebuild the list while
  preserving cursor position via stable IDs.
- Fuzzy search: / activates bubbletea's built-in list filter. Section
  jumps are disabled while a fuzzy filter is active.
- Detail pane: full deal metadata with word-wrapped text, scrollable
  via j/k, u/d (half-page), b/f (full page). Tab switches focus.
- Terminal size awareness: minimum 92x24, graceful too-small message.
- JSON mode: tui --json fetches and filters without launching the
  interactive UI, consistent with other commands.

New files:
  cmd/tui_model.go      — Bubble Tea model, view, update, grouping logic
  cmd/tui_model_test.go — Tests for sort canonicalization, group ordering,
                           and category choice building

Dependencies added: charmbracelet/bubbles, charmbracelet/bubbletea.
Transitive deps upgraded: lipgloss, x/ansi, x/cellbuf, x/term, go-colorful,
go-runewidth, sys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:32:10 -05:00
f486150c06 Update upstream error classification to match current API client errors
The API client consolidates response decoding into a single code path
that produces "decoding response" errors instead of the previous
"decoding savings" / "decoding stores" / "reading response" variants.
Update classifyCLIError to match the current error strings, and add
"fetching savings" alongside the existing "fetching deals" / "fetching
stores" cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:31:55 -05:00
28479071ae Fix category synonym matching: deduplicate aliases, remove broken fast-path
Three issues in categoryAliasList/categoryMatcher:

1. categoryAliasList appended raw synonyms without deduplication—the
   addAlias helper already handles lowering and dedup, so route synonyms
   through it instead of direct append.

2. categoryMatcher.matches had a fast-path that returned false when the
   input contained no separators (-_ space), skipping the normalization
   step entirely. This caused legitimate matches like "frozen foods" vs
   "frozen" to fail when the input was a simple word that needed plural
   stripping to match.

3. normalizeCategory unconditionally replaced underscores/hyphens and
   re-joined fields even for inputs without separators. Gate the
   separator logic behind a ContainsAny check, and use direct slice
   indexing instead of TrimSuffix for the plural stripping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:31:50 -05:00
11a90d2fd1 Document compare/tui commands and new sort/synonym behavior
- expand feature list with sort support, store comparison, and interactive TUI browsing
- add command reference sections for compare and tui with concrete examples
- document new --sort flag in the filter flag table
- clarify behavior notes so category filtering is described as case-insensitive with practical synonym support
2026-02-23 00:27:25 -05:00
7dd963141a Add compare and tui commands with shared sort/filter CLI wiring
- add pubcli compare command to rank nearby stores by filtered deal coverage, bogo count, aggregate score, and distance tie-breaks
- support --count (1-10) for comparison breadth and emit structured JSON/text output with ranked entries
- add robust distance token parsing to tolerate upstream distance string formatting differences
- add pubcli tui interactive terminal browser with paging, deal detail drill-in, and explicit TTY validation for stdin/stdout
- share deal-filter flag registration across root/tui/compare and add --sort support in root execution path
- validate sort mode early and allow canonical aliases (end, expiry, expiration) while preserving explicit invalid-arg guidance
- expand tolerant CLI normalization for new commands/flags and aliases (orderby, sortby, count, bare-flag rewrite for compare/tui)
- update quick-start flag list and integration tests to cover compare help and normalization behavior
2026-02-23 00:27:18 -05:00
eb2328b768 Enhance filter pipeline with synonym-aware categories and deal sorting
- extend filter.Options with sort mode support and keep Apply as a single-pass pipeline with limit behavior preserved for unsorted flows
- add sort normalization and two ordering strategies:
  * savings: rank by computed DealScore with deterministic title tie-break
  * ending: rank by earliest parsed end date, then DealScore fallback
- introduce DealScore heuristics that combine BOGO weighting, dollar-off extraction, and percentage extraction from savings/deal-info text
- add category synonym matcher that supports:
  * direct case-insensitive matches
  * canonical group synonym expansion (e.g. veggies -> produce)
  * normalized fallback for hyphen/underscore/plural variants without breaking exact unknown-category matching
- include explicit tests for synonym matching, hyphenated category handling, unknown plural exact matching, and sort ordering behavior
- keep allocation-sensitive behavior intact while adding matcher precomputation and fast-path checks
2026-02-23 00:26:55 -05:00
b91c44c4ed Add end-to-end pipeline benchmark covering fetch→filter→display
New internal/perf package with BenchmarkZipPipeline_1kDeals that
exercises the full hot path: API fetch (stores + savings against an
httptest server with pre-marshaled payloads), filter.Apply with all
predicates active and a 50-item limit, and display.PrintDealsJSON
to io.Discard.

This provides a single top-level benchmark to catch regressions
across package boundaries — e.g. if a filter optimization shifts
allocation pressure into the display layer, this benchmark surfaces
it where per-package benchmarks would not.

Synthetic dataset: 1000 deals with deterministic category/department
distribution to ensure the filter pipeline has meaningful work to do
(~1/3 BOGO, mixed departments, keyword matches in titles).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:12:02 -05:00
53d65c2148 Add cascading fallback for deal titles instead of hardcoded "Unknown"
Replace the simple nil-title → "Unknown" logic in printDeal with a
fallbackDealTitle() function that tries multiple fields in priority
order before giving up:

  1. Title (cleaned) — the happy path, same as before
  2. Brand + Department — e.g. "Publix deal (Meat)"
  3. Brand alone — e.g. "Publix deal"
  4. Department alone — e.g. "Meat deal"
  5. Description truncated to 48 chars — last-resort meaningful text
  6. Item ID — e.g. "Deal 12345"
  7. "Untitled deal" — only when every field is empty

This makes the output more useful for the ~5-10% of weekly ad items
that ship with a nil Title from the Publix API, which previously all
showed as "Unknown" and were indistinguishable from each other.

Tests:
- TestPrintDeals_FallbackTitleFromBrandAndDepartment
- TestPrintDeals_FallbackTitleFromID

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:11:50 -05:00
df0af4a5f8 Rewrite filter.Apply as single-pass with early-exit and pre-allocation
Replace the multi-pass where() chain in Apply() with a single loop that
evaluates all filter predicates per item and skips immediately on first
mismatch. This eliminates N intermediate slice allocations (one per
active filter) and avoids re-scanning the full dataset for each filter
dimension.

Key changes in filter.go:
- Single loop with continue-on-mismatch for BOGO, category, department,
  and query filters — combined categories check scans item.Categories
  once for both BOGO and category instead of twice
- Pre-allocate result slice capped at min(len(items), opts.Limit) to
  avoid grow-and-copy churn
- Fast-path bypass when no filters are active (just apply limit)
- Break early once limit is reached instead of filtering everything
  and truncating after
- Remove the now-unused where() helper function
- Add early-return fast paths to CleanText() for the common case where
  input contains no HTML entities or newlines, avoiding unnecessary
  html.UnescapeString and ReplaceAll calls

Test coverage:
- filter_equivalence_test.go (new): Reference implementation of the
  original multi-pass algorithm with 500 randomized test cases verifying
  behavioral equivalence. Includes allocation budget guardrail (<=80
  allocs/op for 1k items) to catch accidental regression to multi-pass.
  Benchmarks for new vs legacy reference on identical workload.
- filter_test.go: Benchmark comparisons for CleanText on plain text
  (fast path) vs escaped HTML (full path), new vs legacy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:11:38 -05:00
4310375dc9 Replace io.ReadAll + json.Unmarshal with streaming JSON decoder
Refactor the internal HTTP helper from get() returning raw bytes to
getAndDecode() that streams directly into the target struct via
json.NewDecoder. This eliminates the intermediate []byte allocation
from io.ReadAll on every API response.

The new decoder also validates that responses contain exactly one JSON
value by attempting a second Decode after the primary one — any content
beyond the first value (e.g., concatenated objects from a misbehaving
proxy) returns an error instead of silently discarding it.

Changes:
- api/client.go: Replace get() with getAndDecode(), update FetchStores
  and FetchSavings callers to use the new signature
- api/client_test.go: Add TestFetchSavings_TrailingJSONIsRejected and
  TestFetchStores_MalformedJSONReturnsDecodeError covering the new
  decoder error paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:11:24 -05:00
23 changed files with 2663 additions and 112 deletions

View File

@@ -2,16 +2,52 @@
The `pubcli` CLI is intentionally tolerant of minor syntax mistakes when intent is clear. 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: Accepted flexible forms include:
- `-zip 33101` -> interpreted as `--zip 33101` - `-zip 33101` -> interpreted as `--zip 33101`
- `zip=33101` -> interpreted as `--zip=33101` - `zip=33101` -> interpreted as `--zip=33101`
- `--ziip 33101` -> interpreted as `--zip 33101` - `--ziip 33101` -> interpreted as `--zip 33101`
- `categoriess` -> interpreted as `categories` - `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: The CLI prints a `note:` line when it auto-corrects input. Use canonical syntax in future commands:
- `pubcli --zip 33101` - `pubcli --zip 33101`
- `pubcli --store 1425 --bogo` - `pubcli --store 1425 --bogo`
- `pubcli categories --zip 33101` - `pubcli categories --zip 33101`
- `pubcli stores --zip 33101 --json` - `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
View File

@@ -7,7 +7,10 @@
- Fetch weekly ad deals for a specific store - Fetch weekly ad deals for a specific store
- Resolve nearest Publix store from a ZIP code - Resolve nearest Publix store from a ZIP code
- Filter deals by category, department, keyword, and BOGO status - Filter deals by category, department, keyword, and BOGO status
- Sort deals by estimated savings or ending date
- List weekly categories with deal counts - 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 - Output data as formatted terminal text or JSON
- Generate shell completions (`bash`, `zsh`, `fish`, `powershell`) - Generate shell completions (`bash`, `zsh`, `fish`, `powershell`)
- Tolerate minor CLI syntax mistakes when intent is clear - Tolerate minor CLI syntax mistakes when intent is clear
@@ -90,33 +93,96 @@ pubcli categories --store 1425
pubcli categories -z 33101 --json 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 ## Flags
Global flags: Global flags (available on all commands):
- `-s, --store string` Publix store number (example: `1425`) - `-s, --store string` Publix store number (example: `1425`)
- `-z, --zip string` ZIP code for store lookup - `-z, --zip string` ZIP code for store lookup
- `--json` Output JSON instead of styled terminal output - `--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 - `--bogo` Show only BOGO deals
- `-c, --category string` Filter by category (example: `bogo`, `meat`, `produce`) - `-c, --category string` Filter by category (example: `bogo`, `meat`, `produce`)
- `-d, --department string` Filter by department (substring match, case-insensitive) - `-d, --department string` Filter by department (substring match, case-insensitive)
- `-q, --query string` Search title/description (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) - `-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 ## 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. - 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. - 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`. - Filtering is applied in this order: `bogo` + `category`, `department`, `query`, `sort`, `limit`.
- Category matching is exact and case-insensitive. - Category matching is case-insensitive and supports synonym groups (see below).
- Department and query filters use case-insensitive substring matching. - Department and query filters use case-insensitive substring matching.
- Running `pubcli` with no args prints compact quick-start help. - 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. - 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 ## CLI Input Tolerance
The CLI auto-corrects common input mistakes and prints a `note:` describing the normalization: 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` - `zip=33101` -> `--zip=33101`
- `--ziip 33101` -> `--zip 33101` - `--ziip 33101` -> `--zip 33101`
- `stores zip 33101` -> `stores --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: 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 ## Structured Errors
When command execution fails, errors include: When command execution fails, errors include:

View File

@@ -18,13 +18,17 @@ var knownFlags = map[string]flagSpec{
"department": {name: "department", requiresValue: true}, "department": {name: "department", requiresValue: true},
"bogo": {name: "bogo", requiresValue: false}, "bogo": {name: "bogo", requiresValue: false},
"query": {name: "query", requiresValue: true}, "query": {name: "query", requiresValue: true},
"sort": {name: "sort", requiresValue: true},
"limit": {name: "limit", requiresValue: true}, "limit": {name: "limit", requiresValue: true},
"count": {name: "count", requiresValue: true},
"help": {name: "help", requiresValue: false}, "help": {name: "help", requiresValue: false},
} }
var knownCommands = []string{ var knownCommands = []string{
"categories", "categories",
"stores", "stores",
"compare",
"tui",
"completion", "completion",
"help", "help",
} }
@@ -36,6 +40,8 @@ var flagAliases = map[string]string{
"storeno": "store", "storeno": "store",
"dept": "department", "dept": "department",
"search": "query", "search": "query",
"orderby": "sort",
"sortby": "sort",
"max": "limit", "max": "limit",
} }
@@ -156,7 +162,7 @@ func bareFlagRewriteAllowed(command string) bool {
// Some commands (for example `stores` and `categories`) are flag-only, so // Some commands (for example `stores` and `categories`) are flag-only, so
// rewriting bare tokens like `zip` -> `--zip` is helpful there. // rewriting bare tokens like `zip` -> `--zip` is helpful there.
switch command { switch command {
case "stores", "categories": case "stores", "categories", "compare", "tui":
return true return true
default: default:
return false return false

View File

@@ -21,6 +21,13 @@ func TestNormalizeCLIArgs_RewritesTypoFlag(t *testing.T) {
assert.NotEmpty(t, notes) 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) { func TestNormalizeCLIArgs_RewritesCommandTypo(t *testing.T) {
args, notes := normalizeCLIArgs([]string{"categoriess", "--zip", "33101"}) args, notes := normalizeCLIArgs([]string{"categoriess", "--zip", "33101"})
@@ -49,6 +56,13 @@ func TestNormalizeCLIArgs_RespectsDoubleDashBoundary(t *testing.T) {
assert.Empty(t, notes) 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) { func TestNormalizeCLIArgs_LeavesKnownShorthandUntouched(t *testing.T) {
args, notes := normalizeCLIArgs([]string{"-z", "33101", "-n", "5"}) args, notes := normalizeCLIArgs([]string{"-z", "33101", "-n", "5"})

198
cmd/compare.go Normal file
View 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
}

View File

@@ -175,10 +175,9 @@ func classifyCLIError(err error) *cliError {
} }
case strings.Contains(lowerMsg, "unexpected status"), case strings.Contains(lowerMsg, "unexpected status"),
strings.Contains(lowerMsg, "executing request"), strings.Contains(lowerMsg, "executing request"),
strings.Contains(lowerMsg, "reading response"), strings.Contains(lowerMsg, "decoding response"),
strings.Contains(lowerMsg, "decoding savings"),
strings.Contains(lowerMsg, "decoding stores"),
strings.Contains(lowerMsg, "fetching deals"), strings.Contains(lowerMsg, "fetching deals"),
strings.Contains(lowerMsg, "fetching savings"),
strings.Contains(lowerMsg, "fetching stores"), strings.Contains(lowerMsg, "fetching stores"),
strings.Contains(lowerMsg, "finding stores"): strings.Contains(lowerMsg, "finding stores"):
return &cliError{ return &cliError{
@@ -299,7 +298,7 @@ func printQuickStart(w io.Writer, asJSON bool) error {
_, err := fmt.Fprintf( _, err := fmt.Fprintf(
w, 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.Name,
help.Usage, help.Usage,
help.Examples[0], help.Examples[0],

View File

@@ -4,8 +4,10 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/tayloree/publix-deals/internal/api" "github.com/tayloree/publix-deals/internal/api"
"github.com/tayloree/publix-deals/internal/display" "github.com/tayloree/publix-deals/internal/display"
"github.com/tayloree/publix-deals/internal/filter" "github.com/tayloree/publix-deals/internal/filter"
@@ -18,6 +20,7 @@ var (
flagDepartment string flagDepartment string
flagBogo bool flagBogo bool
flagQuery string flagQuery string
flagSort string
flagLimit int flagLimit int
flagJSON bool flagJSON bool
) )
@@ -31,8 +34,10 @@ var rootCmd = &cobra.Command{
"(for example: -zip 33101, zip=33101, --ziip 33101).", "(for example: -zip 33101, zip=33101, --ziip 33101).",
Example: ` pubcli --zip 33101 Example: ` pubcli --zip 33101
pubcli --store 1425 --bogo pubcli --store 1425 --bogo
pubcli --zip 33101 --sort savings
pubcli categories --zip 33101 pubcli categories --zip 33101
pubcli stores --zip 33101 --json`, pubcli stores --zip 33101 --json
pubcli compare --zip 33101 --category produce`,
RunE: runDeals, RunE: runDeals,
} }
@@ -45,12 +50,7 @@ func init() {
pf.StringVarP(&flagZip, "zip", "z", "", "Zip code to find nearby stores") pf.StringVarP(&flagZip, "zip", "z", "", "Zip code to find nearby stores")
pf.BoolVar(&flagJSON, "json", false, "Output as JSON") pf.BoolVar(&flagJSON, "json", false, "Output as JSON")
f := rootCmd.Flags() registerDealFilterFlags(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. // Execute runs the root command.
@@ -112,10 +112,34 @@ func resetCLIState() {
flagDepartment = "" flagDepartment = ""
flagBogo = false flagBogo = false
flagQuery = "" flagQuery = ""
flagSort = ""
flagLimit = 0 flagLimit = 0
flagCompareCount = 5
flagJSON = false 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) { func resolveStore(cmd *cobra.Command, client *api.Client) (string, error) {
if flagStore != "" { if flagStore != "" {
return flagStore, nil return flagStore, nil
@@ -147,6 +171,10 @@ func resolveStore(cmd *cobra.Command, client *api.Client) (string, error) {
} }
func runDeals(cmd *cobra.Command, _ []string) error { func runDeals(cmd *cobra.Command, _ []string) error {
if err := validateSortMode(); err != nil {
return err
}
client := api.NewClient() client := api.NewClient()
storeNumber, err := resolveStore(cmd, client) storeNumber, err := resolveStore(cmd, client)
@@ -172,6 +200,7 @@ func runDeals(cmd *cobra.Command, _ []string) error {
Category: flagCategory, Category: flagCategory,
Department: flagDepartment, Department: flagDepartment,
Query: flagQuery, Query: flagQuery,
Sort: flagSort,
Limit: flagLimit, Limit: flagLimit,
}) })

View File

@@ -30,6 +30,17 @@ func TestRunCLI_HelpStores(t *testing.T) {
assert.Empty(t, stderr.String()) 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) { func TestRunCLI_TolerantRewriteWithoutNetworkCall(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer

150
cmd/tui.go Normal file
View 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

File diff suppressed because it is too large Load Diff

62
cmd/tui_model_test.go Normal file
View 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
View File

@@ -3,28 +3,40 @@ module github.com/tayloree/publix-deals
go 1.24.4 go 1.24.4
require ( require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/term v0.30.0 golang.org/x/term v0.30.0
) )
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.1 // 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/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/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-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/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // 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 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 gopkg.in/yaml.v3 v3.0.1 // indirect
) )

64
go.sum
View File

@@ -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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 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 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 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.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/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 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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= 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-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != 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") 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) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("executing request: %w", err) return fmt.Errorf("executing request: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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) dec := json.NewDecoder(resp.Body)
if err != nil { if err := dec.Decode(out); err != nil {
return nil, fmt.Errorf("reading response: %w", err) 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. // 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}, "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 var resp StoreResponse
if err := json.Unmarshal(body, &resp); err != nil { if err := c.getAndDecode(ctx, c.storeURL+"?"+params.Encode(), "", &resp); err != nil {
return nil, fmt.Errorf("decoding stores: %w", err) return nil, fmt.Errorf("fetching stores: %w", err)
} }
return resp.Stores, nil return resp.Stores, nil
} }
@@ -104,14 +103,9 @@ func (c *Client) FetchSavings(ctx context.Context, storeNumber string) (*Savings
"getSavingType": {"WeeklyAd"}, "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 var resp SavingsResponse
if err := json.Unmarshal(body, &resp); err != nil { if err := c.getAndDecode(ctx, c.savingsURL+"?"+params.Encode(), storeNumber, &resp); err != nil {
return nil, fmt.Errorf("decoding savings: %w", err) return nil, fmt.Errorf("fetching savings: %w", err)
} }
return &resp, nil return &resp, nil
} }

View File

@@ -127,6 +127,34 @@ func TestFetchStores_NoResults(t *testing.T) {
assert.Empty(t, result) 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) { func TestStoreNumber(t *testing.T) {
tests := []struct { tests := []struct {
input string input string

View File

@@ -151,10 +151,7 @@ func PrintWarning(w io.Writer, msg string) {
} }
func printDeal(w io.Writer, item api.SavingItem) { func printDeal(w io.Writer, item api.SavingItem) {
title := filter.CleanText(filter.Deref(item.Title)) title := fallbackDealTitle(item)
if title == "" {
title = "Unknown"
}
savings := filter.CleanText(filter.Deref(item.Savings)) savings := filter.CleanText(filter.Deref(item.Savings))
desc := filter.CleanText(filter.Deref(item.Description)) desc := filter.CleanText(filter.Deref(item.Description))
dept := filter.CleanText(filter.Deref(item.Department)) 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 { func toDealJSON(item api.SavingItem) DealJSON {
categories := item.Categories categories := item.Categories
if categories == nil { if categories == nil {

View File

@@ -55,6 +55,41 @@ func TestPrintDeals_ContainsExpectedContent(t *testing.T) {
assert.NotContains(t, output, "&amp;") assert.NotContains(t, output, "&amp;")
} }
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) { func TestPrintDealsJSON(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
err := display.PrintDealsJSON(&buf, sampleDeals()) err := display.PrintDealsJSON(&buf, sampleDeals())

View 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
}

View File

@@ -2,6 +2,7 @@ package filter
import ( import (
"html" "html"
"sort"
"strings" "strings"
"github.com/tayloree/publix-deals/internal/api" "github.com/tayloree/publix-deals/internal/api"
@@ -13,45 +14,88 @@ type Options struct {
Category string Category string
Department string Department string
Query string Query string
Sort string
Limit int Limit int
} }
// Apply filters a slice of SavingItems according to the given options. // Apply filters a slice of SavingItems according to the given options.
func Apply(items []api.SavingItem, opts Options) []api.SavingItem { 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 { if !needsFiltering && !hasSort {
result = where(result, func(i api.SavingItem) bool { if opts.Limit > 0 && opts.Limit < len(items) {
return ContainsIgnoreCase(i.Categories, "bogo") return items[:opts.Limit]
}) }
return items
} }
if opts.Category != "" { var result []api.SavingItem
result = where(result, func(i api.SavingItem) bool { if opts.Limit > 0 && opts.Limit < len(items) {
return ContainsIgnoreCase(i.Categories, opts.Category) result = make([]api.SavingItem, 0, opts.Limit)
}) } else {
result = make([]api.SavingItem, 0, len(items))
} }
if opts.Department != "" { department := strings.ToLower(opts.Department)
dept := strings.ToLower(opts.Department) query := strings.ToLower(opts.Query)
result = where(result, func(i api.SavingItem) bool { applyLimitWhileFiltering := !hasSort && opts.Limit > 0
return strings.Contains(strings.ToLower(Deref(i.Department)), dept) 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 != "" { if !hasBogo || !hasCategory {
q := strings.ToLower(opts.Query) continue
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 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) { if opts.Limit > 0 && opts.Limit < len(result) {
result = result[:opts.Limit] result = result[:opts.Limit]
} }
if len(result) == 0 {
return nil
}
return result return result
} }
@@ -76,23 +120,21 @@ func Deref(s *string) string {
// CleanText unescapes HTML entities and normalizes whitespace. // CleanText unescapes HTML entities and normalizes whitespace.
func CleanText(s string) string { func CleanText(s string) string {
if !strings.ContainsAny(s, "&\r\n") {
return strings.TrimSpace(s)
}
s = html.UnescapeString(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\n", " ")
s = strings.ReplaceAll(s, "\r", " ") s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\n", " ")
return strings.TrimSpace(s) 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. // ContainsIgnoreCase reports whether any element in slice matches val case-insensitively.
func ContainsIgnoreCase(slice []string, val string) bool { func ContainsIgnoreCase(slice []string, val string) bool {
for _, s := range slice { for _, s := range slice {
@@ -102,3 +144,35 @@ func ContainsIgnoreCase(slice []string, val string) bool {
} }
return false 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])
}
})
}
}

View 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)
}
}

View File

@@ -1,6 +1,8 @@
package filter_test package filter_test
import ( import (
"html"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -71,6 +73,25 @@ func TestApply_CategoryCaseInsensitive(t *testing.T) {
assert.Len(t, result, 2) 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) { func TestApply_Department(t *testing.T) {
result := filter.Apply(sampleItems(), filter.Options{Department: "produce"}) result := filter.Apply(sampleItems(), filter.Options{Department: "produce"})
assert.Len(t, result, 1) assert.Len(t, result, 1)
@@ -114,6 +135,33 @@ func TestApply_CombinedFilters(t *testing.T) {
assert.Equal(t, "2", result[0].ID) 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) { func TestApply_NilFields(t *testing.T) {
// Item 5 has nil title/department/categories — should not panic // Item 5 has nil title/department/categories — should not panic
result := filter.Apply(sampleItems(), filter.Options{Query: "anything"}) 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) 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&#39;Clock &amp; 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&#39;Clock &amp; Tea\r\nSpecial "
b.ReportAllocs()
b.ResetTimer()
for range b.N {
_ = legacyCleanText(input)
}
}

85
internal/filter/sort.go Normal file
View 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
}

View 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)
}
}