vehicles 0.9.0 → 0.9.2

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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -7
  3. data/README.md +353 -69
  4. data/data/PROVENANCE-vehicles.md +2 -2
  5. data/data/plates/PROVENANCE.md +2 -2
  6. data/data/plates/_decode/at-districts.yml +2 -2
  7. data/data/plates/_decode/cn-issuing-offices.yml +2 -2
  8. data/data/plates/_decode/cn-provinces.yml +2 -2
  9. data/data/plates/_decode/cz-kraj.yml +1 -1
  10. data/data/plates/_decode/dk-serial-blocks.yml +1 -1
  11. data/data/plates/_decode/es-provinces.yml +1 -1
  12. data/data/plates/_decode/fr-departements.yml +2 -2
  13. data/data/plates/_decode/gb-age-identifiers.yml +1 -1
  14. data/data/plates/_decode/gb-memory-tags.yml +1 -1
  15. data/data/plates/_decode/gr-prefectures.yml +1 -1
  16. data/data/plates/_decode/hr-cities.yml +3 -3
  17. data/data/plates/_decode/ie-counties.yml +3 -3
  18. data/data/plates/_decode/in-state-codes.yml +2 -2
  19. data/data/plates/_decode/it-provinces.yml +3 -3
  20. data/data/plates/_decode/jp-office-marks.yml +2 -2
  21. data/data/plates/_decode/jp-use-marks.yml +2 -2
  22. data/data/plates/_decode/kr-authority-marks.yml +1 -1
  23. data/data/plates/_decode/mx-entity-series.yml +1 -1
  24. data/data/plates/_decode/my-index-marks.yml +4 -4
  25. data/data/plates/_decode/pt-trailer-services.yml +1 -1
  26. data/data/plates/_decode/ro-counties.yml +1 -1
  27. data/data/plates/_decode/se-diplomatic.yml +1 -1
  28. data/data/plates/_decode/si-regions.yml +1 -1
  29. data/data/plates/_decode/ua-regions.yml +1 -1
  30. data/data/plates/_meta/classes.yml +1 -1
  31. data/data/plates/_meta/separators.yml +1 -1
  32. data/data/plates/ad.yml +555 -52
  33. data/data/plates/ar.yml +8 -8
  34. data/data/plates/at.yml +91 -10
  35. data/data/plates/au-act.yml +13 -13
  36. data/data/plates/au-nsw.yml +9 -9
  37. data/data/plates/au-nt.yml +13 -14
  38. data/data/plates/au-qld.yml +7 -7
  39. data/data/plates/au-sa.yml +7 -7
  40. data/data/plates/au-tas.yml +10 -10
  41. data/data/plates/au-vic.yml +6 -6
  42. data/data/plates/au-wa.yml +8 -8
  43. data/data/plates/be.yml +11 -11
  44. data/data/plates/br.yml +14 -14
  45. data/data/plates/ca-ab.yml +10 -10
  46. data/data/plates/ca-bc.yml +6 -6
  47. data/data/plates/ca-mb.yml +4 -4
  48. data/data/plates/ca-nb.yml +9 -9
  49. data/data/plates/ca-nl.yml +11 -11
  50. data/data/plates/ca-ns.yml +12 -12
  51. data/data/plates/ca-nt.yml +7 -7
  52. data/data/plates/ca-nu.yml +7 -7
  53. data/data/plates/ca-on.yml +21 -21
  54. data/data/plates/ca-pe.yml +13 -13
  55. data/data/plates/ca-qc.yml +11 -11
  56. data/data/plates/ca-sk.yml +6 -6
  57. data/data/plates/ca-yt.yml +6 -6
  58. data/data/plates/ch.yml +14 -14
  59. data/data/plates/cn.yml +9 -9
  60. data/data/plates/cy.yml +6 -6
  61. data/data/plates/cz.yml +9 -9
  62. data/data/plates/de.yml +8 -8
  63. data/data/plates/dk.yml +12 -12
  64. data/data/plates/ee.yml +13 -13
  65. data/data/plates/eg.yml +708 -0
  66. data/data/plates/es.yml +8 -8
  67. data/data/plates/fi.yml +14 -14
  68. data/data/plates/fr.yml +12 -12
  69. data/data/plates/gb.yml +16 -16
  70. data/data/plates/gr.yml +14 -14
  71. data/data/plates/hr.yml +15 -15
  72. data/data/plates/hu.yml +16 -16
  73. data/data/plates/ie.yml +7 -7
  74. data/data/plates/in.yml +7 -7
  75. data/data/plates/is.yml +3 -3
  76. data/data/plates/it.yml +21 -21
  77. data/data/plates/jp.yml +121 -121
  78. data/data/plates/kr.yml +6 -6
  79. data/data/plates/li.yml +6 -6
  80. data/data/plates/lt.yml +10 -10
  81. data/data/plates/lu.yml +4 -4
  82. data/data/plates/lv.yml +9 -9
  83. data/data/plates/mc.yml +4 -4
  84. data/data/plates/mt.yml +5 -5
  85. data/data/plates/mx.yml +7 -7
  86. data/data/plates/my.yml +18 -18
  87. data/data/plates/ng.yml +346 -0
  88. data/data/plates/nl.yml +5 -5
  89. data/data/plates/no.yml +7 -7
  90. data/data/plates/nz.yml +18 -18
  91. data/data/plates/pl.yml +4 -4
  92. data/data/plates/pt.yml +13 -5
  93. data/data/plates/ro.yml +14 -14
  94. data/data/plates/se.yml +13 -13
  95. data/data/plates/si.yml +8 -8
  96. data/data/plates/sk.yml +15 -15
  97. data/data/plates/sm.yml +7 -7
  98. data/data/plates/th.yml +10 -10
  99. data/data/plates/tr.yml +432 -0
  100. data/data/plates/ua.yml +9 -9
  101. data/data/plates/us-ak.yml +11 -11
  102. data/data/plates/us-al.yml +12 -12
  103. data/data/plates/us-ar.yml +15 -15
  104. data/data/plates/us-as.yml +10 -10
  105. data/data/plates/us-az.yml +10 -10
  106. data/data/plates/us-ca.yml +13 -13
  107. data/data/plates/us-co.yml +14 -14
  108. data/data/plates/us-ct.yml +24 -24
  109. data/data/plates/us-dc.yml +7 -7
  110. data/data/plates/us-de.yml +10 -10
  111. data/data/plates/us-fl.yml +17 -17
  112. data/data/plates/us-ga.yml +10 -10
  113. data/data/plates/us-gu.yml +11 -11
  114. data/data/plates/us-hi.yml +11 -11
  115. data/data/plates/us-ia.yml +9 -9
  116. data/data/plates/us-id.yml +13 -13
  117. data/data/plates/us-il.yml +11 -11
  118. data/data/plates/us-in.yml +9 -9
  119. data/data/plates/us-ks.yml +8 -8
  120. data/data/plates/us-ky.yml +11 -11
  121. data/data/plates/us-la.yml +11 -11
  122. data/data/plates/us-ma.yml +10 -10
  123. data/data/plates/us-md.yml +7 -7
  124. data/data/plates/us-me.yml +16 -16
  125. data/data/plates/us-mi.yml +8 -8
  126. data/data/plates/us-mn.yml +13 -13
  127. data/data/plates/us-mo.yml +13 -13
  128. data/data/plates/us-mp.yml +12 -12
  129. data/data/plates/us-ms.yml +8 -8
  130. data/data/plates/us-mt.yml +12 -12
  131. data/data/plates/us-nc.yml +12 -12
  132. data/data/plates/us-nd.yml +10 -10
  133. data/data/plates/us-ne.yml +10 -10
  134. data/data/plates/us-nh.yml +8 -8
  135. data/data/plates/us-nj.yml +8 -8
  136. data/data/plates/us-nm.yml +14 -14
  137. data/data/plates/us-nv.yml +18 -18
  138. data/data/plates/us-ny.yml +17 -17
  139. data/data/plates/us-oh.yml +13 -13
  140. data/data/plates/us-ok.yml +12 -12
  141. data/data/plates/us-or.yml +15 -15
  142. data/data/plates/us-pa.yml +14 -14
  143. data/data/plates/us-pr.yml +15 -15
  144. data/data/plates/us-ri.yml +10 -10
  145. data/data/plates/us-sc.yml +7 -7
  146. data/data/plates/us-sd.yml +12 -12
  147. data/data/plates/us-tn.yml +8 -8
  148. data/data/plates/us-tx.yml +17 -17
  149. data/data/plates/us-ut.yml +12 -12
  150. data/data/plates/us-va.yml +14 -14
  151. data/data/plates/us-vi.yml +16 -16
  152. data/data/plates/us-vt.yml +9 -9
  153. data/data/plates/us-wa.yml +18 -18
  154. data/data/plates/us-wi.yml +8 -8
  155. data/data/plates/us-wv.yml +9 -9
  156. data/data/plates/us-wy.yml +10 -10
  157. data/data/plates/va.yml +7 -7
  158. data/data/plates/za.yml +11 -11
  159. data/data/vehicles.json +121081 -62015
  160. data/lib/generators/vehicles/templates/initializer.rb +21 -12
  161. data/lib/generators/vehicles/templates/vehicles_refresh_job.rb +8 -4
  162. data/lib/vehicles/color.rb +8 -1
  163. data/lib/vehicles/colors.rb +14 -10
  164. data/lib/vehicles/configuration.rb +82 -3
  165. data/lib/vehicles/data_source.rb +220 -0
  166. data/lib/vehicles/dataset.rb +38 -6
  167. data/lib/vehicles/dataset_resolution.rb +39 -3
  168. data/lib/vehicles/dataset_slice.rb +90 -0
  169. data/lib/vehicles/hosted_result.rb +94 -0
  170. data/lib/vehicles/http.rb +75 -7
  171. data/lib/vehicles/id_lookup.rb +37 -0
  172. data/lib/vehicles/kind_rule.rb +63 -0
  173. data/lib/vehicles/labels.rb +131 -0
  174. data/lib/vehicles/make.rb +45 -19
  175. data/lib/vehicles/model.rb +66 -19
  176. data/lib/vehicles/model_match.rb +16 -2
  177. data/lib/vehicles/model_matcher.rb +6 -7
  178. data/lib/vehicles/plates/series.rb +87 -8
  179. data/lib/vehicles/plates/template_fill.rb +2 -2
  180. data/lib/vehicles/plates/templates.rb +9 -5
  181. data/lib/vehicles/plates.rb +54 -8
  182. data/lib/vehicles/providers/hosted_provider.rb +145 -32
  183. data/lib/vehicles/providers/local_provider.rb +2 -2
  184. data/lib/vehicles/refresh_result.rb +74 -0
  185. data/lib/vehicles/refresher.rb +190 -54
  186. data/lib/vehicles/release_checks.rb +72 -0
  187. data/lib/vehicles/text_matcher.rb +19 -10
  188. data/lib/vehicles/validators/vehicle_make_validator.rb +18 -1
  189. data/lib/vehicles/validators/vehicle_model_validator.rb +17 -1
  190. data/lib/vehicles/version.rb +1 -1
  191. data/lib/vehicles.rb +119 -67
  192. data/rakelib/data_refresh.rake +1 -1
  193. data/rakelib/plates_refresh.rake +1 -1
  194. data/sig/vehicles.rbs +67 -13
  195. metadata +13 -2
