philiprehberger-differ 0.2.8 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b373be1315d237effea59c584220c77fb0bd1deb9ba6e238b698e4919e321de
4
- data.tar.gz: 599f9707141498e91952c4d26fabe62cf1eff4ebd466d485e01403119946f1fa
3
+ metadata.gz: 9e8573d84ddf309e3d702cb9b90d1b407c8963cd84cf62235e6620d586949437
4
+ data.tar.gz: 79ddab362109ba0b8b7958ba77fe9242426a403904082e81b2c8db2f6246a2c2
5
5
  SHA512:
6
- metadata.gz: 7b62b0044c3493e371c44bea5bebd02adf183fc437aec5a8e5b63b92a53cc96a33cca0274329c4bacda38cd44042f1afec6a0de93b4f768615383fd4cc3416f7
7
- data.tar.gz: 5338d2c13503d74f963f3bd7e8ff4f8488adf739c7a57bfe2d735ab00190ad3052893c5d017014511c1a2c96c78f380384e274d4a427dfbc2c00da3b7c110a02
6
+ metadata.gz: 585f857fb63da7b81aad2ded9a2948e4cf27b5d996c6afa76480a0ff41397d3181ab02cad53ab285a8d629f15df669c6372d2d7d220b68d27168dac72f8993be
7
+ data.tar.gz: 20e291749bdbec14d58031833b02c9de65dc5f40bd3675407b478ce8dce20efeeb613ca9e28062b97628fd8a32cf44eb98df5e781964b5f70ac85fe1a09a6618
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-04-10
11
+
12
+ ### Added
13
+ - `Changeset` now includes `Enumerable` for direct iteration over changes
14
+ - `Changeset#count` returning the number of changes
15
+ - `Changeset#paths` returning all changed paths
16
+ - `Changeset#include?(path)` to check if a specific path was changed
17
+ - `Changeset#summary` returning `{ added:, removed:, changed: }` counts
18
+
19
+ ## [0.3.0] - 2026-04-01
20
+
21
+ ### Added
22
+ - `Differ.subset(changeset, path)` for filtering changes by path prefix
23
+ - `Differ.merge(base, theirs, ours)` for three-way merge with conflict detection
24
+ - `Differ.breaking_changes?(changeset)` for detecting removals and type changes
25
+
26
+ ## [0.2.9] - 2026-03-31
27
+
28
+ ### Added
29
+ - Add GitHub issue templates, dependabot config, and PR template
30
+
10
31
  ## [0.2.8] - 2026-03-31
11
32
 
12
33
  ### Changed
data/README.md CHANGED
@@ -116,6 +116,29 @@ new_data = { users: [{ id: 2, name: 'Bobby' }, { id: 1, name: 'Alice' }] }
116
116
  changeset = Philiprehberger::Differ.diff(old_data, new_data, array_key: :id)
