ruby-progress 1.3.1 → 1.3.4

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.
@@ -2,11 +2,14 @@
2
2
 
3
3
  require 'optparse'
4
4
  require 'fileutils'
5
+ require 'json'
6
+ require 'securerandom'
5
7
  require_relative 'cli/fill_options'
6
8
  require_relative 'output_capture'
7
9
 
8
10
  module RubyProgress
9
11
  # CLI module for Fill command
12
+ # rubocop:disable Metrics/ClassLength
10
13
  module FillCLI
11
14
  class << self
12
15
  def run
@@ -35,11 +38,14 @@ module RubyProgress
35
38
 
36
39
  # Handle daemon control first
37
40
  if options[:status] || options[:stop]
38
- pid_file = options[:pid_file] || '/tmp/ruby-progress/fill.pid'
41
+ pid_file = resolve_pid_file(options, :status_name)
39
42
  if options[:status]
40
43
  Daemon.show_status(pid_file)
41
44
  else
42
- Daemon.stop_daemon_by_pid_file(pid_file)
45
+ Daemon.stop_daemon_by_pid_file(pid_file,
46
+ message: options[:stop_success],
47
+ checkmark: options[:stop_checkmark],
48
+ error: !options[:stop_error].nil?)
43
49
  end
44
50
  exit
45
51
  end
@@ -48,6 +54,17 @@ module RubyProgress
48
54
  parsed_style = parse_fill_style(options[:style])
49
55
 
50
56
  if options[:daemon]
57
+ # Resolve pid file and honor daemon-as/name
58
+ pid_file = resolve_pid_file(options, :daemon_name)
59
+ options[:pid_file] = pid_file
60
+
61
+ # Detach or background without detaching based on --no-detach
62
+ if options[:no_detach]
63
+ PrgCLI.backgroundize
64
+ else
65
+ PrgCLI.daemonize
66
+ end
67
+
51
68
  run_daemon_mode(options, parsed_style)
52
69
  elsif options[:current]
53
70
  show_current_percentage(options, parsed_style)
@@ -62,6 +79,14 @@ module RubyProgress
62
79
 
63
80
  private
64
81
 
82
+ def resolve_pid_file(options, name_key = :daemon_name)
83
+ return options[:pid_file] if options[:pid_file]
84
+
85
+ return "/tmp/ruby-progress/#{options[name_key]}.pid" if options[name_key]
86
+
87
+ '/tmp/ruby-progress/fill.pid'
88
+ end
89
+
65
90
  def parse_fill_style(style_option)
66
91
  case style_option
67
92
  when String
@@ -76,9 +101,6 @@ module RubyProgress
76
101
  end
77
102
 
78
103
  def run_daemon_mode(options, parsed_style)
79
- # For daemon mode, detach the process
80
- PrgCLI.daemonize
81
-
82
104
  pid_file = options[:pid_file] || '/tmp/ruby-progress/fill.pid'
83
105
  FileUtils.mkdir_p(File.dirname(pid_file))
84
106
  File.write(pid_file, Process.pid.to_s)
@@ -98,6 +120,72 @@ module RubyProgress
98
120
  begin
99
121
  fill_bar.render # Show initial empty bar
100
122
 
