Module: DataShifter::Internal::Output

Defined in:
lib/data_shifter/internal/output.rb

Overview

Output formatting utilities for data shift runs. All methods are stateless module functions that accept IO and context parameters.

Constant Summary collapse

TRANSACTION_MODE_LABELS =
{
  single: "single (all-or-nothing)",
  per_record: "per-record",
  none: "none",
}.freeze
SKIP_REASONS_DISPLAY_LIMIT =
10
DIVIDER =
"=" * 60
SEPARATOR =
"-" * 60

Class Method Summary collapse

Class Method Details

.build_status_line(status_interval) ⇒ Object



177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/data_shifter/internal/output.rb', line 177

def build_status_line(status_interval)
  status_tips = []
  status_tips << "Ctrl+T" if Signal.list.key?("INFO")
  status_tips << "kill -USR1 #{Process.pid}" if Signal.list.key?("USR1")

  if status_interval
    interval_msg = "STATUS_INTERVAL is set to #{status_interval}s."
    status_tips.any? ? "#{interval_msg} Or: #{status_tips.join(", ")}" : interval_msg
  elsif status_tips.any?
    status_tips.join(" or ")
  end
end

.mode_label(dry_run:, io:) ⇒ Object



115
116
117
118
119
120
121
# File 'lib/data_shifter/internal/output.rb', line 115

def mode_label(dry_run:, io:)
  if dry_run
    "#{Colors.cyan("DRY RUN", io:)} (no changes will be persisted)"
  else
    Colors.warning("LIVE", io:)
  end
end


169
170
171
172
173
174
175
# File 'lib/data_shifter/internal/output.rb', line 169

def print_continue_from_hint(io:, task_name:, last_successful_id:, dry_run:, transaction_mode:, errors:)
  return if dry_run || transaction_mode != :none || errors.empty? || !last_successful_id || !task_name.present?

  io.puts ""
  io.puts "To resume from the last successful record:"
  io.puts "    #{Colors.bold("CONTINUE_FROM=#{last_successful_id} COMMIT=1 rake data:shift:#{task_name}", io:)}"
end


160
161
162
163
164
165
166
167
# File 'lib/data_shifter/internal/output.rb', line 160

def print_dry_run_instructions(io:, task_name:)
  io.puts ""
  io.puts Colors.cyan("[!] No changes were saved.", io:)
  return unless task_name.present?

  io.puts "To apply these changes, run:"
  io.puts "    #{Colors.bold("COMMIT=1 rake data:shift:#{task_name}", io:)}"
end


74
75
76
77
78
# File 'lib/data_shifter/internal/output.rb', line 74

def print_errors(io:, errors:)
  io.puts ""
  io.puts Colors.error("ERRORS:", io:)
  errors.each { |err| print_single_error(io:, err:) }
end

— Public header methods —



24
25
26
27
28
29
# File 'lib/data_shifter/internal/output.rb', line 24

def print_header(io:, shift_class:, total:, label:, dry_run:, transaction_mode:, status_interval:)
  print_header_top(io:, shift_class:, dry_run:)
  io.puts "Records:     #{total} #{label}"
  io.puts "Transaction: #{TRANSACTION_MODE_LABELS[transaction_mode]}"
  print_header_bottom(io:, status_interval:)
end


91
92
93
94
95
96
# File 'lib/data_shifter/internal/output.rb', line 91

def print_header_bottom(io:, status_interval:)
  status_line = build_status_line(status_interval)
  io.puts Colors.dim("Status:      #{status_line} for live progress (no abort)", io:) if status_line
  io.puts Colors.dim(DIVIDER, io:)
  io.puts ""
end

— Private helpers —



82
83
84
85
86
87
88
89
# File 'lib/data_shifter/internal/output.rb', line 82

def print_header_top(io:, shift_class:, dry_run:)
  io.puts ""
  io.puts Colors.dim(DIVIDER, io:)
  io.puts Colors.bold(shift_class.name || "DataShifter::Shift (anonymous)", io:)
  io.puts Colors.dim("\"#{shift_class.description}\"", io:) if shift_class.description.present?
  io.puts Colors.dim(SEPARATOR, io:)
  io.puts "Mode:        #{mode_label(dry_run:, io:)}"
end


147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/data_shifter/internal/output.rb', line 147

def print_interrupt_warning(io:, transaction_mode:, dry_run:)
  msg = if transaction_mode == :none
          "`transaction false` mode was active. Some DB changes may have been applied."
        elsif dry_run
          "All DB changes have been rolled back (dry run)."
        else
          "DB transaction has been rolled back. No DB changes were persisted."
        end
  io.puts ""
  io.puts "#{Colors.warning("[!] INTERRUPTED:", io:)} #{msg}"
  io.puts "    Non-DB side effects (API calls, emails, etc.) are not rolled back."
end


59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/data_shifter/internal/output.rb', line 59

