taski 0.2.1 → 0.2.3

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
@@ -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,6 +96,12 @@ 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
@@ -37,7 +37,19 @@ module Taski
37
37
  def create_ref_method_if_needed
38
38
  return if method_defined_for_define?(:ref)
39
39
 
40
- define_singleton_method(:ref) { |klass| Object.const_get(klass) }
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
41
53
  mark_method_as_defined(:ref)
42
54
  end
43
55
 
@@ -80,7 +92,11 @@ module Taski
80
92
 
81
93
  # Reset resolution state
82
94
  classes.each do |task_class|
83
- 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
84
100
  end
85
101
 
86
102
  classes
@@ -80,17 +80,7 @@ module Taski
80
80
 
81
81
  # Build detailed error message for circular dependencies
82
82
  def build_circular_dependency_message(cycle_path)
83
- path_names = cycle_path.map { |klass| klass.name || klass.to_s }
84
-
85
- message = "Circular dependency detected!\n"
86
- message += "Cycle: #{path_names.join(" → ")}\n\n"
87
- message += "Detailed dependency chain:\n"
88
-
89
- cycle_path.each_cons(2).with_index do |(from, to), index|
90
- message += " #{index + 1}. #{from.name} depends on → #{to.name}\n"
91
- end
92
-
93
- message
83
+ Utils::CircularDependencyHelpers.build_error_message(cycle_path, "dependency")
94
84
  end
95
85
 
96
86
  public
@@ -133,6 +123,11 @@ module Taski
133
123
  def dependency_exists?(dep_class)
134
124
  (@dependencies || []).any? { |d| d[:klass] == dep_class }
135
125
  end
126
+
127
+ private
128
+
129
+ include Utils::DependencyUtils
130
+ private :extract_class
136
131
  end
137
132
  end
138
133
  end
@@ -8,9 +8,19 @@ module Taski
8
8
  # === Lifecycle Management ===
9
9
 
10
10
  # Build this task and all its dependencies
11
- def build
12
- resolve_dependencies.reverse_each do |task_class|
13
- task_class.ensure_instance_built
11
+ # @param args [Hash] Optional arguments for parametrized builds
12
+ # @return [Task] Returns task instance (singleton or temporary)
13
+ def build(**args)
14
+ if args.empty?
15
+ # Traditional build: singleton instance with caching
16
+ resolve_dependencies.reverse_each do |task_class|
17
+ task_class.ensure_instance_built
18
+ end
19
+ # Return the singleton instance for consistency
20
+ instance_variable_get(:@__task_instance)
21
+ else
22
+ # Parametrized build: temporary instance without caching
23
+ build_with_args(args)
14
24
  end
15
25
  end
16
26
 
@@ -41,6 +51,32 @@ module Taski
41
51
  reset!
42
52
  end
43
53
 
54
+ # === Parametrized Build Support ===
55
+
56
+ # Build temporary instance with arguments
57
+ # @param args [Hash] Build arguments
58
+ # @return [Task] Temporary task instance
59
+ def build_with_args(args)
60
+ # Resolve dependencies first (same as normal build)
61
+ resolve_dependencies.reverse_each do |task_class|
62
+ task_class.ensure_instance_built
63
+ end
64
+
65
+ # Create temporary instance with arguments
66
+ temp_instance = new
67
+ temp_instance.instance_variable_set(:@build_args, args)
68
+
69
+ # Build with logging using common utility
70
+ Utils::TaskBuildHelpers.with_build_logging(name.to_s,
71
+ dependencies: @dependencies || [],
72
+ args: args) do
73
+ temp_instance.build
74
+ temp_instance
75
+ end
76
+ end
77
+
78
+ private :build_with_args
79
+
44
80
  # === Instance Management ===
45
81
 
46
82
  # Ensure task instance is built (public because called from build)
@@ -93,18 +129,10 @@ module Taski
93
129
  # @return [Task] Built task instance
94
130
  def build_instance
95
131
  instance = new
96
- build_start_time = Time.now
97
- begin
98
- Taski.logger.task_build_start(name.to_s, dependencies: @dependencies || [])
132
+ Utils::TaskBuildHelpers.with_build_logging(name.to_s,
133
+ dependencies: @dependencies || []) do
99
134
  instance.build
100
- duration = Time.now - build_start_time
101
- Taski.logger.task_build_complete(name.to_s, duration: duration)
102
135
  instance
103
- rescue => e
104
- duration = Time.now - build_start_time
105
- # Log the error with full context
106
- Taski.logger.task_build_failed(name.to_s, error: e, duration: duration)
107
- raise TaskBuildError, "Failed to build task #{name}: #{e.message}"
108
136
  end
109
137
  end
110
138
 
@@ -129,6 +157,8 @@ module Taski
129
157
  end
130
158
  end
131
159
 
160
+ private
161
+
132
162
  # Build current dependency path from thread-local storage
133
163
  # @return [Array<Class>] Array of classes in the current build path
134
164
  def build_current_dependency_path
@@ -150,27 +180,11 @@ module Taski
150
180
  # @param cycle_path [Array<Class>] The circular dependency path
151
181
  # @return [String] Formatted error message
152
182
  def build_runtime_circular_dependency_message(cycle_path)
