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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require "fileutils"
5
+
6
+ module Railbow
7
+ module Init
8
+ module_function
9
+
10
+ TEMPLATE = <<~YAML
11
+ # Railbow configuration
12
+ # https://github.com/amberpixels/railbow
13
+ #
14
+ # Config layers (each overrides the previous):
15
+ # 1. Gem defaults (built-in)
16
+ # 2. Global: ~/.config/railbow/config.yml
17
+ # 3. Project: .railbow.yml (commit to git)
18
+ # 4. Local: .railbow.local.yml (gitignored, personal overrides)
19
+ #
20
+ # Every key can also be overridden via RBW_* environment variables.
21
+
22
+ # Time window for migrations to display (e.g. 30d, 2mo, 1y, all)
23
+ since: "70d"
24
+
25
+ # Sort order: file (by filename/version) or date (by timestamp)
26
+ # sort: "file"
27
+
28
+ # Git integration (comma-separated compound value):
29
+ # author — add Author column (same as author:all)
30
+ # author:me — highlight your own migrations
31
+ # author:all — show all authors
32
+ # diff — tag migrations by git origin branch
33
+ # base:<branch> — base branch for diff (default: auto-detected)
34
+ # mask:auto — auto-extract ticket id from branch name
35
+ # mask:<re> — custom regex to extract branch label, e.g. mask:(WS-[^/]+)/
36
+ git: "author:all,diff,mask:auto"
37
+
38
+ # Author display format — preset or custom pattern (FF L, FFF LL, etc.):
39
+ # initials (J D), short (Jo D), first_name (John), last_name (Doe),
40
+ # full_name (John Doe), full_name_short (John D.)
41
+ author_format: "short"
42
+
43
+ # View mode (comma-separated): calendar, tables
44
+ view: "calendar,tables"
45
+
46
+ # Calendar options: wticks (show week tick separators)
47
+ calendar: "wticks"
48
+
49
+ # Date format: full, rel, short, or custom(%b %d, %Y)
50
+ # date: "full"
51
+
52
+ # Compact mode (comma-separated):
53
+ # oneline — one line per migration
54
+ # dense — reduce padding
55
+ # noheader — hide table headers
56
+ # maxw:<N> — max column width
57
+ # hide:<col> — hide a column (repeatable)
58
+ # compact: ""
59
+
60
+ # Rename column headers and cell values in table output
61
+ aliases:
62
+ columns:
63
+ Status: Live
64
+ values:
65
+ Status:
66
+ up: "↑↑"
67
+ down: "↓↓"
68
+ # Verb:
69
+ # GET: G
70
+ # POST: P
71
+ YAML
72
+
73
+ def global_path
74
+ File.join(Config.global_dir, "config.yml")
75
+ end
76
+
77
+ def project_path
78
+ File.join(Config.root, ".railbow.yml")
79
+ end
80
+
81
+ def run(input: $stdin, output: $stdout)
82
+ output.puts " Where should the config be created?"
83
+ output.puts ""
84
+ output.puts " 1) Global: #{global_path}"
85
+ output.puts " 2) Project: #{project_path}"
86
+ output.puts " 3) Cancel"
87
+ output.puts ""
88
+ output.print " Choose [1/2/3]: "
89
+ output.flush
90
+
91
+ choice = input.gets&.strip
92
+ target = case choice
93
+ when "1" then global_path
94
+ when "2" then project_path
95
+ else
96
+ output.puts " Cancelled."
97
+ return
98
+ end
99
+
100
+ if File.exist?(target)
101
+ output.puts " Already exists: #{target}"
102
+ output.puts " Remove it first if you want to regenerate."
103
+ return
104
+ end
105
+
106
+ FileUtils.mkdir_p(File.dirname(target))
107
+ File.write(target, TEMPLATE)
108
+ output.puts ""
109
+ output.puts " Created: #{target}"
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railbow
4
+ LOGO_SEGMENTS = [
5
+ ["░█▀▀█", "░█▀▀█", "▀█▀", "░█───", "░█▀▀█", "░█▀▀▀█", "░█───░█"],
6
+ ["░█▄▄▀", "░█▄▄█", "░█─", "░█───", "░█▀▀▄", "░█──░█", "░█─█─░█"],
7
+ ["░█─░█", "░█─░█", "▄█▄", "░█▄▄█", "░█▄▄█", "░█▄▄▄█", "─░█░█─"]
8
+ ].freeze
9
+
10
+ LOGO_COLORS = [
11
+ "\e[38;5;196m", "\e[38;5;208m", "\e[38;5;220m",
12
+ "\e[38;5;40m", "\e[38;5;33m", "\e[38;5;93m", "\e[38;5;163m"
13
+ ].freeze
14
+
15
+ RESET = "\e[0m"
16
+
17
+ def self.print_logo
18
+ LOGO_SEGMENTS.each do |segments|
19
+ print " "
20
+ segments.each_with_index do |seg, i|
21
+ print "#{LOGO_COLORS[i]}#{seg}#{RESET}"
22
+ print " " unless i == segments.length - 1
23
+ end
24
+ puts
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark"
4
+ require_relative "formatters/base"
5
+
6
+ module Railbow
7
+ module MigrationFormatter
8
+ FORMATTER = Formatters::Base.new.freeze
9
+
10
+ def announce(message)
11
+ return super if Railbow.plain?
12
+
13
+ f = FORMATTER
14
+ migration_name = self.class.name&.demodulize || "Migration"
15
+
16
+ line = case message
17
+ when /migrating/i
18
+ f.cyan("#{f.emoji(:migrating)} #{migration_name}: migrating...")
19
+ when /migrated\s*\((\d+\.\d+)s\)/i
20
+ raw_seconds = Regexp.last_match(1).to_f
21
+ formatted = f.format_timing(raw_seconds)
22
+ f.green("#{f.emoji(:migrated)} #{migration_name}: migrated") + " (#{formatted} total)"
23
+ when /reverting/i
24
+ f.yellow("#{f.emoji(:reverting)} #{migration_name}: reverting...")
25
+ when /reverted\s*\((\d+\.\d+)s\)/i
26
+ raw_seconds = Regexp.last_match(1).to_f
27
+ formatted = f.format_timing(raw_seconds)
28
+ f.green("#{f.emoji(:reverted)} #{migration_name}: reverted") + " (#{formatted} total)"
29
+ else
30
+ "#{migration_name}: #{message}"
31
+ end
32
+
33
+ write ""
34
+ write line
35
+ end
36
+
37
+ def say_with_time(message)
38
+ return super { yield } if Railbow.plain?
39
+
40
+ f = FORMATTER
41
+ result = nil
42
+ time = Benchmark.measure { result = yield }
43
+
44
+ timing = f.format_timing(time.real)
45
+ write " #{f.green(f.emoji(:check))} #{message} → #{timing}"
46
+ say("#{result} rows", :subitem) if result.is_a?(Integer)
47
+
48
+ result
49
+ end
50
+
51
+ def say(message, subitem = false)
52
+ return super if Railbow.plain?
53
+
54
+ prefix = subitem ? " " : " "
55
+ write("#{prefix}#{message}")
56
+ end
57
+
58
+ def write(text = "")
59
+ return super if Railbow.plain?
60
+
61
+ puts text
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/inflector"
4
+
5
+ module Railbow
6
+ class MigrationParser
7
+ # Methods that take a single table name as the first argument
8
+ SINGLE_TABLE_METHODS = %w[
9
+ create_table drop_table change_table
10
+ add_column remove_column rename_column change_column
11
+ add_index remove_index
12
+ add_reference remove_reference
13
+ add_belongs_to remove_belongs_to
14
+ add_timestamps remove_timestamps
15
+ ].freeze
16
+
17
+ # Methods that take two table names as arguments
18
+ DUAL_TABLE_METHODS = %w[
19
+ add_foreign_key remove_foreign_key
20
+ ].freeze
21
+
22
+ SINGLE_TABLE_PATTERN = /\b(?:#{SINGLE_TABLE_METHODS.join("|")})\s+[:"](\w+)/
23
+ DUAL_TABLE_PATTERN = /\b(?:#{DUAL_TABLE_METHODS.join("|")})\s+[:"](\w+)["\s,]+[:"](\w+)/
24
+
25
+ # SQL keywords followed by a table name
26
+ SQL_TABLE_PATTERNS = [
27
+ /\bUPDATE\s+["']?(\w+)["']?/i,
28
+ /\bINSERT\s+INTO\s+["']?(\w+)["']?/i,
29
+ /\bDELETE\s+FROM\s+["']?(\w+)["']?/i,
30
+ /\bALTER\s+TABLE\s+["']?(\w+)["']?/i,
31
+ /\bTRUNCATE\s+(?:TABLE\s+)?["']?(\w+)["']?/i,
32
+ /\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["']?(\w+)["']?/i,
33
+ /\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i
34
+ ].freeze
35
+
36
+ # AR class-level query/mutation methods that strongly signal a constant is a model.
37
+ # Kept narrow on purpose — methods like `find`, `all`, `first`, `count`, `create`,
38
+ # `new`, `transaction`, `order`, `includes` collide with stdlib/non-model constants
39
+ # (e.g. Time.new, File.find, Array#first) and would produce false positives.
40
+ MODEL_AR_METHODS = %w[
41
+ find_each find_in_batches
42
+ find_by find_by!
43
+ find_or_create_by find_or_create_by!
44
+ find_or_initialize_by
45
+ where update_all delete_all destroy_all
46
+ upsert_all insert_all
47
+ pluck pick
48
+ in_batches
49
+ ].freeze
50
+
51
+ MODEL_REFERENCE_PATTERN = /\b([A-Z][A-Za-z0-9]*(?:::[A-Z][A-Za-z0-9]*)*)\.(?:#{MODEL_AR_METHODS.join("|")})\b/
52
+
53
+ def self.extract_tables(filepath)
54
+ return [] if filepath.nil? || filepath.empty?
55
+ return [] unless File.exist?(filepath)
56
+
57
+ extract_tables_from_content(File.read(filepath))
58
+ end
59
+
60
+ def self.extract_tables_from_content(content)
61
+ return [] if content.nil? || content.empty?
62
+
63
+ tables = []
64
+
65
+ content.scan(SINGLE_TABLE_PATTERN) { |match| tables << match[0] }
66
+ content.scan(DUAL_TABLE_PATTERN) { |match| tables.concat(match) }
67
+ SQL_TABLE_PATTERNS.each do |pattern|
68
+ content.scan(pattern) { |match| tables << match[0] }
69
+ end
70
+ tables.uniq!
71
+
72
+ # Model-based detection is heuristic (a constant that happens to respond
73
+ # to one of these methods isn't necessarily an AR model). Only use it to
74
+ # supplement sparse DDL/SQL findings — skip once we already know of ≥2
75
+ # tables, and cap the final total at 2.
76
+ if tables.size < 2
77
+ content.scan(MODEL_REFERENCE_PATTERN) do |match|
78
+ break if tables.size >= 2
79
+ table = ActiveSupport::Inflector.tableize(match[0].split("::").last)
80
+ tables << table unless tables.include?(table)
81
+ end
82
+ end
83
+
84
+ tables
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "name_formatter"
4
+
5
+ module Railbow
6
+ # Detects and resolves collisions when multiple different names produce the
7
+ # same formatted output. Works by progressively expanding pattern tokens
8
+ # (F → FF → FFF → FFFF, then L) until every distinct raw name maps to a
9
+ # unique formatted string.
10
+ #
11
+ # Names that share the same first+last name but differ only in middle name
12
+ # are treated as the same person and merged before collision detection.
13
+ #
14
+ # NameCollisionResolver.resolve(["John Doe", "Jane Doe"], "F L")
15
+ # # => {"John Doe" => "Jo D", "Jane Doe" => "Ja D"}
16
+ module NameCollisionResolver
17
+ SUPERSCRIPTS = %w[² ³ ⁴ ⁵ ⁶ ⁷ ⁸ ⁹].freeze
18
+
19
+ module_function
20
+
21
+ # @param names [Array<String, nil>] raw author names (may contain duplicates/nils)
22
+ # @param pattern [String] a NameFormatter pattern or preset name
23
+ # @return [Hash{String => String}] raw_name → disambiguated formatted name
24
+ def resolve(names, pattern)
25
+ pattern = NameFormatter::PRESETS.fetch(pattern, pattern)
26
+ uniq_names = names.compact.reject(&:empty?).uniq
27
+
28
+ # Merge names that are the same person (same first+last, differ only in middle)
29
+ canonical, aliases = merge_same_person(uniq_names)
30
+
31
+ # Format every canonical name with the base pattern
32
+ result = {}
33
+ canonical.each { |n| result[n] = NameFormatter.format(n, pattern) }
34
+
35
+ # Find collision groups (different raw names → same formatted output)
36
+ collisions = find_collisions(result)
37
+ unless collisions.empty?
38
+ tokens = parse_tokens(pattern)
39
+ collisions.each do |_formatted, raw_names|
40
+ resolve_group(raw_names, pattern, tokens, result)
41
+ end
42
+ end
43
+
44
+ # Expand aliases: all variant spellings get the same formatted output
45
+ aliases.each { |variant, canon| result[variant] = result[canon] }
46
+
47
+ result
48
+ end
49
+
50
+ # --- private helpers ---
51
+
52
+ # Group names by (first, last) and pick the shortest as canonical.
53
+ # Returns [canonical_names, aliases_hash] where aliases maps variant → canonical.
54
+ def merge_same_person(names)
55
+ groups = {}
56
+ names.each do |name|
57
+ parts = name.split(/[\s._-]+/)
58
+ key = if parts.size >= 2
59
+ [parts.first.downcase, parts.last.downcase]
60
+ else
61
+ [parts.first&.downcase, nil]
62
+ end
63
+ (groups[key] ||= []) << name
64
+ end
65
+
66
+ canonical = []
67
+ aliases = {}
68
+ groups.each_value do |group|
69
+ # Pick shortest name as canonical (the one without middle name)
70
+ canon = group.min_by(&:length)
71
+ canonical << canon
72
+ group.each { |name| aliases[name] = canon unless name == canon }
73
+ end
74
+
75
+ [canonical, aliases]
76
+ end
77
+
78
+ def find_collisions(mapping)
79
+ mapping.group_by { |_raw, fmt| fmt }
80
+ .select { |_fmt, pairs| pairs.size > 1 }
81
+ .transform_values { |pairs| pairs.map(&:first) }
82
+ end
83
+
84
+ # Resolve a single collision group by progressively expanding tokens.
85
+ # Only names that still collide continue to be expanded; once a name
86
+ # becomes unique it keeps its current (minimal) expansion.
87
+ def resolve_group(raw_names, base_pattern, base_tokens, result)
88
+ remaining = raw_names.dup
89
+ tokens = base_tokens.map(&:dup)
90
+
91
+ # Expansion order: exhaust all F tokens first, then L tokens
92
+ expansion_order = tokens.each_index.sort_by { |i| (tokens[i][:type] == "F") ? 0 : 1 }
93
+
94
+ expansion_order.each do |ti|
95
+ tok = tokens[ti]
96
+ next if tok[:maxed]
97
+
98
+ until tok[:maxed]
99
+ tok[:length] += 1
100
+ tok[:maxed] = true if tok[:length] >= 4
101
+ current_pattern = rebuild_pattern(tokens, base_pattern)
102
+
103
+ remaining.each { |n| result[n] = NameFormatter.format(n, current_pattern) }
104
+
105
+ # Check collisions only within this group (not the full result),
106
+ # so expansion from one group can't chase collisions with other groups
107
+ still_colliding = find_collisions(result.slice(*raw_names))
108
+ if still_colliding.empty?
109
+ remaining = []
110
+ break
111
+ end
112
+
113
+ # Only keep expanding names that still collide within the group
114
+ remaining = still_colliding.values.flatten
115
+ end
116
+
117
+ break if remaining.empty?
118
+ end
119
+
120
+ return if remaining.empty?
121
+
122
+ # Last resort: numeric suffixes
123
+ find_collisions(result.slice(*remaining)).each_value do |group|
124
+ group[1..].each_with_index do |name, i|
125
+ suffix = SUPERSCRIPTS[i] || (i + 2).to_s
126
+ result[name] = "#{result[name]}#{suffix}"
127
+ end
128
+ end
129
+ end
130
+
131
+ # Parse pattern into token descriptors: [{type:, length:, maxed:}, ...]
132
+ def parse_tokens(pattern)
133
+ tokens = []
134
+ pattern.scan(NameFormatter::TOKEN_RE) do |match|
135
+ type = match[0]
136
+ len = Regexp.last_match(0).length
137
+ tokens << {type: type, length: len, maxed: len >= 4}
138
+ end
139
+ tokens
140
+ end
141
+
142
+ # Rebuild a pattern string from token descriptors, preserving literals.
143
+ def rebuild_pattern(tokens, original_pattern)
144
+ ti = 0
145
+ original_pattern.gsub(NameFormatter::TOKEN_RE) do
146
+ tok = tokens[ti]
147
+ ti += 1
148
+ tok[:type] * tok[:length]
149
+ end
150
+ end
151
+
152
+ private_class_method :merge_same_person, :find_collisions, :resolve_group,
153
+ :parse_tokens, :rebuild_pattern
154
+ end
155
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railbow
4
+ # Formats person names using a pattern language inspired by date-format strings.
5
+ #
6
+ # Tokens (repeat the letter to control length; 4+ means full word):
7
+ # F / FF / FFF / FFFF+ — first name (1 char, 2 chars, … , full)
8
+ # L / LL / LLL / LLLL+ — last name
9
+ # M / MM / MMM / MMMM+ — middle name(s)
10
+ #
11
+ # Any other characters (spaces, dots, commas) are literals and pass through.
12
+ #
13
+ # Named presets:
14
+ # "initials" → "F L"
15
+ # "short" → "FF L"
16
+ # "first_name" → "FFFF"
17
+ # "last_name" → "LLLL"
18
+ # "full_name" → "FFFF MMMM LLLL"
19
+ # "full_name_short" → "FFFF L."
20
+ module NameFormatter
21
+ PRESETS = {
22
+ "initials" => "F L",
23
+ "short" => "FF L",
24
+ "first_name" => "FFFF",
25
+ "last_name" => "LLLL",
26
+ "full_name" => "FFFF MMMM LLLL",
27
+ "full_name_short" => "FFFF L."
28
+ }.freeze
29
+
30
+ TOKEN_RE = /([FLM])\1*/
31
+ # Sentinel inserted when a token expands to empty, so surrounding
32
+ # literals that only make sense next to a value can be cleaned up.
33
+ EMPTY = "\x00"
34
+
35
+ module_function
36
+
37
+ # Format a name according to a pattern or preset name.
38
+ # Returns "" for nil/empty input.
39
+ def format(name, pattern)
40
+ return "" if name.nil? || name.empty?
41
+
42
+ pattern = PRESETS.fetch(pattern, pattern)
43
+ parts = name.split(/[\s._-]+/)
44
+
45
+ first = parts.first || ""
46
+ last = (parts.size > 1) ? parts.last : ""
47
+ middle = (parts.size > 2) ? parts[1..-2] : []
48
+
49
+ result = pattern.gsub(TOKEN_RE) do |match|
50
+ letter = match[0]
51
+ len = match.length
52
+
53
+ expanded = case letter
54
+ when "F" then truncate(first, len)
55
+ when "L" then truncate(last, len)
56
+ when "M" then middle.map { |m| truncate(m, len) }.join(" ")
57
+ end
58
+
59
+ expanded.empty? ? EMPTY : expanded
60
+ end
61
+
62
+ # Remove sentinels and any adjacent non-letter literals (dots, commas, spaces)
63
+ result.gsub(/[^a-zA-Z\x00]*\x00[^a-zA-Z\x00]*/, " ")
64
+ .gsub(/ +/, " ")
65
+ .strip
66
+ end
67
+
68
+ # Truncate a word to `len` chars (4+ means full), preserving original case
69
+ # for full words, capitalizing otherwise.
70
+ def truncate(word, len)
71
+ return "" if word.nil? || word.empty?
72
+
73
+ if len >= 4 || len >= word.length
74
+ word
75
+ else
76
+ word[0, len].capitalize
77
+ end
78
+ end
79
+
80
+ private_class_method :truncate
81
+ end
82
+ end