|
1 | 1 | # Implementation - ST0008: Orthogonalised formatting and outputting |
2 | 2 |
|
3 | | -## Implementation |
| 3 | +## Implementation Overview |
4 | 4 |
|
5 | | -[Notes on implementation details, decisions, challenges, and their resolutions] |
| 5 | +The orthogonalized output system separates data processing, styling, and formatting concerns through a layered architecture: |
| 6 | + |
| 7 | +1. **Context Layer** (`Arca.Cli.Ctx`) - Carries structured command output |
| 8 | +2. **Renderer Layer** - Style-specific rendering (Plain, ANSI, JSON, Dump) |
| 9 | +3. **Output Layer** (`Arca.Cli.Output`) - Orchestrates rendering pipeline |
| 10 | +4. **Integration Layer** - Command execution and callback processing |
| 11 | + |
| 12 | +## Architecture |
| 13 | + |
| 14 | +``` |
| 15 | +Command → Context → Callbacks → Output → Renderer → Final Output |
| 16 | + ↓ |
| 17 | + Style Detection |
| 18 | + (ENV, CLI flags, TTY) |
| 19 | +``` |
6 | 20 |
|
7 | 21 | ## Code Examples |
8 | 22 |
|
9 | | -[Key code snippets and examples] |
| 23 | +### Command Using Context Pattern |
| 24 | + |
| 25 | +```elixir |
| 26 | +defmodule Arca.Cli.Commands.SettingsAllCommand do |
| 27 | + def handle(_args, settings, _optimus) do |
| 28 | + case Arca.Cli.load_settings() do |
| 29 | + {:ok, loaded_settings} -> |
| 30 | + table_rows = settings_to_table_rows(loaded_settings) |
| 31 | + |
| 32 | + Ctx.new(:"settings.all", settings) |
| 33 | + |> Ctx.add_output({:info, "Current Configuration Settings"}) |
| 34 | + |> Ctx.add_output({:table, table_rows, [has_headers: true]}) |
| 35 | + |> Ctx.with_cargo(%{settings_count: map_size(loaded_settings)}) |
| 36 | + |> Ctx.complete(:ok) |
| 37 | + |
| 38 | + {:error, _reason} -> |
| 39 | + Ctx.new(:"settings.all", settings) |
| 40 | + |> Ctx.add_error("Failed to load settings") |
| 41 | + |> Ctx.complete(:error) |
| 42 | + end |
| 43 | + end |
| 44 | +end |
| 45 | +``` |
| 46 | + |
| 47 | +### Pattern-Matched Command Result Processing |
| 48 | + |
| 49 | +```elixir |
| 50 | +# In lib/arca_cli.ex |
| 51 | +defp process_command_result(%Ctx{} = ctx, _handler, _settings) do |
| 52 | + {:ok, Output.render(ctx)} |
| 53 | +end |
| 54 | + |
| 55 | +defp process_command_result(result, _handler, settings) when is_binary(result) do |
| 56 | + {:ok, apply_legacy_formatting(result, settings)} |
| 57 | +end |
| 58 | + |
| 59 | +defp process_command_result({:error, _} = error, _handler, _settings) do |
| 60 | + error |
| 61 | +end |
| 62 | + |
| 63 | +defp process_command_result({:nooutput, _} = result, _handler, _settings) do |
| 64 | + result |
| 65 | +end |
| 66 | +``` |
| 67 | + |
| 68 | +### Polymorphic Callback Support |
| 69 | + |
| 70 | +```elixir |
| 71 | +defp apply_format_callback(value, callback) when is_binary(value) do |
| 72 | + callback.(value) |
| 73 | +end |
| 74 | + |
| 75 | +defp apply_format_callback(%Ctx{} = ctx, callback) do |
| 76 | + callback.(ctx) |
| 77 | +end |
| 78 | +``` |
| 79 | + |
| 80 | +### ANSI Renderer with Owl Integration |
| 81 | + |
| 82 | +```elixir |
| 83 | +defp apply_cell_color(:header, cell), do: Owl.Data.tag(cell, :bright) |
| 84 | +defp apply_cell_color(:number, cell), do: Owl.Data.tag(cell, :cyan) |
| 85 | +defp apply_cell_color(:success, cell), do: Owl.Data.tag(cell, :green) |
| 86 | +defp apply_cell_color(:error, cell), do: Owl.Data.tag(cell, :red) |
| 87 | +defp apply_cell_color(:boolean_true, cell), do: Owl.Data.tag(cell, :green) |
| 88 | +defp apply_cell_color(:boolean_false, cell), do: Owl.Data.tag(cell, :yellow) |
| 89 | +defp apply_cell_color(_, cell), do: cell |
| 90 | +``` |
10 | 91 |
|
11 | 92 | ## Technical Details |
12 | 93 |
|
13 | | -[Specific technical details and considerations] |
| 94 | +### Style Precedence Chain |
| 95 | + |
| 96 | +1. Explicit style in Context metadata (highest priority) |
| 97 | +2. CLI flags (`--cli-style`, `--cli-no-ansi`) |
| 98 | +3. Environment variables (`NO_COLOR`, `ARCA_STYLE`) |
| 99 | +4. Test environment detection (`MIX_ENV=test`) |
| 100 | +5. TTY availability check (lowest priority) |
| 101 | + |
| 102 | +### Output Types |
| 103 | + |
| 104 | +- **Messages**: `:success`, `:error`, `:warning`, `:info`, `:text` |
| 105 | +- **Structured**: `:table`, `:list` |
| 106 | +- **Interactive**: `:spinner`, `:progress` |
| 107 | + |
| 108 | +### Table Rendering |
| 109 | + |
| 110 | +Tables support two formats: |
| 111 | +1. List of lists: `[["Name", "Age"], ["Alice", "30"]]` |
| 112 | +2. List of maps: `[%{name: "Alice", age: 30}]` |
| 113 | + |
| 114 | +Headers are indicated via the `has_headers: true` option. |
| 115 | + |
| 116 | +### Global CLI Options |
| 117 | + |
| 118 | +```elixir |
| 119 | +options: [ |
| 120 | + cli_style: [ |
| 121 | + value_name: "STYLE", |
| 122 | + long: "--cli-style", |
| 123 | + help: "Set output style (ansi, plain, json, dump)", |
| 124 | + parser: fn s -> |
| 125 | + case String.downcase(s) do |
| 126 | + style when style in ["ansi", "plain", "json", "dump"] -> |
| 127 | + {:ok, String.to_atom(style)} |
| 128 | + _ -> |
| 129 | + {:error, "Invalid style. Must be one of: ansi, plain, json, dump"} |
| 130 | + end |
| 131 | + end |
| 132 | + ] |
| 133 | +], |
| 134 | +flags: [ |
| 135 | + cli_no_ansi: [ |
| 136 | + long: "--cli-no-ansi", |
| 137 | + help: "Disable ANSI colors in output (same as --cli-style plain)" |
| 138 | + ] |
| 139 | +] |
| 140 | +``` |
14 | 141 |
|
15 | 142 | ## Challenges & Solutions |
16 | 143 |
|
17 | | -[Challenges encountered during implementation and how they were resolved] |
| 144 | +### Challenge 1: ANSI Codes Breaking Table Column Widths |
| 145 | + |
| 146 | +**Problem**: Raw ANSI escape sequences in cell content caused Owl to miscalculate column widths, resulting in misaligned tables. |
| 147 | + |
| 148 | +**Solution**: Use `Owl.Data.tag/2` instead of raw `IO.ANSI` functions. Owl's tagging system preserves the actual string length for width calculations while applying colors during rendering. |
| 149 | + |
| 150 | +### Challenge 2: Header Detection in Tables |
| 151 | + |
| 152 | +**Initial Approach**: Tried heuristic detection (checking if first row contains only strings). |
| 153 | + |
| 154 | +**Problem**: Unreliable and could misidentify data rows as headers. |
| 155 | + |
| 156 | +**Solution**: Explicit `has_headers: true` option in table output tuple. Commands explicitly indicate when first row contains headers. |
| 157 | + |
| 158 | +### Challenge 3: Test Pollution from Callbacks |
| 159 | + |
| 160 | +**Problem**: Callbacks registered in tests were leaking between test runs, causing intermittent failures with "[LEGACY]" prefixes appearing randomly. |
| 161 | + |
| 162 | +**Solution**: |
| 163 | +- Made polymorphic formatter test synchronous (`async: false`) |
| 164 | +- Added proper cleanup with `on_exit` callbacks |
| 165 | +- Save and restore Application environment in test setup/teardown |
| 166 | + |
| 167 | +### Challenge 4: Mix.env() Not Available in Production |
| 168 | + |
| 169 | +**Problem**: Initial implementation used `Mix.env()` for test detection, which isn't available in production builds. |
| 170 | + |
| 171 | +**Solution**: Use `System.get_env("MIX_ENV")` instead, which works in all environments. |
| 172 | + |
| 173 | +### Challenge 5: Backwards Compatibility |
| 174 | + |
| 175 | +**Problem**: Need to support both legacy string returns and new Context returns without breaking existing commands. |
| 176 | + |
| 177 | +**Solution**: Pattern-matched dispatch in `process_command_result/3` that detects return type and applies appropriate processing path. |
| 178 | + |
| 179 | +### Challenge 6: Style Naming Confusion |
| 180 | + |
| 181 | +**Problem**: Original names ("fancy", "plain", "dump") weren't intuitive or standard. |
| 182 | + |
| 183 | +**Solution**: Renamed to industry-standard terms: |
| 184 | +- `fancy` → `ansi` (clear indication of ANSI color support) |
| 185 | +- `plain` → `plain` (unchanged) |
| 186 | +- Added `json` for structured output |
| 187 | +- Kept `dump` for debugging |
| 188 | + |
| 189 | +## Migration Path |
| 190 | + |
| 191 | +Commands can be migrated incrementally: |
| 192 | + |
| 193 | +1. **Phase 1**: Existing commands continue returning strings (fully supported) |
| 194 | +2. **Phase 2**: New commands use Context pattern |
| 195 | +3. **Phase 3**: Gradually migrate existing commands as needed |
| 196 | + |
| 197 | +Example migration: |
| 198 | + |
| 199 | +```elixir |
| 200 | +# Before |
| 201 | +def handle(args, settings, optimus) do |
| 202 | + result = process_data() |
| 203 | + formatted = format_as_string(result) |
| 204 | + IO.puts(formatted) |
| 205 | + formatted |
| 206 | +end |
| 207 | + |
| 208 | +# After |
| 209 | +def handle(args, settings, optimus) do |
| 210 | + result = process_data() |
| 211 | + |
| 212 | + Ctx.new(:my_command, settings) |
| 213 | + |> Ctx.add_output({:info, "Processing complete"}) |
| 214 | + |> Ctx.add_output({:table, result, [has_headers: true]}) |
| 215 | + |> Ctx.complete(:ok) |
| 216 | +end |
| 217 | +``` |
| 218 | + |
| 219 | +## Performance Considerations |
| 220 | + |
| 221 | +- Context creation is lightweight (simple struct initialization) |
| 222 | +- Rendering is lazy - only happens when Output.render is called |
| 223 | +- Callback processing uses pattern matching for efficiency |
| 224 | +- No performance regression observed in testing (337 tests run in ~2.3s) |
| 225 | + |
| 226 | +## Testing Strategy |
| 227 | + |
| 228 | +- Unit tests for each renderer verify output format |
| 229 | +- Integration tests verify end-to-end command execution |
| 230 | +- Environment variable isolation prevents test pollution |
| 231 | +- Polymorphic callbacks tested with both string and Context inputs |
| 232 | +- Edge cases (nil output, malformed data) handled gracefully |
0 commit comments