BUCKYVERSE

Elixir Migration Patterns

This document provides concrete examples and patterns for migrating Ruby code to Elixir, focusing on key architectural patterns that will be used throughout the replatforming effort.

Table of Contents

  1. Command Execution Pattern
  2. Error Handling with {:ok, result} | {:error, reason}
  3. Configuration Management with Application
  4. Process Supervision Strategies
  5. Concurrency Patterns for Format Generation

1. Command Execution Pattern

Current Ruby Implementation

In our Ruby codebase, we have two main command execution patterns:

  1. System::CommandExecutor - A low-level command execution utility
  2. BookProcessing::Utilities::CommandRunner - A higher-level wrapper with domain-specific functionality

Example Ruby Implementation:

# Low-level command execution
module System
  class CommandExecutor
    def self.execute(command, options = {})
      stdout, stderr, status = Open3.capture3(command)
      
      if status.success?
        { success: true, stdout: stdout, stderr: stderr, exit_code: status.exitstatus }
      else
        { success: false, stdout: stdout, stderr: stderr, exit_code: status.exitstatus }
      end
    end
  end
end

# Domain-specific command runner
module BookProcessing
  module Utilities
    class CommandRunner
      def initialize(logger)
        @logger = logger
      end
      
      def run_latex_command(command, working_dir)
        @logger.info("Running LaTeX command: #{command}")
        
        Dir.chdir(working_dir) do
          result = System::CommandExecutor.execute(command)
          
          if result[:success]
            @logger.info("Command completed successfully")
            return result
          else
            @logger.error("Command failed with exit code #{result[:exit_code]}")
            @logger.error("Error output: #{result[:stderr]}")
            raise CommandExecutionError.new(result[:stderr], result[:exit_code])
          end
        end
      end
    end
  end
end

Elixir Implementation Pattern

In Elixir, we'll implement command execution using a combination of System.cmd/3 for simple commands and Port for more complex scenarios requiring streaming or interactive communication.

Basic Command Execution Module

defmodule Codexis.System.CommandExecutor do
  @moduledoc """
  Low-level command execution utility.
  
  Provides functions for executing system commands and handling their results.
  """
  
  @doc """
  Executes a system command and returns a structured result.
  
  ## Parameters
  
  - `command`: String command to execute
  - `args`: List of command arguments
  - `opts`: Keyword list of options (see `System.cmd/3` for available options)
  
  ## Returns
  
  - `{:ok, result}` where result is a map containing :stdout, :stderr, and :exit_code
  - `{:error, reason}` if the command fails to execute
  
  ## Examples
  
      iex> Codexis.System.CommandExecutor.execute("echo", ["hello"])
      {:ok, %{stdout: "hello\\n", stderr: "", exit_code: 0}}
      
      iex> Codexis.System.CommandExecutor.execute("invalid_command", [])
      {:error, :command_not_found}
  """
  def execute(command, args \\ [], opts \\ []) do
    try do
      # Merge default options with provided options
      merged_opts = Keyword.merge([stderr_to_stdout: false], opts)
      
      # Execute command
      {stdout, exit_code} = System.cmd(command, args, merged_opts)
      
      # Extract stderr if captured separately
      stderr = if opts[:stderr_to_stdout], do: "", else: get_stderr(opts)
      
      if exit_code == 0 do
        {:ok, %{stdout: stdout, stderr: stderr, exit_code: exit_code}}
      else
        {:error, %{reason: :non_zero_exit, stdout: stdout, stderr: stderr, exit_code: exit_code}}
      end
    rescue
      e in ErlangError ->
        case e do
          %ErlangError{original: :enoent} ->
            {:error, :command_not_found}
          _ ->
            {:error, %{reason: :execution_failed, message: Exception.message(e)}}
        end
    end
  end
  
  @doc """
  Executes a command in a specific working directory.
  
  ## Parameters
  
  - `command`: String command to execute
  - `args`: List of command arguments
  - `working_dir`: Directory to execute the command in
  - `opts`: Additional options for `execute/3`
  
  ## Returns
  
  Same as `execute/3`
  """
  def execute_in_dir(command, args \\ [], working_dir, opts \\ []) do
    # Add cd option to change directory
    dir_opts = Keyword.put(opts, :cd, working_dir)
    execute(command, args, dir_opts)
  end
  
  # Helper to extract stderr from options
  defp get_stderr(opts) do
    case Keyword.get(opts, :stderr_to_stdout) do
      true -> ""
      _ -> 
        case Keyword.get(opts, :stderr_file) do
          nil -> ""
          file -> File.read!(file)
        end
    end
  end
end

Domain-Specific Command Runner

defmodule Codexis.BookProcessing.CommandRunner do
  @moduledoc """
  Domain-specific command runner for book processing operations.
  
  Provides higher-level functions for executing commands related to book processing,
  with appropriate logging and error handling.
  """
  
  require Logger
  alias Codexis.System.CommandExecutor
  
  @doc """
  Runs a LaTeX command in the specified working directory.
  
  ## Parameters
  
  - `command`: LaTeX command to run
  - `args`: Command arguments
  - `working_dir`: Directory containing LaTeX source files
  - `opts`: Additional options
  
  ## Returns
  
  - `{:ok, result}` on success
  - `{:error, reason}` on failure
  
  ## Examples
  
      iex> Codexis.BookProcessing.CommandRunner.run_latex_command("pdflatex", ["book.tex"], "/path/to/book")
      {:ok, %{stdout: "...", stderr: "", exit_code: 0}}
  """
  def run_latex_command(command, args, working_dir, opts \\ []) do
    Logger.info("Running LaTeX command: #{command} #{Enum.join(args, " ")}")
    
    case CommandExecutor.execute_in_dir(command, args, working_dir, opts) do
      {:ok, result} ->
        Logger.info("LaTeX command completed successfully")
        {:ok, result}
        
      {:error, %{reason: :non_zero_exit} = error} ->
        Logger.error("LaTeX command failed with exit code #{error.exit_code}")
        Logger.error("Error output: #{error.stderr}")
        {:error, {:latex_error, error}}
        
      {:error, reason} ->
        Logger.error("Failed to execute LaTeX command: #{inspect(reason)}")
        {:error, {:command_execution_failed, reason}}
    end
  end
  
  @doc """
  Runs a Calibre command for ebook conversion.
  
  ## Parameters
  
  - `command`: Calibre command (e.g., "ebook-convert")
  - `args`: Command arguments
  - `working_dir`: Working directory
  - `opts`: Additional options
  
  ## Returns
  
  - `{:ok, result}` on success
  - `{:error, reason}` on failure
  """
  def run_calibre_command(command, args, working_dir, opts \\ []) do
    Logger.info("Running Calibre command: #{command} #{Enum.join(args, " ")}")
    
    case CommandExecutor.execute_in_dir(command, args, working_dir, opts) do
      {:ok, result} ->
        Logger.info("Calibre command completed successfully")
        {:ok, result}
        
      {:error, %{reason: :non_zero_exit} = error} ->
        Logger.error("Calibre command failed with exit code #{error.exit_code}")
        Logger.error("Error output: #{error.stderr}")
        {:error, {:calibre_error, error}}
        
      {:error, reason} ->
        Logger.error("Failed to execute Calibre command: #{inspect(reason)}")
        {:error, {:command_execution_failed, reason}}
    end
  end
end

Advanced Command Execution with Port

For more complex scenarios, such as streaming output or interactive commands, we can use Elixir's Port module:

defmodule Codexis.System.StreamingCommandExecutor do
  @moduledoc """
  Provides streaming command execution capabilities for long-running processes.
  """
  
  require Logger
  
  @doc """
  Executes a command and streams its output to a callback function.
  
  ## Parameters
  
  - `command`: Command to execute
  - `args`: Command arguments
  - `callback`: Function to call with each line of output
  - `opts`: Additional options
  
  ## Returns
  
  - `{:ok, exit_code}` on successful completion
  - `{:error, reason}` on failure
  """
  def stream_command(command, args, callback, opts \\ []) do
    # Create a port to execute the command
    port = Port.open({:spawn_executable, find_executable(command)}, [
      {:args, args},
      :binary,
      :exit_status,
      :use_stdio,
      :stderr_to_stdout
    ] ++ port_options(opts))
    
    # Process the command output
    process_output(port, "", callback, opts)
  end
  
  # Process output from the port
  defp process_output(port, buffer, callback, opts) do
    receive do
      {^port, {:data, data}} ->
        # Process received data
        {new_buffer, lines} = process_data(buffer <> data)
        
        # Call callback for each complete line
        Enum.each(lines, &callback.(&1))
        
        # Continue processing
        process_output(port, new_buffer, callback, opts)
        
      {^port, {:exit_status, status}} ->
        # Command has completed
        if buffer != "" do
          callback.(buffer)
        end
        
        if status == 0 do
          {:ok, status}
        else
          {:error, {:non_zero_exit, status}}
        end
        
      other ->
        Logger.warn("Unexpected message received by port: #{inspect(other)}")
        process_output(port, buffer, callback, opts)
    end
  end
  
  # Process data into complete lines
  defp process_data(data) do
    case String.split(data, "\n", parts: :infinity) do
      [buffer] ->
        {buffer, []}
      parts ->
        buffer = List.last(parts)
        lines = Enum.take(parts, length(parts) - 1)
        {buffer, lines}
    end
  end
  
  # Find the executable path
  defp find_executable(command) do
    case System.find_executable(command) do
      nil -> raise "Command not found: #{command}"
      path -> path
    end
  end
  
  # Extract port-specific options
  defp port_options(opts) do
    Keyword.take(opts, [:cd, :env])
  end
