taski 0.2.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e44ea93464ceaf8994d761cec75cf4d32df7cdb8f90d25b4cfcae5be7f8a586
4
- data.tar.gz: 8a1b5fae85bbffb28756f2e13b3f98c2cb8023c76346b5da722ac55e87ef3cc5
3
+ metadata.gz: 28f2e52366511da4606df64a169c804712fcf4398b55a7b7d6430a1367cc183d
4
+ data.tar.gz: 97cc63c767ceaf7dda2fbb4345876c47d1fb1bf7e0395c8a2a0369bc8585e817
5
5
  SHA512:
6
- metadata.gz: '037285b1bc19edd6d674f6748674fa8443c0cb40d5199de86bdfa3696c1597308d69ff5236a7114c43cf666a27e62f5c4111c5a36301736c4fdac98cdd65b4c4'
7
- data.tar.gz: afa967d630b56f9de1f4974879533572d06c42d574dce6bea69da9635ce69e06251af555168ff0b3475e95d5a1d6d07e8951e6052f0f6e04291d07b15d23239b
6
+ metadata.gz: 4558c7e5ab3ae48301da573a0d2dffa935ecf2bdf7f04ccce1b92add5ea45b3aea1b562dcce0c6356d28b7eacd58fcdca030a9a2748fbafdda9d9046b5a09213
7
+ data.tar.gz: f440abca3014be0bffdd34cb8440f197b55403fc938660a8eb311e1bb6c64127bf9d033335d7e52f71cefec534ae4b6e41e2b83164d76b893d3a3c8f692bc221
data/README.md CHANGED
@@ -99,7 +99,7 @@ EnvironmentConfig.build
99
99
 
100
100
  ### When to Use Each API
101
101
 
102
- - **Define API**: Best for dynamic runtime dependencies. Cannot contain side effects in definition blocks.
102
+ - **Define API**: Best for dynamic runtime dependencies. Cannot contain side effects in definition blocks. Dependencies are analyzed at class definition time, not runtime.
103
103
  - **Exports API**: Ideal for static dependencies. Supports side effects in build methods.
104
104
 
105
105
  | Use Case | API | Example |
@@ -109,6 +109,8 @@ EnvironmentConfig.build
109
109
  | Side effects | Exports | Database connections, I/O |
110
110
  | Conditional processing | Define | Algorithm selection |
111
111
 
112
+ **Note**: Define API analyzes dependencies when the class is defined. Conditional dependencies like `ENV['USE_NEW'] ? TaskA : TaskB` will only include the task selected at class definition time, not runtime.
113
+
112
114
  ## ✨ Key Features
113
115
 
114
116
  - **Automatic Dependency Resolution**: Dependencies detected through static analysis
@@ -172,6 +174,29 @@ WebServer.clean
172
174
  # => Database disconnected
173
175
  ```
174
176
 
177
+ ### Clean Method Idempotency
178
+
179
+ **Important**: The `clean` method must be idempotent - safe to call multiple times without errors.
180
+
181
+ ```ruby
182
+ class FileTask < Taski::Task
183
+ exports :output_file
184
+
185
+ def build
186
+ @output_file = '/tmp/data.csv'
187
+ File.write(@output_file, process_data)
188
+ end
189
+
190
+ def clean
191
+ # ❌ Bad: Raises error if file doesn't exist
192
+ # File.delete(@output_file)
193
+
194
+ # ✅ Good: Check before delete
195
+ File.delete(@output_file) if File.exist?(@output_file)
196
+ end
197
+ end
198
+ ```
199
+
175
200
  ### Error Handling
176
201
 
177
202
  ```ruby
