railbow 0.1.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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Railbow
6
+ module Demo
7
+ module Fixtures
8
+ # Fictional veterinary SaaS — "PawClinic"
9
+ # 4 authors: Alice Chen, Bob Wilson, Carol Davis, Me (current user)
10
+ DEMO_USER_NAME = "Me"
11
+ DEMO_USER_EMAIL = "me@example.com"
12
+
13
+ AUTHORS = {
14
+ alice: {name: "Alice Chen", email: "alice@pawclinic.dev"},
15
+ bob: {name: "Bob Wilson", email: "bob@pawclinic.dev"},
16
+ carol: {name: "Carol Davis", email: "carol@pawclinic.dev"},
17
+ me: {name: DEMO_USER_NAME, email: DEMO_USER_EMAIL}
18
+ }.freeze
19
+
20
+ # Each entry: [days_ago, time, name, author, status, tables, branch, landed_days_after]
21
+ # landed_days_after: if > 0, the landing date badge shows (must be >7 to trigger)
22
+ MIGRATION_TEMPLATES = [
23
+ # ── ~68 days ago (pre-last month) ──
24
+ [68, "09:30:12", "CreateVeterinarians", :alice, "up", ["vets"], nil, 0],
25
+ [66, "14:15:00", "CreatePets", :bob, "up", ["pets"], nil, 13],
26
+ [65, "11:00:30", "CreateOwners", :alice, "up", ["owners"], nil, 13],
27
+ [62, "16:30:00", "AddBreedAndColorToPets", :me, "up", ["pets"], nil, 0],
28
+ [59, "08:45:00", "CreateAppointments", :carol, "up", ["appointments"], nil, 0],
29
+ [57, "15:12:00", "AddOwnerIdToPets", :bob, "up", ["pets", "owners"], nil, 12],
30
+ [55, "10:00:00", "CreateVaccinations", :alice, "up", ["vaccinations"], nil, 0],
31
+ [53, "17:20:00", "AddPhoneToOwners", :carol, "up", ["owners"], nil, 0],
32
+ [51, "09:15:00", "CreateTreatments", :me, "up", ["treatments"], nil, 0],
33
+ [49, "14:00:00", "AddWeightToPets", :bob, "up", ["pets"], nil, 0],
34
+ # ── ~45 days ago (last month) ──
35
+ [45, "09:30:00", "CreateBillingRecords", :me, "up", ["bills"], nil, 0],
36
+ [43, "16:00:00", "AddVetIdToAppointments", :carol, "up", ["appointments", "vets"], nil, 0],
37
+ [41, "12:00:00", "CreateMedicalRecords", :bob, "up", ["records"], nil, 15],
38
+ [39, "08:30:00", "AddIndexOnPetsBreed", :alice, "up", ["pets"], nil, 0],
39
+ [37, "14:00:00", "CreateSpecies", :me, "up", ["species"], nil, 0],
40
+ [35, "11:00:00", "AddSpeciesIdToPets", :me, "up", ["pets", "species", "breeds"], nil, 0],
41
+ [33, "09:30:00", "AddNotesToAppointments", :carol, "up", ["appointments"], nil, 0],
42
+ [31, "15:45:00", "CreateLabResults", :alice, "up", ["labs"], nil, 0],
43
+ [29, "10:20:00", "AddDosageToTreatments", :bob, "up", ["treatments"], nil, 0],
44
+ [27, "13:00:00", "CreateReminders", :me, "up", ["reminders", "pets"], nil, 0],
45
+ # ── ~25 days ago (current month) ──
46
+ [24, "10:00:00", "CreatePrescriptions", :bob, "up", ["prescriptions"], nil, 10],
47
+ [22, "14:00:00", "AddWeightHistoryToPets", :alice, "up", ["pets"], nil, 0],
48
+ [20, "16:30:00", "AddStatusToAppointments", :carol, "up", ["appointments"], nil, 0],
49
+ [18, "09:00:00", "CreateInventory", :me, "up", ["inventory"], nil, 0],
50
+ [16, "11:30:00", "AddAllergiesToPets", :alice, "up", ["pets", "allergies"], nil, 0],
51
+ [14, "08:30:00", "RenameAmountOnBills", :me, "up", ["bills"], nil, 0],
52
+ [10, "15:00:00", "AddMicrochipToPets", :bob, "up", ["pets"], nil, 0],
53
+ [5, "16:00:00", "AddInsuranceToOwners", :carol, "up", ["owners", "plans", "bills", "logs"], "VET-150", 0],
54
+ [3, "11:00:00", "CreateInsuranceClaims", :me, "up", ["claims"], "VET-152", 0],
55
+ [2, "14:30:00", "AddPolicyNumberToBills", :me, "down", ["bills"], "VET-152", 0]
56
+ ].freeze
57
+
58
+ # Build migrations with dates relative to today.
59
+ def self.migrations
60
+ today = Date.today
61
+ MIGRATION_TEMPLATES.map do |days_ago, time, name, author_key, status, tables, branch, landed_after|
62
+ date = today - days_ago
63
+ h, m, s = time.split(":").map(&:to_i)
64
+ version = date.strftime("%Y%m%d") + format("%02d%02d%02d", h, m, s)
65
+
66
+ landed_date = (landed_after > 7) ? date + landed_after : nil
67
+
68
+ author = AUTHORS[author_key]
69
+ {
70
+ status: status,
71
+ version: version,
72
+ name: name,
73
+ author_name: author[:name],
74
+ author_email: author[:email],
75
+ branch: branch,
76
+ landed_date: landed_date,
77
+ tables: tables
78
+ }
79
+ end
80
+ end
81
+
82
+ ROUTES = [
83
+ # Pets
84
+ {verb: "GET", path: "/pets", reqs: "pets#index", name: "pets"},
85
+ {verb: "GET", path: "/pets/new", reqs: "pets#new", name: "new_pet"},
86
+ {verb: "POST", path: "/pets", reqs: "pets#create", name: ""},
87
+ {verb: "GET", path: "/pets/:id", reqs: "pets#show", name: "pet"},
88
+ {verb: "GET", path: "/pets/:id/edit", reqs: "pets#edit", name: "edit_pet"},
89
+ {verb: "PATCH|PUT", path: "/pets/:id", reqs: "pets#update", name: ""},
90
+ {verb: "DELETE", path: "/pets/:id", reqs: "pets#destroy", name: ""},
91
+ # Owners
92
+ {verb: "GET", path: "/owners", reqs: "owners#index", name: "owners"},
93
+ {verb: "POST", path: "/owners", reqs: "owners#create", name: ""},
94
+ {verb: "GET", path: "/owners/:id", reqs: "owners#show", name: "owner"},
95
+ # Appointments (nested under pets)
96
+ {verb: "GET", path: "/pets/:pet_id/appointments", reqs: "appointments#index", name: "pet_appointments"},
97
+ {verb: "POST", path: "/pets/:pet_id/appointments", reqs: "appointments#create", name: ""},
98
+ {verb: "GET", path: "/pets/:pet_id/appointments/:id", reqs: "appointments#show", name: "pet_appointment"},
99
+ # API namespace
100
+ {verb: "GET", path: "/api/v1/pets", reqs: "api/v1/pets#index", name: "api_v1_pets"},
101
+ {verb: "GET", path: "/api/v1/pets/:id", reqs: "api/v1/pets#show", name: "api_v1_pet"}
102
+ ].freeze
103
+
104
+ # Simulate `db:migrate` running — list of [name, timing_seconds]
105
+ MIGRATE_STEPS = [
106
+ ["CreateInsuranceClaims", [
107
+ ["create_table(:insurance_claims)", 0.0043],
108
+ ["add_index(:insurance_claims, :owner_id)", 0.0012]
109
+ ]],
110
+ ["AddPolicyNumberToBills", [
111
+ ["add_column(:bills, :policy_number, :string)", 0.0008]
112
+ ]],
113
+ ["AddInsuranceToOwners", [
114
+ ["add_column(:owners, :insurance_provider, :string)", 0.0006],
115
+ ["add_column(:owners, :insurance_id, :string)", 0.0005],
116
+ ["add_index(:owners, :insurance_provider)", 0.2310]
117
+ ]]
118
+ ].freeze
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formatters/base"
4
+ require_relative "fixtures"
5
+
6
+ module Railbow
7
+ module Demo
8
+ class MigrateDemo
9
+ def self.run
10
+ new.run
11
+ end
12
+
13
+ def run
14
+ f = Railbow::Formatters::Base.new
15
+
16
+ Fixtures::MIGRATE_STEPS.each do |name, steps|
17
+ puts
18
+ puts f.cyan("#{f.emoji(:migrating)} #{name}: migrating...")
19
+
20
+ total_time = 0.0
21
+ steps.each do |operation, seconds|
22
+ total_time += seconds
23
+ timing = f.format_timing(seconds)
24
+ puts " #{f.green(f.emoji(:check))} #{operation} \u2192 #{timing}"
25
+ end
26
+
27
+ total_timing = f.format_timing(total_time)
28
+ puts f.green("#{f.emoji(:migrated)} #{name}: migrated") + " (#{total_timing} total)"
29
+ end
30
+
31
+ puts
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formatters/base"
4
+ require_relative "../table"
5
+ require_relative "../config"
6
+ require_relative "fixtures"
7
+
8
+ module Railbow
9
+ module Demo
10
+ class RoutesDemo
11
+ VERB_COLORS = {
12
+ "GET" => Railbow::Formatters::Base::GREEN,
13
+ "POST" => Railbow::Formatters::Base::YELLOW,
14
+ "PATCH" => Railbow::Formatters::Base::CYAN,
15
+ "PUT" => Railbow::Formatters::Base::CYAN,
16
+ "DELETE" => Railbow::Formatters::Base::RED
17
+ }.freeze
18
+
19
+ RESET = Railbow::Formatters::Base::RESET
20
+ BOLD = Railbow::Formatters::Base::BOLD
21
+ DIM = Railbow::Formatters::Base::DIM
22
+ CYAN = Railbow::Formatters::Base::CYAN
23
+
24
+ def self.run
25
+ new.run
26
+ end
27
+
28
+ def run
29
+ routes = Fixtures::ROUTES
30
+ groups = routes.group_by { |r| r[:reqs].include?("#") ? r[:reqs].split("#").first : "(other)" }
31
+
32
+ lines = []
33
+ groups.each do |label, group_routes|
34
+ lines << ""
35
+ lines << "#{BOLD}#{CYAN}\u2500\u2500 #{label} \u2500\u2500#{RESET}"
36
+
37
+ columns = [
38
+ Railbow::Table::Column.new(label: "Verb", sticky: true),
39
+ Railbow::Table::Column.new(label: "URI Pattern", sticky: true),
40
+ Railbow::Table::Column.new(label: "Controller#Action"),
41
+ Railbow::Table::Column.new(label: "Prefix")
42
+ ]
43
+
44
+ rows = group_routes.map { |r|
45
+ [
46
+ colorize_verb(r[:verb]),
47
+ colorize_path(r[:path]),
48
+ colorize_reqs(r[:reqs]),
49
+ r[:name].empty? ? "" : "#{DIM}#{r[:name]}#{RESET}"
50
+ ]
51
+ }
52
+
53
+ renderer = Railbow::Table::Renderer.new(
54
+ columns: columns,
55
+ theme: Railbow::Table::Themes::PLAIN,
56
+ compact: {oneline: false, dense: false, noheader: false, maxw: nil, hidden_columns: []},
57
+ aliases: Railbow::Config.table_aliases
58
+ )
59
+ lines << renderer.render(rows)
60
+ end
61
+
62
+ puts lines.join("\n")
63
+ end
64
+
65
+ private
66
+
67
+ def colorize_verb(verb)
68
+ return verb if verb.empty?
69
+
70
+ verb.split("|").map { |v|
71
+ color = VERB_COLORS[v]
72
+ color ? "#{color}#{v}#{RESET}" : v
73
+ }.join("#{DIM}|#{RESET}")
74
+ end
75
+
76
+ def colorize_path(path)
77
+ return path if path.empty?
78
+
79
+ path.gsub(/:[a-z_]+|\*[a-z_]+/) { |match| "#{CYAN}#{match}#{RESET}" }
80
+ end
81
+
82
+ def colorize_reqs(reqs)
83
+ return reqs if reqs.empty?
84
+
85
+ if reqs.include?("#")
86
+ controller, action = reqs.split("#", 2)
87
+ "#{BOLD}#{controller}#{RESET}#{DIM}##{RESET}#{action}"
88
+ else
89
+ "#{DIM}#{reqs}#{RESET}"
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../railbow"
4
+ require_relative "../logo"
5
+
6
+ module Railbow
7
+ module Demo
8
+ class Runner
9
+ SUBCOMMANDS = %w[status migrate routes all].freeze
10
+
11
+ def self.run(subcommand = "status")
12
+ subcommand = subcommand.to_s.downcase
13
+ unless SUBCOMMANDS.include?(subcommand)
14
+ warn "Unknown demo: #{subcommand}"
15
+ warn "Available: #{SUBCOMMANDS.join(", ")}"
16
+ exit 1
17
+ end
18
+
19
+ Railbow.print_logo
20
+ puts
21
+
22
+ if subcommand == "all"
23
+ run_all
24
+ else
25
+ run_one(subcommand)
26
+ end
27
+ end
28
+
29
+ def self.run_one(subcommand)
30
+ case subcommand
31
+ when "status"
32
+ require_relative "status_demo"
33
+ StatusDemo.run
34
+ when "migrate"
35
+ require_relative "migrate_demo"
36
+ MigrateDemo.run
37
+ when "routes"
38
+ require_relative "routes_demo"
39
+ RoutesDemo.run
40
+ end
41
+ end
42
+
43
+ def self.run_all
44
+ %w[status migrate routes].each do |sub|
45
+ puts "\n\e[1m\e[36m── railbow demo #{sub} ──\e[0m\n"
46
+ run_one(sub)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "../formatters/base"
5
+ require_relative "../table"
6
+ require_relative "../config"
7
+ require_relative "fixtures"
8
+
9
+ module Railbow
10
+ module Demo
11
+ class StatusDemo
12
+ def self.run
13
+ new.run
14
+ end
15
+
16
+ def run
17
+ formatter = Railbow::Formatters::Base.new
18
+ migrations = Fixtures.migrations
19
+
20
+ puts "\n#{formatter.emoji(:status)} Database: #{formatter.cyan("db/development.sqlite3")}"
21
+ puts
22
+
23
+ # Use default config: author:me, diff, calendar, tables, wticks, full date
24
+ date_format = "full"
25
+ tables_enabled = true
26
+ diff_enabled = true
27
+
28
+ git_email = Fixtures::DEMO_USER_EMAIL
29
+ git_name = Fixtures::DEMO_USER_NAME
30
+
31
+ # Latest migration date — for "fresh" landed badge detection
32
+ latest_version = migrations.last[:version]
33
+ latest_mig_date = parse_version_date(latest_version)
34
+
35
+ # Check if any landed tags exist
36
+ has_landed_tags = migrations.any? { |m|
37
+ next false unless m[:landed_date]
38
+ mig_date = parse_version_date(m[:version])
39
+ mig_date && (m[:landed_date] - mig_date) > 7
40
+ }
41
+
42
+ needs_name_truncation = tables_enabled || diff_enabled || has_landed_tags
43
+ name_col_width = needs_name_truncation ? 60 : nil
44
+
45
+ table_columns = [
46
+ Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true),
47
+ Railbow::Table::Column.new(label: "Migration ID", sticky: true),
48
+ Railbow::Table::Column.new(label: "Created At"),
49
+ Railbow::Table::Column.new(label: "Migration Name",
50
+ max_width: name_col_width,
51
+ truncate: needs_name_truncation)
52
+ ]
53
+ # No Author column in default config (author:me only highlights)
54
+
55
+ tables_truncate_fn = ->(cell_raw, max_w) { formatter.table_tags_fitted(cell_raw, max_w) }
56
+ table_columns << Railbow::Table::Column.new(label: "Tables", truncate: true, truncate_fn: tables_truncate_fn)
57
+
58
+ # Build rows
59
+ highlight_rows = Set.new
60
+ rows = migrations.each_with_index.map do |m, idx|
61
+ colored_status = case m[:status]
62
+ when "up" then formatter.green_bold("up")
63
+ when "down" then formatter.yellow_bold("down")
64
+ else m[:status]
65
+ end
66
+
67
+ display_name = m[:name]
68
+
69
+ # Diff tag (branch badge)
70
+ diff_tag = m[:branch] ? formatter.diff_tag_branch(m[:branch]) : nil
71
+
72
+ # Landed badge
73
+ landed_tag = nil
74
+ if m[:landed_date]
75
+ mig_date = parse_version_date(m[:version])
76
+ if mig_date && (m[:landed_date] - mig_date) > 7
77
+ fresh = latest_mig_date && m[:landed_date] >= latest_mig_date
78
+ landed_tag = formatter.landed_tag(m[:landed_date], fresh: fresh)
79
+ end
80
+ end
81
+
82
+ # Append tags to display_name
83
+ tags = [landed_tag, diff_tag].compact.join(" ")
84
+ if !tags.empty? && name_col_width
85
+ tags_width = formatter.display_width(formatter.strip_ansi(tags))
86
+ available = name_col_width - tags_width - 2
87
+ display_name = formatter.truncate_str(display_name, available)
88
+ name_width = formatter.display_width(formatter.strip_ansi(display_name))
89
+ padding = name_col_width - name_width - tags_width
90
+ display_name = "#{display_name}#{" " * [padding, 2].max}#{tags}"
91
+ elsif !tags.empty?
92
+ display_name = "#{display_name} #{tags}"
93
+ end
94
+
95
+ created_at = formatter.format_date(m[:version], date_format)
96
+
97
+ # Highlight current user's rows (author:me mode)
98
+ if m[:author_email] == git_email ||
99
+ (git_name && m[:author_name] && m[:author_name].downcase == git_name.downcase)
100
+ highlight_rows << idx
101
+ end
102
+
103
+ # Table tags
104
+ table_tags = formatter.table_tags(m[:tables])
105
+
106
+ [colored_status, m[:version], created_at, display_name, table_tags]
107
+ end
108
+
109
+ # Calendar separators
110
+ separators = {}
111
+ versions = migrations.map { |m| m[:version] }
112
+ month_keys = versions.map { |v| v[0..5] }
113
+ calendar_label_fmt = "%b %Y W%V"
114
+
115
+ if month_keys.uniq.size > 1
116
+ month_keys.each_with_index do |mk, i|
117
+ next if i == 0
118
+ if mk != month_keys[i - 1]
119
+ v = versions[i]
120
+ date = Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
121
+ separators[i] = date.strftime(calendar_label_fmt)
122
+ end
123
+ end
124
+ end
125
+
126
+ # Week ticks
127
+ tick_rows = Set.new
128
+ prev_week = nil
129
+ versions.each_with_index do |v, i|
130
+ y = v[0..3].to_i
131
+ m = v[4..5].to_i
132
+ d = v[6..7].to_i
133
+ next if y == 0 || m == 0 || d == 0
134
+
135
+ week = Date.new(y, m, d).cweek
136
+
137
+ if i > 0 && prev_week && week != prev_week
138
+ tick_rows << i
139
+ end
140
+
141
+ prev_week = week
142
+ end
143
+
144
+ aliases = Railbow::Config.table_aliases
145
+ renderer = Railbow::Table::Renderer.new(
146
+ columns: table_columns,
147
+ theme: Railbow::Table::Themes::WALLS,
148
+ compact: {oneline: false, dense: false, noheader: false, maxw: nil, hidden_columns: []},
149
+ aliases: aliases
150
+ )
151
+ puts renderer.render(rows, separators: separators, highlight_rows: highlight_rows, tick_rows: tick_rows, tick_col: 2)
152
+ end
153
+
154
+ private
155
+
156
+ def parse_version_date(version)
157
+ v = version.to_s
158
+ Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
159
+ rescue Date::Error
160
+ nil
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require_relative "../text_utils"
5
+
6
+ module Railbow
7
+ module Formatters
8
+ class Base
9
+ include TextUtils
10
+
11
+ RESET = "\e[0m"
12
+ BOLD = "\e[1m"
13
+ GREEN = "\e[32m"
14
+ YELLOW = "\e[33m"
15
+ RED = "\e[31m"
16
+ CYAN = "\e[36m"
17
+ DIM = "\e[2m"
18
+ WHITE = "\e[97m"
19
+
20
+ TABLE_PALETTE = [
21
+ 196, # red
22
+ 208, # orange
23
+ 220, # yellow
24
+ 76, # green
25
+ 48, # mint
26
+ 39, # cyan
27
+ 33, # blue
28
+ 63, # indigo
29
+ 129, # purple
30
+ 170, # pink
31
+ 214, # amber
32
+ 109 # teal
33
+ ].freeze
34
+
35
+ BRIGHT_WHITE = "\e[1;97m"
36
+
37
+ def dim(str) = "#{DIM}#{str}#{RESET}"
38
+ def green(str) = "#{GREEN}#{str}#{RESET}"
39
+ def yellow(str) = "#{YELLOW}#{str}#{RESET}"
40
+ def red(str) = "#{RED}#{str}#{RESET}"
41
+ def cyan(str) = "#{CYAN}#{str}#{RESET}"
42
+ def bold(str) = "#{BOLD}#{str}#{RESET}"
43
+ def green_bold(str) = "#{GREEN}#{BOLD}#{str}#{RESET}"
44
+ def yellow_bold(str) = "#{YELLOW}#{BOLD}#{str}#{RESET}"
45
+
46
+ def table_color(table_name)
47
+ TABLE_PALETTE[Zlib.crc32(table_name.to_s) % TABLE_PALETTE.size]
48
+ end
49
+
50
+ def diff_tag_branch(name) = "\e[38;5;39m\u2387 #{name}#{RESET}"
51
+ def diff_tag_merging(name) = "\e[38;5;213m\u2B07 #{name}#{RESET}"
52
+
53
+ def landed_tag(date, fresh: false)
54
+ color = fresh ? "\e[38;5;220m" : DIM
55
+ "#{color}↪ #{date.strftime("%b %d")}#{RESET}"
56
+ end
57
+
58
+ def table_tag(table_name)
59
+ color_code = table_color(table_name)
60
+ "\e[38;5;#{color_code}m● #{table_name}#{RESET}"
61
+ end
62
+
63
+ def table_tags(table_names)
64
+ return "" if table_names.nil? || table_names.empty?
65
+
66
+ table_names.map { |t| table_tag(t) }.join(" ")
67
+ end
68
+
69
+ # Re-fits a pre-formatted table_tags string within max_width,
70
+ # showing full table names and "+N" for overflow.
71
+ # Accepts the full formatted string (with ANSI) and splits it into segments.
72
+ def table_tags_fitted(formatted_str, max_width)
73
+ return formatted_str if formatted_str.nil? || formatted_str.empty?
74
+
75
+ # Split into individual tag segments: each is "\e[38;5;NNNm● name\e[0m"
76
+ segments = formatted_str.scan(/\e\[38;5;\d+m● [^\e]+\e\[0m/)
77
+ return formatted_str if segments.empty?
78
+
79
+ total = segments.size
80
+ plain_segments = segments.map { |s| strip_ansi(s) }
81
+
82
+ # Try fitting all tags
83
+ return formatted_str if display_width(plain_segments.join(" ")) <= max_width
84
+
85
+ # Try fitting progressively fewer tags with +N suffix
86
+ (total - 1).downto(1) do |count|
87
+ remaining = total - count
88
+ suffix = " +#{remaining}"
89
+ candidate = plain_segments[0...count].join(" ") + suffix
90
+ if display_width(candidate) <= max_width
91
+ return segments[0...count].join(" ") + suffix
92
+ end
93
+ end
94
+
95
+ # Try fitting a truncated first table name + overflow.
96
+ # Need at least 7 chars: "● t… +N" (dot + color prefix use no visible width beyond the dot)
97
+ if max_width >= 7
98
+ suffix = (total > 1) ? " +#{total - 1}" : ""
99
+ # Available width for "● name…" part
100
+ avail = max_width - display_width(suffix)
101
+ # "● " prefix = 2 chars visible, plus at least 1 char of name + ellipsis (1 char)
102
+ if avail >= 4 # "● " (2) + at least 1 char + "…" (1)
103
+ first_plain = plain_segments[0] # e.g. "● some_table"
104
+ name_part = first_plain.sub(/^● /, "")
105
+ # Truncate name to fit: avail - 2 ("● ") - 1 ("…") = chars for name
106
+ name_max = avail - 2 - 1
107
+ truncated_name = name_part[0, name_max]
108
+ color_match = segments[0].match(/\e\[38;5;\d+m/)
109
+ color = color_match ? color_match[0] : ""
110
+ return "#{color}● #{truncated_name}…#{RESET}#{suffix}"
111
+ end
112
+ end
113
+
114
+ # Absolute fallback: just +N
115
+ "+#{total}"
116
+ end
117
+
118
+ def format_timing(seconds)
119
+ milliseconds = (seconds * 1000).round(1)
120
+
121
+ timing_str = if milliseconds < 1
122
+ "#{(milliseconds * 1000).round(0)}μs"
123
+ else
124
+ "#{milliseconds}ms"
125
+ end
126
+
127
+ cyan(timing_str)
128
+ end
129
+
130
+ def emoji(type)
131
+ case type
132
+ when :migrating then "🚀"
133
+ when :migrated then "✅"
134
+ when :reverting then "⏪"
135
+ when :reverted then "✅"
136
+ when :check then "✓"
137
+ when :status then "📊"
138
+ else ""
139
+ end
140
+ end
141
+
142
+ def format_date(timestamp, mode = "full")
143
+ ts = timestamp.to_s
144
+ return ts if ts.length != 14
145
+
146
+ time = begin
147
+ Time.new(
148
+ ts[0..3].to_i, ts[4..5].to_i, ts[6..7].to_i,
149
+ ts[8..9].to_i, ts[10..11].to_i, ts[12..13].to_i
150
+ )
151
+ rescue ArgumentError
152
+ return ts
153
+ end
154
+
155
+ case mode
156
+ when "full"
157
+ time.strftime("%Y-%m-%d %H:%M:%S")
158
+ when "rel"
159
+ format_relative_time_from(time)
160
+ when "short"
161
+ time.strftime("%b %-d")
162
+ when /\Acustom\((.+)\)\z/
163
+ time.strftime($1)
164
+ else
165
+ time.strftime("%Y-%m-%d %H:%M:%S")
166
+ end
167
+ end
168
+
169
+ private
170
+
171
+ def format_relative_time_from(time)
172
+ diff = Time.now - time
173
+ return "just now" if diff < 0
174
+
175
+ minutes = diff.to_i / 60
176
+ hours = minutes / 60
177
+ days = hours / 24
178
+ weeks = days / 7
179
+ months = days / 30
180
+ years = days / 365
181
+
182
+ if minutes < 1 then "just now"
183
+ elsif hours < 1 then "~#{minutes}min ago"
184
+ elsif days < 1 then "~#{hours}hr ago"
185
+ elsif weeks < 1 then "~#{days}d ago"
186
+ elsif months < 1 then "~#{weeks}w ago"
187
+ elsif years < 1 then "~#{months}mo ago"
188
+ else "~#{years}y ago"
189
+ end
190
+ end
191
+
192
+ public
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Railbow
6
+ # Runs git commands defensively: when the git binary is not installed,
7
+ # returns empty output and a failed status instead of raising Errno::ENOENT.
8
+ module GitUtils
9
+ MISSING_GIT_STATUS = Object.new
10
+
11
+ def MISSING_GIT_STATUS.success?
12
+ false
13
+ end
14
+
15
+ module_function
16
+
17
+ def capture2(*args)
18
+ Open3.capture2("git", *args)
19
+ rescue Errno::ENOENT
20
+ ["", MISSING_GIT_STATUS]
21
+ end
22
+
23
+ def capture3(*args)
24
+ Open3.capture3("git", *args)
25
+ rescue Errno::ENOENT
26
+ ["", "", MISSING_GIT_STATUS]
27
+ end
28
+ end
29
+ end