diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/dsl.rb | 30 | ||||
| -rw-r--r-- | lib/dslkeywords/agent.rb | 45 | ||||
| -rw-r--r-- | lib/dslkeywords/file.rb | 97 | ||||
| -rw-r--r-- | lib/dslkeywords/keyword.rb | 5 | ||||
| -rw-r--r-- | lib/dslkeywords/prompt.rb | 45 |
5 files changed, 214 insertions, 8 deletions
@@ -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 |
