vehicles 0.4.1 → 0.8.0

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.
Files changed (189) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +164 -0
  3. data/CHANGELOG.md +321 -0
  4. data/README.md +88 -15
  5. data/data/PROVENANCE-vehicles.md +12 -0
  6. data/data/plates/PROVENANCE.md +10 -0
  7. data/data/plates/_decode/at-districts.yml +287 -0
  8. data/data/plates/_decode/ch-cantons.yml +110 -0
  9. data/data/plates/_decode/cn-issuing-offices.yml +463 -0
  10. data/data/plates/_decode/cn-provinces.yml +614 -0
  11. data/data/plates/_decode/cz-kraj.yml +159 -0
  12. data/data/plates/_decode/dk-serial-blocks.yml +327 -0
  13. data/data/plates/_decode/es-provinces.yml +116 -0
  14. data/data/plates/_decode/fr-departements.yml +191 -0
  15. data/data/plates/_decode/gb-age-identifiers.yml +170 -0
  16. data/data/plates/_decode/gb-memory-tags.yml +202 -0
  17. data/data/plates/_decode/gr-prefectures.yml +1408 -0
  18. data/data/plates/_decode/hr-cities.yml +315 -0
  19. data/data/plates/_decode/ie-counties.yml +227 -0
  20. data/data/plates/_decode/in-state-codes.yml +169 -0
  21. data/data/plates/_decode/is-districts-1938.yml +116 -0
  22. data/data/plates/_decode/it-provinces.yml +348 -0
  23. data/data/plates/_decode/jp-office-marks.yml +960 -0
  24. data/data/plates/_decode/jp-use-marks.yml +205 -0
  25. data/data/plates/_decode/kr-authority-marks.yml +146 -0
  26. data/data/plates/_decode/kr-use-syllables.yml +194 -0
  27. data/data/plates/_decode/lu-series-order.yml +638 -0
  28. data/data/plates/_decode/mx-entity-series.yml +380 -0
  29. data/data/plates/_decode/my-index-marks.yml +271 -0
  30. data/data/plates/_decode/no-counties-1913.yml +148 -0
  31. data/data/plates/_decode/no-districts.yml +901 -0
  32. data/data/plates/_decode/pl-powiats.yml +567 -0
  33. data/data/plates/_decode/pt-trailer-services.yml +96 -0
  34. data/data/plates/_decode/ro-counties.yml +213 -0
  35. data/data/plates/_decode/se-diplomatic.yml +220 -0
  36. data/data/plates/_decode/si-regions.yml +123 -0
  37. data/data/plates/_decode/sk-districts.yml +314 -0
  38. data/data/plates/_decode/th-registration-elements.yml +208 -0
  39. data/data/plates/_decode/ua-regions.yml +393 -0
  40. data/data/plates/_decode/za-licence-marks.yml +215 -0
  41. data/data/plates/_meta/classes.yml +26 -0
  42. data/data/plates/_meta/separators.yml +102 -0
  43. data/data/plates/ad.yml +961 -0
  44. data/data/plates/ar.yml +762 -0
  45. data/data/plates/at.yml +1424 -0
  46. data/data/plates/au-act.yml +472 -0
  47. data/data/plates/au-nsw.yml +282 -0
  48. data/data/plates/au-nt.yml +431 -0
  49. data/data/plates/au-qld.yml +272 -0
  50. data/data/plates/au-sa.yml +248 -0
  51. data/data/plates/au-tas.yml +302 -0
  52. data/data/plates/au-vic.yml +215 -0
  53. data/data/plates/au-wa.yml +309 -0
  54. data/data/plates/be.yml +1840 -0
  55. data/data/plates/br.yml +1152 -0
  56. data/data/plates/ca-ab.yml +430 -0
  57. data/data/plates/ca-bc.yml +697 -0
  58. data/data/plates/ca-mb.yml +499 -0
  59. data/data/plates/ca-nb.yml +537 -0
  60. data/data/plates/ca-nl.yml +743 -0
  61. data/data/plates/ca-ns.yml +808 -0
  62. data/data/plates/ca-nt.yml +610 -0
  63. data/data/plates/ca-nu.yml +280 -0
  64. data/data/plates/ca-on.yml +881 -0
  65. data/data/plates/ca-pe.yml +592 -0
  66. data/data/plates/ca-qc.yml +1036 -0
  67. data/data/plates/ca-sk.yml +256 -0
  68. data/data/plates/ca-yt.yml +656 -0
  69. data/data/plates/ch.yml +1273 -0
  70. data/data/plates/cn.yml +1431 -0
  71. data/data/plates/cy.yml +688 -0
  72. data/data/plates/cz.yml +1179 -0
  73. data/data/plates/de.yml +1107 -0
  74. data/data/plates/dk.yml +1647 -0
  75. data/data/plates/ee.yml +1275 -0
  76. data/data/plates/es.yml +888 -0
  77. data/data/plates/fi.yml +1271 -0
  78. data/data/plates/fr.yml +1265 -0
  79. data/data/plates/gb.yml +1188 -0
  80. data/data/plates/gr.yml +721 -0
  81. data/data/plates/hr.yml +860 -0
  82. data/data/plates/hu.yml +959 -0
  83. data/data/plates/ie.yml +775 -0
  84. data/data/plates/in.yml +1172 -0
  85. data/data/plates/is.yml +667 -0
  86. data/data/plates/it.yml +1317 -0
  87. data/data/plates/jp.yml +3650 -0
  88. data/data/plates/kr.yml +1375 -0
  89. data/data/plates/li.yml +1010 -0
  90. data/data/plates/lt.yml +879 -0
  91. data/data/plates/lu.yml +842 -0
  92. data/data/plates/lv.yml +924 -0
  93. data/data/plates/mc.yml +1024 -0
  94. data/data/plates/mt.yml +854 -0
  95. data/data/plates/mx.yml +1372 -0
  96. data/data/plates/my.yml +685 -0
  97. data/data/plates/nl.yml +1016 -0
  98. data/data/plates/no.yml +1018 -0
  99. data/data/plates/nz.yml +1699 -0
  100. data/data/plates/pl.yml +1296 -0
  101. data/data/plates/pt.yml +847 -0
  102. data/data/plates/ro.yml +785 -0
  103. data/data/plates/se.yml +1091 -0
  104. data/data/plates/si.yml +1105 -0
  105. data/data/plates/sk.yml +1439 -0
  106. data/data/plates/sm.yml +1119 -0
  107. data/data/plates/th.yml +1150 -0
  108. data/data/plates/ua.yml +1672 -0
  109. data/data/plates/us-ak.yml +763 -0
  110. data/data/plates/us-al.yml +457 -0
  111. data/data/plates/us-ar.yml +427 -0
  112. data/data/plates/us-as.yml +454 -0
  113. data/data/plates/us-az.yml +430 -0
  114. data/data/plates/us-ca.yml +892 -0
  115. data/data/plates/us-co.yml +452 -0
  116. data/data/plates/us-ct.yml +427 -0
  117. data/data/plates/us-dc.yml +614 -0
  118. data/data/plates/us-de.yml +551 -0
  119. data/data/plates/us-fl.yml +741 -0
  120. data/data/plates/us-ga.yml +644 -0
  121. data/data/plates/us-gu.yml +658 -0
  122. data/data/plates/us-hi.yml +585 -0
  123. data/data/plates/us-ia.yml +438 -0
  124. data/data/plates/us-id.yml +531 -0
  125. data/data/plates/us-il.yml +516 -0
  126. data/data/plates/us-in.yml +324 -0
  127. data/data/plates/us-ks.yml +351 -0
  128. data/data/plates/us-ky.yml +770 -0
  129. data/data/plates/us-la.yml +568 -0
  130. data/data/plates/us-ma.yml +379 -0
  131. data/data/plates/us-md.yml +536 -0
  132. data/data/plates/us-me.yml +445 -0
  133. data/data/plates/us-mi.yml +408 -0
  134. data/data/plates/us-mn.yml +411 -0
  135. data/data/plates/us-mo.yml +356 -0
  136. data/data/plates/us-mp.yml +402 -0
  137. data/data/plates/us-ms.yml +432 -0
  138. data/data/plates/us-mt.yml +619 -0
  139. data/data/plates/us-nc.yml +602 -0
  140. data/data/plates/us-nd.yml +419 -0
  141. data/data/plates/us-ne.yml +483 -0
  142. data/data/plates/us-nh.yml +394 -0
  143. data/data/plates/us-nj.yml +547 -0
  144. data/data/plates/us-nm.yml +574 -0
  145. data/data/plates/us-nv.yml +655 -0
  146. data/data/plates/us-ny.yml +592 -0
  147. data/data/plates/us-oh.yml +404 -0
  148. data/data/plates/us-ok.yml +513 -0
  149. data/data/plates/us-or.yml +392 -0
  150. data/data/plates/us-pa.yml +506 -0
  151. data/data/plates/us-pr.yml +507 -0
  152. data/data/plates/us-ri.yml +445 -0
  153. data/data/plates/us-sc.yml +605 -0
  154. data/data/plates/us-sd.yml +428 -0
  155. data/data/plates/us-tn.yml +471 -0
  156. data/data/plates/us-tx.yml +535 -0
  157. data/data/plates/us-ut.yml +384 -0
  158. data/data/plates/us-va.yml +567 -0
  159. data/data/plates/us-vi.yml +489 -0
  160. data/data/plates/us-vt.yml +458 -0
  161. data/data/plates/us-wa.yml +484 -0
  162. data/data/plates/us-wi.yml +509 -0
  163. data/data/plates/us-wv.yml +446 -0
  164. data/data/plates/us-wy.yml +523 -0
  165. data/data/plates/va.yml +604 -0
  166. data/data/plates/za.yml +1141 -0
  167. data/data/vehicles.json +160653 -105090
  168. data/lib/vehicles/configuration.rb +18 -2
  169. data/lib/vehicles/dataset.rb +73 -18
  170. data/lib/vehicles/fuzzy.rb +115 -0
  171. data/lib/vehicles/http.rb +48 -0
  172. data/lib/vehicles/make.rb +36 -6
  173. data/lib/vehicles/mcp_server.rb +5 -5
  174. data/lib/vehicles/model.rb +42 -6
  175. data/lib/vehicles/plates/jurisdiction.rb +32 -0
  176. data/lib/vehicles/plates/series.rb +291 -0
  177. data/lib/vehicles/plates/template_fill.rb +188 -0
  178. data/lib/vehicles/plates/templates.rb +162 -0
  179. data/lib/vehicles/plates.rb +122 -0
  180. data/lib/vehicles/providers/hosted_provider.rb +49 -58
  181. data/lib/vehicles/providers/local_provider.rb +4 -3
  182. data/lib/vehicles/validators/vehicle_make_validator.rb +3 -1
  183. data/lib/vehicles/validators/vehicle_model_validator.rb +1 -1
  184. data/lib/vehicles/version.rb +1 -1
  185. data/lib/vehicles.rb +53 -16
  186. data/rakelib/data_refresh.rake +109 -0
  187. data/rakelib/plates_refresh.rake +29 -0
  188. data/sig/vehicles.rbs +18 -0
  189. metadata +174 -2