117
117
  ```
118
118
 
119
+ ### Subset Filtering
120
+
121
+ Filter a diff to only changes under a specific path:
122
+
123
+ ```ruby
124
+ changeset = Philiprehberger::Differ.diff(old_data, new_data)
125
+ user_changes = Philiprehberger::Differ.subset(changeset, 'user')
126
+ ```
127
+
128
+ ### Three-Way Merge
129
+
130
+ ```ruby
131
+ result = Philiprehberger::Differ.merge(base, theirs, ours)
132
+ result[:merged] # => merged hash
133
+ result[:conflicts] # => [{ path:, theirs:, ours: }]
134
+ ```
135
+
136
+ ### Breaking Change Detection
137
+
138
+ ```ruby
139
+ Philiprehberger::Differ.breaking_changes?(changeset) # => true/false
140
+ ```
141
+
119
142
  ## API
120
143
 
121
144
  ### `Philiprehberger::Differ.diff(old_val, new_val, ignore: [], array_key: nil)`
@@ -131,6 +154,11 @@ Returns a Float between 0.0 (completely different) and 1.0 (identical).
131
154
  | Method | Description |
132
155
  |---|---|
133
156
  | `changed?` | Returns `true` if any differences exist |
157
+ | `count` | Number of changes |
158
+ | `paths` | Array of all changed paths |
159
+ | `include?(path)` | Check if a specific path was changed |
160
+ | `summary` | `{ added:, removed:, changed: }` counts |
161
+ | `each` | Iterate over changes (includes `Enumerable`) |
134
162
  | `changes` | All `Change` objects |
135
163
  | `added` | Changes where `type == :added` |
136
164
  | `removed` | Changes where `type == :removed` |
@@ -141,6 +169,12 @@ Returns a Float between 0.0 (completely different) and 1.0 (identical).
141
169
  | `to_text` | Human-readable text with +/- prefixes |
142
170
  | `to_json_patch` | Array of RFC 6902 JSON Patch operations |
143
171
 
172
+ | Method | Description |
173
+ |---|---|
174
+ | `Differ.subset(changeset, path)` | Filter changes to a specific path prefix |
175
+ | `Differ.merge(base, theirs, ours)` | Three-way merge with conflict detection |
176
+ | `Differ.breaking_changes?(changeset)` | Detect removals and type changes |
177
+
144
178
  ### `Change`
145
179
 
146
180
  | Attribute | Description |
@@ -3,12 +3,52 @@
3
3
  module Philiprehberger
4
4
  module Differ
5
5
  class Changeset
6
+ include Enumerable
7
+
6
8
  attr_reader :changes
7
9
 
8
10
  def initialize(changes = [])
9
11
  @changes = changes
10
12
  end
11
13
 
14
+ # Iterate over each Change.
15
+ #
16
+ # @yield [Change] each change
17
+ # @return [Enumerator] if no block given
18
+ def each(&)
19
+ @changes.each(&)
20
+ end
21
+
22
+ # Number of changes.
23
+ #
24
+ # @return [Integer]
25
+ def count
26
+ @changes.length
27
+ end
28
+
29
+ # Array of all changed paths.
30
+ #
31
+ # @return [Array<String>]
32
+ def paths
33
+ @changes.map(&:path)
34
+ end
35
+
36
+ # Check if a specific path was changed.
37
+ #
38
+ # @param path [String, Symbol] the path to check
39
+ # @return [Boolean]
40
+ def include?(path)
41
+ path_s = path.to_s
42
+ @changes.any? { |c| c.path.to_s == path_s }
43
+ end
44
+
45
+ # Summary counts by change type.
46
+ #
47
+ # @return [Hash{Symbol => Integer}] { added:, removed:, changed: }
48
+ def summary
49
+ { added: added.length, removed: removed.length, changed: changed.length }
50
+ end
51
+
12
52
  def changed?
13
53
  !@changes.empty?
14
54
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module Differ
5
- VERSION = '0.2.8'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
@@ -19,5 +19,96 @@ module Philiprehberger
19
19
  def self.similarity(old_val, new_val, ignore: [], array_key: nil)
20
20
  Similarity.call(old_val, new_val, ignore: ignore, array_key: array_key)
21
21
  end
22
+
23
+ # Filter changeset to only changes under a specific path prefix
24
+ #
25
+ # @param changeset [Changeset] the changeset to filter
26
+ # @param path [String] the path prefix to filter by
27
+ # @return [Changeset] new changeset with only matching changes
28
+ def self.subset(changeset, path)
29
+ prefix = path.to_s
30
+ filtered = changeset.changes.select do |change|
31
+ change.path.to_s == prefix || change.path.to_s.start_with?("#{prefix}.")
32
+ end
33
+ Changeset.new(filtered)
34
+ end
35
+
36
+ # Perform a three-way merge with conflict detection
37
+ #
38
+ # @param base [Hash] the common ancestor
39
+ # @param theirs [Hash] their changes
40
+ # @param ours [Hash] our changes
41
+ # @return [Hash] { merged: Hash, conflicts: Array }
42
+ def self.merge(base, theirs, ours)
43
+ their_changes = Comparator.call(base, theirs)
44
+ our_changes = Comparator.call(base, ours)
45
+
46
+ conflicts = detect_conflicts(their_changes, our_changes)
47
+ conflict_paths = conflicts.map { |c| c[:path] }
48
+
49
+ merged = deep_dup(base)
50
+ (their_changes + our_changes).each do |change|
51
+ next if conflict_paths.include?(change.path)
52
+
53
+ apply_merge_change(merged, change)
54
+ end
55
+
56
+ { merged: merged, conflicts: conflicts }
57
+ end
58
+
59
+ # Detect if a changeset contains breaking changes (removals or type changes)
60
+ #
61
+ # @param changeset [Changeset] the changeset to check
62
+ # @return [Boolean] true if breaking changes are detected
63
+ def self.breaking_changes?(changeset)
64
+ changeset.changes.any? do |change|
65
+ change.type == :removed ||
66
+ (change.type == :changed && change.old_value.class != change.new_value.class)
67
+ end
68
+ end
69
+
70
+ def self.detect_conflicts(their_changes, our_changes)
71
+ their_paths = their_changes.to_h { |c| [c.path, c] }
72
+ our_paths = our_changes.to_h { |c| [c.path, c] }
73
+
74
+ conflicts = []
75
+ their_paths.each do |path, their_change|
76
+ our_change = our_paths[path]
77
+ next unless our_change
78
+
79
+ if their_change.new_value != our_change.new_value
80
+ conflicts << { path: path, theirs: their_change.new_value, ours: our_change.new_value }
81
+ end
82
+ end
83
+ conflicts
84
+ end
85
+ private_class_method :detect_conflicts
86
+
87
+ def self.apply_merge_change(hash, change)
88
+ keys = change.path.to_s.split('.')
89
+ target = hash
90
+ keys[0..-2].each do |k|
91
+ k = k.to_sym if target.is_a?(Hash) && target.key?(k.to_sym)
92
+ target = target[k]
93
+ end
94
+
95
+ key = keys.last
96
+ key = key.to_sym if target.is_a?(Hash) && (target.key?(key.to_sym) || target.keys.any?(Symbol))
97
+
98
+ case change.type
99
+ when :added, :changed then target[key] = change.new_value
100
+ when :removed then target.delete(key)
101
+ end
102
+ end
103
+ private_class_method :apply_merge_change
104
+
105
+ def self.deep_dup(obj)
106
+ case obj
107
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
108
+ when Array then obj.map { |v| deep_dup(v) }
109
+ else obj
110
+ end
111
+ end
112
+ private_class_method :deep_dup
22
113
  end
23
114
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-differ
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.8
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-31 00:00:00.000000000 Z
11
+ date: 2026-04-10 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A Ruby library for deep structural diffing of hashes, arrays, and nested
14
14
  objects with apply/revert, JSON Patch output, similarity scoring, and configurable
@@ -29,11 +29,11 @@ files:
29
29
  - lib/philiprehberger/differ/formatters.rb
30
30
  - lib/philiprehberger/differ/similarity.rb
31
31
  - lib/philiprehberger/differ/version.rb
32
- homepage: https://github.com/philiprehberger/rb-differ
32
+ homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-differ
33
33
  licenses:
34
34
  - MIT
35
35
  metadata:
36
- homepage_uri: https://github.com/philiprehberger/rb-differ
36
+ homepage_uri: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-differ
37
37
  source_code_uri: https://github.com/philiprehberger/rb-differ
38
38
  changelog_uri: https://github.com/philiprehberger/rb-differ/blob/main/CHANGELOG.md
39
39
  bug_tracker_uri: https://github.com/philiprehberger/rb-differ/issues