Ruby to Elixir Migration Patterns
This document outlines the patterns and strategies for migrating our Ruby-based ebook conversion system to Elixir. It provides concrete examples of how Ruby patterns in our codebase will map to Elixir patterns.
Table of Contents
- Command Execution Pattern
- Error Handling Pattern
- Configuration Management
- State Management
- Process Supervision
- Concurrency Patterns
- File System Operations
- Dependency Injection
- Testing Strategies
- Logging Approaches
1. Command Execution Pattern
Ruby Implementation
In our Ruby codebase, we use the CommandExecutor and CommandRunner classes to execute system commands:
# System::CommandExecutor
def run(command, error_msg = nil, return_output: false, timeout: nil, env: nil,
working_dir: nil, raise_on_error: true)
# Validate command
# Execute command with Open3.capture3
# Handle errors and return results
end
# BookProcessing::Utilities::CommandRunner
def run_with_retry(command, error_msg = nil, retries: 3, delay: 2, backoff: 1.0, **options)
# Retry logic for flaky commands
end
Elixir Implementation
In Elixir, we'll use a combination of System.cmd/3, Port, and Task for command execution:
defmodule Codexis.System.CommandExecutor do
@moduledoc """
Executes system commands with consistent error handling and logging.
"""
@doc """
Runs a system command with error handling and logging.
## Options
* `:return_output` - Whether to return command output (default: false)
* `:timeout` - Timeout in milliseconds (default: nil)
* `:env` - Environment variables to set (default: nil)
* `:working_dir` - Working directory (default: nil)
* `:raise_on_error` - Whether to raise on error (default: true)
## Returns
* `{:ok, result}` - Command succeeded, result is output if return_output is true, otherwise true
* `{:error, reason}` - Command failed and raise_on_error is false
## Raises
* `Codexis.System.CommandError` - Command failed and raise_on_error is true
"""
def run(command, error_msg \\ nil, opts \\ []) do
# Default options
opts = Keyword.merge([
return_output: false,
timeout: nil,
env: nil,
working_dir: nil,
raise_on_error: true
], opts)
# Validate command
with :ok <- validate_command(command) do
try do
# Log command execution
Logger.debug("📦 Executing: #{command}")
start_time = System.monotonic_time(:millisecond)
# Execute command
result = execute_command(command, opts)
# Log success
duration = System.monotonic_time(:millisecond) - start_time
Logger.debug("✓ Command completed in #{duration}ms")
# Return result
{:ok, result}
rescue
e in Codexis.System.CommandError ->
# Log error
duration = System.monotonic_time(:millisecond) - start_time
Logger.error("Command failed after #{duration}ms: #{Exception.message(e)}")
if opts[:raise_on_error] do
reraise e, __STACKTRACE__
else
{:error, Exception.message(e)}
end
end
end
end
@doc """
Runs a command with retry logic for flaky commands.
## Options
* `:retries` - Number of retry attempts (default: 3)
* `:delay` - Delay between retries in milliseconds (default: 2000)
* `:backoff` - Multiplier for delay between retries (default: 1.5)
* Other options are passed to `run/3`
## Returns
* `{:ok, result}` - Command succeeded
* `{:error, reason}` - Command failed after all retries
"""
def run_with_retry(command, error_msg \\ nil, opts \\ []) do
# Extract retry options
retries = Keyword.get(opts, :retries, 3)
delay = Keyword.get(opts, :delay, 2000)
backoff = Keyword.get(opts, :backoff, 1.5)
opts = Keyword.drop(opts, [:retries, :delay, :backoff])
# Run with retry
do_run_with_retry(command, error_msg, opts, retries, delay, backoff, 0)
end
# Private helper for retry logic
defp do_run_with_retry(command, error_msg, opts, retries, delay, backoff, attempt) do
case run(command, error_msg, Keyword.put(opts, :raise_on_error, false)) do
{:ok, result} ->
{:ok, result}
{:error, reason} ->
if attempt < retries do
# Log retry attempt
Logger.warn("Command failed, retrying (#{attempt + 1}/#{retries}) after #{delay}ms: #{command}")
# Wait before retry
Process.sleep(delay)
# Retry with increased delay
do_run_with_retry(
command,
error_msg,
opts,
retries,
trunc(delay * backoff),
backoff,
attempt + 1
)
else
# All retries failed
error_msg = error_msg || "Command failed after #{retries} retries: #{reason}"
if Keyword.get(opts, :raise_on_error, true) do
raise Codexis.System.CommandError, message: error_msg
else
{:error, error_msg}
end
end
end
end
# Execute command with System.cmd
defp execute_command(command, opts) do
# Split command into executable and args
[executable | args] = String.split(command, " ", trim: true)
# Prepare options for System.cmd
cmd_opts = []
cmd_opts = if opts[:env], do: [{:env, opts[:env]} | cmd_opts], else: cmd_opts
cmd_opts = if opts[:working_dir], do: [{:cd, opts[:working_dir]} | cmd_opts], else: cmd_opts
# Execute command
{output, status} = System.cmd(executable, args, cmd_opts)
# Handle result
if status == 0 do
if opts[:return_output], do: output, else: true
else
error_msg = opts[:error_msg] || "Command failed with status #{status}: #{command}"
raise Codexis.System.CommandError, message: error_msg
end
end
# Validate command
defp validate_command(command) do
cond do
is_nil(command) ->
{:error, "Command cannot be nil"}
String.trim(command) == "" ->
{:error, "Command cannot be empty"}
true ->
:ok
end
end
end
For more complex command execution with streaming output or long-running processes, we'll use Ports:
defmodule Codexis.System.CommandServer do
@moduledoc """
GenServer for managing long-running commands with streaming output.
"""
use GenServer
# Client API
@doc """
Starts a command server for the given command.
"""
def start_link(command, opts \\ []) do
GenServer.start_link(__MODULE__, {command, opts})
end
@doc """
Gets the current output of the command.
"""
def get_output(pid) do
GenServer.call(pid, :get_output)
end
@doc """
Stops the command.
"""
def stop(pid) do
GenServer.stop(pid)
end
# Server callbacks
@impl true
def init({command, opts}) do
# Split command into executable and args
[executable | args] = String.split(command, " ", trim: true)
# Prepare port options
port_opts = [:binary, :exit_status, {:line, 1024}, {:args, args}]
# Open port
port = Port.open({:spawn_executable, executable}, port_opts)
# Initialize state
state = %{
port: port,
command: command,
output: [],
status: :running,
opts: opts
}
{:ok, state}
end
@impl true
def handle_info({port, {:data, data}}, %{port: port} = state) do
# Log output
Logger.debug("Command output: #{data}")
# Update state with new output
new_state = %{state | output: [data | state.output]}
# Notify subscribers if configured
if state.opts[:notify_pid] do
send(state.opts[:notify_pid], {:command_output, data})
end
{:noreply, new_state}
end
@impl true
def handle_info({port, {:exit_status, status}}, %{port: port} = state) do
# Log exit status
Logger.info("Command exited with status #{status}: #{state.command}")
# Update state with exit status
new_state = %{state | status: (if status == 0, do: :success, else: :error)}
# Notify subscribers if configured
if state.opts[:notify_pid] do
send(state.opts[:notify_pid], {:command_exit, status})
end
{:noreply, new_state}
end
@impl true
def handle_call(:get_output, _from, state) do
# Return the collected output in original order
output = state.output |> Enum.reverse() |> Enum.join("\n")
{:reply, {state.status, output}, state}
end
@impl true
def terminate(_reason, state) do
# Close port if still open
if Port.info(state.port) do
Port.close(state.port)
end
:ok
end
end
For concurrent command execution, we'll use Tasks:
defmodule Codexis.System.CommandRunner do
@moduledoc """
Provides book-specific command execution functionality.
"""
@doc """
Runs multiple commands in parallel.
## Returns
* `{:ok, results}` - All commands succeeded
* `{:error, failed_commands}` - Some commands failed
"""
def run_parallel(commands, opts \\ []) do
# Start a task for each command
tasks = Enum.map(commands, fn {name, cmd} ->
Task.async(fn ->
{name, Codexis.System.CommandExecutor.run(cmd, nil, opts)}
end)
end)
# Wait for all tasks to complete
results = Task.await_many(tasks, Keyword.get(opts, :timeout, 30_000))
# Separate successful and failed commands
{successful, failed} = Enum.split_with(results, fn {_, result} ->
match?({:ok, _}, result)
end)
# Return results
if Enum.empty?(failed) do
{:ok, Map.new(successful, fn {name, {:ok, result}} -> {name, result} end)}
else
failed_map = Map.new(failed, fn {name, {:error, reason}} -> {name, reason} end)
{:error, failed_map}
end
end
end
2. Error Handling Pattern
Ruby Implementation
In our Ruby codebase, we use a hierarchy of error classes and the ErrorHandler module:
# BookProcessing::ErrorHandler
module ErrorHandler
class Error < StandardError; end
class CommandError < Error; end
class FileSystemError < Error; end
def self.handle(error, logger, options = {})
# Log error
# Attempt recovery
# Re-raise or return based on options
end
end
# Usage
begin
# Some operation
rescue => e
ErrorHandler.handle(e, logger, raise: true)
end
Elixir Implementation
In Elixir, we'll use a combination of custom error structs, pattern matching, and the {:ok, result} | {:error, reason} convention:
defmodule Codexis.Error do
@moduledoc """
Error handling utilities and error types for the ELib system.
"""
@doc """
Defines a new error type with the given fields.
"""
defmacro deferror(name, fields \\ []) do
quote do
defmodule unquote(name) do
@moduledoc """
Error type: #{unquote(name)}
"""
defexception unquote(fields) ++ [:message, :context]
@doc """
Creates a new error with the given message and context.
"""
def new(message, context \\ %{}) do
%__MODULE__{message: message, context: context}
end
@doc """
Formats the error message with context.
"""
def message(%__MODULE__{message: message, context: context}) do
if map_size(context) == 0 do
message
else
context_str = context
|> Enum.map(fn {k, v} -> "#{k}: #{inspect(v)}" end)
|> Enum.join(", ")
"#{message} (Context: #{context_str})"
end
end
end
end
end
# Define error types
defmodule Types do
require Codexis.Error
Codexis.Error.deferror(CommandError)
Codexis.Error.deferror(FileSystemError)
Codexis.Error.deferror(ConfigurationError)
Codexis.Error.deferror(ConversionError)
Codexis.Error.deferror(ValidationError)
end
@doc """
Handles an error with standardized logging and optional recovery.
## Options
* `:raise` - Whether to raise the error (default: false)
* `:recovery` - Function to attempt recovery (default: nil)
* `:context` - Additional context to add to the error (default: %{})
## Returns
* `{:ok, result}` - Recovery was successful
* `{:error, error}` - Error occurred and no recovery was attempted or recovery failed
"""
def handle(error, logger, opts \\ []) do
# Default options
opts = Keyword.merge([
raise: false,
recovery: nil,
context: %{}
], opts)
# Add context to the error
error = add_context(error, opts[:context])
# Log the error
log_error(error, logger)
# Attempt recovery if provided
if recovery = opts[:recovery] do
try do
logger.info("Attempting error recovery...")
case recovery.(error) do
{:ok, result} ->
logger.info("Recovery successful")
{:ok, result}
{:error, reason} ->
logger.info("Recovery procedure completed but did not resolve the error: #{inspect(reason)}")
{:error, error}
end
rescue
recovery_error ->
logger.error("Recovery failed: #{Exception.message(recovery_error)}")
{:error, error}
end
else
# No recovery attempted
{:error, error}
end
after
# Raise if requested
if opts[:raise] do
raise error
end
end
# Add context to an error
defp add_context(error, context) when map_size(context) == 0, do: error
defp add_context(%{context: existing_context} = error, new_context) do
%{error | context: Map.merge(existing_context, new_context)}
end
defp add_context(error, context) do
# For standard exceptions without a context field
Map.put(error, :context, context)
end
# Log an error with appropriate level and details
defp log_error(error, logger) do
# Format the error message
message = Exception.message(error)
# Log with appropriate level based on error type
cond do
match?(%Types.CommandError{}, error) ->
logger.error("Command Error: #{message}")
match?(%Types.FileSystemError{}, error) ->
logger.error("File System Error: #{message}")
match?(%Types.ConfigurationError{}, error) ->
logger.error("Configuration Error: #{message}")
match?(%Types.ConversionError{}, error) ->
logger.error("Conversion Error: #{message}")
match?(%Types.ValidationError{}, error) ->
logger.error("Validation Error: #{message}")
true ->
logger.error("Error: #{message}")
end
# Log stacktrace at debug level
logger.debug("Stacktrace: #{Exception.format_stacktrace(__STACKTRACE__)}")
end
end
For function results, we'll use the {:ok, result} | {:error, reason} pattern:
defmodule Codexis.Formats.PDF do
@moduledoc """
Handles PDF generation from LaTeX source.
"""
@doc """
Builds a PDF from LaTeX source.
## Returns
* `{:ok, pdf_path}` - PDF was successfully generated
* `{:error, reason}` - PDF generation failed
"""
def build(meta, opts \\ []) do
with {:ok, _} <- process_images(meta),
{:ok, _} <- compile_latex(meta),
{:ok, pdf_path} <- move_pdf(meta) do
# Open PDF if requested
if Keyword.get(opts, :open_after_build, true) do
_ = open_pdf(pdf_path)
end
{:ok, pdf_path}
end
end
# Process images for LaTeX
defp process_images(meta) do
# Implementation
{:ok, processed_count}
end
# Compile LaTeX to PDF
defp compile_latex(meta) do
# Implementation
{:ok, output}
end
# Move PDF to output directory
defp move_pdf(meta) do
# Implementation
{:ok, pdf_path}
end
# Open PDF with viewer
defp open_pdf(pdf_path) do
# Implementation - non-critical, so we don't propagate errors
case Codexis.System.CommandExecutor.run("open -a Skim #{pdf_path}", nil, raise_on_error: false) do
{:ok, _} -> :ok
{:error, _} -> :error
end
end
end
3. Configuration Management
Ruby Implementation
In our Ruby codebase, we use a combination of YAML files and environment variables:
# Configuration loading
config = YAML.load_file('config.yml')
config.merge!(YAML.load_file("config.#{ENV['ENVIRONMENT']}.yml")) if ENV['ENVIRONMENT']
# Usage
output_dir = config['output_dir'] || 'output'
Elixir Implementation
In Elixir, we'll use the built-in configuration system with runtime configuration support:
# config/config.exs
import Config
config :elib, :general,
output_dir: "output",
temp_dir: "tmp"
config :elib, :pdf,
engine: :xelatex,
options: ["-interaction=nonstopmode"]
# Import environment specific config
import_config "#{config_env()}.exs"
# config/runtime.exs
import Config
if config_env() == :prod do
# Production-specific configuration
config :elib, :general,
output_dir: System.get_env("OUTPUT_DIR") || "/var/lib/elib/output"
end
For accessing configuration:
defmodule Codexis.Config do
@moduledoc """
Configuration utilities for the ELib system.
"""
@doc """
Gets a configuration value with support for nested keys.
## Examples
iex> Codexis.Config.get([:general, :output_dir])
"output"
iex> Codexis.Config.get([:pdf, :engine])
:xelatex
"""
def get(keys, default \\ nil) do
get_in(Application.get_all_env(:elib), List.wrap(keys)) || default
end
@doc """
Gets a configuration value with support for nested keys and environment variables.
## Examples
iex> Codexis.Config.get_env([:general, :output_dir], "OUTPUT_DIR", "/tmp")
"/var/lib/elib/output" # If OUTPUT_DIR is set
iex> Codexis.Config.get_env([:general, :output_dir], "OUTPUT_DIR", "/tmp")
"output" # If OUTPUT_DIR is not set but config is
iex> Codexis.Config.get_env([:missing], "MISSING", "/tmp")
"/tmp" # If neither is set
"""
def get_env(keys, env_var, default \\ nil) do
System.get_env(env_var) || get(keys, default)
end
@doc """
Validates the configuration against a schema.
## Examples
iex> Codexis.Config.validate([:pdf], %{
...> engine: [type: :atom, required: true],
...> options: [type: :list, required: false]
...> })
:ok
"""
def validate(prefix, schema) do
config = get(prefix, %{})
Enum.reduce_while(schema, :ok, fn {key, validations}, :ok ->
value = Map.get(config, key)
cond do
# Check required
Keyword.get(validations, :required, false) && is_nil(value) ->
{:halt, {:error, "Required configuration #{prefix}.#{key} is missing"}}
# Check type if value exists
!is_nil(value) && Keyword.has_key?(validations, :type) ->
expected_type = Keyword.get(validations, :type)
actual_type = type_of(value)
if actual_type == expected_type do
{:cont, :ok}
else
{:halt, {:error, "Configuration #{prefix}.#{key} has wrong type: expected #{expected_type}, got #{actual_type}"}}
end
true ->
{:cont, :ok}
end
end)
end
# Helper to get the type of a value
defp type_of(value) when is_binary(value), do: :string
defp type_of(value) when is_integer(value), do: :integer
defp type_of(value) when is_float(value), do: :float
defp type_of(value) when is_boolean(value), do: :boolean
defp type_of(value) when is_atom(value), do: :atom
defp type_of(value) when is_list(value), do: :list
defp type_of(value) when is_map(value), do: :map
defp type_of(_), do: :unknown
end
4. State Management
Ruby Implementation
In our Ruby codebase, we use instance variables and mutable objects to manage state:
class PdfBuilder
def initialize(metadata)
@metadata = metadata
@output_path = nil
@build_status = :pending
end
def build
@build_status = :building
# Build logic
@output_path = "path/to/output.pdf"
@build_status = :completed
@output_path
end
def status
@build_status
end
end
# Usage
builder = PdfBuilder.new(metadata)
output_path = builder.build
puts "Build status: #{builder.status}"
Elixir Implementation
In Elixir, we'll use immutable data structures and processes to manage state:
defmodule Codexis.Formats.PDF.Builder do
@moduledoc """
Handles building PDF files from LaTeX source.
"""
@doc """
Creates a new builder struct with initial state.
"""
def new(metadata) do
%{
meta metadata,
output_path: nil,
build_status: :pending,
start_time: nil,
end_time: nil
}
end
@doc """
Builds a PDF from the given builder state.
## Returns
* `{:ok, updated_builder}` - Build succeeded
* `{:error, reason, updated_builder}` - Build failed
"""
def build(builder) do
# Update state to building
builder = %{builder | build_status: :building, start_time: DateTime.utc_now()}
# Build logic
case do_build(builder) do
{:ok, output_path} ->
# Update state to completed
updated_builder = %{builder |
build_status: :completed,
output_path: output_path,
end_time: DateTime.utc_now()
}
{:ok, updated_builder}
{:error, reason} ->
# Update state to failed
updated_builder = %{builder |
build_status: :failed,
end_time: DateTime.utc_now()
}
{:error, reason, updated_builder}
end
end
# Private build implementation
defp do_build(builder) do
# Implementation
{:ok, "path/to/output.pdf"}
end
end
For long-lived state, we'll use GenServer:
defmodule Codexis.Formats.PDF.BuildServer do
@moduledoc """
GenServer for managing PDF build state.
"""
use GenServer
# Client API
@doc """
Starts a build server for the given metadata.
"""
def start_link(metadata) do
GenServer.start_link(__MODULE__, metadata)
end
@doc """
Builds a PDF.
"""
def build(pid) do
GenServer.call(pid, :build)
end
@doc """
Gets the current build status.
"""
def status(pid) do
GenServer.call(pid, :status)
end
# Server callbacks
@impl true
def init(metadata) do
{:ok, Codexis.Formats.PDF.Builder.new(metadata)}
end
@impl true
def handle_call(:build, _from, state) do
case Codexis.Formats.PDF.Builder.build(state) do
{:ok, updated_state} ->
{:reply, {:ok, updated_state.output_path}, updated_state}
{:error, reason, updated_state} ->
{:reply, {:error, reason}, updated_state}
end
end
@impl true
def handle_call(:status, _from, state) do
{:reply, state.build_status, state}
end
end
5. Process Supervision
Ruby Implementation
In our Ruby codebase, we don't have a direct equivalent to OTP supervision, but we use error handling and recovery:
def build_all_formats(metadata)
begin
build_pdf(metadata)
rescue => e
log_error("PDF build failed: #{e.message}")
end
begin
build_epub(metadata)
rescue => e
log_error("EPUB build failed: #{e.message}")
end
# Continue with other formats
end
Elixir Implementation
In Elixir, we'll use OTP supervision trees to manage processes and handle failures:
defmodule Codexis.Application do
@moduledoc """
The ELib Application.
"""
use Application
@impl true
def start(_type, _args) do
children = [
# Registry for dynamic processes
{Registry, keys: :unique, name: Codexis.Registry},
# Supervisor for build processes
{Codexis.BuildSupervisor, []},
# Supervisor for format-specific processes
{Codexis.FormatsSupervisor, []},
# PubSub for event broadcasting
{Phoenix.PubSub, name: Codexis.PubSub}
]
opts = [strategy: :one_for_one, name: Codexis.Supervisor]
Supervisor.start_link(children, opts)
end
end
defmodule Codexis.BuildSupervisor do
@moduledoc """
Supervisor for build processes.
"""
use DynamicSupervisor
def start_link(init_arg) do
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
DynamicSupervisor.init(strategy: :one_for_one)
end
@doc """
Starts a build process for the given metadata.
"""
def start_build(metadata) do
# Create a unique ID for this build
build_id = UUID.uuid4()
# Child spec for the build process
child_spec = {Codexis.Build.BuildServer, {build_id, metadata}}
# Start the build process under this supervisor
case DynamicSupervisor.start_child(__MODULE__, child_spec) do
{:ok, pid} -> {:ok, build_id, pid}
{:error, reason} -> {:error, reason}
end
end
end
defmodule Codexis.FormatsSupervisor do
@moduledoc """
Supervisor for format-specific processes.
"""
use Supervisor
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
children = [
# PDF format supervisor
{Codexis.Formats.PDF.Supervisor, []},
# EPUB format supervisor
{Codexis.Formats.EPUB.Supervisor, []},
# MOBI format supervisor
{Codexis.Formats.MOBI.Supervisor, []}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
defmodule Codexis.Formats.PDF.Supervisor do
@moduledoc """
Supervisor for PDF-specific processes.
"""
use DynamicSupervisor
def start_link(init_arg) do
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
DynamicSupervisor.init(strategy: :one_for_one)
end
@doc """
Starts a PDF build process for the given build ID and metadata.
"""
def start_builder(build_id, metadata) do
# Child spec for the PDF builder
child_spec = {Codexis.Formats.PDF.BuildServer, {build_id, metadata}}
# Start the PDF builder under this supervisor
case DynamicSupervisor.start_child(__MODULE__, child_spec) do
{:ok, pid} -> {:ok, pid}
{:error, reason} -> {:error, reason}
end
end
end
6. Concurrency Patterns
Ruby Implementation
In our Ruby codebase, we use threads for limited concurrency:
def build_formats_concurrently(metadata, formats)
threads = formats.map do |format|
Thread.new do
begin
send("build_#{format}", metadata)
rescue => e
log_error("#{format.upcase} build failed: #{e.message}")
nil
end
end
end
# Wait for all threads to complete
results = threads.map(&:value)
# Filter out nil results (failed builds)
results.compact
end
Elixir Implementation
In Elixir, we'll use Tasks, GenServers, and other OTP constructs for concurrency:
defmodule Codexis.Build.FormatBuilder do
@moduledoc """
Handles building multiple formats concurrently.
"""
@doc """
Builds multiple formats concurrently.
## Returns
* `{:ok, results}` - All formats built successfully
* `{:partial, results, errors}` - Some formats built successfully, some failed
* `{:error, errors}` - All formats failed
"""
def build_formats(metadata, formats) do
# Start a task for each format
tasks = Enum.map(formats, fn format ->
Task.async(fn ->
{format, build_format(format, metadata)}
end)
end)
# Wait for all tasks to complete
results = Task.await_many(tasks, 60_000)
# Separate successful and failed builds
{successful, failed} = Enum.split_with(results, fn {_, result} ->
match?({:ok, _}, result)
end)
# Format results
success_map = Map.new(successful, fn {format, {:ok, path}} -> {format, path} end)
error_map = Map.new(failed, fn {format, {:error, reason}} -> {format, reason} end)
# Return appropriate result based on success/failure
cond do
Enum.empty?(failed) ->
{:ok, success_map}
Enum.empty?(successful) ->
{:error, error_map}
true ->
{:partial, success_map, error_map}
end
end
# Build a specific format
defp build_format(:pdf, metadata) do
Codexis.Formats.PDF.build(metadata)
end
defp build_format(:epub, metadata) do
Codexis.Formats.EPUB.build(metadata)
end
defp build_format(:mobi, metadata) do
Codexis.Formats.MOBI.build(metadata)
end
defp build_format(format, _metadata) do
{:error, "Unsupported format: #{format}"}
end
end
For more complex concurrency patterns, we'll use GenStage:
defmodule Codexis.Pipeline do
@moduledoc """
Defines a processing pipeline for book conversion.
"""
@doc """
Creates a new pipeline for the given metadata.
"""
def new(metadata, formats) do
# Create a producer for the source content
{:ok, producer} = Codexis.Pipeline.ContentProducer.start_link(metadata)
# Create processors for each stage
{:ok, preprocessor} = Codexis.Pipeline.Preprocessor.start_link()
# Create consumers for each format
consumers = Enum.map(formats, fn format ->
{:ok, pid} = format_consumer(format).start_link()
pid
end)
# Connect the stages
GenStage.sync_subscribe(preprocessor, to: producer)
Enum.each(consumers, fn consumer ->
GenStage.sync_subscribe(consumer, to: preprocessor)
end)
# Return the pipeline
%{
metadata: metadata,
formats: formats,
producer: producer,
preprocessor: preprocessor,
consumers: consumers
}
end
# Get the appropriate consumer module for a format
defp format_consumer(:pdf), do: Codexis.Pipeline.PDFConsumer
defp format_consumer(:epub), do: Codexis.Pipeline.EPUBConsumer
defp format_consumer(:mobi), do: Codexis.Pipeline.MOBIConsumer
end
defmodule Codexis.Pipeline.ContentProducer do
@moduledoc """
GenStage producer that provides content chunks for processing.
"""
use GenStage
def start_link(metadata) do
GenStage.start_link(__MODULE__, metadata)
end
@impl true
def init(metadata) do
# Load content from metadata
{:ok, content} = Codexis.Content.load(metadata)
# Split content into chunks
chunks = Codexis.Content.chunk(content)
# Initialize state
{:producer, %{chunks: chunks, meta metadata}}
end
@impl true
def handle_demand(demand, %{chunks: chunks} = state) when demand > 0 do
# Take demanded chunks
{events, remaining} = Enum.split(chunks, demand)
# Update state
{:noreply, events, %{state | chunks: remaining}}
end
end
defmodule Codexis.Pipeline.Preprocessor do
@moduledoc """
GenStage producer-consumer that preprocesses content chunks.
"""
use GenStage
def start_link do
GenStage.start_link(__MODULE__, :ok)
end
@impl true
def init(:ok) do
{:producer_consumer, %{}}
end
@impl true
def handle_events(events, _from, state) do
# Process each chunk
processed = Enum.map(events, &preprocess_chunk/1)
# Forward processed chunks
{:noreply, processed, state}
end
# Preprocess a content chunk
defp preprocess_chunk(chunk) do
# Implementation
processed_chunk = chunk
processed_chunk
end
end
defmodule Codexis.Pipeline.PDFConsumer do
@moduledoc """
GenStage consumer that generates PDF output.
"""
use GenStage
def start_link do
GenStage.start_link(__MODULE__, :ok)
end
@impl true
def init(:ok) do
{:consumer, %{chunks: []}}
end
@impl true
def handle_events(events, _from, state) do
# Collect chunks
updated_state = %{state | chunks: state.chunks ++ events}
# Process if we have all chunks
if Enum.empty?(events) do
# Generate PDF from all chunks
generate_pdf(updated_state.chunks)
end
{:noreply, [], updated_state}
end
# Generate PDF from chunks
defp generate_pdf(chunks) do
# Implementation
:ok
end
end
7. File System Operations
Ruby Implementation
In our Ruby codebase, we use standard Ruby file operations:
def ensure_directory(path)
FileUtils.mkdir_p(path) unless Dir.exist?(path)
end
def copy_file(source, destination)
FileUtils.cp(source, destination)
end
def write_file(path, content)
File.write(path, content)
end
def read_file(path)
File.read(path)
end
Elixir Implementation
In Elixir, we'll use the File module and create a dedicated file system service:
defmodule Codexis.FileSystem do
@moduledoc """
File system operations for the ELib system.
"""
@doc """
Ensures a directory exists, creating it if necessary.
## Returns
* `{:ok, path}` - Directory exists or was created
* `{:error, reason}` - Failed to create directory
"""
def ensure_directory(path) do
case File.dir?(path) do
true ->
{:ok, path}
false ->
case File.mkdir_p(path) do
:ok -> {:ok, path}
{:error, reason} -> {:error, reason}
end
end
end
@doc """
Copies a file from source to destination.
## Returns
* `{:ok, destination}` - File was copied
* `{:error, reason}` - Failed to copy file
"""
def copy_file(source, destination) do
# Ensure destination directory exists
with {:ok, _} <- ensure_directory(Path.dirname(destination)),
:ok <- File.copy(source, destination) do
{:ok, destination}
end
end
@doc """
Writes content to a file.
## Returns
* `{:ok, path}` - Content was written
* `{:error, reason}` - Failed to write content
"""
def write_file(path, content) do
# Ensure directory exists
with {:ok, _} <- ensure_directory(Path.dirname(path)),
:ok <- File.write(path, content) do
{:ok, path}
end
end
@doc """
Reads content from a file.
## Returns
* `{:ok, content}` - Content was read
* `{:error, reason}` - Failed to read content
"""
def read_file(path) do
case File.read(path) do
{:ok, content} -> {:ok, content}
{:error, reason} -> {:error, reason}
end
end
@doc """
Lists files in a directory matching a pattern.
## Returns
* `{:ok, files}` - Files were listed
* `{:error, reason}` - Failed to list files
"""
def list_files(path, pattern \\ "*") do
case File.dir?(path) do
true ->
files = path
|> Path.join(pattern)
|> Path.wildcard()
|> Enum.filter(&File.regular?/1)
{:ok, files}
false ->
{:error, :enoent}
end
end
@doc """
Removes a file or directory.
## Options
* `:recursive` - Whether to remove directories recursively (default: false)
## Returns
* `{:ok, path}` - Path was removed
* `{:error, reason}` - Failed to remove path
"""
def remove(path, opts \\ []) do
recursive = Keyword.get(opts, :recursive, false)
cond do
File.regular?(path) ->
case File.rm(path) do
:ok -> {:ok, path}
{:error, reason} -> {:error, reason}
end
File.dir?(path) && recursive ->
case File.rm_rf(path) do
{:ok, _} -> {:ok, path}
{:error, reason} -> {:error, reason}
end
File.dir?(path) ->
case File.rmdir(path) do
:ok -> {:ok, path}
{:error, reason} -> {:error, reason}
end
true ->
{:error, :enoent}
end
end
end
8. Dependency Injection
Ruby Implementation
In our Ruby codebase, we use constructor injection:
class PdfBuilder
def initialize(metadata, command_runner: nil, logger: nil)
@metadata = metadata
@command_runner = command_runner || BookProcessing::Utilities::CommandRunner.new
@logger = logger || BookProcessing::LoggerSetup.default_logger
end
def build
# Use @command_runner and @logger
end
end
# Usage
builder = PdfBuilder.new(
metadata,
command_runner: custom_runner,
logger: custom_logger
)
Elixir Implementation
In Elixir, we'll use function parameters and structs for dependency injection:
defmodule Codexis.Formats.PDF do
@moduledoc """
Handles PDF generation from LaTeX source.
"""
@doc """
Creates a new PDF builder with the given dependencies.
## Options
* `:command_executor` - Command executor to use (default: Codexis.System.CommandExecutor)
* `:logger` - Logger to use (default: Logger)
* `:file_system` - File system service to use (default: Codexis.FileSystem)
"""
def new(metadata, opts \\ []) do
%{
metadata: metadata,
command_executor: Keyword.get(opts, :command_executor, Codexis.System.CommandExecutor),
logger: Keyword.get(opts, :logger, Logger),
file_system: Keyword.get(opts, :file_system, Codexis.FileSystem)
}
end
@doc """
Builds a PDF using the given builder.
## Returns
* `{:ok, pdf_path}` - PDF was successfully generated
* `{:error, reason}` - PDF generation failed
"""
def build(builder) do
# Use injected dependencies
builder.logger.info("Building PDF for #{builder.metadata.title}")
with {:ok, _} <- process_images(builder),
{:ok, _} <- compile_latex(builder),
{:ok, pdf_path} <- move_pdf(builder) do
{:ok, pdf_path}
end
end
# Process images for LaTeX
defp process_images(builder) do
# Use builder.command_executor and builder.file_system
{:ok, processed_count}
end
# Compile LaTeX to PDF
defp compile_latex(builder) do
# Use builder.command_executor
{:ok, output}
end
# Move PDF to output directory
defp move_pdf(builder) do
# Use builder.file_system
{:ok, pdf_path}
end
end
# Usage
builder = Codexis.Formats.PDF.new(
metadata,
command_executor: MockCommandExecutor,
logger: MockLogger,
file_system: MockFileSystem
)
{:ok, pdf_path} = Codexis.Formats.PDF.build(builder)
For more complex dependency injection, we can use a context pattern:
defmodule Codexis.Context do
@moduledoc """
Provides a context for dependency injection.
"""
@doc """
Creates a new context with the given dependencies.
"""
def new(opts \\ []) do
%{
command_executor: Keyword.get(opts, :command_executor, Codexis.System.CommandExecutor),
logger: Keyword.get(opts, :logger, Logger),
file_system: Keyword.get(opts, :file_system, Codexis.FileSystem),
config: Keyword.get(opts, :config, Codexis.Config)
}
end
@doc """
Gets a dependency from the context.
"""
def get(context, key) do
Map.get(context, key)
end
@doc """
Updates a dependency in the context.
"""
def put(context, key, value) do
Map.put(context, key, value)
end
end
# Usage
context = Codexis.Context.new(logger: MockLogger)
Codexis.Formats.PDF.build(metadata, context)
9. Testing Strategies
Ruby Implementation
In our Ruby codebase, we use RSpec for testing:
RSpec.describe PdfBuilder do
let(:metadata) { double("Metadata", title: "Test Book") }
let(:command_runner) { double("CommandRunner") }
let(:logger) { double("Logger", info: nil, error: nil) }
subject { described_class.new(metadata, command_runner: command_runner, logger: logger) }
describe "#build" do
before do
allow(command_runner).to receive(:run).and_return(true)
end
it "builds a PDF successfully" do
expect(subject.build).to eq("path/to/output.pdf")
end
context "when command fails" do
before do
allow(command_runner).to receive(:run).and_raise("Command failed")
end
it "raises an error" do
expect { subject.build }.to raise_error("Command failed")
end
end
end
end
Elixir Implementation
In Elixir, we'll use ExUnit for testing with mocks and dependency injection:
defmodule Codexis.Formats.PDFTest do
use ExUnit.Case, async: true
# Define test doubles
defmodule MockCommandExecutor do
def run(_command, _error_msg \\ nil, _opts \\ []) do
{:ok, true}
end
end
defmodule MockLogger do
def info(_msg), do: :ok
def error(_msg), do: :ok
end
defmodule MockFileSystem do
def ensure_directory(_path), do: {:ok, "test/dir"}
def copy_file(_source, destination), do: {:ok, destination}
def write_file(_path, _content), do: {:ok, "test/file.txt"}
def read_file(_path), do: {:ok, "test content"}
end
# Test metadata
@metadata %{title: "Test Book", author: "Test Author"}
describe "build/1" do
test "builds a PDF successfully" do
# Create builder with mocks
builder = Codexis.Formats.PDF.new(
@metadata,
command_executor: MockCommandExecutor,
logger: MockLogger,
file_system: MockFileSystem
)
# Test build function
assert {:ok, pdf_path} = Codexis.Formats.PDF.build(builder)
assert is_binary(pdf_path)
end
test "handles command failure" do
# Create mock that simulates failure
defmodule FailingCommandExecutor do
def run(_command, _error_msg \\ nil, _opts \\ []) do
{:error, "Command failed"}
end
end
# Create builder with failing mock
builder = Codexis.Formats.PDF.new(
@metadata,
command_executor: FailingCommandExecutor,
logger: MockLogger,
file_system: MockFileSystem
)
# Test build function with failure
assert {:error, reason} = Codexis.Formats.PDF.build(builder)
assert reason =~ "Command failed"
end
end
end
For more complex testing, we can use Mox:
defmodule Codexis.Test.Mocks do
# Define behaviors for mocks
defmodule CommandExecutor do
@callback run(String.t(), String.t() | nil, keyword()) ::
{:ok, boolean() | String.t()} | {:error, String.t()}
@callback run_with_retry(String.t(), String.t() | nil, keyword()) ::
{:ok, boolean() | String.t()} | {:error, String.t()}
end
defmodule FileSystem do
@callback ensure_directory(String.t()) :: {:ok, String.t()} | {:error, atom()}
@callback copy_file(String.t(), String.t()) :: {:ok, String.t()} | {:error, atom()}
@callback write_file(String.t(), String.t()) :: {:ok, String.t()} | {:error, atom()}
@callback read_file(String.t()) :: {:ok, String.t()} | {:error, atom()}
end
end
# In test_helper.exs
Mox.defmock(Codexis.Test.MockCommandExecutor, for: Codexis.Test.Mocks.CommandExecutor)
Mox.defmock(Codexis.Test.MockFileSystem, for: Codexis.Test.Mocks.FileSystem)
# In test file
defmodule Codexis.Formats.PDFTest do
use ExUnit.Case, async: true
import Mox
# Verify mocks after each test
setup :verify_on_exit!
# Test metadata
@metadata %{title: "Test Book", author: "Test Author"}
describe "build/1" do
test "builds a PDF successfully" do
# Set up expectations
Codexis.Test.MockCommandExecutor
|> expect(:run, fn _cmd, _err, _opts -> {:ok, true} end)
|> expect(:run, fn _cmd, _err, _opts -> {:ok, true} end)
Codexis.Test.MockFileSystem
|> expect(:ensure_directory, fn _path -> {:ok, "test/dir"} end)
|> expect(:copy_file, fn _src, dest -> {:ok, dest} end)
# Create builder with mocks
builder = Codexis.Formats.PDF.new(
@metadata,
command_executor: Codexis.Test.MockCommandExecutor,
logger: MockLogger,
file_system: Codexis.Test.MockFileSystem
)
# Test build function
assert {:ok, pdf_path} = Codexis.Formats.PDF.build(builder)
assert is_binary(pdf_path)
end
end
end
10. Logging Approaches
Ruby Implementation
In our Ruby codebase, we use a custom logger setup:
module BookProcessing
module LoggerSetup
def self.default_logger
logger = Logger.new(STDOUT)
logger.level = ENV['DEBUG'] ? Logger::DEBUG : Logger::INFO
logger.formatter = proc do |severity, datetime, progname, msg|
"#{datetime.strftime('%Y-%m-%d %H:%M:%S')} [#{severity}] #{msg}\n"
end
logger
end
def self.file_logger(path)
logger = Logger.new(path)
logger.level = Logger::DEBUG
logger.formatter = proc do |severity, datetime, progname, msg|
"#{datetime.strftime('%Y-%m-%d %H:%M:%S')} [#{severity}] #{msg}\n"
end
logger
end
end
end
# Usage
logger = BookProcessing::LoggerSetup.default_logger
logger.info("Starting build")
logger.debug("Debug information")
Elixir Implementation
In Elixir, we'll use the built-in Logger with custom backends and metadata:
# config/config.exs
import Config
config :logger,
level: :info,
format: "$time $metadata[$level] $message\n",
metadata: [:build_id, :format, :module]
# For development
if config_env() == :dev do
config :logger, :console,
format: "[$level] $message\n"
end
# For production
if config_env() == :prod do
config :logger, :console,
level: :info,
format: "$time [$level] $message\n"
config :logger, :file,
path: "/var/log/elib/elib.log",
level: :info,
rotate: %{max_bytes: 52428800, keep: 5}
end
# In application code
defmodule Codexis.Logging do
@moduledoc """
Logging utilities for the ELib system.
"""
require Logger
@doc """
Sets up logging for a build.
"""
def setup_build_logging(build_id) do
# Set up file logging for this build
log_path = Path.join([
Codexis.Config.get([:general, :log_dir], "log"),
"#{build_id}.log"
])
# Ensure log directory exists
:ok = File.mkdir_p(Path.dirname(log_path))
# Add file backend for this build
{:ok, _} = Logger.add_backend({LoggerFileBackend, :build_log})
:ok = Logger.configure_backend({LoggerFileBackend, :build_log},
path: log_path,
level: :debug,
metadata_filter: [build_id: build_id]
)
# Return log path
{:ok, log_path}
end
@doc """
Logs a message with build context.
"""
def log(level, message, metadata \\ []) do
Logger.log(level, message, metadata)
end
@doc """
Logs the start of a build step.
"""
def log_step_start(step, metadata \\ []) do
Logger.info("Starting step: #{step}", metadata)
end
@doc """
Logs the completion of a build step.
"""
def log_step_complete(step, metadata \\ []) do
Logger.info("Completed step: #{step}", metadata)
end
@doc """
Logs a build error.
"""
def log_build_error(error, metadata \\ []) do
Logger.error("Build error: #{inspect(error)}", metadata)
end
end
# Usage in application code
defmodule Codexis.Formats.PDF do
require Logger
def build(metadata) do
# Set metadata for all log messages in this process
Logger.metadata(format: :pdf, build_id: metadata.build_id)
Logger.info("Building PDF for #{metadata.title}")
# Build logic
Logger.info("PDF build complete")
{:ok, pdf_path}
end
end
For structured logging and metrics, we can use Telemetry:
defmodule Codexis.Telemetry do
@moduledoc """
Telemetry utilities for the ELib system.
"""
@doc """
Executes a function with telemetry events.
## Examples
iex> Codexis.Telemetry.span([:elib, :pdf, :build], %{book_id: 123}, fn ->
...> # Build logic
...> {:ok, "path/to/pdf"}
...> end)
"""
def span(event_prefix, metadata, function) do
start_time = System.monotonic_time()
start_event = event_prefix ++ [:start]
:telemetry.execute(start_event, %{system_time: System.system_time()}, metadata)
try do
result = function.()
end_time = System.monotonic_time()
duration = end_time - start_time
# Determine event name based on result
event_name = case result do
{:ok, _} -> event_prefix ++ [:success]
{:error, _} -> event_prefix ++ [:failure]
_ -> event_prefix ++ [:complete]
end
# Include result in metadata
result_metadata = Map.put(metadata, :result, result)
# Execute telemetry event
:telemetry.execute(
event_name,
%{
duration: duration,
system_time: System.system_time()
},
result_metadata
)
result
rescue
error ->
end_time = System.monotonic_time()
duration = end_time - start_time
# Execute exception event
:telemetry.execute(
event_prefix ++ [:exception],
%{
duration: duration,
system_time: System.system_time()
},
Map.merge(metadata, %{
kind: :error,
error: error,
stacktrace: __STACKTRACE__
})
)
reraise error, __STACKTRACE__
end
end
end
# Usage in application code
defmodule Codexis.Formats.PDF do
def build(metadata) do
Codexis.Telemetry.span([:elib, :formats, :pdf, :build], %{book_id: metadata.id}, fn ->
# Build logic
{:ok, pdf_path}
end)
end
end
# Telemetry handler setup
defmodule Codexis.TelemetryHandlers do
def setup do
# Attach handlers for logging
:telemetry.attach(
"elib-pdf-build-handler",
[:elib, :formats, :pdf, :build, :start],
&handle_pdf_build_start/4,
nil
)
:telemetry.attach(
"elib-pdf-build-success-handler",
[:elib, :formats, :pdf, :build, :success],
&handle_pdf_build_success/4,
nil
)
# Attach handlers for metrics
:telemetry.attach(
"elib-build-duration-handler",
[:elib, :formats, :*],
&handle_build_duration/4,
nil
)
end
# Handler functions
def handle_pdf_build_start(_event, _measurements, metadata, _config) do
Logger.info("Starting PDF build for book #{metadata.book_id}")
end
def handle_pdf_build_success(_event, measurements, metadata, _config) do
Logger.info("PDF build successful for book #{metadata.book_id} in #{measurements.duration / 1_000_000} ms")
end
def handle_build_duration(_event, measurements, metadata, _config) do
# Record metrics
:telemetry_metrics_counter.inc([:elib, :build, :count], 1)
:telemetry_metrics_summary.observe([:elib, :build, :duration], measurements.duration / 1_000_000)
end
end