123
+ # Start job processor thread for fill (so daemon can accept jobs)
124
+ job_dir = RubyProgress::Daemon.job_dir_for_pid(pid_file)
125
+ Thread.new do
126
+ RubyProgress::Daemon.process_jobs(job_dir) do |job|
127
+ jid = job['id'] || SecureRandom.uuid
128
+ log_path = begin
129
+ File.join(File.dirname(job_dir), "#{jid}.log")
130
+ rescue StandardError
131
+ nil
132
+ end
133
+
134
+ if job['command']
135
+ oc = RubyProgress::OutputCapture.new(
136
+ command: job['command'],
137
+ lines: options[:output_lines] || 3,
138
+ position: options[:output_position] || :above,
139
+ log_path: log_path,
140
+ stream: options[:stdout_live]
141
+ )
142
+ oc.start
143
+
144
+ fill_bar.instance_variable_set(:@output_capture, oc)
145
+ oc.wait
146
+ captured = oc.lines.join("\n")
147
+ exit_status = oc.exit_status
148
+ fill_bar.instance_variable_set(:@output_capture, nil)
149
+
150
+ success = exit_status.to_i.zero?
151
+ if job['message']
152
+ RubyProgress::Utils.display_completion(
153
+ job['message'],
154
+ success: success,
155
+ show_checkmark: job['checkmark'] || false,
156
+ output_stream: :stdout,
157
+ icons: { success: options[:success_icon], error: options[:error_icon] }
158
+ )
159
+ end
160
+
161
+ { 'exit_status' => exit_status, 'output' => captured, 'log_path' => log_path }
162
+
163
+ elsif job['action']
164
+ case job['action']
165
+ when 'advance'
166
+ fill_bar.advance
167
+ { 'status' => 'done', 'action' => 'advance' }
168
+ when 'percent'
169
+ val = job['value'] || job['percent'] || 0
170
+ fill_bar.percent = val.to_f
171
+ { 'status' => 'done', 'action' => 'percent', 'value' => val }
172
+ when 'complete'
173
+ fill_bar.complete
174
+ { 'status' => 'done', 'action' => 'complete' }
175
+ when 'cancel'
176
+ fill_bar.cancel
177
+ { 'status' => 'done', 'action' => 'cancel' }
178
+ else
179
+ { 'status' => 'error', 'error' => 'unknown action' }
180
+ end
181
+ else
182
+ { 'status' => 'error', 'error' => 'no command or action provided' }
183
+ end
184
+ rescue StandardError
185
+ nil
186
+ end
187
+ end
188
+
101
189
  # Set up signal handlers for daemon control
102
190
  stop_requested = false
103
191
  Signal.trap('INT') { stop_requested = true }
@@ -107,51 +195,82 @@ module RubyProgress
107
195
  # Keep daemon alive until stop requested
108
196
  sleep(0.1) until stop_requested
109
197
  ensure
198
+ # If a control message file exists, print its contents like other CLIs
199
+ cmf = RubyProgress::Daemon.control_message_file(pid_file)
200
+ if File.exist?(cmf)
201
+ begin
202
+ data = JSON.parse(File.read(cmf))
203
+ message = data['message']
204
+ check = if data.key?('checkmark')
205
+ data['checkmark'] ? true : false
206
+ else
207
+ false
208
+ end
209
+
210
+ success_val = if data.key?('success')
211
+ data['success'] ? true : false
212
+ else
213
+ true
214
+ end
215
+ if message
216
+ RubyProgress::Utils.display_completion(
217
+ message,
218
+ success: success_val,
219
+ show_checkmark: check,
220
+ output_stream: :stdout,
221
+ icons: { success: options[:success_icon], error: options[:error_icon] }
222
+ )
223
+ end
224
+ rescue StandardError
225
+ # ignore
226
+ ensure
227
+ begin
228
+ File.delete(cmf)
229
+ rescue StandardError
230
+ nil
231
+ end
232
+ end
233
+ end
234
+
110
235
  Fill.show_cursor
111
236
  FileUtils.rm_f(pid_file)
112
237
  end
113
238
  end
114
239
 
115
- def show_current_percentage(options, _parsed_style)
116
- # Just output the percentage for scripting (default to 50% for demonstration)
117
- percentage = options[:percent] || 50
118
- puts percentage.to_f
119
- end
120
-
121
240
  def show_progress_report(options, parsed_style)
