railbow 0.4.0 → 0.5.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.
@@ -0,0 +1,551 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "../config"
5
+ require_relative "../formatters/base"
6
+ require_relative "../migration_parser"
7
+ require_relative "../params"
8
+ require_relative "../table"
9
+ require_relative "../calendar"
10
+ require_relative "git_data"
11
+ require_relative "ghosts"
12
+
13
+ module Railbow
14
+ module Status
15
+ # One migrations directory's worth of db:migrate:status.
16
+ #
17
+ # Usually that is one database. Databases sharing a migrations_paths (the
18
+ # shape horizontal sharding takes) see the same files and differ only in
19
+ # what they have applied, so they merge into a single section carrying a
20
+ # status per database.
21
+ #
22
+ # Rows are built lazily, on first read, because merging has to finish
23
+ # before the status cells can be built. A section never prints: Printer
24
+ # owns output, which is what lets sections share column widths.
25
+ class Section
26
+ # Index of the Date column, where calendar week ticks are drawn.
27
+ TICK_COL = 2
28
+
29
+ # Width the Migration Name column is capped at once anything competes
30
+ # with it for horizontal space (tags, authors, table names).
31
+ NAME_COL_WIDTH = 60
32
+
33
+ NO_FILE = "NO FILE"
34
+
35
+ # A version one database in a shard group has and another does not.
36
+ ABSENT = "·"
37
+
38
+ attr_reader :databases, :migrations_key, :state, :hidden_count, :total_count,
39
+ :since_value, :entries, :ghosts
40
+
41
+ # True when the time window left too few rows and the floor topped the
42
+ # section back up, which is worth saying out loud: the table then shows
43
+ # migrations from outside the window it advertises.
44
+ def floor_applied?
45
+ @floor_applied == true
46
+ end
47
+
48
+ def initialize(pool)
49
+ @formatter = Formatters::Base.new
50
+ @databases = [{name: pool.db_config.name, database: pool.db_config.database}]
51
+ @migrations_key = migrations_key_for(pool)
52
+ @entries = {}
53
+ @ghosts = {}
54
+ @hidden_count = 0
55
+ @total_count = 0
56
+ @window_count = 0
57
+ @floor_applied = false
58
+ @since_value = Railbow::Params.since
59
+
60
+ collect(pool)
61
+ end
62
+
63
+ # The database this section is named after. Multi-database sections use
64
+ # #databases; this is the single-database convenience.
65
+ def database
66
+ databases.first[:database]
67
+ end
68
+
69
+ def db_name
70
+ databases.first[:name]
71
+ end
72
+
73
+ def db_names
74
+ databases.map { |d| d[:name] }
75
+ end
76
+
77
+ def same_migrations?(other)
78
+ migrations_key == other.migrations_key
79
+ end
80
+
81
+ # Folds another database that runs the same migration files into this
82
+ # section: its statuses join the existing rows, its identity joins the
83
+ # header, and anything already built is discarded.
84
+ def merge!(other)
85
+ other.entries.each do |version, entry|
86
+ mine = (@entries[version] ||= {name: entry[:name], statuses: {}})
87
+ mine[:name] = entry[:name] if mine[:name].include?(NO_FILE)
88
+ mine[:statuses].merge!(entry[:statuses])
89
+ end
90
+ @databases.concat(other.databases)
91
+ @ghosts = other.ghosts.merge(@ghosts)
92
+ @total_count = [@total_count, other.total_count].max
93
+ @hidden_count = [@hidden_count, other.hidden_count].max
94
+ @pending_count = [pending_count, other.pending_count].max
95
+ @ghost_total = [ghost_count, other.ghost_count].max
96
+ @latest_applied_version = [latest_applied_version, other.latest_applied_version].compact.max
97
+ @state = other.state if state_rank(other.state) > state_rank(@state)
98
+ reset_built
99
+ self
100
+ end
101
+
102
+ def sharded?
103
+ databases.size > 1
104
+ end
105
+
106
+ # Whether any row this section would render is still pending. Answered
107
+ # from the collected entries, so asking costs no git work - which is the
108
+ # point, since it is asked in order to decide whether to build the rows
109
+ # at all.
110
+ def pending_in_view?
111
+ entries.any? { |_, entry| entry[:statuses].value?("down") }
112
+ end
113
+
114
+ def tick_col
115
+ TICK_COL
116
+ end
117
+
118
+ # Nothing in the window and nothing pending: the section has no table
119
+ # worth drawing, only a line saying so.
120
+ def quiet?
121
+ state != :ok
122
+ end
123
+
124
+ def pending_count
125
+ @pending_count ||= 0
126
+ end
127
+
128
+ def ghost_count
129
+ @ghost_total ||= 0
130
+ end
131
+
132
+ attr_reader :latest_applied_version
133
+
134
+ def latest_applied_date
135
+ version_date(latest_applied_version) if latest_applied_version
136
+ end
137
+
138
+ def columns
139
+ build! unless @built
140
+ @columns
141
+ end
142
+
143
+ def rows
144
+ build! unless @built
145
+ @rows
146
+ end
147
+
148
+ def highlight_rows
149
+ build! unless @built
150
+ @highlight_rows
151
+ end
152
+
153
+ def ghost_rows
154
+ build! unless @built
155
+ @ghost_rows
156
+ end
157
+
158
+ def down_rows
159
+ build! unless @built
160
+ @down_rows
161
+ end
162
+
163
+ def calendar
164
+ build! unless @built
165
+ @calendar
166
+ end
167
+
168
+ private
169
+
170
+ attr_reader :formatter, :git, :version_to_file
171
+
172
+ def migrations_key_for(pool)
173
+ paths = Array(pool.migration_context.migrations_paths)
174
+ paths.map { |p| File.expand_path(p.to_s) }.sort
175
+ end
176
+
177
+ def state_rank(state)
178
+ {no_migrations: 0, none_in_period: 1, ok: 2}.fetch(state, 0)
179
+ end
180
+
181
+ def reset_built
182
+ @built = false
183
+ end
184
+
185
+ def collect(pool)
186
+ db_list = pool.migration_context.migrations_status
187
+ @total_count = db_list.size
188
+ record_totals(db_list)
189
+
190
+ if db_list.empty?
191
+ @state = :no_migrations
192
+ return
193
+ end
194
+
195
+ db_list = apply_since_filter(db_list)
196
+
197
+ # State tracks recency, not row count: a section whose only rows were
198
+ # pulled in by the floor still has nothing recent to say, and should
199
+ # still collapse in a multi-database run.
200
+ @state = @window_count.zero? ? :none_in_period : :ok
201
+ return if db_list.empty?
202
+
203
+ @entries = db_list.to_h do |status, version, name|
204
+ [version.to_s, {name: name, statuses: {db_name => status}}]
205
+ end
206
+
207
+ @version_to_file = {}
208
+ pool.migration_context.migrations.each do |m|
209
+ @version_to_file[m.version.to_s] = m.filename
210
+ end
211
+ end
212
+
213
+ # Counted over every migration, not just the ones in the window, so a
214
+ # collapsed section can still report what it is hiding.
215
+ def record_totals(db_list)
216
+ @pending_count = db_list.count { |status, _, _| status == "down" }
217
+ @ghost_total = db_list.count { |_, _, name| name.to_s.include?(NO_FILE) }
218
+ applied = db_list.select { |status, _, _| status == "up" }
219
+ @latest_applied_version = applied.last&.dig(1)&.to_s
220
+ end
221
+
222
+ # The time window is a soft limit. Whatever it leaves, the floor tops the
223
+ # result back up to RBW_SINCE_MIN rows, so a database with five
224
+ # migrations shows all five rather than hiding the two that happen to be
225
+ # old. The window is still what decides whether the section reads as
226
+ # recent - see #collect.
227
+ def apply_since_filter(db_list)
228
+ since_cutoff = Railbow::Params.parse_since(since_value, context: "migrations")
229
+ unless since_cutoff
230
+ @window_count = db_list.size
231
+ return db_list
232
+ end
233
+
234
+ cutoff_version = since_cutoff.strftime("%Y%m%d%H%M%S").to_i
235
+ @window_count = db_list.count { |_, v, _| v.to_i >= cutoff_version }
236
+
237
+ # Not Comparable#clamp: the floor can exceed the total, and clamp
238
+ # raises when its lower bound sits above its upper one.
239
+ keep = [@window_count, floor].max
240
+ keep = db_list.size if keep > db_list.size
241
+ @floor_applied = keep > @window_count
242
+ if keep.zero?
243
+ @hidden_count = db_list.size
244
+ return []
245
+ end
246
+
247
+ # Taken by rank rather than by slicing the tail, so the same rows are
248
+ # kept whatever order Rails hands the list over in.
249
+ threshold = db_list.map { |_, v, _| v.to_i }.sort[-keep]
250
+ filtered = db_list.select { |_, v, _| v.to_i >= threshold }
251
+ @hidden_count = db_list.size - filtered.size
252
+ filtered
253
+ end
254
+
255
+ def floor
256
+ [Railbow::Params.since_min, 0].max
257
+ end
258
+
259
+ def load_git
260
+ sample_file = version_to_file.values.first
261
+ GitData.for(
262
+ migrate_dir: sample_file ? File.dirname(sample_file) : nil,
263
+ author_enabled: author_enabled?,
264
+ diff_enabled: Railbow::Params.git_diff?,
265
+ base_override: Railbow::Params.git_base,
266
+ branch_mask: Railbow::Params.git_mask
267
+ )
268
+ end
269
+
270
+ def load_ghosts
271
+ return {} unless Ghosts.available?
272
+
273
+ versions = entries.select { |_, e| e[:name].include?(NO_FILE) }.keys
274
+ return {} if versions.empty?
275
+
276
+ Ghosts.load(versions, with_content: tables_enabled?)
277
+ end
278
+
279
+ def author_mode
280
+ @author_mode ||= Railbow::Params.git_author
281
+ end
282
+
283
+ def author_enabled?
284
+ %w[all me].include?(author_mode)
285
+ end
286
+
287
+ def tables_enabled?
288
+ return @tables_enabled unless @tables_enabled.nil?
289
+
290
+ @tables_enabled = Railbow::Params.view_tables?
291
+ end
292
+
293
+ # Git lookups and ghost recovery happen here rather than during collect,
294
+ # so a section that ends up collapsed never pays for them.
295
+ def build!
296
+ @built = true
297
+ @columns = []
298
+ @rows = []
299
+ @highlight_rows = Set.new
300
+ @ghost_rows = Set.new
301
+ @down_rows = Set.new
302
+ @calendar = Railbow::Calendar.none
303
+ return if entries.empty?
304
+
305
+ @git = load_git
306
+ @ghosts = load_ghosts
307
+ @columns = build_columns
308
+ @rows = build_rows
309
+ @calendar = build_calendar
310
+ end
311
+
312
+ # The name column only needs capping when something else competes for the
313
+ # row: table tags, an author column, branch badges or landed badges.
314
+ def build_columns
315
+ @name_col_width = needs_name_truncation? ? NAME_COL_WIDTH : nil
316
+
317
+ cols = [
318
+ Table::Column.new(label: "Status", max_width: status_col_width,
319
+ sticky: true, accent: true, aliased: !sharded?),
320
+ Table::Column.new(label: "Migration ID", sticky: true),
321
+ Table::Column.new(label: (date_format == "full") ? "Created At" : "Date"),
322
+ Table::Column.new(label: "Migration Name",
323
+ max_width: @name_col_width,
324
+ truncate: !@name_col_width.nil?)
325
+ ]
326
+ cols << Table::Column.new(label: "Who") if author_mode == "all"
327
+ if tables_enabled?
328
+ truncate_fn = ->(cell_raw, max_w) { formatter.table_tags_fitted(cell_raw, max_w) }
329
+ cols << Table::Column.new(label: "Tables",
330
+ truncate: Railbow::Params.compact_oneline?, truncate_fn: truncate_fn)
331
+ end
332
+ cols
333
+ end
334
+
335
+ # One glyph plus a space per database, and never narrower than the single
336
+ # database case, which also has to fit a trailing indicator.
337
+ def status_col_width
338
+ [6, 3 * databases.size].max
339
+ end
340
+
341
+ def date_format
342
+ @date_format ||= Railbow::Params.date_format
343
+ end
344
+
345
+ def needs_name_truncation?
346
+ tables_enabled? || author_mode == "all" || Railbow::Params.git_diff? || landed_tags?
347
+ end
348
+
349
+ # Only migrations that landed well after they were written earn a badge,
350
+ # so a repo that always merges promptly never pays for the column space.
351
+ def landed_tags?
352
+ git.landed_dates.any? do |basename, landed|
353
+ mig_date = version_date(basename[0..13])
354
+ mig_date && (landed - mig_date) > 7
355
+ end
356
+ end
357
+
358
+ def build_rows
359
+ versions = entries.keys.sort
360
+ latest_mig_date = version_date(versions.last)
361
+ author_display = build_author_display
362
+
363
+ versions.each_with_index.map do |version, idx|
364
+ entry = entries[version]
365
+ name = entry[:name]
366
+ statuses = entry[:statuses]
367
+
368
+ # A pending migration is not in effect yet: grey the whole row out so
369
+ # it reads as inactive next to the applied ones. In a shard group only
370
+ # a row pending everywhere reads as inactive.
371
+ @down_rows << idx if statuses.values.all? { |s| s == "down" }
372
+
373
+ ghost = name.include?(NO_FILE) ? ghosts[version] : nil
374
+ @ghost_rows << idx if ghost
375
+
376
+ status_cell = build_status_cell(statuses, ghost)
377
+ display_name = name_cell(
378
+ name: name, version: version, ghost: ghost, latest_mig_date: latest_mig_date
379
+ ) do |indicator|
380
+ status_cell = "#{status_cell} #{indicator}"
381
+ end
382
+
383
+ row = [status_cell, version, formatter.format_date(version, date_format), display_name]
384
+ row << author_cell(version, ghost, author_display) if author_mode == "all"
385
+ track_highlight(idx, version, ghost) if author_enabled?
386
+ row << formatter.table_tags(tables_for(version, ghost)) if tables_enabled?
387
+ row
388
+ end
389
+ end
390
+
391
+ def build_status_cell(statuses, ghost)
392
+ return status_glyph(statuses[db_name], ghost) unless sharded?
393
+
394
+ databases.map { |db| status_glyph(statuses[db[:name]], ghost) }.join(" ")
395
+ end
396
+
397
+ # A sharded section resolves the up/down aliases itself: the renderer
398
+ # applies them by matching the whole cell, which a cluster never is.
399
+ def status_glyph(status, ghost)
400
+ # A superseded ghost lives on under another version: stale bookkeeping,
401
+ # not a lost migration, so it gets a calmer glyph.
402
+ return ghost.superseded_by ? "🪦" : "👻" if ghost && status
403
+
404
+ case status
405
+ when "up" then formatter.green_bold(sharded? ? status_alias("up") : "up")
406
+ when "down" then formatter.yellow_bold(sharded? ? status_alias("down") : "down")
407
+ when nil then formatter.dim(ABSENT)
408
+ else status
409
+ end
410
+ end
411
+
412
+ def status_alias(status)
413
+ @status_aliases ||= Railbow::Config.value_aliases["Status"] || {}
414
+ @status_aliases[status] || status
415
+ end
416
+
417
+ # Builds the Migration Name cell and right-aligns whatever badges it
418
+ # carries. Yields a status indicator when the row earns one.
419
+ def name_cell(name:, version:, ghost:, latest_mig_date:)
420
+ if ghost
421
+ return with_tags(Ghosts.display_name(ghost), Ghosts.tag(ghost), strip: false)
422
+ end
423
+ return formatter.red(NO_FILE) if name.include?(NO_FILE)
424
+
425
+ basename = basename_for(version)
426
+ tags = []
427
+
428
+ diff_tag = nil
429
+ if Railbow::Params.git_diff? && basename
430
+ if git.incoming_merge_files.include?(basename)
431
+ yield "\e[38;5;213m⬇#{Formatters::Base::RESET}"
432
+ diff_tag = formatter.diff_tag_merging(git.merge_source_label || "merge")
433
+ elsif git.uncommitted_files.include?(basename)
434
+ yield "\e[38;5;220m◆#{Formatters::Base::RESET}"
435
+ end
436
+ diff_tag ||= formatter.diff_tag_branch(git.branch_origins[basename]) if git.branch_origins.key?(basename)
437
+ end
438
+
439
+ tags << landed_tag(basename, version, latest_mig_date)
440
+ tags << diff_tag
441
+
442
+ with_tags(name, tags.compact.join(" "))
443
+ end
444
+
445
+ # Landed badge: the migration reached the mainline more than a week after
446
+ # it was written, which usually means a long-lived branch.
447
+ def landed_tag(basename, version, latest_mig_date)
448
+ return nil unless basename
449
+
450
+ landed = git.landed_dates[basename]
451
+ return nil unless landed
452
+
453
+ mig_date = version_date(version)
454
+ return nil unless mig_date && (landed - mig_date) > 7
455
+
456
+ formatter.landed_tag(landed, fresh: latest_mig_date && landed >= latest_mig_date)
457
+ end
458
+
459
+ # Pads the name so its tags sit flush against the right edge of the
460
+ # column. Falls back to a two-space gap when the column is uncapped.
461
+ def with_tags(name, tags, strip: true)
462
+ return name if tags.nil? || tags.empty?
463
+ return "#{name} #{tags}" unless @name_col_width
464
+
465
+ tags_width = formatter.display_width(formatter.strip_ansi(tags))
466
+ name = formatter.truncate_str(name, @name_col_width - tags_width - 2)
467
+ name_width = formatter.display_width(strip ? formatter.strip_ansi(name) : name)
468
+ padding = @name_col_width - name_width - tags_width
469
+ "#{name}#{" " * [padding, 2].max}#{tags}"
470
+ end
471
+
472
+ def build_author_display
473
+ return {} unless author_mode == "all"
474
+
475
+ raw = git.author_names.values.compact
476
+ raw << git.git_name if git.git_name
477
+ ghosts.each_value { |g| raw << g.author_name if g.author_name }
478
+ Railbow::Params.format_authors(raw)
479
+ end
480
+
481
+ def author_cell(version, ghost, author_display)
482
+ raw = if ghost
483
+ ghost.author_name || ""
484
+ else
485
+ basename = basename_for(version)
486
+ author = basename ? git.author_names[basename] : nil
487
+ author || (basename ? git.git_name : "")
488
+ end
489
+ author_display[raw] || Railbow::Params.format_author(raw)
490
+ end
491
+
492
+ # Uncommitted migrations have no git author, so they are treated as mine.
493
+ # Match by email first, then by name, to survive a commit email that
494
+ # differs from git config (GitHub noreply addresses after a squash-merge,
495
+ # or a mailmap rewrite).
496
+ def track_highlight(idx, version, ghost)
497
+ return unless git.git_email
498
+
499
+ if ghost
500
+ email = ghost.author_email&.downcase
501
+ @highlight_rows << idx if email && email == git.git_email
502
+ return
503
+ end
504
+
505
+ basename = basename_for(version)
506
+ # A row with no file has no author to compare against, which the two
507
+ # modes read differently: author:all treats "nobody else's" as mine,
508
+ # author:me requires a real file before claiming anything.
509
+ return if author_mode == "me" && basename.nil?
510
+
511
+ email = git.author_emails[basename]
512
+ author = git.author_names[basename]
513
+ @highlight_rows << idx if email.nil? || email == git.git_email ||
514
+ (git.git_name && author && author.downcase == git.git_name.downcase)
515
+ end
516
+
517
+ def tables_for(version, ghost)
518
+ if ghost&.content && !ghost.content.empty?
519
+ Railbow::MigrationParser.extract_tables_from_content(ghost.content)
520
+ else
521
+ Railbow::MigrationParser.extract_tables(version_to_file[version])
522
+ end
523
+ end
524
+
525
+ def basename_for(version)
526
+ filepath = version_to_file[version]
527
+ filepath ? File.basename(filepath) : nil
528
+ end
529
+
530
+ def build_calendar
531
+ return Railbow::Calendar.none unless Railbow::Params.view_calendar?
532
+
533
+ Railbow::Calendar.build(
534
+ entries.keys.sort,
535
+ weeks: Railbow::Params.calendar_wdividers?,
536
+ ticks: Railbow::Params.calendar_wticks?,
537
+ counts: Railbow::Params.calendar_counts?,
538
+ month_label: Railbow::Params.calendar_label,
539
+ week_label: Railbow::Params.calendar_week_label
540
+ )
541
+ end
542
+
543
+ def version_date(version)
544
+ v = version.to_s
545
+ Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
546
+ rescue Date::Error
547
+ nil
548
+ end
549
+ end
550
+ end
551
+ end
@@ -3,11 +3,15 @@
3
3
  module Railbow
