metaclean 1.0.2

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dd45cefaffcadee5f5c740e5e469cba971140c9a2d448d54d433970c985aedf6
4
+ data.tar.gz: 3c998706d833d78bbaa039ad537d6ed6a4918cb443294f1249597ca88c1b9799
5
+ SHA512:
6
+ metadata.gz: 81170d9320c56d98a22939c92ca91e40c896ec1e1383880face355a5c372efdb77c6b94cb3614e98a6c6840296c14a1a7eadb6d885a093853015832cdeef2e23
7
+ data.tar.gz: 67855bc4b712f6bf1601d9224f672006d4d0f3b9a9ed1c73888b204d45393f1f71323dc9ade31e527205771a726274113cfdd4082943ab3b62a102851cbc343c
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2026 metaclean contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # metaclean
2
+
3
+ A small cross-platform Ruby CLI that strips metadata from almost any file
4
+ (images, audio, video, PDFs, Office documents, …) and shows you a colored
5
+ before/after diff of exactly what was removed.
6
+
7
+ It wraps three battle-tested tools and routes each file to the right one:
8
+
9
+ - **ExifTool** — the broadest format coverage (EXIF, IPTC, XMP, GPS, ID3, …)
10
+ - **mat2** — stricter on `.docx` / `.pdf` / `.png` (rebuilds the file)
11
+ - **qpdf** — rebuilds PDFs and clears residual metadata in unused streams
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ # 1. Install the underlying tools (ExifTool is required, mat2 + qpdf optional)
17
+ brew install exiftool mat2 qpdf # macOS
18
+ sudo apt install libimage-exiftool-perl mat2 qpdf # Debian / Ubuntu
19
+ sudo dnf install perl-Image-ExifTool mat2 qpdf # Fedora
20
+ sudo pacman -S perl-image-exiftool mat2 qpdf # Arch
21
+ scoop install exiftool qpdf # Windows
22
+
23
+ # 2. Install metaclean after it has been published to RubyGems
24
+ gem install metaclean
25
+ metaclean --version
26
+ metaclean --help
27
+ ```
28
+
29
+ From a source checkout before publishing:
30
+
31
+ ```bash
32
+ git clone https://github.com/26zl/metaclean.git && cd metaclean
33
+ chmod +x bin/metaclean
34
+ bundle install
35
+ bundle exec rake install
36
+ metaclean --version
37
+ ```
38
+
39
+ You can also run the checkout without installing it by using
40
+ `./bin/metaclean`.
41
+
42
+ ### Windows
43
+
44
+ You also need Ruby itself. The recommended installer is
45
+ [**RubyInstaller**](https://rubyinstaller.org/) — it ships Ruby plus the
46
+ MSYS2 toolchain (DevKit) that some gems need to compile native extensions.
47
+ `scoop install ruby` and `choco install ruby` install the same RubyInstaller
48
+ distribution under the hood.
49
+
50
+ Note: `mat2` is hard to install on native Windows (it depends on Python +
51
+ GTK). If you want full coverage, the simplest route is
52
+ **[WSL2](https://learn.microsoft.com/windows/wsl/install)** with Ubuntu, then
53
+ follow the Debian/Ubuntu line above. On native Windows without `mat2`,
54
+ metaclean still works — ExifTool + qpdf cover the common cases.
55
+
56
+ ## Quick start
57
+
58
+ These examples assume metaclean is installed as a gem. From a source checkout,
59
+ replace `metaclean` with `./bin/metaclean`.
60
+
61
+ ```bash
62
+ # Show metadata, do not modify
63
+ metaclean --inspect photo.jpg
64
+
65
+ # Clean a file → writes photo_clean.jpg next to the original
66
+ metaclean photo.jpg
67
+
68
+ # Overwrite the original (a .bak is kept by default)
69
+ metaclean --in-place photo.jpg
70
+
71
+ # Clean a whole folder, recursively, no prompts, no backups
72
+ metaclean -r --in-place --no-backup --force ./vacation
73
+
74
+ # See what would change without writing anything
75
+ metaclean --dry-run photo.jpg
76
+ ```
77
+
78
+ ## Flags
79
+
80
+ | Flag | What it does |
81
+ |---|---|
82
+ | `--inspect` | Read-only — print metadata, never write |
83
+ | `--json` | JSON output (with `--inspect`) |
84
+ | `--dry-run` | Simulate cleaning, show diff, write nothing |
85
+ | `-i`, `--in-place` | Overwrite originals |
86
+ | `--no-backup` | Do not keep `<file>.bak` (with `--in-place`) |
87
+ | `-r`, `--recursive` | Recurse into directories |
88
+ | `--types=jpg,png,…` | Only process these extensions |
89
+ | `--follow-symlinks` | Follow symlinks (default: skip) |
90
+ | `--keep-orientation` | Preserve EXIF Orientation |
91
+ | `--keep-color-profile` | Preserve embedded ICC profile |
92
+ | `--exiftool-only` | Use only ExifTool |
93
+ | `--no-mat2` / `--no-qpdf` / `--no-exiftool` | Disable a specific tool |
94
+ | `-f`, `--force` | Skip the confirmation prompt |
95
+ | `--strict-verify` | Exit non-zero if privacy tags survive |
96
+
97
+ ## Publishing
98
+
99
+ The release workflow builds a `.gem` file for every `v*` tag. If the GitHub
100
+ repository has a `RUBYGEMS_API_KEY` secret, the workflow also publishes the gem
101
+ to RubyGems.
102
+
103
+ ```bash
104
+ git tag v1.0.1
105
+ git push origin v1.0.1
106
+ ```
107
+
108
+ ## Safety
109
+
110
+ - All shell-outs use argument arrays (`Open3.capture3(*args)`), so filenames
111
+ with spaces, quotes, or shell metacharacters are safe.
112
+ - `--in-place` writes atomically: the file is built in a temp file and
113
+ renamed into place, so a crash mid-run cannot leave a half-written original.
114
+ - Symlinks are skipped by default.
115
+ - Filename collisions (`photo_clean.jpg` already exists, `.bak` already
116
+ exists) are resolved with `_1`, `_2`, … suffixes.
117
+ - After cleaning, metaclean re-reads the file and warns if known
118
+ privacy-relevant tags (GPS, MakerNotes, Author, camera serial number, etc.)
119
+ survived. With `--strict-verify` this becomes a non-zero exit code.
120
+
121
+ ## What it does *not* do
122
+
123
+ - Steganography (data hidden inside the pixel/audio data itself).
124
+ - Filesystem metadata (mtime, ownership) — that's the OS, not the file.
125
+ - Office macros or PDF JavaScript — open untrusted files in a sandbox.
126
+
127
+ Keep ExifTool, mat2, and qpdf updated; they parse hostile binary formats and
128
+ have had CVEs in the past.
129
+
130
+ ## License
131
+
132
+ MIT
data/bin/metaclean ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ───────────────────────────────────────────────────────────────────────────
5
+ # bin/metaclean — entry point invoked by the user's shell.
6
+ #
7
+ # The shebang `#!/usr/bin/env ruby` makes the file executable on macOS/Linux
8
+ # (after `chmod +x`). `env` looks up Ruby on PATH so it works regardless of
9
+ # where Ruby is installed (Homebrew, rbenv, asdf, etc.).
10
+ #
11
+ # `frozen_string_literal: true` is a Ruby 3 magic comment: every string in
12
+ # this file is implicitly frozen (immutable). This prevents accidental
13
+ # mutation and gives a small performance/memory win.
14
+ # ───────────────────────────────────────────────────────────────────────────
15
+
16
+ # Add `<repo>/lib` to Ruby's load path so `require 'metaclean'` finds our code
17
+ # without needing the gem installed. `__dir__` is the directory of this file
18
+ # (`bin/`), so `../lib` is the sibling `lib/` directory. `expand_path` turns
19
+ # the relative path into an absolute one.
20
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
21
+
22
+ # Pull in the whole library. See `lib/metaclean.rb` — that file `require`s
23
+ # every sub-component in dependency order.
24
+ require 'metaclean'
25
+
26
+ # Hand control to the CLI. `ARGV` is Ruby's built-in array of command-line
27
+ # arguments (everything after the script name). `CLI.start` parses them and
28
+ # decides what to do.
29
+ Metaclean::CLI.start(ARGV)
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ───────────────────────────────────────────────────────────────────────────
4
+ # Command-line argument parser.
5
+ #
6
+ # We use Ruby's standard library `OptionParser` instead of a gem like Thor
7
+ # because it has zero dependencies and is plenty for our needs. The pattern is:
8
+ #
9
+ # 1. Define each flag inside an `OptionParser.new do |o| ... end` block.
10
+ # 2. `o.on(...)` takes the flag spec and a block that runs when the flag
11
+ # is seen. The block usually mutates `@options` to record the choice.
12
+ # 3. `parser.parse!(@argv)` consumes flags from `@argv` and leaves
13
+ # positional args (the file paths) behind.
14
+ # ───────────────────────────────────────────────────────────────────────────
15
+
16
+ require 'optparse'
17
+
18
+ module Metaclean
19
+ class CLI
20
+ # Class-level convenience: `Metaclean::CLI.start(ARGV)` reads cleaner
21
+ # than `Metaclean::CLI.new(ARGV).run`.
22
+ def self.start(argv)
23
+ new(argv).run
24
+ end
25
+
26
+ def initialize(argv)
27
+ # `dup` makes a shallow copy so we can mutate `@argv` without
28
+ # surprising the caller (ARGV itself stays intact).
29
+ @argv = argv.dup
30
+
31
+ # All options default to safe/off values. `parse!` flips them
32
+ # selectively as it sees flags.
33
+ @options = {
34
+ recursive: false,
35
+ in_place: false,
36
+ no_backup: false,
37
+ force: false,
38
+ inspect_only: false,
39
+ format: :pretty,
40
+ keep_orientation: false,
41
+ keep_color_profile: false,
42
+ dry_run: false,
43
+ follow_symlinks: false,
44
+ strict_verify: false,
45
+ no_mat2: false,
46
+ no_qpdf: false,
47
+ no_exiftool: false,
48
+ exiftool_only: false,
49
+ types: nil
50
+ }
51
+ @paths = []
52
+ end
53
+
54
+ # Top-level dispatcher. Catches our errors and exits with codes that
55
+ # shells/CI can act on:
56
+ # 0 → success
57
+ # 1 → general failure
58
+ # 2 → ExifTool missing (specific install hint shown)
59
+ # 130→ user pressed Ctrl-C (matches the standard SIGINT exit code)
60
+ def run
61
+ parse!
62
+ runner = Runner.new(@options)
63
+ if @options[:inspect_only]
64
+ runner.inspect_paths(@paths)
65
+ else
66
+ runner.clean_paths(@paths)
67
+ end
68
+ rescue ExiftoolMissing => e
69
+ warn Display.error('ExifTool missing')
70
+ warn e.message
71
+ exit 2
72
+ rescue Error => e
73
+ warn Display.error(e.message)
74
+ exit 1
75
+ rescue Interrupt
76
+ # Pressing Ctrl-C raises `Interrupt`. Catching it lets us print a
77
+ # clean message instead of a Ruby stack trace.
78
+ warn "\n#{Display.error('Interrupted.')}"
79
+ exit 130
80
+ end
81
+
82
+ private
83
+
84
+ def parse!
85
+ parser = OptionParser.new do |o|
86
+ # The banner shows up at the top of `--help`.
87
+ o.banner = 'Usage: metaclean [options] <path> [<path>...]'
88
+ o.separator ''
89
+ o.separator 'Cross-platform metadata cleaner. Strips EXIF, IPTC, XMP, GPS,'
90
+ o.separator 'MakerNotes, ID3, document properties, etc. — uses ExifTool, mat2'
91
+ o.separator 'and qpdf together for maximum coverage.'
92
+ o.separator ''
93
+
94
+ # Each `o.on` registers a flag. The block runs when that flag is
95
+ # found in argv. `_v` is the captured value (unused for booleans).
96
+ o.separator 'Modes:'
97
+ o.on('--inspect', 'Only show metadata, do not modify files') { @options[:inspect_only] = true }
98
+ o.on('--json', 'Output as JSON (with --inspect)') { @options[:format] = :json }
99
+ o.on('--dry-run', 'Simulate cleaning, show diff, write nothing') { @options[:dry_run] = true }
100
+
101
+ o.separator ''
102
+ o.separator 'Output:'
103
+ o.on('-i', '--in-place', 'Overwrite originals (default: write *_clean.<ext>)') { @options[:in_place] = true }
104
+ o.on('--no-backup', "Don't keep a .bak when --in-place") { @options[:no_backup] = true }
105
+
106
+ o.separator ''
107
+ o.separator 'Selection:'
108
+ o.on('-r', '--recursive', 'Recurse into directories') { @options[:recursive] = true }
109
+ o.on('--follow-symlinks', 'Follow symlinks (default: skip them)') { @options[:follow_symlinks] = true }
110
+ # `--types=LIST, Array` tells OptionParser to split the value on
111
+ # commas. So `--types=jpg,png` arrives as ["jpg", "png"].
112
+ o.on('--types=LIST', Array, 'Only process these extensions (e.g. jpg,png,pdf)') do |v|
113
+ @options[:types] = v.map { |x| x.to_s.downcase.delete('.') }
114
+ end
115
+
116
+ o.separator ''
117
+ o.separator 'Tool selection:'
118
+ o.on('--exiftool-only', 'Use only ExifTool (skip mat2 and qpdf)') { @options[:exiftool_only] = true }
119
+ o.on('--no-mat2', 'Disable mat2 even if available') { @options[:no_mat2] = true }
120
+ o.on('--no-qpdf', 'Disable qpdf even if available') { @options[:no_qpdf] = true }
121
+ o.on('--no-exiftool', 'Disable ExifTool') { @options[:no_exiftool] = true }
122
+
123
+ o.separator ''
124
+ o.separator 'Preservation:'
125
+ o.on('--keep-orientation', 'Preserve EXIF Orientation tag') { @options[:keep_orientation] = true }
126
+ o.on('--keep-color-profile', 'Preserve embedded ICC profile') { @options[:keep_color_profile] = true }
127
+
128
+ o.separator ''
129
+ o.separator 'Verification:'
130
+ o.on('-f', '--force', 'Skip confirmation prompt') { @options[:force] = true }
131
+ o.on('--strict-verify', 'Exit non-zero if privacy tags survive') { @options[:strict_verify] = true }
132
+
133
+ o.separator ''
134
+ o.separator 'Other:'
135
+ o.on('-h', '--help') { puts o; exit }
136
+ o.on('-v', '--version') do
137
+ puts "metaclean #{Metaclean::VERSION}"
138
+ # `&.split&.last` is safe-navigation: if `Qpdf.version` is nil
139
+ # (qpdf not installed), the chain short-circuits to nil rather
140
+ # than blowing up with NoMethodError.
141
+ puts " exiftool: #{Exiftool.version || 'not found'}"
142
+ puts " mat2: #{Mat2.version || 'not found'}"
143
+ puts " qpdf: #{Qpdf.version&.split&.last || 'not found'}"
144
+ exit
145
+ end
146
+ end
147
+
148
+ # `parse!` mutates @argv in place: known flags are consumed,
149
+ # positional args (file paths) are left behind.
150
+ begin
151
+ parser.parse!(@argv)
152
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
153
+ # Bad flag → show the message + the help text and exit non-zero.
154
+ warn Display.error(e.message)
155
+ warn parser
156
+ exit 1
157
+ end
158
+
159
+ # No paths after flags → user probably ran `metaclean` with no args.
160
+ # Show help and exit non-zero so scripts notice.
161
+ if @argv.empty?
162
+ puts parser
163
+ exit 1
164
+ end
165
+
166
+ @paths = @argv.dup
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ───────────────────────────────────────────────────────────────────────────
4
+ # Anything that prints to the terminal lives here: ANSI colors, headers,
5
+ # tables, the before/after diff. Keeping presentation in one module means
6
+ # the rest of the codebase stays focused on logic.
7
+ #
8
+ # ANSI escape sequences:
9
+ # "\e[31m" turns the terminal text red.
10
+ # "\e[0m" resets all styling.
11
+ # A modern terminal interprets these; if you redirect to a file, they show
12
+ # up as garbage — that's why we check `tty?` before emitting them.
13
+ # ───────────────────────────────────────────────────────────────────────────
14
+
15
+ module Metaclean
16
+ module Display
17
+ COLORS = {
18
+ reset: "\e[0m",
19
+ bold: "\e[1m",
20
+ dim: "\e[2m",
21
+ red: "\e[31m",
22
+ green: "\e[32m",
23
+ yellow: "\e[33m",
24
+ blue: "\e[34m",
25
+ magenta: "\e[35m",
26
+ cyan: "\e[36m",
27
+ gray: "\e[90m"
28
+ }.freeze
29
+
30
+ # ExifTool reports four "groups" that are descriptions of the file
31
+ # itself, not embedded metadata: System (filesystem stat), File (header
32
+ # info), ExifTool (its own version), Composite (computed values).
33
+ # Excluding these makes the diff focus on what actually got stripped.
34
+ NON_METADATA_GROUPS = %w[System File ExifTool Composite].freeze
35
+
36
+ module_function
37
+
38
+ # Decides whether to emit ANSI color codes. Colors are wrong when:
39
+ # * stdout is a pipe/file (not a terminal) — `tty?` is false there
40
+ # * NO_COLOR env var is set (de-facto convention, see no-color.org)
41
+ # * we're on classic Windows cmd.exe (modern Windows Terminal is fine,
42
+ # but to be safe we require an explicit FORCE_COLOR opt-in there)
43
+ def color?
44
+ return @color if defined?(@color)
45
+
46
+ # Per https://no-color.org: disable only when NO_COLOR is set to a
47
+ # non-empty value. An unset or empty NO_COLOR leaves colors on.
48
+ no_color = ENV['NO_COLOR'].to_s
49
+ @color = $stdout.tty? && no_color.empty? && !Gem.win_platform?
50
+ @color = true if ENV['FORCE_COLOR']
51
+ @color
52
+ end
53
+
54
+ # `c` for "color". Wraps text in the requested color, or returns it
55
+ # plain if colors are disabled. The reset code at the end stops the
56
+ # color from bleeding into following output.
57
+ def c(text, color)
58
+ return text.to_s unless color?
59
+
60
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
61
+ end
62
+
63
+ # Visual section markers used throughout the runner's output. Keeping
64
+ # them here means a single change updates the look everywhere.
65
+ def header(text)
66
+ puts
67
+ puts c('━' * 64, :gray)
68
+ puts c(text, :bold)
69
+ puts c('━' * 64, :gray)
70
+ end
71
+
72
+ def section(text); puts c("▸ #{text}", :cyan); end
73
+ def info(text); puts c(" #{text}", :gray); end
74
+ def success(text); puts c("✓ #{text}", :green); end
75
+ def warning(text); puts c("⚠ #{text}", :yellow);end
76
+
77
+ # `error` returns a string instead of printing it — callers usually want
78
+ # to send it to STDERR via `warn`, not stdout via `puts`.
79
+ def error(text); c("✗ #{text}", :red); end
80
+
81
+ # Prints a metadata Hash as a grouped, indented table.
82
+ # `only_embedded:` filters out the System/File/etc. noise.
83
+ def metadata_table(meta, only_embedded: false)
84
+ rows = meta.reject { |k, _| k == 'SourceFile' }
85
+ rows = rows.reject { |k, _| NON_METADATA_GROUPS.include?(group_of(k)) } if only_embedded
86
+
87
+ if rows.empty?
88
+ info(only_embedded ? '(no embedded metadata)' : '(no metadata)')
89
+ return
90
+ end
91
+
92
+ # `group_by` partitions an Enumerable into a Hash keyed by the block's
93
+ # result. Here we group all "GPS:*" tags together, all "EXIF:*" together,
94
+ # etc., then print each group as a labeled sub-table.
95
+ grouped = rows.group_by { |k, _| group_of(k) }
96
+ grouped.sort_by { |g, _| g.to_s }.each do |group, pairs|
97
+ puts c(" [#{group}]", :magenta)
98
+ pairs.sort_by { |k, _| k.to_s }.each do |k, v|
99
+ tag = k.to_s.split(':', 2).last
100
+ # `format` (alias of sprintf) does column alignment: %-38s = left-
101
+ # aligned, padded to 38 chars.
102
+ line = format(' %-38s %s', truncate(tag, 38), truncate(format_value(v), 60))
103
+ puts c(line, :dim)
104
+ end
105
+ end
106
+ end
107
+
108
+ # Compares two metadata hashes (before vs after) and prints three
109
+ # sections: removed, changed, still-present. This is the "before/after"
110
+ # the user asked for.
111
+ def diff(before, after)
112
+ keys = (before.keys + after.keys).uniq
113
+ .reject { |k| k == 'SourceFile' }
114
+ .reject { |k| NON_METADATA_GROUPS.include?(group_of(k)) }
115
+
116
+ removed = []
117
+ changed = []
118
+ kept = []
119
+
120
+ # Classifying each key into one of three buckets keeps the rest of
121
+ # the method simple and testable.
122
+ keys.sort.each do |k|
123
+ b = before[k]
124
+ a = after[k]
125
+ if a.nil? && !b.nil?
126
+ removed << [k, b]
127
+ elsif !b.nil? && a != b
128
+ changed << [k, b, a]
129
+ elsif !b.nil?
130
+ kept << [k, b]
131
+ end
132
+ end
133
+
134
+ if removed.any?
135
+ section "Removed (#{removed.size})"
136
+ removed.each do |k, b|
137
+ puts " #{c('-', :red)} #{c(k, :red)} #{c(truncate(format_value(b), 60), :gray)}"
138
+ end
139
+ end
140
+
141
+ if changed.any?
142
+ section "Changed (#{changed.size})"
143
+ changed.each do |k, b, a|
144
+ puts " #{c('~', :yellow)} #{c(k, :yellow)}"
145
+ puts " #{c('-', :red)} #{truncate(format_value(b), 60)}"
146
+ puts " #{c('+', :green)} #{truncate(format_value(a), 60)}"
147
+ end
148
+ end
149
+
150
+ if kept.any?
151
+ section "Still present (#{kept.size})"
152
+ kept.each do |k, b|
153
+ puts " #{c('=', :gray)} #{c(k, :gray)} #{c(truncate(format_value(b), 60), :gray)}"
154
+ end
155
+ end
156
+
157
+ if removed.empty? && changed.empty? && kept.empty?
158
+ info 'Nothing to strip — file already clean.'
159
+ elsif removed.empty? && changed.empty?
160
+ info 'No tags were removed — see "Still present" above.'
161
+ end
162
+ end
163
+
164
+ # Pull the group name out of "Group:Tag". The `2` argument to split caps
165
+ # the result at 2 elements, so a value containing ":" doesn't break it.
166
+ def group_of(key)
167
+ key.to_s.split(':', 2).first.to_s
168
+ end
169
+
170
+ # Make any value safe to print on a single line. Hashes/Arrays get
171
+ # `inspect` (shows their structure); strings are collapsed to single
172
+ # spaces so a multiline tag value doesn't wreck the table.
173
+ def format_value(v)
174
+ case v
175
+ when Hash, Array then v.inspect
176
+ else v.to_s.gsub(/\s+/, ' ')
177
+ end
178
+ end
179
+
180
+ # Truncate to N chars with a single-character ellipsis. We use "…"
181
+ # (one Unicode char) instead of "..." so the truncation doesn't itself
182
+ # spill over the budget.
183
+ def truncate(s, n)
184
+ s = s.to_s
185
+ s.length > n ? "#{s[0, n - 1]}…" : s
186
+ end
187
+
188
+ # How many "real" embedded tags are there? Used for the
189
+ # "Before (24 embedded tags) → After (0)" summary line.
190
+ def count_embedded(meta)
191
+ meta.keys
192
+ .reject { |k| k == 'SourceFile' }
193
+ .reject { |k| NON_METADATA_GROUPS.include?(group_of(k)) }
194
+ .size
195
+ end
196
+ end
197
+ end