# `GunsNPrices`
[🔗](https://github.com/gunsnprices/gunsnprices-elixir/blob/v0.1.0/lib/gunsnprices.ex#L1)

Parse firearm and ammunition product titles into structured data: brand, model, caliber, magazine capacity, barrel
length, rounds per box and cost per round.

    iex> info = GunsNPrices.parse_title("Glock 19 Gen 5 MOS 9mm 4.02in 15rd Pistol PA195S203MOS")
    iex> {info.model.model, info.caliber.name, info.capacity, info.barrel_length}
    {"Glock 19 Gen 5 MOS", "9×19mm Parabellum", 15, 4.02}

This is the title parser behind [GunsNPrices](https://gunsnprices.com), a price comparison site that tracks 40+ US
gun and ammo stores. Store titles are messy (`G19 GEN5 9MM 15RD FS`, `O/U 12/28 BL/WD 3"`,
`Federal 308 150gr ... 20rds`), and matching the same product across stores starts with reading them reliably. The
model dictionaries (50 brands) and the caliber list (293 calibers) compiled into this package are the ones the site
uses to group listings, so you get the same model names you see on gunsnprices.com.

It is a port of the [Python package](https://pypi.org/project/gunsnprices/) and gives the same results: the test
suite checks 1,150 recorded store titles against the Python output.

## What it recognizes

| Field | Example in a title | Result |
|---|---|---|
| Firearm or ammo | `... 150gr ... 20rds` | `:ammo` |
| Brand and model | `G19 Gen5 MOS`, part number `PA195S203` | `Glock 19 Gen 5 MOS` |
| Calibers | `.357 Mag / .38 Spl`, `6.5 CM`, `12ga`, `300 BLK`, `7MM RM` | `.357 Mag`, `.38 S&W Special`, ... |
| Magazine capacity | `15+1`, `17RD`, `13rd/15rd`, `10-shot` | `15`, `17`, `15`, `10` |
| Barrel length, inches | `4.02in`, `16 1/2"`, `22 BBL`, `O/U 12/28` | `4.02`, `16.5`, `22.0`, `28.0` |
| Rounds per box | `50/Box`, `Box of 20`, `1,000 Rounds`, `20/25` | `50`, `20`, `1000`, `20` |
| Cost per round | price `449.99` + `1000 Rounds` | `0.45` |

## Performance

The data is compiled into the package; the rules are compiled on first use (a fraction of a second, see
`warm_up/0`) and kept in `:persistent_term`. All functions are pure and can be called from any process.

## Data license

The code is MIT. The model dictionaries and caliber list (`priv/data/*.json`) are © GunsNPrices, licensed under
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/): credit "GunsNPrices (gunsnprices.com)" with a link to
<https://gunsnprices.com> if you redistribute them.

# `option`

```elixir
@type option() ::
  {:brand, String.t() | nil}
  | {:mpn, String.t() | nil}
  | {:sku, String.t() | nil}
  | {:price, number() | nil}
  | {:kind, :firearm | :ammo | String.t() | nil}
```

What you know about a listing besides its title (all optional):

  * `:brand` - manufacturer (helps with titles that don't name the brand)
  * `:mpn` - manufacturer part number (Glock part numbers encode the model and generation)
  * `:sku` - store SKU
  * `:price` - price of the listing; with ammo it gives the cost per round
  * `:kind` - `:firearm` or `:ammo` (also `"firearm"` / `"ammo"`); guessed from the title when not given

# `barrel_length`

```elixir
@spec barrel_length(String.t() | nil) :: float() | nil
```

Barrel length in inches from a firearm title (`4.25"`, "16 in", "20-inch", `16 1/2"`, "22 BBL"), or `nil`.

Chamber length (3" on shotguns), twist rate, handguard and length-of-pull values are skipped.

    iex> GunsNPrices.barrel_length("Ruger GP100 .357 Mag 4.2in Revolver")
    4.2
    iex> GunsNPrices.barrel_length(~s(Mossberg 590 Persuader 12/20 BL/SY 3"))
    20.0

# `caliber_by_id`

```elixir
@spec caliber_by_id(String.t()) :: GunsNPrices.Caliber.t() | nil
```

The caliber with this id, or `nil`.

    iex> GunsNPrices.caliber_by_id("12gauge").name
    "12 Gauge"

# `cost_per_round`

```elixir
@spec cost_per_round(number() | nil, integer() | nil) :: float() | nil
```

Price of one round, rounded to 4 decimals (half to even, like Python), or `nil` without a price or a round count.

    iex> GunsNPrices.cost_per_round(18.99, 50)
    0.3798

# `find_caliber`

```elixir
@spec find_caliber(String.t() | nil) :: GunsNPrices.Caliber.t() | nil
```

The first caliber mentioned in `text`, or `nil`.

    iex> GunsNPrices.find_caliber("Hornady 6.5 CM 140gr ELD Match").id
    "6-5mmcreedmoor"

# `find_calibers`

```elixir
@spec find_calibers(String.t() | nil) :: [GunsNPrices.Caliber.t()]
```

All calibers mentioned in `text`, in order of appearance, without duplicates.

    iex> GunsNPrices.find_calibers("Ruger GP100 .357 Mag / .38 Special 4.2in Revolver") |> Enum.map(& &1.name)
    [".357 Mag", ".38 S&W Special"]

# `identify_model`

```elixir
@spec identify_model(String.t() | nil, [option()]) :: GunsNPrices.ModelMatch.t() | nil
```

Find the brand, model family and model in a firearm product title.

Returns `nil` when no covered brand/model is recognized. Only firearms are covered: titles of accessories, parts and
airguns return `nil`. Takes the `:brand`, `:mpn` and `:sku` options (see `t:option/0`); with the part number it
decodes Glock models when the title says nothing useful.

    iex> m = GunsNPrices.identify_model("Glock 19 Gen 5 MOS 9mm 4.02in 15rd Pistol PA195S203MOS")
    iex> {m.family, m.model, m.generation, m.configs}
    {"Glock 19", "Glock 19 Gen 5 MOS", "5", ["MOS"]}
    iex> GunsNPrices.identify_model("GLOCK 9MM PISTOL 15RD", brand: "Glock", mpn: "UA4350201").model
    "Glock 43"
    iex> GunsNPrices.identify_model("Glock 19 holster")
    nil

# `list_brands`

```elixir
@spec list_brands() :: [String.t()]
```

Brands covered by the model dictionaries, in dictionary order.

    iex> brands = GunsNPrices.list_brands()
    iex> {length(brands), "Glock" in brands}
    {50, true}

# `list_calibers`

```elixir
@spec list_calibers() :: [GunsNPrices.Caliber.t()]
```

The full caliber list (293 calibers), sorted by name.

# `looks_like_ammo?`

```elixir
@spec looks_like_ammo?(String.t() | nil) :: boolean()
```

True if the title reads like ammunition (bullet weight, "ammo", "20/box", ...) rather than a firearm.

    iex> GunsNPrices.looks_like_ammo?("Winchester 308 150gr PowerMax Bonded 20rds")
    true

# `magazine_capacity`

```elixir
@spec magazine_capacity(String.t() | nil) :: pos_integer() | nil
```

Magazine capacity of a firearm or magazine from its title ("17RD", "15+1", "10-shot"), or `nil`.

For "15+1" the magazine capacity (15) is returned; with two magazine sizes ("13rd/15rd") the larger one. Use it on
firearm and magazine titles: in ammunition titles "20 rounds" means rounds per box (see `rounds_per_box/1`).

    iex> GunsNPrices.magazine_capacity("Glock 19 Gen5 9mm 4.02in 15+1 Black")
    15

# `normalize_caliber`

```elixir
@spec normalize_caliber(String.t() | nil) :: GunsNPrices.Caliber.t() | nil
```

Normalize a caliber field ("9mm Luger", "5.56 NATO", "12 GA") to a `GunsNPrices.Caliber`.

The whole value is looked up first; if it isn't a known spelling, the first caliber found inside it is returned.

    iex> c = GunsNPrices.normalize_caliber("308 WIN (7.62X51 NATO)")
    iex> {c.id, c.name, c.type}
    {"308win", ".308 Win", :rifle}

# `parse_title`

```elixir
@spec parse_title(String.t() | nil, [option()]) :: GunsNPrices.ProductInfo.t()
```

Parse a product title into brand, model, caliber, capacity, barrel length, rounds per box and cost per round.

The kind (firearm or ammo) is guessed from the title unless the `:kind` option is given. Firearms get `model`,
`capacity` and `barrel_length`; ammunition gets `rounds` and, with the `:price` option, `cost_per_round`.

    iex> info = GunsNPrices.parse_title("Federal American Eagle 9mm Luger 115gr FMJ, 50/Box", price: 18.99)
    iex> {info.kind, info.caliber.id, info.rounds, info.cost_per_round}
    {:ammo, "9x19mmparabellum", 50, 0.3798}

# `rounds_per_box`

```elixir
@spec rounds_per_box(String.t() | nil) :: pos_integer() | nil
```

Number of rounds in one package of ammunition ("20rds", "50/box", "Box of 25", "1,000 Rounds"), or `nil`.

    iex> GunsNPrices.rounds_per_box("Federal American Eagle 9mm 115gr FMJ, 50/Box")
    50

# `version`

```elixir
@spec version() :: String.t()
```

Version of this package.

# `warm_up`

```elixir
@spec warm_up() :: :ok
```

Compile all rules now instead of on first use (for example in your application's `start/2`).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