@@ -0,0 +1,122 @@
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
+ # A _decode table by bare name ("at-districts", "jp-office-marks") —
52
+ # the region/office/use-mark lookups that make plates decodable and
53
+ # let renderers resolve subregion emblems from a serial. Nil for
54
+ # unknown names; memoized like everything else here.
55
+ def decode_table(name)
56
+ @decode_tables ||= {}
57
+ @decode_tables[name] ||= begin
58
+ path = File.join(DATA_DIR, "_decode", "#{name.to_s.tr('_', '-')}.yml")
59
+ File.exist?(path) ? YAML.safe_load_file(path) : nil
60
+ end
61
+ end
62
+
63
+ # The matcher behind Vehicles.plate. Unknown jurisdiction => empty Match.
64
+ def match(input, jurisdiction:)
65
+ juris = jurisdiction(jurisdiction)
66
+ typed = input.to_s.upcase.strip
67
+ canon = typed.gsub(SEPARATORS, "")
68
+ return Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: []) if juris.nil? || canon.empty?
69
+
70
+ hits = juris.series.filter_map do |series|
71
+ next nil unless series.validation_regexp
72
+
73
+ exact = series.validation_regexp.match?(typed)
74
+ lenient = exact || series.lenient_regexp&.match?(canon)
75
+ Match::Hit.new(series: series, exact: exact) if lenient
76
+ end
77
+
78
+ Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: hits)
79
+ end
80
+
81
+ # The answer to "is this a plate, and which series issues it?".
82
+ class Match
83
+ Hit = Struct.new(:series, :exact, keyword_init: true) do
84
+ def exact? = exact
85
+ end
86
+
87
+ attr_reader :input, :jurisdiction, :canon, :hits
88
+
89
+ def initialize(input:, jurisdiction:, canon:, hits:)
90
+ @input = input
91
+ @jurisdiction = jurisdiction
92
+ @canon = canon
93
+ # Exact hits outrank lenient ones, strict-validated series outrank
94
+ # recall-only catch-alls; series order (standard first) breaks ties.
95
+ @hits = hits.sort_by { |h| [ h.exact? ? 0 : 1, h.series.strict? ? 0 : 1 ] }.freeze
96
+ freeze
97
+ end
98
+
99
+ def valid? = hits.any?
100
+ def exact? = hits.any?(&:exact?)
101
+
102
+ # True when at least one hit is a strict-validated series — the strong
103
+ # signal ("the authority issues this alphabet"), vs a catch-all shape
104
+ # union like an export plate. Prefer this for user-input validation.
105
+ def strict? = hits.any? { |h| h.series.strict? }
106
+
107
+ def series = hits.map(&:series)
108
+ def best = hits.first&.series
109
+
110
+ # The serial formatted the way the best-matching series prints it on
111
+ # the physical plate — "1234XYZ" typed, "1234 XYZ" issued.
112
+ def formatted
113
+ best&.format_serial(canon)
114
+ end
115
+
116
+ # Only when the user's punctuation was off: the "did you mean" string.
117
+ def suggestion
118
+ formatted if valid? && !exact?
119
+ end
120
+ end
121
+ end
122
+ end
@@ -4,83 +4,74 @@ require "json"
4
4
 
