taski 0.2.0 → 0.2.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.
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ 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}"
12
+
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
211
+ 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)
223
+ @output = output
224
+ @terminal = TerminalController.new(output)
225
+ @spinner = SpinnerAnimation.new
226
+ @output_capture = OutputCapture.new(output)
227
+
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
230
+
231
+ @completed_tasks = []
232
+ @current_display_lines = 0
233
+ end
234
+
235
+ def start_task(task_name, dependencies: [])
236
+ puts "DEBUG: start_task called for #{task_name}, enabled: #{@enabled}" if ENV["TASKI_DEBUG"]
237
+ return unless @enabled
238
+
239
+ clear_current_display
240
+ @output_capture.start
241
+
242
+ start_spinner_display(task_name)
243
+ end
244
+
245
+ def complete_task(task_name, duration:)
246
+ return unless @enabled
247
+
248
+ status = TaskStatus.new(name: task_name, duration: duration)
249
+ finish_task(status)
250
+ end
251
+
252
+ def fail_task(task_name, error:, duration:)
253
+ return unless @enabled
254
+
255
+ status = TaskStatus.new(name: task_name, duration: duration, error: error)
256
+ finish_task(status)
257
+ end
258
+
259
+ def clear
260
+ return unless @enabled
261
+
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
276
+ end
277
+
278
+ def enabled?
279
+ @enabled
280
+ 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
+ end
356
+ end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'exceptions'
3
+ require_relative "exceptions"
4
4
 
5
5
  module Taski
6
6
  # Reference class for task references
7
- #
7
+ #
8
8
  # Used to create lazy references to task classes by name,
9
9
  # which is useful for dependency tracking and metaprogramming.
10
10
  class Reference
@@ -37,4 +37,4 @@ module Taski
37
37
  "&#{@klass}"
38
38
  end
39
39
  end
40
- end
40
+ end
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../exceptions'
3
+ require_relative "../exceptions"
4
4
 
5
5
  module Taski
6
6
  # Base Task class that provides the foundation for task framework
7
7
  # This module contains the core constants and basic structure
8
8
  class Task
9
9
  # Constants for thread-local keys and method tracking
10
- THREAD_KEY_SUFFIX = '_building'
10
+ THREAD_KEY_SUFFIX = "_building"
11
11
  TASKI_ANALYZING_DEFINE_KEY = :taski_analyzing_define
12
12
  ANALYZED_METHODS = [:build, :clean].freeze
13
13
 
@@ -41,6 +41,51 @@ module Taski
41
41
  def __resolve__
42
42
  @__resolve__ ||= {}
43
43
  end
44
+
45
+ # Display dependency tree for this task
46
+ # @param prefix [String] Current indentation prefix
47
+ # @param visited [Set] Set of visited classes to prevent infinite loops
48
+ # @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
81
+
82
+ result
83
+ end
84
+
85
+ private
86
+
87
+ include Utils::DependencyUtils
88
+ private :extract_class
44
89
  end
45
90
 
46
91
  # === Instance Methods ===
@@ -51,10 +96,16 @@ module Taski
51
96
  raise NotImplementedError, "You must implement the build method in your task class"
52
97
  end
53
98
 
99
+ # Access build arguments passed to parametrized builds
100
+ # @return [Hash] Build arguments or empty hash if none provided
101
+ def build_args
102
+ @build_args || {}
103
+ end
104
+
54
105
  # Clean method with default empty implementation
55
106
  # Subclasses can override this method to implement cleanup logic
56
107
  def clean
57
108
  # Default implementation does nothing
58
109
  end
59
110
  end
60
- end
111
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
-
5
3
  module Taski
6
4
  class Task
7
5
  class << self
@@ -18,6 +16,9 @@ module Taski
18
16
  @dependencies ||= []
19
17
  @definitions ||= {}
20
18
 
19
+ # Ensure ref method is defined first time define is called
20
+ create_ref_method_if_needed
21
+
21
22
  # Create method that tracks dependencies on first call
22
23
  create_tracking_method(name)
23
24
 
@@ -25,16 +26,37 @@ module Taski
25
26
  dependencies = analyze_define_dependencies(block)
26
27
 
27
28
  @dependencies += dependencies
28
- @definitions[name] = { block:, options:, classes: dependencies }
29
+ @definitions[name] = {block:, options:, classes: dependencies}
29
30
  end
30
31
 
31
32
  private
32
33
 
33
34
  # === Define API Implementation ===
34
35
 
36
+ # Create ref method if needed to avoid redefinition warnings
37
+ def create_ref_method_if_needed
38
+ return if method_defined_for_define?(:ref)
39
+
40
+ define_singleton_method(:ref) do |klass_name|
41
+ # During dependency analysis, track as dependency but defer resolution
42
+ if Thread.current[TASKI_ANALYZING_DEFINE_KEY]
43
+ # Create Reference object for deferred resolution
44
+ reference = Taski::Reference.new(klass_name)
45
+
46
+ # Track as dependency by throwing unresolved
47
+ throw :unresolved, [reference, :deref]
48
+ else
49
+ # At runtime, resolve to actual class
50
+ Object.const_get(klass_name)
51
+ end
52
+ end
53
+ mark_method_as_defined(:ref)
54
+ end
55
+
35
56
  # Create method that tracks dependencies for define API
36
57
  # @param name [Symbol] Method name to create
37
58
  def create_tracking_method(name)
59
+ # Only create tracking method during dependency analysis
38
60
  class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
