taski 0.2.3 → 0.3.1

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,356 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "stringio"
3
+ require_relative "progress/terminal_controller"
4
+ require_relative "progress/spinner_animation"
5
+ require_relative "progress/output_capture"
6
+ require_relative "progress/display_manager"
4
7
 
5
8
  module Taski
6
- # Terminal control operations with ANSI escape sequences
7
- class TerminalController
8
- # ANSI escape sequences
9
- MOVE_UP = "\033[A"
10
- CLEAR_LINE = "\033[K"
11
- MOVE_UP_AND_CLEAR = "#{MOVE_UP}#{CLEAR_LINE}"
9
+ # Backward compatibility aliases
10
+ TerminalController = Progress::TerminalController
11
+ SpinnerAnimation = Progress::SpinnerAnimation
12
+ OutputCapture = Progress::OutputCapture
13
+ TaskStatus = Progress::TaskStatus
12
14
 
13
- def initialize(output)
14
- @output = output
15
- end
16
-
17
- def clear_lines(count)
18
- return if count == 0
19
-
20
- count.times { @output.print MOVE_UP_AND_CLEAR }
21
- end
22
-
23
- def puts(text)
24
- @output.puts text
25
- end
26
-
27
- def print(text)
28
- @output.print text
29
- end
30
-
31
- def flush
32
- @output.flush
33
- end
34
- end
35
-
36
- # Spinner animation with dots-style characters
37
- class SpinnerAnimation
38
- SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].freeze
39
- FRAME_DELAY = 0.1
40
-
41
- def initialize
42
- @frame = 0
43
- @running = false
44
- @thread = nil
45
- end
46
-
47
- def start(terminal, task_name, &display_callback)
48
- return if @running
49
-
50
- @running = true
51
- @frame = 0
52
-
53
- @thread = Thread.new do
54
- while @running
55
- current_char = SPINNER_CHARS[@frame % SPINNER_CHARS.length]
56
- display_callback&.call(current_char, task_name)
57
-
58
- @frame += 1
59
- sleep FRAME_DELAY
60
- end
61
- rescue
62
- # Silently handle thread errors
63
- end
64
- end
65
-
66
- def stop
67
- @running = false
68
- @thread&.join(0.2)
69
- @thread = nil
70
- end
71
-
72
- def running?
73
- @running
74
- end
75
- end
76
-
77
- # Captures stdout and maintains last N lines like tail -f
78
- class OutputCapture
79
- MAX_LINES = 10
80
- DISPLAY_LINES = 5
81
-
82
- def initialize(main_output)
83
- @main_output = main_output
84
- @buffer = []
85
- @capturing = false
86
- @original_stdout = nil
87
- @pipe_reader = nil
88
- @pipe_writer = nil
89
- @capture_thread = nil
90
- end
91
-
92
- def start
93
- return if @capturing
94
-
95
- @buffer.clear
96
- setup_stdout_redirection
97
- @capturing = true
98
-
99
- start_capture_thread
100
- end
101
-
102
- def stop
103
- return unless @capturing
104
-
105
- @capturing = false
106
-
107
- # Restore stdout
108
- restore_stdout
109
-
110
- # Clean up pipes and thread
111
- cleanup_capture_thread
112
- cleanup_pipes
113
- end
114
-
115
- def last_lines
116
- @buffer.last(DISPLAY_LINES)
117
- end
118
-
119
- def capturing?
120
- @capturing
121
- end
122
-
123
- private
124
-
125
- def setup_stdout_redirection
126
- @original_stdout = $stdout
127
- @pipe_reader, @pipe_writer = IO.pipe
128
- $stdout = @pipe_writer
129
- end
130
-
131
- def restore_stdout
132
- return unless @original_stdout
133
-
134
- $stdout = @original_stdout
135
- @original_stdout = nil
136
- end
137
-
138
- def start_capture_thread
139
- @capture_thread = Thread.new do
140
- while (line = @pipe_reader.gets)
141
- line = line.chomp
142
- next if line.empty?
143
- next if skip_line?(line)
144
-
145
- add_line_to_buffer(line)
146
- end
147
- rescue IOError
148
- # Pipe closed, normal termination
149
- end
150
- end
151
-
152
- def skip_line?(line)
153
- # Skip logger lines (they appear separately)
154
- line.match?(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]/)
155
- end
156
-
157
- def add_line_to_buffer(line)
158
- @buffer << line
159
- @buffer.shift while @buffer.length > MAX_LINES
160
- end
161
-
162
- def cleanup_capture_thread
163
- @capture_thread&.join(0.1)
164
- @capture_thread = nil
165
- end
166
-
167
- def cleanup_pipes
168
- [@pipe_writer, @pipe_reader].each do |pipe|
169
- pipe&.close
170
- rescue IOError
171
- # Already closed, ignore
172
- end
173
- @pipe_writer = @pipe_reader = nil
174
- end
175
- end
176
-
177
- # Represents task execution status
178
- class TaskStatus
179
- attr_reader :name, :duration, :error
180
-
181
- def initialize(name:, duration: nil, error: nil)
182
- @name = name
183
- @duration = duration
184
- @error = error
185
- end
186
-
187
- def success?
188
- @error.nil?
189
- end
190
-
191
- def failure?
192
- !success?
193
- end
194
-
195
- def duration_ms
196
- return nil unless @duration
197
- (@duration * 1000).round(1)
198
- end
199
-
200
- def icon
201
- success? ? "✅" : "❌"
202
- end
203
-
204
- def format_duration
205
- return "" unless duration_ms
206
- "(#{duration_ms}ms)"
207
- end
208
- end
209
-
210
- # Main progress display controller
15
+ # Main progress display controller - refactored for better separation of concerns
211
16
  class ProgressDisplay
