philiprehberger-env_diff 0.1.7 → 0.3.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: 4594951e7754c86bb5d2793b74a3ab831e3de57174aafbad7478c2848cd9d3ae
4
- data.tar.gz: e9540341ad4400e0a03f825693c21ad9f057ef20cff52e02082cd7f26fb6d787
3
+ metadata.gz: cb5b721f4c0ca4625cc7065f8e443aeaeb069394759e21829141bc2e551180c4
4
+ data.tar.gz: 44866b77b4a8adeab0ac1550d1a53b995875cc444fc1fd67a7deb32bd1216035
5
5
  SHA512:
6
- metadata.gz: 5735362de38190a82ce601a78cb3dfddfdc51a9bddd74ef7f67f432acf9a275e82e2c6601d32534e1d5f6aae56911a08cb83ab7eb567e52bb589b9977be60433
7
- data.tar.gz: 1bd5999f51acb2ea3930aa3d0ae7ec62884915c8715d1ea4bf5f78c430eb29314a705215fa062d8236f28a798734b92e13e6346419eb1ecc66a1c52a9019b15c
6
+ metadata.gz: 8e35a278641cbd4e7adaf9fe571e096a9ebd3a2521c56aed09750025e04265ddc743a8551d0efd94c5fb5b190b789fca7a9e995aad0deadef4dbffe973d5d04a
7
+ data.tar.gz: 606bed2477c6205c621a5e4e728eeb6f22d8dc3e6f671ac66d7d3077a1948aed414cda0570207b5b5019a32a94e14cb40b4aed48c4b940fd0f5f4f0b02c03e69
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-04-14
11
+
12
+ ### Added
13
+ - `EnvDiff.validate(target, required:)` — check that all required keys exist in a target hash or .env file
14
+ - `case_sensitive:` keyword on `compare`, `from_hash`, `from_env_file`, and `from_system` — normalize keys to uppercase when false
15
+ - `EnvDiff.to_markdown(diff)` — format a diff result as a Markdown table string
16
+ - `EnvDiff.to_html(diff)` — format a diff result as an HTML table string
17
+
18
+ ## [0.2.0] - 2026-04-03
19
+
20
+ ### Added
21
+ - `Diff#summary(mask:)` — mask values for sensitive keys using string or regex patterns
22
+ - `Diff#to_h` — structured hash output with added, removed, changed, and unchanged categories
23
+ - `Diff#to_json` — JSON serialization of the structured hash
24
+ - `Diff#filter(pattern:)` — return a new Diff containing only keys matching a regex pattern
25
+ - `Diff#stats` — returns counts of added, removed, changed, unchanged, and total keys
26
+ - `EnvDiff.from_system(target)` — compare current ENV against a target hash or .env file path
27
+
28
+ ## [0.1.8] - 2026-03-31
29
+
30
+ ### Added
31
+ - Add GitHub issue templates, dependabot config, and PR template
32
+
10
33
  ## [0.1.7] - 2026-03-31
11
34
 
12
35
  ### Changed
data/README.md CHANGED
@@ -46,6 +46,100 @@ puts diff.summary
46
46
  # ~ DATABASE_URL: "postgres://localhost/dev" -> "postgres://prod-host/app"