122
- # Create a fill bar to demonstrate current progress
123
- fill_options = {
124
- style: parsed_style,
125
- length: options[:length],
126
- ends: options[:ends]
127
- }
128
-
129
- fill_bar = Fill.new(fill_options)
130
-
131
- # Set percentage (default to 50% for demonstration)
132
- fill_bar.percent = options[:percent] || 50
133
-
134
- # Get detailed report
135
- report = fill_bar.report
136
-
137
- # Display the current progress bar and detailed status
138
- fill_bar.render
139
- puts "\nProgress Report:"
140
- puts " Progress: #{report[:progress][0]}/#{report[:progress][1]}"
141
- puts " Percent: #{report[:percent]}%"
142
- puts " Completed: #{report[:completed] ? 'Yes' : 'No'}"
143
- puts " Style: #{report[:style]}"
241
+ # Produce a simple scripting-friendly report to stdout
242
+ length = options[:length] || 20
243
+ percent = (options[:percent] || 50.0).to_f
244
+ style = parsed_style
245
+
246
+ fill = Fill.new(style: style, length: length)
247
+ fill.percent = percent
248
+
249
+ report = fill.report
250
+ puts 'Progress Report:'
251
+ puts "Progress: #{report[:progress][0]}/#{report[:progress][1]}"
252
+ puts "Percent: #{report[:percent]}%"
253
+ puts "Completed: #{report[:completed] ? 'Yes' : 'No'}"
254
+ puts "Style: #{report[:style].inspect}"
255
+ exit(0)
144
256
  end
145
257
 
146
258
  def handle_progress_commands(_options, _parsed_style)
147
- # For progress commands, we assume there's a daemon running
148
- # This is a simplified version - in a real implementation,
149
- # we'd need IPC to communicate with the daemon
259
+ # For now the progress commands are only supported in daemon mode.
260
+ # Return a clear error to the caller (specs assert this message exists).
150
261
  warn 'Progress commands require daemon mode implementation'
151
- warn "Run 'prg fill --daemon' first, then use progress commands"
152
- exit 1
262
+ exit(1)
263
+ end
264
+
265
+ def show_current_percentage(options, _parsed_style)
266
+ # For scripting and tests: print the current percentage to stdout and exit.
267
+ # If no explicit percent was provided, default to 50.0
268
+ percent = (options[:percent] || 50.0).to_f
269
+ $stdout.print("#{percent}\n")
270
+ exit(0)
153
271
  end
154
272
 
273
+ # Foreground / auto-advance / command mode when not daemonizing
155
274
  def run_auto_advance_mode(options, parsed_style)
156
275
  fill_options = {
157
276
  style: parsed_style,
@@ -161,20 +280,10 @@ module RubyProgress
161
280
  error: options[:error_message]
162
281
  }
163
282
 
164
- # If a command is provided, capture its output and pass an OutputCapture
165
- if options[:command]
166
- oc = RubyProgress::OutputCapture.new(
167
- command: options[:command],
168
- lines: options[:output_lines] || 3,
169
- position: options[:output_position] || :above
170
- )
171
- oc.start
172
- fill_options[:output_capture] = oc
173
- end
174
-
175
283
  fill_bar = Fill.new(fill_options)
176
284
  Fill.hide_cursor
177
285
 
286
+ oc = nil
178
287
  begin
179
288
  if options[:percent]
180
289
  # Set to specific percentage
@@ -185,7 +294,20 @@ module RubyProgress
185
294
  sleep(0.1)
186
295
  end
187
296
  elsif options[:command]
188
- # While the command runs, keep redrawing the bar (live redraw handled by Fill#render)
297
+ # Run the command with OutputCapture
298
+ oc = RubyProgress::OutputCapture.new(
299
+ command: options[:command],
300
+ lines: options[:output_lines] || 3,
301
+ position: options[:output_position] || :above,
302
+ log_path: nil,
303
+ stream: options[:stdout] || options[:stdout_live]
304
+ )
305
+ oc.start
306
+
307
+ # Attach capture to the live fill instance so it can render output
308
+ fill_bar.instance_variable_set(:@output_capture, oc)
309
+
310
+ # While the command runs, keep redrawing the bar
189
311
  sleep_time = case options[:speed]
190
312
  when :fast then 0.1
191
313
  when :medium, nil then 0.2
@@ -195,11 +317,11 @@ module RubyProgress
195
317
  end
196
318
 
197
319
  fill_bar.render
198
- # Loop until the OutputCapture reader has finished
199
320
  while oc.alive?
200
321
  sleep(sleep_time)
201
322
  fill_bar.render
202
323
  end