@@ -6,16 +6,21 @@
6
6
  # dataset, so it works with zero configuration. Uncomment what you need.
7
7
 
8
8
  Vehicles.configure do |config|
9
- # Default region for queries. Today the bundled data covers the EU market;
10
- # :us / :gb / :au / :nz / :ca packs are on the roadmap (the API is already
11
- # region-aware, so adding them won't change your code).
9
+ # Optional default CONTINENT filter (:eu/:na/:as/:sa/:oc/:af). The bundled
10
+ # data is global; nil (the default) means every market. For one COUNTRY,
11
+ # pass `country:` where you build pickers: Vehicles.catalog(kind: :car, country: :es).
12
12
  # config.region = :eu
13
13
 
14
- # Optional: a VehiclesDB API key unlocks hosted enrichment — production years,
15
- # model images (year- and color-accurate), and market segments — on the same
16
- # Vehicles::Model objects you already use. Without a key, everything still
17
- # works on the bundled data; those richer fields just return nil.
14
+ # Optional: a VehiclesDB API key unlocks studio model images
15
+ # (`model.image(color: "red")`) on the same Vehicles::Model objects you already
16
+ # use. Without a key, everything still works on the bundled data; images are nil.
18
17
  # config.api_key = ENV["VEHICLESDB_API_KEY"]