5
5
  module Vehicles
6
6
  module Providers
7
- # Talks to the hosted VehiclesDB API to enrich models with years, images, and
8
- # segments. It is STRICTLY OPTIONAL: `available?` is true only when an api_key
9
- # is configured, so a key-less install never reaches the network. Every call
10
- # is error-isolated — a slow, missing, or failing API yields nil and the
11
- # resolver falls back to the local data. Tracking/enrichment must never break
12
- # the host app, so this never raises out.
7
+ # Talks to the hosted VehiclesDB API for vehicle imagery. It is STRICTLY
8
+ # OPTIONAL: `available?` is true only when an api_key is configured, so a
9
+ # key-less install never reaches the network. Every call is error-isolated
10
+ # — a slow, missing, or failing API yields nil and the resolver falls back
11
+ # to the local data. Enrichment must never break the host app, so this
12
+ # 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
+ #
18
+ # `years` and `segment` are the wired-up seam for enrichment the API does
19
+ # not serve yet: they answer nil WITHOUT a request (a call to an endpoint
20
+ # that doesn't exist would only cost latency), so the chain falls through
21
+ # to the local data. They light up here when the API ships them.
18
22
  module HostedProvider
23
+ # Every VehiclesDB API endpoint lives under this path on
24
+ # `config.api_base_url` (https://vehiclesdb.com/api/v1/...).
25
+ API_PREFIX = "/api/v1"
26
+
19
27
  module_function
