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.
@@ -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