18
+ #
19
+ # Image payloads are cached (in-process by default); share them across
20
+ # processes with Rails.cache. Hosted failures (bad key, out of credits, rate
21
+ # limited, timeouts) are never raised: report them here.
22
+ # config.image_cache = Rails.cache
23
+ # config.on_hosted_error = ->(result) { Rails.error.report(result.to_error, handled: true) }
19
24
 
20
25
  # Optional: your own make aliases, merged over the built-ins. Matched
21
26
  # forgivingly (case/diacritics-insensitive).
@@ -30,11 +35,15 @@ Vehicles.configure do |config|
30
35
 
31
36
  # --- Data refresh (optional) ----------------------------------------------
32
37
  # The gem works offline with its bundled snapshot. To get data fixes and new
33
- # makes WITHOUT upgrading the gem, schedule VehiclesRefreshJob (see app/jobs)
34
- # — it pulls the latest published dataset into a local cache, which loads
35
- # prefer over the bundled copy. Defaults below are sensible; override if needed.
38
+ # makes WITHOUT upgrading the gem, schedule VehiclesRefreshJob (see app/jobs).
39
+ # It writes a checked release to `cache_path`; every process serves it once
40
+ # it is newer than the bundled data, re-checking the file every few seconds.
41
+ #
42
+ # Web and jobs in separate containers (Kamal, Docker)? Point both at ONE
43
+ # shared volume so a refresh run by the job container reaches the web one:
44
+ # config.cache_dir = Rails.root.join("storage", "vehicles") # or ENV VEHICLES_CACHE_DIR
36
45
  #
37
- # config.data_url = "https://cdn.jsdelivr.net/gh/vehiclesdb/vehiclesdb@latest/dist/vehicles.json"
38
- # config.cache_path = Rails.root.join("tmp", "cache", "vehicles", "vehicles.json")
39
46
  # config.use_cache = true # set false to always use the bundled snapshot (fully offline/deterministic)
47
+ # config.refresh_min_ratio = 0.9 # refuse a release that shrinks a kind or a country below 90%
48
+ # config.refresh_guard = ->(new_data, current) { true } # your own last check (Vehicles::Dataset objects)
40
49
  end
@@ -8,13 +8,17 @@
8
8
  # class: VehiclesRefreshJob
9
9
  # schedule: every day at 3am
10
10
  #
11
- # It's safe to run anytime: it never raises, and a failed/partial download leaves
12
- # your current data untouched (the gem keeps serving the cache, or the bundled
13
- # snapshot if there's no cache yet).
11
+ # Safe to run anytime and from ONE place: it reads the release manifest first
12
+ # (nothing is downloaded when you are current), never downgrades, never writes
13
+ # a release your gem version cannot read or one that lost a chunk of your data,
14
+ # and never raises. Every process (web workers, other containers sharing
15
+ # `config.cache_dir`) picks the new file up on its own within a few seconds.
14
16
  class VehiclesRefreshJob < ApplicationJob
15
17
  queue_as :default
16
18
 
17
19
  def perform
18
- Vehicles.refresh!
20
+ # Vehicles.refresh! (true/false) works too; the result says why.
21
+ result = Vehicles.refresh
22
+ Rails.logger.warn("[vehicles] #{result.message}") unless result.ok?
19
23
  end
