Module: SmilyCli::Formatters

Defined in:
lib/smily_cli/formatters.rb

Overview

Rendering of Result records into the output formats the CLI supports. Formatters operate on plain record hashes (string keys) and return a String; the command layer decides where to print it. Table output is human-first (aligned columns, optional color, vertical layout for single records); the rest are pipe-friendly and lossless.

Constant Summary collapse

MAX_COL_WIDTH =

Maximum width of a single table column before truncation.

48
MAX_TABLE_COLUMNS =

Default cap on the number of columns shown in a multi-row table.

8

Class Method Summary collapse

Class Method Details

.column_widths(records, columns) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



168
169
170
171
172
173
174
# File 'lib/smily_cli/formatters.rb', line 168

def column_widths(records, columns)
  columns.map do |col|
    cells = records.map { |r| scalarize(r[col]).length }
    widest = [col.to_s.length, *cells].max
    [widest, MAX_COL_WIDTH].min
  end
end

.csv(records, fields: nil) ⇒ String

Returns CSV with a header row; nested values become compact JSON.

Returns:

  • (String)

    CSV with a header row; nested values become compact JSON



58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/smily_cli/formatters.rb', line 58

def csv(records, fields: nil)
  return "" if records.empty?

  rows = rowify(records)
  columns = fields || union_columns(rows)
  CSV.generate do |out|
    out << columns
    rows.each do |record|
      out << columns.map { |c| scalarize(record[c]) }
    end
  end
end

