vehicles 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,9 @@ module Vehicles
22
22
  # works on the bundled data — the gem is standalone first, SDK second.
23
23
  attr_accessor :api_key
24
24
 
25
- # Base URL for the hosted VehiclesDB API. Overridable for self-hosting/testing.
25
+ # ORIGIN of the hosted VehiclesDB API scheme + host only, no path
26
+ # (endpoint paths carry their own /v1 prefix, and URI.join would drop a
27
+ # path suffix here anyway). Overridable for self-hosting/testing.
26
28
  attr_accessor :api_base_url
27
29
 
28
30
  # Network timeout (seconds) for hosted API calls. Kept short so a slow/missing
@@ -63,7 +65,7 @@ module Vehicles
63
65
  def initialize
64
66
  @region = nil # no continent filter by default (global dataset)
65
67
  @api_key = nil
66
- @api_base_url = "https://api.vehiclesdb.com"
68
+ @api_base_url = "https://vehiclesdb.com"
67
69
  @api_timeout = 2
68
70
  @aliases = {}
69
71
  @other_label = "Other"
@@ -25,10 +25,10 @@ module Vehicles
25
25
  REGIONS = %i[eu na as sa oc af].freeze
26
26
 
27
27
  attr_reader :make, :make_slug, :name, :kind, :body_type, :global_decile,
28
- :availability, :regions, :aliases
28
+ :availability, :regions, :aliases, :former_ids
29
29
 
30
30
  # @param attrs [Hash] one model entry from the dataset (name/slug/kind +
31
- # optional body_type/global_decile/availability/regions/aliases)
31
+ # optional body_type/global_decile/availability/regions/aliases/former_ids)
32
32
  # @param make [String] the parent make's display name
33
33
  # @param make_slug [String] the parent make's slug (for the composite slug)
34
34
  def initialize(attrs, make:, make_slug:)
@@ -51,6 +51,10 @@ module Vehicles
51
51
  @regions = (attrs["regions"] || []).map(&:to_sym).freeze
52
52
  # Documented alternate names (nicknames, native scripts, market names).
53
53
  @aliases = (attrs["aliases"] || []).freeze
54
+ # Full canonical ids ("car/alfa-romeo/159sw") this record absorbed via
55
+ # the dataset's append-only migration contract (SCHEMA.md: renames
56
+ # alias, nothing is silently deleted). Empty for never-renamed records.
57
+ @former_ids = (attrs["former_ids"] || []).freeze
54
58
  freeze
55
59
  end
56
60
 
@@ -126,7 +130,8 @@ module Vehicles
126
130
  def to_h
127
131
  { make: make, model: name, slug: slug, kind: kind, body_type: body_type,
128
132
  global_decile: global_decile, rarity: rarity,
129
- availability: availability, regions: regions, aliases: aliases }
133
+ availability: availability, regions: regions, aliases: aliases,
134
+ former_ids: former_ids }
130
135
  end
131
136
 
132
137
  # Value-object equality — two models with the same slug are equal.
@@ -159,9 +164,21 @@ module Vehicles
159
164
  Vehicles.resolve(:segment, self)
160
165
  end
161
166
 
162
- # Image URL, optionally year-/color-accurate. nil without hosted data.
163
- def image(year: nil, color: nil)
164
- Vehicles.resolve(:image, self, year: year, color: color)
167
+ # One rendered image URL, ready for an <img src>. `size` picks the
168
+ # variant (:sm/:md/:lg), `color` a palette slug (Vehicles.colors); an
169
+ # un-rendered color falls back honestly server-side rather than erroring.
170
+ # `year` is accepted for forward-compatibility but not yet served.
171
+ # nil without hosted data — always render a placeholder path.
172
+ def image(year: nil, color: nil, size: :md)
173
+ Vehicles.resolve(:image, self, year: year, color: color, size: size)
174
+ end
175
+
176
+ # The full hosted images payload — every variant with dimensions, the
177
+ # rendered palette for this model, served-vs-requested color, provenance.
178
+ # Reach for this when one URL isn't enough (srcset, color pickers,
179
+ # caching whole responses). nil without hosted data.
180
+ def images(color: nil)
181
+ Vehicles.resolve(:images, self, color: color)
165
182
  end
166
183
  end