20
24
  end
@@ -5,7 +5,8 @@ module Vehicles
5
5
  # palette every consumer (and, in time, the hosted VehiclesDB image API) can
6
6
  # share, so "red" means the same thing — and maps to the same image variant —
7
7
  # everywhere. Display names are English; localize in your app (the slug is the
8
- # stable, locale-independent key you store). `hex` is a representative swatch
8
+ # stable, locale-independent key you store; `label(:es)` gives the Spanish
9
+ # one). `hex` is a representative swatch
9
10
  # for UI chips / future color-accurate imagery.
10
11
  class Color
11
12
  attr_reader :slug, :name, :hex
@@ -17,6 +18,12 @@ module Vehicles
17
18
  freeze
18
19
  end
19
20
 
21
+ # The display label in a locale (:en, :es; others fall back to English).
22
+ # Vehicles.color("red").label(:es) # => "Rojo"
23
+ def label(locale = :en)
24
+ Labels.color(slug, locale) || name
25
+ end
26
+
20
27
  def to_h
21
28
  { slug: slug, name: name, hex: hex }
22
29
  end
@@ -24,18 +24,22 @@ module Vehicles
24
24
  Color.new("other", "Other", "#9AA0A6")
25
25
  ].freeze
26
26
 
27
- # Accept a few common spellings/synonyms so lookups are forgiving like the
28
- # rest of the gem (normalized keys → canonical slug).
29
- SYNONYMS = {
30
- "gray" => "grey",
31
- "silvery" => "silver",
32
- "tan" => "beige",
33
- "cream" => "beige",
34
- "burgundy" => "red",
35
- "navy" => "blue"
36
- }.freeze
27
+ # English spellings/synonyms (normalized keys → canonical slug). Every
28
+ # locale's synonyms live in Labels::COLOR_SYNONYMS.
29
+ SYNONYMS = Labels::COLOR_SYNONYMS[:en]
37
30
 
38
31
  BY_SLUG = ALL.to_h { |c| [c.slug, c] }.freeze
39
32
  BY_NAME = ALL.to_h { |c| [Vehicles.normalize(c.name), c] }.freeze
33
+
34
+ # Every normalized label and synonym in every locale → Color, so "Rojo",
35
+ # "gris", "marrón", "plateada" and "navy" all resolve (Vehicles.color).
36
+ LOOKUP = begin
37
+ table = {}
38
+ Labels::LOCALES.each do |locale|
39
+ Labels::COLORS[locale].each { |slug, label| table[Vehicles.normalize(label)] ||= BY_SLUG[slug] }
40
+ Labels::COLOR_SYNONYMS[locale].each { |key, slug| table[Vehicles.normalize(key)] ||= BY_SLUG[slug] }
41
+ end
42
+ table.freeze
43
+ end
40
44
  end
41
45
  end
@@ -38,7 +38,7 @@ module Vehicles
38
38
  # carry nothing personal, so a long TTL is safe; the server re-renders
39
39
  # them only when VehiclesDB's plate art changes.
40
40
  attr_writer :plate_template_cache
41
- attr_accessor :plate_template_ttl
41
+ attr_accessor :plate_template_ttl, :image_cache_ttl, :image_miss_ttl
42
42
 
43
43
  def plate_template_cache
44
44
  @plate_template_cache ||= Plates::TemplateCache.new
@@ -64,10 +64,36 @@ module Vehicles
64
64
  # VehiclesDB data repo via jsDelivr's CDN (always-latest release tag).
65
65
  attr_accessor :data_url
66
66
 
67
+ # The release manifest `refresh` reads FIRST (version, schema, per-kind
68
+ # counts, checksum when published): a few KB that decide whether the
69
+ # 8 MB payload is worth downloading at all. Derived from `data_url`
70
+ # (".../dist/vehicles.json" → ".../manifest.json") until you set it; set
71
+ # nil for a self-hosted payload that has no manifest.
72
+ attr_writer :manifest_url
73
+
74
+ def manifest_url
75
+ return @manifest_url unless @manifest_url == :derived
76
+
77
+ url = data_url.to_s
78
+ url.match?(%r{/dist/[^/]+\.json\z}) ? url.sub(%r{/dist/[^/]+\.json\z}, "/manifest.json") : nil
79
+ end
80
+
67
81
  # Where a refreshed dataset is cached on disk. Defaults to the app's cache dir
68
- # (Rails) or the system temp dir. The refresh writes here; loads prefer it.
82
+ # (Rails) or the system temp dir — or to $VEHICLES_CACHE_DIR when set. The
83
+ # refresh writes here; every process serves it when it is usable (see
84
+ # Vehicles::DataSource). Several containers (web + jobs) share ONE refresh
85
+ # by pointing this at a shared volume: `config.cache_dir = "/rails/storage/vehicles"`.
69
86
  attr_accessor :cache_path
70
87
 
88
+ # Directory form of `cache_path` (the file is <dir>/vehicles.json).
89
+ def cache_dir=(dir)
90
+ @cache_path = File.join(dir.to_s, "vehicles.json")
91
+ end
92
+
93
+ def cache_dir
94
+ File.dirname(cache_path.to_s)
95
+ end
96
+
71
97
  # Prefer a refreshed (cached) dataset over the bundled one when present.