212
- # ANSI colors
213
- COLORS = {
214
- reset: "\033[0m",
215
- bold: "\033[1m",
216
- dim: "\033[2m",
217
- cyan: "\033[36m",
218
- green: "\033[32m",
219
- red: "\033[31m"
220
- }.freeze
221
-
222
- def initialize(output: $stdout, force_enable: nil)
17
+ def initialize(output: $stdout, enable: true, include_captured_output: nil)
223
18
  @output = output
224
- @terminal = TerminalController.new(output)
225
- @spinner = SpinnerAnimation.new
226
- @output_capture = OutputCapture.new(output)
19
+ @terminal = Progress::TerminalController.new(output)
20
+ @spinner = Progress::SpinnerAnimation.new
21
+ @output_capture = Progress::OutputCapture.new(output)
227
22
 
228
- # Enable if TTY or force enabled or environment variable set
229
- @enabled = force_enable.nil? ? (output.tty? || ENV["TASKI_FORCE_PROGRESS"] == "1") : force_enable
23
+ include_captured_output = include_captured_output.nil? ? (output != $stdout) : include_captured_output
24
+ @display_manager = Progress::DisplayManager.new(@terminal, @spinner, @output_capture, include_captured_output: include_captured_output)
230
25
 
231
- @completed_tasks = []
232
- @current_display_lines = 0
26
+ @enabled = ENV["TASKI_PROGRESS_DISABLE"] != "1" && enable
233
27
  end
234
28
 
235
29
  def start_task(task_name, dependencies: [])
236
- puts "DEBUG: start_task called for #{task_name}, enabled: #{@enabled}" if ENV["TASKI_DEBUG"]
237
30
  return unless @enabled
238
31
 
239
- clear_current_display
240
- @output_capture.start
241
-
242
- start_spinner_display(task_name)
32
+ @display_manager.start_task_display(task_name)
243
33
  end
244
34
 
245
35
  def complete_task(task_name, duration:)
246
36
  return unless @enabled
247
37
 
248
- status = TaskStatus.new(name: task_name, duration: duration)
249
- finish_task(status)
38
+ @display_manager.complete_task_display(task_name, duration: duration)
250
39
  end
251
40
 
252
41
  def fail_task(task_name, error:, duration:)
253
42
  return unless @enabled
254
43
 
255
- status = TaskStatus.new(name: task_name, duration: duration, error: error)
256
- finish_task(status)
44
+ @display_manager.fail_task_display(task_name, error: error, duration: duration)
257
45
  end