4
4
  module Table
5
5
  class Column
6
- attr_reader :label, :width, :min_width, :max_width, :align, :truncate, :truncate_fn, :sticky, :accent
6
+ attr_reader :label, :width, :min_width, :max_width, :align, :truncate, :truncate_fn,
7
+ :sticky, :accent, :aliased
7
8
 
8
9
  # accent: the column carries its own meaning through color (a status
9
10
  # glyph, say), so it keeps that color when the row is dimmed.
10
- def initialize(label:, width: :auto, min_width: nil, max_width: nil, align: :left, truncate: false, truncate_fn: nil, sticky: false, accent: false)
11
+ # aliased: value aliases from config may rewrite this column's cells.
12
+ # Off for cells the caller already resolved, such as a cluster of status
13
+ # glyphs, which no whole-cell alias could ever match.
14
+ def initialize(label:, width: :auto, min_width: nil, max_width: nil, align: :left, truncate: false, truncate_fn: nil, sticky: false, accent: false, aliased: true)
11
15
  @label = label
12
16
  @width = width
13
17
  @min_width = min_width
@@ -17,6 +21,7 @@ module Railbow
17
21
  @truncate_fn = truncate_fn
18
22
  @sticky = sticky
19
23
  @accent = accent
24
+ @aliased = aliased
20
25
  end