20
28
 
21
29
  def available?
22
30
  !Vehicles.configuration.api_key.to_s.empty?
23
31
  end
24
32
 
25
- def years(model)
26
- data = fetch(model)
27
- return nil unless data && data["year_start"]
33
+ def years(_model)
34
+ nil
35
+ end
28
36
 
29
- (data["year_start"]..data["year_end"]) # year_end may be nil => endless range
37
+ def segment(_model)
38
+ nil
30
39
  end
31
40
 
32
- def segment(model)
33
- fetch(model)&.dig("segment")&.to_sym
41
+ # One variant URL — the common "just give me an <img src>" case.
42
+ # `size` picks from the API's rendered variants (:sm 320×180, :md
43
+ # 640×360, :lg 1280×720; webp).
44
+ def image(model, year: nil, color: nil, size: :md)
45
+ images(model, color: color)&.dig("variants", size.to_s, "url")
34
46
  end
35
47
 
36
- def image(model, year:, color:)
48
+ # The full images payload for a model — palette, every variant with
49
+ # dimensions, provenance, and the honest color fallback: `color` is what
50
+ # the API actually served, `requested_color` what you asked for (a color
51
+ # that isn't rendered yet falls back rather than 404ing).
52
+ #
53
+ # GET /api/v1/vehicles/:kind/:make_slug/:model_slug/images?color=<slug>
54
+ #
55
+ # `year`/`trim` filters are RESERVED server-side today (the API 422s on
56
+ # them by contract, so callers can't silently build on unimplemented
57
+ # semantics) — which is why `image` accepts `year:` but never sends it:
58
+ # serving the current rendering beats an error until the filter ships.
59
+ def images(model, color: nil)
37
60
  params = {}
38
- params[:year] = year if year
39
- params[:color] = color if color
40
- get("/v1/models/#{model.slug}/image", params)&.dig("url")
61
+ params[:color] = color.to_s unless color.to_s.empty?
62
+ get("#{API_PREFIX}/vehicles/#{model.kind}/#{model.make_slug}/#{model.model_slug}/images", params)
41
63
  end
42
64
 
43
65
  # --- internals -----------------------------------------------------------
44
66
 
45
- # Fetch + memoize the full model payload for this process. Keyed by slug.
46
- def fetch(model)
47
- @cache ||= {}
48
- return @cache[model.slug] if @cache.key?(model.slug)
49
-
50
- @cache[model.slug] = get("/v1/models/#{model.slug}", {})
51
- end
52
-
53
- def reset!
54
- @cache = {}
55
- end
56
-
57
- # Issue a GET and parse JSON. Returns nil on ANY failure (network, timeout,
58
- # non-200, bad JSON) — callers treat nil as "fall back to local data".
67
+ # Issue a keyed GET and parse JSON. Returns nil on ANY failure (network,
68
+ # timeout, non-200, bad JSON) — callers treat nil as "fall back to local
69
+ # data". The wire rules (timeout, no retry) are Vehicles::HTTP's.
59
70
  def get(path, params)
