Add deal filtering engine with BOGO, category, department, and keyword support

Composable filter pipeline that processes SavingItem slices through
chained predicates: BOGO detection (category match), exact category
match, substring department match, and keyword search across title
and description fields. All text matching is case-insensitive.

Includes utility functions for HTML entity unescaping (CleanText),
nil-safe string pointer dereferencing (Deref), and case-insensitive
slice membership (ContainsIgnoreCase). An optional limit truncates
results after all filters are applied. Tests cover each filter in
isolation, combined filters, nil field safety, and the Categories
aggregation helper.
This commit is contained in:
2026-02-22 21:12:19 -05:00
parent 5efe7581ed
commit 12eb55f4b8
2 changed files with 258 additions and 0 deletions

104
internal/filter/filter.go Normal file
View File

@@ -0,0 +1,104 @@
package filter
import (
"html"
"strings"
"github.com/tayloree/publix-deals/internal/api"
)
// Options holds all filter criteria.
type Options struct {
BOGO bool
Category string
Department string
Query 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
if opts.BOGO {
result = where(result, func(i api.SavingItem) bool {
return ContainsIgnoreCase(i.Categories, "bogo")
})
}
if opts.Category != "" {
result = where(result, func(i api.SavingItem) bool {
return ContainsIgnoreCase(i.Categories, opts.Category)
})
}
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)
})
}
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 opts.Limit > 0 && opts.Limit < len(result) {
result = result[:opts.Limit]
}
return result
}
// Categories returns a map of category name to count across all items.
func Categories(items []api.SavingItem) map[string]int {
cats := make(map[string]int)
for _, item := range items {
for _, c := range item.Categories {
cats[c]++
}
}
return cats
}
// Deref safely dereferences a string pointer, returning "" for nil.
func Deref(s *string) string {
if s == nil {
return ""
}
return *s
}
// CleanText unescapes HTML entities and normalizes whitespace.
func CleanText(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 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 {
if strings.EqualFold(s, val) {
return true
}
}
return false
}

View File

@@ -0,0 +1,154 @@
package filter_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tayloree/publix-deals/internal/api"
"github.com/tayloree/publix-deals/internal/filter"
)
func ptr(s string) *string { return &s }
func sampleItems() []api.SavingItem {
return []api.SavingItem{
{
ID: "1",
Title: ptr("Chicken Breasts"),
Department: ptr("Meat"),
Categories: []string{"meat"},
},
{
ID: "2",
Title: ptr("Nutella Spread"),
Savings: ptr("Buy 1 Get 1 FREE"),
Department: ptr("Peanut Butter & Jelly"),
Categories: []string{"bogo", "grocery"},
},
{
ID: "3",
Title: ptr("Organic Spinach"),
Description: ptr("Fresh baby spinach, 5-oz pkg."),
Department: ptr("Produce"),
Categories: []string{"produce"},
},
{
ID: "4",
Title: ptr("Dog Food"),
Department: ptr("Pet Food"),
Categories: []string{"bogo", "pet", "pet-bogos"},
},
{
ID: "5",
Title: nil,
Department: nil,
Categories: nil,
},
}
}
func TestApply_NoFilters(t *testing.T) {
items := sampleItems()
result := filter.Apply(items, filter.Options{})
assert.Len(t, result, 5)
}
func TestApply_BOGO(t *testing.T) {
result := filter.Apply(sampleItems(), filter.Options{BOGO: true})
assert.Len(t, result, 2)
assert.Equal(t, "2", result[0].ID)
assert.Equal(t, "4", result[1].ID)
}
func TestApply_Category(t *testing.T) {
result := filter.Apply(sampleItems(), filter.Options{Category: "meat"})
assert.Len(t, result, 1)
assert.Equal(t, "1", result[0].ID)
}
func TestApply_CategoryCaseInsensitive(t *testing.T) {
result := filter.Apply(sampleItems(), filter.Options{Category: "BOGO"})
assert.Len(t, result, 2)
}
func TestApply_Department(t *testing.T) {
result := filter.Apply(sampleItems(), filter.Options{Department: "produce"})
assert.Len(t, result, 1)
assert.Equal(t, "Organic Spinach", *result[0].Title)
}
func TestApply_DepartmentPartialMatch(t *testing.T) {
result := filter.Apply(sampleItems(), filter.Options{Department: "pet"})
assert.Len(t, result, 1)
assert.Equal(t, "4", result[0].ID)
}
func TestApply_Query(t *testing.T) {
result := filter.Apply(sampleItems(), filter.Options{Query: "chicken"})
assert.Len(t, result, 1)
assert.Equal(t, "1", result[0].ID)
}
func TestApply_QueryMatchesDescription(t *testing.T) {
result := filter.Apply(sampleItems(), filter.Options{Query: "spinach"})
assert.Len(t, result, 1)
assert.Equal(t, "3", result[0].ID)
}
func TestApply_QueryNoMatch(t *testing.T) {
result := filter.Apply(sampleItems(), filter.Options{Query: "xyz123"})
assert.Empty(t, result)
}
func TestApply_Limit(t *testing.T) {
result := filter.Apply(sampleItems(), filter.Options{Limit: 2})
assert.Len(t, result, 2)
}
func TestApply_CombinedFilters(t *testing.T) {
result := filter.Apply(sampleItems(), filter.Options{
BOGO: true,
Limit: 1,
})
assert.Len(t, result, 1)
assert.Equal(t, "2", result[0].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"})
assert.Empty(t, result)
}
func TestCategories(t *testing.T) {
cats := filter.Categories(sampleItems())
assert.Equal(t, 2, cats["bogo"])
assert.Equal(t, 1, cats["meat"])
assert.Equal(t, 1, cats["grocery"])
assert.Equal(t, 1, cats["produce"])
assert.Equal(t, 1, cats["pet"])
assert.Equal(t, 1, cats["pet-bogos"])
}
func TestDeref(t *testing.T) {
s := "hello"
assert.Equal(t, "hello", filter.Deref(&s))
assert.Equal(t, "", filter.Deref(nil))
}
func TestCleanText(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Hello &amp; World", "Hello & World"},
{"Line1\r\nLine2", "Line1 Line2"},
{" spaces ", "spaces"},
{"Eight O&#39;Clock", "Eight O'Clock"},
{"", ""},
}
for _, tt := range tests {
assert.Equal(t, tt.want, filter.CleanText(tt.input), "CleanText(%q)", tt.input)
}
}