testprune 0.3.0 → 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: e0524c23f51b9afdbb9731dee285f81be5571abcff4730b09194865edcb01d0d
4
- data.tar.gz: 648d2e72e49dc16a083b7ca116889e4694fdaa72e6e1337207bdc7da7376260f
3
+ metadata.gz: 5df7245c9484fac5c375f5bc3275e2af2ffb4a48e1090a15cfff56188a24047f
4
+ data.tar.gz: 23edfd9d58322bdc03dacc04d5986772dfe57690ad5a6481da6bcf21dc604715
5
5
  SHA512:
6
- metadata.gz: 762a0cf8e298644d06094814b81e9579312c16247a649013da282207623e88349a0918a95657f9a33125fb9b0a029369630071806b484b8c5869f4979dafc858
7
- data.tar.gz: a245409289d680cfe06bc08ad2afdc004ae1d542f9e2895e70d0840834852f8ebf75d95215129f1afd7c58d84b34925864d5af02ae34ae933b70c34aa35a0a75
6
+ metadata.gz: cab54aebaf7b972b7efa70c617bc769109ce74d3aee567717ddd33339d164410a78c4cb6e98ac6277f28828e26179fff8b4cf267d511a1fdbe22ea6b0e5781ca
7
+ data.tar.gz: 5a9ef2a87d2ce1ddd88ace0cc19dc6ba3259ba7a500db3abc9f88cb95805ef5a42736c29d7489eb4cdf992d7fec2361bf7b584fbd7532ca88044e8b6add3dde9
data/README.md CHANGED
@@ -82,8 +82,15 @@ testprune scan -- bundle exec rails test test/controllers/
82
82
 
83
83
  # Restrict which source files are analyzed (-s is repeatable)
84
84
  testprune scan -s app -s lib -s packs
85
+
86
+ # Show raw test output (disables the progress display)
87
+ testprune scan --verbose # or -V
85
88
  ```
86
89
 
90
+ By default, `testprune scan` suppresses raw test runner output and shows a live progress display (spinner + test counter + elapsed time). If test errors occur they are flagged non-blocking — the scan completes and you're prompted to expand the error detail before continuing.
91
+
92
+ Pass `--verbose` / `-V` to stream raw output directly instead (the pre-0.4.0 behavior).
93
+
87
94
  When run with no arguments, `testprune scan` checks your `test/` directory and flags any subdirectories whose names contain `selenium`, `request`, `piper`, or `integration` — folders that tend to be slow, browser-driven, or external and are generally poor candidates for coverage analysis. You'll be prompted to exclude them before the run starts:
88
95
 
89
96
  ```
@@ -494,6 +501,7 @@ testprune report -s app -s lib || true
494
501
  | `-o, --output DIR` | `tmp/.testprune` | all | Where `run.json` and `removal.patch` are written. |
495
502
  | `--baseline FRAC` | `0.5` | report, apply | Strip units in ≥ FRAC of tests as shared-setup noise before detection. `0` disables. |
496
503
  | `--json` | off | report | Emit machine-readable JSON instead of human text. |
504
+ | `-V, --verbose` | off | scan | Stream raw test output directly (disables progress display). |
497
505
  | `-h, --help` | | all | Show help. |
498
506
  | `-v, --version` | | | Print version. |
499
507
 
@@ -506,6 +514,7 @@ testprune report -s app -s lib || true
506
514
  | `TESTPRUNE_OUTPUT_DIR` | Output directory. Set automatically by `testprune scan`. |
507
515
  | `TESTPRUNE_DEBUG` | Print adapter-load diagnostics (`[testprune-debug] autostart loaded in pid …`). Useful when capture produces no `run.json`. |
508
516
  | `DISABLE_SPRING` | Disable Spring preloader so the test process inherits testprune's instrumentation. |