153
- path_names = cycle_path.map { |klass| klass.name || klass.to_s }
154
-
155
- message = "Circular dependency detected!\n"
156
- message += "Cycle: #{path_names.join(" → ")}\n\n"
157
- message += "The dependency chain is:\n"
158
-
159
- cycle_path.each_cons(2).with_index do |(from, to), index|
160
- message += " #{index + 1}. #{from.name} is trying to build → #{to.name}\n"
161
- end
162
-
163
- message += "\nThis creates an infinite loop that cannot be resolved."
164
- message
183
+ Utils::CircularDependencyHelpers.build_error_message(cycle_path, "runtime")
165
184
  end
166
185
 
167
- # Extract class from dependency hash
168
- # @param dep [Hash] Dependency information
169
- # @return [Class] The dependency class
170
- def extract_class(dep)
171
- klass = dep[:klass]
172
- klass.is_a?(Reference) ? klass.deref : klass
173
- end
186
+ include Utils::DependencyUtils
187
+ private :extract_class
174
188
  end
175
189
  end
176
190
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ # Common utility functions for the Taski framework
5
+ module Utils
6
+ # Handle circular dependency error message generation
7
+ module CircularDependencyHelpers
8
+ # Build detailed error message for circular dependencies
9
+ # @param cycle_path [Array<Class>] The circular dependency path
10
+ # @param context [String] Context of the error (dependency, runtime)
11
+ # @return [String] Formatted error message
12
+ def self.build_error_message(cycle_path, context = "dependency")
13
+ path_names = cycle_path.map { |klass| klass.name || klass.to_s }
14
+
15
+ message = "Circular dependency detected!\n"
16
+ message += "Cycle: #{path_names.join(" → ")}\n\n"
17
+ message += "The #{context} chain is:\n"
18
+
19
+ cycle_path.each_cons(2).with_index do |(from, to), index|
20
+ action = (context == "dependency") ? "depends on" : "is trying to build"
21
+ message += " #{index + 1}. #{from.name} #{action} → #{to.name}\n"
22
+ end
23
+
24
+ message += "\nThis creates an infinite loop that cannot be resolved." if context == "dependency"
25
+ message
26
+ end
27
+ end
28
+
29
+ # Common dependency utility functions
30
+ module DependencyUtils
31
+ # Extract class from dependency hash
32
+ # @param dep [Hash] Dependency information
33
+ # @return [Class] The dependency class
34
+ def extract_class(dep)
35
+ klass = dep[:klass]
36
+ klass.is_a?(Reference) ? klass.deref : klass
37
+ end
38
+ end
39
+
40
+ # Common task build utility functions
41
+ module TaskBuildHelpers
42
+ # Format arguments hash for display in error messages
43
+ # @param args [Hash] Arguments hash
44
+ # @return [String] Formatted arguments string
45
+ def self.format_args(args)
46
+ return "" if args.nil? || args.empty?
47
+
48
+ formatted_pairs = args.map do |key, value|
49
+ "#{key}: #{value.inspect}"
50
+ end
51
+ "{#{formatted_pairs.join(", ")}}"
52
+ end
53
+
54
+ # Execute block with comprehensive build logging and progress display
55
+ # @param task_name [String] Name of the task being built
56
+ # @param dependencies [Array] List of dependencies
57
+ # @param args [Hash] Build arguments for parametrized builds
58
+ # @yield Block to execute with logging
59
+ # @return [Object] Result of the block execution
60
+ def self.with_build_logging(task_name, dependencies: [], args: nil)
61
+ build_start_time = Time.now
62
+
63
+ begin
64
+ # Traditional logging first (before any stdout redirection)
65
+ Taski.logger.task_build_start(task_name, dependencies: dependencies, args: args)
66
+
67
+ # Show progress display if enabled (this may redirect stdout)
68
+ Taski.progress_display&.start_task(task_name, dependencies: dependencies)
69
+
70
+ result = yield
71
+ duration = Time.now - build_start_time
72
+
73
+ # Complete progress display first (this restores stdout)
74
+ Taski.progress_display&.complete_task(task_name, duration: duration)
75
+
76
+ # Then do logging (on restored stdout)
77
+ begin
78
+ Taski.logger.task_build_complete(task_name, duration: duration)
79
+ rescue IOError
80
+ # If logger fails due to closed stream, write to STDERR instead
81
+ warn "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S.%3N")}] INFO Taski: Task build completed (task=#{task_name}, duration_ms=#{(duration * 1000).round(2)})"
82
+ end
83
+
84
+ result
85
+ rescue => e
86
+ duration = Time.now - build_start_time
87
+
88
+ # Complete progress display first (with error)
89
+ Taski.progress_display&.fail_task(task_name, error: e, duration: duration)
90
+
91
+ # Then do error logging (on restored stdout)
92
+ begin
93
+ Taski.logger.task_build_failed(task_name, error: e, duration: duration)
94
+ rescue IOError
95
+ # If logger fails due to closed stream, write to STDERR instead
96
+ warn "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S.%3N")}] ERROR Taski: Task build failed (task=#{task_name}, error=#{e.message}, duration_ms=#{(duration * 1000).round(2)})"
97
+ end
98
+
99
+ error_message = "Failed to build task #{task_name}"
100
+ error_message += " with args #{format_args(args)}" if args && !args.empty?
101
+ error_message += ": #{e.message}"
102
+ raise TaskBuildError, error_message
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end