.deep_stringify(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



214
215
216
217
218
219
220
# File 'lib/smily_cli/formatters.rb', line 214

def deep_stringify(value)
  case value
  when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
  when Array then value.map { |v| deep_stringify(v) }
  else value
  end
end

.default_columns(records) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



153
154
155
156
157
158
# File 'lib/smily_cli/formatters.rb', line 153

def default_columns(records)
  cols = union_columns(records)
  preferred = %w[id]
  ordered = preferred.select { |c| cols.include?(c) } + (cols - preferred)
  ordered.first(MAX_TABLE_COLUMNS)
end

.grid_table(records, fields:, meta: nil) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/smily_cli/formatters.rb', line 111

def grid_table(records, fields:, meta: nil)
  columns = fields || default_columns(records)
  widths = column_widths(records, columns)
  numeric = numeric_columns(records, columns)

  header = columns.map.with_index do |col, i|
    Color.bold(pad(col.to_s, widths[i], right: numeric[col]))
  end.join("  ")
  rule = Color.dim(columns.map.with_index { |_, i| "-" * widths[i] }.join("  "))

  rows = records.map do |record|
    columns.map.with_index do |col, i|
      pad(scalarize(record[col]), widths[i], right: numeric[col])
    end.join("  ")
  end

  [header, rule, *rows, meta].compact.join("\n")
end

.hidden_field_note(records) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



142
143
144
145
146
147
148
149
150
# File 'lib/smily_cli/formatters.rb', line 142

def hidden_field_note(records)
  return nil if records.empty?

  total = union_columns(records).length
  shown = default_columns(records).length
  return nil if total <= shown

  "#{total - shown} more fields — use --fields or -o json"
end

.json(payload) ⇒ String

Returns pretty JSON (object for single, array for collections).

Returns:

  • (String)

    pretty JSON (object for single, array for collections)



43
44
45
# File 'lib/smily_cli/formatters.rb', line 43

def json(payload)
  JSON.pretty_generate(payload.nil? ? [] : payload)
end

.ndjson(records) ⇒ String

Returns one compact JSON object per line.

Returns:

  • (String)

    one compact JSON object per line



53
54
55
# File 'lib/smily_cli/formatters.rb', line 53

def ndjson(records)
  records.map { |r| JSON.generate(r) }.join("\n")
end

.numeric_columns(records, columns) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



177
178
179
180
181
182
# File 'lib/smily_cli/formatters.rb', line 177

def numeric_columns(records, columns)
  columns.each_with_object({}) do |col, acc|
    values = records.map { |r| r[col] }.compact
    acc[col] = !values.empty? && values.all?(Numeric)
  end
end

.pad(text, width, right: false) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



185
186
187
188
# File 'lib/smily_cli/formatters.rb', line 185

def pad(text, width, right: false)
  s = truncate(text.to_s, width)
  right ? s.rjust(width) : s.ljust(width)
end

.render(result, format:, fields: nil) ⇒ String

Render a result in the requested format.

Parameters:

  • result (Result)
  • format (String)
  • fields (Array<String>, nil) (defaults to: nil)

    explicit column/field selection

Returns:

  • (String)


27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/smily_cli/formatters.rb', line 27

def render(result, format:, fields: nil)
  records = result.records
  payload = result.single? ? records.first : records

  case format
  when "json"          then json(payload)
  when "yaml"          then yaml(payload)
  when "ndjson", "jsonl" then ndjson(records)
  when "csv"           then csv(records, fields: fields)
  when "table"         then table(result, fields: fields)
  else
    raise UsageError, "Unknown output format #{format.inspect}."
  end
end

.rowify(records) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Coerce records so table/CSV rendering never assumes a Hash: a non-hash record (a scalar or array an arbitrary MCP tool might return) becomes a single value column.



95
96
97
# File 'lib/smily_cli/formatters.rb', line 95

def rowify(records)
  records.map { |record| record.is_a?(Hash) ? record : { "value" => record } }
end

.scalarize(value, limit: nil) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Render a value as a single-line string suitable for a cell/CSV field. Scalars pass through; Hash/Array become compact JSON.



202
203
204
205
206
207
208
209
210
211
# File 'lib/smily_cli/formatters.rb', line 202

def scalarize(value, limit: nil)
  s = case value
      when nil then ""
      when String then value
      when Hash, Array then JSON.generate(value)
      else value.to_s
      end
  s = s.gsub(/\s+/, " ").strip
  limit ? truncate(s, limit) : s
end

.table(result, fields: nil) ⇒ String

Render a table: a vertical key/value layout for a single record, or an aligned grid for a collection.

Parameters:

  • result (Result)
  • fields (Array<String>, nil) (defaults to: nil)

Returns:

  • (String)


77
78
79
80
81
82
83
84
85
86
# File 'lib/smily_cli/formatters.rb', line 77

def table(result, fields: nil)
  records = rowify(result.records)
  return Color.dim("No records found.") if records.empty?

  if result.single?
    vertical_table(records.first, fields: fields)
  else
    grid_table(records, fields: fields, meta: table_footer(result, records))
  end
end

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



131
132
133
134
135
136
137
138
139
# File 'lib/smily_cli/formatters.rb', line 131

def table_footer(result, records)
  count = records.length
  parts = []
  parts << "#{count} #{count == 1 ? 'record' : 'records'}"
  parts << "#{result.total_pages} pages total" if result.total_pages
  hidden = hidden_field_note(records)
  parts << hidden if hidden
  Color.dim("(#{parts.join(', ')})")
end

.truncate(text, width) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



191
192
193
194
195
196
# File 'lib/smily_cli/formatters.rb', line 191

def truncate(text, width)
  return text if text.length <= width
  return text[0, width] if width <= 1

  "#{text[0, width - 1]}"
end

.union_columns(records) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



161
162
163
164
165
# File 'lib/smily_cli/formatters.rb', line 161

def union_columns(records)
  records.each_with_object([]) do |record, acc|
    record.each_key { |k| acc << k.to_s unless acc.include?(k.to_s) }
  end
end

.vertical_table(record, fields: nil) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



100
101
102
103
104
105
106
107
108
# File 'lib/smily_cli/formatters.rb', line 100

def vertical_table(record, fields: nil)
  columns = fields || record.keys
  width = columns.map { |c| c.to_s.length }.max || 0
  lines = columns.map do |col|
    label = Color.bold(col.to_s.ljust(width))
    "#{label}  #{scalarize(record[col], limit: 200)}"
  end
  lines.join("\n")
end

.yaml(payload) ⇒ String

Returns:

  • (String)


48
49
50
# File 'lib/smily_cli/formatters.rb', line 48

def yaml(payload)
  YAML.dump(deep_stringify(payload.nil? ? [] : payload))
end