517
+ | `NO_COLOR` | Set to any value to disable all ANSI color output ([no-color.org](https://no-color.org/)). Styled output is also disabled automatically when stdout/stderr is not a TTY (e.g. piped output, CI without color support). |
509
518
 
510
519
  </details>
511
520
 
data/lib/testprune/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'optparse'
4
4
  require_relative '../testprune'
5
+ require_relative 'ui'
5
6
 
6
7
  module Testprune
7
8
  # Command-line front end. Three real commands:
@@ -32,6 +33,7 @@ module Testprune
32
33
  --baseline FRAC Treat units run by >= FRAC of tests as shared-setup
33
34
  noise and subtract them (0..1; default 0.5; 0 to disable)
34
35
  --json Emit machine-readable JSON (report only)
36
+ -V, --verbose Show raw test output during scan (disables progress display)
35
37
  -h, --help Show this help
36
38
  -v, --version Show version
37
39
  TXT
@@ -78,6 +80,7 @@ module Testprune
78
80
  o.on('-o', '--output DIR') { |v| opts[:output] = v }
79
81
  o.on('--baseline FRAC', Float) { |v| opts[:baseline] = v }
80
82
  o.on('--json') { opts[:json] = true }
83
+ o.on('-V', '--verbose') { opts[:verbose] = true }
81
84
  o.on('-h', '--help') { puts(BANNER); exit(0) }
82
85
  end
83
86
  rest = parser.parse(argv)
@@ -104,7 +107,7 @@ module Testprune
104
107
  elsif test_command.nil?
105
108
  test_command = runner.command_for_paths(paths)
106
109
  end
107
- runner.call(test_command)
110
+ runner.call(test_command, verbose: opts[:verbose])
108
111
  end
109
112
 
110
113
  def prompt_noisy_exclusions(runner)
@@ -131,8 +134,29 @@ module Testprune
131
134
  end
132
135
 
133
136
  def cmd_prune(argv)
137
+ if UI.tty?($stderr)
138
+ $stderr.puts UI::Styles::HEADER_BOX.render("✂️ testprune prune — scan + apply in one step")
139
+ $stderr.puts
140
+ end
134
141
  cmd_scan(argv)
135
- cmd_apply([])
142
+ if UI.tty?($stderr)
143
+ sep = UI::Styles::PURPLE_TEXT.render(' ' + '━' * 58)
144
+ $stderr.puts
145
+ $stderr.puts sep
146
+ $stderr.puts " #{UI::Styles::PURPLE_TEXT.render('✂️')} Moving to apply…"
147
+ $stderr.puts sep
148
+ $stderr.puts
149
+ end
150
+ patch_path = cmd_apply([])
151
+ # T018: done summary
152
+ if UI.tty?($stdout)
153
+ summary = case patch_path
154
+ when String then " ✂️ Done — patch written\n #{UI::Styles::PURPLE_TEXT.render("git apply #{patch_path}")}"
155
+ when false then " ✂️ Done — aborted, no patch written"
156
+ else " ✂️ Done — nothing to prune"
157
+ end
158
+ puts UI::Styles::SUCCESS_BOX.render(summary)
159
+ end
136
160
  end
137
161
 
138
162
  def cmd_report(argv)
@@ -154,22 +178,46 @@ module Testprune
154
178
 
155
179
  approved = result.approved_removals
156
180
  if approved.empty?
157
- puts("\nNothing safe to remove. No patch written.")
158
- return
181
+ msg = " Nothing safe to remove. No patch written."
182
+ puts(UI.tty?($stdout) ? UI::Styles::SUCCESS_BOX.render(msg) : "\n#{msg.strip}")
183
+ return nil
184
+ end
185
+
186
+ # Styled confirmation prompt
187
+ if UI.tty?($stdout)
188
+ puts
189
+ puts " Apply #{UI::Styles::GREEN_TEXT.render(approved.size.to_s)} HIGH-confidence removal(s) as a patch?"
190
+ puts " #{UI::Styles::DIM_TEXT.render('(MEDIUM/LOW review-only candidates are NOT patched.)')}"
191
+ print " #{UI::Styles::PURPLE_TEXT.render('[y/N]')} > "
192
+ else
193
+ print("\nApply #{approved.size} HIGH-confidence, safety-verified removal(s) as a patch?\n" \
194
+ "(MEDIUM/LOW review-only candidates are NOT patched automatically.) [y/N] ")
159
195
  end
160
196
 
161
- print("\nApply #{approved.size} HIGH-confidence, safety-verified removal(s) as a patch?\n" \
162
- "(MEDIUM/LOW review-only candidates are NOT patched automatically.) [y/N] ")
163
197
  answer = $stdin.gets&.strip&.downcase
164
198
  unless %w[y yes].include?(answer)
165
- puts('Aborted. No patch written.')
166
- return
199
+ msg = " Aborted no patch written."
200
+ puts(UI.tty?($stdout) ? UI::Styles::DIM_TEXT.render(msg) : 'Aborted. No patch written.')
201
+ return false
167
202
  end
168
203
 
169
204
  require_relative 'patch_writer'
170
205
  path = PatchWriter.new(Testprune.config).write(approved)
171
- puts("Wrote #{path}")
172
- puts("Review it, then apply with: git apply #{path}")
206
+
207
+ if UI.tty?($stdout)
208
+ box = [
209
+ " #{UI::Styles::GREEN_TEXT.render('✓')} Patch written",
210
+ " #{path}",
211
+ " #{UI::Styles::DIM_TEXT.render('Apply with:')} " \
212
+ "#{UI::Styles::PURPLE_TEXT.render("git apply #{path}")}"
213
+ ].join("\n")
214
+ puts UI::Styles::SUCCESS_BOX.render(box)
215
+ else
216
+ puts("Wrote #{path}")
217
+ puts("Review it, then apply with: git apply #{path}")
218
+ end
219
+
220
+ path
173
221
  end
174
222
  end
175
223
  end
@@ -26,27 +26,8 @@ module Testprune
26
26
  private
27
27
 
28
28
  def render_text
29
- lines = []
30
- lines << 'testprune — test coverage redundancy report'
31
- lines << "Suite: #{test_count} test(s), framework=#{@result.run['framework']}"
32
- if @result.ambient_units.positive?
33
- lines << "Baseline: subtracted #{@result.ambient_units} shared-setup unit(s); " \
34
- "#{@result.setup_only} test(s) had no distinctive coverage and were set aside."
35
- end
36
- lines << ''
37
-
38
- lines.concat(section('HIGH confidence — safe to remove', high_candidates))
39
- lines.concat(section('MEDIUM confidence — review (structural duplicates)', medium_candidates))
40
- lines.concat(section('LOW confidence — review (overlapping coverage)', low_candidates))
41
-
42
- lines.concat(savings_section)
43
- lines << ''
44
- lines << if @result.approved_removals.empty?
45
- 'No auto-removable candidates. Nothing to apply.'
46
- else
47
- 'Run `testprune apply` to review and emit a removal patch.'
48
- end
49
- lines.join("\n")
29
+ require_relative 'ui/report_renderer'
30
+ UI::ReportRenderer.new(@result).render
50
31
  end
51
32
 
52
33
  def section(title, candidates)
@@ -2,27 +2,38 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'json'
5
+ require 'open3'
5
6
  require_relative '../testprune'
7
+ require_relative 'ui'
6
8
 
7
9
  module Testprune
8
10
  # Boots the target project's suite in a subprocess, instrumented so the adapters
9
11
  # capture per-test coverage. Reuses the gem's lib via RUBYOPT (-I) so the target
10
12
  # project does not need testprune in its Gemfile.
11
13
  class Runner
14
+ # Progress-line patterns: match single-char Minitest/RSpec progress indicators.
15
+ TEST_PROGRESS_RE = /\A[.FES*P]+\z/
16
+ # Lines that indicate a test error or failure.
17
+ ERROR_RE = /Error:|FAILED|Failure:|error:/
18
+
12
19
  def initialize(config)
13
20
  @config = config
14
21
  end
15
22
 
16
- # explicit_command: array form of a user-supplied test command (after `--`),
17
- # or nil to autodetect.
18
- def call(explicit_command = nil)
23
+ # explicit_command: array form of a user-supplied test command (after `--`), or nil.
24
+ # verbose: when true, stream raw output directly (no capture, no spinner).
25
+ def call(explicit_command = nil, verbose: false)
19
26
  framework, command = resolve(explicit_command)
20
27
 
21
28
  FileUtils.mkdir_p(@config.output_dir)
22
29
  File.delete(@config.run_file) if File.exist?(@config.run_file)
23
30
 
24
- warn("testprune: framework=#{framework} running: #{command.join(' ')}")
25
- ok = system(env, *command, chdir: @config.root)
31
+ if verbose
32
+ warn("testprune: framework=#{framework} running: #{command.join(' ')}")
33
+ ok = system(env, *command, chdir: @config.root)
34
+ else
35
+ ok = run_captured(framework, command)
36
+ end
26
37
 
27
38
  unless File.exist?(@config.run_file)
28
39
  raise Error, 'suite finished but no run.json was captured — the adapter may ' \
@@ -34,8 +45,11 @@ module Testprune
34
45
  rescue JSON::ParserError
35
46
  '(unreadable — suite may have been interrupted mid-write)'
36
47
  end
37
- warn("testprune: captured #{count} test(s) -> #{@config.run_file}")
38
- warn('testprune: suite exited non-zero; coverage was still captured.') unless ok
48
+
49
+ unless verbose
50
+ warn("testprune: captured #{count} test(s) -> #{@config.run_file}")
51
+ end
52
+
39
53
  ok
40
54
  end
41
55
 
@@ -53,6 +67,57 @@ module Testprune
53
67
 
54
68
  private
55
69
 
70
+ def run_captured(framework, command)
71
+ progress = UI::Progress.new(io: $stderr)
72
+ output = []
73
+ error_lines = []
74
+
75
+ progress.start
76
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
77
+
78
+ ok = Open3.popen2e(env, *command, chdir: @config.root) do |_stdin, oe, wait_thr|
79
+ oe.each_line do |line|
80
+ output << line
81
+ # Count progress chars
82
+ progress.increment if line.match?(TEST_PROGRESS_RE)
83
+ error_lines << line.chomp if line.match?(ERROR_RE)
84
+ end
85
+ wait_thr.value.success?
86
+ end
87
+
88
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
89
+ test_count = begin
90
+ File.exist?(@config.run_file) ? JSON.parse(File.read(@config.run_file)).fetch('tests', []).size : 0
91
+ rescue StandardError
92
+ 0
93
+ end
94
+
95
+ progress.stop(test_count: test_count, elapsed: elapsed)
96
+ print_scan_summary(test_count, elapsed, error_lines)
97
+
98
+ ok
99
+ end
100
+
101
+ def print_scan_summary(test_count, elapsed, error_lines)
102
+ mins = (elapsed / 60).to_i
103
+ secs = (elapsed % 60).to_i
104
+ time_s = format('%02d:%02d', mins, secs)
105
+
106
+ if UI.tty?($stderr)
107
+ lines = []
108
+ lines << " #{UI::Styles::GREEN_TEXT.render('✓')} Scan complete"
109
+ lines << " #{test_count} tests · #{time_s} elapsed"
110
+ lines << " Data written to #{@config.output_dir}/"
111
+ $stderr.puts UI::Styles::SUCCESS_BOX.render(lines.join("\n"))
112
+ else
113
+ $stderr.puts("testprune: scan complete — #{test_count} tests in #{time_s}")
114
+ end
115
+
116
+ return if error_lines.empty?
117
+
118
+ UI::ErrorToggle.new(errors: error_lines, io: $stderr).run
119
+ end
120
+
56
121
  def env
57
122
  gem_lib = File.expand_path('..', __dir__)
58
123
  rubyopt = ["-I#{gem_lib}", '-rtestprune/autostart', ENV['RUBYOPT']].compact.reject(&:empty?).join(' ')
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'testprune/ui/styles'
4
+
5
+ module Testprune
6
+ module UI
7
+ # Post-scan interactive prompt. When test errors occurred (non-blocking),
8
+ # shows a count indicator and lets the user toggle the error detail on/off
9
+ # before continuing the workflow.
10
+ class ErrorToggle
11
+ def initialize(errors:, io: $stderr, stdin: $stdin)
12
+ @errors = errors
13
+ @io = io
14
+ @stdin = stdin
15
+ @expanded = false
16
+ end
17
+
18
+ def run
19
+ return if @errors.empty?
20
+
21
+ print_indicator
22
+ loop do
23
+ @io.print(" [e + Enter] #{styled('[e]', Styles::PURPLE_TEXT)} show/hide errors" \
24
+ " [Enter] skip > ")
25
+ @io.flush
26
+ input = @stdin.gets&.strip&.downcase
27
+ break if input.nil? || input.empty?
28
+ if input == 'e'
29
+ @expanded = !@expanded
30
+ @expanded ? print_errors : clear_errors
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def tty?
38
+ return false if ENV['NO_COLOR']
39
+ @io.respond_to?(:isatty) && @io.isatty
40
+ end
41
+
42
+ def styled(text, style)
43
+ tty? ? style.render(text) : text
44
+ end
45
+
46
+ def print_indicator
47
+ label = styled("⚠ #{@errors.size} test error(s) detected", Styles::AMBER_TEXT)
48
+ note = styled(' (non-blocking — scan completed)', Styles::META_TEXT)
49
+ @io.puts("#{label}#{note}")
50
+ end
51
+
52
+ def print_errors
53
+ sep = styled(' ' + '─' * 62, Styles::DIM_TEXT)
54
+ @io.puts(sep)
55
+ @errors.each { |line| @io.puts(" #{styled(line, Styles::ERROR_TEXT)}") }
56
+ @io.puts(sep)
57
+ end
58
+
59
+ def clear_errors
60
+ # Errors are already scrolled — just note they're hidden
61
+ @io.puts(styled(' (errors hidden)', Styles::DIM_TEXT))
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'testprune/ui/styles'
4
+
5
+ module Testprune
6
+ module UI
7
+ # Live scan progress display. Shows a Braille spinner, running test counter,
8
+ # and elapsed time. All output goes to io (default $stderr) and is suppressed
9
+ # when not a TTY or when NO_COLOR is set.
10
+ class Progress
11
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
12
+
13
+ def initialize(io: $stderr)
14
+ @io = io
15
+ @counter = 0
16
+ @frame = 0
17
+ @thread = nil
18
+ @mu = Mutex.new
19
+ end
20
+
21
+ def tty?
22
+ return false if ENV['NO_COLOR']
23
+ @io.respond_to?(:isatty) && @io.isatty
24
+ end
25
+
26
+ def start
27
+ return unless tty?
28
+ @start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
+ @thread = Thread.new do
30
+ loop do
31
+ draw
32
+ sleep 0.1
33
+ end
34
+ end
35
+ end
36
+
37
+ def increment(count: 1)
38
+ @mu.synchronize { @counter += count }
39
+ end
40
+
41
+ # Stops the spinner thread and clears the line. Returns stats hash.
42
+ def stop(test_count:, elapsed:)
43
+ @thread&.kill
44
+ @thread&.join(0.2)
45
+ @thread = nil
46
+ @io.print("\r\e[K") if tty?
47
+ { test_count: test_count, elapsed: elapsed }
48
+ end
49
+
50
+ private
51
+
52
+ def draw
53
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start
54
+ count = @mu.synchronize { @counter }
55
+ frame = FRAMES[@frame % FRAMES.size]
56
+ @frame += 1
57
+ mins = (elapsed / 60).to_i
58
+ secs = (elapsed % 60).to_i
59
+ time_s = format('%02d:%02d', mins, secs)
60
+ line = "#{Styles::PURPLE_TEXT.render(frame)} Running suite… " \
61
+ "#{Styles::GREEN_TEXT.render(count.to_s)} tests " \
62
+ "#{Styles::META_TEXT.render(time_s)} elapsed"
63
+ @io.print("\r\e[K#{line}")
64
+ @io.flush
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'testprune/ui/styles'
4
+
5
+ module Testprune
6
+ module UI
7
+ # Styled replacement for Report#render_text. Renders the analysis result as a
8
+ # rich, color-coded terminal report using lipgloss. Degrades to plain text when
9
+ # NO_COLOR=1 is set or when output is not a TTY (lipgloss handles this automatically).
10
+ class ReportRenderer
11
+ GROUP_LABELS = {
12
+ identical: 'identical',
13
+ subset: 'subset',
14
+ structural: 'structural',
15
+ overlap: 'overlap'
16
+ }.freeze
17
+
18
+ def initialize(result)
19
+ @result = result
20
+ end
21
+
22
+ def render
23
+ parts = []
24
+ parts << header_section
25
+ parts << ''
26
+
27
+ if @result.candidates.empty?
28
+ parts << nothing_found_section
29
+ else
30
+ parts << confidence_section(:high, 'HIGH confidence — safe to remove', Styles::HIGH_BADGE, '●')
31
+ parts << confidence_section(:medium, 'MEDIUM confidence — review', Styles::MEDIUM_BADGE, '●')
32
+ parts << confidence_section(:low, 'LOW confidence — review', Styles::LOW_BADGE, '●')
33
+ parts << savings_section unless @result.approved_removals.empty?
34
+ parts << ''
35
+ parts << cta_line
36
+ end
37
+
38
+ parts.compact.join("\n")
39
+ end
40
+
41
+ private
42
+
43
+ def tty?
44
+ return false if ENV['NO_COLOR']
45
+
46
+ $stdout.respond_to?(:isatty) && $stdout.isatty
47
+ end
48
+
49
+ def styled(text, style)
50
+ tty? ? style.render(text) : text
51
+ end
52
+
53
+ # ── Sections ─────────────────────────────────────────────────────────────
54
+
55
+ def header_section
56
+ framework = @result.run['framework'] || 'unknown'
57
+ test_count = (@result.run['tests'] || []).size
58
+ baseline_info = @result.ambient_units.positive? ?
59
+ " baseline: subtracted #{@result.ambient_units} shared-setup unit(s)" : ''
60
+
61
+ lines = [
62
+ " testprune — coverage redundancy report",
63
+ " #{test_count} tests · #{framework}#{baseline_info}"
64
+ ]
65
+
66
+ if tty?
67
+ Styles::REPORT_BOX.render(lines.join("\n"))
68
+ else
69
+ lines.join("\n") + "\n" + ('─' * 64)
70
+ end
71
+ end
72
+
73
+ def confidence_section(tier, title, badge_style, bullet)
74
+ candidates = @result.candidates.select { |c| c.confidence == tier }
75
+ return nil if candidates.empty?
76
+
77
+ lines = []
78
+ badge = styled(" #{bullet} ", badge_style)
79
+ title_text = styled(title, badge_style)
80
+ count_text = styled(" (#{candidates.size})", Styles::META_TEXT)
81
+ lines << "#{badge}#{title_text}#{count_text}"
82
+ lines << styled(' ' + '─' * 62, Styles::DIM_TEXT)
83
+ lines << ''
84
+ candidates.each do |c|
85
+ lines.concat(candidate_lines(c))
86
+ lines << ''
87
+ end
88
+ lines.join("\n")
89
+ end
90
+
91
+ def candidate_lines(candidate)
92
+ fp = candidate.footprint
93
+ lines = []
94
+
95
+ group_badge = styled("[#{GROUP_LABELS[candidate.group] || candidate.group}]", Styles::PURPLE_TEXT)
96
+ lines << " #{group_badge} #{fp.id}"
97
+ lines << " #{styled('at: ', Styles::DIM_TEXT)}#{styled("#{fp.file}:#{fp.line}", Styles::META_TEXT)}" if fp.file
98
+ lines << " #{styled('reason: ', Styles::DIM_TEXT)}#{styled(candidate.reason, Styles::META_TEXT)}"
99
+
100
+ unless candidate.kept_by.empty?
101
+ lines << " #{styled('kept by: ', Styles::DIM_TEXT)}#{styled(candidate.kept_by.join(', '), Styles::DIM_TEXT)}"
102
+ end
103
+
104
+ covers = fp.units.map { |id| @result.label_for(id) }.sort
105
+ covers_text = covers.size <= 4 ? covers.join(' · ') : "#{covers.first(4).join(' · ')} (+#{covers.size - 4} more)"
106
+ lines << " #{styled('covers: ', Styles::DIM_TEXT)}#{styled(covers_text, Styles::DIM_TEXT)}"
107
+
108
+ safety = case candidate.safe
109
+ when true then styled(' ✓ safe — every covered unit is retained by another test', Styles::SAFE_LINE)
110
+ when false then styled(" ✗ NOT safe — #{candidate.safety_note}", Styles::UNSAFE_LINE)
111
+ else styled(' · review-only — not auto-applied', Styles::DIM_TEXT)
112
+ end
113
+ lines << safety
114
+ lines
115
+ end
116
+
117
+ def savings_section
118
+ s = @result.savings
119
+ lines = [
120
+ ' Estimated CI savings',
121
+ " #{styled("#{s.approved_count} test(s)", Styles::GREEN_TEXT)}" \
122
+ "#{styled(' · ', Styles::META_TEXT)}" \
123
+ "#{styled(format('%.4fs saved', s.approved_time), Styles::GREEN_TEXT)}" \
124
+ "#{styled(' · ', Styles::META_TEXT)}" \
125
+ "#{styled(format('~%.1f%% of suite', s.percent_of_test_time), Styles::GREEN_TEXT)}",
126
+ styled(' Note: wall-clock savings lower on parallel CI runners', Styles::META_TEXT)
127
+ ]
128
+
129
+ if tty?
130
+ Styles::REPORT_BOX.render(lines.join("\n"))
131
+ else
132
+ lines.join("\n")
133
+ end
134
+ end
135
+
136
+ def nothing_found_section
137
+ msg = ' Nothing redundant found — suite looks clean.'
138
+ tty? ? Styles::SUCCESS_BOX.render(msg) : msg
139
+ end
140
+
141
+ def cta_line
142
+ " #{styled('Run ', Styles::META_TEXT)}" \
143
+ "#{styled('testprune apply', Styles::PURPLE_TEXT)}" \
144
+ "#{styled(' to review and emit a removal patch.', Styles::META_TEXT)}"
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lipgloss'
4
+
5
+ module Testprune
6
+ module UI
7
+ # Centralized style palette. All UI components pull from here so the visual
8
+ # identity stays consistent and editable in one place.
9
+ #
10
+ # Colors degrade automatically when NO_COLOR=1 is set (lipgloss strips ANSI).
11
+ module Styles
12
+ # ── Color hex constants ──────────────────────────────────────────────────
13
+ PURPLE = '#7D56F4' # brand / borders / interactive
14
+ GREEN = '#22C55E' # HIGH confidence / success / safe
15
+ AMBER = '#F59E0B' # MEDIUM confidence / warnings
16
+ GRAY = '#6B7280' # LOW confidence
17
+ RED = '#EF4444' # errors / unsafe removals
18
+ EMERALD = '#10B981' # scan complete / patch written
19
+ META = '#9CA3AF' # subdued meta-text (kept by, covers, timestamps)
20
+ DIM = '#3D3D5C' # decorators / separators
21
+ TEXT = '#E2E8F0' # default body text
22
+
23
+ # ── Pre-built reusable styles ────────────────────────────────────────────
24
+
25
+ # Rounded box with purple border — used for command headers.
26
+ HEADER_BOX = Lipgloss::Style.new
27
+ .border(:rounded)
28
+ .border_foreground(PURPLE)
29
+ .padding(0, 1)
30
+
31
+ # Rounded box with emerald border — used for success summaries.
32
+ SUCCESS_BOX = Lipgloss::Style.new
33
+ .border(:rounded)
34
+ .border_foreground(EMERALD)
35
+ .padding(0, 1)
36
+
37
+ # Rounded box with purple border — used for report sections and savings.
38
+ REPORT_BOX = Lipgloss::Style.new
39
+ .border(:rounded)
40
+ .border_foreground(PURPLE)
41
+ .padding(0, 1)
42
+
43
+ # Confidence badges
44
+ HIGH_BADGE = Lipgloss::Style.new.foreground(GREEN).bold(true)
45
+ MEDIUM_BADGE = Lipgloss::Style.new.foreground(AMBER).bold(true)
46
+ LOW_BADGE = Lipgloss::Style.new.foreground(GRAY)
47
+
48
+ # Inline text styles
49
+ SAFE_LINE = Lipgloss::Style.new.foreground(GREEN)
50
+ UNSAFE_LINE = Lipgloss::Style.new.foreground(RED).bold(true)
51
+ META_TEXT = Lipgloss::Style.new.foreground(META)
52
+ DIM_TEXT = Lipgloss::Style.new.foreground(DIM)
53
+ PURPLE_TEXT = Lipgloss::Style.new.foreground(PURPLE)
54
+ GREEN_TEXT = Lipgloss::Style.new.foreground(GREEN)
55
+ AMBER_TEXT = Lipgloss::Style.new.foreground(AMBER)
56
+ ERROR_TEXT = Lipgloss::Style.new.foreground(RED).bold(true)
57
+ EMERALD_TEXT = Lipgloss::Style.new.foreground(EMERALD)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lipgloss'
4
+ require_relative 'ui/styles'
5
+ require_relative 'ui/progress'
6
+ require_relative 'ui/error_toggle'
7
+ require_relative 'ui/report_renderer'
8
+
9
+ module Testprune
10
+ # Terminal UI components built on lipgloss-ruby.
11
+ # All components degrade gracefully when NO_COLOR=1 is set or output is not a TTY.
12
+ module UI
13
+ # Returns true when styled output is appropriate for the given IO.
14
+ # Respects NO_COLOR (https://no-color.org/) and non-TTY output streams.
15
+ def self.tty?(io = $stdout)
16
+ return false if ENV['NO_COLOR']
17
+
18
+ io.respond_to?(:isatty) && io.isatty
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Testprune
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: testprune
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seth MacPherson
@@ -29,6 +29,20 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '3'
32
+ - !ruby/object:Gem::Dependency
33
+ name: lipgloss
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0.1'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0.1'
32
46
  description: Combines Ruby's native Coverage execution counts with Prism AST analysis
33
47
  to map per-test coverage onto semantic units (methods, branches, conditions), find
34
48
  redundant tests grouped by duplication type with confidence levels, and emit a removal
@@ -63,6 +77,11 @@ files:
63
77
  - lib/testprune/savings_estimator.rb
64
78
  - lib/testprune/semantic_map.rb
65
79
  - lib/testprune/test_body.rb
80
+ - lib/testprune/ui.rb
81
+ - lib/testprune/ui/error_toggle.rb
82
+ - lib/testprune/ui/progress.rb
83
+ - lib/testprune/ui/report_renderer.rb
84
+ - lib/testprune/ui/styles.rb
66
85
  - lib/testprune/version.rb
67
86
  homepage: https://github.com/seth-macpherson/testprune
68
87
  licenses: