diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-11 08:54:10 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-11 08:54:10 +0300 |
| commit | d57d57fa6cf99db8447380202b7f091827728ef5 (patch) | |
| tree | 6d317535f5e6d4487ef662330cd78a7a09dc0d14 | |
| parent | 8b0531bec5e9229ca41ab7bf143e319f66ed0a22 (diff) | |
project flow diagram
| -rw-r--r-- | .serena/project.yml | 114 | ||||
| -rw-r--r-- | README.md | 56 | ||||
| -rw-r--r-- | docs/PROJECT_FLOW.md | 133 | ||||
| -rw-r--r-- | lib/dsl.rb | 2 | ||||
| -rw-r--r-- | lib/dslkeywords/agent.rb | 37 | ||||
| -rw-r--r-- | lib/dslkeywords/file.rb | 255 | ||||
| -rw-r--r-- | lib/dslkeywords/file_backup.rb | 12 | ||||
| -rw-r--r-- | lib/dslkeywords/prompt.rb | 39 | ||||
| -rw-r--r-- | test/lib/dslkeywords/agent_test.rb | 733 | ||||
| -rw-r--r-- | test/lib/dslkeywords/file_test.rb | 136 | ||||
| -rw-r--r-- | test/support/mock_agent.rb | 46 |
11 files changed, 1482 insertions, 81 deletions
diff --git a/.serena/project.yml b/.serena/project.yml index 5b72e88..392a540 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,21 +3,26 @@ project_name: "rcm" # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# swift systemverilog terraform toml typescript +# typescript_vts vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. @@ -52,52 +57,19 @@ ignored_paths: [] # Added on 2025-04-18 read_only: false -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # list of mode names to that are always to be included in the set of active modes @@ -108,11 +80,14 @@ fixed_tools: [] # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: # initial prompt for the project. It will always be given to the LLM upon activating the project @@ -128,3 +103,38 @@ symbol_info_budget: # list of regex patterns which, when matched, mark a memory entry as read‑only. # Extends the list from the global configuration, merging the two lists. read_only_memory_patterns: [] + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] @@ -17,6 +17,7 @@ This software has been written by a human by 90%, and only the last 10% were AI - [DSL Reference](#dsl-reference) - [configure / configure_from_scratch](#configure--configure_from_scratch) - [file](#file) + - [agent / prompt / command](#agent--prompt--command) - [touch](#touch) - [symlink](#symlink) - [directory](#directory) @@ -201,6 +202,16 @@ file '/tmp/deep/nested/dir/config.txt' do 'content' end +# File content extended from a command +command spell do + 'aspell list < FILE_PATH' +end + +file '/tmp/post.txt' do + append from command spell + 'Draft text' +end + # Named file with explicit path file create config do path '/etc/myapp.conf' @@ -215,6 +226,51 @@ file '/tmp/obsolete.txt' do end ``` +### agent / prompt / command + +Use `agent` definitions for the final file-processing command, `command` +definitions for reusable prompt-time shell commands, and `prompt` +definitions to compose the text passed to the agent. + +```ruby +agent hexai do + retries 3 + retry_delay 1 + retry_backoff 2 + 'hexai PROMPT' +end + +command spell do + 'aspell list < FILE_PATH' +end + +prompt fix english do + append from command spell + 'Correct spelling and grammar. Use the spell-check output as hints.' +end + +file '/tmp/post.txt' do + agent hexai fix english +end +``` + +Inside a prompt block, `append from command ...` and `prepend from command ...` +insert the raw stdout of the named command after or before the static prompt +text. `FILE_PATH` expands to the current file being processed. + +Agent-backed files are cached under `$XDG_CACHE_HOME/rcm/agents` (or +`~/.cache/rcm/agents` when `XDG_CACHE_HOME` is unset). RCM reruns the +agent only when the file content checksum changes or the selected +agent, prompt, or prompt-command definition changes. + +Agent definitions retry failed invocations by default twice after the +first run, for 3 total attempts. If the agent still fails, RCM skips +that file and continues with the next resource. `retries` sets the +number of extra attempts after the first run, `retry_delay` sets the +initial wait in seconds, and `retry_backoff` multiplies that delay after +each failure. This is useful for transient failures from remote-backed +agents such as network timeouts. + ### touch Create empty files, like the Unix `touch` command. diff --git a/docs/PROJECT_FLOW.md b/docs/PROJECT_FLOW.md new file mode 100644 index 0000000..f8f0146 --- /dev/null +++ b/docs/PROJECT_FLOW.md @@ -0,0 +1,133 @@ +# RCM Project Flow + +This diagram shows how a configuration run moves from the entry point through parsing, registration, dependency resolution, and finally execution. + +## High-Level Flow + +```mermaid +flowchart TD + subgraph Entry["Entry Point"] + A[configure / configure_from_scratch] --> B{reset?} + B -->|yes| C[DSL.reset!] + B -->|no| D[DSL.new] + end + + subgraph Init["Bootstrap"] + D --> E[Options.parse!] + E --> F[Config.load!] + F --> G[DSL instance_eval &block] + end + + subgraph Register["Registration"] + G --> H[file / package / touch / ...] + H --> I{conds_met?} + I -->|yes| J[Keyword.new] + I -->|no| Z1[skip] + J --> K[register obj in @@objs] + K --> L{is Resource?} + L -->|yes| M[add to @scheduled] + L -->|no| Z2[done] + end + + subgraph Eval["Evaluation"] + N[evaluate!] --> O[foreach scheduled] + O --> P[Resource.evaluate!] + P --> Q{already evaluated?} + Q -->|yes| Z3[skip] + Q -->|no| R[loop detection check] + R --> S[resolve dependencies] + S --> T[run action / dry-run?] + T --> U[mark evaluated] + end + + G -.-> N +``` + +## Class & Mixin Relationships + +```mermaid +classDiagram + class Keyword { + +id + +dsl + +id_for(name) + } + + class Resource { + +evaluated + +subclass_names + +find(id) + +evaluate!() + } + + class DSL { + +conds_met + +register(obj) + +object!(klass, name) + +evaluate!() + +register_keyword(...) + } + + class Config { + +load!() + +config(key) + } + + class Options { + +parse!() + +option(key) + } + + class Log { + +info(msg) + +debug(msg) + +warn(msg) + } + + class Chained { + +method_missing() + } + + class DryRun { + +do?(message) + } + + class ResourceDependencies { + +requires(*others) + +requires?(*others) + } + + class DependencyEvaluator { + +evaluate!() + } + + Keyword <|-- Resource + DSL --> Keyword : registers + DSL --> Resource : schedules + + Keyword ..> Log : includes + Keyword ..> Options : includes + DSL ..> Config : includes + DSL ..> Options : includes + DSL ..> Log : includes + DSL ..> Chained : includes + + Resource ..> DryRun : includes + Resource ..> ResourceDependencies : includes + Resource ..> DependencyEvaluator : includes +``` + +## What Happens During a Run + +1. **Entry** — `configure(reset: true)` or `configure(reset: false)` is called. +2. **Bootstrap** — `Options.parse!` reads CLI flags (`--debug`, `--dry`, `--hosts`) and `Config.load!` reads `config.toml`. +3. **DSL block** — The user’s configuration block is executed inside the DSL instance via `instance_eval`. +4. **Keyword creation** — Each keyword (`file`, `given`, `notify`, etc.) instantiates a `Keyword` or `Resource` subclass. Names are normalised via `Keyword.id_for`. +5. **Registration** — The generic `register(obj)` stores the object in the class-level `@@objs` hash keyed by `id`. If the object is a `Resource`, it is also appended to `@scheduled`. +6. **Conditionals** — `given { ... }` sets `@conds_met`. When false, subsequent keyword calls are skipped (no object created). +7. **Evaluation** — After the block finishes, `DSL#evaluate!` iterates `@scheduled`. Each `Resource#evaluate!`: + - Checks for dependency loops. + - Recursively evaluates its `requires` dependencies. + - Executes the concrete action (file write, package install, etc.) unless `--dry` is active. + - Marks itself as evaluated. +8. **Dry-run** — The `DryRun#do?` mixin wraps every side-effecting action. In dry mode it logs and returns without touching the system. @@ -7,6 +7,7 @@ require_relative 'log' require_relative 'chained' require_relative 'dslkeywords/agent' +require_relative 'dslkeywords/command' require_relative 'dslkeywords/prompt' require_relative 'dslkeywords/file' require_relative 'dslkeywords/symlink' @@ -38,6 +39,7 @@ module RCM class DuplicateResource < StandardError; end class DuplicateDefinition < StandardError; end class NoSuchAgentDefinition < StandardError; end + class NoSuchCommandDefinition < StandardError; end class NoSuchPromptDefinition < StandardError; end def initialize(reset) diff --git a/lib/dslkeywords/agent.rb b/lib/dslkeywords/agent.rb index 6632835..3d6874c 100644 --- a/lib/dslkeywords/agent.rb +++ b/lib/dslkeywords/agent.rb @@ -8,6 +8,7 @@ module RCM attr_reader :name class InvalidName < StandardError; end + class InvalidRetrySetting < StandardError; end def self.id_for(name) = super(normalize_name(name)) @@ -20,6 +21,9 @@ module RCM def initialize(name) @name = self.class.normalize_name(name) + @retries = 2 + @retry_delay = 1.0 + @retry_backoff = 2.0 super(@name) end @@ -28,6 +32,39 @@ module RCM @command = text.to_s end + + def retries(value = nil) + return @retries if value.nil? + + @retries = Integer(value) + raise InvalidRetrySetting, 'Retry count must be non-negative' if @retries.negative? + + @retries + rescue ArgumentError, TypeError + raise InvalidRetrySetting, "Invalid retry count: #{value.inspect}" + end + + def retry_delay(value = nil) + return @retry_delay if value.nil? + + @retry_delay = Float(value) + raise InvalidRetrySetting, 'Retry delay must be non-negative' if @retry_delay.negative? + + @retry_delay + rescue ArgumentError, TypeError + raise InvalidRetrySetting, "Invalid retry delay: #{value.inspect}" + end + + def retry_backoff(value = nil) + return @retry_backoff if value.nil? + + @retry_backoff = Float(value) + raise InvalidRetrySetting, 'Retry backoff must be at least 1.0' if @retry_backoff < 1.0 + + @retry_backoff + rescue ArgumentError, TypeError + raise InvalidRetrySetting, "Invalid retry backoff: #{value.inspect}" + end end # Adds the `agent` definition keyword to the top-level DSL. diff --git a/lib/dslkeywords/file.rb b/lib/dslkeywords/file.rb index 1e40691..06bb5ce 100644 --- a/lib/dslkeywords/file.rb +++ b/lib/dslkeywords/file.rb @@ -3,12 +3,14 @@ require 'digest' require 'erb' require 'fileutils' +require 'json' require 'open3' require 'shellwords' require 'tempfile' require_relative 'resource' require_relative '../chained' +require_relative 'command' require_relative 'file_backup' module RCM @@ -131,7 +133,11 @@ module RCM # sourcefile reading. Touch and Directory extend BasicFile directly so # they are not burdened with content/from (ISP). class BaseFile < BasicFile - def from(what) = @from = validate(__method__, what.to_sym, :sourcefile, :template) + def from(what) + return what if what.is_a?(CommandDefinition::Reference) + + @from = validate(__method__, what.to_sym, :sourcefile, :template) + end # Return or set the resource's content. # Getter: resolves ERB templates or reads sourcefile on demand. @@ -149,12 +155,22 @@ module RCM # delete. Writes via a temp file so the final rename is atomic. # rubocop:disable Metrics/ClassLength class File < BaseFile + include CommandComposable + class AgentCommandFailed < StandardError; end + class CommandFailed < StandardError; end + class InvalidComposition < StandardError; end class InvalidAgentSpec < StandardError; end + class InvalidAgentCacheRecord < StandardError; end class MissingAgentInput < StandardError; end attr_reader :agent_name, :prompt_name + def initialize(file_path) + super(file_path) + initialize_command_composition! + end + def agent(spec = nil, prompt_name = nil) agent_name, prompt_name = resolved_agent_spec(spec, prompt_name) @@ -170,6 +186,12 @@ module RCM def line(line) = @ensure_line = line + def content(text = nil) + return render_composed_content(super(), @file_path) if text.nil? + + super + end + def evaluate! return unless super @@ -254,16 +276,23 @@ module RCM raise MissingAgentInput, "File #{@file_path} does not exist for agent processing" unless ::File.file?(@file_path) agent_definition, prompt_definition = agent_configuration! + cache_state = agent_cache_state(agent_definition, prompt_definition) + return skip_fresh_agent_processing! unless cache_state[:stale] - if option :dry - info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name} - dry run!" - return - end + info "Agent cache is stale for #{@file_path}: #{cache_state[:reason]}" + process_stale_agent!(agent_definition, prompt_definition, cache_state) + rescue AgentCommandFailed => e + warn "#{e.message}. Skipping #{@file_path} and continuing" + end + def process_stale_agent!(agent_definition, prompt_definition, cache_state) input = ::File.read(@file_path) output = run_agent!(input, agent_definition, prompt_definition) + return if option :dry + create_parent_directory! unless ::File.directory?(::File.dirname(@file_path)) write!(output) + refresh_agent_cache_record!(cache_state[:record_path], cache_state[:definition_fingerprint]) end # rubocop:disable Metrics/MethodLength @@ -294,25 +323,121 @@ module RCM end # rubocop:enable Metrics/MethodLength - # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def run_agent!(input, agent_definition, prompt_definition) + with_agent_input_file(input) do |input_path| + prompt_text = prompt_definition.render(@file_path) + return dry_run_agent_input(input) if option :dry + + command = render_agent_command(agent_definition.command.to_s, prompt_text, input_path) + execute_agent_command_with_retries(command, input, agent_definition) + end + end + + def execute_agent_command_with_retries(command, input, agent_definition) + attempt = 1 + retry_state = agent_retry_state(agent_definition) + + loop do + stdout, stderr, status = capture_agent_command(command, input, attempt, retry_state[:max_attempts]) + return stdout if status.success? + + retry_state[:delay] = handle_failed_agent_command(status, stderr, attempt, retry_state) + attempt += 1 + end + end + + def agent_retry_state(agent_definition) + { + max_attempts: agent_definition.retries + 1, + delay: agent_definition.retry_delay, + backoff: agent_definition.retry_backoff + } + end + + def handle_failed_agent_command(status, stderr, attempt, retry_state) + failure_message = agent_command_failure_message(status, stderr, attempt, retry_state[:max_attempts]) + retry_agent_command!( + failure_message, + attempt, + retry_state[:max_attempts], + retry_state[:delay], + retry_state[:backoff] + ) + end + + def with_agent_input_file(input) Tempfile.create(['rcm-agent-input', '.txt']) do |tmp| tmp.write(input) tmp.flush tmp.close + yield tmp.path + end + end - 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? + def dry_run_agent_input(input) + info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name} - dry run!" + input + end + + def capture_agent_command(command, input, attempt, max_attempts) + info agent_processing_message(attempt, max_attempts) + Open3.popen3(command) do |stdin, stdout, stderr, wait_thread| + stdin.write(input) + stdin.close + + stdout_buffer, stderr_buffer, stream_threads = start_agent_streams(stdout, stderr) + stream_threads.each(&:join) + + [stdout_buffer, stderr_buffer, wait_thread.value] + end + end + + def retry_agent_command!(failure_message, attempt, max_attempts, retry_delay, retry_backoff) + raise AgentCommandFailed, failure_message if attempt >= max_attempts - 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}" + warn "#{failure_message}. Retrying in #{formatted_retry_delay(retry_delay)}s" + sleep(retry_delay) if retry_delay.positive? + retry_delay * retry_backoff + end + + def stream_agent_io(source, buffer, destination) + loop do + chunk = source.readpartial(4096) + buffer << chunk + destination.write(chunk) + destination.flush + rescue EOFError + break end + + destination.puts unless buffer.empty? || buffer.end_with?("\n") + end + + def start_agent_streams(stdout, stderr) + stdout_buffer = +'' + stderr_buffer = +'' + stream_threads = [ + Thread.new { stream_agent_io(stdout, stdout_buffer, $stdout) }, + Thread.new { stream_agent_io(stderr, stderr_buffer, $stderr) } + ] + [stdout_buffer, stderr_buffer, stream_threads] + end + + def agent_processing_message(attempt, max_attempts) + suffix = max_attempts > 1 ? " (attempt #{attempt}/#{max_attempts})" : '' + "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name}#{suffix}" + end + + def agent_command_failure_message(status, stderr, attempt, max_attempts) + message = stderr.to_s.strip + message = 'no stderr output' if message.empty? + attempt_suffix = max_attempts > 1 ? ", attempt #{attempt}/#{max_attempts}" : '' + "Agent #{@agent_name} failed for #{@file_path} (exit #{status.exitstatus}#{attempt_suffix}): #{message}" + end + + def formatted_retry_delay(retry_delay) + format('%.3g', retry_delay) end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength def agent_configuration! [ @@ -328,6 +453,106 @@ module RCM command.gsub!(/\bFILE_PATH\b/, Shellwords.escape(@file_path)) command end + + def agent_cache_state(agent_definition, prompt_definition) + file_checksum = checksum_for(@file_path) + definition_fingerprint = agent_definition_fingerprint(agent_definition, prompt_definition) + record_path = agent_cache_record_path + stale, reason = stale_agent_cache_record?( + load_agent_cache_record(record_path), + file_checksum, + definition_fingerprint + ) + + { stale:, reason:, record_path:, definition_fingerprint: } + end + + def stale_agent_cache_record?(record, file_checksum, definition_fingerprint) + return [true, 'no track record'] if record.nil? + return [true, 'cache format changed'] if record['version'] != 2 || record['file_checksum'].nil? + return [true, 'file checksum changed'] if record['file_checksum'] != file_checksum + return [true, 'agent or prompt changed'] if record['definition_fingerprint'] != definition_fingerprint + + [false, 'fresh'] + end + + def agent_definition_fingerprint(agent_definition, prompt_definition) + Digest::SHA256.hexdigest( + JSON.generate( + agent_command: agent_definition.command.to_s, + prompt: prompt_definition.fingerprint_source + ) + ) + end + + def refresh_agent_cache_record!(record_path, definition_fingerprint) + persist_agent_cache_record!( + record_path, + agent_cache_record_payload(checksum_for(@file_path), definition_fingerprint) + ) + end + + def agent_cache_record_payload(file_checksum, definition_fingerprint) + { + version: 2, + file_path: expanded_file_path, + agent_name: @agent_name, + prompt_name: @prompt_name, + file_checksum:, + definition_fingerprint:, + recorded_at_ns: current_time_ns + } + end + + def persist_agent_cache_record!(record_path, payload) + cache_dir = ::File.dirname(record_path) + ::FileUtils.mkdir_p(cache_dir) + tmp_path = "#{record_path}.tmp" + ::File.write(tmp_path, JSON.generate(payload)) + ::File.rename(tmp_path, record_path) + rescue SystemCallError => e + warn "Unable to persist agent cache #{record_path}: #{e.message}" + ::File.delete(tmp_path) if defined?(tmp_path) && ::File.file?(tmp_path) + end + + def load_agent_cache_record(record_path) + return unless ::File.file?(record_path) + + JSON.parse(::File.read(record_path)) + rescue JSON::ParserError, SystemCallError => e + warn "Ignoring invalid agent cache #{record_path}: #{e.message}" + nil + end + + def agent_cache_record_path + key = Digest::SHA256.hexdigest( + JSON.generate(file_path: expanded_file_path, agent_name: @agent_name, prompt_name: @prompt_name) + ) + ::File.join(agent_cache_dir, "#{key}.json") + end + + def agent_cache_dir + cache_root = ENV['XDG_CACHE_HOME'] || ::File.expand_path('~/.cache') + ::File.join(cache_root, 'rcm', 'agents') + end + + def expanded_file_path = ::File.expand_path(@file_path) + + def current_time_ns + now = ::Time.now + (now.to_i * 1_000_000_000) + now.nsec + end + + def skip_fresh_agent_processing! + info "Skipping #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name}; agent cache is fresh" + end + + def invalid_command_composition!(position) + raise InvalidComposition, "#{position} expects `from command ...`" + end + + def command_failure_class = CommandFailed + def command_composition_subject = "file #{@file_path}" end # rubocop:enable Metrics/ClassLength diff --git a/lib/dslkeywords/file_backup.rb b/lib/dslkeywords/file_backup.rb index 210804c..1716aa5 100644 --- a/lib/dslkeywords/file_backup.rb +++ b/lib/dslkeywords/file_backup.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'digest' module RCM @@ -5,12 +7,16 @@ module RCM # Included by BasicFile so all file/directory/symlink resources share # the same backup logic. module FileBackup + def checksum_for(file_path) + Digest::SHA256.file(file_path).hexdigest + end + # TODO: Make protected? def backup!(file_path, checksum = nil) return if @without_backup suffix = if ::File.file?(file_path) - checksum.nil? ? Digest::SHA256.file(file_path).hexdigest : checksum + checksum.nil? ? checksum_for(file_path) : checksum else Time.now.strftime('%s-%L') end @@ -18,8 +24,8 @@ module RCM end def different?(file_a, file_b) - checksum_a = Digest::SHA256.file(file_a).hexdigest - checksum_b = Digest::SHA256.file(file_b).hexdigest + checksum_a = checksum_for(file_a) + checksum_b = checksum_for(file_b) [checksum_a != checksum_b, checksum_a, checksum_b] end diff --git a/lib/dslkeywords/prompt.rb b/lib/dslkeywords/prompt.rb index 9e599ef..1b383b9 100644 --- a/lib/dslkeywords/prompt.rb +++ b/lib/dslkeywords/prompt.rb @@ -1,13 +1,20 @@ # frozen_string_literal: true +require_relative '../chained' +require_relative 'command' require_relative 'keyword' module RCM # Stores a named prompt body for agent-backed file processing. class PromptDefinition < Keyword + include Chained + include CommandComposable + attr_reader :name class InvalidName < StandardError; end + class InvalidComposition < StandardError; end + class CommandFailed < StandardError; end def self.id_for(name) = super(normalize_name(name)) @@ -20,6 +27,7 @@ module RCM def initialize(name) @name = self.class.normalize_name(name) + initialize_command_composition! super(@name) end @@ -28,6 +36,37 @@ module RCM @text = value.to_s end + + def from(value = nil) = value + + def render(file_path) = render_composed_content(text.to_s, file_path) + + def fingerprint_source + { + text: text.to_s, + composition: @command_composition.map { |entry| fingerprinted_command(entry) } + } + end + + private + + def fingerprinted_command(entry) + command_definition = dsl.object!( + CommandDefinition, + entry[:name], + error_class: DSL::NoSuchCommandDefinition, + kind: 'command' + ) + + entry.merge(template: command_definition.template.to_s) + end + + def invalid_command_composition!(position) + raise InvalidComposition, "#{position} expects `from command ...`" + end + + def command_failure_class = CommandFailed + def command_composition_subject = "prompt #{@name}" end # Adds the `prompt` definition keyword to the top-level DSL. diff --git a/test/lib/dslkeywords/agent_test.rb b/test/lib/dslkeywords/agent_test.rb index b09a2ec..39d997e 100644 --- a/test/lib/dslkeywords/agent_test.rb +++ b/test/lib/dslkeywords/agent_test.rb @@ -2,6 +2,7 @@ # rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize require 'minitest/autorun' +require 'json' require 'fileutils' require 'rbconfig' require 'shellwords' @@ -15,10 +16,13 @@ class RCMAgentTest < Minitest::Test def setup @dir_path = Dir.mktmpdir('.agent_test.rcmtmp.') @original_argv = ARGV.dup + @original_xdg_cache_home = ENV['XDG_CACHE_HOME'] + ENV['XDG_CACHE_HOME'] = path('cache') end def teardown ARGV.replace(@original_argv) if @original_argv + ENV['XDG_CACHE_HOME'] = @original_xdg_cache_home FileUtils.rm_rf(@dir_path) if @dir_path end @@ -39,6 +43,14 @@ class RCMAgentTest < Minitest::Test [Shellwords.escape(parts.shift), Shellwords.escape(parts.shift), Shellwords.escape(parts.shift), *parts].join(' ') end + def agent_cache_files + Dir.glob(File.join(ENV.fetch('XDG_CACHE_HOME'), 'rcm', 'agents', '*.json')) + end + + def counter_value(counter_path) + File.file?(counter_path) ? File.read(counter_path).to_i : 0 + end + def test_duplicate_agent_definition assert_raises(RCM::DSL::DuplicateDefinition) do configure_from_scratch do @@ -67,6 +79,22 @@ class RCMAgentTest < Minitest::Test end end + def test_duplicate_command_definition + template = mock_agent_command(:join_args, 'spell') + + assert_raises(RCM::DSL::DuplicateDefinition) do + configure_from_scratch do + command spell do + template + end + + command spell do + template + end + end + end + end + def test_agent_processes_file_using_stdin_and_names_with_spaces file_path = path('process.txt') command = mock_agent_command(:upcase_prompt, 'PROMPT') @@ -111,6 +139,89 @@ class RCMAgentTest < Minitest::Test assert_equal 'HELLO WORLD|Fix grammar', File.read(file_path) end + def test_agent_processes_file_using_prompt_appended_command_output + file_path = path('process-appended-command.txt') + agent_command = mock_agent_command(:upcase_prompt, 'PROMPT') + spell_command = mock_agent_command(:join_args, 'spell', 'FILE_PATH') + File.write(file_path, 'hello world') + + configure_from_scratch do + agent mock do + agent_command + end + + command spell output do + spell_command + end + + prompt fix english do + append from command spell output + 'Fix grammar' + end + + file file_path do + agent mock fix english + end + end + + assert_equal "HELLO WORLD|Fix grammar\nspell|#{file_path}", File.read(file_path) + end + + def test_agent_processes_file_using_prompt_prepended_command_output + file_path = path('process-prepended-command.txt') + agent_command = mock_agent_command(:upcase_prompt, 'PROMPT') + spell_command = mock_agent_command(:join_args, 'spell', 'FILE_PATH') + File.write(file_path, 'hello world') + + configure_from_scratch do + agent mock do + agent_command + end + + command spell output do + spell_command + end + + prompt fix english do + prepend from command spell output + 'Fix grammar' + end + + file file_path do + agent mock fix english + end + end + + assert_equal "HELLO WORLD|spell|#{file_path}\nFix grammar", File.read(file_path) + end + + def test_agent_streams_output_while_capturing_final_content + file_path = path('streamed-output.txt') + command = mock_agent_command(:stream_chunks, 'he', 'llo', '--stderr=oops') + File.write(file_path, 'ignored') + + stdout, stderr = capture_io do + configure_from_scratch do + agent streamer do + retries 0 + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent streamer, 'no op' + end + end + end + + assert_includes stdout, 'hello' + assert_includes stderr, 'oops' + assert_equal 'hello', File.read(file_path) + end + def test_agent_can_use_input_placeholder file_path = path('input.txt') command = mock_agent_command(:reverse_input, 'INPUT') @@ -233,6 +344,398 @@ class RCMAgentTest < Minitest::Test assert_equal 1, Dir.glob(File.join(backup_dir, 'backup.txt.*')).count end + def test_agent_processing_creates_track_record_and_skips_when_checksum_is_fresh + file_path = path('fresh.txt') + counter_path = path('fresh-counter.txt') + command = mock_agent_command(:count_pass_through, counter_path) + File.write(file_path, 'same content') + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + assert_equal 1, counter_value(counter_path) + assert_equal 1, agent_cache_files.count + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + assert_equal 1, counter_value(counter_path) + assert_equal 1, agent_cache_files.count + record = JSON.parse(File.read(agent_cache_files.first)) + assert_equal Digest::SHA256.hexdigest('same content'), record.fetch('file_checksum') + end + + def test_agent_processing_updates_track_record_immediately_after_file_change + file_path = path('fresh-after-change.txt') + counter_path = path('fresh-after-change-counter.txt') + command = mock_agent_command(:count_upcase, counter_path) + File.write(file_path, 'same content') + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + assert_equal 'SAME CONTENT', File.read(file_path) + assert_equal 1, counter_value(counter_path) + record = JSON.parse(File.read(agent_cache_files.first)) + assert_equal Digest::SHA256.hexdigest('SAME CONTENT'), record.fetch('file_checksum') + end + + def test_agent_processing_reruns_when_input_checksum_changes + file_path = path('changed.txt') + counter_path = path('changed-counter.txt') + command = mock_agent_command(:count_pass_through, counter_path) + File.write(file_path, 'same content') + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + File.write(file_path, 'changed content') + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + assert_equal 2, counter_value(counter_path) + end + + def test_agent_processing_reruns_when_agent_definition_changes + file_path = path('agent-definition-change.txt') + counter_path = path('agent-definition-change-counter.txt') + command = mock_agent_command(:count_pass_through, counter_path) + File.write(file_path, 'same content') + + configure_from_scratch do + agent 'count runs' do + "#{command} # one" + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + configure_from_scratch do + agent 'count runs' do + "#{command} # two" + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + assert_equal 2, counter_value(counter_path) + end + + def test_agent_processing_reruns_when_prompt_definition_changes + file_path = path('prompt-definition-change.txt') + counter_path = path('prompt-definition-change-counter.txt') + command = mock_agent_command(:count_pass_through, counter_path) + File.write(file_path, 'same content') + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + 'changed prompt' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + assert_equal 2, counter_value(counter_path) + end + + def test_agent_processing_reruns_when_prompt_command_definition_changes + file_path = path('prompt-command-definition-change.txt') + counter_path = path('prompt-command-definition-change-counter.txt') + agent_command = mock_agent_command(:count_pass_through, counter_path) + spell_command = mock_agent_command(:join_args, 'spell', 'FILE_PATH') + File.write(file_path, 'same content') + + configure_from_scratch do + agent 'count runs' do + agent_command + end + + command spell do + "#{spell_command} # one" + end + + prompt 'fix english' do + append from command spell + 'Fix grammar' + end + + file file_path do + agent 'count runs', 'fix english' + end + end + + configure_from_scratch do + agent 'count runs' do + agent_command + end + + command spell do + "#{spell_command} # two" + end + + prompt 'fix english' do + append from command spell + 'Fix grammar' + end + + file file_path do + agent 'count runs', 'fix english' + end + end + + assert_equal 2, counter_value(counter_path) + end + + def test_agent_processing_uses_separate_track_records_for_same_file_and_different_prompts + file_path = path('multiple-prompts.txt') + counter_path = path('multiple-prompts-counter.txt') + command = mock_agent_command(:count_pass_through, counter_path) + File.write(file_path, 'same content') + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt one do + '' + end + + file file_path do + agent 'count runs', 'one' + end + end + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt two do + '' + end + + file file_path do + agent 'count runs', 'two' + end + end + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt one do + '' + end + + file file_path do + agent 'count runs', 'one' + end + end + + assert_equal 2, counter_value(counter_path) + assert_equal 2, agent_cache_files.count + end + + def test_failed_agent_run_does_not_refresh_track_record + file_path = path('failure-does-not-refresh.txt') + counter_path = path('failure-does-not-refresh-counter.txt') + marker_path = path('failure-marker.txt') + command = mock_agent_command(:count_or_fail, counter_path, marker_path, 'boom', '7') + File.write(file_path, 'same content') + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + File.write(file_path, 'changed content') + File.write(marker_path, 'boom') + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + File.delete(marker_path) + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + assert_equal 2, counter_value(counter_path) + end + + def test_corrupt_agent_track_record_is_treated_as_stale + file_path = path('corrupt-record.txt') + counter_path = path('corrupt-record-counter.txt') + command = mock_agent_command(:count_pass_through, counter_path) + File.write(file_path, 'same content') + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + File.write(agent_cache_files.first, '{') + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + assert_equal 2, counter_value(counter_path) + end + def test_unknown_agent_raises file_path = path('unknown-agent.txt') File.write(file_path, 'hello') @@ -268,6 +771,29 @@ class RCMAgentTest < Minitest::Test end end + def test_unknown_command_raises + file_path = path('unknown-command.txt') + agent_command = mock_agent_command(:pass_through) + File.write(file_path, 'hello') + + assert_raises(RCM::DSL::NoSuchCommandDefinition) do + configure_from_scratch do + agent mock do + agent_command + end + + prompt fix english do + append from command spell output + 'Fix grammar' + end + + file file_path do + agent mock fix english + end + end + end + end + def test_missing_agent_input_raises file_path = path('missing.txt') command = mock_agent_command(:pass_through) @@ -313,6 +839,60 @@ class RCMAgentTest < Minitest::Test assert_equal 'keep me', File.read(file_path) end + def test_dry_run_stale_agent_does_not_create_track_record + file_path = path('dry-run-no-cache.txt') + counter_path = path('dry-run-no-cache-counter.txt') + command = mock_agent_command(:count_pass_through, counter_path) + File.write(file_path, 'keep me') + ARGV.replace(['--dry']) + + configure_from_scratch do + agent 'count runs' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'count runs', 'no op' + end + end + + assert_equal 0, counter_value(counter_path) + assert_empty agent_cache_files + end + + def test_dry_run_does_not_execute_prompt_command + file_path = path('dry-run-command.txt') + agent_command = mock_agent_command(:fail, 'agent boom', '7') + prompt_command = mock_agent_command(:fail, 'prompt boom', '8') + File.write(file_path, 'keep me') + ARGV.replace(['--dry']) + + configure_from_scratch do + agent 'should not run' do + agent_command + end + + command spell do + prompt_command + end + + prompt fix english do + append from command spell + 'Fix grammar' + end + + file file_path do + agent 'should not run', 'fix english' + end + end + + assert_equal 'keep me', File.read(file_path) + end + def test_dry_run_unknown_agent_raises file_path = path('dry-run-unknown-agent.txt') File.write(file_path, 'keep me') @@ -350,23 +930,160 @@ class RCMAgentTest < Minitest::Test end end - def test_non_zero_exit_raises + def test_non_zero_exit_skips_file_and_continues_after_retries_are_exhausted file_path = path('broken.txt') - command = mock_agent_command(:fail, 'boom', '7') + next_file_path = path('still-runs.txt') + counter_path = path('broken-counter.txt') + command = mock_agent_command(:fail_then_pass, counter_path, '5', 'boom', '7') + File.write(file_path, 'hello') + + configure_from_scratch do + agent 'broken agent' do + retry_delay 0 + retry_backoff 1 + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'broken agent', 'no op' + end + + file next_file_path do + 'still runs' + end + end + + assert_equal 'hello', File.read(file_path) + assert_equal 'still runs', File.read(next_file_path) + assert_equal 3, counter_value(counter_path) + assert_empty agent_cache_files + end + + def test_agent_retries_failed_command_and_eventually_succeeds + file_path = path('retry-success.txt') + counter_path = path('retry-success-counter.txt') + command = mock_agent_command(:fail_then_pass, counter_path, '1', 'boom', '7') + File.write(file_path, 'hello') + + configure_from_scratch do + agent 'flaky agent' do + retries 1 + retry_delay 0 + retry_backoff 1 + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'flaky agent', 'no op' + end + end + + assert_equal 'hello', File.read(file_path) + assert_equal 2, counter_value(counter_path) + end + + def test_agent_skips_file_after_retries_are_exhausted + file_path = path('retry-failure.txt') + counter_path = path('retry-failure-counter.txt') + command = mock_agent_command(:fail_then_pass, counter_path, '5', 'boom', '7') File.write(file_path, 'hello') - error = assert_raises(RCM::File::AgentCommandFailed) do + configure_from_scratch do + agent 'flaky agent' do + retries 2 + retry_delay 0 + retry_backoff 1 + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'flaky agent', 'no op' + end + end + + assert_equal 'hello', File.read(file_path) + assert_equal 3, counter_value(counter_path) + assert_empty agent_cache_files + end + + def test_invalid_agent_retry_settings_raise + assert_raises(RCM::AgentDefinition::InvalidRetrySetting) do configure_from_scratch do - agent 'broken agent' do - command + agent 'broken retries' do + retries(-1) + 'ruby -e "print STDIN.read"' end + end + end - prompt 'no op' do - '' + assert_raises(RCM::AgentDefinition::InvalidRetrySetting) do + configure_from_scratch do + agent 'broken backoff' do + retry_backoff 0.5 + 'ruby -e "print STDIN.read"' + end + end + end + end + + def test_agent_processing_writes_via_temporary_file + file_path = path('temporary-write.txt') + command = mock_agent_command(:upcase) + File.write(file_path, 'hello') + + configure_from_scratch do + agent 'make loud' do + command + end + + prompt 'no op' do + '' + end + + file file_path do + agent 'make loud', 'no op' + end + end + + assert_equal 'HELLO', File.read(file_path) + refute File.exist?("#{file_path}.rcmtmp") + end + + def test_non_zero_prompt_command_exit_raises + file_path = path('broken-prompt-command.txt') + agent_command = mock_agent_command(:pass_through) + prompt_command = mock_agent_command(:fail, 'boom', '7') + File.write(file_path, 'hello') + + error = assert_raises(RCM::PromptDefinition::CommandFailed) do + configure_from_scratch do + agent mock do + agent_command + end + + command spell do + prompt_command + end + + prompt fix english do + append from command spell + 'Fix grammar' end file file_path do - agent 'broken agent', 'no op' + agent mock fix english end end end diff --git a/test/lib/dslkeywords/file_test.rb b/test/lib/dslkeywords/file_test.rb index a290627..124a612 100644 --- a/test/lib/dslkeywords/file_test.rb +++ b/test/lib/dslkeywords/file_test.rb @@ -1,11 +1,17 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize require 'minitest/autorun' require 'fileutils' +require 'rbconfig' +require 'shellwords' require_relative '../../../lib/dsl' class RCMFileTest < Minitest::Test - FILE_PATH = './.file_test.rcmtmp'.freeze - DIR_PATH = './.dir_test.rcmtmp'.freeze + FILE_PATH = './.file_test.rcmtmp' + DIR_PATH = './.dir_test.rcmtmp' + MOCK_COMMAND = File.expand_path('../../support/mock_agent.rb', __dir__).freeze Minitest.after_run do File.unlink(FILE_PATH) if File.file?(FILE_PATH) @@ -14,10 +20,28 @@ class RCMFileTest < Minitest::Test # Clean up shared temp file between tests to prevent order-dependent failures def setup + @original_argv = ARGV.dup File.unlink(FILE_PATH) if File.file?(FILE_PATH) FileUtils.rm_r(DIR_PATH) if File.directory?(DIR_PATH) end + def teardown + ARGV.replace(@original_argv) if @original_argv + end + + def mock_command(mode, *args) + parts = [RbConfig.ruby, MOCK_COMMAND, mode.to_s] + args.each do |arg| + parts << if %w[INPUT PROMPT FILE_PATH].include?(arg) + arg + else + Shellwords.escape(arg.to_s) + end + end + + [Shellwords.escape(parts.shift), Shellwords.escape(parts.shift), Shellwords.escape(parts.shift), *parts].join(' ') + end + def test_create_file_from_string text = 'Hello World!' configure_from_scratch do @@ -104,6 +128,58 @@ class RCMFileTest < Minitest::Test assert_equal 'One plus two is 3!', File.read(FILE_PATH) end + def test_file_appends_command_output + command_template = mock_command(:join_args, 'suffix', 'FILE_PATH') + + configure_from_scratch do + command suffix do + command_template + end + + file FILE_PATH do + append from command suffix + 'Hello World!' + end + end + + assert_equal "Hello World!\nsuffix|#{FILE_PATH}", File.read(FILE_PATH) + end + + def test_file_prepends_command_output + command_template = mock_command(:join_args, 'prefix', 'FILE_PATH') + + configure_from_scratch do + command prefix do + command_template + end + + file FILE_PATH do + prepend from command prefix + 'Hello World!' + end + end + + assert_equal "prefix|#{FILE_PATH}\nHello World!", File.read(FILE_PATH) + end + + def test_file_appends_command_output_to_template_content + command_template = mock_command(:join_args, 'suffix', 'FILE_PATH') + + configure_from_scratch do + command suffix do + command_template + end + + file FILE_PATH do + append from command suffix + from template + 'One plus two is <%= 1 + 2 %>!' + end + end + + assert_equal "One plus two is 3!\nsuffix|#{FILE_PATH}", File.read(FILE_PATH) + end + def test_line File.write(FILE_PATH, "Hey there\n") configure_from_scratch { file(FILE_PATH) { line 'Whats up?' } } @@ -146,7 +222,8 @@ class RCMFileTest < Minitest::Test def test_backup file_path = "#{DIR_PATH}/foo/backup-me.txt" original_content = 'original_content' - backup_path = "#{DIR_PATH}/foo/.rcmbackup/backup-me.txt.d4c3af73588ce06c32ed04d1b79801286109ea265712a2bd3fdc3ed01c82bb86" + backup_path = + "#{DIR_PATH}/foo/.rcmbackup/backup-me.txt.d4c3af73588ce06c32ed04d1b79801286109ea265712a2bd3fdc3ed01c82bb86" configure_from_scratch do file original do @@ -168,4 +245,57 @@ class RCMFileTest < Minitest::Test assert File.file?(file_path) assert_equal :new_content.to_s, File.read(file_path) end + + def test_unknown_command_raises_for_file_content + error = assert_raises(RCM::DSL::NoSuchCommandDefinition) do + configure_from_scratch do + file FILE_PATH do + append from command suffix + 'Hello World!' + end + end + end + + assert_match("No such command 'suffix'", error.message) + end + + def test_non_zero_command_exit_raises_for_file_content + command_template = mock_command(:fail, 'boom', '7') + + error = assert_raises(RCM::File::CommandFailed) do + configure_from_scratch do + command explode do + command_template + end + + file FILE_PATH do + append from command explode + 'Hello World!' + end + end + end + + assert_match('exit 7', error.message) + assert_match('boom', error.message) + end + + def test_dry_run_does_not_execute_file_command + command_template = mock_command(:fail, 'boom', '7') + ARGV.replace(['--dry']) + + configure_from_scratch do + command explode do + command_template + end + + file FILE_PATH do + append from command explode + 'Hello World!' + end + end + + refute File.exist?(FILE_PATH) + end end + +# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize diff --git a/test/support/mock_agent.rb b/test/support/mock_agent.rb index b86de4f..9c21345 100644 --- a/test/support/mock_agent.rb +++ b/test/support/mock_agent.rb @@ -13,6 +13,52 @@ when 'reverse_input' when 'basename' file_path = ARGV.fetch(0) print File.basename(file_path) +when 'join_args' + print ARGV.join('|') +when 'count_pass_through' + counter_path = ARGV.fetch(0) + count = File.file?(counter_path) ? File.read(counter_path).to_i : 0 + File.write(counter_path, count + 1) + print stdin +when 'count_upcase' + counter_path = ARGV.fetch(0) + count = File.file?(counter_path) ? File.read(counter_path).to_i : 0 + File.write(counter_path, count + 1) + print stdin.upcase +when 'fail_then_pass' + counter_path = ARGV.fetch(0) + failures_before_success = Integer(ARGV.fetch(1, '1')) + message = ARGV.fetch(2, 'boom') + exit_code = Integer(ARGV.fetch(3, '7')) + count = File.file?(counter_path) ? File.read(counter_path).to_i : 0 + count += 1 + File.write(counter_path, count) + if count <= failures_before_success + warn message + exit exit_code + end + print stdin +when 'stream_chunks' + stderr_text = ARGV.pop.sub('--stderr=', '') if ARGV.last&.start_with?('--stderr=') + ARGV.each do |chunk| + $stdout.write(chunk) + $stdout.flush + sleep 0.01 + end + if stderr_text + $stderr.write(stderr_text) + $stderr.flush + end +when 'count_or_fail' + counter_path = ARGV.fetch(0) + marker_path = ARGV.fetch(1) + if File.exist?(marker_path) + warn ARGV.fetch(2, 'boom') + exit Integer(ARGV.fetch(3, '7')) + end + count = File.file?(counter_path) ? File.read(counter_path).to_i : 0 + File.write(counter_path, count + 1) + print stdin when 'pass_through' print stdin when 'upcase' |