324
+ fill_bar.instance_variable_set(:@output_capture, nil)
203
325
  else
204
326
  # Auto-advance mode
205
327
  sleep_time = case options[:speed]
@@ -216,6 +338,7 @@ module RubyProgress
216
338
  fill_bar.advance
217
339
  end
218
340
  end
341
+
219
342
  fill_bar.complete
220
343
  rescue Interrupt
221
344
  fill_bar.cancel
@@ -2,27 +2,69 @@
2
2
 
3
3
  require 'pty'
4
4
  require 'io/console'
5
- require 'English'
5
+ require 'english'
6
+ require 'fileutils'
7
+
8
+ begin
9
+ require 'tty-cursor'
10
+ require 'tty-screen'
11
+ rescue LoadError
12
+ # fall back to ANSI sequences when tty gems are not installed
13
+ end
6
14
 
7
15
  module RubyProgress
8
- # Simple PTY-based output capture that reserves a small area of the terminal
9
- # for printing captured output while the animation is drawn separately.
16
+ # PTY-based live output capture that reserves a small terminal area
17
+ # for printing captured output while the animation draws elsewhere.
10
18
  class OutputCapture
11
19
  attr_reader :exit_status
12
20
 
13
- def initialize(command:, lines: 3, position: :above, log_path: nil)
21
+ def initialize(command:, lines: 3, position: :above, log_path: nil, stream: false, debug: nil)
14
22
  @command = command
15
- @lines = lines
16
- @position = position
23
+ # Coerce lines into a positive Integer
24
+ @lines = (lines || 3).to_i
25
+ @lines = 1 if @lines < 1
26
+
27
+ # Normalize position (accept :top/:bottom or :above/:below or strings)
28
+ pos = position.respond_to?(:to_sym) ? position.to_sym : position
29
+ @position = case pos
30
+ when :top, 'top' then :above
31
+ when :bottom, 'bottom' then :below
32
+ when :above, 'above' then :above
33
+ when :below, 'below' then :below
34
+ else
35
+ :above
36
+ end
37
+
17
38
  @buffer = []
18
39
  @buf_mutex = Mutex.new
19
40
  @stop = false
20
41
  @log_path = log_path
21
42
  @log_file = nil
43
+ @stream = stream
44
+
45
+ @debug = if debug.nil?
46
+ ENV.fetch('RUBY_PROGRESS_DEBUG', nil) && ENV['RUBY_PROGRESS_DEBUG'] != '0'
47
+ else
48
+ debug
49
+ end
50
+ @debug_path = '/tmp/ruby-progress-debug.log'
51
+
52
+ if @debug
53
+ begin
54
+ FileUtils.mkdir_p(File.dirname(@debug_path))
55
+ File.open(@debug_path, 'w') { |f| f.puts("debug start: #{Time.now}") }
56
+ rescue StandardError
57
+ @debug = false
58
+ end
59
+ end
60
+
61
+ # Debug: log init if requested via ENV or explicit debug flag
62
+ debug_log("init: position=#{@position.inspect}; lines=#{@lines}")
22
63
  end
23
64
 
24
- # Start the child process and return a thread that manages capture.
65
+ # Start capturing the child process. Returns self.
25
66
  def start
67
+ reserve_space($stderr) if @stream
26
68
  @reader_thread = Thread.new { spawn_and_read }
27
69
  self
28
70
  end
@@ -32,45 +74,98 @@ module RubyProgress
32
74
  @reader_thread&.join
33
75
  end
34
76
 
35
- # Wait for the reader thread to complete
36
77
  def wait
37
78
  @reader_thread&.join
38
79
  end
39
80
 
40
- # Return snapshot of buffered lines (thread-safe)
41
81
  def lines
42
82
  @buf_mutex.synchronize { @buffer.dup }
43
83
  end
44
84
 
45
- # Returns whether the reader thread is still alive
46
85
  def alive?
47
86
  @reader_thread&.alive? || false
48
87
  end
49
88
 