167
184
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vehicles
4
+ module Plates
5
+ # One issuing jurisdiction — a country, or a state where plates are
6
+ # state-issued ("us-fl"; a bare "us" will never exist). Wraps the raw
7
+ # dataset YAML: authority, binding (vehicle vs owner), the
8
+ # jurisdiction-wide charset law, and every series.
9
+ class Jurisdiction
10
+ attr_reader :code, :authority_name, :authority_url, :binding, :charset, :series
11
+
12
+ def self.load(path)
13
+ raw = YAML.safe_load_file(path, aliases: true)
14
+ new(code: File.basename(path, ".yml"), raw: raw)
15
+ end
16
+
17
+ def initialize(code:, raw:)
18
+ @code = code
19
+ @authority_name = raw.dig("authority", "name")
20
+ @authority_url = raw.dig("authority", "url")
21
+ @binding = raw["binding"]
22
+ @charset = (raw["charset_defaults"] || {}).freeze
23
+ @series = (raw["series"] || []).map { |entry| Series.new(entry) }
24
+ .sort_by { |s| [ s.klass == "standard" ? 0 : 1, s.klass, s.period["start"] || 0 ] }.freeze
25
+ freeze
26
+ end
27
+
28
+ def to_s = code
29
+ def inspect = %(#<Vehicles::Plates::Jurisdiction #{code} (#{series.size} series)>)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vehicles
4
+ module Plates
5
+ # One registration series: a pattern identity with a period, a class
6
+ # (standard/taxi/diplomatic/…), categories, sourced design facts, and the
7
+ # two regexes of the dataset's regex policy — `regex` (recall: the
8
+ # pattern's own alphabet) and `regex_strict` (validation: the alphabet
9
+ # the authority actually issues). Validation prefers strict.
10
+ class Series
11
+ attr_reader :id, :klass, :categories, :period, :pattern, :regex,
12
+ :regex_strict, :design, :variants, :sources, :notes,
13
+ :validation_regexp, :lenient_regexp
14
+
15
+ def initialize(entry)
16
+ @id = entry["id"]
17
+ @klass = entry["class"] || "standard"
18
+ @categories = Array(entry["categories"]).freeze
19
+ @period = (entry["period"] || {}).freeze
20
+ @pattern = entry.dig("format", "pattern")
21
+ @regex = entry.dig("format", "regex")
22
+ @regex_strict = entry.dig("format", "regex_strict")
23
+ @design = (entry["design"] || {}).freeze
24
+ @variants = Array(entry["variants"]).freeze
25
+ @sources = Array(entry["sources"]).freeze
26
+ @notes = entry["notes"]
27
+ # Compiled eagerly — the object is frozen, so no lazy memoization.
28
+ @validation_regexp = compile(@regex_strict || @regex)
29
+ @lenient_regexp = compile(self.class.strip_separators(@regex_strict || @regex))
30
+ freeze
31
+ end
32
+
33
+ # A strict-validated series has an authority-alphabet regex twin; a
34
+ # recall-only series (export/dark-blue catch-alls that accept any
35
+ # current shape) matches loosely by design. Consumers validating user
36
+ # input should weight strict hits above recall-only ones — Match does.
37
+ def strict?
38
+ !regex_strict.nil?
39
+ end
40
+
41
+ # "2000 →" (open, runs until exhausted) or "1971–2000".
42
+ def period_label
43
+ finish = period["end"]
44
+ finish ? "#{period["start"]}–#{finish}" : "#{period["start"]} →"
45
+ end
46
+
47
+ # Re-print a separator-less serial the way this series formats it:
48
+ # walk the pattern; 9/L and literal serial characters consume one input
49
+ # character, separator characters re-emerge from the pattern itself.
50
+ def format_serial(serial)
51
+ return nil if serial.nil? || pattern.nil?
52
+
53
+ idx = 0
54
+ out = pattern.each_char.with_object(+"") do |ch, str|
55
+ if [ "-", " ", "·", "." ].include?(ch)
56
+ str << ch
57
+ else
58
+ str << (serial[idx] || "")
59
+ idx += 1
60
+ end
61
+ end
62
+ out.freeze
63
+ end
64
+
65
+ # Tokens that read as "a separator" when they make up a whole
66
+ # character class — `[- ]` in the ES consular series is dash-or-space,
67
+ # i.e. a separator spelled as a class.
68
+ SEPARATOR_TOKENS = [ " ", "-", "·", ".", '\s', '\-', '\.', '\ ' ].freeze
69
+
70
+ # Remove separator LITERALS from a regex source, respecting character
71
+ # classes (the "-" inside [A-Z] is a range, not a separator; a class
72
+ # composed ONLY of separators, like `[- ]`, is a separator and goes).
73
+ # A quantifier left dangling by a removed separator ("-?", "[- ]?") is
74
+ # swallowed with it. Linear scan — regexes in this dataset are simple.
75
+ def self.strip_separators(src)
76
+ return nil if src.nil?
77
+
78
+ out = +""
79
+ chars = src.chars
80
+ i = 0
81
+ while i < chars.size
82
+ ch = chars[i]
83
+
84
+ if ch == "\\" && i + 1 < chars.size
85
+ nxt = chars[i + 1]
86
+ if [ "s", "-", ".", " " ].include?(nxt)
87
+ i += 2
88
+ i += 1 if [ "?", "*" ].include?(chars[i]) # optional separator: drop its quantifier too
89
+ else
90
+ out << ch << nxt
91
+ i += 2
92
+ end
93
+ next
94
+ end
95
+
96
+ if ch == "["
97
+ # Copy or drop the WHOLE class span in one decision.
98
+ closing = i + 1
99
+ closing += 1 while closing < chars.size && chars[closing] != "]"
100
+ content = chars[(i + 1)...closing].join
101
+ tokens = content.scan(/\\.|./m)
102
+ if tokens.any? && tokens.all? { |t| SEPARATOR_TOKENS.include?(t) }
103
+ i = closing + 1
104
+ i += 1 if [ "?", "*" ].include?(chars[i])
105
+ else
106
+ out << chars[i..closing].join
107
+ i = closing + 1
108
+ end
109
+ next
110
+ end
111
+
112
+ if [ "-", " ", "·" ].include?(ch)
113
+ i += 1
114
+ i += 1 if [ "?", "*" ].include?(chars[i])
115
+ else
116
+ out << ch
117
+ i += 1
118
+ end
119
+ end
120
+ out
121
+ end
122
+
123
+ def inspect = %(#<Vehicles::Plates::Series #{id} "#{pattern}">)
124
+
125
+ private
126
+
127
+ # Bad regex in data must degrade like every other lookup: nil, not raise.
128
+ def compile(source)
129
+ return nil if source.nil?
130
+
131
+ Regexp.new(source)
132
+ rescue RegexpError
133
+ nil
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Vehicles
6
+ # License-plate & registration-mark knowledge (the VehiclesDB PRD-PLATES
7
+ # dataset, bundled offline like everything else in this gem). Ask it two
8
+ # kinds of question:
9
+ #
10
+ # Vehicles.plates # => every Jurisdiction
11
+ # Vehicles.plates(:nl) # => one Jurisdiction (series, charset, authority)
12
+ # Vehicles.plate("12-GB-BD", jurisdiction: :nl) # => Plates::Match
13
+ #
14
+ # Matching is TWO-TIER, because humans type plates every way at once:
15
+ #
16
+ # exact — the input, upcased, matches a series' STRICT regex as-typed
17
+ # (separators included): formatted exactly as issued.
18
+ # lenient — the input with separators stripped ("1234XYZ", "1234 xyz",
19
+ # "1234-XYZ" all collapse to "1234XYZ") matches the same strict
20
+ # alphabet with the separators removed. Still the authority's
21
+ # real issuing alphabet — only the punctuation is forgiven.
22
+ #
23
+ # A lenient-only match carries `suggestion`: the serial re-formatted the
24
+ # way it appears on the physical plate ("did you mean 1234 XYZ").
25
+ # Same posture as every lookup in this gem: garbage in => empty result,
26
+ # never an exception.
27
+ module Plates
28
+ DATA_DIR = File.expand_path("../../data/plates", __dir__)
29
+
30
+ # What people type between plate groups: space(s), hyphen/en/em dash,
31
+ # middot, period. Stripped for the lenient tier (and from the strict
32
+ # regexes to derive their lenient twins).
33
+ SEPARATORS = /[\s\-–—·.]+/
34
+
35
+ module_function
36
+
37
+ def jurisdictions
38
+ @jurisdictions ||= Dir.glob(File.join(DATA_DIR, "*.yml")).sort.map do |path|
39
+ Jurisdiction.load(path)
40
+ end
41
+ end
42
+
43
+ def jurisdiction(code)
44
+ jurisdictions.find { |j| j.code == code.to_s.downcase.tr("_", "-") }
45
+ end
46
+
47
+ def classes
48
+ @classes ||= YAML.safe_load_file(File.join(DATA_DIR, "_meta", "classes.yml")) || {}
49
+ end
50
+
51
+ # The matcher behind Vehicles.plate. Unknown jurisdiction => empty Match.
52
+ def match(input, jurisdiction:)
53
+ juris = jurisdiction(jurisdiction)
54
+ typed = input.to_s.upcase.strip
55
+ canon = typed.gsub(SEPARATORS, "")
56
+ return Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: []) if juris.nil? || canon.empty?
57
+
58
+ hits = juris.series.filter_map do |series|
59
+ next nil unless series.validation_regexp
60
+
61
+ exact = series.validation_regexp.match?(typed)
62
+ lenient = exact || series.lenient_regexp&.match?(canon)
63
+ Match::Hit.new(series: series, exact: exact) if lenient
64
+ end
65
+
66
+ Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: hits)
67
+ end
68
+
69
+ # The answer to "is this a plate, and which series issues it?".
70
+ class Match
71
+ Hit = Struct.new(:series, :exact, keyword_init: true) do
72
+ def exact? = exact
73
+ end
74
+
75
+ attr_reader :input, :jurisdiction, :canon, :hits
76
+
77
+ def initialize(input:, jurisdiction:, canon:, hits:)
78
+ @input = input
79
+ @jurisdiction = jurisdiction
80
+ @canon = canon
81
+ # Exact hits outrank lenient ones, strict-validated series outrank
82
+ # recall-only catch-alls; series order (standard first) breaks ties.
83
+ @hits = hits.sort_by { |h| [ h.exact? ? 0 : 1, h.series.strict? ? 0 : 1 ] }.freeze
84
+ freeze
85
+ end
86
+
87
+ def valid? = hits.any?
88
+ def exact? = hits.any?(&:exact?)
89
+
90
+ # True when at least one hit is a strict-validated series — the strong
91
+ # signal ("the authority issues this alphabet"), vs a catch-all shape
92
+ # union like an export plate. Prefer this for user-input validation.
93
+ def strict? = hits.any? { |h| h.series.strict? }
94
+
95
+ def series = hits.map(&:series)
96
+ def best = hits.first&.series
97
+
98
+ # The serial formatted the way the best-matching series prints it on
99
+ # the physical plate — "1234XYZ" typed, "1234 XYZ" issued.
100
+ def formatted
101
+ best&.format_serial(canon)
102
+ end
103
+
104
+ # Only when the user's punctuation was off: the "did you mean" string.
105
+ def suggestion
106
+ formatted if valid? && !exact?
107
+ end
108
+ end
109
+ end
110
+ end
@@ -11,10 +11,11 @@ module Vehicles
11
11
  # resolver falls back to the local data. Tracking/enrichment must never break