21
26
 
22
27
  def fixed?
@@ -16,35 +16,40 @@ module Railbow
16
16
 
17
17
  attr_reader :columns, :theme
18
18
 
19
- def initialize(columns:, theme:, compact: {}, aliases: {})
19
+ # min_widths raises the resolved width of each column to at least the
20
+ # given value, which is how several tables rendered in one run line their
21
+ # columns up with each other. An explicit compact maxw still wins.
22
+ def initialize(columns:, theme:, compact: {}, aliases: {}, min_widths: nil)
20
23
  @compact = compact
21
24
  @aliases = aliases
25
+ @min_widths = min_widths
22
26
  @reverse_col_aliases = aliases[:columns]&.invert || {}
23
27
  @columns = apply_hidden_columns(columns)
24
28
  @theme = theme
25
29
  end
26
30
 
27
- def render(rows, separators: {}, highlight_rows: Set.new, ghost_rows: Set.new, dim_rows: Set.new, tick_rows: Set.new, tick_col: nil)
28
- return "" if columns.empty?
31
+ # The widths this table would resolve to on its own. Callers rendering
32
+ # several tables together take the per-column maximum and feed it back as
33
+ # min_widths.
34
+ def column_widths(rows)
35
+ return [] if columns.empty?
29
36
 
30
- # Remap rows if columns were hidden
31
- rows = remap_rows(rows) if @hidden_indices&.any?
37
+ resolve_widths(prepare_rows(rows))
38
+ end
32
39
 