72
98
  # Set false to always use the bundled snapshot (fully offline/deterministic).
73
99
  attr_accessor :use_cache
@@ -75,6 +101,48 @@ module Vehicles
75
101
  # Network timeout (seconds) for a refresh download.
76
102
  attr_accessor :refresh_timeout
77
103
 
104
+ # How often (seconds) a process checks whether the dataset file changed
105
+ # (one File.stat), so a refresh made by ANY process or container reaches
106
+ # it without a restart. nil = never (only `Vehicles.reload!`).
107
+ attr_accessor :reload_check_interval
108
+
109
+ # A release is refused when any kind, or any (kind, country) slice with at
110
+ # least 25 models, would shrink below this share of the data in use.
111
+ # nil disables the check.
112
+ attr_accessor :refresh_min_ratio
113
+
114
+ # Optional last word on a release: ->(candidate, current) { … } with two
115
+ # Vehicles::Dataset objects; falsy refuses it. Runs after every built-in
116
+ # check, e.g. "Spain's cars must not shrink":
117
+ # config.refresh_guard = ->(new, old) {
118
+ # new.top_models(kind: :car, country: :es, limit: nil).size >=
119
+ # old.top_models(kind: :car, country: :es, limit: nil).size
120
+ # }
121
+ attr_accessor :refresh_guard
122
+
123
+ # --- hosted API (optional) -----------------------------------------------
124
+
125
+ # Called with a Vehicles::HostedResult whenever a hosted call fails
126
+ # (401/402/403/429, 5xx, timeout, network…; not for a plain "no image"
127
+ # 404). Use it to report to your error tracker. Never allowed to raise
128
+ # into your page: its own errors are swallowed.
129
+ # config.on_hosted_error = ->(r) { Rails.error.report(r.to_error, handled: true) }
130
+ attr_accessor :on_hosted_error
131
+
132
+ # Image payloads are cached so a page view does not pay per image: any
133
+ # store with read(key) / write(key, value, expires_in:) (Rails.cache
134
+ # shares it across processes). In-process by default; false disables.
135
+ # A hit is kept `image_cache_ttl` seconds (capped by the payload's
136
+ # `expires_at` when the API sends one), a definitive miss (404)
137
+ # `image_miss_ttl`. Errors (401/402/429/timeouts) are never cached.
138
+ attr_writer :image_cache
139
+
140
+ def image_cache
141
+ return nil if @image_cache == false
142
+
143
+ @image_cache ||= Plates::TemplateCache.new
144
+ end
145
+
78
146
  def initialize
79
147
  @region = nil # no continent filter by default (global dataset)
80
148
  @api_key = nil
@@ -86,6 +154,13 @@ module Vehicles
86
154
  @cache_path = default_cache_path
87
155
  @use_cache = true
88
156
  @refresh_timeout = 5
157
+ @manifest_url = :derived
158
+ @reload_check_interval = 5
159
+ @refresh_min_ratio = 0.9
160
+ @refresh_guard = nil
161
+ @on_hosted_error = nil
162
+ @image_cache_ttl = 86_400 # a day; variant URLs live ~30 days
163
+ @image_miss_ttl = 600
89
164
  @plate_template_ttl = 86_400 # a day, the server's own shared-cache lifetime
90
165
  end
91
166
 
@@ -98,9 +173,13 @@ module Vehicles
98
173
 
99
174
  private
100
175
 
101
- # tmp/cache/vehicles under a Rails app, else a stable spot in the system temp
176
+ # $VEHICLES_CACHE_DIR when set (a shared volume), else tmp/cache/vehicles
177
+ # under a Rails app, else a stable spot in the system temp
102
178
  # dir. Refreshable data, so a cache-style location is appropriate.
103
179
  def default_cache_path
180
+ shared = ENV["VEHICLES_CACHE_DIR"].to_s
181
+ return File.join(shared, "vehicles.json") unless shared.empty?
182
+
104
183
  base =
105
184
  if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