258
46
 
259
47
  def clear
260
48
  return unless @enabled
261
49
 
262
- @spinner.stop
263
- @output_capture.stop
264
- clear_current_display
265
-
266
- # Display final summary of all completed tasks
267
- if @completed_tasks.any?
268
- @completed_tasks.each do |status|
269
- @terminal.puts format_completed_task(status)
270
- end
271
- @terminal.flush
272
- end
273
-
274
- @completed_tasks.clear
275
- @current_display_lines = 0
50
+ @display_manager.clear_all_displays
276
51
  end
277
52
 
278
53
  def enabled?
279
54
  @enabled
280
55
  end
281
-
282
- private
283
-
284
- def start_spinner_display(task_name)
285
- @spinner.start(@terminal, task_name) do |spinner_char, name|
286
- display_current_state(spinner_char, name)
287
- end
288
- end
289
-
290
- def display_current_state(spinner_char, task_name)
291
- clear_current_display
292
-
293
- lines_count = 0
294
-
295
- # Only display current task with spinner (no past completed tasks during execution)
296
- @terminal.puts format_current_task(spinner_char, task_name)
297
- lines_count += 1
298
-
299
- # Display output lines
300
- @output_capture.last_lines.each do |line|
301
- @terminal.puts format_output_line(line)
302
- lines_count += 1
303
- end
304
-
305
- @current_display_lines = lines_count
306
- @terminal.flush
307
- end
308
-
309
- def finish_task(status)
310
- @spinner.stop
311
-
312
- # Capture output before stopping
313
- captured_output = @output_capture.last_lines
314
- @output_capture.stop
315
- clear_current_display
316
-
317
- # In test environments (when terminal is StringIO), include captured output
318
- if @terminal.is_a?(StringIO) && captured_output.any?
319
- captured_output.each do |line|
320
- @terminal.puts line.chomp
321
- end
322
- end
323
-
324
- @completed_tasks << status
325
- display_final_state
326
- end
327
-
328
- def display_final_state
329
- # Only display the newly completed task (last one)
330
- if @completed_tasks.any?
331
- latest_task = @completed_tasks.last
332
- @terminal.puts format_completed_task(latest_task)
333
- end
334
- @terminal.flush
335
- @current_display_lines = 1 # Only one line for the latest task
336
- end
337
-
338
- def format_completed_task(status)
339
- color = status.success? ? COLORS[:green] : COLORS[:red]
340
- "#{color}#{COLORS[:bold]}#{status.icon} #{status.name}#{COLORS[:reset]} #{COLORS[:dim]}#{status.format_duration}#{COLORS[:reset]}"
341
- end
342
-
343
- def format_current_task(spinner_char, task_name)
344
- "#{COLORS[:cyan]}#{spinner_char}#{COLORS[:reset]} #{COLORS[:bold]}#{task_name}#{COLORS[:reset]}"
345
- end
346
-
347
- def format_output_line(line)
348
- " #{COLORS[:dim]}#{line}#{COLORS[:reset]}"
349
- end
350
-
351
- def clear_current_display
352
- @terminal.clear_lines(@current_display_lines)
353
- @current_display_lines = 0
354
- end
355
56
  end
356
57
  end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dependency_analyzer"