33
- # Apply value aliases
34
- rows = apply_value_aliases(rows) if @aliases[:values]&.any?
40
+ # Width of everything left of the last column. The last column flexes to
41
+ # the terminal, so this is where furniture drawn around the table (a
42
+ # section rule, say) can stop without running past it.
43
+ def fixed_width(widths)
44
+ return 0 if columns.empty? || widths.nil? || widths.empty?
35
45
 
36
- # Pre-truncate non-last columns that have truncate + max_width
37
- rows = rows.map { |row|
38
- row.each_with_index.map { |cell, i|
39
- col = columns[i]
40
- if col&.truncate && col.max_width && i < columns.size - 1
41
- truncate_str(cell.to_s, col.max_width)
42
- else
43
- cell
44
- end
45
- }
46
- }
46
+ compute_prefix_width(widths, columns.size - 1)
47
+ end
47
48
 
49
+ def render(rows, separators: {}, highlight_rows: Set.new, ghost_rows: Set.new, dim_rows: Set.new, tick_rows: Set.new, tick_col: nil)
50
+ return "" if columns.empty?
51
+
52
+ rows = prepare_rows(rows)
48
53
  resolved = resolve_widths(rows)
49
54
 
50
55
  # In oneline mode, truncate non-sticky non-last columns at resolved width