39
61
  def self.#{name}
40
62
  __resolve__[__callee__] ||= false
@@ -65,12 +87,16 @@ module Taski
65
87
 
66
88
  break if klass.nil?
67
89
 
68
- classes << { klass:, task: }
90
+ classes << {klass:, task:}
69
91
  end
70
92
 
71
93
  # Reset resolution state
72
94
  classes.each do |task_class|
73
- task_class[:klass].instance_variable_set(:@__resolve__, {})
95
+ klass = task_class[:klass]
96
+ # Only reset Task classes, not Reference objects
97
+ if klass.respond_to?(:instance_variable_set) && !klass.is_a?(Taski::Reference)
98
+ klass.instance_variable_set(:@__resolve__, {})
99
+ end
74
100
  end
75
101
 
76
102
  classes
@@ -90,6 +116,9 @@ module Taski
90
116
  # @param name [Symbol] Method name
91
117
  # @param definition [Hash] Method definition information
92
118
  def create_defined_method(name, definition)
119
+ # Remove tracking method first to avoid redefinition warnings
120
+ singleton_class.undef_method(name) if singleton_class.method_defined?(name)
121
+
93
122
  # Class method with lazy evaluation
94
123
  define_singleton_method(name) do
95
124
  @__defined_values ||= {}
@@ -101,7 +130,7 @@ module Taski
101
130
  @__defined_values ||= {}
102
131
  @__defined_values[name] ||= self.class.send(name)
103
132
  end
104
-
133
+
105
134
  # Mark as defined for this resolution
106
135
  mark_method_as_defined(name)
107
136
  end
@@ -122,4 +151,4 @@ module Taski
122
151
  end
123
152
  end
124
153
  end
125
- end
154
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
- require_relative '../dependency_analyzer'
3
+ require_relative "../dependency_analyzer"
5
4
 
6
5
  module Taski
7
6
  class Task
@@ -23,12 +22,6 @@ module Taski
23
22
  queue << task_class
24
23
  end
25
24
 
26
- # Override ref method after resolution for define API compatibility
27
- # Note: This may cause "method redefined" warnings, which is expected behavior
28
- if (@definitions && !@definitions.empty?) && !method_defined_for_define?(:ref)
29
- define_singleton_method(:ref) { |klass| Object.const_get(klass) }
30
- end
31
-
32
25
  # Create getter methods for defined values
33
26
  create_defined_methods
34
27
 
@@ -42,6 +35,7 @@ module Taski
42
35
  resolved = []
43
36
  visited = Set.new
44
37
  resolving = Set.new # Track currently resolving tasks
38
+ path_map = {self => []} # Track paths to each task
45
39
 
46
40
  while queue.any?
47
41
  task_class = queue.shift
@@ -49,12 +43,26 @@ module Taski
49
43
 
50
44
  # Check for circular dependency
51
45
  if resolving.include?(task_class)
52
- raise CircularDependencyError, "Circular dependency detected involving #{task_class.name}"
46
+ # Build error message with path information
47
+ cycle_path = build_cycle_path(task_class, path_map)
48
+ raise CircularDependencyError, build_circular_dependency_message(cycle_path)
53
49
  end
54
50
 
55
51
  resolving << task_class
56
52
  visited << task_class
53
+
54
+ # Store current path for dependencies
55
+ current_path = path_map[task_class] || []
56
+
57
+ # Let task resolve its dependencies
57
58
  task_class.resolve(queue, resolved)
59
+
60
+ # Track paths for each dependency
61
+ task_class.instance_variable_get(:@dependencies)&.each do |dep|
62
+ dep_class = extract_class(dep)
63
+ path_map[dep_class] = current_path + [task_class] unless path_map.key?(dep_class)
64
+ end
65
+
58
66
  resolving.delete(task_class)
59
67
  resolved << task_class unless resolved.include?(task_class)
60
68
  end
@@ -62,6 +70,21 @@ module Taski
62
70
  resolved
63
71
  end
64
72
 
73
+ private
74
+
75
+ # Build the cycle path from path tracking information
76
+ def build_cycle_path(task_class, path_map)
77
+ path = path_map[task_class] || []
78
+ path + [task_class]
79
+ end
80
+
81
+ # Build detailed error message for circular dependencies
82
+ def build_circular_dependency_message(cycle_path)
83
+ Utils::CircularDependencyHelpers.build_error_message(cycle_path, "dependency")
84
+ end
85
+
86
+ public
87
+
65
88
  # === Static Analysis ===
66
89
 
67
90
  # Analyze dependencies when methods are defined
@@ -91,7 +114,7 @@ module Taski
91
114
  # @param dep_class [Class] Dependency class to add
92
115
  def add_dependency(dep_class)
93
116
  @dependencies ||= []
94
- @dependencies << { klass: dep_class }
117
+ @dependencies << {klass: dep_class}
95
118
  end
96
119
 
97
120
  # Check if dependency already exists
@@ -100,6 +123,11 @@ module Taski
100
123
  def dependency_exists?(dep_class)
101
124
  (@dependencies || []).any? { |d| d[:klass] == dep_class }
102
125
  end
126
+
127
+ private
128
+
129
+ include Utils::DependencyUtils
130
+ private :extract_class
103
131
  end
104
132
  end
105
- end
133
+ end
@@ -28,4 +28,4 @@ module Taski
28
28
  end
29
29
  end
30
30
  end
31
- end
31
+ end