taski 0.2.3 → 0.3.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.
@@ -1,356 +1,59 @@
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
+ # Default to including captured output in test environments (when output != $stdout)
24
+ # This ensures test output is visible in the test output stream
25
+ include_captured_output = include_captured_output.nil? ? (output != $stdout) : include_captured_output
26
+ @display_manager = Progress::DisplayManager.new(@terminal, @spinner, @output_capture, include_captured_output: include_captured_output)
230
27
 
231
- @completed_tasks = []
232
- @current_display_lines = 0
28
+ @enabled = ENV["TASKI_PROGRESS_DISABLE"] != "1" && enable
233
29
  end
234
30
 
235
31
  def start_task(task_name, dependencies: [])
236
- puts "DEBUG: start_task called for #{task_name}, enabled: #{@enabled}" if ENV["TASKI_DEBUG"]
237
32
  return unless @enabled
238
33
 
239
- clear_current_display
240
- @output_capture.start
241
-
242
- start_spinner_display(task_name)
34
+ @display_manager.start_task_display(task_name)
243
35
  end
244
36
 
245
37
  def complete_task(task_name, duration:)
246
38
  return unless @enabled
247
39
 
248
- status = TaskStatus.new(name: task_name, duration: duration)
249
- finish_task(status)
40
+ @display_manager.complete_task_display(task_name, duration: duration)
250
41
  end
251
42
 
252
43
  def fail_task(task_name, error:, duration:)
253
44
  return unless @enabled
254
45
 
255
- status = TaskStatus.new(name: task_name, duration: duration, error: error)
256
- finish_task(status)
46
+ @display_manager.fail_task_display(task_name, error: error, duration: duration)
257
47
  end
258
48
 
259
49
  def clear
260
50
  return unless @enabled
261
51
 
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
52
+ @display_manager.clear_all_displays
276
53
  end
277
54
 
278
55
  def enabled?
279
56
  @enabled
280
57
  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
58
  end
356
59
  end
@@ -0,0 +1,268 @@
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
+ end
178
+
179
+ # Get the interface exports for this section
180
+ def interface_exports
181
+ @interface_exports || []
182
+ end
183
+
184
+ # Check if impl method is defined (as instance method)
185
+ def impl_defined?
186
+ instance_methods(false).include?(:impl)
187
+ end
188
+
189
+ # Get implementation class from instance method
190
+ def get_implementation_class
191
+ if impl_defined?
192
+ # Create a temporary instance to call impl method
193
+ allocate.impl
194
+ else
195
+ # Fall back to class method if exists
196
+ impl
197
+ end
198
+ end
199
+
200
+ # Override const_set to auto-add exports to nested Task classes
201
+ def const_set(name, value)
202
+ result = super
203
+
204
+ # If the constant is a Task class and we have interface exports,
205
+ # automatically add exports to avoid duplication
206
+ if value.is_a?(Class) && value < Taski::Task && !interface_exports.empty?
207
+ # Add exports declaration to the nested task
208
+ exports_list = interface_exports
209
+ value.class_eval do
210
+ exports(*exports_list)
211
+ end
212
+ end
213
+
214
+ result
215
+ end
216
+
217
+ # Apply auto-exports to all nested Task classes
218
+ # Call this method after defining nested Task classes to automatically add exports
219
+ def apply_auto_exports
220
+ constants.each do |const_name|
221
+ const_value = const_get(const_name)
222
+ if const_value.is_a?(Class) && const_value < Taski::Task && !interface_exports.empty?
223
+ exports_list = interface_exports
224
+ const_value.class_eval do
225
+ exports(*exports_list) unless @exports_defined
226
+ @exports_defined = true
227
+ end
228
+ end
229
+ end
230
+ end
231
+
232
+ # Find possible implementation classes by scanning nested Task classes
233
+ def find_possible_implementations
234
+ task_classes = []
235
+ constants.each do |const_name|
236
+ const_value = const_get(const_name)
237
+ if task_class?(const_value)
238
+ task_classes << const_value
239
+ end
240
+ end
241
+ task_classes
242
+ end
243
+
244
+ # Extract readable name from implementation class
245
+ def extract_implementation_name(impl_class)
246
+ class_name = impl_class.name
247
+ return impl_class.to_s unless class_name&.include?("::")
248
+
249
+ class_name.split("::").last
250
+ end
251
+
252
+ # Check if a constant value is a Task class
253
+ def task_class?(const_value)
254
+ const_value.is_a?(Class) && const_value < Taski::Task
255
+ end
256
+
257
+ private
258
+
259
+ include Utils::TreeDisplayHelper
260
+ include Utils::DependencyResolverHelper
261
+
262
+ # Subclasses should override this method to select appropriate implementation
263
+ def impl
264
+ raise NotImplementedError, "Subclass must implement impl"
265
+ end
266
+ end
267
+ end
268
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../exceptions"
4
+ require_relative "../utils/tree_display_helper"
4
5
 
5
6
  module Taski
6
7
  # Base Task class that provides the foundation for task framework
@@ -46,38 +47,15 @@ module Taski
46
47
  # @param prefix [String] Current indentation prefix
47
48
  # @param visited [Set] Set of visited classes to prevent infinite loops
48
49
  # @return [String] Formatted dependency tree
49
- def tree(prefix = "", visited = Set.new)
50
- return "#{prefix}#{name} (circular)\n" if visited.include?(self)
51
-
52
- visited = visited.dup
53
- visited << self
54
-
55
- result = "#{prefix}#{name}\n"
56
-
57
- dependencies = (@dependencies || []).uniq { |dep| extract_class(dep) }
58
- dependencies.each_with_index do |dep, index|
59
- dep_class = extract_class(dep)
60
- is_last = index == dependencies.length - 1
61
-
62
- connector = is_last ? "└── " : "├── "
63
- child_prefix = prefix + (is_last ? " " : "│ ")
64
-
65
- # For the dependency itself, we want to use the connector
66
- # For its children, we want to use the child_prefix
67
- dep_tree = dep_class.tree(child_prefix, visited)
68
- # Replace the first line (which has child_prefix) with the proper connector
69
- dep_lines = dep_tree.lines
70
- if dep_lines.any?
71
- # Replace the first line prefix with connector
72
- first_line = dep_lines[0]
73
- fixed_first_line = first_line.sub(/^#{Regexp.escape(child_prefix)}/, prefix + connector)
74
- result += fixed_first_line
75
- # Add the rest of the lines as-is
76
- result += dep_lines[1..].join if dep_lines.length > 1
77
- else
78
- result += "#{prefix}#{connector}#{dep_class.name}\n"
79
- end
80
- end
50
+ def tree(prefix = "", visited = Set.new, color: TreeColors.enabled?)
51
+ should_return_early, early_result, new_visited = handle_circular_dependency_check(visited, self, prefix)
52
+ return early_result if should_return_early
53
+
54
+ task_name = color ? TreeColors.task(name) : name
55
+ result = "#{prefix}#{task_name}\n"
56
+
57
+ dependencies = @dependencies || []
58
+ result += render_dependencies_tree(dependencies, prefix, new_visited, color)
81
59
 
82
60
  result
83
61
  end
@@ -85,6 +63,7 @@ module Taski
85
63
  private
86
64
 
87
65
  include Utils::DependencyUtils
66
+ include Utils::TreeDisplayHelper
88
67
  private :extract_class
89
68
  end
90
69