ruby-progress 1.2.4 → 1.3.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'fileutils'
4
5
 
5
6
  module RubyProgress
6
7
  module Daemon
@@ -14,6 +15,70 @@ module RubyProgress
14
15
  "#{pid_file}.msg"
15
16
  end
16
17
 
18
+ # Resolve a job directory for the daemon based on pid_file or name.
19
+ # If pid_file is '/tmp/ruby-progress/mytask.pid' -> jobs dir '/tmp/ruby-progress/mytask.jobs'
20
+ def job_dir_for_pid(pid_file)
21
+ base = File.basename(pid_file, '.*')
22
+ File.join(File.dirname(pid_file), "#{base}.jobs")
23
+ end
24
+
25
+ # Process available job files in job_dir. Each job is a JSON file with {"id","command","meta"}.
26
+ # This method polls the directory and yields each parsed job hash to the provided block.
27
+ def process_jobs(job_dir, poll_interval: 0.2)
28
+ FileUtils.mkdir_p(job_dir)
29
+
30
+ loop do
31
+ # Accept any job file ending in .json (UUID filenames are common)
32
+ # Ignore processed-* archives and temporary files (e.g., .tmp)
33
+ files = Dir.children(job_dir).select do |f|
34
+ f.end_with?('.json') && !f.start_with?('processed-')
35
+ end.sort
36
+
37
+ files.each do |f|
38
+ path = File.join(job_dir, f)
39
+ processing = "#{path}.processing"
40
+
41
+ # Claim the file atomically
42
+ begin
43
+ File.rename(path, processing)
44
+ rescue StandardError
45
+ next
46
+ end
47
+
48
+ job = begin
49
+ JSON.parse(File.read(processing))
50
+ rescue StandardError
51
+ FileUtils.rm_f(processing)
52
+ next
53
+ end
54
+
55
+ begin
56
+ yielded = yield(job)
57
+
58
+ # on success, write .result info and merge any returned info
59
+ result = { 'id' => job['id'], 'status' => 'done', 'time' => Time.now.to_i }
60
+ if yielded.is_a?(Hash)
61
+ # ensure string keys
62
+ extra = yielded.transform_keys(&:to_s)
63
+ result.merge!(extra)
64
+ end
65
+ File.write("#{processing}.result", result.to_json)
66
+ rescue StandardError => e
67
+ result = { 'id' => job['id'], 'status' => 'error', 'error' => e.message }
68
+ File.write("#{processing}.result", result.to_json)
69
+ ensure
70
+ begin
71
+ FileUtils.mv(processing, File.join(job_dir, "processed-#{f}"))
72
+ rescue StandardError
73
+ FileUtils.rm_f(processing)
74
+ end
75
+ end
76
+ end
77
+
78
+ sleep(poll_interval)
79
+ end
80
+ end
81
+
17
82
  def show_status(pid_file)
18
83
  if File.exist?(pid_file)
19
84
  pid = File.read(pid_file).strip
@@ -25,6 +25,7 @@ module RubyProgress
25
25
  @current_progress = 0
26
26
  @success_message = options[:success]
27
27
  @error_message = options[:error]
28
+ @output_capture = options[:output_capture]
28
29
 
29
30
  # Parse --ends characters
30
31
  if options[:ends]
@@ -82,6 +83,9 @@ module RubyProgress
82
83
 
83
84
  # Render the current progress bar to stderr
84
85
  def render
86
+ # First redraw captured output (if any) so it appears above/below the bar
87
+ @output_capture&.redraw($stderr)
88
+
85
89
  filled = @style[:full] * @current_progress
86
90
  empty = @style[:empty] * (@length - @current_progress)
87
91
  bar = "#{@start_chars}#{filled}#{empty}#{@end_chars}"
@@ -91,7 +95,7 @@ module RubyProgress
91
95
  end
92
96
 
93
97
  # Complete the progress bar and show success message