@@ -77,6 +82,25 @@ module Railbow
77
82
 
78
83
  private
79
84
 
85
+ # Everything that changes a cell's content, and therefore its width,
86
+ # before widths are resolved: hidden columns, value aliases, and the
87
+ # pre-truncation of non-last columns that cap themselves.
88
+ def prepare_rows(rows)
89
+ rows = remap_rows(rows) if @hidden_indices&.any?
90
+ rows = apply_value_aliases(rows) if @aliases[:values]&.any?
91
+
92
+ rows.map { |row|
93
+ row.each_with_index.map { |cell, i|
94
+ col = columns[i]
95
+ if col&.truncate && col.max_width && i < columns.size - 1
96
+ truncate_str(cell.to_s, col.max_width)
97
+ else
98
+ cell
99
+ end
100
+ }
101
+ }
102
+ end
103
+
80
104
  def resolve_widths(rows)
81
105
  all_rows = rows
82
106
  last = columns.size - 1
@@ -95,6 +119,9 @@ module Railbow
95
119
  w = [w, col.min_width].max if col.min_width
96
120
  w = [w, col.max_width].min if col.max_width
97
121
  end
122
+ # Alignment across tables raises the width; an explicit maxw caps it
123
+ # afterwards, so the user's cap stays authoritative.
124
+ w = [w, @min_widths[i].to_i].max if @min_widths && i != last && w > 0
98
125
  w = [w, global_maxw].min if global_maxw && i != last && w > 0
99
126
  w
100
127
  end
@@ -429,6 +456,8 @@ module Railbow
429
456
  # Build column index → value alias map
430
457
  col_map = {}
431
458
  columns.each_with_index do |col, i|
459
+ next unless col.aliased
460
+
432
461
  label = col.label
433
462
  col_map[i] = value_aliases[label] if value_aliases[label]
434
463
  # Also look up by original name if column was renamed by alias