dotenv-merge 1.0.3 → 7.1.3

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.
@@ -0,0 +1,690 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Dotenv
6
+ module Merge
7
+ # Source-preserving, base-aware provider for dotenv assignment documents.
8
+ # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- provider planning, rendering, and reporting form one boundary
9
+ class Provider
10
+ DEFAULT_PROFILE = :source_preserving
11
+ DIALECTS = %i[dotenv].freeze
12
+
13
+ Analysis = Data.define(:role, :source, :file, :lines, :assignments, :by_key, :unowned, :issues)
14
+ Decision = Data.define(:key, :base, :ours, :theirs, :selected_role, :classification, :conflict)
15
+
16
+ def provider_id = 'ruby.dotenv'
17
+ def family = 'dotenv'
18
+
19
+ def capabilities
20
+ {
21
+ operations: Ast::Merge::ProviderContract::OPERATIONS,
22
+ dialects: DIALECTS,
23
+ backends: [Dotenv::Merge::BACKEND_REFERENCE.id.to_sym],
24
+ profiles: [DEFAULT_PROFILE],
25
+ role: :workflow,
26
+ source_preservation: %i[exact_source assignment_fragments line_provenance reparse]
27
+ }.freeze
28
+ end
29
+
30
+ def analyze(request)
31
+ analysis = analyze_role(:source, request.fetch(:source))
32
+ result(
33
+ :analyze,
34
+ request,
35
+ analysis: analysis_payload(analysis),
36
+ diagnostics: diagnostics_for(analysis),
37
+ verification: { source_parsed: true, structurally_unambiguous: analysis.issues.empty? }
38
+ )
39
+ end
40
+
41
+ def diff2(request)
42
+ before = analyze_role(:before, request.fetch(:before_source))
43
+ after = analyze_role(:after, request.fetch(:after_source))
44
+ changes = assignment_changes(before, after)
45
+ result(
46
+ :diff2,
47
+ request,
48
+ diff: { before: analysis_payload(before), after: analysis_payload(after), changes: changes },
49
+ changes: changes,
50
+ diagnostics: diagnostics_for(before, after),
51
+ verification: { before_parsed: true, after_parsed: true }
52
+ )
53
+ end
54
+
55
+ def merge2(request)
56
+ incoming = request.fetch(:incoming_source)
57
+ current = request.fetch(:current_source)
58
+ inputs = [
59
+ analyze_role(:incoming, incoming),
60
+ analyze_role(:current, current)
61
+ ]
62
+ unsafe = inputs.reject { |analysis| analysis.issues.empty? }
63
+ unless unsafe.empty?
64
+ return unsafe_failure(
65
+ :merge2,
66
+ request,
67
+ unsafe,
68
+ message: 'dotenv merge2 input is structurally ambiguous'
69
+ )
70
+ end
71
+
72
+ merged = SmartMerger.new(incoming, current, add_template_only_nodes: true).merge.to_s
73
+ output = analyze_role(:output, merged)
74
+ unless output.issues.empty?
75
+ return unsafe_failure(
76
+ :merge2,
77
+ request,
78
+ [output],
79
+ message: 'dotenv merge2 output is structurally ambiguous'
80
+ )
81
+ end
82
+
83
+ result(
84
+ :merge2,
85
+ request,
86
+ output: merged,
87
+ render_report: { strategy: :dotenv_smart_merger, synthesis: :family_emission },
88
+ verification: { output_reparsed: true, ordered_assignments: semantic_assignments(output) }
89
+ )
90
+ rescue Dotenv::Merge::ParseError => e
91
+ failure(:merge2, request, category: :parse_error, message: e.message)
92
+ end
93
+
94
+ def merge3(request)
95
+ analyses = %i[base ours theirs].to_h do |role|
96
+ [role, analyze_role(role, request.fetch(:"#{role}_source"))]
97
+ end
98
+ exact_role = exact_revision_role(analyses)
99
+ if exact_role
100
+ winner = analyses.fetch(exact_role)
101
+ return unsafe_conflict(request, analyses, [winner]) unless winner.issues.empty?
102
+
103
+ return render_exact(request, analyses, exact_role)
104
+ end
105
+
106
+ unsafe = analyses.values.reject { |analysis| analysis.issues.empty? }
107
+ return unsafe_conflict(request, analyses, unsafe) unless unsafe.empty?
108
+ return unowned_conflict(request, analyses) unless unowned_unchanged?(analyses)
109
+
110
+ decisions = three_way_decisions(analyses)
111
+ conflicts = decisions.select(&:conflict)
112
+ return render_conflicts(request, analyses, decisions, conflicts) unless conflicts.empty?
113
+
114
+ render_composite(request, analyses, decisions)
115
+ end
116
+
117
+ private
118
+
119
+ def analyze_role(role, source)
120
+ file = FileAnalysis.new(source)
121
+ lines = source.lines.each_index.map { |index| file.line_at(index + 1) }
122
+ assignments = file.all_assignments
123
+ grouped = assignments.group_by(&:key)
124
+ issues = file.structural_diagnostics.dup
125
+ grouped.each do |key, values|
126
+ next if values.one?
127
+
128
+ issues << { category: :duplicate_key, key: key, message: "Duplicate dotenv key #{key}" }.freeze
129
+ end
130
+ lines.select(&:invalid?).each do |line|
131
+ issues << {
132
+ category: :invalid_line,
133
+ line: line.line_number,
134
+ message: "Invalid dotenv line #{line.line_number}"
135
+ }.freeze
136
+ end
137
+ if file.structural_owners.any? { |owner| owner.is_a?(FreezeNode) }
138
+ issues << { category: :freeze_block, message: 'Freeze blocks require an exact one-sided merge' }.freeze
139
+ end
140
+
141
+ Analysis.new(
142
+ role: role,
143
+ source: source,
144
+ file: file,
145
+ lines: lines.freeze,
146
+ assignments: assignments.freeze,
147
+ by_key: grouped.transform_values(&:first).freeze,
148
+ unowned: lines.reject(&:assignment?).map(&:raw).freeze,
149
+ issues: issues.freeze
150
+ )
151
+ end
152
+
153
+ def analysis_payload(analysis)
154
+ {
155
+ dialect: 'dotenv',
156
+ backend: Dotenv::Merge::BACKEND_REFERENCE.id,
157
+ assignments: semantic_assignments(analysis),
158
+ comment_capability: analysis.file.comment_capability.to_h,
159
+ comment_support_style: analysis.file.comment_support_style.to_h,
160
+ structurally_unambiguous: analysis.issues.empty?
161
+ }
162
+ end
163
+
164
+ def semantic_assignments(analysis)
165
+ analysis.assignments.map do |line|
166
+ { key: line.key, value: line.value, export: line.export? }.freeze
167
+ end.freeze
168
+ end
169
+
170
+ def line_state(line)
171
+ line && [line.key, line.value, line.export?, line.raw]
172
+ end
173
+
174
+ def exact_revision_role(analyses)
175
+ return :ours if analyses[:ours].source == analyses[:theirs].source
176
+ return :ours if analyses[:base].source == analyses[:theirs].source
177
+
178
+ :theirs if analyses[:base].source == analyses[:ours].source
179
+ end
180
+
181
+ def render_exact(request, analyses, role)
182
+ rendered = render_plan(request, [whole_source_fragment(role, analyses.fetch(role).source)])
183
+ verification = verify_output(rendered.content, analyses.fetch(role).assignments, role)
184
+ result(
185
+ :merge3,
186
+ request,
187
+ output: rendered.content,
188
+ changes: assignment_changes(analyses[:base], analyses.fetch(role)),
189
+ diagnostics: diagnostics_for(*analyses.values),
190
+ render_report: render_report(rendered, :exact_revision),
191
+ verification: verification.merge(base_participated: true)
192
+ )
193
+ end
194
+
195
+ def assignment_changes(before, after)
196
+ ordered_keys(before, after).filter_map do |key|
197
+ left = before.by_key[key]
198
+ right = after.by_key[key]
199
+ next if line_state(left) == line_state(right)
200
+
201
+ {
202
+ path: "/assignments/#{key}",
203
+ ours: :unchanged,
204
+ theirs: change_kind(left, right)
205
+ }.freeze
206
+ end.freeze
207
+ end
208
+
209
+ def ordered_keys(*analyses)
210
+ analyses.flat_map { |analysis| analysis.assignments.map(&:key) }.uniq
211
+ end
212
+
213
+ def change_kind(before, after)
214
+ return :added unless before
215
+ return :deleted unless after
216
+
217
+ :edited
218
+ end
219
+
220
+ def three_way_decisions(analyses)
221
+ ordered_keys(analyses[:ours], analyses[:theirs], analyses[:base]).map do |key|
222
+ base = analyses[:base].by_key[key]
223
+ ours = analyses[:ours].by_key[key]
224
+ theirs = analyses[:theirs].by_key[key]
225
+ base_semantic = line_state(base)
226
+ ours_semantic = line_state(ours)
227
+ theirs_semantic = line_state(theirs)
228
+ selected_role =
229
+ if ours_semantic == theirs_semantic
230
+ :ours
231
+ elsif ours_semantic == base_semantic
232
+ :theirs
233
+ elsif theirs_semantic == base_semantic
234
+ :ours
235
+ end
236
+ classification = {
237
+ path: "/assignments/#{key}",
238
+ ours: side_change(base, ours),
239
+ theirs: side_change(base, theirs)
240
+ }.freeze
241
+ Decision.new(
242
+ key: key,
243
+ base: base,
244
+ ours: ours,
245
+ theirs: theirs,
246
+ selected_role: selected_role,
247
+ classification: classification,
248
+ conflict: selected_role.nil?
249
+ )
250
+ end.freeze
251
+ end
252
+
253
+ def side_change(base, side)
254
+ return :unchanged if line_state(base) == line_state(side)
255
+
256
+ change_kind(base, side)
257
+ end
258
+
259
+ def unowned_unchanged?(analyses)
260
+ anchor_keys = analyses.values.map { |analysis| analysis.by_key.keys }.reduce(&:intersection)
261
+ signatures = analyses.transform_values { |analysis| unmanaged_signature(analysis, anchor_keys) }
262
+ signatures[:base] == signatures[:ours] && signatures[:base] == signatures[:theirs]
263
+ end
264
+
265
+ def unmanaged_signature(analysis, anchor_keys)
266
+ analysis.lines.filter_map do |line|
267
+ if line.assignment?
268
+ [:assignment, line.key] if anchor_keys.include?(line.key)
269
+ else
270
+ [:unmanaged, line.raw]
271
+ end
272
+ end
273
+ end
274
+
275
+ def render_composite(request, analyses, decisions)
276
+ by_key = decisions.to_h { |decision| [decision.key, decision] }
277
+ fragments = []
278
+ selected = []
279
+ analyses[:ours].lines.each do |line|
280
+ if line.assignment?
281
+ decision = by_key.fetch(line.key)
282
+ next unless decision.selected_role
283
+
284
+ selected_line = decision.public_send(decision.selected_role)
285
+ next unless selected_line
286
+
287
+ fragments << line_fragment(decision.selected_role, selected_line.line_number, decision.key)
288
+ selected << selected_assignment(decision, selected_line)
289
+ else
290
+ fragments << line_fragment(:ours, line.line_number)
291
+ end
292
+ end
293
+ decisions.each do |decision|
294
+ next if decision.ours || decision.selected_role != :theirs || !decision.theirs
295
+
296
+ fragments << line_fragment(:theirs, decision.theirs.line_number, decision.key)
297
+ selected << selected_assignment(decision, decision.theirs)
298
+ end
299
+
300
+ rendered = render_plan(request, line_bounded_fragments(fragments, analyses))
301
+ expected = selected.map { |item| item.fetch(:semantic) }
302
+ verification = verify_composite(rendered.content, expected, selected)
303
+ return verification_failure(request, rendered, decisions, verification) unless verification[:semantic_match]
304
+
305
+ result(
306
+ :merge3,
307
+ request,
308
+ output: rendered.content,
309
+ changes: decisions.map(&:classification),
310
+ render_report: render_report(rendered, :exact_assignment_composite),
311
+ verification: verification.merge(base_participated: true)
312
+ )
313
+ end
314
+
315
+ def selected_assignment(decision, line)
316
+ {
317
+ key: decision.key,
318
+ source_role: decision.selected_role,
319
+ source_line: line.line_number,
320
+ source: line.raw,
321
+ semantic: { key: line.key, value: line.value, export: line.export? }.freeze
322
+ }.freeze
323
+ end
324
+
325
+ def fragment_content(fragment, analyses)
326
+ return fragment.content if fragment.is_a?(Ast::Merge::SourceRender::SynthesizedFragment)
327
+
328
+ analyses.fetch(fragment.revision).source.lines[fragment.start_line - 1].to_s
329
+ end
330
+
331
+ def line_bounded_fragments(fragments, analyses)
332
+ fragments.each_with_index.flat_map do |fragment, index|
333
+ next [fragment] if index == fragments.length - 1
334
+ next [fragment] if fragment.is_a?(Ast::Merge::SourceRender::ConflictFragment)
335
+ next [fragment] if fragment_content(fragment, analyses).end_with?("\n")
336
+
337
+ content = fragment_content(fragment, analyses)
338
+ [
339
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
340
+ content: "#{content}\n",
341
+ reason: :assignment_separator,
342
+ producer: provider_id,
343
+ metadata: fragment.metadata.merge(copied_source: true)
344
+ )
345
+ ]
346
+ end
347
+ end
348
+
349
+ def verify_composite(output, expected, selected)
350
+ parsed = analyze_role(:output, output)
351
+ actual = semantic_assignments(parsed)
352
+ source_match = parsed.assignments.map(&:raw) == selected.map { |item| item.fetch(:source) }
353
+ {
354
+ output_reparsed: true,
355
+ semantic_match: parsed.issues.empty? && actual == expected && source_match,
356
+ source_match: source_match,
357
+ ordered_assignments: actual,
358
+ assignment_sources: selected.map { |item| item.except(:semantic, :source) }
359
+ }
360
+ end
361
+
362
+ def verify_output(output, expected_lines, role)
363
+ parsed = analyze_role(:output, output)
364
+ expected = expected_lines.map { |line| { key: line.key, value: line.value, export: line.export? }.freeze }
365
+ {
366
+ output_reparsed: true,
367
+ semantic_match: semantic_assignments(parsed) == expected,
368
+ structurally_unambiguous: parsed.issues.empty?,
369
+ ordered_assignments: semantic_assignments(parsed),
370
+ source_role: role
371
+ }
372
+ end
373
+
374
+ def render_conflicts(request, analyses, decisions, conflicts)
375
+ if conflicts.any? { |item| item.ours.nil? }
376
+ return full_file_conflict(
377
+ request,
378
+ analyses,
379
+ decisions,
380
+ conflicts,
381
+ :conflict_not_line_addressable
382
+ )
383
+ end
384
+
385
+ by_key = conflicts.to_h { |conflict| [conflict.key, conflict] }
386
+ fragments = analyses[:ours].lines.map do |line|
387
+ conflict = line.assignment? && by_key[line.key]
388
+ conflict ? conflict_fragment(request, conflict) : line_fragment(:ours, line.line_number)
389
+ end
390
+ rendered = render_plan(request, fragments)
391
+ conflict_failure(request, decisions, conflicts, rendered, :assignment_localized_conflict)
392
+ end
393
+
394
+ def conflict_fragment(request, decision)
395
+ Ast::Merge::SourceRender::ConflictFragment.new(
396
+ conflict_id: conflict_id(decision.key),
397
+ base: conflict_side(request, :base, decision.base),
398
+ ours: conflict_side(request, :ours, decision.ours),
399
+ theirs: conflict_side(request, :theirs, decision.theirs),
400
+ labels: request.fetch(:labels, {}),
401
+ marker_size: request.fetch(:conflict_marker_size, 7),
402
+ metadata: { path: decision.classification.fetch(:path), category: conflict_category(decision) }
403
+ )
404
+ end
405
+
406
+ def conflict_side(request, role, line)
407
+ return [] unless line
408
+
409
+ fragment = line_fragment(role, line.line_number, line.key)
410
+ source_line = request.fetch(:"#{role}_source").lines[line.line_number - 1].to_s
411
+ return [fragment] if source_line.end_with?("\n")
412
+
413
+ [
414
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
415
+ content: "#{source_line}\n",
416
+ reason: :conflict_line_boundary,
417
+ producer: provider_id,
418
+ metadata: { source_role: role, assignment_key: line.key, copied_source: true }
419
+ )
420
+ ]
421
+ end
422
+
423
+ def conflict_category(decision)
424
+ return :delete_edit if decision.ours.nil? || decision.theirs.nil?
425
+
426
+ :edit_edit
427
+ end
428
+
429
+ def conflict_id(key)
430
+ "dotenv-assignment-#{Digest::SHA256.hexdigest(key)[0, 16]}"
431
+ end
432
+
433
+ def conflict_failure(request, decisions, conflicts, rendered, strategy)
434
+ failure(
435
+ :merge3,
436
+ request,
437
+ category: :merge_conflict,
438
+ message: 'Dotenv assignments changed incompatibly.',
439
+ changes: decisions.map(&:classification),
440
+ conflicts: conflicts.map do |decision|
441
+ {
442
+ conflict_id: conflict_id(decision.key),
443
+ category: conflict_category(decision),
444
+ path: decision.classification.fetch(:path),
445
+ change_classification: decision.classification
446
+ }.freeze
447
+ end,
448
+ conflicted_output: rendered.content,
449
+ conflicted_source: rendered.content,
450
+ render_report: render_report(rendered, strategy),
451
+ verification: { base_participated: true }
452
+ )
453
+ end
454
+
455
+ def unsafe_conflict(request, analyses, unsafe)
456
+ source_role = unsafe.first.role
457
+ diagnostics = [{
458
+ category: :parse_error,
459
+ severity: :error,
460
+ message: "#{source_role} structural parse is ambiguous.",
461
+ blocking: true,
462
+ source_role: source_role
463
+ }.freeze, *diagnostics_for(*unsafe)]
464
+ full_file_conflict(
465
+ request,
466
+ analyses,
467
+ three_way_decisions(analyses),
468
+ [],
469
+ :structural_ambiguity,
470
+ diagnostics: diagnostics
471
+ )
472
+ end
473
+
474
+ def unowned_conflict(request, analyses)
475
+ full_file_conflict(
476
+ request,
477
+ analyses,
478
+ three_way_decisions(analyses),
479
+ [],
480
+ :unmanaged_source_change,
481
+ diagnostics: [{
482
+ category: :unmanaged_source_change,
483
+ severity: :error,
484
+ message: 'Non-assignment dotenv source changed independently.',
485
+ blocking: true
486
+ }]
487
+ )
488
+ end
489
+
490
+ def full_file_conflict(request, analyses, decisions, conflicts, reason, diagnostics: nil)
491
+ conflict = {
492
+ conflict_id: 'dotenv-conflict-root',
493
+ category: reason,
494
+ path: '<unmanaged-source>'
495
+ }.freeze
496
+ rendered = render_plan(
497
+ request,
498
+ [
499
+ Ast::Merge::SourceRender::ConflictFragment.new(
500
+ conflict_id: conflict.fetch(:conflict_id),
501
+ base: conflict_source(:base, analyses[:base].source),
502
+ ours: conflict_source(:ours, analyses[:ours].source),
503
+ theirs: conflict_source(:theirs, analyses[:theirs].source),
504
+ labels: request.fetch(:labels, {}),
505
+ marker_size: request.fetch(:conflict_marker_size, 7),
506
+ metadata: { category: reason }
507
+ )
508
+ ]
509
+ )
510
+ failure(
511
+ :merge3,
512
+ request,
513
+ category: reason,
514
+ message: 'Dotenv source cannot be safely combined structurally.',
515
+ diagnostics: diagnostics,
516
+ changes: decisions.map(&:classification),
517
+ conflicts: conflicts.empty? ? [conflict] : conflicts.map { |item| conflict_payload(item) },
518
+ fallbacks: [{ from: :assignment_composite, to: :full_file_conflict, reason: reason }],
519
+ conflicted_output: rendered.content,
520
+ conflicted_source: rendered.content,
521
+ render_report: render_report(rendered, :full_file_conflict),
522
+ verification: { base_participated: true },
523
+ source_role: diagnostics&.first&.fetch(:source_role, nil)
524
+ )
525
+ end
526
+
527
+ def conflict_source(role, source)
528
+ return [] if source.empty?
529
+ return [whole_source_fragment(role, source)] if source.end_with?("\n")
530
+
531
+ [
532
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
533
+ content: "#{source}\n",
534
+ reason: :conflict_line_boundary,
535
+ producer: provider_id,
536
+ metadata: { source_role: role, copied_source: true }
537
+ )
538
+ ]
539
+ end
540
+
541
+ def conflict_payload(decision)
542
+ return decision if decision.is_a?(Hash)
543
+
544
+ {
545
+ conflict_id: conflict_id(decision.key),
546
+ category: conflict_category(decision),
547
+ path: decision.classification.fetch(:path),
548
+ change_classification: decision.classification
549
+ }.freeze
550
+ end
551
+
552
+ def whole_source_fragment(role, source)
553
+ if source.empty?
554
+ return Ast::Merge::SourceRender::SynthesizedFragment.new(
555
+ content: '',
556
+ reason: :exact_empty_source,
557
+ producer: provider_id,
558
+ metadata: { source_role: role }
559
+ )
560
+ end
561
+
562
+ Ast::Merge::SourceRender::SourceFragment.new(
563
+ revision: role,
564
+ start_line: 1,
565
+ end_line: source.lines.length,
566
+ metadata: { source_role: role }
567
+ )
568
+ end
569
+
570
+ def line_fragment(role, line_number, key = nil)
571
+ Ast::Merge::SourceRender::SourceFragment.new(
572
+ revision: role,
573
+ start_line: line_number,
574
+ end_line: line_number,
575
+ metadata: { source_role: role, assignment_key: key }.compact
576
+ )
577
+ end
578
+
579
+ def render_plan(request, fragments)
580
+ Ast::Merge::SourceRender::Renderer.new.render(
581
+ Ast::Merge::SourceRender::Plan.new(
582
+ sources: {
583
+ base: request[:base_source].to_s,
584
+ ours: request[:ours_source].to_s,
585
+ theirs: request[:theirs_source].to_s
586
+ },
587
+ fragments: fragments,
588
+ metadata: { provider_id: provider_id }
589
+ )
590
+ )
591
+ end
592
+
593
+ def render_report(rendered, strategy)
594
+ {
595
+ strategy: strategy,
596
+ line_records: rendered.line_records,
597
+ synthesized_fragments: rendered.synthesized_fragments,
598
+ conflicts: rendered.conflicts,
599
+ verification_input: rendered.verification_input
600
+ }
601
+ end
602
+
603
+ def diagnostics_for(*analyses)
604
+ analyses.flat_map do |analysis|
605
+ analysis.issues.map do |issue|
606
+ issue.merge(
607
+ severity: :warning,
608
+ blocking: false,
609
+ source_role: analysis.role
610
+ ).freeze
611
+ end
612
+ end.freeze
613
+ end
614
+
615
+ def unsafe_failure(operation, request, analyses, message:)
616
+ failure(
617
+ operation,
618
+ request,
619
+ category: :structural_ambiguity,
620
+ message: message,
621
+ diagnostics: diagnostics_for(*analyses)
622
+ )
623
+ end
624
+
625
+ def verification_failure(request, rendered, decisions, verification)
626
+ failure(
627
+ :merge3,
628
+ request,
629
+ category: :verification_failure,
630
+ message: 'Rendered dotenv assignments did not match the planned ordered semantics.',
631
+ changes: decisions.map(&:classification),
632
+ render_report: render_report(rendered, :exact_assignment_composite),
633
+ verification: verification.merge(base_participated: true)
634
+ )
635
+ end
636
+
637
+ def result(operation, request, diagnostics: [], changes: [], conflicts: [], fallbacks: [],
638
+ render_report: {}, verification: {}, **payload)
639
+ Ast::Merge::ProviderResult.build(
640
+ operation: operation,
641
+ success: true,
642
+ envelope: envelope(
643
+ request,
644
+ diagnostics: diagnostics,
645
+ changes: changes,
646
+ conflicts: conflicts,
647
+ fallbacks: fallbacks,
648
+ render_report: render_report,
649
+ verification: verification
650
+ ),
651
+ **payload
652
+ )
653
+ end
654
+
655
+ def failure(operation, request, category:, message:, diagnostics: nil, changes: [], conflicts: [],
656
+ fallbacks: [], render_report: {}, verification: {}, **payload)
657
+ diagnostics ||= [{ category: category, severity: :error, message: message, blocking: true }]
658
+ Ast::Merge::ProviderResult.build(
659
+ operation: operation,
660
+ success: false,
661
+ envelope: envelope(
662
+ request,
663
+ diagnostics: diagnostics,
664
+ changes: changes,
665
+ conflicts: conflicts,
666
+ fallbacks: fallbacks,
667
+ render_report: render_report,
668
+ verification: verification
669
+ ),
670
+ **payload
671
+ )
672
+ end
673
+
674
+ def envelope(request, **fields)
675
+ {
676
+ provider: {
677
+ provider_id: provider_id,
678
+ family: family,
679
+ dialect: request[:dialect] || :dotenv,
680
+ backend: request[:backend] || Dotenv::Merge::BACKEND_REFERENCE.id,
681
+ package: 'dotenv-merge',
682
+ package_version: Dotenv::Merge::Version::VERSION
683
+ },
684
+ profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
685
+ }.merge(fields)
686
+ end
687
+ end
688
+ # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
689
+ end
690
+ end