mendix-ruby-bridge 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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/.mxcli-version +2 -0
  3. data/LICENSE +21 -0
  4. data/README.md +77 -0
  5. data/bin/git-mendix +4 -0
  6. data/bin/mendix-apply +145 -0
  7. data/bin/mendix-desktop +26 -0
  8. data/bin/mendix-git +121 -0
  9. data/bin/mendix-ruby +580 -0
  10. data/bin/mxcli +20 -0
  11. data/bin/setup-tools +25 -0
  12. data/lib/mendix_bridge/backend_server.rb +1404 -0
  13. data/lib/mendix_bridge/change_planner.rb +594 -0
  14. data/lib/mendix_bridge/config.rb +89 -0
  15. data/lib/mendix_bridge/dependency_index.rb +188 -0
  16. data/lib/mendix_bridge/desktop_app.rb +121 -0
  17. data/lib/mendix_bridge/document_parser.rb +120 -0
  18. data/lib/mendix_bridge/domain_parser.rb +103 -0
  19. data/lib/mendix_bridge/dsl.rb +540 -0
  20. data/lib/mendix_bridge/enumeration_parser.rb +32 -0
  21. data/lib/mendix_bridge/git_workflow.rb +321 -0
  22. data/lib/mendix_bridge/html_viewer.rb +672 -0
  23. data/lib/mendix_bridge/importer.rb +415 -0
  24. data/lib/mendix_bridge/inventory.rb +93 -0
  25. data/lib/mendix_bridge/mdl_generator.rb +343 -0
  26. data/lib/mendix_bridge/microflow_parser.rb +131 -0
  27. data/lib/mendix_bridge/migration.rb +290 -0
  28. data/lib/mendix_bridge/migration_executor.rb +234 -0
  29. data/lib/mendix_bridge/model.rb +204 -0
  30. data/lib/mendix_bridge/page_parser.rb +95 -0
  31. data/lib/mendix_bridge/presenter.rb +35 -0
  32. data/lib/mendix_bridge/project_creator.rb +181 -0
  33. data/lib/mendix_bridge/ruby_inventory_generator.rb +63 -0
  34. data/lib/mendix_bridge/security_parser.rb +72 -0
  35. data/lib/mendix_bridge/snapshot_diff.rb +58 -0
  36. data/lib/mendix_bridge/validator.rb +46 -0
  37. data/lib/mendix_bridge/version.rb +5 -0
  38. data/lib/mendix_bridge/visual_entity_plan.rb +176 -0
  39. data/lib/mendix_bridge.rb +127 -0
  40. data/share/applications/mendix-ruby-bridge.desktop +12 -0
  41. data/share/icons/hicolor/128x128/apps/mendix-ruby-bridge.png +0 -0
  42. data/share/icons/hicolor/256x256/apps/mendix-ruby-bridge.png +0 -0
  43. data/share/icons/hicolor/32x32/apps/mendix-ruby-bridge.png +0 -0
  44. data/share/icons/hicolor/48x48/apps/mendix-ruby-bridge.png +0 -0
  45. data/share/icons/hicolor/512x512/apps/mendix-ruby-bridge.png +0 -0
  46. data/share/icons/hicolor/64x64/apps/mendix-ruby-bridge.png +0 -0
  47. data/web/dist/apple-touch-icon.png +0 -0
  48. data/web/dist/assets/index-BO6iPT1P.css +1 -0
  49. data/web/dist/assets/index-JGODw81W.js +27 -0
  50. data/web/dist/brand/mendix-ruby-bridge.png +0 -0
  51. data/web/dist/favicon.png +0 -0
  52. data/web/dist/icons.svg +24 -0
  53. data/web/dist/index.html +17 -0
  54. metadata +179 -0
