diff --git a/lib/rex/text.rb b/lib/rex/text.rb index bade61d..e461066 100644 --- a/lib/rex/text.rb +++ b/lib/rex/text.rb @@ -30,6 +30,7 @@ require 'rex/text/color' require 'rex/text/table' require 'rex/text/wrapped_table' +require 'rex/text/hierarchy_table' module Rex diff --git a/lib/rex/text/hierarchy_table.rb b/lib/rex/text/hierarchy_table.rb new file mode 100644 index 0000000..b2d8430 --- /dev/null +++ b/lib/rex/text/hierarchy_table.rb @@ -0,0 +1,606 @@ +# -*- coding: binary -*- + +module Rex +module Text + +### +# +# Renders rows with parent/child relationships as a table. +# +# Rows can form a hierarchy directly through {#add_row} and {Row#add_child}, or +# they can describe relationships with keys and parent keys. Key-based relationships +# are resolved only when the table is rendered, so rows may be added in any order. +# +# @example Building a relational hierarchy +# table = Rex::Text::HierarchyTable.new( +# columns: ['Id', 'Type', 'Information'], +# header: 'Sessions' +# ) +# +# table.add_row(['2', 'shell', 'via pivot'], key: 2, parent_keys: [1]) +# table.add_row(['1', 'meterpreter', 'DESKTOP-A'], key: 1) +# +# puts table.render(mode: :tree) +# +### +class HierarchyTable + MODE_TREE = :tree + MODE_GROUPED = :grouped + MODES = [MODE_TREE, MODE_GROUPED].freeze + DEFAULT_MODE = MODE_TREE + + GLYPH_SETS = { + ascii: { + last: '\\- ', + mid: '+- ', + carry: '| ', + clear: ' ' + }.freeze, + unicode: { + last: "\u2514\u2500 ", + mid: "\u251c\u2500 ", + carry: "\u2502 ", + clear: ' ' + }.freeze + }.freeze + + DEFAULT_HIERARCHY_STYLE = :ascii + DEFAULT_REFERENCE_SUFFIX = ' (see above)' + TRUNCATION_MARKER = '...' + + # Distinguishes an omitted render override from an explicit value. + RENDER_OPTION_UNSPECIFIED = Object.new.freeze + private_constant :RENDER_OPTION_UNSPECIFIED + + # A transient grouping row used to build a presentation without changing the + # source rows. + RenderNode = Struct.new(:fields, :children) + private_constant :RenderNode + + # A row and the rows nested directly beneath it. + class Row + # @return [Array] The immutable values stored in this row. + attr_reader :fields + + # @return [Object, nil] The identity used to resolve relationships. + attr_reader :key + + # @return [Array] Identities of this row's parents. + attr_reader :parent_keys + + # @param column_count [Integer] The required number of fields. + # @param fields [Array] The values stored in this row. + # @param key [Object, nil] Optional identity used to resolve relationships. + # @param parent_keys [Array] Identities of this row's parents. + def initialize(column_count, fields, key: nil, parent_keys: []) + unless fields.is_a?(Array) && fields.length == column_count + actual = fields.respond_to?(:length) ? fields.length : 0 + raise ArgumentError, "Expected #{column_count} fields, got #{actual}" + end + raise ArgumentError, 'parent_keys must be an Array' unless parent_keys.is_a?(Array) + if key.nil? && !parent_keys.empty? + raise ArgumentError, 'A row with parent keys must have a key' + end + + @column_count = column_count + @fields = fields.map { |field| field.to_s.strip.freeze }.freeze + @key = key + @parent_keys = parent_keys.compact.uniq.freeze + @children = [] + end + + # @return [Boolean] Whether this row participates in key-based relationships. + def relational? + !key.nil? + end + + # Adds a row directly beneath this row. + # + # @param fields [Array] The values stored in the child row. + # @return [Row] The child row. + def add_child(fields) + child = self.class.new(@column_count, fields) + @children << child + child + end + + # @return [Array] The child rows in insertion order. + def children + @children.dup + end + end + + # @return [Array] The supported rendering modes. + def self.modes + MODES.dup + end + + # @return [Array] The supported connector styles. + def self.hierarchy_styles + GLYPH_SETS.keys + end + + # @return [Array] The table's immutable column names. + attr_reader :columns + + # @return [String, nil] The heading printed above the table. + attr_reader :header + + # @return [Integer] The column on which hierarchy connectors are drawn. + attr_reader :hierarchy_column_index + + # @return [Symbol] The rendering mode used by {#to_s}. + attr_reader :mode + + # @return [Symbol] The connector style used to draw the hierarchy. + attr_reader :hierarchy_style + + # @param columns [Array] The table's source column names. + # @param header [String, nil] An optional heading printed above the table. + # @param hierarchy_column [String, Integer] The column on which connectors are drawn. + # @param mode [Symbol] Either `:tree` or `:grouped`. When omitted, non-empty group columns select grouped mode. + # @param hierarchy_style [Symbol] Either `:ascii` or `:unicode`. + # @param max_chars [Hash{String,Integer=>Integer}] Optional character limits by source column. + # @param group_columns [Array] Source columns promoted to hierarchy levels in grouped mode. + # @param reference_suffix [String] Text appended when a relational row is referenced more than once. + def initialize(columns:, header: nil, hierarchy_column: 0, mode: RENDER_OPTION_UNSPECIFIED, hierarchy_style: DEFAULT_HIERARCHY_STYLE, max_chars: {}, group_columns: [], reference_suffix: DEFAULT_REFERENCE_SUFFIX) + unless columns.is_a?(Array) && !columns.empty? + raise ArgumentError, 'Columns must be a non-empty Array' + end + + @columns = columns.map { |column| column.to_s.dup.freeze }.freeze + @header = header.nil? ? nil : header.to_s.dup.freeze + @hierarchy_column_index = resolve_column(hierarchy_column, 'hierarchy column') + @group_column_indices = resolve_group_columns(group_columns).freeze + @mode = mode.equal?(RENDER_OPTION_UNSPECIFIED) ? inferred_mode(@group_column_indices) : resolve_mode(mode) + validate_mode(@mode, @group_column_indices) + @hierarchy_style = resolve_hierarchy_style(hierarchy_style) + @glyphs = GLYPH_SETS.fetch(@hierarchy_style) + @max_chars = resolve_max_chars(max_chars).freeze + @reference_suffix = reference_suffix.to_s.dup.freeze + @rows = [] + @rows_by_key = {} + end + + # Adds a row. Keyed rows have their relationships resolved when rendered; + # unkeyed rows are roots and can still be nested directly with {Row#add_child}. + # Re-adding an identical keyed row returns the previously added row, while a + # conflicting definition for the same key is rejected. + # + # @param fields [Array] The values stored in the row. + # @param key [Object, nil] Optional identity used to resolve relationships. + # @param parent_keys [Array] Identities of this row's parents. + # @return [Row] The added or previously added identical row. + def add_row(fields, key: nil, parent_keys: []) + row = Row.new(columns.length, fields, key: key, parent_keys: parent_keys) + + unless key.nil? + existing_row = @rows_by_key[key] + if existing_row + same_parent_keys = existing_row.parent_keys.length == row.parent_keys.length && (existing_row.parent_keys - row.parent_keys).empty? + return existing_row if existing_row.fields == row.fields && same_parent_keys + + raise ArgumentError, "Duplicate row key #{key.inspect} has conflicting row data" + end + end + + @rows << row + @rows_by_key[key] = row unless key.nil? + row + end + + # @return [Array] The root rows in insertion order. + def rows + @rows.dup + end + + # Renders the hierarchy using the constructor-provided mode. + # + # @return [String] The rendered table. + def to_s + render + end + + # Renders the hierarchy as an aligned table. + # + # An explicit mode overrides the constructor-provided mode for this rendering only. + # + # @param mode [Symbol] Either `:tree` or `:grouped`. + # @return [String] The rendered table. + def render(mode: RENDER_OPTION_UNSPECIFIED) + render_mode = mode.equal?(RENDER_OPTION_UNSPECIFIED) ? @mode : resolve_mode(mode) + validate_mode(render_mode, @group_column_indices) + group_column_indices = render_mode == MODE_GROUPED ? @group_column_indices : [] + visible_column_indices = columns.each_index.reject { |index| group_column_indices.include?(index) } + visible_hierarchy_column_index = visible_column_indices.index(hierarchy_column_index) + display_columns = rendered_columns(group_column_indices, visible_column_indices, visible_hierarchy_column_index) + display_rows = rendered_rows(group_column_indices).map do |row| + visible_column_indices.map { |index| row[index] } + end + widths = column_widths(display_columns, display_rows) + lines = [] + + unless header.nil? + lines << header + lines << ('=' * character_length(header)) + lines << '' + end + + lines << format_row(display_columns, widths) + lines << format_row(display_columns.map { |column| '-' * character_length(column) }, widths) + display_rows.each { |row| lines << format_row(row, widths) } + + lines.join("\n") + "\n" + end + + private + + # Resolves key-based parent relationships into presentation nodes without + # changing source rows. Missing parents promote a row to a root. Shared rows and + # cycles are expanded once and represented by a suffixed reference thereafter. + # + # @return [Array<(Row, RenderNode)>] Root nodes ready for tree rendering. + def relationship_presentation_rows + return @rows if @rows_by_key.empty? + + children_by_key = Hash.new { |hash, key| hash[key] = [] } + + @rows_by_key.each_value do |row| + row.parent_keys.each do |parent_key| + children_by_key[parent_key] << row if @rows_by_key.key?(parent_key) + end + end + + roots = @rows.reject do |row| + row.relational? && row.parent_keys.any? { |parent_key| @rows_by_key.key?(parent_key) } + end + rendered_keys = {} + rendered = roots.map { |row| relationship_node(row, children_by_key, rendered_keys) } + + # A graph made entirely of cycles has no root. Start each still-unrendered + # component at its first-added row so no source row is lost. + @rows_by_key.each_value do |row| + next if rendered_keys.key?(row.key) + + rendered << relationship_node(row, children_by_key, rendered_keys) + end + + rendered + end + + # @param row [Row] The source row to project. + # @param children_by_key [Hash{Object=>Array}] Relational children by key. + # @param rendered_keys [Hash] Keys expanded during this rendering. + # @return [Row, RenderNode] A source row or transient relational node. + def relationship_node(row, children_by_key, rendered_keys) + return row unless row.relational? + + if rendered_keys.key?(row.key) + fields = row.fields.dup + fields[hierarchy_column_index] = fields[hierarchy_column_index] + @reference_suffix + return RenderNode.new(fields, []) + end + + rendered_keys[row.key] = true + projected_children = (row.children + children_by_key[row.key]).map do |child| + relationship_node(child, children_by_key, rendered_keys) + end + RenderNode.new(row.fields, projected_children) + end + + # @param group_column_indices [Array] Group columns for this rendering. + # @param visible_column_indices [Array] Source columns visible in this rendering. + # @param visible_hierarchy_column_index [Integer] The hierarchy column's rendered position. + # @return [Array] Column headings after presentation-only grouping. + def rendered_columns(group_column_indices, visible_column_indices, visible_hierarchy_column_index) + rendered = visible_column_indices.map { |index| columns[index] } + return rendered if group_column_indices.empty? + + grouped_heading = (group_column_indices + [hierarchy_column_index]).map { |index| columns[index] }.join(' / ') + rendered[visible_hierarchy_column_index] = grouped_heading + rendered + end + + # @param group_column_indices [Array] Group columns for this rendering. + # @return [Array<(Row, RenderNode)>] Rows used for this rendering. + def presentation_rows(group_column_indices) + rows = relationship_presentation_rows + return rows if group_column_indices.empty? + + grouped_presentation_rows(rows, 0, group_column_indices) + end + + # @param rows [Array] Source rows sharing the preceding group path. + # @param depth [Integer] The group column currently being projected. + # @param group_column_indices [Array] Group columns for this rendering. + # @param group_values [Array] Values projected by preceding group levels. + # @return [Array] Group nodes in first-seen order. + def grouped_presentation_rows(rows, depth, group_column_indices, group_values = []) + group_column_index = group_column_indices[depth] + + rows.group_by { |row| row.fields[group_column_index] }.map do |value, members| + current_group_values = group_values + [value] + if depth == group_column_indices.length - 1 + final_group_node(value, members, group_column_indices, current_group_values) + else + RenderNode.new( + group_fields(value), + grouped_presentation_rows(members, depth + 1, group_column_indices, current_group_values) + ) + end + end + end + + # @param value [String] The final group value. + # @param rows [Array] Source rows with the same complete group path. + # @param group_column_indices [Array] Group columns for this rendering. + # @param group_values [Array] The complete group path represented by this node. + # @return [RenderNode] The final group node, optionally populated by a matching row. + def final_group_node(value, rows, group_column_indices, group_values) + collapsed = rows.find { |row| row.fields[hierarchy_column_index] == value } + children = rows.flat_map do |row| + if row.equal?(collapsed) + projected_children(row.children, group_column_indices, group_values) + else + [projected_row(row, group_column_indices, group_values)] + end + end + + RenderNode.new( + collapsed ? collapsed.fields : group_fields(value), + children + ) + end + + # Projects a source row while preserving endpoint changes in its descendants. + # + # @param row [Row] The source row. + # @param group_column_indices [Array] Group columns for this rendering. + # @param group_values [Array] The group path containing the row. + # @return [RenderNode] A presentation copy of the source row. + def projected_row(row, group_column_indices, group_values) + RenderNode.new( + row.fields, + projected_children(row.children, group_column_indices, group_values) + ) + end + + # Projects child rows, adding new group levels when their group path differs + # from their parent's path. Children sharing a changed path share its synthetic + # group levels while direct children retain insertion order. + # + # @param rows [Array] Source child rows. + # @param group_column_indices [Array] Group columns for this rendering. + # @param parent_group_values [Array] The parent row's group path. + # @return [Array] Projected child rows and group nodes. + def projected_children(rows, group_column_indices, parent_group_values) + entries = [] + grouped_entry_indices = {} + + rows.each do |row| + group_values = group_column_indices.map { |index| row.fields[index] } + if group_values == parent_group_values + entries << [:direct, [row]] + elsif grouped_entry_indices.key?(group_values) + entries[grouped_entry_indices[group_values]][1] << row + else + grouped_entry_indices[group_values] = entries.length + entries << [:grouped, [row]] + end + end + + entries.flat_map do |type, members| + if type == :direct + members.map { |row| projected_row(row, group_column_indices, parent_group_values) } + else + [changed_group_node(members, group_column_indices, parent_group_values)] + end + end + end + + # Builds only the changed suffix of a child row's group path. + # + # @param rows [Array] Source rows sharing the changed group path. + # @param group_column_indices [Array] Group columns for this rendering. + # @param parent_group_values [Array] The parent row's group path. + # @return [RenderNode] The first synthetic node in the changed path. + def changed_group_node(rows, group_column_indices, parent_group_values) + group_values = group_column_indices.map { |index| rows.first.fields[index] } + first_changed_depth = group_values.each_index.find { |depth| group_values[depth] != parent_group_values[depth] } + last_depth = group_column_indices.length - 1 + node = final_group_node( + group_values[last_depth], + rows, + group_column_indices, + group_values + ) + + (last_depth - 1).downto(first_changed_depth) do |depth| + node = RenderNode.new(group_fields(group_values[depth]), [node]) + end + + node + end + + # @param value [String] The value displayed by a synthetic group row. + # @return [Array] Source-shaped fields with only the hierarchy field populated. + def group_fields(value) + fields = Array.new(columns.length, '') + fields[hierarchy_column_index] = value + fields + end + + # @param group_column_indices [Array] Group columns for this rendering. + # @return [Array>] Rows with truncation and hierarchy prefixes applied. + def rendered_rows(group_column_indices) + rendered = [] + append_rendered_rows(presentation_rows(group_column_indices), rendered, nil) + rendered + end + + # @param rows [Array<(Row, RenderNode)>] Sibling rows at the current depth. + # @param rendered [Array>] The output rows accumulated so far. + # @param carried [String, nil] Connectors carried from ancestor rows, or nil for roots. + # @return [void] + def append_rendered_rows(rows, rendered, carried) + root_level = carried.nil? + + rows.each_with_index do |row, index| + last = index == rows.length - 1 + prefix = root_level ? '' : carried + (last ? @glyphs[:last] : @glyphs[:mid]) + fields = truncate_fields(row.fields) + fields[hierarchy_column_index] = prefix + fields[hierarchy_column_index] + rendered << fields + + next if row.children.empty? + + child_carry = root_level ? '' : carried + (last ? @glyphs[:clear] : @glyphs[:carry]) + append_rendered_rows(row.children, rendered, child_carry) + end + end + + # @param fields [Array] The stored source-shaped fields. + # @return [Array] Truncated copies of the fields. + def truncate_fields(fields) + fields.each_with_index.map do |value, index| + limit = @max_chars[index] + limit.nil? ? value : truncate_value(value, limit) + end + end + + # @param value [String] The value to truncate. + # @param limit [Integer] Its maximum character count. + # @return [String] The value truncated to the requested length. + def truncate_value(value, limit) + return value if character_length(value) <= limit + + marker = limit > TRUNCATION_MARKER.length ? TRUNCATION_MARKER : '' + string = utf8_string(value) || value + string[0, limit - marker.length] + marker + end + + # @param display_columns [Array] The rendered column headings. + # @param rows [Array>] The rows that will be rendered. + # @return [Array] The display width of each column. + def column_widths(display_columns, rows) + display_columns.each_index.map do |index| + ([character_length(display_columns[index])] + rows.map { |row| character_length(row[index]) }).max + end + end + + # @param fields [Array] The row to format. + # @param widths [Array] The display width of each column. + # @return [String] The aligned row without trailing whitespace. + def format_row(fields, widths) + fields.each_with_index.map do |field, index| + index == fields.length - 1 ? field : pad(field, widths[index]) + end.join(' ').rstrip + end + + # @param value [String] The value to pad. + # @param width [Integer] The target display width. + # @return [String] The padded value. + def pad(value, width) + value + (' ' * [width - character_length(value), 0].max) + end + + # @param value [Object] The value to measure. + # @return [Integer] The number of characters in the value. + def character_length(value) + string = utf8_string(value) + string ? string.length : value.to_s.length + end + + # @param value [Object] The value to encode as UTF-8. + # @return [String, nil] A valid UTF-8 string, or nil when conversion is unsafe. + def utf8_string(value) + string = value.to_s.dup.force_encoding('UTF-8') + string.valid_encoding? ? string : nil + end + + # @param column [String, Integer] A column name or index. + # @param description [String] The option being resolved, for error messages. + # @return [Integer] The resolved column index. + def resolve_column(column, description) + if column.is_a?(Integer) + unless column.between?(0, columns.length - 1) + raise ArgumentError, "Invalid #{description} index #{column}" + end + + return column + end + + index = columns.index(column.to_s) + raise ArgumentError, "Invalid #{description} name #{column.inspect}" if index.nil? + + index + end + + # @param group_columns [Array] Requested presentation group columns. + # @return [Array] Resolved source column indexes. + def resolve_group_columns(group_columns) + raise ArgumentError, 'group_columns must be an Array' unless group_columns.is_a?(Array) + + resolved = group_columns.map { |column| resolve_column(column, 'group column') } + if resolved.uniq.length != resolved.length + raise ArgumentError, 'Duplicate group column' + end + if resolved.include?(hierarchy_column_index) + raise ArgumentError, 'The hierarchy column cannot also be a group column' + end + + resolved + end + + # @param group_column_indices [Array] The configured grouping columns. + # @return [Symbol] Grouped mode when columns are configured, otherwise tree mode. + def inferred_mode(group_column_indices) + group_column_indices.empty? ? DEFAULT_MODE : MODE_GROUPED + end + + # @param mode [Symbol, String] The requested rendering mode. + # @return [Symbol] The normalized mode. + def resolve_mode(mode) + mode = mode.to_sym if mode.respond_to?(:to_sym) + return mode if MODES.include?(mode) + + expected = MODES.map { |supported_mode| ":#{supported_mode}" }.join(' or ') + raise ArgumentError, "Unknown hierarchy mode #{mode.inspect}; expected #{expected}" + end + + # @param mode [Symbol] The resolved rendering mode. + # @param group_column_indices [Array] The configured grouping columns. + # @return [void] + def validate_mode(mode, group_column_indices) + return unless mode == MODE_GROUPED && group_column_indices.empty? + + raise ArgumentError, 'Grouped hierarchy mode requires at least one group column' + end + + # @param style [Symbol, String] The requested connector style. + # @return [Symbol] The normalized style. + def resolve_hierarchy_style(style) + style = style.to_sym if style.respond_to?(:to_sym) + return style if GLYPH_SETS.key?(style) + + expected = self.class.hierarchy_styles.map { |supported_style| ":#{supported_style}" }.join(' or ') + raise ArgumentError, "Unknown hierarchy style #{style.inspect}; expected #{expected}" + end + + # @param max_chars [Hash] Maximum character counts keyed by column name or index. + # @return [Hash{Integer=>Integer}] Maximum character counts keyed by column index. + def resolve_max_chars(max_chars) + raise ArgumentError, 'max_chars must be a Hash' unless max_chars.is_a?(Hash) + + max_chars.each_with_object({}) do |(column, limit), resolved| + index = resolve_column(column, 'max_chars column') + limit = Integer(limit) + raise ArgumentError, 'Maximum character counts must be greater than zero' unless limit > 0 + + resolved[index] = limit + end + end +end + +end +end diff --git a/spec/rex/text/hierarchy_table_spec.rb b/spec/rex/text/hierarchy_table_spec.rb new file mode 100644 index 0000000..b5f731c --- /dev/null +++ b/spec/rex/text/hierarchy_table_spec.rb @@ -0,0 +1,427 @@ +# The box drawing connectors in the expectations below need to compare equal to the +# UTF-8 strings the table produces, so this file deliberately is not `coding: binary`. + +require 'spec_helper' +require 'rex/text/hierarchy_table' + +RSpec.describe Rex::Text::HierarchyTable do + let(:options) do + { + columns: %w[Name Value], + mode: :tree, + hierarchy_style: :ascii + } + end + + subject(:table) { described_class.new(**options) } + + describe '.new' do + it 'creates a standalone hierarchy table' do + expect(table).to be_a described_class + expect(table).to_not be_a Rex::Text::Table + end + + it 'exposes the supported modes and connector styles' do + expect(described_class.modes).to eql %i[tree grouped] + expect(described_class.hierarchy_styles).to eql %i[ascii unicode] + end + + it 'does not expose its mode registry for mutation' do + described_class.modes.clear + + expect(described_class.modes).to eql %i[tree grouped] + end + + it 'requires at least one column' do + expect { described_class.new(columns: []) }.to raise_error ArgumentError, /Columns/ + end + + it 'rejects an unknown hierarchy mode' do + options[:mode] = :sparkles + + expect { table }.to raise_error ArgumentError, /Unknown hierarchy mode/ + end + + it 'infers grouped mode from configured group columns for compatibility' do + options.delete(:mode) + options[:group_columns] = ['Value'] + + expect(table.mode).to eql :grouped + end + + it 'requires group columns in grouped mode' do + options[:mode] = :grouped + + expect { table }.to raise_error ArgumentError, /requires at least one group column/ + end + + it 'rejects an unknown hierarchy style' do + options[:hierarchy_style] = :sparkles + + expect { table }.to raise_error ArgumentError, /Unknown hierarchy style/ + end + + it 'rejects an unknown hierarchy column' do + options[:hierarchy_column] = 'Nope' + + expect { table }.to raise_error ArgumentError, /Invalid hierarchy column/ + end + + it 'rejects an out-of-range hierarchy column index' do + options[:hierarchy_column] = 5 + + expect { table }.to raise_error ArgumentError, /Invalid hierarchy column/ + end + + it 'rejects an unknown max_chars column' do + options[:max_chars] = { 'Nope' => 10 } + + expect { table }.to raise_error ArgumentError, /Invalid max_chars column/ + end + end + + describe '#add_row' do + it 'returns a root row containing normalized fields' do + row = table.add_row([' root ', 1]) + + expect(row).to be_a described_class::Row + expect(row.fields).to eql %w[root 1] + expect(table.rows).to eql [row] + end + + it 'keeps root rows in insertion order' do + first = table.add_row(%w[first 1]) + second = table.add_row(%w[second 2]) + + expect(table.rows).to eql [first, second] + end + + it 'does not expose its mutable root collection' do + table.add_row(%w[root 1]) + + table.rows.clear + + expect(table.rows.length).to eql 1 + end + + it 'requires one field per column' do + expect { table.add_row(%w[too many fields]) } + .to raise_error ArgumentError, /Expected 2 fields, got 3/ + end + end + + describe 'Row' do + it 'adds children beneath their parent' do + root = table.add_row(%w[root 1]) + child = root.add_child(%w[child 2]) + grandchild = child.add_child(%w[grandchild 3]) + + expect(root.children).to eql [child] + expect(child.children).to eql [grandchild] + expect(grandchild.children).to be_empty + end + + it 'keeps children in insertion order' do + root = table.add_row(%w[root 1]) + first = root.add_child(%w[first 2]) + second = root.add_child(%w[second 3]) + + expect(root.children).to eql [first, second] + end + + it 'does not expose its mutable child collection' do + root = table.add_row(%w[root 1]) + root.add_child(%w[child 2]) + + root.children.clear + + expect(root.children.length).to eql 1 + end + + it 'keeps stored fields immutable' do + row = table.add_row(%w[root 1]) + + expect(row.fields).to be_frozen + expect(row.fields).to all(be_frozen) + expect { row.fields << 'extra' }.to raise_error RuntimeError + expect { row.fields.first << 'changed' }.to raise_error RuntimeError + end + end + + describe 'relational rows' do + it 'resolves parents regardless of insertion order' do + table.add_row(%w[child 2], key: :child, parent_keys: [:root]) + table.add_row(%w[root 1], key: :root) + + expect(table.to_s).to eql <<~'TABLE' + Name Value + ---- ----- + root 1 + \- child 2 + TABLE + end + + it 'promotes rows with missing parents to roots' do + table.add_row(%w[orphan 1], key: :orphan, parent_keys: [:missing]) + + expect(table.to_s).to include "orphan 1\n" + end + + it 'expands a shared row under the first-added root and marks later references' do + table.add_row(%w[root_b 2], key: :root_b) + table.add_row(%w[root_a 1], key: :root_a) + table.add_row(%w[shared 3], key: :shared, parent_keys: %i[root_a root_b]) + + expect(table.to_s).to eql <<~'TABLE' + Name Value + ---- ----- + root_b 2 + \- shared 3 + root_a 1 + \- shared (see above) 3 + TABLE + end + + it 'starts a rootless cycle at the first-added row' do + table.add_row(%w[b 2], key: :b, parent_keys: [:a]) + table.add_row(%w[a 1], key: :a, parent_keys: [:b]) + + expect(table.to_s).to eql <<~'TABLE' + Name Value + ---- ----- + b 2 + \- a 1 + \- b (see above) 2 + TABLE + end + + it 'preserves insertion order for relational roots and siblings' do + table.add_row(%w[second 2], key: :second) + table.add_row(%w[parent 1], key: :parent) + table.add_row(%w[z_child 3], key: :z_child, parent_keys: [:parent]) + table.add_row(%w[a_child 4], key: :a_child, parent_keys: [:parent]) + + expect(table.to_s).to eql <<~'TABLE' + Name Value + ---- ----- + second 2 + parent 1 + +- z_child 3 + \- a_child 4 + TABLE + end + + it 'returns the existing row when an identical key is added again' do + first = table.add_row(%w[first 1], key: :duplicate, parent_keys: %i[parent_a parent_b]) + + duplicate = table.add_row(%w[first 1], key: :duplicate, parent_keys: %i[parent_b parent_a]) + + expect(duplicate).to equal first + expect(table.rows).to contain_exactly(first) + end + + it 'rejects conflicting duplicate keys' do + table.add_row(%w[first 1], key: :duplicate) + + expect { table.add_row(%w[second 2], key: :duplicate) } + .to raise_error ArgumentError, /Duplicate row key/ + end + + it 'requires keyed rows when parent keys are supplied' do + expect { table.add_row(%w[child 1], parent_keys: [:root]) } + .to raise_error ArgumentError, /must have a key/ + end + end + + describe '#to_s' do + it 'renders roots followed recursively by their children' do + root = table.add_row(%w[root 1]) + child = root.add_child(%w[child 2]) + child.add_child(%w[grandchild 3]) + table.add_row(%w[other 4]) + + expect(table.to_s).to eql <<~'TABLE' + Name Value + ---- ----- + root 1 + \- child 2 + \- grandchild 3 + other 4 + TABLE + end + + it 'draws sibling and continuation connectors' do + root = table.add_row(%w[root 1]) + branch = root.add_child(%w[branch 2]) + branch.add_child(%w[branch_child_a 3]) + branch.add_child(%w[branch_child_b 4]) + root.add_child(%w[last 5]) + + expect(table.to_s).to eql <<~'TABLE' + Name Value + ---- ----- + root 1 + +- branch 2 + | +- branch_child_a 3 + | \- branch_child_b 4 + \- last 5 + TABLE + end + + it 'supports unicode connectors' do + options[:hierarchy_style] = :unicode + root = table.add_row(%w[root 1]) + root.add_child(%w[first 2]) + root.add_child(%w[last 3]) + + expect(table.to_s).to eql <<~TABLE + Name Value + ---- ----- + root 1 + ├─ first 2 + └─ last 3 + TABLE + end + + it 'draws the hierarchy on the selected column' do + options[:hierarchy_column] = 'Value' + root = table.add_row(%w[root 1]) + root.add_child(%w[child 2]) + + expect(table.to_s).to eql <<~'TABLE' + Name Value + ---- ----- + root 1 + child \- 2 + TABLE + end + + it 'renders an optional heading' do + options[:header] = 'Example' + table.add_row(%w[root 1]) + + expect(table.to_s).to eql <<~TABLE + Example + ======= + + Name Value + ---- ----- + root 1 + TABLE + end + + it 'truncates configured columns without changing stored fields' do + options[:columns] = %w[Name Info] + options[:max_chars] = { 'Info' => 10 } + row = table.add_row(['root', 'x' * 20]) + + expect(table.to_s).to eql <<~TABLE + Name Info + ---- ---- + root xxxxxxx... + TABLE + expect(row.fields).to eql ['root', 'x' * 20] + end + + it 'keeps connectors outside the hierarchy column limit' do + options[:max_chars] = { 'Name' => 8 } + root = table.add_row(%w[root info]) + root.add_child(['a_very_long_child_name', 'more']) + + expect(table.to_s).to eql <<~'TABLE' + Name Value + ---- ----- + root info + \- a_ver... more + TABLE + end + + it 'cuts without a marker when the limit cannot hold it' do + options[:max_chars] = { 'Value' => 3 } + table.add_row(%w[root abcdef]) + + expect(table.to_s).to eql <<~TABLE + Name Value + ---- ----- + root abc + TABLE + end + + it 'renders grouped mode as hierarchy levels' do + options[:columns] = %w[Host Port Proto Name] + options[:hierarchy_column] = 'Name' + options[:mode] = :grouped + options[:group_columns] = %w[Host Port Proto] + transport = table.add_row(['192.0.2.1', '53', 'udp', 'udp']) + transport.add_child(['192.0.2.1', '53', 'udp', 'dns']) + + expect(table.to_s).to eql <<~'TABLE' + Host / Port / Proto / Name + -------------------------- + 192.0.2.1 + \- 53 + \- udp + \- dns + TABLE + end + + it 'shows changed group values on descendants instead of hiding them' do + options[:columns] = %w[Host Port Proto Name] + options[:hierarchy_column] = 'Name' + options[:mode] = :grouped + options[:group_columns] = %w[Host Port Proto] + root = table.add_row(['192.0.2.1', '53', 'udp', 'dns']) + root.add_child(['192.0.2.1', '8080', 'tcp', 'http']) + root.add_child(['192.0.2.1', '8080', 'tcp', 'admin']) + + expect(table.to_s).to eql <<~'TABLE' + Host / Port / Proto / Name + -------------------------- + 192.0.2.1 + \- 53 + \- udp + \- dns + \- 8080 + \- tcp + +- http + \- admin + TABLE + end + + it 'renders the same output without mutating rows' do + root = table.add_row(%w[root 1]) + child = root.add_child(%w[child 2]) + fields = [root.fields, child.fields].map(&:dup) + + expect(table.to_s).to eql table.to_s + expect([root.fields, child.fields]).to eql fields + end + end + + describe '#render' do + it 'can override the configured mode for one rendering' do + options[:group_columns] = ['Value'] + root = table.add_row(%w[root 1]) + root.add_child(%w[child 1]) + + expect(table.render(mode: :grouped)).to eql <<~'TABLE' + Value / Name + ------------ + 1 + \- root + \- child + TABLE + expect(table.to_s).to eql <<~'TABLE' + Name Value + ---- ----- + root 1 + \- child 1 + TABLE + end + + it 'rejects grouped mode without configured group columns' do + expect { table.render(mode: :grouped) } + .to raise_error ArgumentError, /requires at least one group column/ + end + end +end