Class: Canon::Comparison::DiffNodeBuilder

Inherits:
Object
  • Object
show all
Defined in:
lib/canon/comparison/xml_comparator/diff_node_builder.rb

Overview

Builder for creating enriched DiffNode objects Handles path building, serialization, and attribute extraction

Class Method Summary collapse

Class Method Details

.build(node1:, node2:, diff1:, diff2:, dimension:, **_opts) ⇒ DiffNode?

Build an enriched DiffNode

Parameters:

  • node1 (Object, nil)

    First node

  • node2 (Object, nil)

    Second node

  • diff1 (String)

    Difference type for node1

  • diff2 (String)

    Difference type for node2

  • dimension (Symbol)

    The match dimension causing this difference

Returns:

  • (DiffNode, nil)

    Enriched DiffNode or nil if dimension is nil



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 22

def self.build(node1:, node2:, diff1:, diff2:, dimension:, **_opts)
  # Validate dimension is required
  if dimension.nil?
    raise ArgumentError,
          "dimension required for DiffNode"
  end

  # Build informative reason message
  reason = build_reason(node1, node2, diff1, diff2, dimension)

  # Enrich with path, serialized content, and attributes for Stage 4 rendering
   = (node1, node2)

  Canon::Diff::DiffNode.new(
    node1: node1,
    node2: node2,
    dimension: dimension,
    reason: reason,
    **,
  )
end

.build_attribute_difference_reason(attrs1, attrs2) ⇒ String

Build a clear reason message for attribute presence differences Shows which attributes are only in node1, only in node2, or different values

Parameters:

  • attrs1 (Hash, nil)

    First node’s attributes

  • attrs2 (Hash, nil)

    Second node’s attributes

Returns:

  • (String)

    Clear explanation of the attribute difference



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 164

def self.build_attribute_difference_reason(attrs1, attrs2)
  return "#{attrs1&.keys&.size || 0} vs #{attrs2&.keys&.size || 0} attributes" unless attrs1 && attrs2

  keys1 = attrs1.keys.to_set
  keys2 = attrs2.keys.to_set

  only_in_1 = keys1 - keys2
  only_in_2 = keys2 - keys1
  common = keys1 & keys2

  # Check if values differ for common keys
  different_values = common.reject { |k| attrs1[k] == attrs2[k] }

  parts = []
  parts << "only in first: #{only_in_1.to_a.sort.join(', ')}" if only_in_1.any?
  parts << "only in second: #{only_in_2.to_a.sort.join(', ')}" if only_in_2.any?
  parts << "different values: #{different_values.sort.join(', ')}" if different_values.any?

  if parts.empty?
    "#{keys1.size} vs #{keys2.size} attributes (same names)"
  else
    parts.join("; ")
  end
end

.build_comment_difference_reason(node1, node2) ⇒ Object

Build a Reason line for a :comments diff. Returns nil when neither side carries a comment (caller falls back to default).



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 230

def self.build_comment_difference_reason(node1, node2)
  cm1 = node1 && Canon::Comparison::NodeInspector.comment_node?(node1)
  cm2 = node2 && Canon::Comparison::NodeInspector.comment_node?(node2)

  return nil unless cm1 || cm2

  if cm1 && !cm2
    "Comment present on EXPECTED only: " \
      "<!--#{truncate(comment_text(node1))}-->"
  elsif cm2 && !cm1
    "Comment present on ACTUAL only: " \
      "<!--#{truncate(comment_text(node2))}-->"
  else
    t1 = truncate(comment_text(node1))
    t2 = truncate(comment_text(node2))
    "Comment text differs: <!--#{t1}--> vs <!--#{t2}-->"
  end
end

.build_path(node) ⇒ String?

Build canonical path for a node

Parameters:

  • node (Object)

    Node to build path for

Returns:

  • (String, nil)

    Canonical path with ordinal indices



132
133
134
135
136
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 132

def self.build_path(node)
  return nil if node.nil?

  Canon::Diff::PathBuilder.build(node, format: :document)
end

.build_reason(node1, node2, diff1, diff2, dimension) ⇒ String

Build a human-readable reason for a difference

Parameters:

  • node1 (Object)

    First node

  • node2 (Object)

    Second node

  • diff1 (String)

    Difference type for node1

  • diff2 (String)

    Difference type for node2

  • dimension (Symbol)

    The dimension of the difference

Returns:

  • (String)

    Human-readable reason



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 52

