Render Targets and Snapshots Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [x]) syntax for tracking.
Goal: Make explicit destinations deterministic through nested rendering and provide reusable snapshots. Architecture: Core stores an optional immutable protocol context; ext constructs targets and filters segments; art consumes context without depending on ext. Tech Stack: Rust, Arc, existing Console/Segment/Style and public HTML/SVG exporters. Spec: Sections A and package impact.
Global Constraints¶
All index constraints apply: Rust 1.90, unchanged default rendering, no required Renderable methods, no new ConsoleOptions/ImageOptions fields. The attachment trait lives in protocol.rs; its data-only implementation/private storage is in Console.
Review Focus¶
Nested containers, zero viewports, disabled hyperlinks with ANSI colour, mixed export destinations and stable style-only snapshots are pinned by A1–A3 below.
A1: Protocol context and explicit target adapter¶
Files: Modify crates/rich/src/protocol.rs, crates/rich/src/console.rs; create crates/rich-ext/src/target.rs, crates/rich-ext/tests/targets.rs; export from crates/rich-ext/src/lib.rs; document in crates/rich-ext/README.md.
Interfaces: Define these new protocol types; all fields are immutable snapshots:
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Support { Unsupported, Inferred, Confirmed }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TargetCapabilities {
pub width: usize, pub height: usize,
pub color_system: Option<rich::ColorSystem>,
pub interactive: bool, pub unicode: bool,
pub hyperlinks: bool, pub sixel: Support,
}
pub trait RenderEnvironment: Send + Sync {
fn capabilities(&self) -> TargetCapabilities;
}
pub trait ConsoleEnvironment {
fn set_render_environment(&mut self, value: Option<std::sync::Arc<dyn RenderEnvironment>>);
fn render_environment(&self) -> Option<&dyn RenderEnvironment>;
}
Inside core use crate::color::ColorSystem instead of the external rich prefix.
Ext defines TargetKind::{Terminal,PlainStream,Capture,Html,Svg,Custom} and
RenderTarget::new(kind: TargetKind, caps: TargetCapabilities, theme: rich::Theme)
-> RenderTarget, console(&self) -> rich::Console,
segments(&self, value: &dyn rich::Renderable) -> Vec<rich::Segment> and
text(&self, value: &dyn rich::Renderable) -> String. TargetCapabilities is
Copy; RenderTarget implements RenderEnvironment. No fallible allocation API is
invented: dimensions are usize, and zero short-circuits before rendering.
- [x] Add an integration test in tests/targets.rs that builds a width-zero Capture with
height 8,
color_system=None, all capabilities false/Unsupported and the default theme; use a Renderable whose rich_render panics. Assert the following:
struct PanicRenderable;
impl rich::Renderable for PanicRenderable {
fn rich_render(&self, _: &rich::Console, _: &rich::ConsoleOptions) -> Vec<rich::Segment> {
panic!("zero target must not render its child")
}
}
let panic_renderable = PanicRenderable;
assert!(target.segments(&panic_renderable).is_empty());
assert_eq!(target.text(&panic_renderable), "");
Add an ANSI Capture of linked red Text: assert red SGR remains, OSC8 is absent
when hyperlinks=false, and the same target remains identical under conflicting
environment values in isolated subprocess tests.
- [x] Run env -u NO_COLOR cargo test -p rs-rich-ext --test targets; expect unresolved new APIs before implementation.
- [x] Implement the protocol context and fully configure ConsoleBuilder, including
width/height, force_terminal, no_color, color_system, ascii_only, legacy_windows,
safe_box, emoji, highlight and theme. Leave the old constructor context None.
Render with explicit ConsoleOptions; apply policy on the resulting segments:
segments.retain(|segment| !segment.control || allow_terminal_controls);
if !caps.hyperlinks {
for segment in &mut segments {
segment.style = segment.style.as_ref().map(|style| style.update_link(None));
}
}
allow_terminal_controls is true only for an interactive Terminal/Custom,
never Capture/PlainStream/Html/Svg. Normalise PlainStream to colourless,
noninteractive, no hyperlinks/no Sixel; exports preserve requested styles but
disable interactive controls/protocols. text serialises filtered segments.
- [x] Rerun targets plus env -u NO_COLOR cargo test -p rs-rich --test golden; existing output must remain identical. Run index commit gates and commit feat: add explicit render environments and targets.
A2: Nested art, detection provenance and CLI destination routing¶
Files: Modify crates/rich-art/src/image_art.rs, crates/rich-ext/src/target.rs, crates/rich-cli/src/main.rs, crates/rich-cli/src/doctor.rs; create crates/rich-cli/src/render_target.rs, crates/rich-cli/tests/render_targets.rs; document docs/cli.md and crates/rich-art/README.md.
Interfaces: Art adds ImageArt::render_with_environment(&self, console: &Console, options: &ConsoleOptions, environment: &dyn RenderEnvironment) -> Result<Vec<Segment>, ImageArtError>. Ext defines CapabilityOrigin::{Configured,Detected,Inferred,Default} and DetectedCapabilities { capabilities: TargetCapabilities, origins: Vec<(String,CapabilityOrigin)> }. Detection consumes injected observations (TargetObservations { width: Option<usize>, height: Option<usize>, is_terminal: bool, color_system: Option<rich::color::ColorSystem>, unicode: bool, hyperlinks: bool, sixel_hint: Support }); an optional CLI factory gathers actual stream observations once. A pure resolve_capabilities(observed: TargetObservations, overrides: TargetOverrides) -> DetectedCapabilities applies optional per-field overrides, with destination restrictions still enforced by RenderTarget. TargetOverrides derives Default and contains width:Option<usize>, height:Option<usize>, interactive:Option<bool>, color_system:Option<Option<rich::color::ColorSystem>>, unicode:Option<bool>, hyperlinks:Option<bool>, sixel:Option<Support>; nested colour Option distinguishes unspecified from explicitly colourless. Missing observed dimensions resolve to 80x25 with Default provenance. Configured overrides take precedence over observations; inferred Sixel remains Inferred, never Confirmed.
- [x] Add a nested Panel test with an ImageArt::Auto and an attached no-Sixel context; compare output with an explicit ASCII/Blocks rendering for that target:
Construct expected using an identical Panel whose image mode is explicitly
selected; run the test with TERM changed only in its subprocess. Add styled
HTML/SVG assertions that contain no protocol/control bytes and direct explicit
Sixel rejection for nonterminal targets.
- [x] Run env -u NO_COLOR cargo test -p rs-rich-cli --test render_targets; expect missing target routing to fail.
- [x] Implement the branch below in the existing render entry, importing the extension trait; then adapt the shared capabilities to the existing art capability struct without probing the environment:
if let Some(environment) = console.render_environment() {
return self.render_with_environment(console, options, environment);
}
Continue with nested lookup through ConsoleEnvironment before the art legacy
detector. Convert the shared snapshot into existing RenderCapabilities; never
consult TERM when context exists. CLI builds separate targets for terminal,
capture and each export; doctor reports selected provenance. When adapting
errors, retain current ImageArtError and legacy infallible safe-text fallback.
Snapshot the selected output stream, not stdout when writing another stream.
- [x] Run default and all-feature render_targets tests and existing image/doctor
tests; prove forced/no-colour and lean behaviour. Run index commit gates and
commit feat: route nested art and CLI output through explicit targets.
A3: Public snapshot helpers¶
Files: Create crates/rich-ext/src/testing.rs, crates/rich-ext/tests/snapshots.rs, crates/rich-ext/examples/snapshot.rs; modify ext Cargo feature/include entries and lib.rs; document in docs/benchmarks.md.
Interfaces: RenderSnapshot::capture(target: &RenderTarget, renderable: &dyn Renderable) -> RenderSnapshot; fields schema_version: u32, width: usize, height: usize, plain: String, ansi: String, segments: Vec<SnapshotSegment>. SnapshotSegment derives Clone/Debug/PartialEq and has text:String, control:bool, foreground:Option<String>, background:Option<String>, attributes:Vec<String>, link:Option<String>. Colours are resolved lowercase six-digit RGB strings; attributes use a documented fixed order, not map iteration. SnapshotError wraps serde_json::Error and implements Error; testing enables the optional serde/serde_json dependencies. to_json(&self) -> Result<String, SnapshotError> and diff(&self, other: &Self) -> Option<String>. The opt-in testing feature includes serialization; normal ext users do not pay for it.
- [x] Add a test capturing red and blue Text with identical visible text:
assert_eq!(red.plain, blue.plain);
assert_ne!(red.segments, blue.segments);
assert!(red.diff(&blue).unwrap().contains("foreground"));
assert_eq!(red.to_json().unwrap(), red_again.to_json().unwrap());
Also assert exact fixed dimensions, no host-generated IDs, nested capture,
plain/ANSI consistency and a useful line diff for changed visible text.
- [x] Run env -u NO_COLOR cargo test -p rs-rich-ext --features testing --test snapshots; expect feature/API unavailable first.
- [x] Implement capture from A1's single filtered segment stream. Serialise explicit
fields in fixed order with schema_version=1; preserve line endings, link values
and control metadata. Compare ordered fields, then produce a line-oriented diff
or metadata path diff; do not normalise away style changes.
- [x] Run the snapshot example twice and byte-compare output. Run index commit
gates plus the testing feature tests; commit feat: add deterministic downstream render snapshots.