50
- # Redraw buffered output into the terminal above/below the current cursor
51
- # io - IO object to write to (default $stderr)
89
+ # Redraw the reserved area using the current buffered lines.
52
90
  def redraw(io = $stderr)
53
91
  buf = lines
54
- _rows, cols = IO.console.winsize
92
+ debug_log("redraw called; buffer=#{buf.size}; lines=#{@lines}; position=#{@position}")
93
+
94
+ # If not streaming live to the terminal, don't redraw during capture.
95
+ return unless @stream
55
96
 
56
- # Ensure we have exactly @lines entries (pad with empty strings)
57
- display_lines = Array.new(@lines) { '' }
58
- start = [0, buf.size - @lines].max
59
- buf[start, @lines]&.each_with_index do |l, i|
60
- display_lines[i + (@lines - [buf.size, @lines].min)] = l.to_s
97
+ cols = if defined?(TTY::Screen)
98
+ TTY::Screen.columns
99
+ else
100
+ IO.console.winsize[1]
101
+ end
102
+
103
+ display_lines = Array.new(@lines, '')
104
+ if buf.empty?
105
+ # leave display_lines as blanks
106
+ elsif buf.size <= @lines
107
+ buf.each_with_index { |l, i| display_lines[i] = l.to_s }
108
+ else
109
+ buf.last(@lines).each_with_index { |l, i| display_lines[i] = l.to_s }
61
110
  end
62
111
 
63
- # Save cursor, move up N lines, clear and print buffer, restore cursor
64
- io.print "\e[s" # save position
65
- io.print "\e[#{@lines}A" # move up @lines
66
- display_lines.each do |line|
67
- io.print "\e[2K" # clear line
68
- io.print "\r" # move cursor to start of line
69
- io.print line[0, cols]
70
- io.print "\n"
112
+ if defined?(TTY::Cursor)
113
+ cursor = TTY::Cursor
114
+ io.print cursor.save
115
+
116
+ if @position == :above
117
+ io.print cursor.up(@lines)
118
+ else
119
+ io.print cursor.down(1)
120
+ end
121
+
122
+ display_lines.each_with_index do |line, idx|
123
+ io.print cursor.clear_line
124
+ io.print line[0, cols]
125
+ io.print cursor.down(1) unless idx == display_lines.length - 1
126
+ end
127
+
128
+ io.print cursor.restore
129
+ debug_log('redraw finished (TTY)')
130
+ else
131
+ io.print "\e7"
132
+
133
+ if @position == :above
134
+ io.print "\e[#{@lines}A"
135
+ else
136
+ io.print "\e[1B"
137
+ end
138
+
139
+ display_lines.each_with_index do |line, idx|
140
+ io.print "\e[2K\r"
141
+ io.print line[0, cols]
142
+ io.print "\e[1B" unless idx == display_lines.length - 1
143
+ end
144
+
145
+ io.print "\e8"
146
+ debug_log('redraw finished (ANSI)')
71
147
  end
72
- io.print "\e[u" # restore
148
+
73
149
  io.flush
150
+ rescue StandardError => e
151
+ debug_log("redraw error: #{e.class}: #{e.message}")
152
+ end
153
+
154
+ # Flush the buffered lines to the given IO (defaults to STDOUT).
155
+ # This is used when capturing non-live output: capture silently during
156
+ # the run and emit all captured output at the end.
157
+ def flush_to(io = $stdout)
158
+ buf = lines
159
+ return if buf.empty?
160
+
161
+ begin
162
+ buf.each do |line|
163
+ io.puts(line)
164
+ end
165
+ io.flush
166
+ rescue StandardError => e
167
+ debug_log("flush_to error: #{e.class}: #{e.message}")
168
+ end
74
169
  end
75
170
 
76
171
  private
@@ -78,13 +173,16 @@ module RubyProgress
78
173
  def spawn_and_read
79
174
  PTY.spawn(@command) do |reader, _writer, pid|
80
175
  @child_pid = pid
176
+ debug_log("spawned pid=#{pid} cmd=#{@command}")
177
+
81
178
  until reader.eof? || @stop
82
179
  next unless reader.wait_readable(0.1)
83
180
 