12
12
  # the host app, so this never raises out.
13
13
  #
14
- # NOTE: the public VehiclesDB API is not live yet. This is the wired-up seam:
15
- # the moment the service ships (and you set `config.api_key`), these methods
16
- # light up with zero code changes on the consumer's side. Until then they
17
- # safely return nil. Endpoint shape is provisional see https://vehiclesdb.com.
14
+ # The images endpoint is LIVE (since 2026-07): mint a key at
15
+ # https://vehiclesdb.com/settings/api-keys, set `config.api_key`, and
16
+ # `model.image` / `model.images` answer with rendered vehicle imagery.
17
+ # The enrichment endpoints (years/segment) are still the wired-up seam
18
+ # they safely return nil until the service ships them.
18
19
  module HostedProvider
19
20
  module_function
20
21
 
@@ -33,11 +34,28 @@ module Vehicles
33
34
  fetch(model)&.dig("segment")&.to_sym
34
35
  end
35
36
 
36
- def image(model, year:, color:)
37
+ # One variant URL — the common "just give me an <img src>" case.
38
+ # `size` picks from the API's rendered variants (:sm 320×180, :md
39
+ # 640×360, :lg 1280×720; webp).
40
+ def image(model, year: nil, color: nil, size: :md)
41
+ images(model, color: color)&.dig("variants", size.to_s, "url")
42
+ end
43
+
44
+ # The full images payload for a model — palette, every variant with
45
+ # dimensions, provenance, and the honest color fallback: `color` is what
46
+ # the API actually served, `requested_color` what you asked for (a color
47
+ # that isn't rendered yet falls back rather than 404ing).
48
+ #
49
+ # GET /v1/vehicles/:kind/:make_slug/:model_slug/images?color=<slug>
50
+ #
51
+ # `year`/`trim` filters are RESERVED server-side today (the API 422s on
52
+ # them by contract, so callers can't silently build on unimplemented
53
+ # semantics) — which is why `image` accepts `year:` but never sends it:
54
+ # serving the current rendering beats an error until the filter ships.
55
+ def images(model, color: nil)
37
56
  params = {}
38
- params[:year] = year if year
39
- params[:color] = color if color
40
- get("/v1/models/#{model.slug}/image", params)&.dig("url")
57
+ params[:color] = color.to_s unless color.to_s.empty?
58
+ get("/v1/vehicles/#{model.kind}/#{model.make_slug}/#{model.model_slug}/images", params)
41
59
  end
42
60
 
43
61
  # --- internals -----------------------------------------------------------
@@ -17,9 +17,10 @@ module Vehicles
17
17
  # The bundled snapshot is make/model/kind/body_type only — richer fields
18
18
  # come from the hosted API. Returning nil here lets the resolver move on
19
19
  # (and ultimately yield nil) instead of raising.
20
- def years(_model) = nil
21
- def segment(_model) = nil
22
- def image(_model, year:, color:) = nil
20
+ def years(_model) = nil
21
+ def segment(_model) = nil
22
+ def image(_model, year: nil, color: nil, size: :md) = nil
23
+ def images(_model, color: nil) = nil
23
24
  end
24
25
  end
25
26
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vehicles
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.1"
5
5
  end
data/lib/vehicles.rb CHANGED
@@ -10,6 +10,9 @@ require_relative "vehicles/refresher"
10
10
  require_relative "vehicles/providers/local_provider"
11
11
  require_relative "vehicles/providers/hosted_provider"
