ukiryu 0.1.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.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +143 -0
- data/.gitignore +8 -0
- data/.gitmodules +3 -0
- data/Gemfile +17 -0
- data/README.adoc +295 -0
- data/Rakefile +8 -0
- data/lib/ukiryu/cli.rb +348 -0
- data/lib/ukiryu/errors.rb +30 -0
- data/lib/ukiryu/executor.rb +716 -0
- data/lib/ukiryu/platform.rb +93 -0
- data/lib/ukiryu/registry.rb +246 -0
- data/lib/ukiryu/schema_validator.rb +103 -0
- data/lib/ukiryu/shell/base.rb +79 -0
- data/lib/ukiryu/shell/bash.rb +60 -0
- data/lib/ukiryu/shell/cmd.rb +75 -0
- data/lib/ukiryu/shell/fish.rb +16 -0
- data/lib/ukiryu/shell/powershell.rb +60 -0
- data/lib/ukiryu/shell/sh.rb +16 -0
- data/lib/ukiryu/shell/zsh.rb +16 -0
- data/lib/ukiryu/shell.rb +164 -0
- data/lib/ukiryu/tool.rb +439 -0
- data/lib/ukiryu/type.rb +254 -0
- data/lib/ukiryu/version.rb +5 -0
- data/lib/ukiryu.rb +54 -0
- data/ukiryu.gemspec +33 -0
- metadata +72 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Ukiryu
|
|
7
|
+
# Command execution with platform-specific methods
|
|
8
|
+
#
|
|
9
|
+
# Handles execution of external commands with:
|
|
10
|
+
# - Shell-specific command line building
|
|
11
|
+
# - Environment variable management
|
|
12
|
+
# - Timeout handling
|
|
13
|
+
# - Error detection and reporting
|
|
14
|
+
module Executor
|
|
15
|
+
class << self
|
|
16
|
+
# Execute a command with the the given options
|
|
17
|
+
#
|
|
18
|
+
# @param executable [String] the executable path
|
|
19
|
+
# @param args [Array<String>] the command arguments
|
|
20
|
+
# @param options [Hash] execution options
|
|
21
|
+
# @option options [Integer] :timeout maximum execution time in seconds
|
|
22
|
+
# @option options [Hash] :env environment variables
|
|
23
|
+
# @option options [String] :cwd working directory
|
|
24
|
+
# @option options [Symbol] :shell shell to use (default: auto-detect)
|
|
25
|
+
# @return [Result] execution result with composed OOP classes
|
|
26
|
+
# @raise [TimeoutError] if command times out
|
|
27
|
+
# @raise [ExecutionError] if command fails
|
|
28
|
+
def execute(executable, args = [], options = {})
|
|
29
|
+
shell_name = options[:shell] || Shell.detect
|
|
30
|
+
shell_class = Shell.class_for(shell_name)
|
|
31
|
+
|
|
32
|
+
# Format the command line
|
|
33
|
+
command = build_command(executable, args, shell_class)
|
|
34
|
+
|
|
35
|
+
# Prepare environment
|
|
36
|
+
env = prepare_environment(options[:env] || {}, shell_class)
|
|
37
|
+
|
|
38
|
+
# Execute with timeout
|
|
39
|
+
timeout = options[:timeout] || 90
|
|
40
|
+
cwd = options[:cwd]
|
|
41
|
+
|
|
42
|
+
started_at = Time.now
|
|
43
|
+
begin
|
|
44
|
+
result = execute_with_timeout(command, env, timeout, cwd)
|
|
45
|
+
rescue Timeout::Error
|
|
46
|
+
finished_at = Time.now
|
|
47
|
+
raise TimeoutError, "Command timed out after #{timeout} seconds: #{executable}"
|
|
48
|
+
end
|
|
49
|
+
finished_at = Time.now
|
|
50
|
+
|
|
51
|
+
# Create OOP result components
|
|
52
|
+
command_info = CommandInfo.new(
|
|
53
|
+
executable: executable,
|
|
54
|
+
arguments: args,
|
|
55
|
+
full_command: command,
|
|
56
|
+
shell: shell_name
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
output = Output.new(
|
|
60
|
+
stdout: result[:stdout],
|
|
61
|
+
stderr: result[:stderr],
|
|
62
|
+
exit_status: result[:status]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
metadata = ExecutionMetadata.new(
|
|
66
|
+
started_at: started_at,
|
|
67
|
+
finished_at: finished_at,
|
|
68
|
+
timeout: timeout
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Check exit status
|
|
72
|
+
if result[:status] != 0 && !options[:allow_failure]
|
|
73
|
+
raise ExecutionError, format_error(executable, command, result)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Result.new(
|
|
77
|
+
command_info: command_info,
|
|
78
|
+
output: output,
|
|
79
|
+
metadata: metadata
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Find an executable in the system PATH
|
|
84
|
+
#
|
|
85
|
+
# @param command [String] the command or executable name
|
|
86
|
+
# @param options [Hash] search options
|
|
87
|
+
# @option options [Array<String>] :additional_paths additional search paths
|
|
88
|
+
# @return [String, nil] the full path to the executable, or nil if not found
|
|
89
|
+
def find_executable(command, options = {})
|
|
90
|
+
# Try with PATHEXT extensions (Windows executables)
|
|
91
|
+
exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
|
|
92
|
+
|
|
93
|
+
search_paths = Platform.executable_search_paths
|
|
94
|
+
search_paths.concat(options[:additional_paths]) if options[:additional_paths]
|
|
95
|
+
search_paths.uniq!
|
|
96
|
+
|
|
97
|
+
search_paths.each do |dir|
|
|
98
|
+
exts.each do |ext|
|
|
99
|
+
exe = File.join(dir, "#{command}#{ext}")
|
|
100
|
+
return exe if File.executable?(exe) && !File.directory?(exe)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Build a command line for the given shell
|
|
108
|
+
#
|
|
109
|
+
# @param executable [String] the executable path
|
|
110
|
+
# @param args [Array<String>] the arguments
|
|
111
|
+
# @param shell_class [Class] the shell implementation class
|
|
112
|
+
# @return [String] the complete command line
|
|
113
|
+
def build_command(executable, args, shell_class)
|
|
114
|
+
shell_instance = shell_class.new
|
|
115
|
+
|
|
116
|
+
# Format executable path if needed
|
|
117
|
+
exe = shell_instance.format_path(executable)
|
|
118
|
+
|
|
119
|
+
# Join executable and arguments
|
|
120
|
+
shell_instance.join(exe, *args)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Execute command with timeout in current directory
|
|
126
|
+
#
|
|
127
|
+
# @param command [String] the command to execute
|
|
128
|
+
# @param env [Hash] environment variables
|
|
129
|
+
# @param timeout [Integer] timeout in seconds
|
|
130
|
+
# @return [Hash] execution result
|
|
131
|
+
def execute_with_timeout(command, env, timeout)
|
|
132
|
+
Timeout.timeout(timeout) do
|
|
133
|
+
stdout, stderr, status = Open3.capture3(env, command)
|
|
134
|
+
{
|
|
135
|
+
status: status.exitstatus || 0,
|
|
136
|
+
stdout: stdout,
|
|
137
|
+
stderr: stderr
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Execute command with timeout in specific directory
|
|
143
|
+
#
|
|
144
|
+
# @param command [String] the command to execute
|
|
145
|
+
# @param env [Hash] environment variables
|
|
146
|
+
# @param timeout [Integer] timeout in seconds
|
|
147
|
+
# @param cwd [String, nil] working directory (nil for current directory)
|
|
148
|
+
# @return [Hash] execution result
|
|
149
|
+
def execute_with_timeout(command, env, timeout, cwd = nil)
|
|
150
|
+
Timeout.timeout(timeout) do
|
|
151
|
+
if cwd
|
|
152
|
+
Dir.chdir(cwd) do
|
|
153
|
+
stdout, stderr, status = Open3.capture3(env, command)
|
|
154
|
+
{
|
|
155
|
+
status: extract_status(status),
|
|
156
|
+
stdout: stdout,
|
|
157
|
+
stderr: stderr
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
else
|
|
161
|
+
stdout, stderr, status = Open3.capture3(env, command)
|
|
162
|
+
{
|
|
163
|
+
status: extract_status(status),
|
|
164
|
+
stdout: stdout,
|
|
165
|
+
stderr: stderr
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Extract exit status from Process::Status
|
|
172
|
+
#
|
|
173
|
+
# @param status [Process::Status] the process status
|
|
174
|
+
# @return [Integer] exit status (128 + signal if terminated by signal)
|
|
175
|
+
def extract_status(status)
|
|
176
|
+
if status.exited?
|
|
177
|
+
status.exitstatus
|
|
178
|
+
elsif status.signaled?
|
|
179
|
+
# Process terminated by signal - return 128 + signal number
|
|
180
|
+
# This matches how shells report terminated processes
|
|
181
|
+
128 + status.termsig
|
|
182
|
+
elsif status.stopped?
|
|
183
|
+
# Process was stopped - return 128 + stop signal
|
|
184
|
+
128 + status.stopsig
|
|
185
|
+
else
|
|
186
|
+
# Unknown status - return failure code
|
|
187
|
+
1
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Prepare environment variables
|
|
192
|
+
#
|
|
193
|
+
# @param user_env [Hash] user-specified environment variables
|
|
194
|
+
# @param shell_class [Class] the shell implementation class
|
|
195
|
+
# @return [Hash] merged environment variables
|
|
196
|
+
def prepare_environment(user_env, shell_class)
|
|
197
|
+
shell_instance = shell_class.new
|
|
198
|
+
|
|
199
|
+
# Start with current environment
|
|
200
|
+
env = ENV.to_h.dup
|
|
201
|
+
|
|
202
|
+
# Add user-specified variables
|
|
203
|
+
user_env.each do |key, value|
|
|
204
|
+
env[key] = value
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Add shell-specific headless environment
|
|
208
|
+
headless = shell_instance.headless_environment
|
|
209
|
+
env.merge!(headless)
|
|
210
|
+
|
|
211
|
+
env
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Format an execution error message
|
|
215
|
+
#
|
|
216
|
+
# @param executable [String] the executable name
|
|
217
|
+
# @param command [String] the full command
|
|
218
|
+
# @param result [Hash] the execution result
|
|
219
|
+
# @return [String] formatted error message
|
|
220
|
+
def format_error(executable, command, result)
|
|
221
|
+
<<~ERROR.chomp
|
|
222
|
+
Command failed: #{executable}
|
|
223
|
+
|
|
224
|
+
Command: #{command}
|
|
225
|
+
Exit status: #{result[:status]}
|
|
226
|
+
|
|
227
|
+
STDOUT:
|
|
228
|
+
#{result[:stdout].strip}
|
|
229
|
+
|
|
230
|
+
STDERR:
|
|
231
|
+
#{result[:stderr].strip}
|
|
232
|
+
ERROR
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Execution command information
|
|
237
|
+
#
|
|
238
|
+
# Encapsulates details about the executed command
|
|
239
|
+
class CommandInfo
|
|
240
|
+
attr_reader :executable, :arguments, :full_command, :shell
|
|
241
|
+
|
|
242
|
+
def initialize(executable:, arguments:, full_command:, shell: nil)
|
|
243
|
+
@executable = executable
|
|
244
|
+
@arguments = arguments
|
|
245
|
+
@full_command = full_command
|
|
246
|
+
@shell = shell
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Get the executable name only
|
|
250
|
+
#
|
|
251
|
+
# @return [String] executable name
|
|
252
|
+
def executable_name
|
|
253
|
+
File.basename(@executable)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Get argument count
|
|
257
|
+
#
|
|
258
|
+
# @return [Integer] number of arguments
|
|
259
|
+
def argument_count
|
|
260
|
+
@arguments.count
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# String representation
|
|
264
|
+
#
|
|
265
|
+
# @return [String] command string
|
|
266
|
+
def to_s
|
|
267
|
+
@full_command
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Inspect
|
|
271
|
+
#
|
|
272
|
+
# @return [String] inspection string
|
|
273
|
+
def inspect
|
|
274
|
+
"#<Ukiryu::Executor::CommandInfo exe=#{executable_name.inspect} args=#{argument_count}>"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Convert to hash
|
|
278
|
+
#
|
|
279
|
+
# @return [Hash] command info as hash
|
|
280
|
+
def to_h
|
|
281
|
+
{
|
|
282
|
+
executable: @executable,
|
|
283
|
+
executable_name: executable_name,
|
|
284
|
+
arguments: @arguments,
|
|
285
|
+
full_command: @full_command,
|
|
286
|
+
shell: @shell
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Captured output from command execution
|
|
292
|
+
#
|
|
293
|
+
# Provides typed access to stdout and stderr with parsing utilities
|
|
294
|
+
class Output
|
|
295
|
+
attr_reader :raw_stdout, :raw_stderr, :exit_status
|
|
296
|
+
|
|
297
|
+
def initialize(stdout:, stderr:, exit_status:)
|
|
298
|
+
@raw_stdout = stdout
|
|
299
|
+
@raw_stderr = stderr
|
|
300
|
+
@exit_status = exit_status
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Get stdout as a string (stripped)
|
|
304
|
+
#
|
|
305
|
+
# @return [String] stripped stdout
|
|
306
|
+
def stdout
|
|
307
|
+
@raw_stdout.strip
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Get stderr as a string (stripped)
|
|
311
|
+
#
|
|
312
|
+
# @return [String] stripped stderr
|
|
313
|
+
def stderr
|
|
314
|
+
@raw_stderr.strip
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Get stdout lines as an array
|
|
318
|
+
#
|
|
319
|
+
# @return [Array<String>] stdout split by lines
|
|
320
|
+
def stdout_lines
|
|
321
|
+
@raw_stdout.split("\n")
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Get stderr lines as an array
|
|
325
|
+
#
|
|
326
|
+
# @return [Array<String>] stderr split by lines
|
|
327
|
+
def stderr_lines
|
|
328
|
+
@raw_stderr.split("\n")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Check if stdout contains a pattern
|
|
332
|
+
#
|
|
333
|
+
# @param pattern [String, Regexp] pattern to search for
|
|
334
|
+
# @return [Boolean] true if pattern is found
|
|
335
|
+
def stdout_contains?(pattern)
|
|
336
|
+
if pattern.is_a?(Regexp)
|
|
337
|
+
@raw_stdout.match?(pattern)
|
|
338
|
+
else
|
|
339
|
+
@raw_stdout.include?(pattern.to_s)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Check if stderr contains a pattern
|
|
344
|
+
#
|
|
345
|
+
# @param pattern [String, Regexp] pattern to search for
|
|
346
|
+
# @return [Boolean] true if pattern is found
|
|
347
|
+
def stderr_contains?(pattern)
|
|
348
|
+
if pattern.is_a?(Regexp)
|
|
349
|
+
@raw_stderr.match?(pattern)
|
|
350
|
+
else
|
|
351
|
+
@raw_stderr.include?(pattern.to_s)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Check if stdout is empty
|
|
356
|
+
#
|
|
357
|
+
# @return [Boolean] true if stdout is empty
|
|
358
|
+
def stdout_empty?
|
|
359
|
+
@raw_stdout.strip.empty?
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Check if stderr is empty
|
|
363
|
+
#
|
|
364
|
+
# @return [Boolean] true if stderr is empty
|
|
365
|
+
def stderr_empty?
|
|
366
|
+
@raw_stderr.strip.empty?
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Get stdout length
|
|
370
|
+
#
|
|
371
|
+
# @return [Integer] byte length of stdout
|
|
372
|
+
def stdout_length
|
|
373
|
+
@raw_stdout.length
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Get stderr length
|
|
377
|
+
#
|
|
378
|
+
# @return [Integer] byte length of stderr
|
|
379
|
+
def stderr_length
|
|
380
|
+
@raw_stderr.length
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Check if command succeeded
|
|
384
|
+
#
|
|
385
|
+
# @return [Boolean] true if exit status is 0
|
|
386
|
+
def success?
|
|
387
|
+
@exit_status == 0
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Check if command failed
|
|
391
|
+
#
|
|
392
|
+
# @return [Boolean] true if exit status is non-zero
|
|
393
|
+
def failure?
|
|
394
|
+
@exit_status != 0
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Convert to hash
|
|
398
|
+
#
|
|
399
|
+
# @return [Hash] output as hash
|
|
400
|
+
def to_h
|
|
401
|
+
{
|
|
402
|
+
stdout: @raw_stdout,
|
|
403
|
+
stderr: @raw_stderr,
|
|
404
|
+
exit_status: @exit_status,
|
|
405
|
+
success: success?,
|
|
406
|
+
stdout_lines: stdout_lines,
|
|
407
|
+
stderr_lines: stderr_lines
|
|
408
|
+
}
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# String representation
|
|
412
|
+
#
|
|
413
|
+
# @return [String] summary string
|
|
414
|
+
def to_s
|
|
415
|
+
if success?
|
|
416
|
+
"Success (exit: #{@exit_status}, stdout: #{stdout_length} bytes, stderr: #{stderr_length} bytes)"
|
|
417
|
+
else
|
|
418
|
+
"Failed (exit: #{@exit_status}, stdout: #{stdout_length} bytes, stderr: #{stderr_length} bytes)"
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Inspect
|
|
423
|
+
#
|
|
424
|
+
# @return [String] inspection string
|
|
425
|
+
def inspect
|
|
426
|
+
"#<Ukiryu::Executor::Output exit=#{@exit_status} success=#{success?}>"
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Execution metadata
|
|
431
|
+
#
|
|
432
|
+
# Provides timing and execution environment information
|
|
433
|
+
class ExecutionMetadata
|
|
434
|
+
attr_reader :started_at, :finished_at, :duration, :timeout
|
|
435
|
+
|
|
436
|
+
def initialize(started_at:, finished_at:, timeout: nil)
|
|
437
|
+
@started_at = started_at
|
|
438
|
+
@finished_at = finished_at
|
|
439
|
+
@timeout = timeout
|
|
440
|
+
@duration = calculate_duration
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Calculate duration from start and finish times
|
|
444
|
+
#
|
|
445
|
+
# @return [Float, nil] duration in seconds
|
|
446
|
+
def calculate_duration
|
|
447
|
+
return nil unless @started_at && @finished_at
|
|
448
|
+
|
|
449
|
+
@finished_at - @started_at
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Get execution duration in seconds
|
|
453
|
+
#
|
|
454
|
+
# @return [Float, nil] duration in seconds
|
|
455
|
+
def duration_seconds
|
|
456
|
+
@duration
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Get execution duration in milliseconds
|
|
460
|
+
#
|
|
461
|
+
# @return [Float, nil] duration in milliseconds
|
|
462
|
+
def duration_milliseconds
|
|
463
|
+
@duration ? @duration * 1000 : nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Check if execution timed out
|
|
467
|
+
#
|
|
468
|
+
# @return [Boolean] true if timeout was set and exceeded
|
|
469
|
+
def timed_out?
|
|
470
|
+
return false unless @timeout && @duration
|
|
471
|
+
|
|
472
|
+
@duration > @timeout
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Format duration for display
|
|
476
|
+
#
|
|
477
|
+
# @return [String] formatted duration
|
|
478
|
+
def formatted_duration
|
|
479
|
+
return "N/A" unless @duration
|
|
480
|
+
|
|
481
|
+
if @duration < 1
|
|
482
|
+
"#{(@duration * 1000).round(2)}ms"
|
|
483
|
+
elsif @duration < 60
|
|
484
|
+
"#{@duration.round(3)}s"
|
|
485
|
+
else
|
|
486
|
+
minutes = @duration / 60
|
|
487
|
+
seconds = @duration % 60
|
|
488
|
+
"#{minutes.to_i}m#{seconds.round(1)}s"
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Convert to hash
|
|
493
|
+
#
|
|
494
|
+
# @return [Hash] metadata as hash
|
|
495
|
+
def to_h
|
|
496
|
+
{
|
|
497
|
+
started_at: @started_at,
|
|
498
|
+
finished_at: @finished_at,
|
|
499
|
+
duration: @duration,
|
|
500
|
+
duration_seconds: @duration,
|
|
501
|
+
duration_milliseconds: duration_milliseconds,
|
|
502
|
+
timeout: @timeout,
|
|
503
|
+
timed_out: timed_out?
|
|
504
|
+
}
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# String representation
|
|
508
|
+
#
|
|
509
|
+
# @return [String] formatted string
|
|
510
|
+
def to_s
|
|
511
|
+
"duration: #{formatted_duration}"
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Inspect
|
|
515
|
+
#
|
|
516
|
+
# @return [String] inspection string
|
|
517
|
+
def inspect
|
|
518
|
+
"#<Ukiryu::Executor::ExecutionMetadata duration=#{formatted_duration}>"
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Result class for command execution
|
|
523
|
+
#
|
|
524
|
+
# Provides a rich, object-oriented interface to command execution results.
|
|
525
|
+
# Composes CommandInfo, Output, and ExecutionMetadata for a fully OOP design.
|
|
526
|
+
class Result
|
|
527
|
+
attr_reader :command_info, :output, :metadata
|
|
528
|
+
|
|
529
|
+
# Initialize a new result
|
|
530
|
+
#
|
|
531
|
+
# @param command_info [CommandInfo] the command execution info
|
|
532
|
+
# @param output [Output] the captured output
|
|
533
|
+
# @param metadata [ExecutionMetadata] execution metadata
|
|
534
|
+
def initialize(command_info:, output:, metadata:)
|
|
535
|
+
@command_info = command_info
|
|
536
|
+
@output = output
|
|
537
|
+
@metadata = metadata
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Get the full command string
|
|
541
|
+
#
|
|
542
|
+
# @return [String] executed command
|
|
543
|
+
def command
|
|
544
|
+
@command_info.full_command
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Get the executable
|
|
548
|
+
#
|
|
549
|
+
# @return [String] executable path
|
|
550
|
+
def executable
|
|
551
|
+
@command_info.executable
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Get the executable name only
|
|
555
|
+
#
|
|
556
|
+
# @return [String] executable name
|
|
557
|
+
def executable_name
|
|
558
|
+
@command_info.executable_name
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Get raw stdout
|
|
562
|
+
#
|
|
563
|
+
# @return [String] raw stdout
|
|
564
|
+
def stdout
|
|
565
|
+
@output.raw_stdout
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Get raw stderr
|
|
569
|
+
#
|
|
570
|
+
# @return [String] raw stderr
|
|
571
|
+
def stderr
|
|
572
|
+
@output.raw_stderr
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Get exit status code
|
|
576
|
+
#
|
|
577
|
+
# @return [Integer] exit status
|
|
578
|
+
def status
|
|
579
|
+
@output.exit_status
|
|
580
|
+
end
|
|
581
|
+
alias exit_code status
|
|
582
|
+
|
|
583
|
+
# Get the exit code (alias for status)
|
|
584
|
+
#
|
|
585
|
+
# @return [Integer] exit status
|
|
586
|
+
def exit_status
|
|
587
|
+
@output.exit_status
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# Get start time
|
|
591
|
+
#
|
|
592
|
+
# @return [Time] when command started
|
|
593
|
+
def started_at
|
|
594
|
+
@metadata.started_at
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Get finish time
|
|
598
|
+
#
|
|
599
|
+
# @return [Time] when command finished
|
|
600
|
+
def finished_at
|
|
601
|
+
@metadata.finished_at
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Get execution duration
|
|
605
|
+
#
|
|
606
|
+
# @return [Float, nil] duration in seconds
|
|
607
|
+
def duration
|
|
608
|
+
@metadata.duration
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Get execution duration (alias)
|
|
612
|
+
#
|
|
613
|
+
# @return [Float, nil] duration in seconds
|
|
614
|
+
def execution_time
|
|
615
|
+
@metadata.duration_seconds
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Check if the command succeeded
|
|
619
|
+
#
|
|
620
|
+
# @return [Boolean]
|
|
621
|
+
def success?
|
|
622
|
+
@output.success?
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# Check if the command failed
|
|
626
|
+
#
|
|
627
|
+
# @return [Boolean]
|
|
628
|
+
def failure?
|
|
629
|
+
@output.failure?
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# Get stdout as a stripped string
|
|
633
|
+
#
|
|
634
|
+
# @return [String] stripped stdout
|
|
635
|
+
def output
|
|
636
|
+
@output.stdout
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# Get stderr as a stripped string
|
|
640
|
+
#
|
|
641
|
+
# @return [String] stripped stderr
|
|
642
|
+
def error_output
|
|
643
|
+
@output.stderr
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# Get stdout lines
|
|
647
|
+
#
|
|
648
|
+
# @return [Array<String>] stdout split by lines
|
|
649
|
+
def stdout_lines
|
|
650
|
+
@output.stdout_lines
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# Get stderr lines
|
|
654
|
+
#
|
|
655
|
+
# @return [Array<String>] stderr split by lines
|
|
656
|
+
def stderr_lines
|
|
657
|
+
@output.stderr_lines
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# Check if stdout contains a pattern
|
|
661
|
+
#
|
|
662
|
+
# @param pattern [String, Regexp] pattern to search for
|
|
663
|
+
# @return [Boolean] true if pattern is found
|
|
664
|
+
def stdout_contains?(pattern)
|
|
665
|
+
@output.stdout_contains?(pattern)
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Check if stderr contains a pattern
|
|
669
|
+
#
|
|
670
|
+
# @param pattern [String, Regexp] pattern to search for
|
|
671
|
+
# @return [Boolean] true if pattern is found
|
|
672
|
+
def stderr_contains?(pattern)
|
|
673
|
+
@output.stderr_contains?(pattern)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Get a hash representation of the result
|
|
677
|
+
#
|
|
678
|
+
# @return [Hash] result data as a hash
|
|
679
|
+
def to_h
|
|
680
|
+
{
|
|
681
|
+
command: @command_info.to_h,
|
|
682
|
+
output: @output.to_h,
|
|
683
|
+
metadata: @metadata.to_h,
|
|
684
|
+
success: success?,
|
|
685
|
+
status: status
|
|
686
|
+
}
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Get a JSON representation of the result
|
|
690
|
+
#
|
|
691
|
+
# @return [String] result data as JSON
|
|
692
|
+
def to_json(*args)
|
|
693
|
+
require 'json'
|
|
694
|
+
to_h.to_json(*args)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# String representation of the result
|
|
698
|
+
#
|
|
699
|
+
# @return [String] summary of the result
|
|
700
|
+
def to_s
|
|
701
|
+
if success?
|
|
702
|
+
"Success (#{@command_info.executable_name}, status: #{status}, duration: #{@metadata.formatted_duration})"
|
|
703
|
+
else
|
|
704
|
+
"Failed (#{@command_info.executable_name}, status: #{status}, duration: #{@metadata.formatted_duration})"
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Inspect the result (for debugging)
|
|
709
|
+
#
|
|
710
|
+
# @return [String] detailed inspection string
|
|
711
|
+
def inspect
|
|
712
|
+
"#<Ukiryu::Executor::Result exe=#{@command_info.executable_name.inspect} status=#{status} duration=#{@metadata.formatted_duration}>"
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
end
|