106
185
  Rails.root.join("tmp", "cache", "vehicles")
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Vehicles
6
+ # Which dataset file this process serves, and when it switches to another.
7
+ # Behind Vehicles.dataset; not called directly.
8
+ #
9
+ # The rules (0.9.2):
10
+ # - an explicit `Vehicles.data_path=` always wins;
11
+ # - otherwise a refreshed cache (`config.cache_path`) is served only when
12
+ # it is USABLE: a schema this gem reads, and not older than the data
13
+ # bundled in the gem. A cache an older gem left behind loses to a newer
14
+ # gem's bundled data instead of silently downgrading it;
15
+ # - every process notices a new file: at most every
16
+ # `config.reload_check_interval` seconds (default 5) a call stats the
17
+ # file in effect and the cache, and reloads when either changed. So a
18
+ # refresh run by a job, another worker or another container (cache on a
19
+ # shared volume) reaches every web worker without a restart or a hook;
20
+ # - a file that cannot be read or parsed never replaces the dataset in
21
+ # memory: the process keeps serving what it has.
22
+ module DataSource
23
+ class Error < StandardError; end
24
+
25
+ # Dataset `schema_version`s this gem reads. A payload or cache with any
26
+ # other schema is never loaded or promoted.
27
+ SCHEMA_VERSIONS = [2].freeze
28
+
29
+ # How much of a file's start holds "version"/"schema_version" (both open
30
+ # every dist/vehicles.json), so checking a file never parses 8 MB.
31
+ HEADER_BYTES = 4096
32
+
33
+ Snapshot = Struct.new(:path, :stamp, :dataset, :checked_at)
34
+
35
+ LOCK = Mutex.new
36
+
37
+ module_function
38
+
39
+ # The Dataset to serve now. Lock-free on the hot path; the stat check and
40
+ # any reload happen under LOCK, once, whoever gets there first.
41
+ def dataset
42
+ snap = @snapshot
43
+ return snap.dataset if snap && !due?(snap)
44
+
45
+ LOCK.synchronize { sync }
46
+ end
47
+
48
+ def reset!
49
+ LOCK.synchronize do
50
+ @snapshot = nil
51
+ @headers = nil
52
+ @unloadable = nil
53
+ end
54
+ end
55
+
56
+ # The file Vehicles.dataset loads (see the rules above).
57
+ def active_path
58
+ return Vehicles.data_path_override if Vehicles.data_path_override
59
+
60
+ cache = Vehicles.configuration.cache_path.to_s
61
+ return cache if Vehicles.configuration.use_cache && usable_cache?(cache)
62
+
63
+ Vehicles::DATA_PATH
64
+ end
65
+
66
+ # A cache file the gem may serve: present, a supported schema, a valid
67
+ # version, and not older than the bundled data.
68
+ def usable_cache?(path)
69
+ return false if path.empty? || !File.file?(path)
70
+
71
+ head = header(path)
72
+ bundled = header(Vehicles::DATA_PATH)
73
+ compatible?(head) && head[:version] >= bundled[:version]
74
+ end
75
+
76
+ # { version: Gem::Version or nil, schema_version: Integer or nil } of a
77
+ # dataset file, memoized per file stamp (a changed file is re-read).
78
+ def header(path)
79
+ stamp = stamp(path)
80
+ cached = (@headers ||= {})[path]
81
+ return cached[1] if cached && cached[0] == stamp
82
+
83
+ parsed = read_header(path)
84
+ @headers[path] = [stamp, parsed]
85
+ parsed
86
+ end
87
+
88
+ def compatible?(head)
89
+ SCHEMA_VERSIONS.include?(head[:schema_version]) && !head[:version].nil?
90
+ end
91
+
92
+ # "2026.11.2" → Gem::Version, or nil when it is not a version.
93
+ def version(str)
94
+ str = str.to_s
95
+ Gem::Version.correct?(str) && !str.empty? ? Gem::Version.new(str) : nil
96
+ end
97
+
98
+ # [mtime, size, inode] — a rename onto the path changes the inode, so a
99
+ # promoted file is noticed even within one mtime tick. nil when missing.
100
+ def stamp(path)
101
+ st = File.stat(path)
102
+ [st.mtime, st.size, st.ino]
103
+ rescue SystemCallError
104
+ nil
105
+ end
106
+
107
+ # The newest dataset this app already has: the bundled one, or a usable
108
+ # cache (whatever `use_cache` says — a refresh never writes an older file).
109
+ # (and, unless `data_path=` pins a file, the dataset this process keeps
110
+ # in memory: it never goes back to older data on its own).
111
+ def newest_version
112
+ bundled = header(Vehicles::DATA_PATH)[:version]
113
+ cache = Vehicles.configuration.cache_path.to_s
114
+ cached = usable_cache?(cache) ? header(cache)[:version] : nil
115
+ memory = Vehicles.data_path_override ? nil : version(Vehicles.dataset.version)
116
+ [bundled, cached, memory].compact.max
117
+ end
118
+
119
+ # The cache file when another process promoted something newer than what
120
+ # this process has loaded, else the dataset in memory.
121
+ def newest_dataset(path)
122
+ current = Vehicles.dataset
123
+ loaded = version(current.version)
124
+ return current unless usable_cache?(path)
125
+ return current if loaded && header(path)[:version] <= loaded
126
+
127
+ build(path)
128
+ end
129
+
130
+ # --- internals -----------------------------------------------------------
131
+
132
+ def due?(snap, now = clock)
133
+ interval = Vehicles.configuration.reload_check_interval
134
+ !interval.nil? && now - snap.checked_at >= interval.to_f
135
+ end
136
+
137
+ def sync
138
+ snap = @snapshot
139
+ now = clock
140
+ return snap.dataset if snap && !due?(snap, now)
141
+
142
+ path = active_path
143
+ stamp = stamp(path)
144
+ # Unchanged, the very file that already failed to load, or older than
145
+ # what is in memory (a cache deleted or replaced by an older/unreadable
146
+ # file): keep serving. Only `reload!` goes back to older data.
147
+ if snap && ((snap.path == path && snap.stamp == stamp) || @unloadable == [path, stamp] || older?(path, snap))
148
+ snap.checked_at = now
149
+ return snap.dataset
150
+ end
151
+
152
+ load_snapshot(path, stamp, now, snap)
153
+ end
154
+
155
+ def older?(path, snap)
156
+ candidate = header(path)[:version]
157
+ current = version(snap.dataset.version)
158
+ current && (candidate.nil? || candidate < current)
159
+ end
160
+
161
+ # Parse and fully build a dataset file, or raise: a file that parses but
162
+ # is not a whole dataset (no makes, a malformed model) must fail HERE,
163
+ # not later inside a request.
164
+ def build(path, previous = nil)
165
+ raw = JSON.parse(File.read(path))
166
+ raise Error, "#{path}: no makes" unless raw.is_a?(Hash) && raw["makes"].is_a?(Array) && raw["makes"].any?
167
+
168
+ # The header was checked on an earlier read; another process may have
169
+ # replaced the file since. Check what was actually parsed.
170
+ unless path == Vehicles::DATA_PATH || Vehicles.data_path_override
171
+ head = { version: version(raw["version"]), schema_version: raw["schema_version"] }
172
+ raise Error, "#{path}: unusable" unless compatible?(head) &&
173
+ head[:version] >= header(Vehicles::DATA_PATH)[:version]
174
+ end
175
+
176
+ if previous && !Vehicles.data_path_override && version(raw["version"]) < version(previous.version)
177
+ raise Error, "#{path}: older than the dataset in memory"
178
+ end
179
+
180
+ Dataset.new(raw).tap(&:all_models)
181
+ end
182
+
183
+ def load_snapshot(path, stamp, now, previous)
184
+ dataset = build(path, previous&.dataset)
185
+
186
+ if previous && previous.dataset.version != dataset.version
187
+ Vehicles.logger&.info("[vehicles] dataset #{previous.dataset.version} → #{dataset.version} (#{path})")
188
+ end
189
+ @snapshot = Snapshot.new(path, stamp, dataset, now)
190
+ dataset
191
+ rescue StandardError => e
192
+ raise if path == Vehicles::DATA_PATH && previous.nil? # the gem's own data: a packaging bug, say so
193
+
194
+ Vehicles.logger&.warn("[vehicles] could not load #{path} (#{e.class}); keeping the dataset in use")
195
+ @unloadable = [path, stamp]
196
+ if previous
197
+ previous.checked_at = now
198
+ previous.dataset
199
+ else
200
+ load_snapshot(Vehicles::DATA_PATH, stamp(Vehicles::DATA_PATH), now, nil)
201
+ end
202
+ end
203
+
204
+ def read_header(path)
205
+ head = File.open(path, "rb") { |f| f.read(HEADER_BYTES) }.to_s.dup.force_encoding(Encoding::UTF_8).scrub
206
+ version = head[/"version"\s*:\s*"([^"]*)"/, 1]
207
+ schema = head[/"schema_version"\s*:\s*(\d+)/, 1]
208
+ if version.nil? || schema.nil? # not at the top: read it the slow way
209
+ raw = JSON.parse(File.read(path))
210
+ version = raw["version"] if raw.is_a?(Hash)
211
+ schema = raw["schema_version"] if raw.is_a?(Hash)
212
+ end
213
+ { version: version(version), schema_version: schema&.to_i }
214
+ rescue StandardError
215
+ { version: nil, schema_version: nil }
216
+ end
217
+
218
+ def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
219
+ end
220
+ end
@@ -37,12 +37,16 @@ module Vehicles
37
37
  end
