taski 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/taski/logger.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "logging/formatter_factory"
4
+
3
5
  module Taski
4
6
  # Enhanced logging functionality for Taski framework
5
7
  # Provides structured logging with multiple levels and context information
@@ -13,7 +15,7 @@ module Taski
13
15
  def initialize(level: :info, output: $stdout, format: :structured)
14
16
  @level = level
15
17
  @output = output
16
- @format = format
18
+ @formatter = Logging::FormatterFactory.create(format)
17
19
  @start_time = Time.now
18
20
  end
19
21
 
@@ -103,21 +105,15 @@ module Taski
103
105
 
104
106
  private
105
107
 
106
- # Core logging method
108
+ # Core logging method using Strategy Pattern
107
109
  # @param level [Symbol] Log level
108
110
  # @param message [String] Log message
109
111
  # @param context [Hash] Additional context
110
112
  def log(level, message, context)
111
113
  return unless should_log?(level)
112
114
 
113
- case @format
114
- when :simple
115
- log_simple(level, message, context)
116
- when :structured
117
- log_structured(level, message, context)
118
- when :json
119
- log_json(level, message, context)
120
- end
115
+ formatted_line = @formatter.format(level, message, context, @start_time)
116
+ @output.puts formatted_line
121
117
  end
122
118
 
123
119
  # Check if message should be logged based on current level
@@ -126,57 +122,6 @@ module Taski
126
122
  def should_log?(level)
127
123
  LEVELS[@level] <= LEVELS[level]
128
124
  end
129
-
130
- # Simple log format: [LEVEL] message
131
- def log_simple(level, message, context)
132
- @output.puts "[#{level.upcase}] #{message}"
133
- end
134
-
135
- # Structured log format with timestamp and context
136
- def log_structured(level, message, context)
137
- timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S.%3N")
138
- elapsed = ((Time.now - @start_time) * 1000).round(1)
139
-
140
- line = "[#{timestamp}] [#{elapsed}ms] #{level.to_s.upcase.ljust(5)} Taski: #{message}"
141
-
142
- unless context.empty?
143
- context_parts = context.map do |key, value|
144
- "#{key}=#{format_value(value)}"
145
- end
146
- line += " (#{context_parts.join(", ")})"
147
- end
148
-
149
- @output.puts line
150
- end
151
-
152
- # JSON log format for structured logging systems
153
- def log_json(level, message, context)
154
- require "json"
155
-
156
- log_entry = {
157
- timestamp: Time.now.iso8601(3),
158
- level: level.to_s,
159
- logger: "taski",
160
- message: message,
161
- elapsed_ms: ((Time.now - @start_time) * 1000).round(1)
162
- }.merge(context)
163
-
164
- @output.puts JSON.generate(log_entry)
165
- end
166
-
167
- # Format values for structured logging
168
- def format_value(value)
169
- case value
170
- when String
171
- (value.length > 50) ? "#{value[0..47]}..." : value
172
- when Array
173
- (value.size > 5) ? "[#{value[0..4].join(", ")}, ...]" : value.inspect
174
- when Hash
175
- (value.size > 3) ? "{#{value.keys[0..2].join(", ")}, ...}" : value.inspect
176
- else
177
- value.inspect
178
- end
179
- end
180
125
  end
181
126
 
182
127
  class << self
@@ -189,7 +134,7 @@ module Taski
189
134
  # Get the current progress display instance (always enabled)
190
135
  # @return [ProgressDisplay] Current progress display instance
191
136
  def progress_display
192
- @progress_display ||= ProgressDisplay.new(force_enable: ENV["TASKI_FORCE_PROGRESS"] == "1")
137
+ @progress_display ||= ProgressDisplay.new
193
138
  end
194
139
 