@@ -0,0 +1,594 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ class ChangePlanner
5
+ Operation = Data.define(:action, :type, :name, :changes, :reason) do
6
+ def to_h
7
+ { action:, type:, name:, changes:, reason: }.compact
8
+ end
9
+ end
10
+
11
+ Result = Data.define(:operations, :preserved_undeclared) do
12
+ def counts
13
+ operations.each_with_object(Hash.new(0)) do |operation, result|
14
+ result[operation.action] += 1
15
+ end.sort.to_h
16
+ end
17
+
18
+ def blocked?
19
+ operations.any? { |operation| operation.action == "blocked" }
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ operations: operations.map(&:to_h),
25
+ counts:,
26
+ preserved_undeclared:
27
+ }
28
+ end
29
+ end
30
+
31
+ TYPE_NAMES = MDLGenerator::TYPE_NAMES
32
+
33
+ def self.plan(model, inventory)
34
+ new(model, inventory).plan
35
+ end
36
+
37
+ def initialize(model, inventory)
38
+ @model = model
39
+ @inventory = inventory
40
+ end
41
+
42
+ def plan
43
+ operations = @model.modules.flat_map { |app_module| plan_module(app_module) }
44
+ operations.concat(@model.user_roles.map { |role| plan_user_role(role) })
45
+ operations << plan_project_security(@model.project_security) if @model.project_security
46
+ operations.concat(
47
+ @model.navigation_profiles.map { |profile| plan_navigation(profile) }
48
+ )
49
+ declared = operations.map { |operation| "#{operation.type}:#{operation.name}" }.to_set
50
+ managed_types = %w[
51
+ entity association enumeration microflow page
52
+ modulerole userrole projectsecurity
53
+ navprofile
54
+ ]
55
+ preserved = @inventory.elements.count do |element|
56
+ managed_types.include?(element.type) &&
57
+ !declared.include?("#{element.type}:#{element.qualified_name}")
58
+ end
59
+
60
+ Result.new(operations:, preserved_undeclared: preserved)
61
+ end
62
+
63
+ private
64
+
65
+ def plan_module(app_module)
66
+ unless @inventory.modules.any? { |existing| existing.qualified_name == app_module.name }
67
+ return [
68
+ Operation.new(
69
+ action: "blocked",
70
+ type: "module",
71
+ name: app_module.name,
72
+ changes: nil,
73
+ reason: "target module does not exist; module creation is not supported"
74
+ )
75
+ ]
76
+ end
77
+
78
+ enumeration_operations = app_module.enumerations.map do |enumeration|
79
+ plan_enumeration(app_module, enumeration)
80
+ end
81
+ microflow_operations = app_module.microflows.map do |microflow|
82
+ plan_microflow(app_module, microflow)
83
+ end
84
+ page_operations = app_module.pages.map do |page|
85
+ plan_page(app_module, page)
86
+ end
87
+ module_role_operations = app_module.module_roles.map do |role|
88
+ plan_module_role(app_module, role)
89
+ end
90
+ entity_operations = app_module.entities.flat_map do |entity|
91
+ [plan_entity(app_module, entity), *entity.associations.map do |association|
92
+ plan_association(app_module, entity, association)
93
+ end]
94
+ end
95
+ module_role_operations + enumeration_operations + entity_operations +
96
+ microflow_operations + page_operations
97
+ end
98
+
99
+ def plan_module_role(app_module, role)
100
+ name = "#{app_module.name}.#{role.name}"
101
+ existing = @inventory.find(name)
102
+ desired = { "description" => role.description }.compact
103
+ return Operation.new(
104
+ action: "create",
105
+ type: "modulerole",
106
+ name:,
107
+ changes: desired,
108
+ reason: nil
109
+ ) unless existing
110
+
111
+ details = existing.details
112
+ unless existing.type == "modulerole" && details&.fetch("parse_status", nil) == "parsed"
113
+ return Operation.new(
114
+ action: "blocked",
115
+ type: "modulerole",
116
+ name:,
117
+ changes: nil,
118
+ reason: "existing module role does not have parsed semantic details"
119
+ )
120
+ end
121
+
122
+ current = { "description" => details["description"] }.compact
123
+ return Operation.new(
124
+ action: "keep",
125
+ type: "modulerole",
126
+ name:,
127
+ changes: nil,
128
+ reason: nil
129
+ ) if current == desired
130
+
131
+ Operation.new(
132
+ action: "blocked",
133
+ type: "modulerole",
134
+ name:,
135
+ changes: { "from" => current, "to" => desired },
136
+ reason: "modifying an existing module role is not supported safely"
137
+ )
138
+ end
139
+
140
+ def plan_user_role(role)
141
+ existing = @inventory.find(role.name)
142
+ desired = {
143
+ "module_roles" => role.module_roles,
144
+ "manage_all_roles" => role.manage_all_roles
145
+ }
146
+ return Operation.new(
147
+ action: "create",
148
+ type: "userrole",
149
+ name: role.name,
150
+ changes: desired,
151
+ reason: nil
152
+ ) unless existing
153
+
154
+ details = existing.details
155
+ unless existing.type == "userrole" && details&.fetch("parse_status", nil) == "parsed"
156
+ return Operation.new(
157
+ action: "blocked",
158
+ type: "userrole",
159
+ name: role.name,
160
+ changes: nil,
161
+ reason: "existing user role does not have parsed semantic details"
162
+ )
163
+ end
164
+
165
+ current = details.slice("module_roles", "manage_all_roles")
166
+ return Operation.new(
167
+ action: "keep",
168
+ type: "userrole",
169
+ name: role.name,
170
+ changes: nil,
171
+ reason: nil
172
+ ) if current == desired
173
+
174
+ Operation.new(
175
+ action: "blocked",
176
+ type: "userrole",
177
+ name: role.name,
178
+ changes: { "from" => current, "to" => desired },
179
+ reason: "modifying an existing user role requires explicit ALTER support"
180
+ )
181
+ end
182
+
183
+ def plan_project_security(security)
184
+ existing = @inventory.find("ProjectSecurity")
185
+ desired = {
186
+ "SecurityLevel" => security.level.capitalize,
187
+ "DemoUsersEnabled" => security.demo_users
188
+ }
189
+ unless existing&.details&.fetch("parse_status", nil) == "parsed"
190
+ return Operation.new(
191
+ action: "blocked",
192
+ type: "projectsecurity",
193
+ name: "ProjectSecurity",
194
+ changes: nil,
195
+ reason: "project security does not have parsed semantic details"
196
+ )
197
+ end
198
+
199
+ current = existing.details.fetch("settings").slice(*desired.keys)
200
+ if current == desired
201
+ Operation.new(
202
+ action: "keep",
203
+ type: "projectsecurity",
204
+ name: "ProjectSecurity",
205
+ changes: nil,
206
+ reason: nil
207
+ )
208
+ else
209
+ Operation.new(
210
+ action: "modify",
211
+ type: "projectsecurity",
212
+ name: "ProjectSecurity",
213
+ changes: { "from" => current, "to" => desired },
214
+ reason: nil
215
+ )
216
+ end
217
+ end
218
+
219
+ def plan_navigation(profile)
220
+ existing = @inventory.find(profile.name)
221
+ mdl = MDLGenerator.new(
222
+ Model::App.new(
223
+ name: "NavigationPlan",
224
+ version: nil,
225
+ modules: [],
226
+ user_roles: [],
227
+ project_security: nil,
228
+ navigation_profiles: [profile]
229
+ )
230
+ ).generate
231
+ desired = DocumentParser.parse("navprofile", "mdl" => mdl)
232
+ comparable_keys = %w[
233
+ home_page role_home_pages login_page not_found_page menu_groups menu_items
234
+ ]
235
+ desired = desired.slice(*comparable_keys)
236
+
237
+ return Operation.new(
238
+ action: "create",
239
+ type: "navprofile",
240
+ name: profile.name,
241
+ changes: desired,
242
+ reason: nil
243
+ ) unless existing
244
+
245
+ details = existing.details
246
+ unless existing.type == "navprofile" && details&.fetch("parse_status", nil) == "parsed"
247
+ return Operation.new(
248
+ action: "blocked",
249
+ type: "navprofile",
250
+ name: profile.name,
251
+ changes: nil,
252
+ reason: "existing navigation profile does not have parsed semantic details"
253
+ )
254
+ end
255
+
256
+ current = details.slice(*comparable_keys)
257
+ if current == desired
258
+ Operation.new(
259
+ action: "keep",
260
+ type: "navprofile",
261
+ name: profile.name,
262
+ changes: nil,
263
+ reason: nil
264
+ )
265
+ else
266
+ Operation.new(
267
+ action: "modify",
268
+ type: "navprofile",
269
+ name: profile.name,
270
+ changes: { "from" => current, "to" => desired },
271
+ reason: nil
272
+ )
273
+ end
274
+ end
275
+
276
+ def plan_page(app_module, page)
277
+ name = "#{app_module.name}.#{page.name}"
278
+ existing = @inventory.find(name)
279
+ desired = PageParser.parse(
280
+ "mdl" => MDLGenerator.page_statement(app_module, page)
281
+ )
282
+ comparable_keys = %w[
283
+ title layout folder parameters widgets data_sources actions
284
+ microflow_calls nanoflow_calls page_links attributes view_roles
285
+ ]
286
+ desired = desired.slice(*comparable_keys)
287
+
288
+ return Operation.new(
289
+ action: "create",
290
+ type: "page",
291
+ name:,
292
+ changes: desired,
293
+ reason: nil
294
+ ) unless existing
295
+
296
+ details = existing.details
297
+ unless existing.type == "page" && details&.fetch("parse_status", nil) == "parsed"
298
+ return Operation.new(
299
+ action: "blocked",
300
+ type: "page",
301
+ name:,
302
+ changes: nil,
303
+ reason: "existing page does not have parsed semantic details"
304
+ )
305
+ end
306
+
307
+ current = details.slice(*comparable_keys)
308
+ if current == desired
309
+ Operation.new(action: "keep", type: "page", name:, changes: nil, reason: nil)
310
+ else
311
+ Operation.new(
312
+ action: "modify",
313
+ type: "page",
314
+ name:,
315
+ changes: { "from" => current, "to" => desired },
316
+ reason: nil
317
+ )
318
+ end
319
+ end
320
+
321
+ def plan_microflow(app_module, microflow)
322
+ name = "#{app_module.name}.#{microflow.name}"
323
+ existing = @inventory.find(name)
324
+ desired = MicroflowParser.parse(
325
+ "mdl" => MDLGenerator.microflow_statement(app_module, microflow)
326
+ )
327
+ comparable_keys = %w[
328
+ parameters return_type folder activities calls execute_roles
329
+ ]
330
+ desired = desired.slice(*comparable_keys)
331
+
332
+ return Operation.new(
333
+ action: "create",
334
+ type: "microflow",
335
+ name:,
336
+ changes: desired,
337
+ reason: nil
338
+ ) unless existing
339
+
340
+ details = existing.details
341
+ unless existing.type == "microflow" && details&.fetch("parse_status", nil) == "parsed"
342
+ return Operation.new(
343
+ action: "blocked",
344
+ type: "microflow",
345
+ name:,
346
+ changes: nil,
347
+ reason: "existing microflow does not have parsed semantic details"
348
+ )
349
+ end
350
+
351
+ current = details.slice(*comparable_keys)
352
+ if current == desired
353
+ Operation.new(action: "keep", type: "microflow", name:, changes: nil, reason: nil)
354
+ else
355
+ Operation.new(
356
+ action: "modify",
357
+ type: "microflow",
358
+ name:,
359
+ changes: { "from" => current, "to" => desired },
360
+ reason: nil
361
+ )
362
+ end
363
+ end
364
+
365
+ def plan_enumeration(app_module, enumeration)
366
+ name = "#{app_module.name}.#{enumeration.name}"
367
+ existing = @inventory.find(name)
368
+ desired = {
369
+ "folder" => enumeration.folder,
370
+ "values" => enumeration.values.map do |value|
371
+ { "name" => value.name, "caption" => value.caption }
372
+ end
373
+ }.compact
374
+ return Operation.new(
375
+ action: "create",
376
+ type: "enumeration",
377
+ name:,
378
+ changes: desired,
379
+ reason: nil
380
+ ) unless existing
381
+
382
+ details = existing.details
383
+ unless existing.type == "enumeration" && details&.fetch("parse_status", nil) == "parsed"
384
+ return Operation.new(
385
+ action: "blocked",
386
+ type: "enumeration",
387
+ name:,
388
+ changes: nil,
389
+ reason: "existing enumeration does not have parsed semantic details"
390
+ )
391
+ end
392
+
393
+ current = {
394
+ "folder" => details["folder"],
395
+ "values" => details.fetch("values", [])
396
+ }.compact
397
+ return Operation.new(
398
+ action: "keep",
399
+ type: "enumeration",
400
+ name:,
401
+ changes: nil,
402
+ reason: nil
403
+ ) if current == desired
404
+
405
+ current_names = current.fetch("values").map { |value| value.fetch("name") }
406
+ desired_names = desired.fetch("values").map { |value| value.fetch("name") }
407
+ removed = current_names - desired_names
408
+ if removed.any?
409
+ return Operation.new(
410
+ action: "blocked",
411
+ type: "enumeration",
412
+ name:,
413
+ changes: { "from" => current, "to" => desired, "removed_values" => removed },
414
+ reason: "enumeration value removal requires explicit migration support"
415
+ )
416
+ end
417
+
418
+ Operation.new(
419
+ action: "modify",
420
+ type: "enumeration",
421
+ name:,
422
+ changes: { "from" => current, "to" => desired },
423
+ reason: nil
424
+ )
425
+ end
426
+
427
+ def plan_entity(app_module, entity)
428
+ name = "#{app_module.name}.#{entity.name}"
429
+ existing = @inventory.find(name)
430
+ return Operation.new(action: "create", type: "entity", name:, changes: nil, reason: nil) unless existing
431
+
432
+ details = existing.details
433
+ unless existing.type == "entity" && details&.fetch("parse_status", nil) == "parsed"
434
+ return Operation.new(
435
+ action: "blocked",
436
+ type: "entity",
437
+ name:,
438
+ changes: nil,
439
+ reason: "existing entity does not have parsed semantic details"
440
+ )
441
+ end
442
+
443
+ changes = entity_changes(entity, details)
444
+ unsafe_reasons = unsafe_entity_reasons(changes)
445
+ if unsafe_reasons.any?
446
+ Operation.new(
447
+ action: "blocked",
448
+ type: "entity",
449
+ name:,
450
+ changes:,
451
+ reason: unsafe_reasons.join("; ")
452
+ )
453
+ elsif changes.empty?
454
+ Operation.new(action: "keep", type: "entity", name:, changes: nil, reason: nil)
455
+ else
456
+ Operation.new(action: "modify", type: "entity", name:, changes:, reason: nil)
457
+ end
458
+ end
459
+
460
+ def entity_changes(entity, details)
461
+ changes = {}
462
+ changes["persistable"] = {
463
+ "from" => details["persistable"],
464
+ "to" => entity.persistable
465
+ } if details["persistable"] != entity.persistable
466
+
467
+ current = details.fetch("attributes", []).to_h { |attribute| [attribute.fetch("name"), attribute] }
468
+ desired = entity.attributes.to_h { |attribute| [attribute.name, desired_attribute(attribute)] }
469
+
470
+ added = desired.keys - current.keys
471
+ removed = current.keys - desired.keys
472
+ modified = (current.keys & desired.keys).filter_map do |name|
473
+ before = comparable_attribute(current.fetch(name))
474
+ after = comparable_attribute(desired.fetch(name))
475
+ { "name" => name, "from" => before, "to" => after } unless before == after
476
+ end
477
+
478
+ changes["added_attributes"] = added.map { |name| desired.fetch(name) } unless added.empty?
479
+ changes["modified_attributes"] = modified unless modified.empty?
480
+ changes["removed_attributes"] = removed unless removed.empty?
481
+
482
+ current_access = details.fetch("access_rules", []).map do |rule|
483
+ comparable_access_rule(rule)
484
+ end
485
+ desired_access = entity.access_rules.map do |rule|
486
+ comparable_access_rule(rule.to_h.transform_keys(&:to_s))
487
+ end
488
+ current_by_role = current_access.to_h { |rule| [rule.fetch("role"), rule] }
489
+ desired_by_role = desired_access.to_h { |rule| [rule.fetch("role"), rule] }
490
+ added_roles = desired_by_role.keys - current_by_role.keys
491
+ removed_roles = current_by_role.keys - desired_by_role.keys
492
+ modified_roles = (current_by_role.keys & desired_by_role.keys).filter_map do |role|
493
+ before = current_by_role.fetch(role)
494
+ after = desired_by_role.fetch(role)
495
+ { "role" => role, "from" => before, "to" => after } unless before == after
496
+ end
497
+ changes["added_access_rules"] =
498
+ added_roles.map { |role| desired_by_role.fetch(role) } unless added_roles.empty?
499
+ changes["modified_access_rules"] = modified_roles unless modified_roles.empty?
500
+ changes["removed_access_rules"] = removed_roles unless removed_roles.empty?
501
+ changes
502
+ end
503
+
504
+ def unsafe_entity_reasons(changes)
505
+ reasons = []
506
+ if changes["removed_attributes"]&.any?
507
+ reasons << "attribute removal requires explicit destructive-change support"
508
+ end
509
+ if changes["removed_access_rules"]&.any?
510
+ reasons << "entity access removal requires explicit REVOKE support"
511
+ end
512
+ if changes.key?("persistable")
513
+ reasons << "changing entity persistence requires explicit migration support"
514
+ end
515
+ if changes.fetch("modified_attributes", []).any? do |change|
516
+ change.dig("from", "type") != change.dig("to", "type")
517
+ end
518
+ reasons << "changing an attribute type requires explicit migration support"
519
+ end
520
+ if changes.fetch("added_attributes", []).any? do |attribute|
521
+ attribute["required"] && !attribute.key?("default")
522
+ end
523
+ reasons << "adding a required attribute without a default may invalidate existing data"
524
+ end
525
+ reasons
526
+ end
527
+
528
+ def plan_association(app_module, entity, association)
529
+ name = "#{app_module.name}.#{association.name}"
530
+ existing = @inventory.find(name)
531
+ desired = {
532
+ "from" => "#{app_module.name}.#{entity.name}",
533
+ "to" => association.target,
534
+ "association_type" => association.cardinality == "many" ? "ReferenceSet" : "Reference",
535
+ "owner" => association.owner == "both" ? "Both" : "Default"
536
+ }
537
+ return Operation.new(action: "create", type: "association", name:, changes: desired, reason: nil) unless existing
538
+
539
+ current = existing.details&.slice(*desired.keys)
540
+ unless existing.type == "association" && current
541
+ return Operation.new(
542
+ action: "blocked",
543
+ type: "association",
544
+ name:,
545
+ changes: nil,
546
+ reason: "existing association does not have parsed semantic details"
547
+ )
548
+ end
549
+ return Operation.new(action: "keep", type: "association", name:, changes: nil, reason: nil) if current == desired
550
+
551
+ Operation.new(
552
+ action: "blocked",
553
+ type: "association",
554
+ name:,
555
+ changes: { "from" => current, "to" => desired },
556
+ reason: "modifying an existing association is not supported safely"
557
+ )
558
+ end
559
+
560
+ def desired_attribute(attribute)
561
+ {
562
+ "name" => attribute.name,
563
+ "type" => attribute_type(attribute),
564
+ "required" => attribute.required,
565
+ "default" => default_value(attribute.default)
566
+ }.compact
567
+ end
568
+
569
+ def comparable_access_rule(rule)
570
+ rule.slice("role", "create", "delete", "read", "write", "xpath")
571
+ end
572
+
573
+ def comparable_attribute(attribute)
574
+ attribute.slice("name", "type", "required", "default")
575
+ end
576
+
577
+ def attribute_type(attribute)
578
+ return "Enumeration(#{attribute.options.fetch(:enumeration)})" if attribute.type == "enumeration"
579
+ return "String(#{attribute.options[:length] || 'unlimited'})" if attribute.type == "string"
580
+
581
+ TYPE_NAMES.fetch(attribute.type)
582
+ end
583
+
584
+ def default_value(value)
585
+ case value
586
+ when nil then nil
587
+ when true then "true"
588
+ when false then "false"
589
+ when Numeric then value.to_s
590
+ when String then "'#{value.gsub("'", "''")}'"
591
+ end
592
+ end
593
+ end
594
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+
6
+ module MendixBridge
7
+ class ConfigError < StandardError; end
8
+
9
+ class Config
10
+ FILE_NAME = ".mendix-ruby.yml"
11
+ KEYS = %w[project inventory model].freeze
12
+
13
+ attr_reader :path
14
+
15
+ def self.find(start_dir = Dir.pwd)
16
+ directory = Pathname.new(File.expand_path(start_dir))
17
+ loop do
18
+ candidate = directory.join(FILE_NAME)
19
+ return load(candidate.to_s) if candidate.file?
20
+ break if directory.root?
21
+
22
+ directory = directory.parent
23
+ end
24
+ nil
25
+ end
26
+
27
+ def self.load(path)
28
+ path = File.expand_path(path)
29
+ data = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
30
+ raise ConfigError, "configuration root must be a mapping: #{path}" unless
31
+ data.is_a?(Hash)
32
+ raise ConfigError, "unsupported configuration version" unless data["version"] == 1
33
+
34
+ unknown = data.keys - ["version", *KEYS]
35
+ raise ConfigError, "unknown configuration keys: #{unknown.join(', ')}" unless
36
+ unknown.empty?
37
+
38
+ new(path, data)
39
+ rescue Psych::Exception => error
40
+ raise ConfigError, "invalid configuration #{path}: #{error.message}"
41
+ end
42
+
43
+ def self.write(path, project:, inventory:, model:, force: false)
44
+ path = File.expand_path(path)
45
+ raise ConfigError, "configuration already exists: #{path}" if
46
+ File.exist?(path) && !force
47
+
48
+ base = File.dirname(path)
49
+ data = {
50
+ "version" => 1,
51
+ "project" => relative_path(project, base),
52
+ "inventory" => relative_path(inventory, base),
53
+ "model" => relative_path(model, base)
54
+ }
55
+ File.write(path, YAML.dump(data))
56
+ load(path)
57
+ end
58
+
59
+ def initialize(path, data)
60
+ @path = path
61
+ @data = data
62
+ end
63
+
64
+ KEYS.each do |key|
65
+ define_method(key) do
66
+ value = @data[key]
67
+ value && File.expand_path(value, File.dirname(path))
68
+ end
69
+ end
70
+
71
+ def to_h
72
+ {
73
+ "path" => path,
74
+ "project" => project,
75
+ "inventory" => inventory,
76
+ "model" => model
77
+ }
78
+ end
79
+
80
+ def self.relative_path(value, base)
81
+ Pathname.new(File.expand_path(value)).relative_path_from(
82
+ Pathname.new(File.expand_path(base))
83
+ ).to_s
84
+ rescue ArgumentError
85
+ File.expand_path(value)
86
+ end
87
+ private_class_method :relative_path
88
+ end
89
+ end