@@ -0,0 +1,57 @@
1
+ # Taski Examples
2
+
3
+ Learn Taski through practical examples, from basic concepts to advanced patterns.
4
+
5
+ ## Getting Started
6
+
7
+ Start with these examples in order:
8
+
9
+ ### 1. **[quick_start.rb](quick_start.rb)** - Your First Taski Program
10
+ - Basic task definition with Exports API
11
+ - Automatic dependency resolution
12
+ - Simple task execution
13
+
14
+ ```bash
15
+ ruby examples/quick_start.rb
16
+ ```
17
+
18
+ ### 2. **[progress_demo.rb](progress_demo.rb)** - Rich CLI Progress Display
19
+ - Animated spinner with ANSI colors
20
+ - Real-time output capture and 5-line tail
21
+ - Production build scenarios
22
+ - TTY detection for clean file output
23
+
24
+ ```bash
25
+ # Interactive mode with rich spinner
26
+ ruby examples/progress_demo.rb
27
+
28
+ # Clean output mode (no spinner)
29
+ ruby examples/progress_demo.rb > build.log 2>&1
30
+ cat build.log
31
+ ```
32
+
33
+ ### 3. **[advanced_patterns.rb](advanced_patterns.rb)** - Complex Dependency Patterns
34
+ - Mixed Exports API and Define API usage
35
+ - Environment-specific dependencies
36
+ - Feature flags and conditional logic
37
+ - Task reset and rebuild scenarios
38
+
39
+ ```bash
40
+ ruby examples/advanced_patterns.rb
41
+ ```
42
+
43
+ ## Key Concepts Demonstrated
44
+
45
+ - **Exports API**: Static dependencies with `exports :property`
46
+ - **Define API**: Dynamic dependencies with `define :property, -> { ... }`
47
+ - **Progress Display**: Rich terminal output with spinners and colors
48
+ - **Output Capture**: Tail-style display of task output
49
+ - **Environment Configuration**: Different behavior based on runtime settings
50
+ - **Error Handling**: Graceful failure with progress indicators
51
+
52
+ ## Next Steps
53
+
54
+ After exploring these examples:
55
+ - Read the main documentation
56
+ - Examine the test files for more usage patterns
57
+ - Check out the source code in `lib/taski/`
@@ -1,8 +1,22 @@
1
1
  #!/usr/bin/env ruby
2
- # Complex example from README showing both APIs
2
+ # frozen_string_literal: true
3
+
4
+ # Taski Advanced Patterns
5
+ #
6
+ # This example demonstrates advanced Taski patterns:
7
+ # - Mixed usage of Exports API and Define API
8
+ # - Environment-specific dependency resolution
9
+ # - Feature flag integration with dynamic dependencies
10
+ # - Task reset and rebuild scenarios
11
+ # - Conditional dependency evaluation
12
+ #
13
+ # Run: ruby examples/advanced_patterns.rb
3
14
 
4
15
  require_relative "../lib/taski"
5
16
 
17
+ puts "⚡ Advanced Taski Patterns"
18
+ puts "=" * 40
19
+
6
20
  # Mock classes for the example
7
21
  class ProductionDB < Taski::Task
8
22
  exports :connection_string
@@ -81,8 +95,6 @@ class Application < Taski::Task
81
95
  end
82
96
 
83
97
  # Test different environments
84
- puts "=== Complex Example ==="
85
-
86
98
  puts "\n1. Development Environment (default):"
87
99
  ENV.delete("RAILS_ENV")
