Skip to content

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:
assert!(!output.contains("\x1bP"));
assert_eq!(output, expected_for_same_panel_and_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.