unitsdb 1.0.0 → 2.1.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.gitmodules +3 -0
  3. data/.rspec +2 -1
  4. data/.rubocop_todo.yml +88 -12
  5. data/Gemfile +2 -2
  6. data/README.adoc +697 -1
  7. data/exe/unitsdb +7 -0
  8. data/lib/unitsdb/cli.rb +81 -0
  9. data/lib/unitsdb/commands/_modify.rb +22 -0
  10. data/lib/unitsdb/commands/base.rb +52 -0
  11. data/lib/unitsdb/commands/check_si.rb +124 -0
  12. data/lib/unitsdb/commands/get.rb +133 -0
  13. data/lib/unitsdb/commands/normalize.rb +81 -0
  14. data/lib/unitsdb/commands/release.rb +112 -0
  15. data/lib/unitsdb/commands/search.rb +219 -0
  16. data/lib/unitsdb/commands/si_formatter.rb +485 -0
  17. data/lib/unitsdb/commands/si_matcher.rb +470 -0
  18. data/lib/unitsdb/commands/si_ttl_parser.rb +100 -0
  19. data/lib/unitsdb/commands/si_updater.rb +212 -0
  20. data/lib/unitsdb/commands/validate/identifiers.rb +40 -0
  21. data/lib/unitsdb/commands/validate/references.rb +316 -0
  22. data/lib/unitsdb/commands/validate/si_references.rb +115 -0
  23. data/lib/unitsdb/commands/validate.rb +40 -0
  24. data/lib/unitsdb/database.rb +661 -0
  25. data/lib/unitsdb/dimension.rb +49 -0
  26. data/lib/unitsdb/dimension_details.rb +20 -0
  27. data/lib/unitsdb/dimension_reference.rb +8 -0
  28. data/lib/unitsdb/dimensions.rb +4 -10
  29. data/lib/unitsdb/errors.rb +13 -0
  30. data/lib/unitsdb/external_reference.rb +14 -0
  31. data/lib/unitsdb/identifier.rb +8 -0
  32. data/lib/unitsdb/localized_string.rb +17 -0
  33. data/lib/unitsdb/prefix.rb +30 -0
  34. data/lib/unitsdb/prefix_reference.rb +10 -0
  35. data/lib/unitsdb/prefixes.rb +4 -11
  36. data/lib/unitsdb/quantities.rb +4 -31
  37. data/lib/unitsdb/quantity.rb +21 -0
  38. data/lib/unitsdb/quantity_reference.rb +10 -0
  39. data/lib/unitsdb/root_unit_reference.rb +14 -0
  40. data/lib/unitsdb/scale.rb +17 -0
  41. data/lib/unitsdb/scale_properties.rb +12 -0
  42. data/lib/unitsdb/scale_reference.rb +10 -0
  43. data/lib/unitsdb/scales.rb +11 -0
  44. data/lib/unitsdb/si_derived_base.rb +19 -0
  45. data/lib/unitsdb/symbol_presentations.rb +3 -8
  46. data/lib/unitsdb/unit.rb +63 -0
  47. data/lib/unitsdb/unit_reference.rb +10 -0
  48. data/lib/unitsdb/unit_system.rb +15 -0
  49. data/lib/unitsdb/unit_system_reference.rb +10 -0
  50. data/lib/unitsdb/unit_systems.rb +4 -10
  51. data/lib/unitsdb/units.rb +4 -10
  52. data/lib/unitsdb/utils.rb +84 -0
  53. data/lib/unitsdb/version.rb +1 -1
  54. data/lib/unitsdb.rb +12 -2
  55. data/unitsdb.gemspec +6 -1
  56. metadata +116 -20
  57. data/lib/unitsdb/dimensions/dimension.rb +0 -59
  58. data/lib/unitsdb/dimensions/quantity.rb +0 -32
  59. data/lib/unitsdb/dimensions/symbol.rb +0 -26
  60. data/lib/unitsdb/prefixes/prefix.rb +0 -35
  61. data/lib/unitsdb/prefixes/symbol.rb +0 -17
  62. data/lib/unitsdb/quantities/quantity.rb +0 -37
  63. data/lib/unitsdb/quantities/unit_reference.rb +0 -15
  64. data/lib/unitsdb/unit_systems/unit_system.rb +0 -19
  65. data/lib/unitsdb/units/quantity_reference.rb +0 -17
  66. data/lib/unitsdb/units/root_unit.rb +0 -21
  67. data/lib/unitsdb/units/root_units.rb +0 -18
  68. data/lib/unitsdb/units/si_derived_base.rb +0 -26
  69. data/lib/unitsdb/units/symbol.rb +0 -19
  70. data/lib/unitsdb/units/system.rb +0 -17
  71. data/lib/unitsdb/units/unit.rb +0 -73
  72. data/lib/unitsdb/unitsdb.rb +0 -6
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Unitsdb
7
+ module Commands
8
+ # Updater for SI references in YAML
9
+ module SiUpdater
10
+ SI_AUTHORITY = "si-digital-framework"
11
+
12
+ module_function
13
+
14
+ # Update references in YAML file (TTL → DB direction)
15
+ def update_references(entity_type, missing_matches, db_entities, output_file, include_potential = false)
16
+ # Use the database objects to access the data directly
17
+ original_yaml_file = db_entities.first.send(:yaml_file) if db_entities&.first.respond_to?(:yaml_file, true)
18
+
19
+ # If we can't get the path from the database object, use the output file path as a fallback
20
+ if original_yaml_file.nil? || !File.exist?(original_yaml_file)
21
+ puts "Warning: Could not determine original YAML file path. Using output file as template."
22
+ original_yaml_file = output_file
23
+
24
+ # Create an empty template if output file doesn't exist
25
+ unless File.exist?(original_yaml_file)
26
+ FileUtils.mkdir_p(File.dirname(original_yaml_file))
27
+ File.write(original_yaml_file, { entity_type => [] }.to_yaml)
28
+ end
29
+ end
30
+
31
+ # Load the original YAML file
32
+ yaml_content = File.read(original_yaml_file)
33
+ output_data = YAML.safe_load(yaml_content)
34
+
35
+ # Group by entity ID to avoid duplicates
36
+ grouped_matches = missing_matches.group_by { |match| match[:entity_id] }
37
+
38
+ # Process each entity that needs updating
39
+ grouped_matches.each do |entity_id, matches|
40
+ # Filter matches based on include_potential parameter
41
+ filtered_matches = matches.select do |match|
42
+ # Check if it's an exact match or if we're including potential matches
43
+ match_details = match[:match_details]
44
+ if match_details&.dig(:exact) == false || %w[symbol_match
45
+ partial_match].include?(match_details&.dig(:match_desc) || "")
46
+ include_potential
47
+ else
48
+ true
49
+ end
50
+ end
51
+
52
+ # Skip if no matches after filtering
53
+ next if filtered_matches.empty?
54
+
55
+ # Find the entity in the array under the entity_type key
56
+ entity_index = output_data[entity_type].find_index do |e|
57
+ # Find entity with matching identifier
58
+ e["identifiers"]&.any? { |id| id["id"] == entity_id }
59
+ end
60
+
61
+ next unless entity_index
62
+
63
+ # Get the entity
64
+ entity = output_data[entity_type][entity_index]
65
+
66
+ # Initialize references array if it doesn't exist
67
+ entity["references"] ||= []
68
+
69
+ # Add new references
70
+ filtered_matches.each do |match|
71
+ # If this match has multiple SI references, add them all
72
+ if match[:multiple_si]
73
+ match[:multiple_si].each do |si_data|
74
+ # Check if reference already exists
75
+ next if entity["references"].any? do |ref|
76
+ ref["uri"] == si_data[:uri] && ref["authority"] == SI_AUTHORITY
77
+ end
78
+
79
+ # Add new reference
80
+ entity["references"] << {
81
+ "uri" => si_data[:uri],
82
+ "type" => "normative",
83
+ "authority" => SI_AUTHORITY
84
+ }
85
+ end
86
+ else
87
+ # Check if reference already exists
88
+ next if entity["references"].any? do |ref|
89
+ ref["uri"] == match[:si_uri] && ref["authority"] == SI_AUTHORITY
90
+ end
91
+
92
+ # Add new reference
93
+ entity["references"] << {
94
+ "uri" => match[:si_uri],
95
+ "type" => "normative",
96
+ "authority" => SI_AUTHORITY
97
+ }
98
+ end
99
+ end
100
+ end
101
+
102
+ write_yaml_file(output_file, output_data)
103
+ end
104
+
105
+ # Update references in YAML file (DB → TTL direction)
106
+ def update_db_references(entity_type, missing_refs, output_file, include_potential = false)
107
+ # Try to get the original YAML file from the first entity
108
+ first_entity = missing_refs.first&.dig(:db_entity)
109
+ original_yaml_file = first_entity.send(:yaml_file) if first_entity.respond_to?(:yaml_file, true)
110
+
111
+ # If we can't get the path from the database object, use the output file path as a fallback
112
+ if original_yaml_file.nil? || !File.exist?(original_yaml_file)
113
+ puts "Warning: Could not determine original YAML file path. Using output file as template."
114
+ original_yaml_file = output_file
115
+
116
+ # Create an empty template if output file doesn't exist
117
+ unless File.exist?(original_yaml_file)
118
+ FileUtils.mkdir_p(File.dirname(original_yaml_file))
119
+ File.write(original_yaml_file, { entity_type => [] }.to_yaml)
120
+ end
121
+ end
122
+
123
+ # Load the original YAML file
124
+ yaml_content = File.read(original_yaml_file)
125
+ output_data = YAML.safe_load(yaml_content)
126
+
127
+ # Group by entity ID to avoid duplicates
128
+ missing_refs_by_id = {}
129
+
130
+ missing_refs.each do |match|
131
+ entity_id = match[:entity_id] || match[:db_entity].short
132
+ ttl_entities = match[:ttl_entities]
133
+ match_types = match[:match_types] || {}
134
+
135
+ # Filter TTL entities based on include_potential parameter
136
+ filtered_ttl_entities = ttl_entities.select do |ttl_entity|
137
+ # Check if it's an exact match or if we're including potential matches
138
+ match_type = match_types[ttl_entity[:uri]] || "Exact match" # Default to exact match
139
+ match_pair_key = "#{entity_id}:#{ttl_entity[:uri]}"
140
+ match_details = Unitsdb::Commands::SiMatcher.instance_variable_get(:@match_details)&.dig(match_pair_key)
141
+
142
+ if match_details && %w[symbol_match partial_match].include?(match_details[:match_desc])
143
+ include_potential
144
+ else
145
+ match_type == "Exact match" || include_potential
146
+ end
147
+ end
148
+
149
+ # Skip if no entities after filtering
150
+ next if filtered_ttl_entities.empty?
151
+
152
+ missing_refs_by_id[entity_id] ||= []
153
+
154
+ # Add filtered matching TTL entities for this DB entity
155
+ filtered_ttl_entities.each do |ttl_entity|
156
+ missing_refs_by_id[entity_id] << {
157
+ uri: ttl_entity[:uri],
158
+ type: "normative",
159
+ authority: SI_AUTHORITY
160
+ }
161
+ end
162
+ end
163
+
164
+ # Update the YAML content
165
+ output_data[entity_type].each do |entity_yaml|
166
+ # Find entity by ID or short
167
+ entity_id = if entity_yaml["identifiers"]
168
+ begin
169
+ entity_yaml["identifiers"].first["id"]
170
+ rescue StandardError
171
+ nil
172
+ end
173
+ elsif entity_yaml["id"]
174
+ entity_yaml["id"]
175
+ end
176
+
177
+ next unless entity_id && missing_refs_by_id.key?(entity_id)
178
+
179
+ # Add references
180
+ entity_yaml["references"] ||= []
181
+
182
+ missing_refs_by_id[entity_id].each do |ref|
183
+ # Check if this reference already exists
184
+ next if entity_yaml["references"].any? do |existing_ref|
185
+ existing_ref["uri"] == ref[:uri] &&
186
+ existing_ref["authority"] == ref[:authority]
187
+ end
188
+
189
+ # Add the reference
190
+ entity_yaml["references"] << {
191
+ "uri" => ref[:uri],
192
+ "type" => ref[:type],
193
+ "authority" => ref[:authority]
194
+ }
195
+ end
196
+ end
197
+
198
+ write_yaml_file(output_file, output_data)
199
+ end
200
+
201
+ # Helper to write YAML file
202
+ def write_yaml_file(output_file, output_data)
203
+ # Ensure the output directory exists
204
+ output_dir = File.dirname(output_file)
205
+ FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
206
+
207
+ # Write to YAML file
208
+ File.write(output_file, output_data.to_yaml)
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unitsdb
4
+ module Commands
5
+ module Validate
6
+ class Identifiers < Base
7
+ def run
8
+ db = load_database
9
+ all_dups = db.validate_uniqueness
10
+
11
+ display_results(all_dups)
12
+ rescue Unitsdb::Errors::DatabaseError => e
13
+ puts "Error: #{e.message}"
14
+ exit(1)
15
+ end
16
+
17
+ private
18
+
19
+ def display_results(all_dups)
20
+ %i[short id].each do |type|
21
+ dups = all_dups[type]
22
+ if dups.empty?
23
+ puts "No duplicate '#{type}' fields found."
24
+ next
25
+ end
26
+
27
+ puts "\nFound duplicate '#{type}' fields:"
28
+ dups.each do |file, items|
29
+ puts " #{file}:"
30
+ items.each do |val, paths|
31
+ puts " '#{val}':"
32
+ paths.each { |p| puts " - #{p}" }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unitsdb
4
+ module Commands
5
+ module Validate
6
+ class References < Base
7
+ def run
8
+ # Load the database
9
+ db = load_database(@options[:database])
10
+
11
+ # Build registry of all valid IDs
12
+ registry = build_id_registry(db)
13
+
14
+ # Check all references
15
+ invalid_refs = check_references(db, registry)
16
+
17
+ # Display results
18
+ display_reference_results(invalid_refs, registry)
19
+ rescue Unitsdb::Errors::DatabaseError => e
20
+ puts "Error: #{e.message}"
21
+ exit(1)
22
+ end
23
+
24
+ private
25
+
26
+ def build_id_registry(db)
27
+ registry = {}
28
+
29
+ # Add all unit identifiers to the registry
30
+ registry["units"] = {}
31
+ db.units.each_with_index do |unit, index|
32
+ unit.identifiers.each do |identifier|
33
+ next unless identifier.id && identifier.type
34
+
35
+ # Add the composite key (type:id)
36
+ composite_key = "#{identifier.type}:#{identifier.id}"
37
+ registry["units"][composite_key] = "index:#{index}"
38
+
39
+ # Also add just the ID for backward compatibility
40
+ registry["units"][identifier.id] = "index:#{index}"
41
+ end
42
+ end
43
+
44
+ # Add dimension identifiers
45
+ registry["dimensions"] = {}
46
+ db.dimensions.each_with_index do |dimension, index|
47
+ dimension.identifiers.each do |identifier|
48
+ next unless identifier.id && identifier.type
49
+
50
+ composite_key = "#{identifier.type}:#{identifier.id}"
51
+ registry["dimensions"][composite_key] = "index:#{index}"
52
+ registry["dimensions"][identifier.id] = "index:#{index}"
53
+ end
54
+
55
+ # Also track dimensions by short name
56
+ if dimension.respond_to?(:short) && dimension.short
57
+ registry["dimensions_short"] ||= {}
58
+ registry["dimensions_short"][dimension.short] = "index:#{index}"
59
+ end
60
+ end
61
+
62
+ # Add quantity identifiers
63
+ registry["quantities"] = {}
64
+ db.quantities.each_with_index do |quantity, index|
65
+ quantity.identifiers.each do |identifier|
66
+ next unless identifier.id && identifier.type
67
+
68
+ composite_key = "#{identifier.type}:#{identifier.id}"
69
+ registry["quantities"][composite_key] = "index:#{index}"
70
+ registry["quantities"][identifier.id] = "index:#{index}"
71
+ end
72
+ end
73
+
74
+ # Add prefix identifiers
75
+ registry["prefixes"] = {}
76
+ db.prefixes.each_with_index do |prefix, index|
77
+ prefix.identifiers.each do |identifier|
78
+ next unless identifier.id && identifier.type
79
+
80
+ composite_key = "#{identifier.type}:#{identifier.id}"
81
+ registry["prefixes"][composite_key] = "index:#{index}"
82
+ registry["prefixes"][identifier.id] = "index:#{index}"
83
+ end
84
+ end
85
+
86
+ # Add unit system identifiers
87
+ registry["unit_systems"] = {}
88
+ db.unit_systems.each_with_index do |unit_system, index|
89
+ unit_system.identifiers.each do |identifier|
90
+ next unless identifier.id && identifier.type
91
+
92
+ composite_key = "#{identifier.type}:#{identifier.id}"
93
+ registry["unit_systems"][composite_key] = "index:#{index}"
94
+ registry["unit_systems"][identifier.id] = "index:#{index}"
95
+ end
96
+
97
+ # Also track unit systems by short name
98
+ if unit_system.respond_to?(:short) && unit_system.short
99
+ registry["unit_systems_short"] ||= {}
100
+ registry["unit_systems_short"][unit_system.short] = "index:#{index}"
101
+ end
102
+ end
103
+
104
+ # Debug registry if requested
105
+ if @options[:debug_registry]
106
+ puts "Registry contents:"
107
+ registry.each do |type, ids|
108
+ puts " #{type}:"
109
+ ids.each do |id, location|
110
+ puts " #{id} => #{location}"
111
+ end
112
+ end
113
+ end
114
+
115
+ registry
116
+ end
117
+
118
+ def check_references(db, registry)
119
+ invalid_refs = {}
120
+
121
+ # Check unit references in dimensions
122
+ check_dimension_references(db, registry, invalid_refs)
123
+
124
+ # Check unit_system references
125
+ check_unit_system_references(db, registry, invalid_refs)
126
+
127
+ # Check quantity references
128
+ check_quantity_references(db, registry, invalid_refs)
129
+
130
+ # Check root unit references in units
131
+ check_root_unit_references(db, registry, invalid_refs)
132
+
133
+ invalid_refs
134
+ end
135
+
136
+ def check_dimension_references(db, registry, invalid_refs)
137
+ db.dimensions.each_with_index do |dimension, index|
138
+ next unless dimension.respond_to?(:dimension_reference) && dimension.dimension_reference
139
+
140
+ ref_id = dimension.dimension_reference
141
+ ref_type = "dimensions"
142
+ ref_path = "dimensions:index:#{index}:dimension_reference"
143
+
144
+ validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs, "dimensions")
145
+ end
146
+ end
147
+
148
+ def check_unit_system_references(db, registry, invalid_refs)
149
+ db.units.each_with_index do |unit, index|
150
+ next unless unit.respond_to?(:unit_system_reference) && unit.unit_system_reference
151
+
152
+ unit.unit_system_reference.each_with_index do |ref_id, idx|
153
+ ref_type = "unit_systems"
154
+ ref_path = "units:index:#{index}:unit_system_reference[#{idx}]"
155
+
156
+ validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs, "units")
157
+ end
158
+ end
159
+ end
160
+
161
+ def check_quantity_references(db, registry, invalid_refs)
162
+ db.units.each_with_index do |unit, index|
163
+ next unless unit.respond_to?(:quantity_references) && unit.quantity_references
164
+
165
+ unit.quantity_references.each_with_index do |ref_id, idx|
166
+ ref_type = "quantities"
167
+ ref_path = "units:index:#{index}:quantity_references[#{idx}]"
168
+
169
+ validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs, "units")
170
+ end
171
+ end
172
+ end
173
+
174
+ def check_root_unit_references(db, registry, invalid_refs)
175
+ db.units.each_with_index do |unit, index|
176
+ next unless unit.respond_to?(:root_units) && unit.root_units
177
+
178
+ unit.root_units.each_with_index do |root_unit, idx|
179
+ next unless root_unit.respond_to?(:unit_reference) && root_unit.unit_reference
180
+
181
+ # Check unit reference
182
+ ref_id = root_unit.unit_reference
183
+ ref_type = "units"
184
+ ref_path = "units:index:#{index}:root_units.#{idx}.unit_reference"
185
+
186
+ validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs, "units")
187
+
188
+ # Check prefix reference if present
189
+ next unless root_unit.respond_to?(:prefix_reference) && root_unit.prefix_reference
190
+
191
+ ref_id = root_unit.prefix_reference
192
+ ref_type = "prefixes"
193
+ ref_path = "units:index:#{index}:root_units.#{idx}.prefix_reference"
194
+
195
+ validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs, "units")
196
+ end
197
+ end
198
+ end
199
+
200
+ def validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs, file_type)
201
+ # Handle references that are objects with id and type (could be a hash or an object)
202
+ if ref_id.respond_to?(:id) && ref_id.respond_to?(:type)
203
+ id = ref_id.id
204
+ type = ref_id.type
205
+ composite_key = "#{type}:#{id}"
206
+
207
+ # Try multiple lookup strategies
208
+ valid = false
209
+
210
+ # 1. Try exact composite key match
211
+ valid = true if registry.key?(ref_type) && registry[ref_type].key?(composite_key)
212
+
213
+ # 2. Try just ID match if composite didn't work
214
+ valid = true if !valid && registry.key?(ref_type) && registry[ref_type].key?(id)
215
+
216
+ # 3. Try alternate ID formats for unit systems (e.g., SI_base vs si-base)
217
+ if !valid && type == "unitsml" && ref_type == "unit_systems" && registry.key?(ref_type) && (
218
+ registry[ref_type].keys.any? { |k| k.end_with?(":#{id}") } ||
219
+ registry[ref_type].keys.any? { |k| k.end_with?(":SI_#{id.sub("si-", "")}") } ||
220
+ registry[ref_type].keys.any? { |k| k.end_with?(":non-SI_#{id.sub("nonsi-", "")}") }
221
+ )
222
+ # Special handling for unit_systems between unitsml and nist types
223
+ valid = true
224
+ end
225
+
226
+ if valid
227
+ puts "Valid reference: #{id} (#{type}) at #{file_type}:#{ref_path}" if @options[:print_valid]
228
+ else
229
+ invalid_refs[file_type] ||= {}
230
+ invalid_refs[file_type][ref_path] = { id: id, type: type, ref_type: ref_type }
231
+ end
232
+ # Handle references that are objects with id and type in a hash
233
+ elsif ref_id.is_a?(Hash) && ref_id.key?("id") && ref_id.key?("type")
234
+ id = ref_id["id"]
235
+ type = ref_id["type"]
236
+ composite_key = "#{type}:#{id}"
237
+
238
+ # Try multiple lookup strategies
239
+ valid = false
240
+
241
+ # 1. Try exact composite key match
242
+ valid = true if registry.key?(ref_type) && registry[ref_type].key?(composite_key)
243
+
244
+ # 2. Try just ID match if composite didn't work
245
+ valid = true if !valid && registry.key?(ref_type) && registry[ref_type].key?(id)
246
+
247
+ # 3. Try alternate ID formats for unit systems (e.g., SI_base vs si-base)
248
+ if !valid && type == "unitsml" && ref_type == "unit_systems" && registry.key?(ref_type) && (
249
+ registry[ref_type].keys.any? { |k| k.end_with?(":#{id}") } ||
250
+ registry[ref_type].keys.any? { |k| k.end_with?(":SI_#{id.sub("si-", "")}") } ||
251
+ registry[ref_type].keys.any? { |k| k.end_with?(":non-SI_#{id.sub("nonsi-", "")}") }
252
+ )
253
+ # Special handling for unit_systems between unitsml and nist types
254
+ valid = true
255
+ end
256
+
257
+ if valid
258
+ puts "Valid reference: #{id} (#{type}) at #{file_type}:#{ref_path}" if @options[:print_valid]
259
+ else
260
+ invalid_refs[file_type] ||= {}
261
+ invalid_refs[file_type][ref_path] = { id: id, type: type, ref_type: ref_type }
262
+ end
263
+ else
264
+ # Handle plain string references (legacy format)
265
+ valid = registry.key?(ref_type) && registry[ref_type].key?(ref_id)
266
+
267
+ if valid
268
+ puts "Valid reference: #{ref_id} (#{ref_type}) at #{file_type}:#{ref_path}" if @options[:print_valid]
269
+ else
270
+ invalid_refs[file_type] ||= {}
271
+ invalid_refs[file_type][ref_path] = { id: ref_id, type: ref_type }
272
+ end
273
+ end
274
+ end
275
+
276
+ def display_reference_results(invalid_refs, registry)
277
+ if invalid_refs.empty?
278
+ puts "All references are valid!"
279
+ return
280
+ end
281
+
282
+ puts "Found invalid references:"
283
+
284
+ # Display registry contents if debug_registry is enabled
285
+ # This is needed for the failing test
286
+ if @options[:debug_registry]
287
+ puts "\nRegistry contents:"
288
+ registry.each do |type, ids|
289
+ next if ids.empty?
290
+
291
+ puts " #{type}:"
292
+ ids.each do |id, location|
293
+ puts " #{id}: {type: #{type.sub("s", "")}, source: #{location}}"
294
+ end
295
+ end
296
+ end
297
+ invalid_refs.each do |file, refs|
298
+ puts " #{file}:"
299
+ refs.each do |path, ref|
300
+ puts " #{path} => '#{ref[:id]}' (#{ref[:type]})"
301
+
302
+ # Suggest corrections
303
+ next unless registry.key?(ref[:ref_type])
304
+
305
+ similar_ids = Unitsdb::Utils.find_similar_ids(ref[:id], registry[ref[:ref_type]].keys)
306
+ if similar_ids.any?
307
+ puts " Did you mean one of these?"
308
+ similar_ids.each { |id| puts " - #{id}" }
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+
5
+ module Unitsdb
6
+ module Commands
7
+ module Validate
8
+ class SiReferences < Unitsdb::Commands::Base
9
+ def run
10
+ # Load the database
11
+ db = load_database(@options[:database])
12
+
13
+ # Check for duplicate SI references
14
+ duplicates = check_si_references(db)
15
+
16
+ # Display results
17
+ display_duplicate_results(duplicates)
18
+ rescue Unitsdb::Errors::DatabaseError => e
19
+ puts "Error: #{e.message}"
20
+ exit(1)
21
+ end
22
+
23
+ private
24
+
25
+ def check_si_references(db)
26
+ duplicates = {}
27
+
28
+ # Check units
29
+ check_entity_si_references(db.units, "units", duplicates)
30
+
31
+ # Check quantities
32
+ check_entity_si_references(db.quantities, "quantities", duplicates)
33
+
34
+ # Check prefixes
35
+ check_entity_si_references(db.prefixes, "prefixes", duplicates)
36
+
37
+ duplicates
38
+ end
39
+
40
+ def check_entity_si_references(entities, entity_type, duplicates)
41
+ # Track SI references by URI
42
+ si_refs = {}
43
+
44
+ entities.each_with_index do |entity, index|
45
+ # Skip if no references
46
+ next unless entity.respond_to?(:references) && entity.references
47
+
48
+ # Check each reference
49
+ entity.references.each do |ref|
50
+ # Only interested in si-digital-framework references
51
+ next unless ref.authority == "si-digital-framework"
52
+
53
+ # Get entity info for display
54
+ entity_id = if entity.respond_to?(:identifiers) && entity.identifiers&.first.respond_to?(:id)
55
+ entity.identifiers.first.id
56
+ else
57
+ entity.short
58
+ end
59
+
60
+ # Track this reference
61
+ si_refs[ref.uri] ||= []
62
+ si_refs[ref.uri] << {
63
+ entity_id: entity_id,
64
+ entity_name: entity.respond_to?(:names) ? entity.names.first : entity.short,
65
+ index: index
66
+ }
67
+ end
68
+ end
69
+
70
+ # Find duplicates (URIs with more than one entity)
71
+ si_refs.each do |uri, entities|
72
+ next unless entities.size > 1
73
+
74
+ # Record this duplicate
75
+ duplicates[entity_type] ||= {}
76
+ duplicates[entity_type][uri] = entities
77
+ end
78
+ end
79
+
80
+ def display_duplicate_results(duplicates)
81
+ if duplicates.empty?
82
+ puts "No duplicate SI references found! Each SI reference URI is used by at most one entity of each type."
83
+ return
84
+ end
85
+
86
+ puts "Found duplicate SI references:"
87
+
88
+ duplicates.each do |entity_type, uri_duplicates|
89
+ puts "\n #{entity_type.capitalize}:"
90
+
91
+ uri_duplicates.each do |uri, entities|
92
+ puts " SI URI: #{uri}"
93
+ puts " Used by #{entities.size} entities:"
94
+
95
+ entities.each do |entity|
96
+ puts " - #{entity[:entity_id]} (#{entity[:entity_name]}) at index #{entity[:index]}"
97
+ end
98
+ puts ""
99
+ end
100
+ end
101
+
102
+ puts "\nEach SI digital framework reference should be used by at most one entity of each type."
103
+ puts "Please fix the duplicates by either removing the reference from all but one entity,"
104
+ puts "or by updating the references to use different URIs appropriate for each entity."
105
+ end
106
+
107
+ def load_database(path)
108
+ Unitsdb::Database.from_db(path)
109
+ rescue StandardError => e
110
+ raise Unitsdb::Errors::DatabaseError, "Failed to load database: #{e.message}"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end