summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/dsl.rb30
-rw-r--r--lib/dslkeywords/agent.rb45
-rw-r--r--lib/dslkeywords/file.rb97
-rw-r--r--lib/dslkeywords/keyword.rb5
-rw-r--r--lib/dslkeywords/prompt.rb45
5 files changed, 214 insertions, 8 deletions
diff --git a/lib/dsl.rb b/lib/dsl.rb
index e3f6ee2..57bbef3 100644
--- a/lib/dsl.rb
+++ b/lib/dsl.rb
@@ -1,8 +1,13 @@
+# frozen_string_literal: true
+
+# rubocop:disable Style/ClassVars
require_relative 'config'
require_relative 'options'
require_relative 'log'
require_relative 'chained'
+require_relative 'dslkeywords/agent'
+require_relative 'dslkeywords/prompt'
require_relative 'dslkeywords/file'
require_relative 'dslkeywords/symlink'
require_relative 'dslkeywords/touch'
@@ -29,6 +34,9 @@ module RCM
include Chained
class DuplicateResource < StandardError; end
+ class DuplicateDefinition < StandardError; end
+ class NoSuchAgentDefinition < StandardError; end
+ class NoSuchPromptDefinition < StandardError; end
def initialize(reset)
DSL.reset! if reset
@@ -41,10 +49,20 @@ module RCM
def to_s = @id
def evaluate! = @scheduled.each(&:evaluate!)
- def <<(obj)
- raise DuplicateResource, "#{obj.id} already declared!" if @@objs.key?(obj.id)
+ def <<(obj) = register(obj)
+
+ def register(obj, schedule: obj.is_a?(Resource), duplicate_error: DuplicateResource)
+ raise duplicate_error, "#{obj.id} already declared!" if @@objs.key?(obj.id)
- @scheduled << @@objs[obj.id] = obj
+ @@objs[obj.id] = obj
+ @scheduled << obj if schedule
+ obj
+ end
+
+ def object!(klass, name, error_class:, kind:)
+ @@objs.fetch(klass.id_for(name)) do
+ raise error_class, "No such #{kind} '#{name}'"
+ end
end
private
@@ -63,13 +81,15 @@ module RCM
return unless @conds_met
obj = klass.new(path)
+ obj.dsl = self if obj.respond_to?(:dsl=)
yield obj
- self << obj
- obj
+ register(obj)
end
end
end
+# rubocop:enable Style/ClassVars
+
def configure(reset: false, &block)
# Parse ARGV and load config.toml each time configure is called so that
# scripts and test suites that call configure multiple times always
diff --git a/lib/dslkeywords/agent.rb b/lib/dslkeywords/agent.rb
new file mode 100644
index 0000000..6632835
--- /dev/null
+++ b/lib/dslkeywords/agent.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require_relative 'keyword'
+
+module RCM
+ # Stores a named shell command template for agent-backed file processing.
+ class AgentDefinition < Keyword
+ attr_reader :name
+
+ class InvalidName < StandardError; end
+
+ def self.id_for(name) = super(normalize_name(name))
+
+ def self.normalize_name(name)
+ normalized = name.to_s.strip.gsub(/\s+/, ' ')
+ raise InvalidName, 'Agent name must not be empty' if normalized.empty?
+
+ normalized
+ end
+
+ def initialize(name)
+ @name = self.class.normalize_name(name)
+ super(@name)
+ end
+
+ def command(text = nil)
+ return @command if text.nil?
+
+ @command = text.to_s
+ end
+ end
+
+ # Adds the `agent` definition keyword to the top-level DSL.
+ class DSL
+ def agent(name = nil, &block)
+ return name if name.nil?
+ return unless @conds_met
+
+ definition = AgentDefinition.new(name)
+ definition.dsl = self
+ definition.command(definition.instance_eval(&block)) if block
+ register(definition, schedule: false, duplicate_error: DuplicateDefinition)
+ end
+ end
+end
diff --git a/lib/dslkeywords/file.rb b/lib/dslkeywords/file.rb
index 8e1c772..81f5f29 100644
--- a/lib/dslkeywords/file.rb
+++ b/lib/dslkeywords/file.rb
@@ -1,6 +1,11 @@
+# frozen_string_literal: true
+
require 'digest'
require 'erb'
require 'fileutils'
+require 'open3'
+require 'shellwords'
+require 'tempfile'
require_relative 'resource'
require_relative '../chained'
@@ -97,7 +102,7 @@ module RCM
def set_mode!(stat, file_path = path)
return if @mode.nil?
- current_mode = stat.mode.to_s(8).split('')[-4..-1].join.to_i(8)
+ current_mode = stat.mode.to_s(8).split('')[-4..].join.to_i(8)
return if current_mode == @mode
do? "Changing mode of #{file_path} to #{@mode}" do
@@ -105,6 +110,7 @@ module RCM
end
end
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def set_owner!(stat, file_path = path)
return if @owner.nil? && @group.nil?
@@ -117,6 +123,7 @@ module RCM
FileUtils.chown(@owner, @group, file_path)
end
end
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
end
# Intermediate base for resources that carry file content: regular files
@@ -140,7 +147,29 @@ module RCM
# Manages regular files: write content, ensure/remove individual lines,
# delete. Writes via a temp file so the final rename is atomic.
+ # rubocop:disable Metrics/ClassLength
class File < BaseFile
+ class AgentCommandFailed < StandardError; end
+ class InvalidAgentSpec < StandardError; end
+ class MissingAgentInput < StandardError; end
+
+ attr_reader :agent_name, :prompt_name
+
+ def agent(spec = nil, prompt_name = nil)
+ agent_name = normalize_agent_reference(spec)
+ prompt_name = normalize_agent_reference(prompt_name)
+ agent_name, prompt_name = agent_name.split(/\s+/, 2) if prompt_name.nil? && agent_name&.include?(' ')
+
+ if agent_name.nil? || prompt_name.nil?
+ raise InvalidAgentSpec, 'Expected exactly one agent name and one prompt name'
+ end
+
+ @agent_name = agent_name
+ @prompt_name = prompt_name
+ end
+
+ def agent_processing? = !@agent_name.nil?
+
def line(line) = @ensure_line = line
def evaluate!
@@ -148,6 +177,7 @@ module RCM
return evaluate_ensure_line! unless @ensure_line.nil?
return evaluate_absent! if %i[absent purged].include?(@is)
+ return evaluate_agent_processing! if agent_processing?
create_parent_directory! if @manage_directory
@@ -181,6 +211,28 @@ module RCM
end
end
+ def normalize_agent_reference(name)
+ normalized = name&.to_s&.strip
+ return if normalized.nil? || normalized.empty?
+
+ normalized.gsub(/\s+/, ' ')
+ end
+
+ def evaluate_agent_processing!
+ raise MissingAgentInput, "File #{@file_path} does not exist for agent processing" unless ::File.file?(@file_path)
+
+ if option :dry
+ info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name} - dry run!"
+ return
+ end
+
+ input = ::File.read(@file_path)
+ output = run_agent!(input)
+ create_parent_directory! unless ::File.directory?(::File.dirname(@file_path))
+ write!(output)
+ end
+
+ # rubocop:disable Metrics/MethodLength
def write!(text)
# In dry-run mode skip all filesystem access and just report what would
# happen — the parent directory may not exist yet so we cannot write the
@@ -206,11 +258,52 @@ module RCM
::File.rename(tmp_path, @file_path)
::File.delete(tmp_path) if ::File.file?(tmp_path)
end
+ # rubocop:enable Metrics/MethodLength
+
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
+ def run_agent!(input)
+ agent_definition = dsl.object!(AgentDefinition, @agent_name,
+ error_class: DSL::NoSuchAgentDefinition, kind: 'agent')
+ prompt_definition = dsl.object!(PromptDefinition, @prompt_name,
+ error_class: DSL::NoSuchPromptDefinition, kind: 'prompt')
+
+ Tempfile.create(['rcm-agent-input', '.txt']) do |tmp|
+ tmp.write(input)
+ tmp.flush
+ tmp.close
+
+ command = render_agent_command(agent_definition.command.to_s, prompt_definition.text.to_s, tmp.path)
+ info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name}"
+ stdout, stderr, status = Open3.capture3(command, stdin_data: input)
+ return stdout if status.success?
+
+ message = stderr.to_s.strip
+ message = 'no stderr output' if message.empty?
+ raise AgentCommandFailed,
+ "Agent #{@agent_name} failed for #{@file_path} (exit #{status.exitstatus}): #{message}"
+ end
+ end
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
+
+ def render_agent_command(template, prompt_text, input_path)
+ command = template.dup
+ command.gsub!(/\bINPUT\b/, Shellwords.escape(input_path))
+ command.gsub!(/\bPROMPT\b/, Shellwords.escape(prompt_text))
+ command.gsub!(/\bFILE_PATH\b/, Shellwords.escape(@file_path))
+ command
+ end
end
+ # rubocop:enable Metrics/ClassLength
+ # Adds the `file` resource keyword to the DSL.
class DSL
def file(file_path = nil, &block)
- register_keyword(File, :file, file_path) { |f| f.content(f.instance_eval(&block)) }
+ register_keyword(File, :file, file_path) do |f|
+ next unless block
+
+ result = f.instance_eval(&block)
+ f.content(result) unless f.agent_processing? || result.nil?
+ end
end
end
end
diff --git a/lib/dslkeywords/keyword.rb b/lib/dslkeywords/keyword.rb
index 1cbda9f..59ef5bf 100644
--- a/lib/dslkeywords/keyword.rb
+++ b/lib/dslkeywords/keyword.rb
@@ -1,4 +1,4 @@
-require 'set'
+# frozen_string_literal: true
require_relative '../options'
require_relative '../log'
@@ -6,11 +6,14 @@ require_relative '../log'
module RCM
# The base class of all DSL key words
class Keyword
+ attr_accessor :dsl
attr_reader :id
include Options
include Log
+ def self.id_for(name) = "#{to_s.sub('RCM::', '').downcase}('#{name}')"
+
def initialize(name) = @id = "#{self.class.to_s.sub('RCM::', '').downcase}('#{name}')"
def to_s = @id
diff --git a/lib/dslkeywords/prompt.rb b/lib/dslkeywords/prompt.rb
new file mode 100644
index 0000000..9e599ef
--- /dev/null
+++ b/lib/dslkeywords/prompt.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require_relative 'keyword'
+
+module RCM
+ # Stores a named prompt body for agent-backed file processing.
+ class PromptDefinition < Keyword
+ attr_reader :name
+
+ class InvalidName < StandardError; end
+
+ def self.id_for(name) = super(normalize_name(name))
+
+ def self.normalize_name(name)
+ normalized = name.to_s.strip.gsub(/\s+/, ' ')
+ raise InvalidName, 'Prompt name must not be empty' if normalized.empty?
+
+ normalized
+ end
+
+ def initialize(name)
+ @name = self.class.normalize_name(name)
+ super(@name)
+ end
+
+ def text(value = nil)
+ return @text if value.nil?
+
+ @text = value.to_s
+ end
+ end
+
+ # Adds the `prompt` definition keyword to the top-level DSL.
+ class DSL
+ def prompt(name = nil, &block)
+ return name if name.nil?
+ return unless @conds_met
+
+ definition = PromptDefinition.new(name)
+ definition.dsl = self
+ definition.text(definition.instance_eval(&block)) if block
+ register(definition, schedule: false, duplicate_error: DuplicateDefinition)
+ end
+ end
+end