Date: 2025-03-25
Status
Accepted
Context
As we continue our migration to Elixir (as outlined in ADR-0029), we need a standardized approach for building command-line interface (CLI) tools. These tools are essential for various system operations, including:
- Build process orchestration
- Asset management
- Content validation
- Development utilities
- Deployment automation
Our current CLI tools, built in Ruby, lack several features that would improve developer experience and system reliability:
- Concurrent execution of independent operations
- Proper error handling and reporting
- Progress visualization for long-running tasks
- Comprehensive testing strategy
- Consistent command structure and interface
Decision
We will implement all CLI tools using Elixir, following a standardized architecture that leverages:
1. Project Structure
We will organize CLI projects with a consistent directory structure:
lib/
my_cli_app/
cli.ex # Entry point with main/1
commands/ # Modules for each CLI command
shell.ex # Wrapper for system commands
progress.ex # Progress bar utilities
test/
support/ # Test helpers and mocks
2. Dependencies
We will use the following dependencies for CLI tool development:
defp deps do
[
{:optimus, "~> 0.2.0"}, # CLI argument parsing
{:prompt, "~> 0.6.0"}, # Interactive prompts
{:progress_bar, "~> 2.0"}, # Progress bars
{:patch, "~> 0.12.0", only: :test}, # Function patching for tests
{:exvcr, "~> 0.13.0", only: :test} # HTTP request recording
]
end
3. Command Structure
All CLI tools will implement a consistent command pattern:
# Entry point (lib/my_cli_app/cli.ex)
defmodule MyCLIApp.CLI do
def main(argv) do
optimus = MyCLIApp.Optimus.definition()
case Optimus.parse!(optimus, argv) do
{:ok, opts} -> MyCLIApp.Router.dispatch(opts)
{:error, msg} ->
IO.puts(msg)
System.halt(1)
end
end
end
# Command behavior (lib/my_cli_app/command.ex)
defmodule MyCLIApp.Command do
@callback run(map()) :: :ok | {:error, String.t()}
end
# Individual commands (lib/my_cli_app/commands/*.ex)
defmodule MyCLIApp.Commands.BuildBook do
@behaviour MyCLIApp.Command
def run(opts) do
# Command implementation
end
end
4. System Command Execution
We will use a standardized pattern for executing system commands:
defmodule MyCLIApp.Shell do
def run(cmd, args, opts \\ []) do
Task.Supervisor.async_nolink(MyCLIApp.TaskSupervisor, fn ->
System.cmd(cmd, args, Keyword.merge(opts, stderr_to_stdout: true))
end)
|> Task.await(5000)
|> handle_result()
end
defp handle_result({output, 0}), do: {:ok, output}
defp handle_result({output, status}), do: {:error, %{status: status, output: output}}
end
5. Concurrency
For performance optimization, we will leverage Elixir's concurrency capabilities:
defmodule MyCLIApp.AssetProcessor do
def process_all(assets) do
assets
|> Task.async_stream(&process_asset/1, max_concurrency: 4)
|> Enum.reduce([], fn {:ok, result}, acc -> [result | acc] end)
end
defp process_asset(asset) do
# Process individual asset
end
end
6. Progress Visualization
For improved user experience, we will implement progress visualization:
defmodule MyCLIApp.Progress do
def with_progress(items, operation) do
total = Enum.count(items)
items
|> Enum.with_index(1)
|> Enum.each(fn {item, index} ->
result = operation.(item)
ProgressBar.render(index, total)
result
end)
end
end
7. Testing Strategy
We will follow a comprehensive testing strategy for CLI tools:
defmodule MyCLIApp.CommandTest do
use ExUnit.Case
import ExUnit.CaptureIO
describe "command execution" do
test "handles successful command" do
output = capture_io(fn ->
assert MyCLIApp.CLI.main(["build", "--format", "pdf"]) == :ok
end)
assert output =~ "Build completed successfully"
end
test "handles command failure" do
Patch.with_mocked_function MyCLIApp.Commands.Build, :run, fn _ ->
{:error, "Build failed"}
end do
output = capture_io(fn ->
assert MyCLIApp.CLI.main(["build", "--format", "pdf"]) == :error
end)
assert output =~ "Build failed"
end
end
end
end
8. Distribution
We will build distributable binaries using escript:
# In mix.exs
def project do
[
app: :my_cli_app,
version: "0.1.0",
elixir: "~> 1.18",
escript: [main_module: MyCLIApp.CLI],
deps: deps()
]
end
For standalone binaries that don't require Erlang/Elixir installation, we will explore Burrito for cross-platform distribution.
Consequences
Positive
- Consistency: Standardized approach across all CLI tools
- Concurrency: Efficient parallel execution of tasks
- Reliability: Better error handling and supervision
- Testability: Comprehensive testing strategy
- User Experience: Improved progress visualization
- Maintainability: Clear separation of concerns
- Performance: Optimized execution of operations
Negative
- Migration Effort: Existing Ruby tools need porting
- Learning Curve: Development team needs to learn Elixir
- Integration: Integration with existing non-Elixir components
- Distribution: More complex distribution process than Ruby gems
Migration Strategy
-
Phase 1: Core Framework
- Implement common CLI framework
- Create shell command execution wrapper
- Establish testing patterns
-
Phase 2: Essential Tools
- Port build command tools
- Port validation tools
- Implement progress visualization
-
Phase 3: Supporting Tools
- Port development utilities
- Port asset management tools
- Implement additional helpers
-
Phase 4: Advanced Features
- Implement concurrent processing
- Add interactive prompts
- Create cross-platform distribution
Compliance
Implementation must:
- Follow the Elixir style guide
- Include comprehensive tests (minimum 80% coverage)
- Provide detailed documentation
- Maintain backward compatibility with existing CLI workflows
- Support all target platforms (macOS, Linux, Windows)