195
140
  # Configure the logger with new settings
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "simple_formatter"
4
+ require_relative "structured_formatter"
5
+ require_relative "json_formatter"
6
+
7
+ module Taski
8
+ module Logging
9
+ # Factory for creating log formatters
10
+ class FormatterFactory
11
+ # Create a formatter instance based on format symbol
12
+ # @param format [Symbol] Format type (:simple, :structured, :json)
13
+ # @return [FormatterInterface] Formatter instance
14
+ def self.create(format)
15
+ case format
16
+ when :simple
17
+ SimpleFormatter.new
18
+ when :structured
19
+ StructuredFormatter.new
20
+ when :json
21
+ JsonFormatter.new
22
+ else
23
+ raise ArgumentError, "Unknown format: #{format}. Valid formats: :simple, :structured, :json"
24
+ end
25
+ end
26
+
27
+ # Get list of available formats
28
+ # @return [Array<Symbol>] Available format symbols
29
+ def self.available_formats
30
+ [:simple, :structured, :json]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Logging
5
+ # Interface for log formatters
6
+ # All formatters must implement the format method
7
+ module FormatterInterface
8
+ # Format a log entry
9
+ # @param level [Symbol] Log level (:debug, :info, :warn, :error)
10
+ # @param message [String] Log message
11
+ # @param context [Hash] Additional context information
12
+ # @param start_time [Time] Logger start time for elapsed calculation
13
+ # @return [String] Formatted log line
14
+ def format(level, message, context, start_time)
15
+ raise NotImplementedError, "Subclass must implement format method"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatter_interface"
4
+
5
+ module Taski
6
+ module Logging
7
+ # JSON log formatter for structured logging systems
8
+ class JsonFormatter
9
+ include FormatterInterface
10
+
11
+ def format(level, message, context, start_time)
12
+ require "json"
13
+
14
+ log_entry = {
15
+ timestamp: Time.now.iso8601(3),
16
+ level: level.to_s,
17
+ logger: "taski",
18
+ message: message,
19
+ elapsed_ms: ((Time.now - start_time) * 1000).round(1)
20
+ }.merge(context)
21
+
22
+ JSON.generate(log_entry)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatter_interface"
4
+
5
+ module Taski
6
+ module Logging
7
+ # Simple log formatter: [LEVEL] message
8
+ class SimpleFormatter
9
+ include FormatterInterface
10
+
11
+ def format(level, message, context, start_time)
12
+ "[#{level.upcase}] #{message}"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatter_interface"
4
+
5
+ module Taski
6
+ module Logging
7
+ # Structured log formatter with timestamp and context
8
+ class StructuredFormatter
9
+ include FormatterInterface
10
+
11
+ def format(level, message, context, start_time)
12
+ timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S.%3N")
13
+ elapsed = ((Time.now - start_time) * 1000).round(1)
14
+
15
+ line = "[#{timestamp}] [#{elapsed}ms] #{level.to_s.upcase.ljust(5)} Taski: #{message}"
16
+
17
+ unless context.empty?
18
+ context_parts = context.map do |key, value|
19
+ "#{key}=#{format_value(value)}"
20
+ end
21
+ line += " (#{context_parts.join(", ")})"
22
+ end
23
+
24
+ line
25
+ end
26
+
27
+ private
28
+
29
+ # Format values for structured logging
30
+ def format_value(value)
31
+ case value
32
+ when String
33
+ (value.length > 50) ? "#{value[0..47]}..." : value
34
+ when Array
35
+ (value.size > 5) ? "[#{value[0..4].join(", ")}, ...]" : value.inspect
36
+ when Hash
37
+ (value.size > 3) ? "{#{value.keys[0..2].join(", ")}, ...}" : value.inspect
38
+ else
39
+ value.inspect
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ # Color constants for progress display
6
+ module DisplayColors
7
+ COLORS = {
8
+ reset: "\033[0m",
9
+ bold: "\033[1m",
10
+ dim: "\033[2m",
11
+ cyan: "\033[36m",
12
+ green: "\033[32m",
13
+ red: "\033[31m"
14
+ }.freeze
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "task_status"
4
+ require_relative "task_formatter"
5
+
6
+ module Taski
7
+ module Progress
8
+ # Manages the display state and coordinates display updates
9
+ class DisplayManager
10
+ def initialize(terminal, spinner, output_capture, include_captured_output: false)
11
+ @terminal = terminal
12
+ @spinner = spinner
13
+ @output_capture = output_capture
14
+ @include_captured_output = include_captured_output
15
+ @formatter = TaskFormatter.new
16
+ @completed_tasks = []
17
+ @current_display_lines = 0
18
+ end
19
+
20
+ def start_task_display(task_name)
21
+ clear_current_display
22
+ @output_capture.start
23
+ start_spinner_display(task_name)
24
+ end
25
+
26
+ def complete_task_display(task_name, duration:)
27
+ status = TaskStatus.new(name: task_name, duration: duration)
28
+ finish_task_display(status)
29
+ end
30
+
31
+ def fail_task_display(task_name, error:, duration:)
32
+ status = TaskStatus.new(name: task_name, duration: duration, error: error)
33
+ finish_task_display(status)
34
+ end
35
+
36
+ def clear_all_displays
37
+ @spinner.stop
38
+ @output_capture.stop
39
+ clear_current_display
40
+
41
+ # Display final summary of all completed tasks
42
+ if @completed_tasks.any?
43
+ @completed_tasks.each do |status|
44
+ @terminal.puts @formatter.format_completed_task(status)
45
+ end
46
+ @terminal.flush
47
+ end
48
+
49
+ @completed_tasks.clear
50
+ @current_display_lines = 0
51
+ end
52
+
53
+ private
54
+
55
+ def start_spinner_display(task_name)
56
+ @spinner.start(@terminal, task_name) do |spinner_char, name|
57
+ display_current_state(spinner_char, name)
58
+ end
59
+ end
60
+
61
+ def display_current_state(spinner_char, task_name)
62
+ clear_current_display
63
+
64
+ lines_count = 0
65
+
66
+ # Only display current task with spinner (no past completed tasks during execution)
67
+ @terminal.puts @formatter.format_current_task(spinner_char, task_name)
68
+ lines_count += 1
69
+
70
+ # Display output lines
71
+ @output_capture.last_lines.each do |line|
72
+ @terminal.puts @formatter.format_output_line(line)
73
+ lines_count += 1
74
+ end
75
+
76
+ @current_display_lines = lines_count
77
+ @terminal.flush
78
+ end
79
+
80
+ def finish_task_display(status)
81
+ @spinner.stop
82
+
83
+ # Capture output before stopping
84
+ captured_output = @output_capture.last_lines
85
+ @output_capture.stop
86
+ clear_current_display
87
+
88
+ # Include captured output if requested (typically for test environments)
89
+ if @include_captured_output && captured_output.any?
90
+ captured_output.each do |line|
91
+ @terminal.puts line.chomp
92
+ end
93
+ end
94
+
95
+ @completed_tasks << status
96
+ display_final_state
97
+ end
98
+
99
+ def display_final_state
100
+ # Only display the newly completed task (last one)
101
+ if @completed_tasks.any?
102
+ latest_task = @completed_tasks.last
103
+ @terminal.puts @formatter.format_completed_task(latest_task)
104
+ end
105
+ @terminal.flush
106
+ @current_display_lines = 1 # Only one line for the latest task
107
+ end
108
+
109
+ def clear_current_display
110
+ @terminal.clear_lines(@current_display_lines)
111
+ @current_display_lines = 0
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ # Captures stdout and maintains last N lines like tail -f
6
+ class OutputCapture
7
+ MAX_LINES = 10
8
+ DISPLAY_LINES = 5
9
+
10
+ def initialize(main_output)
11
+ @main_output = main_output
12
+ @buffer = []
13
+ @capturing = false
14
+ @original_stdout = nil
15
+ @pipe_reader = nil
16
+ @pipe_writer = nil
17
+ @capture_thread = nil
18
+ end
19
+
20
+ def start
21
+ return if @capturing
22
+
23
+ @buffer.clear
24
+ setup_stdout_redirection
25
+ @capturing = true
26
+
27
+ start_capture_thread
28
+ end
29
+
30
+ def stop
31
+ return unless @capturing
32
+
33
+ @capturing = false
34
+
35
+ # Restore stdout
36
+ restore_stdout
37
+
38
+ # Clean up pipes and thread
39
+ cleanup_capture_thread
40
+ cleanup_pipes
41
+ end
42
+
43
+ def last_lines
44
+ @buffer.last(DISPLAY_LINES)
45
+ end
46
+
47
+ def capturing?
48
+ @capturing
49
+ end
50
+
51
+ private
52
+
53
+ def setup_stdout_redirection
54
+ @original_stdout = $stdout
55
+ @pipe_reader, @pipe_writer = IO.pipe
56
+ $stdout = @pipe_writer
57
+ end
58
+
59
+ def restore_stdout
60
+ return unless @original_stdout
61
+
62
+ $stdout = @original_stdout
63
+ @original_stdout = nil
64
+ end
65
+
66
+ def start_capture_thread
67
+ @capture_thread = Thread.new do
68
+ while (line = @pipe_reader.gets)
69
+ line = line.chomp
70
+ next if line.empty?
71
+ next if skip_line?(line)
72
+
73
+ add_line_to_buffer(line)
74
+ end
75
+ rescue IOError
76
+ # Pipe closed, normal termination
77
+ end
78
+ end
79
+
80
+ def skip_line?(line)
81
+ # Skip logger lines (they appear separately)
82
+ line.match?(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]/)
83
+ end
84
+
85
+ def add_line_to_buffer(line)
86
+ @buffer << line
87
+ @buffer.shift while @buffer.length > MAX_LINES
88
+ end
89
+
90
+ def cleanup_capture_thread
91
+ @capture_thread&.join(0.1)
92
+ @capture_thread = nil
93
+ end
94
+
95
+ def cleanup_pipes
96
+ [@pipe_writer, @pipe_reader].each do |pipe|
97
+ pipe&.close
98
+ rescue IOError
99
+ # Already closed, ignore
100
+ end
101
+ @pipe_writer = @pipe_reader = nil
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ # Spinner animation with dots-style characters
6
+ class SpinnerAnimation
7
+ SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].freeze
8
+ FRAME_DELAY = 0.1
9
+
10
+ def initialize
11
+ @frame = 0
12
+ @running = false
13
+ @thread = nil
14
+ end
15
+
16
+ def start(terminal, task_name, &display_callback)
17
+ return if @running
18
+
19
+ @running = true
20
+ @frame = 0
21
+
22
+ @thread = Thread.new do
23
+ while @running
24
+ current_char = SPINNER_CHARS[@frame % SPINNER_CHARS.length]
25
+ display_callback&.call(current_char, task_name)
26
+
27
+ @frame += 1
28
+ sleep FRAME_DELAY
29
+ end
30
+ rescue
31
+ # Silently handle thread errors
32
+ end
33
+ end
34
+
35
+ def stop
36
+ @running = false
37
+ @thread&.join(0.2)
38
+ @thread = nil
39
+ end
40
+
41
+ def running?
42
+ @running
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "display_colors"
4
+
5
+ module Taski
6
+ module Progress
7
+ # Handles formatting of task display messages
8
+ class TaskFormatter
9
+ include DisplayColors
10
+
11
+ def format_completed_task(status)
12
+ color = status.success? ? COLORS[:green] : COLORS[:red]
13
+ "#{color}#{COLORS[:bold]}#{status.icon} #{status.name}#{COLORS[:reset]} #{COLORS[:dim]}#{status.format_duration}#{COLORS[:reset]}"
14
+ end
15
+
16
+ def format_current_task(spinner_char, task_name)
17
+ "#{COLORS[:cyan]}#{spinner_char}#{COLORS[:reset]} #{COLORS[:bold]}#{task_name}#{COLORS[:reset]}"
18
+ end
19
+
20
+ def format_output_line(line)
21
+ " #{COLORS[:dim]}#{line}#{COLORS[:reset]}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ # Represents task execution status
6
+ class TaskStatus
7
+ attr_reader :name, :duration, :error
8
+
9
+ def initialize(name:, duration: nil, error: nil)
10
+ @name = name
11
+ @duration = duration
12
+ @error = error
13
+ end
14
+
15
+ def success?
16
+ @error.nil?
17
+ end
18
+
19
+ def failure?
20
+ !success?
21
+ end
22
+
23
+ def duration_ms
24
+ return nil unless @duration
25
+ (@duration * 1000).round(1)
26
+ end
27
+
28
+ def icon
29
+ success? ? "✅" : "❌"
30
+ end
31
+
32
+ def format_duration
33
+ return "" unless duration_ms
34
+ "(#{duration_ms}ms)"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ # Terminal control operations with ANSI escape sequences
6
+ class TerminalController
7
+ # ANSI escape sequences
8
+ MOVE_UP = "\033[A"
9
+ CLEAR_LINE = "\033[K"
10
+ MOVE_UP_AND_CLEAR = "#{MOVE_UP}#{CLEAR_LINE}"
11
+
12
+ def initialize(output)
13
+ @output = output
14
+ end
15
+
16
+ def clear_lines(count)
17
+ return if count == 0
18
+
19
+ count.times { @output.print MOVE_UP_AND_CLEAR }
20
+ end
21
+
22
+ def puts(text)
23
+ @output.puts text
24
+ end
25
+
26
+ def print(text)
27
+ @output.print text
28
+ end
29
+
30
+ def flush
31
+ @output.flush
32
+ end
33
+ end
34
+ end
35
+ end