ADR-0003: CLI presentation boundary and rs-rich-cli¶
- Status: Accepted (2026-10-04). Not built yet, now a follow-up: a generated JSON Schema per command (§3), and JSON and plain contract snapshots for every command (
ods erd, most ofods lineageandods mcphave none). The output envelope is at 1.0 (#342). - Date: 2026-09-24 (amended 2026-09-29:
rs-rich0.0.9; 2026-10-05: hyperlinks, §7, accepted 2026-10-05) - Issues: #108 (also #6, #21, #22, #85, #107)
- Deciders: @n1ckyb
Context¶
Every ODS module has to show structured results to three kinds of consumer:
- People at a terminal. They want State plans, explanations, ERD summaries and review findings as readable tables, trees, panels and diffs, with progress on long runs.
- Scripts, CI and logs. They want stable, uncoloured, line-oriented text that is easy to grep and diff (#85, #88).
- Machines (the VS Code extension #107, CI PR reports, the REST API #96). They want a versioned JSON contract.
AGENTS.md rule 7 says presentation stays separate from logic. Rule 4 says every decision
must be explainable in both human and JSON form. Issue #108 asks us to standardise on
buchochelliq-labs/rs-rich-cli
rather than build our own terminal renderer. It also asks us to use ODS as a real consumer
of rs-rich-cli ("dogfood" it) and to report the primitives it is missing.
What rs-rich-cli offers (surveyed 2026-09-24, updated for the 0.0.13 release)¶
- A Rust port of Python
rich. The library is thers-richpackage, imported asrich; its extensions arers-rich-ext. It is MIT-licensed. - It is published on crates.io. The survey started on
rs-rich0.0.6. The proof of concept moved to 0.0.7, the core library of the 0.0.11 release cohort, once it was published. ODS now uses 0.0.9, the library of the 0.0.13 release. Pre-1.00.0.xversions may break the API in every release, and Cargo's^0.0.xrequirement pins the exact patch version. - It has
Consolewith a builder that setswidth,force_terminal,color_systemandno_color. It honoursNO_COLORand detects whether output goes to a terminal. From 0.0.7 its colour detection also honoursTERM=dumb/unknown(in 0.0.6 that only affected the pager). It can capture output (capture) and export it (export_text,export_svg), which lets tests pin their output exactly. - Renderables:
Table,Tree,Panel,Rule,Columns,Text, markup,Syntax,Markdown, JSON/pretty printing,Progress,StatusandLive.rs-rich-extadds diff, badges, diagnostics, a hyperlink helper and a clap help adapter. - It requires Rust 1.90; ODS currently requires 1.85. Its CI covers Linux, with Windows
exercised manually. macOS is untested, and the legacy Windows
cmd.execonsole is not supported. - It has 6 direct dependencies (
syntect,fancy-regex,pulldown-cmark,serde_json,terminal_size,anstyle-query). 0.0.6 pulled in 60 unique crates in total; 0.0.7 loads only syntect's bundled dumps, which removes 11 of them (49 remain), includingyaml-rustandplist. From 0.0.9,syntect(Syntax) andpulldown-cmark(Markdown) sit behind the default-onsyntaxandmarkdownfeatures. ODS uses neither, so it depends onrs-richwithdefault-features = falseand no features; that dropssyntect,pulldown-cmark,bincode1.x,fancy-regex0.16 and six smaller crates (10 in all) fromCargo.lock.
Spike (scratch crate, not committed)¶
A release binary that renders one Table through export_text with no_color
produced output that is correct and can be pinned exactly (fixed width).
| Release | Stripped | |
|---|---|---|
| Empty Rust binary | 0.44 MB | 0.35 MB |
Plus rs-rich 0.0.6, one table |
4.25 MB | 3.41 MB |
Plus rs-rich 0.0.7, one table |
4.30 MB | 3.45 MB |
Most of the growth comes from syntect and its bundled syntax and theme definitions.
Measured on the real ods binary (cargo build --release -p ods-cli, Linux x86_64,
stripped with strip) when moving to 0.0.9:
ods |
Release | Stripped |
|---|---|---|
rs-rich 0.0.7 (default features) |
39.15 MB | 29.18 MB |
rs-rich 0.0.9, default-features = false |
38.98 MB | 29.04 MB |
The saving is only about 0.14 MB, not the 3 MB the spike suggested. ODS never renders
Syntax or Markdown, so the linker had already discarded syntect's code and data from
ods. The feature change mainly shortens the build and the dependency tree.
cargo deny check licenses with ODS's deny.toml accepted every crate in the
dependency tree.
Options considered¶
Option A: modules call rs-rich directly¶
Each module crate builds rich renderables and prints them itself. - ✅ Least code. - ❌ Breaks AGENTS.md rules 2 and 7. Terminal code would sit in domain crates. - ❌ JSON and plain output would be separate code paths per module, and would drift. - ❌ Every rs-rich 0.0.x API break would touch every module.
Option B: typed result models, with a presentation layer at the CLI edge (chosen)¶
Modules return typed, serialisable result models. ods-cli owns presentation. It maps
each result to a small ODS-owned view tree, and interchangeable backends render that
tree (rich, plain). JSON output serialises the result model directly.
- ✅ Domain crates never see terminal APIs. The server and the LSP reuse the same result
models.
- ✅ rs-rich is confined to a single backend module, so an API break touches one place.
- ✅ JSON stays a real, versioned data contract and is not a by-product of rendering.
- ❌ One extra mapping step per command (result model to view tree).
- ❌ The view tree duplicates a small subset of rich's vocabulary.
Option C: a full ODS rendering framework (no rs-rich)¶
- ✅ No external API churn.
- ❌ Rebuilds layout, width handling, Unicode cell widths and colour detection that rs-rich already has. #108 rules this out explicitly.
Option D: render JSON first, then turn the JSON into a view generically¶
- ✅ One code path.
- ❌ A generic JSON-to-table view gives poor human output (no grouping, emphasis or reason chains). It also couples the JSON shape to how things look.
Decision¶
Adopt Option B. The rules below define the boundary.
1. Three layers¶
graph LR
mod["module crate<br/>(ods-state, …)"] -- "result model (Serialize)" --> cli
subgraph cli["ods-cli (composition root)"]
present["present::command<br/>result → ViewNode tree"]
rich["backend::rich<br/>(rs-rich)"]
plain["backend::plain"]
json["emit: JSON envelope<br/>(serde_json)"]
end
present --> rich
present --> plain
cli -- "result model" --> json
- Result model. This is a
serde::Serializetype owned by the module (for exampleods_state::ExecutionPlan, or a plan-explanation type). It follows AGENTS.md conventions:#[non_exhaustive], snake_case, deterministic ordering. It carries reasons and evidence as data, never as formatted strings. - View tree.
ods-clidefines a smallViewNodeenum inods-cli::present:Heading,Paragraph,KeyValue,Table,Tree,Notice { level }andGroup. Further nodes, such asListandDiff, are added when a command first needs them. ATablemay split its rows into sections and carry a footer row of totals; both are decoration that repeats nothing new, so the plain backend leaves them out and its tables stay data rows only. APanel { title, level, body }frames any view under a title (one failure and why); the plain backend prints the title as a line and the body as it would anyway. Views carry semantic styles (Emphasis,Muted,Added,Removed,Warning,Error,Success,Code) and never raw colours or markup. The mapping from result to view is a pure function and is unit-tested. - Backends render a whole view to a
String, whichpresent::emitwrites to stdout. Command results are small, and rs-rich can only be pointed at an ODS-chosen stream by capturing its output. There is noRenderertrait yet; one is introduced if a streaming backend (for example live progress) needs it. rich: mapsViewNodetors-richrenderables. Styled text is built from spans withText::append, so user data is never parsed as rich markup. Semantic styles resolve through arich::theme::Themeof named styles (ods.added,ods.warning, …), so colours live in one place and can be overridden later. This is the only module that importsrich/rich_ext.plain: ODS's own implementation, with no ANSI escapes, no box drawing and ASCII only. It printskey: valuelines, tab-separated tables with a header row, and indented trees. Its output is stable across terminals and platforms.- JSON (in
present::emit): serialises the result model inside the envelope, not the view. - Both text backends replace terminal control characters (ESC and other C0/C1 controls) in displayed text, because node names, SQL and warehouse errors are untrusted data.
The view tree stays inside ods-cli until a second consumer needs it, such as
ods-server rendering HTML. At that point it is extracted to a foundation-layer
ods-view crate, which requires an amendment to ADR-0001.
2. Output modes and flags (global, implemented in #6)¶
| Flag | Values | Default |
|---|---|---|
--output / -o |
human, plain, json |
human when stdout is a terminal, otherwise plain |
--json |
shorthand for --output json |
|
--color |
auto, always, never |
auto (honours NO_COLOR, TERM=dumb and redirection); always overrides NO_COLOR |
--width |
integer | detected; 100 when not a terminal |
- Streams. Results go to stdout. Logs, progress, spinners and warnings go to
stderr. Progress and spinners appear only in
humanmode when stderr is a terminal. JSON mode never writes anything else to stdout. - Exit codes are unaffected by the output mode. ADR-0004 defines them (#6).
3. JSON contract¶
Every --json response is a single envelope object:
{
"schema_version": {"major": 1, "minor": 0},
"command": "state.plan",
"ods_version": "0.1.0",
"result": { "...": "command-specific result model" },
"diagnostics": [{"level": "warning", "code": "ODS-W0001", "message": "..."}]
}
schema_version versions the envelope and the result models together, with the same
compatibility rules as ods_core::SchemaVersion.
- On failure, result is null and the error is a diagnostic, which may include an
optional hint. The envelope is still the only thing on stdout (ADR-0004 §4).
- A command's result shape is a public contract. Breaking changes need a major version
bump and a CHANGELOG entry (#101).
- A JSON Schema is generated for each command. We will evaluate schemars against a
hand-written schema in #6.
4. How rs-rich is consumed¶
- The dependency is
rs-richfrom crates.io, at a published version only. Git dependencies and other registries are rejected bydeny.toml[sources]; a path dependency would be caught in review, because cargo-deny allows workspace paths. It is declared once in[workspace.dependencies]and used only byods-cli.rs-rich-extis added only when we need a specific feature (for examplediff). - ODS's MSRV rises from 1.85 to 1.90. This lands in the PR that adds the dependency;
Cargo.tomland the CImsrvjob must change together. - Upgrades are deliberate. Because
^0.0.xpins the patch version, each upgrade is its own PR, and it must update therichbackend snapshots. scripts/check-layering.pygains a check that no crate other thanods-clidepends onrs-rich*.- Data never reaches rs-rich as a string. From 0.0.7, a plain string passed as a table
header, cell or tree label is parsed as markup, as is
Table::title. The rich backend therefore passesTextvalues everywhere, including the table title (Table::title_text, from 0.0.8). Regression tests pin this behaviour, including names such as[bold]xanda\. - Features.
rs-richis built withdefault-features = false. A feature is enabled only when the backend first renders something that needs it (for examplesyntaxforSyntax).
5. Testing¶
instasnapshots for every command injsonandplainmodes, which are the contracts. They live incrates/ods-cli/src/snapshots/. The build's ownods_versionis replaced with[ods-version], so releases don't churn contract snapshots.richmode snapshots use a pinnedConsole(width(100),force_terminal(true),ColorSystem::Standard) captured viacapture. There are fewer of them, and they live incrates/ods-cli/src/snapshots/rich/, apart from the contract snapshots, because rs-rich upgrades may legitimately change them.- The result-to-view mapping gets unit tests. The backends get tests with fixture view trees.
- The CI matrix already covers macOS and Windows, which also covers rs-rich on the platforms its own CI does not test.
6. Missing features and issues to report to rs-rich-cli¶
To be filed as issues in buchochelliq-labs/rs-rich-cli. Status is as of rs-rich 0.0.9.
Items keep their numbers when they are resolved.
Open - (2) A lower MSRV, or a documented MSRV policy for the library crate, separate from the CLI's image and network features. The 1.90 floor is driven by the CLI tree. - (3) macOS in CI, so downstream consumers get a support guarantee on that platform. - (4) A 0.1 / API-stability roadmap, so ODS can move off exact patch pins.
Still open in 0.0.9: the crate's rust-version is 1.90 and its README states no MSRV
policy, macOS CI or 0.1 roadmap.
Partly addressed
- (5) The strings-as-markup change in 0.0.7 compiles silently. Plain strings passed as
table headers, cells or tree labels changed from literal text to markup, but code
written for 0.0.6 still compiles. Any caller passing data is now open to markup
injection, and only a behavioural test catches it (ODS's did). Suggest making the
change visible at compile time, for example by removing the implicit
From<&str>/From<String> for Cell conversions or adding explicit
Cell::markup/Cell::plain constructors, or at least flagging it as a security note
in the migration guide. (Found while reviewing the 0.0.11 release.)
In 0.0.9: the conversions still exist and still parse markup, but they are now
documented as markup, and the docs recommend passing Text for data. A compile-time
check exists only through the separate rs-rich-macros crate (markup checked at
compile time), which does not stop a runtime string from reaching a cell. ODS keeps
passing Text and relies on its regression tests.
- (6) markup::escape is not round-trip safe for a trailing backslash. Escaping a\
and then parsing it renders a\\. (Found while reviewing the 0.0.11 release.)
In 0.0.9: escape was rewritten to handle runs of backslashes, and a backslash
before a tag (a\[b]) round-trips. A trailing backslash still does not:
escaping a\ gives a\\, which still renders a\\ (checked against 0.0.9 with
markup::render). This is documented, deliberate parity with Python rich, which
doubles a lone trailing backslash so it cannot escape appended markup; it also means
escape cannot be undone for such input. ODS does not use escape: data reaches
rs-rich as Text.
Resolved in 0.0.9 (ODS moved from 0.0.7)
- (1) Optional heavy dependencies: syntect (Syntax) and pulldown-cmark
(Markdown) are behind the default-on syntax and markdown features. With
default-features = false, bincode 1.x (RUSTSEC-2025-0141) and the duplicate
fancy-regex 0.16 are gone, and the advisory ignore was removed from deny.toml.
- (7) No literal-text table title (resolved in 0.0.8): Table::title_text(Text) takes
the title literally. The rich backend now uses it instead of printing the title as a
line of its own, so a title is centred over its table. Plain and JSON output are
unchanged.
Resolved in 0.0.7
- Styled table cells and tree labels (reported during the proof of concept):
add_row_text, add_column_text and Tree::new/add taking impl Into<Cell> now
carry Text, so the rich backend keeps tones in cells and tree labels.
- TERM=dumb in colour detection (reported during the proof of concept): now
honoured, as is TERM=unknown. ODS keeps its own check so the §2 contract holds
independently of upstream and stays unit-testable.
- yaml-rust (RUSTSEC-2024-0320) is no longer in the tree, and its ignore was removed.
Named semantic styles are not a gap: rich::theme::Theme already maps style names to
styles.
7. Hyperlinks (amendment, accepted 2026-10-05)¶
A terminal that follows OSC 8 hyperlinks lets people open a file ODS names (a run's journal, later a failing model's file) without copying its path. Links are data that reaches the terminal inside an escape sequence, so they are held to the same rules as the rest of the output:
- A
Spanmay carry aLink, and a link is only ever a URL ODS built itself: a local file (Link::file, afile:URL of the path made absolute) or a page of ODS's own documentation (Link::docs, a fixed page undersite_url). There is no constructor from text, so nothing a project, an engine or a warehouse says becomes a link, and links can't point anywhere ODS didn't choose. - Encoded, never raw. The URL is built with the
urlcrate, which percent-encodes control characters. rs-rich writes a link's URL into the escape as is, so a raw string could end it early (ESC \,BEL) and inject terminal sequences. A test pins that a path holding them still opens and closes exactly one link. - Only where it can be followed. The rich backend writes links when
supports-hyperlinkssays stdout is a terminal that follows them (it honoursFORCE_HYPERLINK); otherwise it drops them before rendering. rs-rich writes them only with colour on. The plain backend and JSON never write them; JSON already carries the paths as data. - A link never says more than its text. The text shows the same path or page, so output reads the same without links, and recordings and snapshots don't change.
First use: the journal path of ods state run (and build, seed, snapshot, test,
retry) and ods state history --run. Linking a failing model's file needs the project
directory in the failure view, and a docs link per error code needs errors rendered as
views; both are left for later.
New dependencies: url (MIT OR Apache-2.0, already in the tree) and
supports-hyperlinks (Apache-2.0).
Consequences¶
- Positive:
- Domain crates stay free of terminal code.
- JSON is a real, versioned API that the VS Code extension, CI reports and the server can build on.
- rs-rich churn is isolated to one module, and the exit strategy is to swap that one backend.
- Plain output is stable, which suits CI logs.
- Negative / trade-offs:
- The MSRV rises to 1.90.
- The
odsbinary grows by the rendering code (the spike measured about 3 MB withsyntect; point 6.1, resolved in 0.0.9, removedsyntectfrom the tree, though inodsthe linker had already dropped most of it). - Each command needs a presentation mapping.
- Two sets of snapshots have to be maintained.
- Follow-up work:
- #6: global output flags, the envelope, exit codes and stream discipline.
- Proof-of-concept PR for #108:
ods-cli::presentplus therich/plain/jsonbackends, renderingods versionand a fixtureExecutionPlan, with the MSRV bump. It will re-render the realods state plan/explainonce #20 and #21 land. - Extend
scripts/check-layering.pyto confiners-rich*toods-cli. - File the issues listed in section 6 in rs-rich-cli.
References¶
-
108; AGENTS.md rules 2, 4 and 7; ADR-0001 (module boundaries); ADR-0002 (stack)¶
- rs-rich-cli:
README.md,docs/getting-started.md,crates/rich/src/console.rs - Python
richdocumentation (upstream API that rs-rich ports)