12
12
  require_relative "vehicles/other_option"
13
+ require_relative "vehicles/plates"
14
+ require_relative "vehicles/plates/jurisdiction"
15
+ require_relative "vehicles/plates/series"
13
16
 
14
17
  # Car makes & models for your Rails app — dropdowns, search, validation. Bundled
15
18
  # data, zero config, no network calls. Standalone first; an SDK for the hosted
@@ -135,6 +138,24 @@ module Vehicles
135
138
  found&.model(model_name)
136
139
  end
137
140
 
141
+ # License plates (the bundled PRD-PLATES dataset). No argument: every
142
+ # jurisdiction. With a code: that jurisdiction (or nil — forgiving).
143
+ # Vehicles.plates # => [#<Jurisdiction nl …>, …]
144
+ # Vehicles.plates(:nl) # => the Netherlands entry (33 series)
145
+ def plates(code = nil)
146
+ code.nil? ? Plates.jurisdictions : Plates.jurisdiction(code)
147
+ end
148
+
149
+ # Validate a typed registration against a jurisdiction's real series.
150
+ # Two-tier: exact as-issued, else separator-forgiving with a formatting
151
+ # suggestion. Never raises; unknown jurisdictions yield an empty match.
152
+ # Vehicles.plate("12-GB-BD", jurisdiction: :nl).valid? # => true (exact)
153
+ # Vehicles.plate("1234XYZ", jurisdiction: :es).suggestion # => "1234 XYZ"
154
+ # Vehicles.plate("12-AB-CD", jurisdiction: :nl).valid? # => false (vowel purge)
155
+ def plate(input, jurisdiction:)
156
+ Plates.match(input, jurisdiction: jurisdiction)
157
+ end
158
+
138
159
  # Every model matching a query, ranked. => [Vehicles::Model, ...]
139
160
  def search(query)
140
161
  dataset.search(query)
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vehicles
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-07-09 00:00:00.000000000 Z
10
+ date: 2026-08-01 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: vehicles ships a curated, bundled dataset of car makes and models (with
13
13
  kind and body type) and a delightful, Rails-friendly API for vehicle dropdowns,
@@ -24,6 +24,13 @@ files:
24
24
  - CHANGELOG.md
25
25
  - LICENSE.txt
26
26
  - README.md
27
+ - data/plates/PROVENANCE.md
28
+ - data/plates/_decode/es-provinces.yml
29
+ - data/plates/_meta/classes.yml
30
+ - data/plates/de.yml
31
+ - data/plates/es.yml
32
+ - data/plates/nl.yml
33
+ - data/plates/us-fl.yml
27
34
  - data/vehicles.json
28
35
  - exe/vehicles-mcp
29
36
  - lib/generators/vehicles/install_generator.rb
@@ -38,6 +45,9 @@ files:
38
45
  - lib/vehicles/mcp_server.rb
39
46
  - lib/vehicles/model.rb
40
47
  - lib/vehicles/other_option.rb
48
+ - lib/vehicles/plates.rb
49
+ - lib/vehicles/plates/jurisdiction.rb
50
+ - lib/vehicles/plates/series.rb
41
51
  - lib/vehicles/providers/hosted_provider.rb
42
52
  - lib/vehicles/providers/local_provider.rb
43
53
  - lib/vehicles/railtie.rb