thundercli 0.2.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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +5 -0
  3. data/exe/thunder +11 -0
  4. data/lib/thunder/application.rb +35 -0
  5. data/lib/thunder/cli/command.rb +19 -0
  6. data/lib/thunder/cli/commands/console.rb +24 -0
  7. data/lib/thunder/cli/commands/db/create.rb +20 -0
  8. data/lib/thunder/cli/commands/db/drop.rb +20 -0
  9. data/lib/thunder/cli/commands/db/migrate.rb +20 -0
  10. data/lib/thunder/cli/commands/db/reset.rb +22 -0
  11. data/lib/thunder/cli/commands/db/setup.rb +21 -0
  12. data/lib/thunder/cli/commands/generate/chunk.rb +23 -0
  13. data/lib/thunder/cli/commands/generate/command.rb +64 -0
  14. data/lib/thunder/cli/commands/generate/event.rb +28 -0
  15. data/lib/thunder/cli/commands/generate/model.rb +39 -0
  16. data/lib/thunder/cli/commands/generate/operation.rb +39 -0
  17. data/lib/thunder/cli/commands/generate/outbox.rb +33 -0
  18. data/lib/thunder/cli/commands/generate/subscriber.rb +33 -0
  19. data/lib/thunder/cli/commands/messaging/consumer.rb +52 -0
  20. data/lib/thunder/cli/commands/messaging/outbox.rb +36 -0
  21. data/lib/thunder/cli/commands/new.rb +44 -0
  22. data/lib/thunder/cli/commands/serve.rb +38 -0
  23. data/lib/thunder/cli/commands/spec.rb +77 -0
  24. data/lib/thunder/cli/commands/version.rb +16 -0
  25. data/lib/thunder/cli.rb +78 -0
  26. data/lib/thunder/console/backtrace.rb +36 -0
  27. data/lib/thunder/console/banner.rb +93 -0
  28. data/lib/thunder/console/completion.rb +68 -0
  29. data/lib/thunder/console/constants.rb +57 -0
  30. data/lib/thunder/console/evaluation.rb +72 -0
  31. data/lib/thunder/console/help.rb +30 -0
  32. data/lib/thunder/console/helpers.rb +16 -0
  33. data/lib/thunder/console/inspector.rb +114 -0
  34. data/lib/thunder/console/pretty.rb +70 -0
  35. data/lib/thunder/console/prompt.rb +52 -0
  36. data/lib/thunder/console/theme.rb +85 -0
  37. data/lib/thunder/console/views/collection.rb +172 -0
  38. data/lib/thunder/console/views/format.rb +114 -0
  39. data/lib/thunder/console/views/layout.rb +72 -0
  40. data/lib/thunder/console/views/model.rb +92 -0
  41. data/lib/thunder/console/views/operation.rb +112 -0
  42. data/lib/thunder/console/views/record.rb +95 -0
  43. data/lib/thunder/console/views.rb +45 -0
  44. data/lib/thunder/console.rb +167 -0
  45. data/lib/thunder/generator.rb +73 -0
  46. data/lib/thunder/templates/chunk/app/common/models/application_model.rb.erb +4 -0
  47. data/lib/thunder/templates/chunk/app/common/operations/external_operation.rb.erb +3 -0
  48. data/lib/thunder/templates/chunk/app/common/operations/internal_operation.rb.erb +3 -0
  49. data/lib/thunder/templates/chunk/app/external/operations/_keep.erb +0 -0
  50. data/lib/thunder/templates/chunk/app/internal/models/_keep.erb +0 -0
  51. data/lib/thunder/templates/chunk/app/internal/operations/_keep.erb +0 -0
  52. data/lib/thunder/templates/chunk/config/chunk.rb.erb +9 -0
  53. data/lib/thunder/templates/chunk/database/migrate/_keep.erb +0 -0
  54. data/lib/thunder/templates/event/event.rb.erb +6 -0
  55. data/lib/thunder/templates/model/migration.rb.erb +17 -0
  56. data/lib/thunder/templates/model/model.rb.erb +6 -0
  57. data/lib/thunder/templates/new/Gemfile.erb +21 -0
  58. data/lib/thunder/templates/new/README.md.erb +8 -0
  59. data/lib/thunder/templates/new/_gitignore.erb +4 -0
  60. data/lib/thunder/templates/new/_ruby-version.erb +2 -0
  61. data/lib/thunder/templates/new/bin/thunder.erb +6 -0
  62. data/lib/thunder/templates/new/chunks/_keep.erb +0 -0
  63. data/lib/thunder/templates/operation/external.rb.erb +24 -0
  64. data/lib/thunder/templates/operation/internal.rb.erb +26 -0
  65. data/lib/thunder/templates/outbox/migration.rb.erb +21 -0
  66. data/lib/thunder/templates/subscriber/subscriber.rb.erb +18 -0
  67. data/lib/thunder/version.rb +5 -0
  68. data/lib/thunder.rb +32 -0
  69. metadata +147 -0
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # WARN: AI MADE
4
+ module Thunder
5
+ class Console
6
+ # Prints hashes and arrays as blocks: one item per line, a trailing comma, the closing bracket on its own line and
7
+ # a │ guide per level. Hashes always open a block; an array stays on one line while it holds only short values
8
+ # (except at the top, where two or more items open a block). Anything else prints as IRB does.
9
+ # {
10
+ # │ a: 1,
11
+ # │ "b" => [2, 3],
12
+ # │ c: {
13
+ # │ │ d: 1,
14
+ # │ │ e: 2,
15
+ # │ }
16
+ # }
17
+ class Pretty
18
+ include Theme
19
+
20
+ INLINE_WIDTH = 60
21
+ GUIDE = '│ '
22
+
23
+ def initialize(colorize:)
24
+ @colorize = colorize
25
+ end
26
+
27
+ def call(value) = render(value, top: true)
28
+
29
+ private
30
+
31
+ def render(value, top: false)
32
+ return block('{', '}', value.map { |key, item| pair(key, item) }) if value.is_a?(Hash) && value.any?
33
+ return block('[', ']', value.map { |item| render(item) }) if value.is_a?(Array) && block_array?(value, top)
34
+ return Views.reference(value) if value.is_a?(ActiveRecord::Base)
35
+
36
+ color_pp(value)
37
+ end
38
+
39
+ def block_array?(array, top)
40
+ return array.size > 1 || array.any? { |item| nested?(item) } if top
41
+
42
+ array.any? { |item| nested?(item) } || !inline?(color_pp(array))
43
+ end
44
+
45
+ def nested?(value) = (value.is_a?(Hash) || value.is_a?(Array)) && value.any?
46
+
47
+ def inline?(text) = !text.include?("\n") && text.gsub(/\e\[[\d;]*m/, '').size <= INLINE_WIDTH
48
+
49
+ def block(open, close, items)
50
+ guide = paint(GUIDE, :faint)
51
+ body = items.map do |item|
52
+ comma = item.end_with?('}', ']') && item.include?("\n") ? '' : ','
53
+ "#{item}#{comma}".gsub(/^/, guide)
54
+ end
55
+ [open, *body, close].join("\n")
56
+ end
57
+
58
+ # Ruby's own key format: a: for a symbol, "a" => for anything else.
59
+ def pair(key, value)
60
+ label = key.is_a?(Symbol) ? "#{key.inspect.delete_prefix(':')}:" : "#{color_pp(key)} =>"
61
+ label = paint(label, :accent) if key.is_a?(Symbol)
62
+ "#{label} #{render(value)}"
63
+ end
64
+
65
+ def color_pp(value) = IRB::ColorPrinter.pp(value, +'', colorize: @colorize).chomp
66
+
67
+ def paint(text, *roles) = @colorize ? Theme.paint(text, *roles) : text
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # WARN: AI MADE
4
+ module Thunder
5
+ class Console
6
+ # Powerline prompt: [⚡ chunk][env] ❯, the arrow red after a failed evaluation. With a Nerd Font the segments end
7
+ # in the separator; without one (see Theme.nerd_font?) they are plain colored blocks.
8
+ class Prompt
9
+ include Theme
10
+
11
+ def initialize(console)
12
+ @console = console
13
+ end
14
+
15
+ # IRB switches to PROMPT_S inside an open string and to PROMPT_C in an unfinished expression, on the first line
16
+ # too, so all three are the full prompt.
17
+ def irb_prompts(failed: false)
18
+ prompt = main(failed:)
19
+ { PROMPT_I: prompt, PROMPT_S: prompt, PROMPT_C: prompt, RETURN: "#{paint('=>', :faint)} %s\n" }
20
+ end
21
+
22
+ # Repaints the prompts of a running IRB context.
23
+ def apply(context, failed:)
24
+ prompt = main(failed:)
25
+ context.prompt_i = prompt
26
+ context.prompt_s = prompt
27
+ context.prompt_c = prompt
28
+ end
29
+
30
+ def main(failed: false)
31
+ name = @console.chunk&.identifier || 'thunder'
32
+ env = @console.env
33
+ return "#{name}(#{env})#{' ✗' if failed} > " unless enabled?
34
+
35
+ "#{segments(name, env)} #{paint('❯', failed ? :fail : :ok)} "
36
+ end
37
+
38
+ private
39
+
40
+ # Reverse video: the segment is the theme color, the text the terminal background.
41
+ def segments(name, env)
42
+ production = env == 'production'
43
+ chunk_segment = "\e[0;7;3#{ACCENT_COLOR}m ⚡ #{name} "
44
+ env_segment = "\e[0;#{'1;' if production}7;3#{env_color(env)}m#{production ? ' PRODUCTION ' : " #{env} "}"
45
+ return "#{chunk_segment}#{env_segment}\e[0m" unless Theme.nerd_font?
46
+
47
+ "#{chunk_segment}\e[0;3#{ACCENT_COLOR};4#{env_color(env)}m#{SEPARATOR}#{env_segment}" \
48
+ "\e[0;3#{env_color(env)}m#{SEPARATOR}\e[0m"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # WARN: AI MADE
4
+ module Thunder
5
+ class Console
6
+ # The 16 ANSI colors (30-37, 90-97), so the terminal theme decides the actual colors. Off when stdout is not a
7
+ # terminal or NO_COLOR is set.
8
+ module Theme
9
+ # Indexes of the ANSI palette: foreground 30 + index, background 40 + index.
10
+ RED = 1
11
+ GREEN = 2
12
+ YELLOW = 3
13
+ BLUE = 4
14
+ MAGENTA = 5
15
+
16
+ ROLES = {
17
+ accent: "3#{BLUE}",
18
+ ok: "3#{GREEN}",
19
+ fail: "3#{RED}",
20
+ warn: "3#{YELLOW}",
21
+ muted: '39',
22
+ faint: '90',
23
+ bold: '1',
24
+ reverse: '7'
25
+ }.freeze
26
+
27
+ # Color of the env segment of the prompt.
28
+ ENV_COLORS = { 'development' => GREEN, 'test' => YELLOW, 'production' => RED }.freeze
29
+ OTHER_ENV_COLOR = MAGENTA
30
+ ACCENT_COLOR = BLUE
31
+ SEPARATOR = ''
32
+
33
+ # A terminal does not report its font. THUNDER_NERD_FONT=1/0 decides; without it, only terminals that ship the
34
+ # Nerd Font symbols themselves (WezTerm, Ghostty, kitty) get the powerline separator.
35
+ NERD_FONT_TERMINALS = %w[WezTerm ghostty].freeze
36
+
37
+ def self.nerd_font?
38
+ return @nerd_font if defined?(@nerd_font)
39
+
40
+ @nerd_font =
41
+ case ENV.fetch('THUNDER_NERD_FONT', nil)
42
+ when '1', 'true' then true
43
+ when '0', 'false' then false
44
+ else nerd_font_terminal?
45
+ end
46
+ end
47
+
48
+ def self.nerd_font_terminal?
49
+ NERD_FONT_TERMINALS.include?(ENV.fetch('TERM_PROGRAM', nil)) ||
50
+ ENV['TERM'] == 'xterm-kitty' || ENV.key?('KITTY_WINDOW_ID')
51
+ end
52
+ private_class_method :nerd_font_terminal?
53
+
54
+ module_function
55
+
56
+ def enabled? = $stdout.tty? && !ENV.key?('NO_COLOR')
57
+
58
+ def paint(text, *roles)
59
+ return text.to_s unless enabled? && roles.any?
60
+
61
+ "\e[#{roles.map { |role| ROLES.fetch(role) }.join(';')}m#{text}\e[0m"
62
+ end
63
+
64
+ def env_color(env) = ENV_COLORS.fetch(env, OTHER_ENV_COLOR)
65
+
66
+ # Label/value lines with the labels in one column; a multi-line value continues under its first line. guide (a
67
+ # role) draws a │ in that color before every line, tying the rows to a block.
68
+ # chunk library
69
+ # operations books.add
70
+ def rows(pairs, indent: 2, guide: nil)
71
+ pairs = pairs.compact
72
+ width = pairs.map { |label, _| label.size }.max.to_i
73
+ prefix = "#{' ' * indent}#{"#{paint('│', guide)} " if guide}"
74
+ margin = "#{prefix}#{' ' * (width + 2)}"
75
+ pairs.map do |label, value|
76
+ "#{prefix}#{paint(label.ljust(width), :faint)} #{value.to_s.gsub("\n", "\n#{margin}")}"
77
+ end
78
+ end
79
+
80
+ def duration(seconds)
81
+ seconds < 1 ? "#{(seconds * 1000).round}ms" : format('%.1fs', seconds)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ # WARN: AI MADE
4
+ module Thunder
5
+ class Console
6
+ module Views
7
+ # A relation, or an array of records of one class, as a table: one record per line, the columns that fit the
8
+ # terminal and rows saying what was left out. Columns go in this order until the width ends: primary key, an
9
+ # identifying column (number, name, title...), the others in schema order, timestamps last. A column with one
10
+ # value in every record moves to the "same" row. Up to 3 records that do not fit are shown transposed, one
11
+ # record per column. Like ActiveRecord's own inspect, a relation loads at most 11 records.
12
+ # => 10 orders relation · 40 columns, 9 shown
13
+ # │ id number status total_cents created_at
14
+ # │ 7c1e0b5a… ORD-2026-000431 paid 15990 2026-10-21 14:19
15
+ # │ same currency BRL · coupon nil
16
+ # │ hidden subtotal_cents, notes, metadata, +19
17
+ class Collection
18
+ include Format
19
+
20
+ MAX_RECORDS = 10
21
+ MAX_ARRAY_RECORDS = 50
22
+ TRANSPOSE_UP_TO = 3
23
+ MAX_ROWS = 20
24
+ IDENTITY = %w[number code name title email slug key].freeze
25
+ GAP = 2
26
+
27
+ def self.records?(value)
28
+ value.is_a?(Array) && value.size > 1 && value.all?(ActiveRecord::Base) && value.map(&:class).uniq.size == 1
29
+ end
30
+
31
+ # A relation is read only up to MAX_RECORDS + 1 rows, so its total is unknown ("10+"); an array is already in
32
+ # memory, so its size is the total and up to MAX_ARRAY_RECORDS are shown.
33
+ def initialize(value)
34
+ if value.is_a?(ActiveRecord::Relation)
35
+ @klass = value.klass
36
+ @source = 'relation'
37
+ @limit = MAX_RECORDS
38
+ @records = read(value)
39
+ @total = @records.size > @limit ? nil : @records.size
40
+ else
41
+ @klass = value.first.class
42
+ @source = 'array'
43
+ @limit = MAX_ARRAY_RECORDS
44
+ @records = value
45
+ @total = value.size
46
+ end
47
+ @more = @records.size > @limit
48
+ @records = @records.first(@limit)
49
+ end
50
+
51
+ def call
52
+ return header([]) if @records.empty?
53
+ return Record.new(@records.first).call if @records.size == 1
54
+
55
+ names = @records.first.attribute_names
56
+ same = constant_columns(names)
57
+ shown, hidden = fit(order(names - same))
58
+ return transposed(names, same) if hidden.any? && @records.size <= TRANSPOSE_UP_TO
59
+
60
+ footer_rows = rows(footer(same, hidden), indent: 3, guide: :accent)
61
+ [header(names, shown + same), *table(shown), *footer_rows].join("\n")
62
+ end
63
+
64
+ private
65
+
66
+ def read(relation)
67
+ return relation.records.first(@limit + 1) if relation.loaded?
68
+
69
+ relation.take([relation.limit_value, @limit + 1].compact.min)
70
+ end
71
+
72
+ # Columns, other than the primary key, with one value in every record.
73
+ def constant_columns(names)
74
+ names.select do |name|
75
+ name != @klass.primary_key && @records.map { |record| record[name] }.uniq.size == 1
76
+ end
77
+ end
78
+
79
+ def header(names, shown = names)
80
+ count = @total || "#{@limit}+"
81
+ noun = key_for(@klass)
82
+ noun = noun.pluralize unless @total == 1
83
+ columns = "#{names.size} #{names.size == 1 ? 'column' : 'columns'}"
84
+ columns += ", #{shown.size} shown" unless names.size == shown.size
85
+ "#{badge("#{count} #{noun}")} #{paint([@source, (columns if names.any?)].compact.join(' · '), :faint)}"
86
+ end
87
+
88
+ def order(names)
89
+ primary = @klass.primary_key
90
+ identity = IDENTITY.find { |name| names.include?(name) }
91
+ first = [primary, identity].compact & names
92
+ stamps = names & TIMESTAMPS
93
+ first + (names - first - stamps) + stamps
94
+ end
95
+
96
+ def fit(names)
97
+ available = screen_width - MARGIN
98
+ used = 0
99
+ shown = names.take_while do |name|
100
+ used += width_of(name) + GAP
101
+ used - GAP <= available
102
+ end
103
+ shown = names.first(1) if shown.empty?
104
+ [shown, names - shown]
105
+ end
106
+
107
+ def width_of(name)
108
+ @widths ||= {}
109
+ @widths[name] ||= [name.size, *@records.map { |record| visible_size(cell(record[name])) }].max
110
+ end
111
+
112
+ def table(names)
113
+ guide = " #{paint('│', :accent)} "
114
+ head = names.map { |name| ljust(paint(name, :faint), width_of(name)) }.join(' ' * GAP).rstrip
115
+ lines = @records.map do |record|
116
+ names.map do |name|
117
+ value = record[name]
118
+ value.is_a?(Numeric) ? rjust(cell(value), width_of(name)) : ljust(cell(value), width_of(name))
119
+ end.join(' ' * GAP).rstrip
120
+ end
121
+ [head, *lines].map { |line| "#{guide}#{line}" }
122
+ end
123
+
124
+ # label_width: the widest label of the rows these go with, so the values stay inside the screen.
125
+ def footer(same, hidden, label_width = 'hidden'.size)
126
+ width = screen_width - MARGIN - label_width - 2
127
+ [
128
+ (['same', same_text(same, width)] if same.any?),
129
+ (['hidden', "#{list(hidden, width - 22)} #{paint('.select(...) to pick', :faint)}"] if hidden.any?),
130
+ (['more', paint(more_hint, :faint)] if @more)
131
+ ]
132
+ end
133
+
134
+ def more_hint
135
+ return "#{@limit} of #{@total} shown; .each or pp for all" if @total
136
+
137
+ "inspect reads #{@limit}; .to_a shows up to #{MAX_ARRAY_RECORDS}, .each goes through all"
138
+ end
139
+
140
+ def same_text(names, width)
141
+ entries = names.map do |name|
142
+ value = @records.first[name]
143
+ "#{name} #{value.nil? ? paint('nil', :faint) : cell(value)}"
144
+ end
145
+ text = entries.join(paint(' · ', :faint))
146
+ visible_size(text) <= width ? text : list(names, width)
147
+ end
148
+
149
+ # One record per column, one attribute per row.
150
+ def transposed(names, same)
151
+ columns = order(names - same)
152
+ primary = @klass.primary_key
153
+ @record_widths = @records.map { |record| [primary, *columns].map { |name| width_in(record, name) }.max }
154
+
155
+ ids = record_line(@records.map { |record| paint(cell(record[primary]), :bold) })
156
+ attributes = (columns - [primary]).map { |name| [name, record_line(@records.map { |r| cell(r[name]) })] }
157
+ label_width = [*columns, 'same'].map(&:size).max
158
+ table = [['', ids], *cap(attributes, MAX_ROWS), *footer(same, [], label_width)]
159
+
160
+ [header(names), *rows(table, indent: 3, guide: :accent)].join("\n")
161
+ end
162
+
163
+ def width_in(record, name) = visible_size(cell(record[name]))
164
+
165
+ # The cells of one attribute, one per record, each in its record's column.
166
+ def record_line(cells)
167
+ cells.each_with_index.map { |text, index| ljust(text, @record_widths[index]) }.join(' ' * GAP).rstrip
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+
5
+ # WARN: AI MADE
6
+ module Thunder
7
+ class Console
8
+ module Views
9
+ # What the views share: the terminal width, the registry or exporter key of a class, and cell and value formatting; Layout
10
+ # adds the sections and aligned columns they are printed in.
11
+ module Format
12
+ include Theme
13
+ include Layout
14
+
15
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/i
16
+ ANSI = /\e\[[\d;]*m/
17
+ # Room taken by IRB's "=> " and the "│ " guide before a view's rows.
18
+ MARGIN = 5
19
+ CELL_WIDTH = 24
20
+ TIMESTAMPS = %w[created_at updated_at].freeze
21
+
22
+ # Columns taken before the view starts, when it is printed inside another table (a Response's payload).
23
+ attr_writer :inset
24
+
25
+ private
26
+
27
+ def screen_width
28
+ width = IO.console&.winsize&.last.to_i
29
+ width = width.positive? ? [width, 60].max : 120
30
+ [width - @inset.to_i, 40].max
31
+ rescue StandardError
32
+ 120
33
+ end
34
+
35
+ # The key the class is registered or exported under in the console's chunk ('book', 'books.add',
36
+ # 'books.show'); its underscored name otherwise.
37
+ def key_for(klass)
38
+ chunk = Console.current&.chunk
39
+ items = [chunk&.registry, chunk&.exporter].compact.flat_map { |source| source.hash.values }
40
+ key = items.lazy.filter_map { |by_key| by_key.key(klass) }.first
41
+ key || klass.name.to_s.demodulize.underscore
42
+ end
43
+
44
+ def visible_size(text) = text.to_s.gsub(ANSI, '').size
45
+
46
+ def ljust(text, width) = "#{text}#{' ' * [width - visible_size(text), 0].max}"
47
+
48
+ def rjust(text, width) = "#{' ' * [width - visible_size(text), 0].max}#{text}"
49
+
50
+ def truncate(text, width)
51
+ text = text.to_s
52
+ text.size > width ? "#{text[0, [width - 1, 1].max]}…" : text
53
+ end
54
+
55
+ def uuid?(value) = value.is_a?(String) && value.match?(UUID)
56
+
57
+ def short_id(value) = uuid?(value) ? "#{value[0, 8]}…" : value.to_s
58
+
59
+ def time?(value) = value.is_a?(Time) || value.is_a?(DateTime) || value.is_a?(ActiveSupport::TimeWithZone)
60
+
61
+ # Times come from the database in UTC; they are shown in Time.zone when it is set, in the system's zone
62
+ # otherwise, so a time without its zone is the local one.
63
+ def local(value) = Time.zone ? value.in_time_zone : value.to_time.getlocal
64
+
65
+ # A value in a table cell: one line, at most CELL_WIDTH wide.
66
+ def cell(value)
67
+ return paint('·', :faint) if value.nil?
68
+ return paint(short_id(value), :faint) if uuid?(value)
69
+ return local(value).strftime('%Y-%m-%d %H:%M') if time?(value)
70
+
71
+ truncate(scalar(value) || value.inspect, CELL_WIDTH)
72
+ end
73
+
74
+ # The text of a date, decimal, string, number or boolean; nil for anything else.
75
+ def scalar(value)
76
+ case value
77
+ when Date then value.iso8601
78
+ when BigDecimal then value.to_s('F')
79
+ when String, Numeric, true, false then value.to_s
80
+ end
81
+ end
82
+
83
+ # A value in a row of its own, cut to width.
84
+ def value_text(value, width)
85
+ return paint('nil', :faint) if value.nil?
86
+ return value if uuid?(value)
87
+ return local(value).strftime('%Y-%m-%d %H:%M:%S') if time?(value)
88
+
89
+ case value
90
+ when Date, BigDecimal then scalar(value)
91
+ when String then long_string(value, width)
92
+ when Hash, Array then collection_text(value, width)
93
+ else truncate(value.inspect, width)
94
+ end
95
+ end
96
+
97
+ def long_string(value, width)
98
+ text = value.inspect
99
+ return text if text.size <= width
100
+
101
+ size = " #{value.size >= 1000 ? format('%.1fk', value.size / 1000.0) : value.size} chars"
102
+ "#{truncate(text, width - size.size)}#{paint(size, :faint)}"
103
+ end
104
+
105
+ def collection_text(value, width)
106
+ text = value.inspect
107
+ return text if text.size <= width
108
+
109
+ value.is_a?(Hash) ? "{…} #{value.size} keys" : "[…] #{value.size} items"
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # WARN: AI MADE
4
+ module Thunder
5
+ class Console
6
+ module Views
7
+ # How the views lay text out: lists cut to a width, badges, capped rows, titled sections and aligned columns.
8
+ # Measures text with visible_size and ljust, and fits it to screen_width, all from Format.
9
+ module Layout
10
+ include Theme
11
+
12
+ SECTION_INDENT = ' '
13
+ ITEM_INDENT = ' '
14
+
15
+ private
16
+
17
+ # Names joined with ", " up to width; the rest becomes "+N".
18
+ def list(names, width)
19
+ shown = []
20
+ names.each_with_index do |name, index|
21
+ rest = names.size - index - 1
22
+ candidate = [*shown, name].join(', ')
23
+ budget = rest.positive? ? width - ", +#{rest}".size : width
24
+ break if candidate.size > budget
25
+
26
+ shown << name
27
+ end
28
+ hidden = names.size - shown.size
29
+ return shown.join(', ') if hidden.zero?
30
+ return paint("+#{hidden}", :faint) if shown.empty?
31
+
32
+ "#{shown.join(', ')}#{paint(", +#{hidden}", :faint)}"
33
+ end
34
+
35
+ def badge(text, role = :accent) = paint(" #{text} ", role, :bold, :reverse)
36
+
37
+ # The first max - 1 rows and a row saying how many were left out, when there are more than max.
38
+ def cap(rows, max, hint = '')
39
+ return rows if rows.size <= max
40
+
41
+ [*rows.first(max - 1), ['', paint("… #{rows.size - (max - 1)} more#{hint}", :faint)]]
42
+ end
43
+
44
+ # A titled section: the title on a line of its own, then its lines indented under it.
45
+ # params 2
46
+ # title String
47
+ # copies Integer optional
48
+ def section(title, lines, count: nil)
49
+ head = paint(title, :accent, :bold)
50
+ head += " #{paint(count, :faint)}" if count
51
+ ["#{SECTION_INDENT}#{head}", *lines.map { |line| "#{ITEM_INDENT}#{line}" }]
52
+ end
53
+
54
+ # Rows of cells with every column aligned; the last cell of a row is not padded.
55
+ def aligned(table)
56
+ widths = []
57
+ table.each do |cells|
58
+ cells.each_with_index { |text, index| widths[index] = [widths[index].to_i, visible_size(text)].max }
59
+ end
60
+ table.map do |cells|
61
+ last = cells.size - 1
62
+ cells.each_with_index.map { |text, index| index == last ? text : ljust(text, widths[index]) }
63
+ .join(' ').rstrip
64
+ end
65
+ end
66
+
67
+ # Width left for an item's text after the section indent.
68
+ def item_width = screen_width - 3 - ITEM_INDENT.size
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # WARN: AI MADE
4
+ module Thunder
5
+ class Console
6
+ module Views
7
+ # A model class as its schema, one section per part, one item per line: the columns with type and flags (pk,
8
+ # → the association of a foreign key, not null, default, array), primary and foreign keys first and timestamps
9
+ # last; then the associations by kind and the enums.
10
+ # => MODEL book Library::Internal::Models::Book · books
11
+ # columns 4
12
+ # id integer pk
13
+ # title string not null
14
+ # created_at datetime not null
15
+ # associations
16
+ # has_many loans
17
+ class Model
18
+ include Format
19
+
20
+ MAX_COLUMNS = 20
21
+ MACROS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
22
+
23
+ def initialize(klass)
24
+ @klass = klass
25
+ end
26
+
27
+ def call
28
+ columns = @klass.columns
29
+ sections = [
30
+ section('columns', column_lines(columns), count: columns.size),
31
+ (section('associations', association_lines) if association_lines.any?),
32
+ (section('enums', enum_lines) if enum_lines.any?)
33
+ ]
34
+ [header, *sections.compact.flatten].join("\n")
35
+ rescue StandardError => e
36
+ [header, *section('schema', [paint("unavailable: #{e.message.lines.first.to_s.strip}", :faint)])].join("\n")
37
+ end
38
+
39
+ private
40
+
41
+ def header
42
+ details = paint("#{@klass.name} · #{@klass.table_name}", :faint)
43
+ "#{badge('MODEL')} #{paint(key_for(@klass), :bold)} #{details}"
44
+ end
45
+
46
+ def column_lines(columns)
47
+ keys, rest = columns.partition { |column| key?(column.name) }
48
+ stamps, rest = rest.partition { |column| TIMESTAMPS.include?(column.name) }
49
+ ordered = keys + rest + stamps
50
+ lines = aligned(ordered.first(MAX_COLUMNS).map { |column| column_cells(column) })
51
+ return lines if ordered.size <= MAX_COLUMNS
52
+
53
+ [*lines, paint("… #{ordered.size - MAX_COLUMNS} more · #{@klass.name.demodulize}.columns for all", :faint)]
54
+ end
55
+
56
+ def column_cells(column) = [column.name, paint(type(column), :faint), *flags(column)]
57
+
58
+ def key?(name) = name == @klass.primary_key || foreign_keys.key?(name)
59
+
60
+ def type(column) = "#{column.type || column.sql_type}#{'[]' if column.respond_to?(:array?) && column.array?}"
61
+
62
+ def flags(column)
63
+ flags = []
64
+ flags << paint('pk', :accent) if column.name == @klass.primary_key
65
+ flags << paint("→ #{foreign_keys[column.name]}", :accent) if foreign_keys.key?(column.name)
66
+ flags << paint('not null', :faint) unless column.null || column.name == @klass.primary_key
67
+ flags << paint("default #{truncate(column.default, 20)}", :faint) unless column.default.nil?
68
+ flags.empty? ? [] : [flags.join(paint(' · ', :faint))]
69
+ end
70
+
71
+ def foreign_keys
72
+ @foreign_keys ||= @klass.reflect_on_all_associations(:belongs_to).to_h do |association|
73
+ [association.foreign_key.to_s, association.name.to_s]
74
+ end
75
+ end
76
+
77
+ def association_lines
78
+ @association_lines ||= aligned(MACROS.filter_map do |macro|
79
+ names = @klass.reflect_on_all_associations(macro).map { |association| association.name.to_s }
80
+ [paint(macro.to_s, :faint), list(names, item_width - macro.size - 2)] if names.any?
81
+ end)
82
+ end
83
+
84
+ def enum_lines
85
+ @enum_lines ||= aligned(enums.map { |name, values| [name, list(values.keys, item_width - name.size - 2)] })
86
+ end
87
+
88
+ def enums = @klass.respond_to?(:defined_enums) ? @klass.defined_enums : {}
89
+ end
90
+ end
91
+ end
92
+ end