end

Usage Examples

Basic Command Execution

# Simple command execution
case Codexis.System.CommandExecutor.execute("pdflatex", ["book.tex"]) do
  {:ok, result} ->
    IO.puts("Command succeeded with output: #{result.stdout}")
    
  {:error, %{reason: :non_zero_exit} = error} ->
    IO.puts("Command failed with exit code #{error.exit_code}")
    IO.puts("Error: #{error.stderr}")
    
  {:error, reason} ->
    IO.puts("Failed to execute command: #{inspect(reason)}")
end

Domain-Specific Command Execution

# Running a LaTeX command
case Codexis.BookProcessing.CommandRunner.run_latex_command("pdflatex", ["book.tex"], "/path/to/book") do
  {:ok, result} ->
    IO.puts("LaTeX compilation successful")
    
  {:error, {:latex_error, error}} ->
    IO.puts("LaTeX compilation failed: #{error.stderr}")
    
  {:error, reason} ->
    IO.puts("Command execution failed: #{inspect(reason)}")
end

Streaming Command Execution

# Stream output from a long-running command
callback = fn line ->
  IO.puts(">> #{line}")
  
  # You could also parse the output for progress information
  if String.contains?(line, "Processing page") do
    # Update progress indicator
  end
end

case Codexis.System.StreamingCommandExecutor.stream_command("pdflatex", ["book.tex"], callback) do
  {:ok, _} ->
    IO.puts("Command completed successfully")
    
  {:error, reason} ->
    IO.puts("Command failed: #{inspect(reason)}")
end

Key Advantages of the Elixir Pattern

  1. Explicit Error Handling: The {:ok, result} | {:error, reason} pattern makes error handling explicit and encourages proper handling of all cases.

  2. Structured Returns: Results are returned as structured maps rather than relying on multiple return values.

  3. Pattern Matching: Elixir's pattern matching makes it easy to handle different error cases elegantly.

  4. Concurrency Support: The Port-based implementation supports concurrent command execution and can be supervised within an OTP application.

  5. Streaming Capability: The streaming executor allows processing output incrementally, which is valuable for long-running commands.

  6. Testability: The functional design makes it easier to test command execution by mocking the low-level functions.

Migration Path

When migrating command execution code from Ruby to Elixir:

  1. Identify Command Patterns: Catalog all command execution patterns in the Ruby codebase.

  2. Map to Elixir Equivalents:

    • Simple commands → CommandExecutor.execute/3
    • Directory-specific commands → CommandExecutor.execute_in_dir/4
    • Long-running commands → StreamingCommandExecutor.stream_command/4
  3. Convert Error Handling:

    • Ruby exceptions → Elixir {:error, reason} tuples
    • Success returns → Elixir {:ok, result} tuples
  4. Update Calling Code:

    • Use pattern matching to handle command results
    • Implement proper error propagation
  5. Add Supervision:

    • For critical command execution, consider adding supervision

Testing Strategies

defmodule Codexis.System.CommandExecutorTest do
  use ExUnit.Case
  
  alias Codexis.System.CommandExecutor
  
  describe "execute/3" do
    test "successfully executes a command" do
      {:ok, result} = CommandExecutor.execute("echo", ["hello"])
      assert result.stdout == "hello\n"
      assert result.exit_code == 0
    end
    
    test "handles non-existent commands" do
      assert {:error, :command_not_found} = CommandExecutor.execute("non_existent_command", [])
    end
    
    test "handles commands that return non-zero exit codes" do
      {:error, error} = CommandExecutor.execute("sh", ["-c", "exit 1"])
      assert error.exit_code == 1
      assert error.reason == :non_zero_exit
    end
  end
end

2. Error Handling with {:ok, result} | {:error, reason}

Coming soon

3. Configuration Management with Application

Coming soon

4. Process Supervision Strategies

Coming soon

5. Concurrency Patterns for Format Generation

Coming soon