94
- def complete(message = nil)
98
+ def complete(message = nil, icons: {})
95
99
  @current_progress = @length
96
100
  render
97
101
 
@@ -101,7 +105,8 @@ module RubyProgress
101
105
  completion_message,
102
106
  success: true,
103
107
  show_checkmark: true,
104
- output_stream: :warn
108
+ output_stream: :warn,
109
+ icons: icons
105
110
  )
106
111
  else
107
112
  $stderr.puts # Just add a newline if no message
@@ -120,7 +125,8 @@ module RubyProgress
120
125
  error_msg,
121
126
  success: false,
122
127
  show_checkmark: true,
123
- output_stream: :warn
128
+ output_stream: :warn,
129
+ icons: {}
124
130
  )
125
131
  end
126
132
 
@@ -2,10 +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'
8
+ require_relative 'output_capture'
6
9
 
7
10
  module RubyProgress
8
11
  # CLI module for Fill command
12
+ # rubocop:disable Metrics/ClassLength
9
13
  module FillCLI
10
14
  class << self
11
15
  def run
@@ -34,11 +38,14 @@ module RubyProgress
34
38
 
35
39
  # Handle daemon control first
36
40
  if options[:status] || options[:stop]
37
- pid_file = options[:pid_file] || '/tmp/ruby-progress/fill.pid'
41
+ pid_file = resolve_pid_file(options, :status_name)
38
42
  if options[:status]
39
43
  Daemon.show_status(pid_file)
40
44
  else
41
- 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?)
42
49
  end
43
50
  exit
44
51
  end
@@ -47,6 +54,17 @@ module RubyProgress
47
54
  parsed_style = parse_fill_style(options[:style])
48
55
 
49
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
+
50
68
  run_daemon_mode(options, parsed_style)
51
69
  elsif options[:current]
52
70
  show_current_percentage(options, parsed_style)
@@ -61,6 +79,14 @@ module RubyProgress
61
79
 
62
80
  private
63
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
+
64
90
  def parse_fill_style(style_option)
65
91
  case style_option
66
92
  when String
@@ -75,9 +101,6 @@ module RubyProgress
75
101
  end
76
102
 
77
103
  def run_daemon_mode(options, parsed_style)
78
- # For daemon mode, detach the process
79
- PrgCLI.daemonize
80
-
81
104
  pid_file = options[:pid_file] || '/tmp/ruby-progress/fill.pid'
82
105
  FileUtils.mkdir_p(File.dirname(pid_file))
83
106
  File.write(pid_file, Process.pid.to_s)
@@ -97,6 +120,71 @@ module RubyProgress
97
120
  begin
98
121
  fill_bar.render # Show initial empty bar
99
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
+ )
141
+ oc.start
142
+
143
+ fill_bar.instance_variable_set(:@output_capture, oc)
144
+ oc.wait
145
+ captured = oc.lines.join("\n")
146
+ exit_status = oc.exit_status
147
+ fill_bar.instance_variable_set(:@output_capture, nil)
148
+
149
+ success = exit_status.to_i.zero?
150
+ if job['message']
151
+ RubyProgress::Utils.display_completion(
152
+ job['message'],
153
+ success: success,
154
+ show_checkmark: job['checkmark'] || false,
155
+ output_stream: :stdout,
156
+ icons: { success: options[:success_icon], error: options[:error_icon] }
157
+ )
158
+ end
159
+
160
+ { 'exit_status' => exit_status, 'output' => captured, 'log_path' => log_path }
161
+
162
+ elsif job['action']
163
+ case job['action']
164
+ when 'advance'
165
+ fill_bar.advance
166
+ { 'status' => 'done', 'action' => 'advance' }
167
+ when 'percent'
168
+ val = job['value'] || job['percent'] || 0
169
+ fill_bar.percent = val.to_f
170
+ { 'status' => 'done', 'action' => 'percent', 'value' => val }
171
+ when 'complete'
172
+ fill_bar.complete
173
+ { 'status' => 'done', 'action' => 'complete' }
174
+ when 'cancel'
175
+ fill_bar.cancel
176
+ { 'status' => 'done', 'action' => 'cancel' }
177
+ else
178
+ { 'status' => 'error', 'error' => 'unknown action' }
179
+ end
180
+ else
181
+ { 'status' => 'error', 'error' => 'no command or action provided' }
182
+ end
183
+ rescue StandardError
184
+ nil
185
+ end
186
+ end
187
+
100
188
  # Set up signal handlers for daemon control