47
47
  ```
48
48
 
49
+ ### Masked summary
50
+
51
+ Hide sensitive values in the summary output using exact strings or regex patterns:
52
+
53
+ ```ruby
54
+ puts diff.summary(mask: ["SECRET", /PASSWORD|TOKEN/])
55
+ # + NEW_KEY
56
+ # - OLD_KEY
57
+ # ~ SECRET: *** -> ***
58
+ ```
59
+
60
+ ### Structured output
61
+
62
+ ```ruby
63
+ diff.to_h
64
+ # => { added: { "NEW_KEY" => "added" }, removed: { "OLD_KEY" => "remove_me" },
65
+ # changed: { "DATABASE_URL" => { source: "postgres://localhost/dev", target: "postgres://prod-host/app" } },
66
+ # unchanged: { "SECRET" => "abc" } }
67
+
68
+ diff.to_json # => JSON string of the structured hash
69
+ ```
70
+
71
+ ### Filter by pattern
72
+
73
+ ```ruby
74
+ db_diff = diff.filter(pattern: /^DATABASE/)
75
+ db_diff.changed.keys # => ["DATABASE_URL"]
76
+ ```
77
+
78
+ ### Stats
79
+
80
+ ```ruby
81
+ diff.stats
82
+ # => { added: 1, removed: 1, changed: 1, unchanged: 1, total: 4 }
83
+ ```
84
+
85
+ ### Validation
86
+
87
+ Check that all required keys exist in a target hash or `.env` file:
88
+
89
+ ```ruby
90
+ target = { 'DATABASE_URL' => 'postgres://localhost', 'PORT' => '3000' }
91
+ result = Philiprehberger::EnvDiff.validate(target, required: %w[DATABASE_URL SECRET PORT])
92
+ result[:valid] # => false
93
+ result[:missing] # => ["SECRET"]
94
+
95
+ # Also works with .env file paths
96
+ result = Philiprehberger::EnvDiff.validate(".env.production", required: %w[DATABASE_URL SECRET])
97
+ ```
98
+
99
+ ### Case-Insensitive Comparison
100
+
101
+ Normalize keys to uppercase before comparing by passing `case_sensitive: false`:
102
+
103
+ ```ruby
104
+ diff = Philiprehberger::EnvDiff.compare(
105
+ { "db_host" => "localhost" },
106
+ { "DB_HOST" => "localhost" },
107
+ case_sensitive: false
108
+ )
109
+ diff.changed? # => false
110
+ diff.unchanged # => ["DB_HOST"]
111
+ ```
112
+
113
+ ### Export Formats
114
+
115
+ Format a diff result as a Markdown or HTML table:
116
+
117
+ ```ruby
118
+ diff = Philiprehberger::EnvDiff.compare(source, target)
119
+
120
+ puts Philiprehberger::EnvDiff.to_markdown(diff)
121
+ # | Key | Status | Source | Target |
122
+ # | --- | ------ | ------ | ------ |
123
+ # | NEW_KEY | added | | added |
124
+ # | OLD_KEY | removed | remove_me | |
125
+ # | DATABASE_URL | changed | postgres://localhost/dev | postgres://prod-host/app |
126
+ # | SECRET | unchanged | abc | abc |
127
+
128
+ puts Philiprehberger::EnvDiff.to_html(diff)
129
+ # <table>
130
+ # <tr><th>Key</th><th>Status</th><th>Source</th><th>Target</th></tr>
131
+ # <tr><td>NEW_KEY</td><td>added</td><td></td><td>added</td></tr>
132
+ # ...
133
+ # </table>
134
+ ```
135
+
136
+ ### Compare against system ENV
137
+
138
+ ```ruby
139
+ diff = Philiprehberger::EnvDiff.from_system({ "APP_ENV" => "production" })
140
+ diff = Philiprehberger::EnvDiff.from_system(".env.production")
141
+ ```
142
+
49
143
  ### Compare .env files
50
144
 
51
145
  ```ruby
@@ -69,15 +163,23 @@ ENV
69
163
 
70
164
  | Method / Class | Description |
71
165
  |----------------|-------------|
72
- | `EnvDiff.compare(source, target)` | Compare two hashes and return a `Diff` |
73
- | `EnvDiff.from_hash(hash_a, hash_b)` | Alias for `compare` |
74
- | `EnvDiff.from_env_file(path_a, path_b)` | Parse two `.env` files and compare them |
166
+ | `EnvDiff.compare(source, target, case_sensitive: true)` | Compare two hashes and return a `Diff` |
167
+ | `EnvDiff.from_hash(hash_a, hash_b, case_sensitive: true)` | Alias for `compare` |
168
+ | `EnvDiff.from_env_file(path_a, path_b, case_sensitive: true)` | Parse two `.env` files and compare them |
169
+ | `EnvDiff.from_system(target, case_sensitive: true)` | Compare current `ENV` against a target hash or `.env` file path |
170
+ | `EnvDiff.validate(target, required:)` | Check that all required keys exist in target; returns `{ valid:, missing: }` |
171
+ | `EnvDiff.to_markdown(diff)` | Format a diff result as a Markdown table string |
172
+ | `EnvDiff.to_html(diff)` | Format a diff result as an HTML table string |
75
173
  | `Diff#added` | Array of keys in target but not source |