88
100
  ENV.delete("FEATURE_REDIS_CACHE")
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Taski Progress Display Demo
5
+ #
6
+ # This comprehensive example demonstrates all progress display features:
7
+ # 1. Basic spinner animation with success/failure indicators
8
+ # 2. Output capture with 5-line tail display
9
+ # 3. Real-world build scenario with external command simulation
10
+ # 4. TTY detection for clean file output
11
+ #
12
+ # Run: ruby examples/progress_demo.rb
13
+ # Try: ruby examples/progress_demo.rb > build.log 2>&1 && cat build.log
14
+
15
+ require_relative "../lib/taski"
16
+
17
+ puts "🎯 Taski Progress Display Demo"
18
+ puts "=" * 50
19
+
20
+ # SECTION 1: Basic Spinner Animation
21
+ puts "\n📍 SECTION 1: Basic Spinner & Success/Failure Indicators"
22
+ puts "-" * 50
23
+
24
+ class ConfigTask < Taski::Task
25
+ exports :database_url, :cache_url
26
+
27
+ def build
28
+ sleep 0.8
29
+ @database_url = "postgres://localhost/myapp"
30
+ @cache_url = "redis://localhost:6379"
31
+ end
32
+ end
33
+
34
+ class DatabaseTask < Taski::Task
35
+ exports :connection
36
+
37
+ def build
38
+ sleep 1.2
39
+ @connection = "Connected to #{ConfigTask.database_url}"
40
+ end
41
+ end
42
+
43
+ class ApplicationTask < Taski::Task
44
+ exports :status
45
+
46
+ def build
47
+ sleep 1.0
48
+ db = DatabaseTask.connection
49
+ @status = "App ready! #{db}"
50
+ end
51
+ end
52
+
53
+ ApplicationTask.build
54
+ puts "🎉 Application Status: #{ApplicationTask.status}"
55
+
56
+ # SECTION 2: Output Capture Demo
57
+ puts "\n📍 SECTION 2: Output Capture with 5-Line Tail"
58
+ puts "-" * 50
59
+
60
+ class VerboseTask < Taski::Task
61
+ exports :result
62
+
63
+ def build
64
+ puts "Starting task initialization..."
65
+ sleep 0.3
66
+
67
+ puts "Loading configuration files..."
68
+ puts "Connecting to database..."
69
+ puts "Connection established: localhost:5432"
70
+ sleep 0.3
71
+
72
+ puts "Running initial checks..."
73
+ puts "Checking schema version..."
74
+ puts "Schema is up to date"
75
+ puts "Performing data validation..."
76
+ puts "Validating user records..."
77
+ puts "Validating product records..."
78
+ puts "All validations passed"
79
+ sleep 0.4
80
+
81
+ puts "Task completed successfully!"
82
+ @result = "All operations completed"
83
+ end
84
+ end
85
+
86
+ VerboseTask.build
87
+ puts "📊 Verbose Task Result: #{VerboseTask.result}"
88
+
89
+ # SECTION 3: Production Build Scenario
90
+ puts "\n📍 SECTION 3: Production Build Scenario"
91
+ puts "-" * 50
92
+
93
+ class CompileTask < Taski::Task
94
+ exports :result
95
+
96
+ def build
97
+ puts "Starting compilation process..."
98
+ sleep 0.8
99
+
100
+ puts "Checking source files..."
101
+ puts "Found: main.c, utils.c, config.h"
102
+ sleep 0.6
103
+
104
+ puts "Running gcc compilation..."
105
+ puts "gcc -Wall -O2 -c main.c"
106
+ puts "gcc -Wall -O2 -c utils.c"
107
+ puts "main.c: In function 'main':"
108
+ puts "main.c:42: warning: unused variable 'temp'"
109
+ puts "utils.c: In function 'parse_config':"
110
+ puts "utils.c:15: warning: implicit declaration of function 'strcpy'"
111
+ sleep 0.8
112
+
113
+ puts "Linking objects..."
114
+ puts "gcc -o myapp main.o utils.o"
115
+ puts "Compilation successful!"
116
+
117
+ @result = "myapp binary created"
118
+ end
119
+ end
120
+
121
+ class TestTask < Taski::Task
122
+ exports :test_result
123
+
124
+ def build
125
+ puts "Running test suite..."
126
+ sleep 0.2
127
+
128
+ (1..8).each do |i|
129
+ puts "Test #{i}/8: #{["PASS", "PASS", "FAIL", "PASS", "PASS", "PASS", "PASS", "PASS"][i - 1]}"
130
+ sleep 0.4
131
+ end
132
+
133
+ puts "Test summary: 7/8 passed, 1 failed"
134
+ @test_result = "Tests completed with 1 failure"
135
+ end
136
+ end
137
+
138
+ CompileTask.build
139
+ puts "📦 Compilation: #{CompileTask.result}"
140
+
141
+ TestTask.build
142
+ puts "🧪 Test Result: #{TestTask.test_result}"
143
+
144
+ # SECTION 4: Error Handling Demo
145
+ puts "\n📍 SECTION 4: Error Handling Demo"
146
+ puts "-" * 50
147
+
148
+ class FailingTask < Taski::Task
149
+ def build
150
+ puts "Attempting network connection..."
151
+ sleep 1.0 # Watch it spin before failing
152
+ puts "Connection timeout after 30 seconds"
153
+ puts "Retrying connection..."
154
+ sleep 0.5
155
+ raise StandardError, "Network connection failed!"
156
+ end
157
+ end
158
+
159
+ begin
160
+ FailingTask.build
161
+ rescue Taski::TaskBuildError => e
162
+ puts "🛡️ Error handled gracefully: #{e.message}"
163
+ end
164
+
165
+ puts "\n✨ Demo Complete!"
166
+ puts "Note: Rich spinner display only appears in terminals, not when output is redirected."
@@ -1,8 +1,20 @@
1
1
  #!/usr/bin/env ruby
