diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8ea271..86e8c1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ on: - main env: - VERSION_NUMBER: 'v1.8.5' + VERSION_NUMBER: 'v1.8.6' DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli' AWS_REGION: 'us-west-2' diff --git a/.gitignore b/.gitignore index 0fb4852..57a8688 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ card_data/storage/ CLAUDE.md REFACTORING.md /card_data/.codspeed/ +/.ai/ diff --git a/.goreleaser.yml b/.goreleaser.yml index 0918c3a..c1b0403 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -14,7 +14,7 @@ builds: - windows - darwin ldflags: - - -s -w -X main.version=v1.8.5 + - -s -w -X main.version=v1.8.6 archives: - formats: [ 'zip' ] diff --git a/Dockerfile b/Dockerfile index 29f2917..2d12483 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN go mod download COPY . . -RUN go build -ldflags "-X main.version=v1.8.5" -o poke-cli . +RUN go build -ldflags "-X main.version=v1.8.6" -o poke-cli . # build 2 FROM --platform=$BUILDPLATFORM alpine:3.23 diff --git a/README.md b/README.md index e3e7afc..0c6ff71 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ pokemon-logo

version-label - docker-image-size + docker-image-size ci-status-badge
@@ -96,11 +96,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an 3. Choose how to interact with the container: * Run a single command and exit: ```bash - docker run --rm -it digitalghostdev/poke-cli:v1.8.5 [subcommand] [flag] + docker run --rm -it digitalghostdev/poke-cli:v1.8.6 [subcommand] [flag] ``` * Enter the container and use its shell: ```bash - docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.5 -c "cd /app && exec sh" + docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.6 -c "cd /app && exec sh" # placed into the /app directory, run the program with './poke-cli' # example: ./poke-cli ability swift-swim ``` diff --git a/card_data/README.md b/card_data/README.md index 9017a98..2ffd2d9 100644 --- a/card_data/README.md +++ b/card_data/README.md @@ -7,7 +7,7 @@ and decided to process all the data myself, load it into Supabase, and read from ## Data Architecture Runs at 2:00PM PST daily. -![data_diagram](https://poke-cli-s3-bucket.s3.us-west-2.amazonaws.com/data_infrastructure.png) +![data_diagram](data_infrastructure_v2.png) 1. TCGPlayer pricing data and TCGDex card data are called and processed through a data pipeline orchestrated by Dagster and hosted on AWS. diff --git a/card_data/data_infrastructure_v2.png b/card_data/data_infrastructure_v2.png new file mode 100644 index 0000000..cae98e9 Binary files /dev/null and b/card_data/data_infrastructure_v2.png differ diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/card_data/pipelines/poke_cli_dbt/dbt_project.yml index d4cd880..46f6ddf 100644 --- a/card_data/pipelines/poke_cli_dbt/dbt_project.yml +++ b/card_data/pipelines/poke_cli_dbt/dbt_project.yml @@ -1,5 +1,5 @@ name: 'poke_cli_dbt' -version: '1.8.5' +version: '1.8.6' profile: 'poke_cli_dbt' diff --git a/card_data/pipelines/poke_cli_dbt/macros/create_view.sql b/card_data/pipelines/poke_cli_dbt/macros/create_view.sql index 5e940f1..ba0a52d 100644 --- a/card_data/pipelines/poke_cli_dbt/macros/create_view.sql +++ b/card_data/pipelines/poke_cli_dbt/macros/create_view.sql @@ -10,7 +10,8 @@ "localId", "set_cardCount_official", CONCAT(name, ' - ', LPAD("localId", 3, '0'), '/', LPAD("set_cardCount_official"::text, 3, '0')) AS card_combined_name, - set_name + set_name, + regulation_mark FROM public.cards ), cards_pricing_cte AS ( @@ -30,7 +31,8 @@ LPAD(c."localId", 3, '0') AS "localId", p."market_price", COALESCE(p."card_number", LPAD(c."localId", 3, '0')) AS card_number, - c.illustrator + c.illustrator, + c.regulation_mark FROM cards_cte AS c LEFT JOIN diff --git a/card_data/pipelines/poke_cli_dbt/models/cards.sql b/card_data/pipelines/poke_cli_dbt/models/cards.sql index b908778..c49bbe4 100644 --- a/card_data/pipelines/poke_cli_dbt/models/cards.sql +++ b/card_data/pipelines/poke_cli_dbt/models/cards.sql @@ -3,5 +3,6 @@ post_hook="{{ enable_rls() }}" ) }} -SELECT id, set_id, image, name, "localId", category, hp, "set_cardCount_official", set_name, illustrator -FROM {{ source('staging', 'cards') }} \ No newline at end of file +SELECT id, set_id, image, name, "localId", category, hp, "set_cardCount_official", set_name, illustrator, "regulationMark" AS regulation_mark +FROM {{ source('staging', 'cards') }} +WHERE "localId" ~ '^[0-9]+$' \ No newline at end of file diff --git a/card_data/pyproject.toml b/card_data/pyproject.toml index f257343..3e1a108 100644 --- a/card_data/pyproject.toml +++ b/card_data/pyproject.toml @@ -10,8 +10,8 @@ dependencies = [ "dagster>=1.11.3", "dagster-dbt>=0.27.3", "dagster-dg-cli>=1.11.3", - "dagster-postgres>=0.27.3", - "dagster-webserver>=1.11.3", + "dagster-postgres==0.28.7", + "dagster-webserver==1.12.7", "dbt-core>=1.10.8", "dbt-postgres>=1.9.0", "pandas>=2.3.1", @@ -27,10 +27,10 @@ dependencies = [ [dependency-groups] dev = [ - "dagster-webserver", + "dagster-webserver==1.12.7", "dagster-dg-cli", "dagster-dbt>=0.27.3", - "dagster-postgres>=0.27.3", + "dagster-postgres==0.28.7", "pytest>=9.0.2", "pytest-codspeed>=4.2.0", "responses>=0.25.8", diff --git a/card_data/uv.lock b/card_data/uv.lock index dcc428a..39d1cf6 100644 --- a/card_data/uv.lock +++ b/card_data/uv.lock @@ -180,8 +180,8 @@ requires-dist = [ { name = "dagster", specifier = ">=1.11.3" }, { name = "dagster-dbt", specifier = ">=0.27.3" }, { name = "dagster-dg-cli", specifier = ">=1.11.3" }, - { name = "dagster-postgres", specifier = ">=0.27.3" }, - { name = "dagster-webserver", specifier = ">=1.11.3" }, + { name = "dagster-postgres", specifier = "==0.28.7" }, + { name = "dagster-webserver", specifier = "==1.12.7" }, { name = "dbt-core", specifier = ">=1.10.8" }, { name = "dbt-postgres", specifier = ">=1.9.0" }, { name = "pandas", specifier = ">=2.3.1" }, diff --git a/cli.go b/cli.go index 8cc8502..39cfd47 100644 --- a/cli.go +++ b/cli.go @@ -24,6 +24,30 @@ import ( var version = "(devel)" +var commandDescriptions = []struct { + name string + desc string +}{ + {"ability", "Get details about an ability"}, + {"berry", "Get details about a berry"}, + {"card", "Get details about a TCG card"}, + {"item", "Get details about an item"}, + {"move", "Get details about a move"}, + {"natures", "Get details about all natures"}, + {"pokemon", "Get details about a Pokémon"}, + {"search", "Search for a resource"}, + {"speed", "Calculate the speed of a Pokémon in battle"}, + {"types", "Get details about a typing"}, +} + +func renderCommandList() string { + var sb strings.Builder + for _, cmd := range commandDescriptions { + sb.WriteString(fmt.Sprintf("\n\t%-15s %s", cmd.name, cmd.desc)) + } + return sb.String() +} + func currentVersion() { if version != "(devel)" { // Use version injected by -ldflags @@ -70,16 +94,7 @@ func runCLI(args []string) int { fmt.Sprintf("\n\t%-15s %s", "-l, --latest", "Prints the latest version available"), fmt.Sprintf("\n\t%-15s %s", "-v, --version", "Prints the current version"), "\n\n", styling.StyleBold.Render("COMMANDS:"), - fmt.Sprintf("\n\t%-15s %s", "ability", "Get details about an ability"), - fmt.Sprintf("\n\t%-15s %s", "berry", "Get details about a berry"), - fmt.Sprintf("\n\t%-15s %s", "card", "Get details about a TCG card"), - fmt.Sprintf("\n\t%-15s %s", "item", "Get details about an item"), - fmt.Sprintf("\n\t%-15s %s", "move", "Get details about a move"), - fmt.Sprintf("\n\t%-15s %s", "natures", "Get details about all natures"), - fmt.Sprintf("\n\t%-15s %s", "pokemon", "Get details about a Pokémon"), - fmt.Sprintf("\n\t%-15s %s", "search", "Search for a resource"), - fmt.Sprintf("\n\t%-15s %s", "speed", "Calculate the speed of a Pokémon in battle"), - fmt.Sprintf("\n\t%-15s %s", "types", "Get details about a typing"), + renderCommandList(), "\n\n", styling.StyleItalic.Render(styling.HyphenHint), "\n", styling.StyleItalic.Render("example: poke-cli ability strong-jaw"), "\n", styling.StyleItalic.Render("example: poke-cli pokemon flutter-mane"), @@ -151,16 +166,7 @@ func runCLI(args []string) int { styling.ErrorColor.Render("✖ Error!"), fmt.Sprintf("\n\t%-15s", fmt.Sprintf("'%s' is not a valid command.\n", cmdArg)), styling.StyleBold.Render("\nCommands:"), - fmt.Sprintf("\n\t%-15s %s", "ability", "Get details about an ability"), - fmt.Sprintf("\n\t%-15s %s", "berry", "Get details about a berry"), - fmt.Sprintf("\n\t%-15s %s", "card", "Get details about a TCG card"), - fmt.Sprintf("\n\t%-15s %s", "item", "Get details about an item"), - fmt.Sprintf("\n\t%-15s %s", "move", "Get details about a move"), - fmt.Sprintf("\n\t%-15s %s", "natures", "Get details about all natures"), - fmt.Sprintf("\n\t%-15s %s", "pokemon", "Get details about a Pokémon"), - fmt.Sprintf("\n\t%-15s %s", "search", "Search for a resource"), - fmt.Sprintf("\n\t%-15s %s", "speed", "Calculate the speed of a Pokémon in battle"), - fmt.Sprintf("\n\t%-15s %s", "types", "Get details about a typing"), + renderCommandList(), fmt.Sprintf("\n\nAlso run %s for more info!", styling.StyleBold.Render("poke-cli -h")), ) output.WriteString(errMessage) diff --git a/cmd/ability/ability.go b/cmd/ability/ability.go index e8ec795..76d8e30 100644 --- a/cmd/ability/ability.go +++ b/cmd/ability/ability.go @@ -99,8 +99,7 @@ func AbilityCommand() (string, error) { if *af.Pokemon || *af.ShortPokemon { if err := flags.PokemonAbilitiesFlag(&output, endpoint, abilityName); err != nil { - output.WriteString(fmt.Sprintf("error parsing flags: %v\n", err)) - return output.String(), fmt.Errorf("error parsing flags: %w", err) + return utils.HandleFlagError(&output, err) } } diff --git a/cmd/card/cardlist.go b/cmd/card/cardlist.go index ba511e5..6627f1b 100644 --- a/cmd/card/cardlist.go +++ b/cmd/card/cardlist.go @@ -14,15 +14,16 @@ import ( ) type CardsModel struct { - Choice string - IllustratorMap map[string]string - ImageMap map[string]string - PriceMap map[string]string - Quitting bool - SelectedOption string - SeriesName string - Table table.Model - ViewImage bool + Choice string + IllustratorMap map[string]string + ImageMap map[string]string + PriceMap map[string]string + RegulationMarkMap map[string]string + Quitting bool + SelectedOption string + SeriesName string + Table table.Model + ViewImage bool } func (m CardsModel) Init() tea.Cmd { @@ -70,7 +71,8 @@ func (m CardsModel) View() string { price = "Price: Not available" } illustrator := m.IllustratorMap[cardName] - selectedCard = cardName + "\n---\n" + price + "\n---\n" + illustrator + regulationMark := m.RegulationMarkMap[cardName] + selectedCard = cardName + "\n---\n" + price + "\n---\n" + illustrator + "\n---\n" + regulationMark } leftPanel := styling.TypesTableBorder.Render(m.Table.View()) @@ -96,11 +98,12 @@ type cardData struct { MarketPrice float64 `json:"market_price"` Name string `json:"name"` NumberPlusName string `json:"number_plus_name"` + RegulationMark string `json:"regulation_mark"` } // CardsList creates and returns a new CardsModel with cards from a specific set func CardsList(setID string) (CardsModel, error) { - url := fmt.Sprintf("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/card_pricing_view?set_id=eq.%s&select=number_plus_name,market_price,image_url,illustrator&order=localId", setID) + url := fmt.Sprintf("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/card_pricing_view?set_id=eq.%s&select=number_plus_name,market_price,image_url,illustrator,regulation_mark&order=localId", setID) body, err := getCardData(url) if err != nil { return CardsModel{}, fmt.Errorf("failed to fetch card data: %w", err) @@ -117,6 +120,7 @@ func CardsList(setID string) (CardsModel, error) { priceMap := make(map[string]string) imageMap := make(map[string]string) illustratorMap := make(map[string]string) + regulationMarkMap := make(map[string]string) for i, card := range allCards { rows[i] = []string{card.NumberPlusName} if card.MarketPrice != 0 { @@ -131,6 +135,12 @@ func CardsList(setID string) (CardsModel, error) { illustratorMap[card.NumberPlusName] = "Illustrator not available" } + if card.RegulationMark != "" { + regulationMarkMap[card.NumberPlusName] = "Regulation: " + card.RegulationMark + } else { + regulationMarkMap[card.NumberPlusName] = "Regulation not available" + } + imageMap[card.NumberPlusName] = card.ImageURL } @@ -155,6 +165,7 @@ func CardsList(setID string) (CardsModel, error) { IllustratorMap: illustratorMap, ImageMap: imageMap, PriceMap: priceMap, + RegulationMarkMap: regulationMarkMap, Table: t, }, nil } diff --git a/cmd/card/cardlist_test.go b/cmd/card/cardlist_test.go index 36a2b17..e3c51e9 100644 --- a/cmd/card/cardlist_test.go +++ b/cmd/card/cardlist_test.go @@ -11,6 +11,10 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +// testSupabaseKey is the publishable API key used in tests. +// Extracted to a constant for easier maintenance if the key changes. +const testSupabaseKey = "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j" + func TestCardsModel_Init(t *testing.T) { model := CardsModel{ SeriesName: "sv", @@ -305,10 +309,10 @@ func TestCardsList_EmptyResult(t *testing.T) { func TestCallCardData_SendsHeadersAndReturnsBody(t *testing.T) { // Start a test HTTP server that validates headers and returns a body srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("apikey"); got != "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j" { + if got := r.Header.Get("apikey"); got != testSupabaseKey { t.Fatalf("missing or wrong apikey header: %q", got) } - if got := r.Header.Get("Authorization"); got != "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j" { + if got := r.Header.Get("Authorization"); got != "Bearer "+testSupabaseKey { t.Fatalf("missing or wrong Authorization header: %q", got) } if got := r.Header.Get("Content-Type"); got != "application/json" { diff --git a/cmd/card/setslist_test.go b/cmd/card/setslist_test.go index c1b0118..1fcdc80 100644 --- a/cmd/card/setslist_test.go +++ b/cmd/card/setslist_test.go @@ -192,10 +192,10 @@ func TestSetsModel_Update_EnterKey(t *testing.T) { func TestCallSetsData_SendsHeadersAndReturnsBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("apikey"); got != "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j" { + if got := r.Header.Get("apikey"); got != testSupabaseKey { t.Fatalf("missing or wrong apikey header: %q", got) } - if got := r.Header.Get("Authorization"); got != "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j" { + if got := r.Header.Get("Authorization"); got != "Bearer "+testSupabaseKey { t.Fatalf("missing or wrong Authorization header: %q", got) } if got := r.Header.Get("Content-Type"); got != "application/json" { diff --git a/cmd/search/parse.go b/cmd/search/parse.go index 50b7e7b..e92d51b 100644 --- a/cmd/search/parse.go +++ b/cmd/search/parse.go @@ -1,9 +1,12 @@ package search import ( + "fmt" + "regexp" "strings" "github.com/digitalghost-dev/poke-cli/connections" + "github.com/schollz/closestmatch" ) type Resource struct { @@ -19,40 +22,94 @@ type Result struct { URL string `json:"url"` } -func parseSearch(results []Result, search string) []Result { - var x int - var substr string - - for _, result := range results { - if string(search[0]) == "^" { - substr = search[1:] - if len(substr) > len(result.Name) { - continue - } - if result.Name[0:len(substr)] != substr { - continue - } - } else { - if !strings.Contains(result.Name, search) { - continue - } +func containsRegexChars(s string) bool { + return strings.ContainsAny(s, "^$.*+?[]{}()|\\") +} + +func parseRegex(results []Result, pattern string) ([]Result, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid regex pattern: %w", err) + } + + filtered := make([]Result, 0) + for _, r := range results { + if re.MatchString(r.Name) { + filtered = append(filtered, r) + } + } + return filtered, nil +} + +func parseFuzzy(results []Result, search string) []Result { + name := make([]string, len(results)) + for i, r := range results { + name[i] = r.Name + } + + bagSizes := []int{2, 3, 4} + cm := closestmatch.New(name, bagSizes) + + matches := cm.ClosestN(search, 10) + + matchSet := make(map[string]struct{}, len(matches)) + for _, m := range matches { + matchSet[m] = struct{}{} + } + + filtered := make([]Result, 0, len(matches)) + for _, r := range results { + if _, ok := matchSet[r.Name]; ok { + filtered = append(filtered, r) + } + } + return filtered +} + +func parseContains(results []Result, search string) []Result { + needle := strings.ToLower(strings.TrimSpace(search)) + if needle == "" { + return results + } + + filtered := make([]Result, 0) + for _, r := range results { + if strings.Contains(strings.ToLower(r.Name), needle) { + filtered = append(filtered, r) } - results[x] = result - x++ } - return results[:x] + return filtered +} + +func parseSearch(results []Result, search string) ([]Result, error) { + if strings.TrimSpace(search) == "" { + return results, nil + } + + if containsRegexChars(search) { + return parseRegex(results, search) + } + + if filtered := parseContains(results, search); len(filtered) > 0 { + return filtered, nil + } + + return parseFuzzy(results, search), nil } var apiCall = connections.ApiCallSetup // set as a var for testability -// Search returns resources list, filtered by resources term. +// Search returns a resources list, filtered by resources term. func query(endpoint string, search string) (result Resource, err error) { url := connections.APIURL + endpoint + "/?offset=0&limit=9999" err = apiCall(url, &result, false) if err != nil { return } - result.Results = parseSearch(result.Results, search) + result.Results, err = parseSearch(result.Results, search) + if err != nil { + return + } result.Count = len(result.Results) return } diff --git a/cmd/search/parse_test.go b/cmd/search/parse_test.go index e3cd81b..2a0ac86 100644 --- a/cmd/search/parse_test.go +++ b/cmd/search/parse_test.go @@ -4,52 +4,178 @@ import "testing" func TestParseSearch(t *testing.T) { mockResults := []Result{ + {Name: "hariyama"}, {Name: "pikachu"}, {Name: "raichu"}, {Name: "pidgey"}, {Name: "sandshrew"}, + {Name: "bulbasaur"}, + {Name: "charmander"}, + {Name: "charmeleon"}, + {Name: "squirtle"}, + {Name: "musharna"}, + {Name: "caterpie"}, + {Name: "weedle"}, + {Name: "rattata"}, } - tests := []struct { - name string - search string - expected []string - }{ - { - name: "Contains match", - search: "chu", - expected: []string{"pikachu", "raichu"}, - }, - { - name: "Prefix match", - search: "^pi", - expected: []string{"pikachu", "pidgey"}, - }, - { - name: "No match", - search: "^z", - expected: []string{}, - }, - { - name: "Contains s", - search: "s", - expected: []string{"sandshrew"}, - }, - } + t.Run("Substring match prefers contains over fuzzy", func(t *testing.T) { + filtered, err := parseSearch(mockResults, "hari") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(filtered) != 1 { + t.Fatalf("expected 1 result, got %d", len(filtered)) + } + if filtered[0].Name != "hariyama" { + t.Fatalf("expected hariyama, got %s", filtered[0].Name) + } + }) + + t.Run("Contains is case-insensitive and trims whitespace", func(t *testing.T) { + filtered, err := parseSearch(mockResults, " HARI ") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(filtered) != 1 { + t.Fatalf("expected 1 result, got %d", len(filtered)) + } + if filtered[0].Name != "hariyama" { + t.Fatalf("expected hariyama, got %s", filtered[0].Name) + } + }) + + t.Run("Fuzzy matching is used when contains has no matches", func(t *testing.T) { + filtered, err := parseSearch(mockResults, "charmender") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(filtered) == 0 { + t.Fatalf("expected fuzzy results, got 0") + } + + found := map[string]bool{} + for _, r := range filtered { + found[r.Name] = true + } + if !found["charmander"] { + t.Fatalf("expected fuzzy results to include charmander, got %#v", found) + } + }) - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - filtered := parseSearch(mockResults, tc.search) - if len(filtered) != len(tc.expected) { - t.Errorf("expected %d results, got %d", len(tc.expected), len(filtered)) + t.Run("Regex prefix match ^", func(t *testing.T) { + filtered, err := parseSearch(mockResults, "^pi") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"pikachu", "pidgey"} + if len(filtered) != len(expected) { + t.Fatalf("expected %d results, got %d", len(expected), len(filtered)) + } + for i, result := range filtered { + if result.Name != expected[i] { + t.Errorf("expected %s, got %s", expected[i], result.Name) } - for i, result := range filtered { - if result.Name != tc.expected[i] { - t.Errorf("expected %s, got %s", tc.expected[i], result.Name) - } + } + }) + + t.Run("Regex suffix match $", func(t *testing.T) { + filtered, err := parseSearch(mockResults, "chu$") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"pikachu", "raichu"} + if len(filtered) != len(expected) { + t.Fatalf("expected %d results, got %d", len(expected), len(filtered)) + } + for i, result := range filtered { + if result.Name != expected[i] { + t.Errorf("expected %s, got %s", expected[i], result.Name) } - }) - } + } + }) + + t.Run("Regex no match", func(t *testing.T) { + filtered, err := parseSearch(mockResults, "^z") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(filtered) != 0 { + t.Errorf("expected 0 results, got %d", len(filtered)) + } + }) + + t.Run("Regex character class []", func(t *testing.T) { + filtered, err := parseSearch(mockResults, "^[rs]") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"raichu", "sandshrew", "squirtle", "rattata"} + if len(filtered) != len(expected) { + t.Fatalf("expected %d results, got %d", len(expected), len(filtered)) + } + for i, result := range filtered { + if result.Name != expected[i] { + t.Errorf("expected %s, got %s", expected[i], result.Name) + } + } + }) + + t.Run("Regex alternation |", func(t *testing.T) { + filtered, err := parseSearch(mockResults, "^(pikachu|bulbasaur)$") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"pikachu", "bulbasaur"} + if len(filtered) != len(expected) { + t.Fatalf("expected %d results, got %d", len(expected), len(filtered)) + } + for i, result := range filtered { + if result.Name != expected[i] { + t.Errorf("expected %s, got %s", expected[i], result.Name) + } + } + }) + + t.Run("Regex one or more +", func(t *testing.T) { + // Matches any name containing one or more 'e' + filtered, err := parseSearch(mockResults, "e+") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"pidgey", "sandshrew", "charmander", "charmeleon", "squirtle", "caterpie", "weedle"} + if len(filtered) != len(expected) { + t.Fatalf("expected %d results, got %d", len(expected), len(filtered)) + } + for i, result := range filtered { + if result.Name != expected[i] { + t.Errorf("expected %s, got %s", expected[i], result.Name) + } + } + }) + + t.Run("Regex optional ?", func(t *testing.T) { + filtered, err := parseSearch(mockResults, "^chu?arm") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"charmander", "charmeleon"} + if len(filtered) != len(expected) { + t.Fatalf("expected %d results, got %d", len(expected), len(filtered)) + } + for i, result := range filtered { + if result.Name != expected[i] { + t.Errorf("expected %s, got %s", expected[i], result.Name) + } + } + }) + + t.Run("Invalid regex returns error", func(t *testing.T) { + _, err := parseSearch(mockResults, "[invalid") + if err == nil { + t.Error("expected error for invalid regex, got nil") + } + }) } func TestQuery(t *testing.T) { @@ -68,8 +194,8 @@ func TestQuery(t *testing.T) { return nil } - // Now call the query - res, err := query("pokemon", "chu") + // Now call the query with regex pattern + res, err := query("pokemon", ".*chu.*") if err != nil { t.Fatalf("expected no error, got %v", err) } diff --git a/go.mod b/go.mod index a4c89cc..0876fed 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/schollz/closestmatch v2.1.0+incompatible // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/go.sum b/go.sum index a31baba..78e742d 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiyyjYS17cCYRqP13/SHk= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/nfpm.yaml b/nfpm.yaml index e70c445..65a9d59 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -1,7 +1,7 @@ name: "poke-cli" arch: "arm64" platform: "linux" -version: "v1.8.5" +version: "v1.8.6" section: "default" version_schema: semver maintainer: "Christian S" diff --git a/testdata/cli_help.golden b/testdata/cli_help.golden index 9596b16..3e7c9bb 100644 --- a/testdata/cli_help.golden +++ b/testdata/cli_help.golden @@ -1,32 +1,32 @@ -╭───────────────────────────────────────────────────────────────╮ -│Welcome! This tool displays data related to Pokémon! │ -│ │ -│ USAGE: │ -│ poke-cli [flag] │ -│ poke-cli [flag] │ -│ poke-cli [flag] │ -│ │ -│ FLAGS: │ -│ -h, --help Shows the help menu │ -│ -l, --latest Prints the latest version available │ -│ -v, --version Prints the current version │ -│ │ -│ COMMANDS: │ -│ ability Get details about an ability │ -│ berry Get details about a berry │ -│ card Get details about a TCG card │ -│ item Get details about an item │ -│ move Get details about a move │ -│ natures Get details about all natures │ -│ pokemon Get details about a Pokémon │ -│ search Search for a resource │ -│ speed Calculate the speed of a Pokémon in battle │ -│ types Get details about a typing │ -│ │ -│ Use a hyphen when typing a name with a space. │ -│ example: poke-cli ability strong-jaw │ -│ example: poke-cli pokemon flutter-mane │ -│ │ -│ ↓ ctrl/cmd + click for docs/guides │ -│ ]8;;https://docs.poke-cli.com\docs.poke-cli.com]8;;\ │ -╰───────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────╮ +│Welcome! This tool displays data related to Pokémon! │ +│ │ +│ USAGE: │ +│ poke-cli [flag] │ +│ poke-cli [flag] │ +│ poke-cli [flag] │ +│ │ +│ FLAGS: │ +│ -h, --help Shows the help menu │ +│ -l, --latest Prints the latest version available │ +│ -v, --version Prints the current version │ +│ │ +│ COMMANDS: │ +│ ability Get details about an ability │ +│ berry Get details about a berry │ +│ card Get details about a TCG card │ +│ item Get details about an item │ +│ move Get details about a move │ +│ natures Get details about all natures │ +│ pokemon Get details about a Pokémon │ +│ search Search for a resource │ +│ speed Calculate the speed of a Pokémon in battle│ +│ types Get details about a typing │ +│ │ +│ Use a hyphen when typing a name with a space. │ +│ example: poke-cli ability strong-jaw │ +│ example: poke-cli pokemon flutter-mane │ +│ │ +│ ↓ ctrl/cmd + click for docs/guides │ +│ ]8;;https://docs.poke-cli.com\docs.poke-cli.com]8;;\ │ +╰──────────────────────────────────────────────────────────────╯ diff --git a/testdata/cli_incorrect_command.golden b/testdata/cli_incorrect_command.golden index 67df972..9481b63 100644 --- a/testdata/cli_incorrect_command.golden +++ b/testdata/cli_incorrect_command.golden @@ -1,18 +1,18 @@ -╭───────────────────────────────────────────────────────────────╮ -│✖ Error! │ -│ 'movesets' is not a valid command. │ -│ │ -│Commands: │ -│ ability Get details about an ability │ -│ berry Get details about a berry │ -│ card Get details about a TCG card │ -│ item Get details about an item │ -│ move Get details about a move │ -│ natures Get details about all natures │ -│ pokemon Get details about a Pokémon │ -│ search Search for a resource │ -│ speed Calculate the speed of a Pokémon in battle │ -│ types Get details about a typing │ -│ │ -│Also run poke-cli -h for more info! │ -╰───────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────╮ +│✖ Error! │ +│ 'movesets' is not a valid command. │ +│ │ +│Commands: │ +│ ability Get details about an ability │ +│ berry Get details about a berry │ +│ card Get details about a TCG card │ +│ item Get details about an item │ +│ move Get details about a move │ +│ natures Get details about all natures │ +│ pokemon Get details about a Pokémon │ +│ search Search for a resource │ +│ speed Calculate the speed of a Pokémon in battle│ +│ types Get details about a typing │ +│ │ +│Also run poke-cli -h for more info! │ +╰──────────────────────────────────────────────────────────────╯ diff --git a/testdata/main_latest_flag.golden b/testdata/main_latest_flag.golden index 1b715f7..78b7cbb 100644 --- a/testdata/main_latest_flag.golden +++ b/testdata/main_latest_flag.golden @@ -2,6 +2,6 @@ ┃ ┃ ┃ Latest available release ┃ ┃ on GitHub: ┃ -┃ • v1.8.4 ┃ +┃ • v1.8.5 ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