76
174
  | `Diff#removed` | Array of keys in source but not target |
77
175
  | `Diff#changed` | Hash of keys with different values |
78
176
  | `Diff#unchanged` | Array of keys with identical values |
79
177
  | `Diff#changed?` | `true` if any differences exist |
80
- | `Diff#summary` | Human-readable multiline diff string |
178
+ | `Diff#summary(mask: [])` | Human-readable multiline diff string with optional value masking |
179
+ | `Diff#to_h` | Structured hash with `:added`, `:removed`, `:changed`, `:unchanged` |
180
+ | `Diff#to_json` | JSON serialization of the structured hash |
181
+ | `Diff#filter(pattern:)` | New `Diff` containing only keys matching the regex pattern |
182
+ | `Diff#stats` | Hash of counts: `:added`, `:removed`, `:changed`, `:unchanged`, `:total` |
81
183
  | `Parser.parse(content)` | Parse `.env` string into a hash |
82
184
  | `Parser.parse_file(path:)` | Read and parse a `.env` file |
83
185
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Philiprehberger
4
6
  module EnvDiff
5
7
  # Represents the result of comparing two sets of environment variables.
@@ -21,6 +23,8 @@ module Philiprehberger
21
23
  # @param source [Hash] the baseline environment hash
22
24
  # @param target [Hash] the environment hash to compare against
23
25
  def initialize(source, target)
26
+ @source = source
27
+ @target = target
24
28
  @added = (target.keys - source.keys).sort
25
29
  @removed = (source.keys - target.keys).sort
26
30
  @changed = build_changed(source, target)
@@ -36,15 +40,58 @@ module Philiprehberger
36
40
 
37
41
  # Human-readable multiline summary of all differences.
38
42
  #
43
+ # @param mask [Array<String, Regexp>] patterns for keys whose values should be masked
39
44
  # @return [String] formatted summary
40
- def summary
45
+ def summary(mask: [])
41
46
  lines = []
42
- append_added(lines)
43
- append_removed(lines)
44
- append_changed(lines)
47
+ append_added(lines, mask)
48
+ append_removed(lines, mask)
49
+ append_changed(lines, mask)
45
50
  lines.empty? ? 'No differences found.' : lines.join("\n")
46
51
  end
47
52
 
53
+ # Structured hash representation of the diff.
54
+ #
55
+ # @return [Hash] with :added, :removed, :changed, :unchanged keys
56
+ def to_h
57
+ {
58
+ added: @added.to_h { |k| [k, @target[k]] },
59
+ removed: @removed.to_h { |k| [k, @source[k]] },
60
+ changed: @changed.transform_values { |v| { source: v[:source], target: v[:target] } },
61
+ unchanged: @unchanged.to_h { |k| [k, @source[k]] }
62
+ }
63
+ end
64
+
65
+ # JSON serialization of the structured hash.
66
+ #
67
+ # @return [String] JSON string
68
+ def to_json(*_args)
69
+ JSON.generate(to_h)
70
+ end
71
+
72
+ # Return a new Diff containing only keys matching the given pattern.
73
+ #
74
+ # @param pattern [Regexp] regex pattern to match keys against
75
+ # @return [Diff] filtered diff
76
+ def filter(pattern:)
77
+ filtered_source = @source.select { |k, _| k.match?(pattern) }
78
+ filtered_target = @target.select { |k, _| k.match?(pattern) }
79
+ Diff.new(filtered_source, filtered_target)
80
+ end
81
+
82
+ # Statistics about the diff.
83
+ #
84
+ # @return [Hash] counts of added, removed, changed, unchanged, and total keys
85
+ def stats
86
+ {
87
+ added: @added.length,
88
+ removed: @removed.length,
89
+ changed: @changed.length,
90
+ unchanged: @unchanged.length,
91
+ total: @added.length + @removed.length + @changed.length + @unchanged.length
92
+ }
93
+ end
94
+
48
95
  private
49
96
 
50
97
  def build_changed(source, target)
@@ -61,17 +108,43 @@ module Philiprehberger
61
108
  common.select { |key| source[key] == target[key] }.sort
62
109
  end
