# frozen_string_literal: true module Lutaml module Model # ComparableModel module provides functionality to compare and diff two objects # of the same class, based on their attribute values. module ComparableModel def self.included(base) base.extend(ClassMethods) end # Checks if two objects are equal based on their attributes # @param other [Object] The object to compare with # @return [Boolean] True if objects are equal, false otherwise def eql?(other) other.class == self.class && self.class.attributes.all? do |attr, _| send(attr) == other.send(attr) end end alias == eql? # Generates a hash value for the object # @return [Integer] The hash value def hash ([self.class] + self.class.attributes.map do |attr, _| send(attr).hash end).hash end # Class methods added to the class that includes ComparableModel module ClassMethods # Generates a diff tree between two objects of the same class # @param obj1 [Object] The first object to compare # @param obj2 [Object] The second object to compare # @param options [Hash] Options for diff generation # @return [String] A string representation of the diff tree def diff_tree if @obj1.nil? && @obj2.nil? @root_tree = Tree.new("Both objects are nil") elsif @obj1.nil? @root_tree = Tree.new("First object is nil") format_single_value(@obj2, @root_tree, @obj2.class.to_s) elsif @obj2.nil? @root_tree = Tree.new("Second object is nil") format_single_value(@obj1, @root_tree, @obj1.class.to_s) else traverse_diff do |name, type, value1, value2, is_last| format_attribute_diff(name, type, value1, value2, is_last) end end @root_tree.to_s end # Generates a diff tree and calculates a diff score between two objects of the same class # @param obj1 [Object] The first object to compare # @param obj2 [Object] The second object to compare # @param options [Hash] Options for diff generation # @return [Array] An array containing the normalized diff score and the diff tree def diff_with_score(obj1, obj2, **options) context = DiffContext.new(obj1, obj2, **options) indent = options[:indent] || "" [context.calculate_diff_score, context.diff_tree(indent)] end end class Tree attr_accessor :content, :children, :color def initialize(content, color: nil) @content = content @children = [] @color = color end def add_child(child) @children << child end def to_s(indent = "", is_last = true) prefix = is_last ? "└── " : "├── " result = "#{indent}#{colorize(prefix + @content, @color)}\n" @children.each_with_index do |child, index| is_last_child = index == @children.size - 1 child_indent = indent + (if is_last " " else "#{colorize('│', @color)} " end) result += child.to_s(child_indent, is_last_child) end result end private def colorize(text, color) return text unless color color_codes = { red: 31, green: 32, blue: 34 } "\e[#{color_codes[color]}m#{text}\e[0m" end end # DiffContext handles the comparison between two objects class DiffContext attr_reader :obj1, :obj2, :show_unchanged, :highlight_diff, :use_colors attr_accessor :level, :tree_lines, :root_tree # Initializes a new DiffContext # @param obj1 [Object] The first object to compare # @param obj2 [Object] The second object to compare # @param options [Hash] Options for diff generation def initialize(obj1, obj2, **options) @obj1 = obj1 @obj2 = obj2 @show_unchanged = options.fetch(:show_unchanged, false) @highlight_diff = options.fetch(:highlight_diff, false) @use_colors = options.fetch(:use_colors, true) @level = 0 @tree_lines = [] @root_tree = Tree.new(obj1.class.to_s) end # Generates a diff tree between the two objects # @return [String] A string representation of the diff tree def diff_tree(indent = "") traverse_diff do |name, type, value1, value2, is_last| format_attribute_diff(name, type, value1, value2, is_last) end @root_tree.to_s(indent) end # Calculates the normalized diff score # @return [Float] The normalized diff score def calculate_diff_score total_score = 0 total_attributes = 0 traverse_diff do |_, _, value1, value2, _| total_score += calculate_attribute_score(value1, value2) total_attributes += 1 end total_attributes.positive? ? total_score / total_attributes : 0 end private # Applies color to text if colors are enabled # @param text [String] The text to color # @param color [Symbol] The color to apply # @return [String] The colored text def colorize(text, color) return text unless @use_colors color_codes = { red: 31, green: 32, blue: 34 } "\e[#{color_codes[color]}m#{text}\e[0m" end # Traverses the attributes of the objects and yields each attribute's details # @yield [String, Symbol, Object, Object, Boolean] Yields the name, type, value1, value2, and is_last for each attribute def traverse_diff return yield nil, nil, obj1, obj2, true if obj1.class != obj2.class obj1.class.attributes.each_with_index do |(name, type), index| yield name, type, obj1.send(name), obj2.send(name), index == obj1.class.attributes.length - 1 end end # Generates the prefix for tree lines # @return [String] Prefix for tree lines def tree_prefix @tree_lines.map { |enabled| enabled ? "│ " : " " }.join end # Formats a line in the tree structure # @param is_last [Boolean] Whether this is the last item in the current level # @param content [String] The content to be displayed in the line # @return [String] Formatted tree line def tree_line(is_last, content) "#{tree_prefix}#{is_last ? '└── ' : '├── '}#{content}\n" end # Calculates the diff score for a single attribute # @param value1 [Object] The value of the attribute in the first object # @param value2 [Object] The value of the attribute in the second object # @return [Float] The diff score for the attribute def calculate_attribute_score(value1, value2) if value1 == value2 0 elsif value1.is_a?(Array) && value2.is_a?(Array) calculate_array_score(value1, value2) else value1.instance_of?(value2.class) ? 0.5 : 1 end end # Calculates the diff score for array attributes # @param arr1 [Array] The array from the first object # @param arr2 [Array] The array from the second object # @return [Float] The diff score for the arrays def calculate_array_score(arr1, arr2) max_length = [arr1.length, arr2.length].max return 0.0 if max_length.zero? total_score = max_length.times.sum do |i| if i < arr1.length && i < arr2.length if arr1[i] == arr2[i] 0.0 elsif arr1[i].is_a?(ComparableModel) && arr2[i].is_a?(ComparableModel) DiffContext.new(arr1[i], arr2[i], show_unchanged: @show_unchanged).calculate_diff_score else calculate_attribute_score(arr1[i], arr2[i]) end else 1.0 end end total_score / max_length end # Formats a value for display in the diff output # @param value [Object] The value to format # @return [String] Formatted value def format_value(value) case value when nil "(nil)" when String "(String) \"#{value}\"" when Array if value.empty? "(Array) 0 items" else items = value.map { |item| format_value(item) }.join(", ") "(Array) [#{items}]" end when Hash "(Hash) #{value.keys.length} keys" when ComparableModel "(#{value.class})" else "(#{value.class}) #{value}" end end # Formats the diff output for a single attribute # @param name [String] The name of the attribute # @param type [Symbol] The type of the attribute # @param value1 [Object] The value of the attribute in the first object # @param value2 [Object] The value of the attribute in the second object # @param is_last [Boolean] Whether this is the last attribute in the list # @return [String] Formatted diff output for the attribute def format_attribute_diff(name, type, value1, value2, _is_last) return if value1 == value2 && !@show_unchanged node = Tree.new("#{name} (#{obj1.class.attributes[name].collection? ? 'collection' : type_name(type)}):") @root_tree.add_child(node) if obj1.class.attributes[name].collection? format_collection(value1, value2, node) elsif value1 == value2 format_single_value(value1, node, "") else format_value_tree(value1, value2, node, "", type_name(type)) end end # Formats a collection (array) for diff output # @param array1 [Array] The first array to compare # @param array2 [Array] The second array to compare # @return [String] Formatted diff output for the collection def format_collection(array1, array2, parent_node) array2 = [] if array2.nil? max_length = [array1.length, array2.length].max if max_length.zero? parent_node.content += " (nil)" return end max_length.times do |index| item1 = array1[index] item2 = array2[index] next if item1 == item2 && !@show_unchanged prefix = if item2.nil? "- " else (item1.nil? ? "+ " : "") end color = if item2.nil? :red else (item1.nil? ? :green : nil) end type = item1&.class || item2&.class node = Tree.new("#{prefix}[#{index + 1}] (#{type_name(type)})", color: color) parent_node.add_child(node) if item1.nil? format_diff_item(item2, :green, node) elsif item2.nil? format_diff_item(item1, :red, node) else format_value_tree(item1, item2, node, "") end end end # Formats a removed item in the diff output # @param item [Object] The removed item # @param is_last [Boolean] Whether this is the last item in the current level # @param index [Integer] The index of the removed item # @return [String] Formatted output for the removed item def format_removed_item(item, _parent_node) format_diff_item(item, :red) end # Formats an added item in the diff output # @param item [Object] The added item # @param is_last [Boolean] Whether this is the last item in the current level # @param index [Integer] The index of the added item # @return [String] Formatted output for the added item def format_added_item(item, _parent_node) format_diff_item(item, :green) end # Formats a diff item (added or removed) # @param item [Object] The item to format # @param is_last [Boolean] Whether this is the last item in the current level # @param index [Integer] The index of the item # @param color [Symbol] The color to use for the item # @param prefix [String] The prefix to use for the item (+ or -) # @return [String] Formatted output for the diff item def format_diff_item(item, color, parent_node) if item.is_a?(ComparableModel) return format_comparable_mapper(item, parent_node, color) end parent_node.add_child(Tree.new(format_value(item), color: color)) end # Formats the content of an object for diff output # @param obj [Object] The object to format # @return [String] Formatted content of the object def format_object_content(obj) return format_value(obj) unless obj.is_a?(ComparableModel) obj.class.attributes.map do |attr, _| "#{attr}: #{format_value(obj.send(attr))}" end.join("\n") end # Formats and colors the content for diff output # @param content [String] The content to format and color # @param color [Symbol] The color to apply # @param is_last [Boolean] Whether this is the last item in the current level # @return [String] Formatted and colored content def format_colored_content(content, color, is_last) lines = content.split("\n") lines.map.with_index do |line, index| if index.zero? "" # Skip the first line as it's already been output else prefix = index == lines.length - 1 && is_last ? "└── " : "├── " tree_line(index == lines.length - 1 && is_last, colorize("#{prefix}#{line}", color)) end end.join end # Gets the name of a type # @param type [Class, Object] The type to get the name for # @return [String] The name of the type def type_name(type) if type.is_a?(Class) type.name elsif type.respond_to?(:type) type.type.name else type.class.name end end # Formats the attributes of an object for diff output # @param obj1 [Object] The first object # @param obj2 [Object] The second object # @return [String] Formatted attributes of the objects def format_object_attributes(obj1, obj2, parent_node) obj1.class.attributes.each_key do |attr| value1 = obj1.send(attr) value2 = obj2&.send(attr) attr_type = obj1.class.attributes[attr].collection? ? "collection" : type_name(obj1.class.attributes[attr]) if value1 == value2 if @show_unchanged format_single_value(value1, parent_node, "#{attr} (#{attr_type})") end else format_value_tree(value1, value2, parent_node, attr, attr_type) end end end # Formats the value tree for diff output # @param value1 [Object] The first value # @param value2 [Object] The second value # @param is_last [Boolean] Whether this is the last item in the current level # @param label [String] The label for the value # @param type_info [String, nil] Additional type information # @return [String] Formatted value tree def format_value_tree(value1, value2, parent_node, label, type_info = nil) return if value1 == value2 && !@show_unchanged if value1 == value2 if @show_unchanged return format_single_value( value1, parent_node, "#{label}#{type_info ? " (#{type_info})" : ''}", ) end return if @highlight_diff end case value1 when Array format_collection(value1, value2, parent_node) when Hash format_hash_tree(value1, value2, parent_node) when ComparableModel format_object_attributes(value1, value2, parent_node) else node = Tree.new("#{label}#{type_info ? " (#{type_info})" : ''}:") parent_node.add_child(node) node.add_child(Tree.new("- #{format_value(value1)}", color: :red)) node.add_child(Tree.new("+ #{format_value(value2)}", color: :green)) end end # Formats a single value for diff output # @param value [Object] The value to format # @param is_last [Boolean] Whether this is the last item in the current level # @param label [String] The label for the value # @return [String] Formatted single value def format_single_value(value, parent_node, label, color = nil) node = Tree.new("#{label}#{label.empty? ? '' : ':'}", color: color) parent_node.add_child(node) case value when ComparableModel format_comparable_mapper(value, node, color) when Array if value.empty? node.add_child(Tree.new("(nil)", color: color)) else format_collection(value, value, node) end else node.content += " #{format_value(value)}" end end # Formats a ComparableModel object for diff output # @param obj [ComparableModel] The object to format # @return [String] Formatted ComparableModel object def format_comparable_mapper(obj, parent_node, color = nil) obj.class.attributes.each do |attr_name, attr_type| attr_value = obj.send(attr_name) attr_node = Tree.new("#{attr_name} (#{type_name(attr_type)}):", color: color) parent_node.add_child(attr_node) if attr_value.is_a?(ComparableModel) format_comparable_mapper(attr_value, attr_node, color) else value_node = Tree.new(format_value(attr_value), color: color) attr_node.add_child(value_node) end end end # Formats a hash tree for diff output # @param hash1 [Hash] The first hash to compare # @param hash2 [Hash] The second hash to compare # @return [String] Formatted hash tree def format_hash_tree(hash1, hash2, parent_node) keys = (hash1.keys + hash2.keys).uniq keys.each do |key| value1 = hash1[key] value2 = hash2[key] if value1 == value2 format_single_value(value1, parent_node, key) if @show_unchanged else format_value_tree(value1, value2, parent_node, key) end end end end end # Generates a tree representation of the object # @return [String] A string representation of the object's attribute tree def to_tree output = "#{self.class}\n" self.class.attributes.each_with_index do |(name, type), index| value = send(name) is_last = index == self.class.attributes.length - 1 context = DiffContext.new(nil, nil, show_unchanged: false) formatted = context.format_value(value) output << context.tree_line(is_last, "#{name} (#{type}): #{formatted}") end output end end end