84
181
  chunk = reader.read_nonblock(4096, exception: false)
85
182
  next if chunk.nil? || chunk.empty?
86
183
 
87
- # lazily open log file if requested
184
+ debug_log("read chunk=#{chunk.inspect}")
185
+
88
186
  if @log_path && !@log_file
89
187
  begin
90
188
  FileUtils.mkdir_p(File.dirname(@log_path))
@@ -95,29 +193,26 @@ module RubyProgress
95
193
  end
96
194
 
97
195
  process_chunk(chunk)
196
+ debug_log("after process_chunk buffer_size=#{@buffer.size}")
197
+
98
198
  next unless @log_file
99
199
 
100
200
  begin
101
201
  @log_file.write(chunk)
102
202
  @log_file.flush
103
203
  rescue StandardError
104
- # ignore logging errors
204
+ # ignore
105
205
  end
106
206
  end
107
207
  end
208
+
108
209
  begin
109
210
  Process.wait(@child_pid) if @child_pid
110
211
  @exit_status = $CHILD_STATUS.exitstatus if $CHILD_STATUS
111
212
  rescue StandardError
112
213
  @exit_status = nil
113
214
  ensure
114
- if @log_file
115
- begin
116
- @log_file.close
117
- rescue StandardError
118
- nil
119
- end
120
- end
215
+ @log_file&.close
121
216
  end
122
217
  rescue Errno::EIO
123
218
  # PTY finished
@@ -125,12 +220,49 @@ module RubyProgress
125
220
 
126
221
  def process_chunk(chunk)
127
222
  @buf_mutex.synchronize do
128
- # split into lines, keep last N lines
129
223
  chunk.each_line do |line|
130
224
  @buffer << line.chomp
131
225
  @buffer.shift while @buffer.size > @lines
132
226
  end
133
227
  end
228
+
229
+ debug_log("process_chunk: buffer=#{@buffer.inspect}")
230
+ redraw($stderr) if @stream
231
+ rescue StandardError => e
232
+ debug_log("process_chunk error: #{e.class}: #{e.message}")
233
+ end
234
+
235
+ def debug_log(msg)
236
+ return unless ENV['RUBY_PROGRESS_DEBUG'] || @debug
237
+
238
+ begin
239
+ File.open(@debug_path, 'a') do |f|
240
+ f.puts("#{Time.now.iso8601} PID=#{Process.pid} #{msg}")
241
+ end
242
+ rescue StandardError
243
+ # swallow logging errors
244
+ end
245
+ end
246
+
247
+ def reserve_space(io = $stderr)
248
+ return unless io.tty?
249
+
250
+ debug_log("reserve_space called; position=#{@position.inspect}; lines=#{@lines}")
251
+
252
+ if @position == :above
253
+ # Insert lines above current cursor using CSI n L
254
+ io.print "\e[#{@lines}L"
255
+ debug_log("reserve_space: inserted #{@lines} lines for :above")
256
+ else
257
+ # Print newlines then move cursor back up so animation stays above
258
+ io.print("\n" * @lines)
259
+ io.print "\e[#{@lines}A"
260
+ debug_log("reserve_space: printed #{@lines} newlines and moved up #{@lines} for :below")
261
+ end
262
+
263
+ io.flush
264
+ rescue StandardError => e
265
+ debug_log("reserve_space error: #{e.class}: #{e.message}")
134
266
  end
135
267
  end
136
268
  end
@@ -154,7 +154,7 @@ module RubyProgress
154
154
  RubyProgress::Utils.show_cursor
155
155
  end
156
156
 
157
- def self.complete(string, message, checkmark, success)
157
+ def self.complete(string, message, checkmark, success, icons: {})
158
158
  display_message = message || (checkmark ? string : nil)
159
159
  return unless display_message
160
160
 
@@ -162,7 +162,8 @@ module RubyProgress
162
162
  display_message,
163
163
  success: success,
164
164
  show_checkmark: checkmark,
165
- output_stream: :warn
165
+ output_stream: :warn,
166
+ icons: icons
166
167
  )
167
168
  end
168
169