101
189
  stop_requested = false
102
190
  Signal.trap('INT') { stop_requested = true }
@@ -106,51 +194,82 @@ module RubyProgress
106
194
  # Keep daemon alive until stop requested
107
195
  sleep(0.1) until stop_requested
108
196
  ensure
197
+ # If a control message file exists, print its contents like other CLIs
198
+ cmf = RubyProgress::Daemon.control_message_file(pid_file)
199
+ if File.exist?(cmf)
200
+ begin
201
+ data = JSON.parse(File.read(cmf))
202
+ message = data['message']
203
+ check = if data.key?('checkmark')
204
+ data['checkmark'] ? true : false
205
+ else
206
+ false
207
+ end
208
+
209
+ success_val = if data.key?('success')
210
+ data['success'] ? true : false
211
+ else
212
+ true
213
+ end
214
+ if message
215
+ RubyProgress::Utils.display_completion(
216
+ message,
217
+ success: success_val,
218
+ show_checkmark: check,
219
+ output_stream: :stdout,
220
+ icons: { success: options[:success_icon], error: options[:error_icon] }
221
+ )
222
+ end
223
+ rescue StandardError
224
+ # ignore
225
+ ensure
226
+ begin
227
+ File.delete(cmf)
228
+ rescue StandardError
229
+ nil
230
+ end
231
+ end
232
+ end
233
+
109
234
  Fill.show_cursor
110
235
  FileUtils.rm_f(pid_file)
111
236
  end
112
237
  end
113
238
 
114
- def show_current_percentage(options, _parsed_style)
115
- # Just output the percentage for scripting (default to 50% for demonstration)
116
- percentage = options[:percent] || 50
117
- puts percentage.to_f
118
- end
119
-
120
239
  def show_progress_report(options, parsed_style)
121
- # Create a fill bar to demonstrate current progress
122
- fill_options = {
123
- style: parsed_style,
124
- length: options[:length],
125
- ends: options[:ends]
126
- }
127
-
128
- fill_bar = Fill.new(fill_options)
129
-
130
- # Set percentage (default to 50% for demonstration)
131
- fill_bar.percent = options[:percent] || 50
132
-
133
- # Get detailed report
134
- report = fill_bar.report
135
-
136
- # Display the current progress bar and detailed status
137
- fill_bar.render
138
- puts "\nProgress Report:"
139
- puts " Progress: #{report[:progress][0]}/#{report[:progress][1]}"
140
- puts " Percent: #{report[:percent]}%"
141
- puts " Completed: #{report[:completed] ? 'Yes' : 'No'}"
142
- puts " Style: #{report[:style]}"
240
+ # Produce a simple scripting-friendly report to stdout
241
+ length = options[:length] || 20
242
+ percent = (options[:percent] || 50.0).to_f
243
+ style = parsed_style
244
+
245
+ fill = Fill.new(style: style, length: length)
246
+ fill.percent = percent
247
+
248
+ report = fill.report
249
+ puts 'Progress Report:'
250
+ puts "Progress: #{report[:progress][0]}/#{report[:progress][1]}"
251
+ puts "Percent: #{report[:percent]}%"
252
+ puts "Completed: #{report[:completed] ? 'Yes' : 'No'}"
253
+ puts "Style: #{report[:style].inspect}"
254
+ exit(0)
143
255
  end
