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.
- checksums.yaml +4 -4
- data/README.md +103 -3
- data/examples/README.md +57 -0
- data/examples/{complex_example.rb → advanced_patterns.rb} +15 -3
- data/examples/progress_demo.rb +166 -0
- data/examples/{readme_example.rb → quick_start.rb} +14 -3
- data/examples/tree_demo.rb +80 -0
- data/lib/taski/dependency_analyzer.rb +41 -9
- data/lib/taski/logger.rb +14 -3
- data/lib/taski/progress_display.rb +356 -0
- data/lib/taski/task/base.rb +51 -0
- data/lib/taski/task/define_api.rb +18 -2
- data/lib/taski/task/dependency_resolver.rb +6 -11
- data/lib/taski/task/instance_management.rb +46 -32
- data/lib/taski/utils.rb +107 -0
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +2 -0
- metadata +10 -5
@@ -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
|
data/lib/taski/task/base.rb
CHANGED
@@ -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)
|
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]
|
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
|
-
|
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
|
-
|
12
|
-
|
13
|
-
|
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
|
-
|
97
|
-
|
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
|
-
|
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
|
-
|
168
|
-
|
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
|
data/lib/taski/utils.rb
ADDED
@@ -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
|