38
38
  end
39
39
 
40
- attr_reader :version, :schema_version, :region
40
+ attr_reader :version, :schema_version, :region, :license, :attribution
41
41
 
42
42
  def initialize(raw)
43
43
  @version = raw["version"]
44
44
  @schema_version = raw["schema_version"]
45
45
  @region = raw["region"]
46
+ # The licence id and the credit block the release publishes (CC-BY
47
+ # 4.0), carried into Vehicles.dataset_slice so a slice keeps them.
48
+ @license = raw["license"]
49
+ @attribution = raw["attribution"]
46
50
 
47
51
  # Name-alphabetical, diacritic-insensitive — the dataset file arrives
48
52
  # slug-sorted, which differs subtly ("Austin-Healey" vs "Austin Morris").
@@ -61,6 +65,12 @@ module Vehicles
61
65
  @exact[a.downcase] ||= make
62
66
  end
63
67
  end
68
+ index_former_slugs
69
+ end
70
+
71
+ # The make that now carries a retired make slug ("mg-roewe" → MG), or nil.
72
+ def make_by_former_slug(slug)
73
+ @by_former_slug[slug.to_s]
64
74
  end
65
75
 
66
76
  # All makes, optionally filtered by kind and/or continent. `region:` is a
@@ -69,10 +79,11 @@ module Vehicles
69
79
  # this?"; on today's global snapshot both readings return European makes
70
80
  # for `region: :eu`, so existing callers are unaffected.) An unmapped
71
81
  # continent honestly returns [].
72
- def makes(kind: nil, region: nil)
82
+ def makes(kind: nil, region: nil, country: nil)
73
83
  list = @makes
74
84
  list = list.select { |m| m.kinds.include?(kind.to_sym) } if kind
75
85
  list = list.select { |m| m.in_region?(region) } if region
86
+ list = list.select { |m| m.available_in?(country, kind: kind) } if country
76
87
  list
77
88
  end
78
89
 
@@ -152,6 +163,11 @@ module Vehicles
152
163
  @all_models ||= @makes.flat_map(&:models).freeze
153
164
  end
154
165
 
166
+ # Continent codes present in the snapshot. => [:af, :as, :eu, ...]
167
+ def regions
168
+ @regions ||= all_models.flat_map(&:regions).uniq.sort.freeze
169
+ end
170
+
155
171
  # Every kind present in the snapshot. => [:bus, :car, :moped, ...]
156
172
  def kinds