2
- # Quick Start example from README
2
+ # frozen_string_literal: true
3
+
4
+ # Taski Quick Start Guide
5
+ #
6
+ # This example demonstrates the fundamentals of Taski:
7
+ # - Task definition with the Exports API
8
+ # - Automatic dependency resolution
9
+ # - Simple task execution
10
+ #
11
+ # Run: ruby examples/quick_start.rb
3
12
 
4
13
  require_relative "../lib/taski"
5
14
 
15
+ puts "🚀 Taski Quick Start"
16
+ puts "=" * 30
17
+
6
18
  # Simple static dependency using Exports API
7
19
  class DatabaseSetup < Taski::Task
8
20
  exports :connection_string
@@ -24,7 +36,6 @@ class APIServer < Taski::Task
24
36
  end
25
37
 
26
38
  # Execute - dependencies are resolved automatically
27
- puts "=== Quick Start Example ==="
28
39
  APIServer.build
29
40
 
30
- puts "\nResult: APIServer running on port #{APIServer.port}"
41
+ puts "\n✅ Result: APIServer running on port #{APIServer.port}"
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Tree Display Demo
5
+ #
6
+ # This example demonstrates the tree display functionality that shows
7
+ # task dependency relationships in a visual tree format.
8
+ #
9
+ # Run: ruby examples/tree_demo.rb
10
+
11
+ require_relative "../lib/taski"
12
+
13
+ puts "🌲 Taski Tree Display Demo"
14
+ puts "=" * 40
15
+
16
+ # Create a dependency chain for demonstration
17
+ class Database < Taski::Task
18
+ exports :connection_string
19
+
20
+ def build
21
+ @connection_string = "postgres://localhost/myapp"
22
+ end
23
+ end
24
+
25
+ class Cache < Taski::Task
26
+ exports :redis_url
27
+
28
+ def build
29
+ @redis_url = "redis://localhost:6379"
30
+ end
31
+ end
32
+
33
+ class Config < Taski::Task
34
+ exports :settings
35
+
36
+ def build
37
+ @settings = {
38
+ database: Database.connection_string,
39
+ cache: Cache.redis_url,
40
+ port: 3000
41
+ }
42
+ end
43
+ end
44
+
45
+ class Logger < Taski::Task
46
+ exports :log_level
47
+
48
+ def build
49
+ @log_level = "info"
50
+ end
51
+ end
52
+
53
+ class WebServer < Taski::Task
54
+ exports :server_instance
55
+
56
+ def build
57
+ @server_instance = "WebServer configured with #{Config.settings[:database]} and #{Logger.log_level}"
58
+ end
59
+ end
60
+
61
+ class Application < Taski::Task
62
+ def build
63
+ puts "Starting application..."
64
+ puts "Web server: #{WebServer.server_instance}"
65
+ puts "Config: #{Config.settings}"
66
+ end
67
+ end
68
+
69
+ puts "\n📊 Application Dependency Tree:"
70
+ puts Application.tree
71
+
72
+ puts "\n🔍 Individual Component Trees:"
73
+ puts "\nWebServer dependencies:"
74
+ puts WebServer.tree
75
+
76
+ puts "\nConfig dependencies:"
77
+ puts Config.tree
78
+
79
+ puts "\n▶️ Building Application (to verify dependencies work):"
80
+ Application.build
data/lib/taski/logger.rb CHANGED
@@ -48,11 +48,16 @@ module Taski
48
48
  # Log task build start event