def print_progress(io:, stats:, errors:, start_time:, status_interval:, skip_reasons: {})
  return unless start_time

  io.puts ""
  io.puts Colors.cyan(DIVIDER, io:)
  io.puts "#{Colors.cyan("STATUS (still running)", io:)} — triggered by #{status_trigger(status_interval)}"
  io.puts Colors.dim(SEPARATOR, io:)
  print_stats(io:, stats:, start_time:, skip_reasons:)

  print_errors(io:, errors:) if errors.any?

  io.puts Colors.cyan(DIVIDER, io:)
  io.puts ""
end


108
109
110
111
112
113
# File 'lib/data_shifter/internal/output.rb', line 108

def print_single_error(io:, err:)
  lines = err[:error].to_s.split("\n")
  io.puts "  #{Colors.red(err[:record].to_s, io:)}: #{lines.first}"
  lines.drop(1).each { |line| io.puts "    #{line}" }
  err[:backtrace]&.each { |line| io.puts Colors.dim("    #{line}", io:) }
end


190
191
192
193
194
195
196
# File 'lib/data_shifter/internal/output.rb', line 190

def print_skip_reasons(io:, skip_reasons:)
  return if skip_reasons.empty?

  top = skip_reasons.sort_by { |_reason, count| -count }.first(SKIP_REASONS_DISPLAY_LIMIT)
  formatted = top.map { |reason, count| "\"#{reason}\" (#{count})" }.join(", ")
  io.puts "             #{formatted}"
end


98
99
100
101
102
103
104
105
106
# File 'lib/data_shifter/internal/output.rb', line 98

def print_stats(io:, stats:, start_time:, skip_reasons:)
  elapsed = (Time.current - start_time).round(1)
  io.puts "Duration:    #{elapsed}s"
  io.puts "Processed:   #{stats[:processed]}"
  io.puts "Succeeded:   #{Colors.green(stats[:succeeded].to_s, io:)}"
  io.puts "Failed:      #{Colors.red(stats[:failed].to_s, io:)}" if stats[:failed].positive?
  io.puts "Skipped:     #{Colors.yellow(stats[:skipped].to_s, io:)}" if stats[:skipped].positive?
  print_skip_reasons(io:, skip_reasons:) if skip_reasons.any?
end

— Public summary/progress methods —



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/data_shifter/internal/output.rb', line 40

def print_summary(io:, stats:, errors:, start_time:, dry_run:, transaction_mode:, interrupted:, task_name:, last_successful_id:, skip_reasons: {})
  return unless start_time

  has_failures = stats[:failed].positive? || interrupted

  io.puts ""
  io.puts summary_divider(has_failures:, io:)
  io.puts summary_title(dry_run:, interrupted:, has_failures:, io:)
  io.puts Colors.dim(SEPARATOR, io:)
  print_stats(io:, stats:, start_time:, skip_reasons:)

  print_errors(io:, errors:) if errors.any?
  print_interrupt_warning(io:, transaction_mode:, dry_run:) if interrupted
  print_dry_run_instructions(io:, task_name:) if dry_run && !interrupted
  print_continue_from_hint(io:, task_name:, last_successful_id:, dry_run:, transaction_mode:, errors:)

  io.puts summary_divider(has_failures:, io:)
end


31
32
33
34
35
36
# File 'lib/data_shifter/internal/output.rb', line 31

def print_task_header(io:, shift_class:, block_count:, dry_run:, transaction_mode:, status_interval:)
  print_header_top(io:, shift_class:, dry_run:)
  io.puts "Tasks:       #{block_count}" if block_count >= 2
  io.puts "Transaction: #{task_transaction_label(transaction_mode)}"
  print_header_bottom(io:, status_interval:)
end

.status_trigger(status_interval) ⇒ Object



137
138
139
140
141
142
143
144
145
# File 'lib/data_shifter/internal/output.rb', line 137

def status_trigger(status_interval)
  if status_interval
    "every #{status_interval}s (STATUS_INTERVAL)"
  elsif Signal.list.key?("INFO")
    "Ctrl+T"
  else
    "SIGUSR1"
  end
end

.summary_divider(has_failures:, io:) ⇒ Object



127
128
129
# File 'lib/data_shifter/internal/output.rb', line 127

def summary_divider(has_failures:, io:)
  has_failures ? Colors.red(DIVIDER, io:) : Colors.green(DIVIDER, io:)
end

.summary_title(dry_run:, interrupted:, has_failures: false, io: $stdout) ⇒ Object



131
132
133
134
135
# File 'lib/data_shifter/internal/output.rb', line 131

def summary_title(dry_run:, interrupted:, has_failures: false, io: $stdout)
  base = dry_run ? "SUMMARY (DRY RUN)" : "SUMMARY"
  title = interrupted ? "#{base} - INTERRUPTED" : base
  has_failures ? Colors.error(title, io:) : Colors.success(title, io:)
end

.task_transaction_label(mode) ⇒ Object



123
124
125
# File 'lib/data_shifter/internal/output.rb', line 123

def task_transaction_label(mode)
  mode == :per_record ? "per-task" : TRANSACTION_MODE_LABELS[mode]
end