157
173
  @kinds ||= @makes.flat_map(&:kinds).uniq.sort.freeze
@@ -178,6 +194,7 @@ module Vehicles
178
194
  # chosen decile only breaks ties among models ranked equally there.
179
195
  # top_models(kind: :car, by: :mass_decile, limit: 10)
180
196
  # top_models(kind: :motorcycle, region: :as, limit: 10) # by continent
197
+ # `limit: nil` returns every match.
181
198
  def top_models(kind: nil, country: nil, region: nil, limit: 20, by: :global_decile)
182
199
  raise ArgumentError, "by: must be :global_decile or :mass_decile" unless RANKINGS.include?(by)
183
200
 
@@ -186,14 +203,17 @@ module Vehicles
186
203
  list = list.select { |m| m.kind == kind.to_sym } if kind
187
204
  list = list.select { |m| m.availability.include?(c) } if c
188
205
  list = list.select { |m| m.available_in_region?(region) } if region
189
- list.sort_by do |m|
190
- rank = c && m.rank_in(c)
191
- [rank ? 0 : 1, rank || 0, m.public_send(by), -m.availability.size, m.name]
192
- end.first(limit)
206
+ sorted = list.sort_by { |m| popularity_key(m, c, by) }
207
+ limit ? sorted.first(limit) : sorted
193
208
  end
194
209
 
195
210
  RANKINGS = %i[global_decile mass_decile].freeze
196
211
 
212
+ def popularity_key(model, country, by)
213
+ rank = country && model.rank_in(country)
214
+ [rank ? 0 : 1, rank || 0, model.public_send(by), -model.availability.size, model.name]
215
+ end
216
+
197
217
  # Every model matching optional kind/region/rarity filters, ranked by
198
218
  # popularity. The "give me a sensible slice" entry point.
199
219
  def all_models_filtered(kind: nil, region: nil, rarity: nil, max_decile: nil)
@@ -214,6 +234,18 @@ module Vehicles
214
234
 
215
235
  private
216
236
 
237
+ # Former make slugs (a renamed or merged make) answer last: a current
238
+ # name, slug or alias always wins over a retired slug.
239
+ def index_former_slugs
240
+ @by_former_slug = {}
241
+ @makes.each do |make|
242
+ make.former_slugs.each do |old|
243
+ @by_former_slug[old] ||= make
244
+ index(old, make)
245
+ end
246
+ end
247
+ end
248
+
217
249
  def index(key, make)
218
250
  @index[Vehicles.normalize(key)] ||= make
219
251
  end
@@ -21,12 +21,48 @@ module Vehicles
21
21
  end
22
22
 
23
23
  # Records ("make/model" slugs, any make) NAMED this (normalized).
24
- def records_named(key)
25
- resolution_index[:name][key] || []
24
+ # `kinds` (a list, or nil for any) limits which records count.
25
+ def records_named(key, kinds = nil)
26
+ (resolution_index[:name][key] || []).select { |m| KindRule.allows?(kinds, m) }
27
+ .map { |m| "#{m.make_slug}/#{m.model_slug}" }.uniq
28
+ end
29
+
30
+ # The `via` step for a make reached through something other than its own
31
+ # name or slug: a retired make slug (`former_make_slug:old→new`,
32
+ # RESOLUTION.md) or an alias (`make_alias:key→slug`).
33
+ def make_step(make, key)
34
+ old = key.tr(" ", "-")
35
+ return "former_make_slug:#{old}→#{make.slug}" if make_by_former_slug(old).equal?(make)
36
+
37
+ "make_alias:#{key}→#{make.slug}"
38
+ end
39
+
40
+ # The live Model a retired id now answers to (Vehicles.by_id), or nil:
41
+ # 1. the record that lists the id in `former_ids` (one record; its kind
42
+ # variant of the id's kind, else the collision rule);
43
+ # 2. failing that, the make's new slug (make `former_slugs`): the same
44
+ # model slug under it, exactly or through ITS former ids.
45
+ def successor_of(id)
46
+ found = absorbed(id)
47
+ return found if found
48
+
49
+ kind, make_slug, model_slug = id.to_s.split("/", 3)
50
+ return nil unless model_slug && (make = make_by_former_slug(make_slug))
51
+
52
+ moved = "#{kind}/#{make.slug}/#{model_slug}"
53
+ models_by_id(moved).first || absorbed(moved)
26
54
  end
27
55
 
28
56
  private
29
57
 
58
+ def absorbed(id)
59
+ hits = models_by_former_id(id)
60
+ return nil if hits.empty? || hits.map { |m| [m.make_slug, m.model_slug] }.uniq.size > 1
61
+
62
+ kind = id.to_s.split("/", 2).first
63
+ hits.find { |m| m.kind.to_s == kind } || KindRule.pick(hits)
64
+ end
65
+
30
66
  # Built once on first use; arrays keep dataset order.
31
67
  def resolution_index
32
68
  @resolution_index ||= begin
@@ -44,7 +80,7 @@ module Vehicles
44
80
  key = Vehicles.normalize(a)
45
81
  (idx[:alias][key] ||= []) << model unless key.empty?
46
82
  end
47
- (idx[:name][Vehicles.normalize(model.name)] ||= []) << record
83
+ (idx[:name][Vehicles.normalize(model.name)] ||= []) << model
48
84
  end
49
85
  end
50
86
  end