60
- # Lazy-required: the gem is standalone-first, and this whole module is
61
- # inert without an api_key, so we don't pay net/http at load time.
62
- require "net/http"
63
- require "uri"
64
-
65
- config = Vehicles.configuration
66
- uri = URI.join(config.api_base_url, path)
67
- uri.query = URI.encode_www_form(params) unless params.empty?
68
-
69
- http = Net::HTTP.new(uri.host, uri.port)
70
- http.use_ssl = uri.scheme == "https"
71
- http.open_timeout = config.api_timeout
72
- http.read_timeout = config.api_timeout
73
-
74
- request = Net::HTTP::Get.new(uri)
75
- request["Authorization"] = "Bearer #{config.api_key}"
76
- request["Accept"] = "application/json"
77
-
78
- response = http.request(request)
79
- return nil unless response.is_a?(Net::HTTPSuccess)
80
-
81
- JSON.parse(response.body)
82
- rescue StandardError => e
83
- warn "[vehicles] hosted lookup failed (#{e.class}); using local data" if $DEBUG
71
+ body = HTTP.get(HTTP.url(path, params),
72
+ "Authorization" => "Bearer #{Vehicles.configuration.api_key}", "Accept" => "application/json")
73
+ body && JSON.parse(body)
74
+ rescue StandardError # bad JSON, a malformed api_base_url
84
75
  nil
85
76
  end
86
77
  end
@@ -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
@@ -15,7 +15,9 @@ class VehicleMakeValidator < ActiveModel::EachValidator
15
15
  def validate_each(record, attribute, value)
16
16
  return if value.blank?
17
17
  return if options[:allow_other] && Vehicles.other?(value)
18
- return if Vehicles.make(value)
18
+ # Exact-only: a typo must fail validation, not be stored as if it were the
19
+ # make it resembles (Vehicles.make is typo-tolerant since 0.7.8).
20
+ return if Vehicles.make(value, fuzzy: false)
19
21
 
20
22
  record.errors.add(attribute, options[:message] || "is not a recognized vehicle make")
21
23
  rescue StandardError => e
@@ -20,7 +20,7 @@ class VehicleModelValidator < ActiveModel::EachValidator
20
20
 
21
21
  make_attribute = options[:make] || :make
22
22
  make_value = record.respond_to?(make_attribute) ? record.public_send(make_attribute) : nil
23
- make = Vehicles.make(make_value)
23
+ make = Vehicles.make(make_value, fuzzy: false) # exact, same rule as the make validator
24
24
  return if make.nil? # unknown make -> defer to the make validator
25
25
  return if make.model(value)
26
26
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vehicles
4
- VERSION = "0.4.1"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/vehicles.rb CHANGED
@@ -2,14 +2,21 @@
2
2
 
3
3
  require_relative "vehicles/version"
4
4
  require_relative "vehicles/configuration"
5
+ require_relative "vehicles/http"
5
6
  require_relative "vehicles/make"
6
7
  require_relative "vehicles/model"
7
8
  require_relative "vehicles/color"
9
+ require_relative "vehicles/fuzzy"
8
10
  require_relative "vehicles/dataset"
9
11
  require_relative "vehicles/refresher"
10
12
  require_relative "vehicles/providers/local_provider"
11
13
  require_relative "vehicles/providers/hosted_provider"
12
14
  require_relative "vehicles/other_option"
15
+ require_relative "vehicles/plates"
16
+ require_relative "vehicles/plates/jurisdiction"
17
+ require_relative "vehicles/plates/series"
18
+ require_relative "vehicles/plates/template_fill"
19
+ require_relative "vehicles/plates/templates"
13
20
 
14
21
  # Car makes & models for your Rails app — dropdowns, search, validation. Bundled
15
22
  # data, zero config, no network calls. Standalone first; an SDK for the hosted
@@ -43,7 +50,6 @@ module Vehicles
43
50
  @providers = nil
44
51
  @data_path = nil
45
52
  Dataset.reset!
46
- Providers::HostedProvider.reset!
47
53
  end
48
54
 
49
55
  # --- data access ---------------------------------------------------------
@@ -108,20 +114,28 @@ module Vehicles
108
114
  # Vehicles.models("Toyota", include_other: true) # ... + the "Other" escape hatch
109
115
  # With `include_other:`, an unknown make (e.g. the "Other" make itself) yields
110
116
  # just `[other_label]`, so a make→model picker never dead-ends.
111
- def models(make, kind: nil, body_type: nil, region: nil, rarity: nil, max_decile: nil, include_other: false)
112
- names = make(make)&.models(kind: kind, body_type: body_type, region: region || configuration.region,
113
- rarity: rarity, max_decile: max_decile)&.map(&:name) || []
117
+ # `fuzzy:` is forwarded to `make` (typo-tolerant by default since 0.7.8).
118
+ def models(make, kind: nil, body_type: nil, region: nil, rarity: nil, max_decile: nil, include_other: false,
119
+ fuzzy: true)
120
+ names = make(make, fuzzy: fuzzy)&.models(kind: kind, body_type: body_type,
121
+ region: region || configuration.region,
122
+ rarity: rarity, max_decile: max_decile)&.map(&:name) || []
114
123
  append_other_name(names, include_other)