144
256
 
145
257
  def handle_progress_commands(_options, _parsed_style)
146
- # For progress commands, we assume there's a daemon running
147
- # This is a simplified version - in a real implementation,
148
- # we'd need IPC to communicate with the daemon
258
+ # For now the progress commands are only supported in daemon mode.
259
+ # Return a clear error to the caller (specs assert this message exists).
149
260
  warn 'Progress commands require daemon mode implementation'
150
- warn "Run 'prg fill --daemon' first, then use progress commands"
151
- exit 1
261
+ exit(1)
152
262
  end
153
263
 
264
+ def show_current_percentage(options, _parsed_style)
265
+ # For scripting and tests: print the current percentage to stdout and exit.
266
+ # If no explicit percent was provided, default to 50.0
267
+ percent = (options[:percent] || 50.0).to_f
268
+ $stdout.print("#{percent}\n")
269
+ exit(0)
270
+ end
271
+
272
+ # Foreground / auto-advance / command mode when not daemonizing
154
273
  def run_auto_advance_mode(options, parsed_style)
155
274
  fill_options = {
156
275
  style: parsed_style,
@@ -163,6 +282,7 @@ module RubyProgress
163
282
  fill_bar = Fill.new(fill_options)
164
283
  Fill.hide_cursor
165
284
 
285
+ oc = nil
166
286
  begin
167
287
  if options[:percent]
168
288
  # Set to specific percentage
@@ -172,6 +292,34 @@ module RubyProgress
172
292
  # For non-complete percentages, show the result briefly
173
293
  sleep(0.1)
174
294
  end
295
+ elsif options[:command]
296
+ # Run the command with OutputCapture
297
+ oc = RubyProgress::OutputCapture.new(
298
+ command: options[:command],
299
+ lines: options[:output_lines] || 3,
300
+ position: options[:output_position] || :above,
301
+ log_path: nil
302
+ )
303
+ oc.start
304
+
305
+ # Attach capture to the live fill instance so it can render output
306
+ fill_bar.instance_variable_set(:@output_capture, oc)
307
+
308
+ # While the command runs, keep redrawing the bar
309
+ sleep_time = case options[:speed]
310
+ when :fast then 0.1
311
+ when :medium, nil then 0.2
312
+ when :slow then 0.5
313
+ when Numeric then 1.0 / options[:speed]
314
+ else 0.3
315
+ end
316
+
317
+ fill_bar.render
318
+ while oc.alive?
319
+ sleep(sleep_time)
320
+ fill_bar.render
321
+ end
322
+ fill_bar.instance_variable_set(:@output_capture, nil)
175
323
  else
176
324
  # Auto-advance mode
177
325
  sleep_time = case options[:speed]
@@ -188,10 +336,13 @@ module RubyProgress
188
336
  fill_bar.advance
189
337
  end
190
338
  end
339
+
191
340
  fill_bar.complete
192
341
  rescue Interrupt
193
342
  fill_bar.cancel
194
343
  ensure
344
+ # Ensure we wait for capture thread to finish and show cursor
345
+ oc&.wait
195
346
  Fill.show_cursor
196
347
  end
197
348
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pty'
4
+ require 'io/console'
5
+ require 'English'
6
+
7
+ 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.
10
+ class OutputCapture
11
+ attr_reader :exit_status
12
+
13
+ def initialize(command:, lines: 3, position: :above, log_path: nil)
14
+ @command = command
15
+ @lines = lines
16
+ @position = position
17
+ @buffer = []
18
+ @buf_mutex = Mutex.new
19
+ @stop = false
20
+ @log_path = log_path
21
+ @log_file = nil
22
+ end
23
+
24
+ # Start the child process and return a thread that manages capture.
25
+ def start
26
+ @reader_thread = Thread.new { spawn_and_read }
27
+ self
28
+ end
29
+
30
+ def stop
31
+ @stop = true
32
+ @reader_thread&.join
33
+ end
34
+
35
+ # Wait for the reader thread to complete
36
+ def wait
37
+ @reader_thread&.join
38
+ end
39
+
40
+ # Return snapshot of buffered lines (thread-safe)
41
+ def lines
42
+ @buf_mutex.synchronize { @buffer.dup }
43
+ end
44
+
45
+ # Returns whether the reader thread is still alive
46
+ def alive?
47
+ @reader_thread&.alive? || false
48
+ end
49
+
50
+ # Redraw buffered output into the terminal above/below the current cursor
51
+ # io - IO object to write to (default $stderr)
52
+ def redraw(io = $stderr)
53
+ buf = lines
54
+ _rows, cols = IO.console.winsize
55
+
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
61
+ end
62
+
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"
71
+ end
72
+ io.print "\e[u" # restore
73
+ io.flush
74
+ end
75
+
76
+ private
77
+
78
+ def spawn_and_read
79
+ PTY.spawn(@command) do |reader, _writer, pid|
80
+ @child_pid = pid
81
+ until reader.eof? || @stop
82
+ next unless reader.wait_readable(0.1)
83
+
84
+ chunk = reader.read_nonblock(4096, exception: false)
85
+ next if chunk.nil? || chunk.empty?
86
+
87
+ # lazily open log file if requested
88
+ if @log_path && !@log_file
89
+ begin
90
+ FileUtils.mkdir_p(File.dirname(@log_path))
91
+ @log_file = File.open(@log_path, 'a')
92
+ rescue StandardError
93
+ @log_file = nil
94
+ end
95
+ end
96
+
97
+ process_chunk(chunk)
98
+ next unless @log_file
99
+
100
+ begin
101
+ @log_file.write(chunk)
102
+ @log_file.flush
103
+ rescue StandardError
104
+ # ignore logging errors
105
+ end
106
+ end
107
+ end
108
+ begin
109
+ Process.wait(@child_pid) if @child_pid
110
+ @exit_status = $CHILD_STATUS.exitstatus if $CHILD_STATUS
111
+ rescue StandardError
112
+ @exit_status = nil
113
+ ensure
114
+ if @log_file
115
+ begin
116
+ @log_file.close
117
+ rescue StandardError
118
+ nil
119
+ end
120
+ end
121
+ end
122
+ rescue Errno::EIO
123
+ # PTY finished
124
+ end
125
+
126
+ def process_chunk(chunk)
127
+ @buf_mutex.synchronize do
128
+ # split into lines, keep last N lines
129
+ chunk.each_line do |line|
130
+ @buffer << line.chomp
131
+ @buffer.shift while @buffer.size > @lines
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -140,6 +140,7 @@ module RubyProgress
140
140
  char = @rainbow ? char.rainbow(i) : char.extend(StringExtensions).light_white
141
141
  post = letters.slice!(0, letters.length).join.extend(StringExtensions).dark_white
142
142
  end
143
+ @output_capture&.redraw($stderr)
143
144
  $stderr.print "\r\e[2K#{@start_chars}#{pre}#{char}#{post}#{@end_chars}"
144
145
  $stderr.flush
145
146
  end
@@ -153,7 +154,7 @@ module RubyProgress
153
154
  RubyProgress::Utils.show_cursor
154
155
  end
155
156
 
156
- def self.complete(string, message, checkmark, success)
157
+ def self.complete(string, message, checkmark, success, icons: {})
157
158
  display_message = message || (checkmark ? string : nil)
158
159
  return unless display_message
159
160
 
@@ -161,7 +162,8 @@ module RubyProgress
161
162
  display_message,
162
163
  success: success,
163
164
  show_checkmark: checkmark,
164
- output_stream: :warn
165
+ output_stream: :warn,
166
+ icons: icons
165
167
  )
166
168
  end
167
169