49
49
  # @param task_name [String] Name of the task being built
50
50
  # @param dependencies [Array] List of task dependencies
51
- def task_build_start(task_name, dependencies: [])
52
- info("Task build started",
51
+ # @param args [Hash] Build arguments for parametrized builds
52
+ def task_build_start(task_name, dependencies: [], args: nil)
53
+ context = {
53
54
  task: task_name,
54
55
  dependencies: dependencies.size,
55
- dependency_names: dependencies.map { |dep| dep.is_a?(Hash) ? dep[:klass].inspect : dep.inspect })
56
+ dependency_names: dependencies.map { |dep| dep.is_a?(Hash) ? dep[:klass].inspect : dep.inspect }
57
+ }
58
+ context[:args] = args if args && !args.empty?
59
+
60
+ info("Task build started", **context)
56
61
  end
57
62
 
58
63
  # Log task build completion event
@@ -181,6 +186,12 @@ module Taski
181
186
  @logger ||= Logger.new
182
187
  end
183
188
 
189
+ # Get the current progress display instance (always enabled)
190
+ # @return [ProgressDisplay] Current progress display instance
191
+ def progress_display
192
+ @progress_display ||= ProgressDisplay.new(force_enable: ENV["TASKI_FORCE_PROGRESS"] == "1")
193
+ end
194
+
184
195
  # Configure the logger with new settings
185
196
  # @param level [Symbol] Log level (:debug, :info, :warn, :error)
186
197
  # @param output [IO] Output destination
@@ -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
data/lib/taski/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Taski
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.2"
5
5
  end
data/lib/taski.rb CHANGED
@@ -6,8 +6,10 @@ require "monitor"
6
6
  require_relative "taski/version"
7
7
  require_relative "taski/exceptions"
8
8
  require_relative "taski/logger"
9
+ require_relative "taski/progress_display"
9
10
  require_relative "taski/reference"
10
11
  require_relative "taski/dependency_analyzer"
12
+ require_relative "taski/utils"
11
13
 
12
14
  # Load Task class components
13
15
  require_relative "taski/task/base"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: taski
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - ahogappa
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-06-21 00:00:00.000000000 Z
10
+ date: 2025-06-27 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: prism
@@ -39,18 +39,23 @@ files:
39
39
  - LICENSE
40
40
  - README.md
41
41
  - Rakefile
42
- - examples/complex_example.rb
43
- - examples/readme_example.rb
42
+ - examples/README.md
43
+ - examples/advanced_patterns.rb
44
+ - examples/progress_demo.rb
45
+ - examples/quick_start.rb
46
+ - examples/tree_demo.rb
44
47
  - lib/taski.rb
45
48
  - lib/taski/dependency_analyzer.rb
46
49
  - lib/taski/exceptions.rb
47
50
  - lib/taski/logger.rb
51
+ - lib/taski/progress_display.rb
48
52
  - lib/taski/reference.rb
49
53
  - lib/taski/task/base.rb
50
54
  - lib/taski/task/define_api.rb
51
55
  - lib/taski/task/dependency_resolver.rb
52
56
  - lib/taski/task/exports_api.rb
53
57
  - lib/taski/task/instance_management.rb
58
+ - lib/taski/utils.rb
54
59
  - lib/taski/version.rb
55
60
  - sig/taski.rbs
56
61
  homepage: https://github.com/ahogappa/taski