115
124
  end
116
125
 
117
- # The rich Make object (or nil). Forgiving: name, slug, or alias.
118
- def make(query)
119
- dataset.find_make(query)
126
+ # The rich Make object (or nil). Forgiving: name, slug, alias — and, since
127
+ # 0.7.8, typos: "marcedes" → Mercedes-Benz, "volkswagon" → Volkswagen
128
+ # (bounded edit distance, only when ONE make is closest; ties → nil).
129
+ # Pass `fuzzy: false` for exact-only resolution (the validators do).
130
+ def make(query, fuzzy: true)
131
+ dataset.find_make(query, fuzzy: fuzzy)
120
132
  end
121
133
 
122
134
  # Resolve a free-text "make + model" string into one Model (or nil).
123
- def find(query)
124
- dataset.find_model(query)
135
+ # Exact makes are tried first; a misspelled make is retried with the same
136
+ # typo tolerance as `make` ("volkswagon golf"). `fuzzy: false` disables it.
137
+ def find(query, fuzzy: true)
138
+ dataset.find_model(query, fuzzy: fuzzy)
125
139
  end
126
140
 
127
141
  # Resolve a stored make + model PAIR into a Model (or nil). The structured
@@ -130,9 +144,31 @@ module Vehicles
130
144
  # model's metadata (kind, body_type, …) back.
131
145
  # Vehicles.model("Audi", "A3") # => #<Vehicles::Model "Audi A3">
132
146
  # Vehicles.model("vw", "golf") # forgiving, like every other lookup
133
- def model(make_name, model_name)
134
- found = make(make_name)
135
- found&.model(model_name)
147
+ # Vehicles.model("Kia", "XCeed") # spacing-blind: the dataset's "X Ceed"
148
+ # Vehicles.model("BMW", "Serie 1") # => BMW 1 Series
149
+ # Vehicles.model("Toyota", "Land Cruiser") # => the car (preferred)
150
+ # Vehicles.model("Toyota", "Land Cruiser", kind: :bus)
151
+ def model(make_name, model_name, fuzzy: true, kind: nil)
152
+ found = make(make_name, fuzzy: fuzzy)
153
+ found&.model(model_name, kind: kind)
154
+ end
155
+
156
+ # License plates (the bundled PRD-PLATES dataset). No argument: every
157
+ # jurisdiction. With a code: that jurisdiction (or nil — forgiving).
158
+ # Vehicles.plates # => [#<Jurisdiction nl …>, …]
159
+ # Vehicles.plates(:nl) # => the Netherlands entry (33 series)
160
+ def plates(code = nil)
161
+ code.nil? ? Plates.jurisdictions : Plates.jurisdiction(code)
162
+ end
163
+
164
+ # Validate a typed registration against a jurisdiction's real series.
165
+ # Two-tier: exact as-issued, else separator-forgiving with a formatting
166
+ # suggestion. Never raises; unknown jurisdictions yield an empty match.
167
+ # Vehicles.plate("12-GB-BD", jurisdiction: :nl).valid? # => true (exact)
168
+ # Vehicles.plate("1234XYZ", jurisdiction: :es).suggestion # => "1234 XYZ"
169
+ # Vehicles.plate("12-AB-CD", jurisdiction: :nl).valid? # => false (vowel purge)
170
+ def plate(input, jurisdiction:)
171
+ Plates.match(input, jurisdiction: jurisdiction)
136
172
  end
137
173
 
138
174
  # Every model matching a query, ranked. => [Vehicles::Model, ...]
@@ -149,8 +185,9 @@ module Vehicles
149
185
  # first. Filter by country (ISO alpha-2) or continent (:eu/:as/…).
150
186
  # Vehicles.top_models(kind: :car, country: :nl, limit: 10).map(&:name)
151
187
  # Vehicles.top_models(kind: :motorcycle, region: :as, limit: 10)
152
- def top_models(kind: nil, country: nil, region: nil, limit: 20)
153
- dataset.top_models(kind: kind, country: country, region: region, limit: limit)
188
+ # Vehicles.top_models(kind: :car, by: :mass_decile) # by registration mass (data 2026.10.1+)
189
+ def top_models(kind: nil, country: nil, region: nil, limit: 20, by: :global_decile)
190
+ dataset.top_models(kind: kind, country: country, region: region, limit: limit, by: by)
154
191
  end
155
192
 
156
193
  # A curated slice of models by kind/continent/rarity — the "give me sensible