63
110
 
64
- def append_added(lines)
65
- @added.each { |key| lines << "+ #{key}" }
111
+ def masked?(key, mask)
112
+ mask.any? do |pattern|
113
+ pattern.is_a?(Regexp) ? key.match?(pattern) : key == pattern
114
+ end
115
+ end
116
+
117
+ def mask_value(_value, key, mask)
118
+ masked?(key, mask) ? '***' : yield
66
119
  end
67
120
 
68
- def append_removed(lines)
69
- @removed.each { |key| lines << "- #{key}" }
121
+ def append_added(lines, mask)
122
+ @added.each do |key|
123
+ lines << if masked?(key, mask)
124
+ "+ #{key}=***"
125
+ else
126
+ "+ #{key}"
127
+ end
128
+ end
129
+ end
130
+
131
+ def append_removed(lines, mask)
132
+ @removed.each do |key|
133
+ lines << if masked?(key, mask)
134
+ "- #{key}=***"
135
+ else
136
+ "- #{key}"
137
+ end
138
+ end
70
139
  end
71
140
 
72
- def append_changed(lines)
141
+ def append_changed(lines, mask)
73
142
  @changed.each do |key, vals|
74
- lines << "~ #{key}: #{vals[:source].inspect} -> #{vals[:target].inspect}"
143
+ lines << if masked?(key, mask)
144
+ "~ #{key}: *** -> ***"
145
+ else
146
+ "~ #{key}: #{vals[:source].inspect} -> #{vals[:target].inspect}"
147
+ end
75
148
  end
76
149
  end
77
150
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module EnvDiff
5
- VERSION = '0.1.7'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
@@ -12,27 +12,115 @@ module Philiprehberger
12
12
  #
13
13
  # @param source [Hash] the baseline environment hash
14
14
  # @param target [Hash] the environment hash to compare against
15
+ # @param case_sensitive [Boolean] whether key comparison is case-sensitive (default: true)
15
16
  # @return [Diff] the computed differences
16
- def self.compare(source, target)
17
- Diff.new(source, target)
17
+ def self.compare(source, target, case_sensitive: true)
18
+ if case_sensitive
19
+ Diff.new(source, target)
20
+ else
21
+ normalized_source = source.transform_keys(&:upcase)
22
+ normalized_target = target.transform_keys(&:upcase)
23
+ Diff.new(normalized_source, normalized_target)
24
+ end
25
+ end
26
+
27
+ # Validate that all required keys exist in target.
28
+ #
29
+ # @param target [Hash, String] a hash of target variables or a path to a .env file
30
+ # @param required [Array<String>] list of required key names
31
+ # @return [Hash] with :valid (Boolean) and :missing (Array<String>)
32
+ def self.validate(target, required:)
33
+ target_hash = target.is_a?(String) ? Parser.parse_file(path: target) : target
34
+ missing = required.reject { |key| target_hash.key?(key) }
35
+ { valid: missing.empty?, missing: missing }
36
+ end
37
+
38
+ # Format a diff result as a Markdown table string.
39
+ #
40
+ # @param diff [Diff] the diff to format
41
+ # @return [String] Markdown table
42
+ def self.to_markdown(diff)
43
+ lines = []
44
+ lines << '| Key | Status | Source | Target |'
45
+ lines << '| --- | ------ | ------ | ------ |'
46
+
47
+ diff.added.each do |key|
48
+ lines << "| #{key} | added | | #{diff.to_h[:added][key]} |"
49
+ end
50
+
51
+ diff.removed.each do |key|
52
+ lines << "| #{key} | removed | #{diff.to_h[:removed][key]} | |"
53
+ end
54
+
55
+ diff.changed.each do |key, vals|
56
+ lines << "| #{key} | changed | #{vals[:source]} | #{vals[:target]} |"
57
+ end
58
+
59
+ diff.unchanged.each do |key|
60
+ lines << "| #{key} | unchanged | #{diff.to_h[:unchanged][key]} | #{diff.to_h[:unchanged][key]} |"
61
+ end
62
+
63
+ lines.join("\n")
64
+ end
65
+
66
+ # Format a diff result as an HTML table string.
67
+ #
68
+ # @param diff [Diff] the diff to format
69
+ # @return [String] HTML table
70
+ def self.to_html(diff)
71
+ rows = diff.added.map do |key|
72
+ " <tr><td>#{key}</td><td>added</td><td></td><td>#{diff.to_h[:added][key]}</td></tr>"
73
+ end
74
+
75
+ diff.removed.each do |key|
76
+ rows << " <tr><td>#{key}</td><td>removed</td><td>#{diff.to_h[:removed][key]}</td><td></td></tr>"
77
+ end
78
+
79
+ diff.changed.each do |key, vals|
80
+ rows << " <tr><td>#{key}</td><td>changed</td><td>#{vals[:source]}</td><td>#{vals[:target]}</td></tr>"
81
+ end
82
+
83
+ diff.unchanged.each do |key|
84
+ val = diff.to_h[:unchanged][key]
85
+ rows << " <tr><td>#{key}</td><td>unchanged</td><td>#{val}</td><td>#{val}</td></tr>"
86
+ end
87
+
88
+ lines = []
89
+ lines << '<table>'
90
+ lines << ' <tr><th>Key</th><th>Status</th><th>Source</th><th>Target</th></tr>'
91
+ lines.concat(rows)
92
+ lines << '</table>'
93
+ lines.join("\n")
18
94
  end