def self.build_reason(node1, node2, diff1, diff2, dimension)
  # For deleted/inserted nodes, include namespace information if available
  if dimension == :text_content && (node1.nil? || node2.nil?)
    node = node1 || node2
    if node.is_a?(Canon::Xml::Node) || node.is_a?(Nokogiri::XML::Node)
      ns = node.namespace_uri
      ns_info = if ns.nil? || ns.empty?
                  ""
                else
                  " (namespace: #{ns})"
                end
      label = Canon::Comparison.code_pair_label(diff1, diff2)
      return "element '#{node.name}'#{ns_info}: #{label}"
    end
  end

  # For attribute presence differences, show what attributes differ
  if dimension == :attribute_presence
    attrs1 = extract_attributes(node1)
    attrs2 = extract_attributes(node2)
    return build_attribute_difference_reason(attrs1, attrs2)
  end

  # For text content differences, show the actual text (truncated if needed)
  if dimension == :text_content
    text1 = extract_text_content(node1)
    text2 = extract_text_content(node2)
    return build_text_difference_reason(text1, text2)
  end

  # For attribute order differences, show the actual attribute names
  if dimension == :attribute_order
    attrs1 = extract_attributes(node1)&.keys || []
    attrs2 = extract_attributes(node2)&.keys || []
    return "Attribute order changed: [#{attrs1.join(', ')}] → [#{attrs2.join(', ')}]"
  end

  # For asymmetric comment nodes (#144), name the side that carries
  # the comment and surface the comment text rather than reusing
  # the generic "element structure mismatch" wording.
  if dimension == :comments
    comment_reason = build_comment_difference_reason(node1, node2)
    return comment_reason if comment_reason
  end

  # Default reason
  if diff1 == Canon::Comparison::MISSING_NODE && diff2 == Canon::Comparison::MISSING_NODE
    "element structure mismatch (children differ)"
  elsif dimension == :element_structure &&
      diff1 == Canon::Comparison::UNEQUAL_ELEMENTS &&
      diff2 == Canon::Comparison::UNEQUAL_ELEMENTS &&
      (node1.is_a?(Canon::Xml::Node) || node1.is_a?(Nokogiri::XML::Node)) &&
      (node2.is_a?(Canon::Xml::Node) || node2.is_a?(Nokogiri::XML::Node)) &&
      node1.name && node2.name && node1.name != node2.name
    "different element name (<#{node1.name}> vs <#{node2.name}>)"
  else
    Canon::Comparison.code_pair_label(diff1, diff2)
  end
end

.build_text_difference_reason(text1, text2) ⇒ String

Build a clear reason message for text content differences Shows the actual text content (truncated if too long)

Parameters:

  • text1 (String, nil)

    First text content

  • text2 (String, nil)

    Second text content

Returns:

  • (String)

    Clear explanation of the text difference



218
219
220
221
222
223
224
225
226
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 218

def self.build_text_difference_reason(text1, text2)
  # Handle nil cases
  return "missing vs '#{truncate(text2)}'" if text1.nil? && text2
  return "'#{truncate(text1)}' vs missing" if text1 && text2.nil?
  return "both missing" if text1.nil? && text2.nil?

  # Both have content - show truncated versions
  "'#{truncate(text1)}' vs '#{truncate(text2)}'"
end

.comment_text(node) ⇒ Object



249
250
251
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 249

def self.comment_text(node)
  Canon::Comparison::NodeInspector.text_content(node).to_s
end

.enrich_metadata(node1, node2) ⇒ Hash

Enrich DiffNode with canonical path, serialized content, and attributes This extracts presentation-ready metadata from nodes for Stage 4 rendering

Parameters:

  • node1 (Object, nil)

    First node

  • node2 (Object, nil)

    Second node

Returns:

  • (Hash)

    Enriched metadata hash



118
119
120
121
122
123
124
125
126
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 118

def self.(node1, node2)
  {
    path: build_path(node1 || node2),
    serialized_before: serialize(node1),
    serialized_after: serialize(node2),
    attributes_before: extract_attributes(node1),
    attributes_after: extract_attributes(node2),
  }
end

.extract_attributes(node) ⇒ Hash?

Extract attributes from a node as a normalized hash

Parameters:

  • node (Object, nil)

    Node to extract attributes from

Returns:

  • (Hash, nil)

    Normalized attributes hash



152
153
154
155
156
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 152

def self.extract_attributes(node)
  return nil if node.nil?

  Canon::Diff::NodeSerializer.extract_attributes(node)
end

.extract_text_content(node) ⇒ String?

Extract text content from a node

Parameters:

  • node (Object, nil)

    Node to extract text from

Returns:

  • (String, nil)

    Text content or nil



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 193

def self.extract_text_content(node)
  return nil if node.nil?

  case node
  when Canon::Xml::Nodes::TextNode
    node.value
  when Canon::Xml::Node
    node.text_content
  when Nokogiri::XML::Node
    node.content.to_s
  when String
    node
  else
    node.to_s
  end
rescue StandardError
  nil
end

.serialize(node) ⇒ String?

Serialize a node to string for display

Parameters:

  • node (Object, nil)

    Node to serialize

Returns:

  • (String, nil)

    Serialized content



142
143
144
145
146
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 142

def self.serialize(node)
  return nil if node.nil?

  Canon::Diff::NodeSerializer.serialize(node)
end

.truncate(text, max_length = 40) ⇒ String

Truncate text for display in reason messages

Parameters:

  • text (String)

    Text to truncate

  • max_length (Integer) (defaults to: 40)

    Maximum length

Returns:

  • (String)

    Truncated text



258
259
260
261
262
263
264
265
# File 'lib/canon/comparison/xml_comparator/diff_node_builder.rb', line 258

def self.truncate(text, max_length = 40)
  return "" if text.nil?

  text = text.to_s
  return text if text.length <= max_length

  "#{text[0...max_length]}..."
end