@@ -198,8 +235,8 @@ module Vehicles
198
235
 
199
236
  # [[label, value], ...] of a make's models for a Rails `select`. Unknown => [].
200
237
  # Vehicles.model_options("audi", include_other: true) # ... + [other_label, "other"]
201
- def model_options(make, kind: nil, body_type: nil, include_other: false)
202
- found = make(make)
238
+ def model_options(make, kind: nil, body_type: nil, include_other: false, fuzzy: true)
239
+ found = make(make, fuzzy: fuzzy)
203
240
  opts = found ? found.model_options(kind: kind, body_type: body_type) : []
204
241
  append_other_option(opts, include_other)
205
242
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "json"
5
+
6
+ # The bundled dataset refresh (data/vehicles.json), as a task so the
7
+ # provenance step and the country-rank merge can't be forgotten.
8
+ #
9
+ # rake "data:refresh[v2026.10.0]" # a published release TAG, never a branch
10
+ #
11
+ # Source: the public data repo at a release tag — dist/vehicles.json (the gem
12
+ # projection) unchanged, EXCEPT that each model gains `country_ranks`
13
+ # ({ "gb" => 2, … }) when the projection does not carry it yet. Those ranks
14
+ # are copied from the SAME tag's catalog/<kind>/models.json
15
+ # (popularity.by_country[cc].rank — open CC-BY data), keyed by the full
16
+ # kind/make/model id, so the merged file is what the pipeline's own projection
17
+ # emits once it ships the key (vehiclesdb-pipeline emit.rb write_dist_json!,
18
+ # 2026-10-10). When the projection DOES carry the key, the task asserts every
19
+ # rank equals the catalog's and aborts on any mismatch. Output formatting is
20
+ # the pipeline's (JSON.pretty_generate + "\n"); stripping `country_ranks`
21
+ # from the output gives back the release file byte for byte.
22
+ module DataRefresh
23
+ module_function
24
+
25
+ def git_show(repo, rev, path)
26
+ out = IO.popen(["git", "-C", repo, "show", "#{rev}:#{path}"], &:read)
27
+ abort "missing #{path} at #{rev}" unless $CHILD_STATUS.success?
28
+ out
29
+ end
30
+
31
+ # "kind/make/model" => { "gb" => 2, … } for every ranked model at the tag.
32
+ def catalog_ranks(repo, rev, kinds)
33
+ kinds.each_with_object({}) do |kind, ranks|
34
+ JSON.parse(git_show(repo, rev, "catalog/#{kind}/models.json")).each do |m|
35
+ by_country = m.dig("popularity", "by_country") || {}
36
+ r = by_country.filter_map { |cc, p| [cc, p["rank"]] if p["rank"] }.sort.to_h
37
+ ranks["#{kind}/#{m["id"]}"] = r unless r.empty?
38
+ end
39
+ end
40
+ end
41
+
42
+ # Insert `country_ranks` where the pipeline emits it (right after
43
+ # global_decile), or assert it when the projection already carries it.
44
+ # Returns [merged, native] counts.
45
+ def merge!(dist, ranks)
46
+ merged = native = 0
47
+ dist["makes"].each do |mk|
48
+ mk["models"].each do |m|
49
+ id = "#{m["kind"]}/#{mk["slug"]}/#{m["slug"]}"
50
+ want = ranks[id]
51
+ if m.key?("country_ranks")
52
+ native += 1
53
+ abort "country_ranks mismatch for #{id}" unless m["country_ranks"] == want
54
+ elsif want
55
+ m.replace(with_ranks(m, want))
56
+ merged += 1
57
+ end
58
+ end
59
+ end
60
+ [merged, native]
61
+ end
62
+
63
+ def with_ranks(model, ranks)
64
+ out = model.each_with_object({}) do |(k, v), h|
65
+ h[k] = v
66
+ h["country_ranks"] = ranks if k == "global_decile"
67
+ end
68
+ out["country_ranks"] ||= ranks
69
+ out
70
+ end
71
+
72
+ def provenance(tag, commit, version, merged, native)
73
+ <<~MD
74
+ # Bundled vehicles dataset
75
+
76
+ `data/vehicles.json` is the public data repo's `dist/vehicles.json`
77
+ (the gem projection) at a release tag:
78
+
79
+ - **Source**: https://github.com/vehiclesdb/vehiclesdb `dist/vehicles.json`
80
+ - **Tag**: `#{tag}` (commit `#{commit}`), dataset version `#{version}`
81
+ - **country_ranks**: #{native} model(s) carried it natively; #{merged} merged from
82
+ the same tag's `catalog/<kind>/models.json` `popularity.by_country[cc].rank`
83
+ - **License**: CC-BY 4.0 (see the data repo's LICENSE + ATTRIBUTION.md)
84
+
85
+ Refresh: `rake "data:refresh[<tag>]"` (this file is generated by it).
86
+ MD
87
+ end
88
+ end
89
+
90
+ namespace :data do
91
+ desc "Refresh data/vehicles.json from a release tag of the local dataset repo"
92
+ task :refresh, [:tag] do |_t, args|
93
+ tag = args[:tag] or abort 'usage: rake "data:refresh[vYYYY.MM.P]" (a release tag)'
94
+ root = File.expand_path("..", __dir__)
95
+ repo = File.expand_path(ENV.fetch("VDB_DATA_REPO", "~/GitHub/vehiclesdb"))
96
+ sh "git", "-C", repo, "fetch", "origin", "--tags", "-q"
97
+ # refs/tags/ only: a branch name or a bare SHA must not pass for a release.
98
+ commit = IO.popen(["git", "-C", repo, "rev-parse", "--verify", "-q", "refs/tags/#{tag}^{commit}"], &:read).strip
99
+ abort "not a release tag: #{tag}" unless $CHILD_STATUS.success? && !commit.empty?
100
+
101
+ # Read at the VERIFIED commit, not the tag name (a same-named branch must never win).
102
+ dist = JSON.parse(DataRefresh.git_show(repo, commit, "dist/vehicles.json"))
103
+ merged, native = DataRefresh.merge!(dist, DataRefresh.catalog_ranks(repo, commit, dist["kinds"]))
104
+ File.write("#{root}/data/vehicles.json", "#{JSON.pretty_generate(dist)}\n")
105
+ File.write("#{root}/data/PROVENANCE-vehicles.md",
106
+ DataRefresh.provenance(tag, commit, dist["version"], merged, native))
107
+ puts "data refreshed to #{tag} (#{dist["version"]}): #{merged} merged, #{native} native country_ranks"
108
+ end
109
+ end
@@ -0,0 +1,29 @@
1
+ # The plates-data refresh, as a task so the PROVENANCE step can't be
2
+ # forgotten (it is gem-local; a bare rm+archive deletes it silently —
3
+ # that shipped once in 0.7.4 and broke consumers reading the pin).
4
+ namespace :plates do
5
+ desc "Refresh data/plates from the local dataset repo's origin/main"
6
+ task :refresh do
7
+ root = File.expand_path("..", __dir__)
8
+ ds = File.expand_path("~/GitHub/vehiclesdb")
9
+ sh "git -C #{ds} fetch origin -q"
10
+ rm_rf "#{root}/data/plates"
11
+ mkdir_p "#{root}/data/plates"
12
+ sh "git -C #{ds} archive origin/main plates | tar -x --exclude 'plates/_art' -C #{root}/data/"
13
+ rm_rf "#{root}/data/plates/_art"
14
+ commit = `git -C #{ds} rev-parse origin/main`.strip
15
+ File.write("#{root}/data/plates/PROVENANCE.md", <<~MD)
16
+ # Bundled plates dataset (PRD-PLATES, full corpus)
17
+
18
+ Copy of `plates/` from the public data repo (EXCLUDING `_art/`),
19
+ refreshed on gem releases:
20
+
21
+ - **Source**: https://github.com/vehiclesdb/vehiclesdb `plates/`
22
+ - **Commit**: `#{commit}`
23
+ - **License**: CC-BY 4.0 (see the data repo's LICENSE + ATTRIBUTION.md)
24
+
25
+ Refresh: `rake plates:refresh` (this file is generated by it).
26
+ MD
27
+ puts "plates refreshed at #{commit}"
28
+ end
29
+ end
data/sig/vehicles.rbs CHANGED
@@ -49,6 +49,24 @@ module Vehicles
49
49
  attr_accessor cache_path: String
50
50
  attr_accessor use_cache: bool
51
51
  attr_accessor refresh_timeout: Integer
52
+ attr_accessor plate_template_cache: untyped
53
+ attr_accessor plate_template_ttl: Integer
54
+ end
55
+
56
+ module Plates
57
+ def self.template: (code: String | Symbol, series: String, ?shape: String?, ?region: String?) -> String?
58
+ def self.render: (String registration, jurisdiction: String | Symbol, ?region: String?, ?label: String?) -> String?
59
+
60
+ module TemplateFill
61
+ class Error < StandardError
62
+ end
63
+
64
+ SCHEMA_VERSION: Integer
65
+ def self.fill: (String svg, String registration, ?label: String?) -> String
66
+ def self.shape_of: (String text) -> String?
67
+ def self.fit_size: (Numeric size, Numeric room, Numeric natural_width) -> Float
68
+ def self.recentered_baseline: (Numeric origin, Numeric size, Numeric per_size) -> Float
69
+ end
52
70
  end
53
71
 
54
72
  class Make