opencdd 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/CLAUDE.md +486 -0
  3. data/README.md +304 -0
  4. data/bin/lint-no-raw-mdc +41 -0
  5. data/lib/cdd.rb +11 -0
  6. data/lib/opencdd/alias_table.rb +52 -0
  7. data/lib/opencdd/cddal/ast.rb +151 -0
  8. data/lib/opencdd/cddal/builder.rb +374 -0
  9. data/lib/opencdd/cddal/fetcher/in_memory.rb +32 -0
  10. data/lib/opencdd/cddal/fetcher/net_http.rb +84 -0
  11. data/lib/opencdd/cddal/fetcher.rb +18 -0
  12. data/lib/opencdd/cddal/generated_parser.rb +805 -0
  13. data/lib/opencdd/cddal/lexer.rb +193 -0
  14. data/lib/opencdd/cddal/parser.rb +19 -0
  15. data/lib/opencdd/cddal/resolver.rb +100 -0
  16. data/lib/opencdd/cddal/serializer.rb +210 -0
  17. data/lib/opencdd/cddal/value_serializer.rb +60 -0
  18. data/lib/opencdd/cddal.rb +63 -0
  19. data/lib/opencdd/class_tree.rb +80 -0
  20. data/lib/opencdd/class_type.rb +33 -0
  21. data/lib/opencdd/codegen/ts.rb +185 -0
  22. data/lib/opencdd/codegen.rb +7 -0
  23. data/lib/opencdd/composition_tree.rb +119 -0
  24. data/lib/opencdd/condition.rb +120 -0
  25. data/lib/opencdd/data_type.rb +143 -0
  26. data/lib/opencdd/database.rb +719 -0
  27. data/lib/opencdd/effective_properties.rb +119 -0
  28. data/lib/opencdd/entity/field_reader.rb +141 -0
  29. data/lib/opencdd/entity/field_registry.rb +99 -0
  30. data/lib/opencdd/entity/version_history.rb +62 -0
  31. data/lib/opencdd/entity.rb +255 -0
  32. data/lib/opencdd/exporters/json.rb +235 -0
  33. data/lib/opencdd/exporters/mermaid.rb +62 -0
  34. data/lib/opencdd/exporters/yaml.rb +16 -0
  35. data/lib/opencdd/exporters.rb +9 -0
  36. data/lib/opencdd/guid.rb +27 -0
  37. data/lib/opencdd/instance_rule.rb +70 -0
  38. data/lib/opencdd/irdi.rb +128 -0
  39. data/lib/opencdd/klass.rb +230 -0
  40. data/lib/opencdd/languages.rb +70 -0
  41. data/lib/opencdd/meta_class.rb +274 -0
  42. data/lib/opencdd/model/entity_store.rb +85 -0
  43. data/lib/opencdd/model/yaml_database.rb +49 -0
  44. data/lib/opencdd/model/yaml_entity.rb +196 -0
  45. data/lib/opencdd/model.rb +13 -0
  46. data/lib/opencdd/parcel/csv_reader.rb +35 -0
  47. data/lib/opencdd/parcel/csv_writer.rb +114 -0
  48. data/lib/opencdd/parcel/entity_manifest.rb +119 -0
  49. data/lib/opencdd/parcel/flat_dir_reader.rb +134 -0
  50. data/lib/opencdd/parcel/layout_detector.rb +115 -0
  51. data/lib/opencdd/parcel/metadata.rb +112 -0
  52. data/lib/opencdd/parcel/referenced_irdis.rb +91 -0
  53. data/lib/opencdd/parcel/scrape_verifier.rb +122 -0
  54. data/lib/opencdd/parcel/selector.rb +115 -0
  55. data/lib/opencdd/parcel/sharded_dir_reader.rb +151 -0
  56. data/lib/opencdd/parcel/sheet.rb +287 -0
  57. data/lib/opencdd/parcel/sheet_emitter.rb +171 -0
  58. data/lib/opencdd/parcel/sheet_schema.rb +253 -0
  59. data/lib/opencdd/parcel/workbook.rb +172 -0
  60. data/lib/opencdd/parcel/workbook_reader.rb +200 -0
  61. data/lib/opencdd/parcel/writer.rb +185 -0
  62. data/lib/opencdd/parcel.rb +175 -0
  63. data/lib/opencdd/parse_helpers.rb +73 -0
  64. data/lib/opencdd/property.rb +120 -0
  65. data/lib/opencdd/property_data_element_type.rb +44 -0
  66. data/lib/opencdd/property_ids.rb +202 -0
  67. data/lib/opencdd/reader.rb +103 -0
  68. data/lib/opencdd/relation.rb +88 -0
  69. data/lib/opencdd/relation_tree.rb +74 -0
  70. data/lib/opencdd/relation_type.rb +58 -0
  71. data/lib/opencdd/structured_values.rb +183 -0
  72. data/lib/opencdd/unit.rb +16 -0
  73. data/lib/opencdd/validator/class_reference_rule.rb +77 -0
  74. data/lib/opencdd/validator/condition_rule.rb +27 -0
  75. data/lib/opencdd/validator/data_type_rule.rb +26 -0
  76. data/lib/opencdd/validator/enum_rule.rb +27 -0
  77. data/lib/opencdd/validator/format_rule.rb +54 -0
  78. data/lib/opencdd/validator/hierarchy_rule.rb +65 -0
  79. data/lib/opencdd/validator/irdi_rule.rb +57 -0
  80. data/lib/opencdd/validator/mandatory_rule.rb +25 -0
  81. data/lib/opencdd/validator/pattern_rule.rb +26 -0
  82. data/lib/opencdd/validator/reference_rule.rb +36 -0
  83. data/lib/opencdd/validator/rule.rb +65 -0
  84. data/lib/opencdd/validator/runner.rb +101 -0
  85. data/lib/opencdd/validator/set_rule.rb +41 -0
  86. data/lib/opencdd/validator/synonym_rule.rb +30 -0
  87. data/lib/opencdd/validator/type_rule.rb +102 -0
  88. data/lib/opencdd/validator/uniqueness_rule.rb +32 -0
  89. data/lib/opencdd/validator.rb +68 -0
  90. data/lib/opencdd/value_format.rb +71 -0
  91. data/lib/opencdd/value_list.rb +42 -0
  92. data/lib/opencdd/value_term.rb +24 -0
  93. data/lib/opencdd/version.rb +5 -0
  94. data/lib/opencdd/view_control.rb +10 -0
  95. data/lib/opencdd/visitor.rb +93 -0
  96. data/lib/opencdd.rb +57 -0
  97. metadata +325 -0
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Opencdd
6
+ module Cddal
7
+ # Source location attached to every entity created from CDDAL
8
+ # for diagnostics (validator messages, error attribution,
9
+ # cross-file navigation in the editor).
10
+ SourceLocation = Struct.new(:file, :line, keyword_init: true) do
11
+ def to_s
12
+ "#{file}:#{line || '?'}"
13
+ end
14
+ end
15
+
16
+ # Builds a +Opencdd::Database+ from a parsed CDDAL AST. Owns the
17
+ # module/import pipeline: resolves specifiers via the configured
18
+ # +Resolver+, fetches sources via the +Fetcher+, recursively
19
+ # builds imported documents into the same database, tracks the
20
+ # dependency graph for cycle detection, and applies bare,
21
+ # qualified, and selective import scoping rules.
22
+ class Builder
23
+ attr_reader :database, :alias_table, :symbol_table, :meta_class_overrides,
24
+ :source_file, :loaded_modules
25
+
26
+ def initialize(database = nil, resolver: nil, source_file: nil,
27
+ loaded_modules: nil, loading_stack: nil)
28
+ @database = database || Opencdd::Database.new
29
+ @resolver = resolver || Opencdd::Cddal.default_resolver
30
+ @source_file = source_file
31
+ @loaded_modules = loaded_modules || {}
32
+ @loading_stack = loading_stack || []
33
+ @alias_table = Opencdd::AliasTable.new(defaults: true)
34
+ @symbol_table = {}
35
+ @qualified_table = {} # "qualifier.name" => entity (qualified imports)
36
+ @instance_decls = []
37
+ @meta_class_overrides = {}
38
+ end
39
+
40
+ def build(document_or_declarations)
41
+ document = wrap_document(document_or_declarations)
42
+ apply_alias_declarations(document)
43
+ apply_meta_class_declarations(document)
44
+ apply_import_declarations(document)
45
+ register_instance_symbols(document)
46
+ instantiate_entities(document)
47
+ resolve_property_references(document)
48
+ # Linking (parent/child, declared_property_irdis, value lists,
49
+ # symbol table) lives in Database.finalize! — single source
50
+ # of truth for entity-graph invariants. Builder used to
51
+ # duplicate link_class_hierarchy / link_property_classes;
52
+ # those were folded into Database#link_property_classes!
53
+ # (which now runs both relation-based and definition_class-
54
+ # based strategies).
55
+ @database.finalize!
56
+ @database
57
+ end
58
+
59
+ # Resolve +specifier+ and return its source text without
60
+ # building it into the database. Used by the validator and
61
+ # by diagnostics that want to peek at imports.
62
+ def peek_import(specifier)
63
+ @resolver.resolve(specifier, importing_file: @source_file)
64
+ end
65
+
66
+ private
67
+
68
+ def wrap_document(document_or_declarations)
69
+ return document_or_declarations if document_or_declarations.is_a?(AST::Document)
70
+ AST::Document.new(declarations: Array(document_or_declarations))
71
+ end
72
+
73
+ def apply_alias_declarations(document)
74
+ document.alias_declarations.each do |decl|
75
+ @alias_table.declare(decl.alias_name, decl.property_id)
76
+ end
77
+ end
78
+
79
+ def apply_meta_class_declarations(document)
80
+ document.meta_class_declarations.each do |decl|
81
+ meta = Opencdd::MetaClass::MetaClasses.for(decl.irdi)
82
+ extension = Opencdd::MetaClass.new(
83
+ irdi: decl.irdi,
84
+ name: meta&.name || decl.irdi,
85
+ entity_class: meta&.entity_class,
86
+ allowed_property_ids: decl.property_ids.map { |id| resolve_property_id(id) },
87
+ )
88
+ @meta_class_overrides[decl.irdi] = extension
89
+ Opencdd::MetaClass::MetaClasses.register(extension)
90
+ end
91
+ end
92
+
93
+ # ── Module/import pipeline ─────────────────────────────────
94
+ #
95
+ # Three import kinds share the same resolution + cycle-check
96
+ # + recursive-build core. They differ only in how the imported
97
+ # module's named declarations are surfaced in the parent
98
+ # document's symbol table:
99
+ #
100
+ # bare — every name from the target is in scope.
101
+ # qualified — name accessible as "<qualifier>.<name>".
102
+ # selective — only the listed names are in scope, each
103
+ # optionally renamed via "as".
104
+ #
105
+ # All entities from the imported module are added to the parent
106
+ # Database regardless of import kind — IRDI references remain
107
+ # resolvable. The import kind controls only which symbolic
108
+ # names are accessible without qualification.
109
+
110
+ def apply_import_declarations(document)
111
+ document.import_declarations.each { |decl| process_import(decl) }
112
+ end
113
+
114
+ def process_import(decl)
115
+ canonical, source = resolve_specifier(decl.specifier)
116
+ # Resolution may return [nil, nil] in non-strict mode when
117
+ # the specifier points at an unreachable URL or a missing
118
+ # file. Skip the import with a warning — preserves the
119
+ # graceful-degradation behavior CDDAL authors expect for
120
+ # cross-dictionary URLs.
121
+ return if canonical.nil?
122
+ return if @loaded_modules.key?(canonical)
123
+ check_cycle!(canonical, decl.specifier)
124
+
125
+ @loading_stack.push(canonical)
126
+ sub_doc = Opencdd::Cddal::Parser.parse(source)
127
+ Builder.new(@database, resolver: @resolver, source_file: canonical,
128
+ loaded_modules: @loaded_modules,
129
+ loading_stack: @loading_stack)
130
+ .build(sub_doc)
131
+ @loading_stack.pop
132
+ @loaded_modules[canonical] = true
133
+
134
+ # Symbol scoping is applied AFTER the sub-document is built,
135
+ # so the named entities exist in @database by the time we
136
+ # look them up.
137
+ apply_import_scope(decl, canonical)
138
+ end
139
+
140
+ def resolve_specifier(specifier)
141
+ @resolver.resolve(specifier, importing_file: @source_file)
142
+ end
143
+
144
+ def check_cycle!(canonical, specifier)
145
+ return unless @loading_stack.include?(canonical)
146
+ cycle = @loading_stack.dup
147
+ cycle << canonical
148
+ raise Opencdd::Cddal::ImportError,
149
+ "circular CDDAL import detected: #{cycle.join(' → ')} " \
150
+ "(originally imported as #{specifier.inspect})"
151
+ end
152
+
153
+ def apply_import_scope(decl, _canonical)
154
+ case decl.kind
155
+ when :bare
156
+ # Nothing to do — bare imports leave the sub-document's
157
+ # names in the shared @database symbol table.
158
+ when :qualified
159
+ register_qualified_symbols(decl.qualifier)
160
+ when :selective
161
+ register_selective_symbols(decl.imported_names)
162
+ end
163
+ end
164
+
165
+ def register_qualified_symbols(qualifier)
166
+ @database.entities.each do |entity|
167
+ name = entity_alias_name(entity)
168
+ next unless name
169
+ qualified = "#{qualifier}.#{name}"
170
+ @qualified_table[qualified] = entity
171
+ @database.register_symbol(qualified, entity)
172
+ end
173
+ end
174
+
175
+ def register_selective_symbols(imported_names)
176
+ imported_names.each do |imported|
177
+ entity = @database.resolve_reference(imported.name)
178
+ next unless entity
179
+ # Register in the parent Database's symbol table so the
180
+ # renamed name resolves during link phase.
181
+ @database.register_symbol(imported.local_name, entity)
182
+ end
183
+ end
184
+
185
+ def entity_alias_name(entity)
186
+ code = entity.code
187
+ return code if code && !code.empty?
188
+ entity.preferred_name.to_s
189
+ end
190
+
191
+ def register_instance_symbols(document)
192
+ document.instance_declarations.each do |decl|
193
+ @instance_decls << decl
194
+ register_symbol_name(decl.name, decl) if decl.name
195
+ end
196
+ end
197
+
198
+ def register_symbol_name(name, decl)
199
+ key = name.to_s
200
+ if @symbol_table.key?(key) && @symbol_table[key] != decl
201
+ warn "Symbol #{key.inspect} already declared; ignoring redefinition"
202
+ return
203
+ end
204
+ @symbol_table[key] = decl
205
+ end
206
+
207
+ def instantiate_entities(document)
208
+ @instance_decls.each do |decl|
209
+ entity = build_entity(decl)
210
+ next unless entity
211
+ attach_source_location(entity, decl)
212
+ @database.add_entity(entity)
213
+ @database.register_symbol(decl.name, entity) if decl.name
214
+ end
215
+ end
216
+
217
+ def attach_source_location(entity, decl)
218
+ loc = SourceLocation.new(file: @source_file, line: decl.line)
219
+ entity.attach_source_location(loc)
220
+ end
221
+
222
+ def build_entity(decl)
223
+ meta_class = resolve_meta_class(decl.meta_class_ref)
224
+ return nil unless meta_class && meta_class.entity_class
225
+
226
+ properties = build_properties(decl)
227
+ irdi = extract_irdi(properties)
228
+ meta_irdi = Opencdd::IRDI.parse(meta_class.irdi)
229
+
230
+ meta_class.entity_class.new(
231
+ irdi: irdi,
232
+ properties: properties,
233
+ schema: nil,
234
+ meta_class_irdi: meta_irdi,
235
+ )
236
+ end
237
+
238
+ def resolve_meta_class(ref)
239
+ return nil unless ref
240
+ return @meta_class_overrides[ref] if @meta_class_overrides.key?(ref)
241
+ return Opencdd::MetaClass::MetaClasses.for(ref) if ref.to_s.match?(/\AMDC_C\d+\z/) || ref.to_s.match?(/\AEXT_C\d+\z/)
242
+
243
+ canonical = resolve_property_id(ref)
244
+ Opencdd::MetaClass::MetaClasses.for(canonical) if canonical
245
+ end
246
+
247
+ def build_properties(decl)
248
+ # Resolve the meta-class once so per-assignment code-alias
249
+ # resolution can be context-aware. Without this, a CDDAL
250
+ # `code: AAAP001` on a Property stores the value under
251
+ # MDC_P001_5 (the Class code) — Parcel readers look for
252
+ # MDC_P001_6 (the Property code) and miss it, dropping the
253
+ # entity on round-trip.
254
+ meta_class_code = resolve_meta_class_code(decl.meta_class_ref)
255
+ canonical_code_pid = meta_class_code &&
256
+ Opencdd::MetaClasses.code_property_id_for(meta_class_code)
257
+
258
+ props = {}
259
+ decl.assignments.each do |assignment|
260
+ property_id = resolve_property_id(
261
+ assignment.identifier,
262
+ canonical_code_pid: canonical_code_pid,
263
+ )
264
+ key = compose_property_key(property_id, assignment.language_tag)
265
+ value = serialize_value(assignment.value, property_id)
266
+ props[key] = merge_property_value(props[key], value)
267
+ end
268
+ props
269
+ end
270
+
271
+ def compose_property_key(property_id, language_tag)
272
+ return property_id unless language_tag
273
+ "#{property_id}.#{language_tag}"
274
+ end
275
+
276
+ def merge_property_value(existing, new_value)
277
+ if existing.nil?
278
+ new_value
279
+ elsif existing.is_a?(Array) || new_value.is_a?(Array)
280
+ Array(existing) + Array(new_value)
281
+ else
282
+ new_value
283
+ end
284
+ end
285
+
286
+ def resolve_meta_class_code(ref)
287
+ return nil unless ref
288
+ meta_class = resolve_meta_class(ref)
289
+ meta_class&.irdi
290
+ end
291
+
292
+ # Resolve an alias or property name to its canonical property ID.
293
+ #
294
+ # When +canonical_code_pid:+ is supplied (the meta-class's
295
+ # canonical code property), and the resolved ID is one of the
296
+ # MDC_P001_X code variants for a *different* meta-class,
297
+ # we redirect to the canonical. This makes the CDDAL `code`
298
+ # alias context-aware: writing `code: AAA001` on a Property
299
+ # stores the value under MDC_P001_6, not MDC_P001_5.
300
+ def resolve_property_id(name, canonical_code_pid: nil)
301
+ s = name.to_s
302
+ return s if s.match?(/\AMDC_P\d+/) || s.match?(/\AEXT_P\d+/)
303
+ resolved = @alias_table.resolve(s) || s
304
+ return resolved unless canonical_code_pid
305
+ return resolved if resolved == canonical_code_pid
306
+ # Only redirect when the resolved ID is one of the per-meta-class
307
+ # code variants AND we have a different canonical in scope.
308
+ if CODE_VARIANT_IDS.include?(resolved)
309
+ canonical_code_pid
310
+ else
311
+ resolved
312
+ end
313
+ end
314
+
315
+ # All per-meta-class code property IDs. Used by
316
+ # +resolve_property_id+ to recognize code aliases that should
317
+ # be redirected to the meta-class's canonical code property.
318
+ CODE_VARIANT_IDS = %w[
319
+ MDC_P001 MDC_P001_1 MDC_P001_2 MDC_P001_5 MDC_P001_6 MDC_P001_7
320
+ MDC_P001_8 MDC_P001_10 MDC_P001_11 MDC_P001_12 MDC_P001_13
321
+ EXT_P001
322
+ ].freeze
323
+ private_constant :CODE_VARIANT_IDS
324
+
325
+ def serialize_value(value, property_id)
326
+ Opencdd::Cddal::ValueSerializer.serialize(value, property_id)
327
+ end
328
+
329
+ def extract_irdi(properties)
330
+ raw = properties[Opencdd::PropertyIds::MDC_P001_5] || properties[Opencdd::PropertyIds::MDC_P001_6] ||
331
+ properties[Opencdd::PropertyIds::MDC_P001_10] || properties[Opencdd::PropertyIds::MDC_P001_11] ||
332
+ properties[Opencdd::PropertyIds::MDC_P001_12] || properties[Opencdd::PropertyIds::MDC_P001_13]
333
+ return nil unless raw
334
+ Opencdd::IRDI.parse(raw.to_s)
335
+ end
336
+
337
+ def resolve_property_references(document)
338
+ reference_kinds = %i[identifier_ref set_of_refs class_ref].freeze
339
+ reference_property_ids = Set.new(
340
+ Opencdd::PropertyIds::REGISTRY.select { |_, e| reference_kinds.include?(e.value_kind) }.keys
341
+ )
342
+ @database.entities.each do |entity|
343
+ properties = entity.properties
344
+ properties.each do |key, value|
345
+ base = key.to_s.sub(/\.\w+\z/, "")
346
+ next unless reference_property_ids.include?(base)
347
+ next if value.nil? || value.to_s.strip.empty?
348
+ resolved = resolve_property_value(value)
349
+ properties[key] = resolved if resolved != value
350
+ end
351
+ end
352
+ end
353
+
354
+ def resolve_property_value(value)
355
+ s = value.to_s.strip
356
+ return resolve_single_reference(s) unless collection_form?(s)
357
+ # SSOT: top-level-aware split via StructuredValues.
358
+ elements = Opencdd::StructuredValues.unwrap_and_split(s)
359
+ resolved = elements.map { |e| resolve_single_reference(e) }.compact
360
+ Opencdd::StructuredValues.rejoin(resolved)
361
+ end
362
+
363
+ def collection_form?(s)
364
+ (s.start_with?("(") && s.end_with?(")")) ||
365
+ (s.start_with?("{") && s.end_with?("}"))
366
+ end
367
+
368
+ def resolve_single_reference(token)
369
+ target = @database.resolve_reference(token)
370
+ target&.irdi&.to_s || token
371
+ end
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Cddal
5
+ module Fetcher
6
+ # Test fetcher. Holds an in-memory URL → source map; specs
7
+ # construct one with the fixtures they want to serve. Avoids
8
+ # any network dependency in the test suite.
9
+ class InMemory
10
+ attr_reader :map
11
+
12
+ def initialize(map = {})
13
+ @map = {}
14
+ map.each { |k, v| @map[k.to_s] = v.to_s }
15
+ end
16
+
17
+ def fetch(url)
18
+ key = url.to_s
19
+ @map.fetch(key) do
20
+ raise Opencdd::Cddal::ImportError,
21
+ "no in-memory fixture registered for #{key}"
22
+ end
23
+ end
24
+
25
+ def register(url, source)
26
+ @map[url.to_s] = source.to_s
27
+ self
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "digest"
5
+ require "tmpdir"
6
+
7
+ module Opencdd
8
+ module Cddal
9
+ module Fetcher
10
+ # Default URL fetcher. Fetches via +Net::HTTP+, caches the
11
+ # response body to +cache_dir+ keyed by URL hash, and supports
12
+ # an +offline:+ mode that serves only cached content (used in
13
+ # CI to keep test runs hermetic).
14
+ class NetHttp
15
+ attr_reader :cache_dir, :offline
16
+
17
+ def initialize(cache_dir: default_cache_dir, offline: false)
18
+ @cache_dir = cache_dir ? Pathname.new(cache_dir.to_s) : nil
19
+ @offline = offline
20
+ @memory_cache = {}
21
+ end
22
+
23
+ def fetch(url)
24
+ key = url.to_s
25
+ return @memory_cache[key] if @memory_cache.key?(key)
26
+
27
+ text = if @offline
28
+ read_from_disk_cache(key) || raise_import_error(key)
29
+ else
30
+ fetch_http(key)
31
+ end
32
+
33
+ @memory_cache[key] = text
34
+ write_to_disk_cache(key, text) if @cache_dir && !@offline
35
+ text
36
+ end
37
+
38
+ private
39
+
40
+ def default_cache_dir
41
+ ENV["CDDAL_CACHE"] || File.join(Dir.tmpdir, "cddal-cache")
42
+ end
43
+
44
+ def fetch_http(url)
45
+ require "net/http"
46
+ require "uri"
47
+ uri = URI.parse(url)
48
+ response = Net::HTTP.get_response(uri)
49
+ unless response.is_a?(Net::HTTPSuccess)
50
+ raise Opencdd::Cddal::ImportError,
51
+ "HTTP #{response.code} #{response.message} for #{url}"
52
+ end
53
+ response.body
54
+ rescue Opencdd::Cddal::ImportError
55
+ raise
56
+ rescue StandardError => e
57
+ raise Opencdd::Cddal::ImportError, "fetch failed for #{url}: #{e.message}"
58
+ end
59
+
60
+ def read_from_disk_cache(url)
61
+ return nil unless @cache_dir
62
+ path = cache_path_for(url)
63
+ path.exist? ? path.read : nil
64
+ end
65
+
66
+ def write_to_disk_cache(url, text)
67
+ path = cache_path_for(url)
68
+ path.dirname.mkpath
69
+ path.write(text) unless path.exist?
70
+ end
71
+
72
+ def cache_path_for(url)
73
+ hash = Digest::SHA256.hexdigest(url)
74
+ @cache_dir.join("#{hash}.cddal")
75
+ end
76
+
77
+ def raise_import_error(url)
78
+ raise Opencdd::Cddal::ImportError,
79
+ "offline mode and no cached copy for #{url}"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "digest"
5
+
6
+ module Opencdd
7
+ module Cddal
8
+ # Pluggable fetcher for URL-based CDDAL imports. The default
9
+ # +NetHttp+ implementation honors an on-disk cache and an
10
+ # +offline:+ flag (used in CI to avoid network dependence).
11
+ # Tests use +InMemory+ to stub URL fetching without touching
12
+ # the network.
13
+ module Fetcher
14
+ autoload :NetHttp, "opencdd/cddal/fetcher/net_http"
15
+ autoload :InMemory, "opencdd/cddal/fetcher/in_memory"
16
+ end
17
+ end
18
+ end