parabot 1.0.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/.rubocop.yml +55 -0
- data/CLAUDE.md +171 -0
- data/DISTRIBUTION.md +287 -0
- data/INSTALL.md +242 -0
- data/LICENSE +21 -0
- data/README.md +371 -0
- data/Rakefile +50 -0
- data/config/base.yml +12 -0
- data/config/commands.yml +28 -0
- data/config/core_prompts/system_prompt.yml +66 -0
- data/config/core_prompts/test_guidance.yml +24 -0
- data/config/languages/elixir.yml +62 -0
- data/config/languages/javascript.yml +64 -0
- data/config/languages/kotlin.yml +64 -0
- data/config/languages/ruby.yml +66 -0
- data/config/languages/shell.yml +63 -0
- data/config/languages/typescript.yml +63 -0
- data/exe/parabot +22 -0
- data/lib/parabot/cli/argument_parser.rb +105 -0
- data/lib/parabot/cli/command_router.rb +114 -0
- data/lib/parabot/cli.rb +71 -0
- data/lib/parabot/commands/base.rb +108 -0
- data/lib/parabot/commands/custom_commands.rb +63 -0
- data/lib/parabot/commands/doctor.rb +196 -0
- data/lib/parabot/commands/init.rb +171 -0
- data/lib/parabot/commands/message.rb +25 -0
- data/lib/parabot/commands/start.rb +35 -0
- data/lib/parabot/commands/test.rb +43 -0
- data/lib/parabot/commands/version.rb +17 -0
- data/lib/parabot/commands.rb +15 -0
- data/lib/parabot/configuration.rb +199 -0
- data/lib/parabot/dry_run_logging.rb +22 -0
- data/lib/parabot/errors.rb +12 -0
- data/lib/parabot/language_detector.rb +158 -0
- data/lib/parabot/language_inference.rb +82 -0
- data/lib/parabot/logging_setup.rb +73 -0
- data/lib/parabot/messaging/adapter.rb +53 -0
- data/lib/parabot/messaging/adapter_factory.rb +33 -0
- data/lib/parabot/messaging/dry_run_adapter.rb +55 -0
- data/lib/parabot/messaging/tmux_adapter.rb +82 -0
- data/lib/parabot/system.rb +41 -0
- data/lib/parabot/test_runner.rb +179 -0
- data/lib/parabot/tmux_manager.rb +245 -0
- data/lib/parabot/version.rb +5 -0
- data/lib/parabot/yaml_text_assembler.rb +155 -0
- data/lib/parabot.rb +30 -0
- data/parabot.gemspec +44 -0
- data/scripts/build-distribution +122 -0
- data/scripts/install +152 -0
- metadata +221 -0
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Parabot
|
4
|
+
class LanguageInference
|
5
|
+
EXTENSION_PATTERNS = {
|
6
|
+
"python" => [".py", ".pyx", ".pyi"],
|
7
|
+
"java" => [".java"],
|
8
|
+
"cpp" => [".cpp", ".cc", ".cxx", ".c++", ".hpp", ".h"],
|
9
|
+
"c" => [".c", ".h"],
|
10
|
+
"go" => [".go"],
|
11
|
+
"rust" => [".rs"],
|
12
|
+
"php" => [".php"],
|
13
|
+
"swift" => [".swift"],
|
14
|
+
"dart" => [".dart"],
|
15
|
+
"scala" => [".scala"],
|
16
|
+
"clojure" => [".clj", ".cljs", ".cljc"],
|
17
|
+
"haskell" => [".hs", ".lhs"],
|
18
|
+
"erlang" => [".erl", ".hrl"],
|
19
|
+
"csharp" => [".cs"],
|
20
|
+
"fsharp" => [".fs", ".fsx"],
|
21
|
+
"perl" => [".pl", ".pm"],
|
22
|
+
"lua" => [".lua"],
|
23
|
+
"r" => [".R", ".r"],
|
24
|
+
"matlab" => [".m"],
|
25
|
+
"julia" => [".jl"]
|
26
|
+
}.freeze
|
27
|
+
|
28
|
+
TEST_DIR_PATTERNS = {
|
29
|
+
"python" => %w[tests test],
|
30
|
+
"java" => ["src/test/java", "test"],
|
31
|
+
"go" => ["."], # Go tests alongside source
|
32
|
+
"rust" => ["tests"],
|
33
|
+
"php" => %w[tests test],
|
34
|
+
"swift" => ["Tests"],
|
35
|
+
"dart" => ["test"],
|
36
|
+
"scala" => ["src/test/scala", "test"],
|
37
|
+
"clojure" => ["test"],
|
38
|
+
"haskell" => ["test"],
|
39
|
+
"erlang" => ["test"],
|
40
|
+
"csharp" => %w[test tests],
|
41
|
+
"fsharp" => %w[test tests],
|
42
|
+
"perl" => %w[t test],
|
43
|
+
"lua" => %w[test spec],
|
44
|
+
"r" => %w[tests test],
|
45
|
+
"matlab" => ["test"],
|
46
|
+
"julia" => ["test"]
|
47
|
+
}.freeze
|
48
|
+
|
49
|
+
PROJECT_FILES = {
|
50
|
+
"python" => ["requirements.txt", "setup.py", "pyproject.toml", "Pipfile"],
|
51
|
+
"java" => ["pom.xml", "build.gradle", "build.gradle.kts"],
|
52
|
+
"go" => ["go.mod", "go.sum"],
|
53
|
+
"rust" => ["Cargo.toml", "Cargo.lock"],
|
54
|
+
"php" => ["composer.json", "composer.lock"],
|
55
|
+
"swift" => ["Package.swift"],
|
56
|
+
"dart" => ["pubspec.yaml"],
|
57
|
+
"scala" => ["build.sbt", "project/build.properties"],
|
58
|
+
"clojure" => ["project.clj", "deps.edn"],
|
59
|
+
"haskell" => ["*.cabal", "stack.yaml"],
|
60
|
+
"erlang" => ["rebar.config", "erlang.mk"],
|
61
|
+
"csharp" => ["*.csproj", "*.sln"],
|
62
|
+
"fsharp" => ["*.fsproj", "*.sln"],
|
63
|
+
"perl" => ["Makefile.PL", "Build.PL"],
|
64
|
+
"lua" => ["*.rockspec"],
|
65
|
+
"r" => %w[DESCRIPTION NAMESPACE],
|
66
|
+
"matlab" => ["*.prj"],
|
67
|
+
"julia" => ["Project.toml", "Manifest.toml"]
|
68
|
+
}.freeze
|
69
|
+
|
70
|
+
def self.infer_extensions(language)
|
71
|
+
EXTENSION_PATTERNS[language.downcase] || [".#{language}"]
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.infer_test_dirs(language)
|
75
|
+
TEST_DIR_PATTERNS[language.downcase] || %w[test tests]
|
76
|
+
end
|
77
|
+
|
78
|
+
def self.infer_project_files(language)
|
79
|
+
PROJECT_FILES[language.downcase] || []
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "semantic_logger"
|
4
|
+
require "pathname"
|
5
|
+
|
6
|
+
module Parabot
|
7
|
+
class LoggingSetup
|
8
|
+
def self.setup_with_config(config)
|
9
|
+
new(config.log_level, config.log_file, config.method(:resolve_log_file_path),
|
10
|
+
config.method(:ensure_log_directory)).setup
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.setup_default(level = :info, file = "stderr")
|
14
|
+
new(level, file).setup
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(level = :info, file = "stderr", file_resolver = nil, dir_ensurer = nil)
|
18
|
+
@level = level
|
19
|
+
@file = file
|
20
|
+
@file_resolver = file_resolver
|
21
|
+
@dir_ensurer = dir_ensurer
|
22
|
+
end
|
23
|
+
|
24
|
+
def setup
|
25
|
+
SemanticLogger.default_level = @level
|
26
|
+
SemanticLogger.clear_appenders!
|
27
|
+
|
28
|
+
if %w[stderr stdout].include?(@file)
|
29
|
+
setup_stderr_logging
|
30
|
+
else
|
31
|
+
setup_file_logging
|
32
|
+
end
|
33
|
+
rescue StandardError => e
|
34
|
+
# Fall back to stderr logging if file logging fails
|
35
|
+
fallback_to_stderr(e)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
attr_reader :level, :file, :file_resolver, :dir_ensurer
|
41
|
+
|
42
|
+
def setup_stderr_logging
|
43
|
+
SemanticLogger.add_appender(io: $stderr, formatter: custom_formatter)
|
44
|
+
end
|
45
|
+
|
46
|
+
def setup_file_logging
|
47
|
+
file_path = file_resolver ? file_resolver.call : @file
|
48
|
+
dir_ensurer&.call(file_path)
|
49
|
+
SemanticLogger.add_appender(file: file_path, formatter: :default)
|
50
|
+
end
|
51
|
+
|
52
|
+
def fallback_to_stderr(error)
|
53
|
+
SemanticLogger.clear_appenders!
|
54
|
+
SemanticLogger.add_appender(io: $stderr, formatter: custom_formatter)
|
55
|
+
SemanticLogger["Parabot::LoggingSetup"].warn("Failed to setup file logging: #{error.message}")
|
56
|
+
end
|
57
|
+
|
58
|
+
def custom_formatter
|
59
|
+
proc do |log|
|
60
|
+
reset_color = "\e[0m"
|
61
|
+
level_color = case log.level
|
62
|
+
when :trace, :debug then "\e[0;90m" # GRAY - Dark gray like shell
|
63
|
+
when :info then "\e[0;36m" # CYAN - Cyan like shell
|
64
|
+
when :warn then "\e[1;33m" # YELLOW - Bold yellow like shell
|
65
|
+
when :error, :fatal then "\e[0;31m" # RED - Red like shell
|
66
|
+
else "\e[0m" # Default
|
67
|
+
end
|
68
|
+
level_tag = "[#{log.level.to_s.upcase}]"
|
69
|
+
"#{level_color}#{level_tag}#{reset_color} #{log.message}"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Parabot
|
4
|
+
module Messaging
|
5
|
+
# Base class for messaging adapters that can send messages to Claude
|
6
|
+
# in different ways (tmux, direct API calls, etc.)
|
7
|
+
class Adapter
|
8
|
+
# @abstract
|
9
|
+
# @param message [String] the message to send to Claude
|
10
|
+
# @return [Boolean] true if message was sent successfully
|
11
|
+
# @raise [NotImplementedError] if not implemented by subclass
|
12
|
+
def send_message(message)
|
13
|
+
raise NotImplementedError, "#{self.class} must implement #send_message"
|
14
|
+
end
|
15
|
+
|
16
|
+
# @abstract
|
17
|
+
# @param system_prompt [String, nil] optional system prompt
|
18
|
+
# @return [Boolean] true if Claude session was started successfully
|
19
|
+
# @raise [NotImplementedError] if not implemented by subclass
|
20
|
+
def start_session(system_prompt: nil)
|
21
|
+
raise NotImplementedError, "#{self.class} must implement #start_session"
|
22
|
+
end
|
23
|
+
|
24
|
+
# @abstract
|
25
|
+
# @return [Boolean] true if session was ended successfully
|
26
|
+
# @raise [NotImplementedError] if not implemented by subclass
|
27
|
+
def end_session
|
28
|
+
raise NotImplementedError, "#{self.class} must implement #end_session"
|
29
|
+
end
|
30
|
+
|
31
|
+
# @abstract
|
32
|
+
# @return [Boolean] true if abort command was sent successfully (usually ESC key)
|
33
|
+
# @raise [NotImplementedError] if not implemented by subclass
|
34
|
+
def abort
|
35
|
+
raise NotImplementedError, "#{self.class} must implement #abort"
|
36
|
+
end
|
37
|
+
|
38
|
+
# @abstract
|
39
|
+
# @return [Boolean] true if Claude session is available and active
|
40
|
+
# @raise [NotImplementedError] if not implemented by subclass
|
41
|
+
def session_active?
|
42
|
+
raise NotImplementedError, "#{self.class} must implement #session_active?"
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def logger
|
48
|
+
require "semantic_logger"
|
49
|
+
SemanticLogger[self.class.name]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "tmux_adapter"
|
4
|
+
require_relative "dry_run_adapter"
|
5
|
+
|
6
|
+
module Parabot
|
7
|
+
module Messaging
|
8
|
+
# Factory class to create appropriate messaging adapters
|
9
|
+
class AdapterFactory
|
10
|
+
# Available adapter types
|
11
|
+
ADAPTERS = {
|
12
|
+
tmux: TmuxAdapter,
|
13
|
+
dry_run: DryRunAdapter
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
# @param type [Symbol] the type of adapter to create (:tmux, :dry_run)
|
17
|
+
# @param kwargs [Hash] additional keyword arguments to pass to the adapter constructor
|
18
|
+
# @return [Messaging::Adapter] an instance of the requested adapter
|
19
|
+
# @raise [ArgumentError] if adapter type is not supported
|
20
|
+
def self.create(type = :tmux, **kwargs)
|
21
|
+
adapter_class = ADAPTERS[type]
|
22
|
+
raise ArgumentError, "Unsupported adapter type: #{type}. Available types: #{ADAPTERS.keys}" unless adapter_class
|
23
|
+
|
24
|
+
adapter_class.new(**kwargs)
|
25
|
+
end
|
26
|
+
|
27
|
+
# @return [Array<Symbol>] list of available adapter types
|
28
|
+
def self.available_types
|
29
|
+
ADAPTERS.keys
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "adapter"
|
4
|
+
require_relative "../dry_run_logging"
|
5
|
+
|
6
|
+
module Parabot
|
7
|
+
module Messaging
|
8
|
+
# Dry-run implementation of the MessagingAdapter that logs actions without executing them
|
9
|
+
class DryRunAdapter < Adapter
|
10
|
+
include DryRunLogging
|
11
|
+
|
12
|
+
def initialize(dry_run: true, **_kwargs)
|
13
|
+
@dry_run = dry_run
|
14
|
+
end
|
15
|
+
|
16
|
+
# @param message [String] the message to send to Claude
|
17
|
+
# @return [Boolean] always true (simulated success)
|
18
|
+
def send_message(message)
|
19
|
+
dry_run_log "Would send message to Claude:"
|
20
|
+
puts message
|
21
|
+
true
|
22
|
+
end
|
23
|
+
|
24
|
+
# @param system_prompt [String, nil] optional system prompt
|
25
|
+
# @return [Boolean] always true (simulated success)
|
26
|
+
def start_session(system_prompt: nil)
|
27
|
+
dry_run_log "Would start Claude session"
|
28
|
+
if system_prompt
|
29
|
+
dry_run_log "Would use system prompt: #{system_prompt[0..100]}#{'...' if system_prompt.length > 100}"
|
30
|
+
else
|
31
|
+
dry_run_log "Would start without system prompt"
|
32
|
+
end
|
33
|
+
true
|
34
|
+
end
|
35
|
+
|
36
|
+
# @return [Boolean] always true (simulated success)
|
37
|
+
def end_session
|
38
|
+
dry_run_log "Would end Claude session"
|
39
|
+
true
|
40
|
+
end
|
41
|
+
|
42
|
+
# @return [Boolean] always true (simulated success)
|
43
|
+
def abort
|
44
|
+
dry_run_log "Would send abort command (ESC key) to Claude"
|
45
|
+
true
|
46
|
+
end
|
47
|
+
|
48
|
+
# @return [Boolean] always true (simulated active session)
|
49
|
+
def session_active?
|
50
|
+
dry_run_log "Would check if Claude session is active"
|
51
|
+
true
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "adapter"
|
4
|
+
require_relative "../tmux_manager"
|
5
|
+
|
6
|
+
module Parabot
|
7
|
+
module Messaging
|
8
|
+
# Tmux implementation of the MessagingAdapter
|
9
|
+
class TmuxAdapter < Adapter
|
10
|
+
attr_reader :tmux_manager
|
11
|
+
|
12
|
+
def initialize(dry_run: false, **_kwargs)
|
13
|
+
@tmux_manager = TmuxManager.new(dry_run: dry_run)
|
14
|
+
end
|
15
|
+
|
16
|
+
# @param message [String] the message to send to Claude
|
17
|
+
# @return [Boolean] true if message was sent successfully
|
18
|
+
def send_message(message)
|
19
|
+
tmux_manager.send_to_claude(message)
|
20
|
+
end
|
21
|
+
|
22
|
+
# @param system_prompt [String, nil] optional system prompt
|
23
|
+
# @return [Boolean] true if Claude session was started successfully
|
24
|
+
def start_session(system_prompt: nil)
|
25
|
+
tmux_manager.start_claude_session(system_prompt: system_prompt)
|
26
|
+
end
|
27
|
+
|
28
|
+
# @return [Boolean] true if session was ended successfully
|
29
|
+
def end_session
|
30
|
+
session = tmux_manager.find_claude_session
|
31
|
+
return false unless session
|
32
|
+
|
33
|
+
target = tmux_manager.find_claude_target(session)
|
34
|
+
|
35
|
+
# Send Ctrl+C then exit command
|
36
|
+
send_cmd("tmux send-keys -t #{session}:#{target.tr(':', '.')} C-c")
|
37
|
+
sleep(0.2)
|
38
|
+
send_cmd("tmux send-keys -t #{session}:#{target.tr(':', '.')} exit Enter")
|
39
|
+
|
40
|
+
true
|
41
|
+
rescue TmuxError
|
42
|
+
false
|
43
|
+
end
|
44
|
+
|
45
|
+
# @return [Boolean] true if abort command was sent successfully (ESC key)
|
46
|
+
def abort
|
47
|
+
session = tmux_manager.find_claude_session
|
48
|
+
return false unless session
|
49
|
+
|
50
|
+
target = tmux_manager.find_claude_target(session)
|
51
|
+
|
52
|
+
# Send ESC key to abort current Claude response
|
53
|
+
send_cmd("tmux send-keys -t #{session}:#{target.tr(':', '.')} Escape")
|
54
|
+
|
55
|
+
true
|
56
|
+
rescue TmuxError
|
57
|
+
false
|
58
|
+
end
|
59
|
+
|
60
|
+
# @return [Boolean] true if Claude session is available and active
|
61
|
+
def session_active?
|
62
|
+
session = tmux_manager.find_claude_session
|
63
|
+
return false unless session
|
64
|
+
|
65
|
+
target = tmux_manager.find_claude_target(session)
|
66
|
+
|
67
|
+
# Check if the target pane has a Claude process running
|
68
|
+
window_pane = target.tr(":", ".")
|
69
|
+
result = tmux_manager.run_command("tmux list-panes -t #{session}:#{window_pane} -F '\#{pane_current_command}'")
|
70
|
+
result.success? && result.out.strip.match?(/claude/i)
|
71
|
+
rescue SystemCallError
|
72
|
+
false
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def send_cmd(command)
|
78
|
+
tmux_manager.run_command(command)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require "semantic_logger"
|
2
|
+
|
3
|
+
module Parabot
|
4
|
+
class System
|
5
|
+
PARABOT_DIR = ".parabot".freeze
|
6
|
+
|
7
|
+
class << self
|
8
|
+
def logger
|
9
|
+
SemanticLogger["Parabot"]
|
10
|
+
end
|
11
|
+
|
12
|
+
def determine_path(filename, project_root)
|
13
|
+
return filename if Pathname.new(filename).absolute?
|
14
|
+
|
15
|
+
File.join(project_root, filename)
|
16
|
+
end
|
17
|
+
|
18
|
+
def determine_system_path(filename, project_root)
|
19
|
+
ensure_parabot_directory_in(project_root)
|
20
|
+
File.join(project_root, PARABOT_DIR, filename)
|
21
|
+
end
|
22
|
+
|
23
|
+
def determine_config_path(relative_path)
|
24
|
+
File.join(__dir__, "../../config", relative_path)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def ensure_parabot_directory_in(project_root)
|
30
|
+
path = File.join(project_root, PARABOT_DIR)
|
31
|
+
|
32
|
+
unless Dir.exist?(path)
|
33
|
+
FileUtils.mkdir_p(path)
|
34
|
+
logger.info("Created parabot directory: #{path}")
|
35
|
+
end
|
36
|
+
rescue SystemCallError => e
|
37
|
+
logger.warn("Cannot create parabot directory #{path}: #{e.message}")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,179 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "ostruct"
|
5
|
+
require "fileutils"
|
6
|
+
require "shellwords"
|
7
|
+
require "tempfile"
|
8
|
+
require_relative "configuration"
|
9
|
+
require_relative "dry_run_logging"
|
10
|
+
require_relative "system"
|
11
|
+
|
12
|
+
module Parabot
|
13
|
+
class TestRunner
|
14
|
+
include DryRunLogging
|
15
|
+
|
16
|
+
TestExecutionError = Class.new(StandardError)
|
17
|
+
TestResult = Struct.new(:exit_status, :output) do
|
18
|
+
def success?
|
19
|
+
exit_status.zero?
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def initialize(messaging_adapter, language_detector, dry_run: false, config: nil, language: nil)
|
24
|
+
@config = config || Configuration.current
|
25
|
+
@dry_run = dry_run
|
26
|
+
@messaging_adapter = messaging_adapter
|
27
|
+
@language = Array(language).first
|
28
|
+
@language_detector = language_detector
|
29
|
+
end
|
30
|
+
|
31
|
+
def run_tests(args: [], send_to_claude: true)
|
32
|
+
test_command = determine_test_command_from_context(args)
|
33
|
+
|
34
|
+
logger.info("Running tests...")
|
35
|
+
logger.info("Executing: #{test_command}")
|
36
|
+
|
37
|
+
result = run_command(test_command)
|
38
|
+
test_results = format_test_results(test_command, result)
|
39
|
+
output_file = save_test_output(test_results)
|
40
|
+
|
41
|
+
TestResult.new(
|
42
|
+
exit_status: result.exit_status,
|
43
|
+
output: output_file || test_results
|
44
|
+
)
|
45
|
+
rescue SystemCallError => e
|
46
|
+
logger.error("Test execution failed: #{e.message}")
|
47
|
+
false
|
48
|
+
rescue StandardError => e
|
49
|
+
raise TestExecutionError, "Failed to run tests: #{e.message}"
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
attr_reader :config, :messaging_adapter, :language, :language_detector
|
55
|
+
|
56
|
+
def run_command(command)
|
57
|
+
dry_run_log(command)
|
58
|
+
return dry_run_stub if dry_run?
|
59
|
+
|
60
|
+
execute_test_command(command)
|
61
|
+
end
|
62
|
+
|
63
|
+
def determine_test_command_from_context(args)
|
64
|
+
detected_language = language || detect_language_from_context(args)
|
65
|
+
|
66
|
+
dry_run_log("Detected language: #{detected_language}")
|
67
|
+
build_command(detected_language, args)
|
68
|
+
end
|
69
|
+
|
70
|
+
def detect_language_from_context(args)
|
71
|
+
detect_from_file_extensions(args) \
|
72
|
+
|| detect_from_directory_scanning(args) \
|
73
|
+
|| language_detector.detect_all_languages.first
|
74
|
+
end
|
75
|
+
|
76
|
+
def detect_from_file_extensions(args)
|
77
|
+
extensions = args.flat_map { |arg| extract_extensions_from_path(arg) }.compact.uniq
|
78
|
+
language_detector.find_language_from_extensions(extensions)
|
79
|
+
end
|
80
|
+
|
81
|
+
def detect_from_directory_scanning(args)
|
82
|
+
directories = Array(args.select { |arg| File.directory?(arg) })
|
83
|
+
|
84
|
+
directories.each do |dir|
|
85
|
+
languages = language_detector.detect_all_languages(dir)
|
86
|
+
return languages.first unless languages.empty?
|
87
|
+
end
|
88
|
+
|
89
|
+
nil
|
90
|
+
end
|
91
|
+
|
92
|
+
def extract_extensions_from_path(path)
|
93
|
+
# Use System.determine_path to handle both absolute and relative paths
|
94
|
+
full_path = System.determine_path(path, config.project_root)
|
95
|
+
|
96
|
+
if File.exist?(full_path)
|
97
|
+
[File.extname(full_path)]
|
98
|
+
elsif path.include?("*") || path.include?("?")
|
99
|
+
# Handle glob patterns - use full_path for the glob too
|
100
|
+
Dir.glob(full_path).map { |f| File.extname(f) }.uniq
|
101
|
+
else
|
102
|
+
[]
|
103
|
+
end.reject(&:empty?)
|
104
|
+
end
|
105
|
+
|
106
|
+
def build_command(language, args)
|
107
|
+
base_command = config.test_command(language)
|
108
|
+
args.any? ? "#{base_command} #{args.join(' ')}"
|
109
|
+
: base_command
|
110
|
+
end
|
111
|
+
|
112
|
+
def execute_test_command(command)
|
113
|
+
temp_file = Tempfile.new("parabot_test")
|
114
|
+
|
115
|
+
begin
|
116
|
+
script_command = "script -q -c #{command.shellescape} /dev/null 2>&1 | tee #{temp_file.path.shellescape}"
|
117
|
+
|
118
|
+
# Execute the streaming command
|
119
|
+
system(script_command)
|
120
|
+
exit_status = $?.exitstatus
|
121
|
+
|
122
|
+
# Read the captured output
|
123
|
+
temp_file.rewind
|
124
|
+
output = temp_file.read
|
125
|
+
|
126
|
+
TestResult.new(
|
127
|
+
exit_status: exit_status,
|
128
|
+
output: output
|
129
|
+
)
|
130
|
+
ensure
|
131
|
+
temp_file.close
|
132
|
+
temp_file.unlink
|
133
|
+
end
|
134
|
+
rescue SystemCallError => e
|
135
|
+
logger.error("Failed to execute test command: #{e.message}")
|
136
|
+
|
137
|
+
TestResult.new(
|
138
|
+
exit_status: 1,
|
139
|
+
output: "Error executing test: #{e.message}"
|
140
|
+
)
|
141
|
+
end
|
142
|
+
|
143
|
+
def format_test_results(command, result)
|
144
|
+
status = result.success? ? "passed" : "failed"
|
145
|
+
|
146
|
+
<<~RESULTS
|
147
|
+
Command: #{command}
|
148
|
+
Status: #{status}
|
149
|
+
Exit code: #{result.exit_status}
|
150
|
+
|
151
|
+
Output:
|
152
|
+
#{result.output}
|
153
|
+
RESULTS
|
154
|
+
end
|
155
|
+
|
156
|
+
def save_test_output(results_text)
|
157
|
+
output_file = Parabot::System.determine_system_path("tests_output", config.project_root)
|
158
|
+
|
159
|
+
File.write(output_file, results_text)
|
160
|
+
logger.info("Test results saved to #{output_file}")
|
161
|
+
|
162
|
+
output_file
|
163
|
+
rescue SystemCallError => e
|
164
|
+
logger.warn("Failed to save test output to file: #{e.message}")
|
165
|
+
nil
|
166
|
+
end
|
167
|
+
|
168
|
+
def dry_run_stub
|
169
|
+
TestResult.new(
|
170
|
+
exit_status: 0,
|
171
|
+
output: "DRY RUN: Mock test output\n\n3 examples, 0 failures\n\nFinished in 0.123 seconds"
|
172
|
+
)
|
173
|
+
end
|
174
|
+
|
175
|
+
def logger
|
176
|
+
Parabot::System.logger
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|