4
+ require_relative "utils"
5
+ require_relative "utils/tree_display_helper"
6
+ require_relative "utils/dependency_resolver_helper"
7
+
8
+ module Taski
9
+ # Section provides an interface abstraction layer for dynamic implementation selection
10
+ # while maintaining static analysis capabilities
11
+ class Section
12
+ class << self
13
+ # === Dependency Resolution ===
14
+
15
+ # Resolve method for dependency graph (called by resolve_dependencies)
16
+ # @param queue [Array] Queue of tasks to process
17
+ # @param resolved [Array] Array of resolved tasks
18
+ # @return [self] Returns self for method chaining
19
+ def resolve(queue, resolved)
20
+ resolve_common(queue, resolved)
21
+ end
22
+
23
+ # Resolve all dependencies in topological order
24
+ # @return [Array<Class>] Array of tasks in dependency order
25
+ def resolve_dependencies
26
+ resolve_dependencies_common
27
+ end
28
+
29
+ # Analyze dependencies when accessing interface methods
30
+ def analyze_dependencies_for_interfaces
31
+ interface_exports.each do |interface_method|
32
+ dependencies = gather_static_dependencies_for_interface(interface_method)
33
+ add_unique_dependencies(dependencies)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ # Gather dependencies from interface method implementation
40
+ def gather_static_dependencies_for_interface(interface_method)
41
+ # For sections, we analyze the impl method
42
+ # Try instance method first, then class method
43
+ if impl_defined?
44
+ # For instance method, we can't analyze dependencies statically
45
+ # So we return empty array
46
+ []
47
+ else
48
+ DependencyAnalyzer.analyze_method(self, :impl)
49
+ end
50
+ end
51
+
52
+ # Add dependencies that don't already exist
53
+ def add_unique_dependencies(dep_classes)
54
+ dep_classes.each do |dep_class|
55
+ next if dep_class == self || dependency_exists?(dep_class)
56
+ add_dependency(dep_class)
57
+ end
58
+ end
59
+
60
+ # Add a single dependency
61
+ def add_dependency(dep_class)
62
+ @dependencies ||= []
63
+ @dependencies << {klass: dep_class}
64
+ end
65
+
66
+ # Check if dependency already exists
67
+ def dependency_exists?(dep_class)
68
+ (@dependencies || []).any? { |d| d[:klass] == dep_class }
69
+ end
70
+
71
+ # Extract class from dependency specification
72
+ def extract_class(task)
73
+ case task
74
+ when Class
75
+ task
76
+ when Hash
77
+ task[:klass]
78
+ else
79
+ task
80
+ end
81
+ end
82
+
83
+ public
84
+
85
+ # === Instance Management (minimal for Section) ===
86
+
87
+ # Ensure section is available (no actual building needed)
88
+ # @return [self] Returns self for compatibility with Task interface
89
+ def ensure_instance_built
90
+ self
91
+ end
92
+
93
+ # Build method for compatibility (Section doesn't build instances)
94
+ # @param args [Hash] Optional arguments (ignored for sections)
95
+ # @return [self] Returns self
96
+ def build(**args)
97
+ self
98
+ end
99
+
100
+ # Reset method for compatibility (Section doesn't have state to reset)
101
+ # @return [self] Returns self
102
+ def reset!
103
+ self
104
+ end
105
+
106
+ # Display dependency tree for this section
107
+ # @param prefix [String] Current indentation prefix
108
+ # @param visited [Set] Set of visited classes to prevent infinite loops
109
+ # @param color [Boolean] Whether to use color output
110
+ # @return [String] Formatted dependency tree
111
+ def tree(prefix = "", visited = Set.new, color: TreeColors.enabled?)
112
+ should_return_early, early_result, new_visited = handle_circular_dependency_check(visited, self, prefix)
113
+ return early_result if should_return_early
114
+
115
+ # Get section name with fallback for anonymous classes
116
+ section_name = name || to_s
117
+ colored_section_name = color ? TreeColors.section(section_name) : section_name
118
+ result = "#{prefix}#{colored_section_name}\n"
119
+
120
+ # Add possible implementations - detect from nested Task classes
121
+ possible_implementations = find_possible_implementations
122
+ if possible_implementations.any?
123
+ impl_names = possible_implementations.map { |impl| extract_implementation_name(impl) }
124
+ impl_text = "[One of: #{impl_names.join(", ")}]"
125
+ colored_impl_text = color ? TreeColors.implementations(impl_text) : impl_text
126
+ connector = color ? TreeColors.connector("└── ") : "└── "
127
+ result += "#{prefix}#{connector}#{colored_impl_text}\n"
128
+ end
129
+
130
+ dependencies = @dependencies || []
131
+ result += render_dependencies_tree(dependencies, prefix, new_visited, color)
132
+
133
+ result
134
+ end
135
+
136
+ # Define interface methods for this section
137
+ def interface(*names)
138
+ if names.empty?
139
+ raise ArgumentError, "interface requires at least one method name"
140
+ end
141
+
142
+ @interface_exports = names
143
+
144
+ # Create accessor methods for each interface name
145
+ names.each do |name|
146
+ define_singleton_method(name) do
147
+ # Get implementation class
148
+ implementation_class = get_implementation_class
149
+
150
+ # Check if implementation is nil
151
+ if implementation_class.nil?
152
+ raise SectionImplementationError,
153
+ "impl returned nil. " \
154
+ "Make sure impl returns a Task class."
155
+ end
156
+
157
+ # Validate that it's a Task class
158
+ unless implementation_class.is_a?(Class) && implementation_class < Taski::Task
159
+ raise SectionImplementationError,
160
+ "impl must return a Task class, got #{implementation_class.class}. " \
161
+ "Make sure impl returns a class that inherits from Taski::Task."
162
+ end
163
+
164
+ # Build the implementation and call the method
165
+ implementation = implementation_class.build
166
+
167
+ begin
168
+ implementation.send(name)
169
+ rescue NoMethodError
170
+ raise SectionImplementationError,
171
+ "Implementation does not provide required method '#{name}'. " \
172
+ "Make sure the implementation class has a '#{name}' method or " \
173
+ "exports :#{name} declaration."
174
+ end
175
+ end
176
+ end
177
+
178
+ # Automatically apply exports to existing nested Task classes
179
+ auto_apply_exports_to_existing_tasks
180
+ end
181
+
182
+ # Get the interface exports for this section
183
+ def interface_exports
184
+ @interface_exports || []
185
+ end
186
+
187
+ # Check if impl method is defined (as instance method)
188
+ def impl_defined?
189
+ instance_methods(false).include?(:impl)
190
+ end
191
+
192
+ # Get implementation class from instance method
193
+ def get_implementation_class
194
+ if impl_defined?
195
+ # Create a temporary instance to call impl method
196
+ allocate.impl
197
+ else
198
+ # Fall back to class method if exists
199
+ impl
200
+ end
201
+ end
202
+
203
+ # Override const_set to auto-add exports to nested Task classes
204
+ def const_set(name, value)
205
+ result = super
206
+
207
+ # If the constant is a Task class and we have interface exports,
208
+ # automatically add exports to avoid duplication
209
+ if value.is_a?(Class) && value < Taski::Task && !interface_exports.empty?
210
+ # Add exports declaration to the nested task
211
+ exports_list = interface_exports
212
+ value.class_eval do
213
+ exports(*exports_list)
214
+ end
215
+ end
216
+
217
+ result
218
+ end
219
+
220
+ private
221
+
222
+ # Automatically apply exports to existing nested Task classes when interface is defined
223
+ def auto_apply_exports_to_existing_tasks
224
+ constants.each do |const_name|
225
+ const_value = const_get(const_name)
226
+ if const_value.is_a?(Class) && const_value < Taski::Task && !interface_exports.empty?
227
+ exports_list = interface_exports
228
+ const_value.class_eval do
229
+ exports(*exports_list) unless @exports_defined
230
+ @exports_defined = true
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ # Find possible implementation classes by scanning nested Task classes
237
+ def find_possible_implementations
238
+ task_classes = []
239
+ constants.each do |const_name|
240
+ const_value = const_get(const_name)
241
+ if task_class?(const_value)
242
+ task_classes << const_value
243
+ end
244
+ end
245
+ task_classes
246
+ end
247
+
248
+ # Extract readable name from implementation class
249
+ def extract_implementation_name(impl_class)
250
+ class_name = impl_class.name
251
+ return impl_class.to_s unless class_name&.include?("::")
252
+
253
+ class_name.split("::").last
254
+ end
255
+
256
+ # Check if a constant value is a Task class
257
+ def task_class?(const_value)
258
+ const_value.is_a?(Class) && const_value < Taski::Task
259
+ end
260
+
261
+ private
262
+
263
+ include Utils::TreeDisplayHelper
264
+ include Utils::DependencyResolverHelper
265
+
266
+ # Subclasses should override this method to select appropriate implementation
267
+ def impl
268
+ raise NotImplementedError, "Subclass must implement impl"
269
+ end
270
+ end
271
+ end
272
+ end