19
95
 
20
96
  # Alias for compare — compare two hashes.
21
97
  #
22
98
  # @param hash_a [Hash] the baseline environment hash
23
99
  # @param hash_b [Hash] the environment hash to compare against
100
+ # @param case_sensitive [Boolean] whether key comparison is case-sensitive (default: true)
24
101
  # @return [Diff] the computed differences
25
- def self.from_hash(hash_a, hash_b)
26
- compare(hash_a, hash_b)
102
+ def self.from_hash(hash_a, hash_b, case_sensitive: true)
103
+ compare(hash_a, hash_b, case_sensitive: case_sensitive)
27
104
  end
28
105
 
29
106
  # Parse two .env files and compare them.
30
107
  #
31
108
  # @param path_a [String] path to the source .env file
32
109
  # @param path_b [String] path to the target .env file
110
+ # @param case_sensitive [Boolean] whether key comparison is case-sensitive (default: true)
33
111
  # @return [Diff] the computed differences
34
- def self.from_env_file(path_a, path_b)
35
- compare(Parser.parse_file(path: path_a), Parser.parse_file(path: path_b))
112
+ def self.from_env_file(path_a, path_b, case_sensitive: true)
113
+ compare(Parser.parse_file(path: path_a), Parser.parse_file(path: path_b), case_sensitive: case_sensitive)
114
+ end
115
+
116
+ # Compare the current system ENV against a target hash or .env file path.
117
+ #
118
+ # @param target [Hash, String] a hash of target variables or a path to a .env file
119
+ # @param case_sensitive [Boolean] whether key comparison is case-sensitive (default: true)
120
+ # @return [Diff] the computed differences between ENV and target
121
+ def self.from_system(target, case_sensitive: true)
122
+ target_hash = target.is_a?(String) ? Parser.parse_file(path: target) : target
123
+ compare(ENV.to_h, target_hash, case_sensitive: case_sensitive)
36
124
  end
37
125
  end
38
126
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-env_diff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.3.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-15 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Parse .env files or environment hashes, compare them, and get a clear
14
14
  report of added, removed, changed, and unchanged variables.
@@ -25,11 +25,11 @@ files:
25
25
  - lib/philiprehberger/env_diff/diff.rb
26
26
  - lib/philiprehberger/env_diff/parser.rb
27
27
  - lib/philiprehberger/env_diff/version.rb
28
- homepage: https://github.com/philiprehberger/rb-env-diff
28
+ homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-env_diff
29
29
  licenses:
30
30
  - MIT
31
31
  metadata:
32
- homepage_uri: https://github.com/philiprehberger/rb-env-diff
32
+ homepage_uri: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-env_diff
33
33
  source_code_uri: https://github.com/philiprehberger/rb-env-diff
34
34
  changelog_uri: https://github.com/philiprehberger/rb-env-diff/blob/main/CHANGELOG.md
35
35
  bug_tracker_uri: https://github.com/philiprehberger/rb-env-diff/issues