Skip to main content

ftui_web/
runtime_options.rs

1//! Runtime option and theming mutation API with atomic validation (bd-2vr05.13.5).
2//!
3//! # Why this exists
4//!
5//! Production embedders of the FrankenTermJS web surface need to reconfigure a
6//! live terminal **without re-instantiating it** — exactly the `term.options`
7//! mutation surface that real `xterm.js` integrators depend on (cursor style,
8//! scrollback depth, screen-reader mode, renderer backend, theme palette, …).
9//!
10//! This module is the **single host-agnostic source of truth** for that option
11//! schema and its mutation semantics. It is pure and deterministic: identical
12//! `(current options, patch, capability profile)` inputs always produce an
13//! identical [`RuntimeOptionUpdate`] outcome and an identical structured-log
14//! serialisation, so a fixed capability profile yields reproducible behaviour
15//! across the rendering backends introduced by `bd-2vr05.8.1`.
16//!
17//! ```text
18//!  JSON patch ──parse──> RuntimeOptionPatch ─┐
19//!  (or typed builder) ───────────────────────┤
20//!                                            ├─ apply_patch(caps) ─┬─ Applied  ─> field diffs + repaint/reinit flags
21//!  OptionCapabilityProfile ──────────────────┘  (atomic: all or    └─ Rejected ─> per-field errors, options UNCHANGED
22//!                                                 nothing commits)
23//! ```
24//!
25//! ## Atomicity & rollback
26//!
27//! [`RuntimeOptions::apply_patch`] validates the **entire** candidate state
28//! (schema range checks + capability gating) before committing anything. If any
29//! field is invalid it returns a [`RuntimeOptionUpdate`] carrying *every*
30//! offending field's error and leaves `self` byte-for-byte unchanged — there is
31//! no partial application. On success it commits atomically and reports the
32//! exact set of changed fields plus the renderer/engine synchronisation that the
33//! host must perform ([`RuntimeOptionUpdate::requires_repaint`] /
34//! [`RuntimeOptionUpdate::requires_renderer_reinit`]).
35//!
36//! ## Capability / profile gating
37//!
38//! A value can be *schema-valid* yet *unsupported by the active engine*: e.g. a
39//! `WebGpu` renderer request when the host only advertises Canvas, or a
40//! scrollback depth beyond the host's memory budget. Those are reported as
41//! gating errors distinct from out-of-range schema errors, so a host can tell
42//! "you asked for something impossible here" apart from "you asked for nonsense".
43//!
44//! The colour swaps themselves stay host-side (browser CSS / canvas), keeping
45//! this module free of any dependency on `ftui-style` — the same layering rule
46//! the accessibility preference policies follow.
47
48use core::fmt;
49
50/// Hard ceiling on scrollback retention, independent of the host's advertised
51/// budget. Guards against absurd payloads even when a host reports a very large
52/// `max_scrollback_lines`.
53pub const SCROLLBACK_HARD_MAX: u32 = 1_000_000;
54
55/// Minimum WCAG contrast ratio expressible by the option, ×100 (`1.0:1`).
56pub const MIN_CONTRAST_RATIO_X100: u16 = 100;
57/// Maximum WCAG contrast ratio expressible by the option, ×100 (`21:1`, the
58/// largest ratio achievable between sRGB black and white).
59pub const MAX_CONTRAST_RATIO_X100: u16 = 2100;
60
61/// Minimum supported tab stop width, in columns.
62pub const MIN_TAB_WIDTH: u8 = 1;
63/// Maximum supported tab stop width, in columns.
64pub const MAX_TAB_WIDTH: u8 = 16;
65
66/// Terminal cursor presentation style (parity with `xterm.js` `cursorStyle`).
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum CursorStyle {
69    /// Full-cell block cursor.
70    Block,
71    /// Underline cursor.
72    Underline,
73    /// Vertical bar cursor.
74    Bar,
75}
76
77impl CursorStyle {
78    /// Stable lowercase token used in serialisation and JSON parsing.
79    #[must_use]
80    pub const fn as_token(self) -> &'static str {
81        match self {
82            Self::Block => "block",
83            Self::Underline => "underline",
84            Self::Bar => "bar",
85        }
86    }
87
88    /// Parse from a stable token; `None` for an unknown style.
89    #[must_use]
90    pub fn from_token(token: &str) -> Option<Self> {
91        match token {
92            "block" => Some(Self::Block),
93            "underline" => Some(Self::Underline),
94            "bar" => Some(Self::Bar),
95            _ => None,
96        }
97    }
98}
99
100impl fmt::Display for CursorStyle {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.write_str(self.as_token())
103    }
104}
105
106/// Rendering backend selection (parity with `xterm.js` renderer addons, plus the
107/// WebGPU-primary backend introduced by `bd-2vr05.8.1`).
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub enum RendererType {
110    /// DOM-cell renderer (most compatible, slowest).
111    Dom,
112    /// 2D canvas renderer.
113    Canvas,
114    /// WebGL renderer.
115    WebGl,
116    /// WebGPU renderer (primary high-performance backend).
117    WebGpu,
118}
119
120impl RendererType {
121    /// Stable lowercase token used in serialisation and JSON parsing.
122    #[must_use]
123    pub const fn as_token(self) -> &'static str {
124        match self {
125            Self::Dom => "dom",
126            Self::Canvas => "canvas",
127            Self::WebGl => "webgl",
128            Self::WebGpu => "webgpu",
129        }
130    }
131
132    /// Parse from a stable token; `None` for an unknown renderer.
133    #[must_use]
134    pub fn from_token(token: &str) -> Option<Self> {
135        match token {
136            "dom" => Some(Self::Dom),
137            "canvas" => Some(Self::Canvas),
138            "webgl" => Some(Self::WebGl),
139            "webgpu" => Some(Self::WebGpu),
140            _ => None,
141        }
142    }
143}
144
145impl fmt::Display for RendererType {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.write_str(self.as_token())
148    }
149}
150
151/// A packed `0xRRGGBBAA` theme colour. Host-agnostic: the actual swap is applied
152/// by the browser/canvas, this is pure intent.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
154pub struct ThemeColor(pub u32);
155
156impl ThemeColor {
157    /// Construct from individual sRGB channels and alpha.
158    #[must_use]
159    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
160        Self(((r as u32) << 24) | ((g as u32) << 16) | ((b as u32) << 8) | (a as u32))
161    }
162
163    /// Construct an opaque colour from sRGB channels.
164    #[must_use]
165    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
166        Self::rgba(r, g, b, 0xFF)
167    }
168
169    /// Red channel.
170    #[must_use]
171    pub const fn r(self) -> u8 {
172        (self.0 >> 24) as u8
173    }
174    /// Green channel.
175    #[must_use]
176    pub const fn g(self) -> u8 {
177        (self.0 >> 16) as u8
178    }
179    /// Blue channel.
180    #[must_use]
181    pub const fn b(self) -> u8 {
182        (self.0 >> 8) as u8
183    }
184    /// Alpha channel (`0xFF` = fully opaque).
185    #[must_use]
186    pub const fn a(self) -> u8 {
187        self.0 as u8
188    }
189
190    /// `true` when the colour is fully opaque.
191    #[must_use]
192    pub const fn is_opaque(self) -> bool {
193        self.a() == 0xFF
194    }
195
196    /// Stable `#rrggbbaa` lowercase hex token used in serialisation.
197    #[must_use]
198    pub fn as_hex(self) -> String {
199        format!("#{:08x}", self.0)
200    }
201
202    /// Parse a `#rrggbb` (opaque) or `#rrggbbaa` hex token.
203    #[must_use]
204    pub fn from_hex(token: &str) -> Option<Self> {
205        let body = token.strip_prefix('#')?;
206        match body.len() {
207            6 => {
208                let rgb = u32::from_str_radix(body, 16).ok()?;
209                Some(Self((rgb << 8) | 0xFF))
210            }
211            8 => Some(Self(u32::from_str_radix(body, 16).ok()?)),
212            _ => None,
213        }
214    }
215}
216
217/// The full theme palette: anchor colours plus the 16 ANSI entries.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
219pub struct ThemePalette {
220    /// Default foreground colour.
221    pub foreground: ThemeColor,
222    /// Default background colour.
223    pub background: ThemeColor,
224    /// Cursor colour.
225    pub cursor: ThemeColor,
226    /// Cursor glyph (accent) colour.
227    pub cursor_accent: ThemeColor,
228    /// Selection highlight background.
229    pub selection_background: ThemeColor,
230    /// The 16 ANSI palette entries (`0..=7` normal, `8..=15` bright).
231    pub ansi: [ThemeColor; 16],
232}
233
234impl ThemePalette {
235    /// The default dark palette (parity with `xterm.js` defaults).
236    #[must_use]
237    pub const fn dark() -> Self {
238        Self {
239            foreground: ThemeColor::rgb(0xD0, 0xD0, 0xD0),
240            background: ThemeColor::rgb(0x00, 0x00, 0x00),
241            cursor: ThemeColor::rgb(0xFF, 0xFF, 0xFF),
242            cursor_accent: ThemeColor::rgb(0x00, 0x00, 0x00),
243            selection_background: ThemeColor::rgba(0xFF, 0xFF, 0xFF, 0x4D),
244            ansi: [
245                ThemeColor::rgb(0x2E, 0x34, 0x36), // black
246                ThemeColor::rgb(0xCC, 0x00, 0x00), // red
247                ThemeColor::rgb(0x4E, 0x9A, 0x06), // green
248                ThemeColor::rgb(0xC4, 0xA0, 0x00), // yellow
249                ThemeColor::rgb(0x34, 0x65, 0xA4), // blue
250                ThemeColor::rgb(0x75, 0x50, 0x7B), // magenta
251                ThemeColor::rgb(0x06, 0x98, 0x9A), // cyan
252                ThemeColor::rgb(0xD3, 0xD7, 0xCF), // white
253                ThemeColor::rgb(0x55, 0x57, 0x53), // bright black
254                ThemeColor::rgb(0xEF, 0x29, 0x29), // bright red
255                ThemeColor::rgb(0x8A, 0xE2, 0x34), // bright green
256                ThemeColor::rgb(0xFC, 0xE9, 0x4F), // bright yellow
257                ThemeColor::rgb(0x72, 0x9F, 0xCF), // bright blue
258                ThemeColor::rgb(0xAD, 0x7F, 0xA8), // bright magenta
259                ThemeColor::rgb(0x34, 0xE2, 0xE2), // bright cyan
260                ThemeColor::rgb(0xEE, 0xEE, 0xEC), // bright white
261            ],
262        }
263    }
264
265    /// The anchor colours that must remain fully opaque (a transparent default
266    /// foreground/background is meaningless for a terminal grid).
267    const fn anchors(self) -> [(&'static str, ThemeColor); 2] {
268        [
269            ("foreground", self.foreground),
270            ("background", self.background),
271        ]
272    }
273}
274
275impl Default for ThemePalette {
276    fn default() -> Self {
277        Self::dark()
278    }
279}
280
281/// The complete, validated runtime option state.
282///
283/// Construct with [`RuntimeOptions::default`] (safe defaults) and mutate via
284/// [`RuntimeOptions::apply_patch`]. Direct field assignment is allowed for
285/// host-side initialisation but bypasses validation — prefer the patch API for
286/// any host-driven mutation.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub struct RuntimeOptions {
289    /// Cursor presentation style.
290    pub cursor_style: CursorStyle,
291    /// Whether the cursor blinks.
292    pub cursor_blink: bool,
293    /// Scrollback retention in lines (`0` = no scrollback).
294    pub scrollback_lines: u32,
295    /// Tab stop width in columns (`1..=16`).
296    pub tab_width: u8,
297    /// Whether bare LF is treated as CRLF on output (`convertEol`).
298    pub convert_eol: bool,
299    /// Whether screen-reader assistive output mode is active.
300    pub screen_reader_mode: bool,
301    /// Whether bracketed paste is enabled.
302    pub bracketed_paste: bool,
303    /// Minimum enforced foreground/background contrast ratio, ×100
304    /// (`100..=2100`). `100` (1.0:1) disables enforcement.
305    pub minimum_contrast_ratio_x100: u16,
306    /// Active rendering backend.
307    pub renderer: RendererType,
308    /// Theme palette.
309    pub theme: ThemePalette,
310}
311
312impl Default for RuntimeOptions {
313    fn default() -> Self {
314        Self {
315            cursor_style: CursorStyle::Block,
316            cursor_blink: false,
317            scrollback_lines: 1_000,
318            tab_width: 8,
319            convert_eol: false,
320            screen_reader_mode: false,
321            bracketed_paste: true,
322            minimum_contrast_ratio_x100: MIN_CONTRAST_RATIO_X100,
323            // DOM is the most compatible backend and is advertised by every
324            // capability profile, so a freshly-defaulted terminal validates and
325            // boots on any engine; the host upgrades to Canvas/WebGL/WebGPU via
326            // an explicit option patch once it knows what the engine supports.
327            renderer: RendererType::Dom,
328            theme: ThemePalette::dark(),
329        }
330    }
331}
332
333impl RuntimeOptions {
334    /// Apply a (sparse) patch atomically against the given capability profile.
335    ///
336    /// Returns a [`RuntimeOptionUpdate`] describing the outcome. On any
337    /// validation or gating failure the update is *rejected*, every offending
338    /// field's error is reported, and `self` is left unchanged. On success the
339    /// patch is committed atomically and the changed fields plus required
340    /// renderer/engine synchronisation are reported.
341    ///
342    /// `correlation_id` is recorded for structured-log triage only; it does not
343    /// affect the decision.
344    #[must_use]
345    pub fn apply_patch(
346        &mut self,
347        patch: &RuntimeOptionPatch,
348        caps: &OptionCapabilityProfile,
349        correlation_id: &str,
350    ) -> RuntimeOptionUpdate {
351        // Build the candidate by overlaying the patch onto the current state.
352        let mut candidate = *self;
353        if let Some(v) = patch.cursor_style {
354            candidate.cursor_style = v;
355        }
356        if let Some(v) = patch.cursor_blink {
357            candidate.cursor_blink = v;
358        }
359        if let Some(v) = patch.scrollback_lines {
360            candidate.scrollback_lines = v;
361        }
362        if let Some(v) = patch.tab_width {
363            candidate.tab_width = v;
364        }
365        if let Some(v) = patch.convert_eol {
366            candidate.convert_eol = v;
367        }
368        if let Some(v) = patch.screen_reader_mode {
369            candidate.screen_reader_mode = v;
370        }
371        if let Some(v) = patch.bracketed_paste {
372            candidate.bracketed_paste = v;
373        }
374        if let Some(v) = patch.minimum_contrast_ratio_x100 {
375            candidate.minimum_contrast_ratio_x100 = v;
376        }
377        if let Some(v) = patch.renderer {
378            candidate.renderer = v;
379        }
380        if let Some(v) = patch.theme {
381            candidate.theme = v;
382        }
383
384        // Validate the whole candidate, collecting EVERY error (deterministic,
385        // complete diagnostics) before deciding.
386        let errors = candidate.validate(caps);
387        if !errors.is_empty() {
388            return RuntimeOptionUpdate {
389                correlation_id: correlation_id.to_owned(),
390                applied: false,
391                changes: Vec::new(),
392                errors,
393                requires_repaint: false,
394                requires_renderer_reinit: false,
395            };
396        }
397
398        // Compute the field-level diff against the (still-current) state.
399        let changes = self.diff(&candidate);
400        let requires_renderer_reinit = changes.iter().any(|c| c.field == FIELD_RENDERER);
401        let requires_repaint = changes.iter().any(|c| {
402            matches!(
403                c.field,
404                FIELD_RENDERER
405                    | FIELD_THEME
406                    | FIELD_SCROLLBACK
407                    | FIELD_TAB_WIDTH
408                    | FIELD_CURSOR_STYLE
409                    | FIELD_MIN_CONTRAST
410            )
411        });
412
413        // Commit atomically.
414        *self = candidate;
415
416        RuntimeOptionUpdate {
417            correlation_id: correlation_id.to_owned(),
418            applied: true,
419            changes,
420            errors: Vec::new(),
421            requires_repaint,
422            requires_renderer_reinit,
423        }
424    }
425
426    /// Validate this option state against a capability profile, returning every
427    /// violation. An empty vector means fully valid and supported.
428    #[must_use]
429    pub fn validate(&self, caps: &OptionCapabilityProfile) -> Vec<RuntimeOptionError> {
430        let mut errors = Vec::new();
431
432        // ── Schema range checks ────────────────────────────────────────────
433        if self.scrollback_lines > SCROLLBACK_HARD_MAX {
434            errors.push(RuntimeOptionError::OutOfRange {
435                field: FIELD_SCROLLBACK,
436                value: u64::from(self.scrollback_lines),
437                min: 0,
438                max: u64::from(SCROLLBACK_HARD_MAX),
439            });
440        }
441        if self.tab_width < MIN_TAB_WIDTH || self.tab_width > MAX_TAB_WIDTH {
442            errors.push(RuntimeOptionError::OutOfRange {
443                field: FIELD_TAB_WIDTH,
444                value: u64::from(self.tab_width),
445                min: u64::from(MIN_TAB_WIDTH),
446                max: u64::from(MAX_TAB_WIDTH),
447            });
448        }
449        if self.minimum_contrast_ratio_x100 < MIN_CONTRAST_RATIO_X100
450            || self.minimum_contrast_ratio_x100 > MAX_CONTRAST_RATIO_X100
451        {
452            errors.push(RuntimeOptionError::OutOfRange {
453                field: FIELD_MIN_CONTRAST,
454                value: u64::from(self.minimum_contrast_ratio_x100),
455                min: u64::from(MIN_CONTRAST_RATIO_X100),
456                max: u64::from(MAX_CONTRAST_RATIO_X100),
457            });
458        }
459
460        // Theme anchors must be opaque.
461        for (slot, color) in self.theme.anchors() {
462            if !color.is_opaque() {
463                errors.push(RuntimeOptionError::TransparentAnchorColor {
464                    field: FIELD_THEME,
465                    slot,
466                    alpha: color.a(),
467                });
468            }
469        }
470
471        // ── Capability / profile gating ────────────────────────────────────
472        // Gating only applies to otherwise schema-valid values, so a single
473        // field never reports both an out-of-range and a gating error (an
474        // over-hard-max scrollback is nonsense, not merely unsupported here).
475        if self.scrollback_lines <= SCROLLBACK_HARD_MAX
476            && self.scrollback_lines > caps.max_scrollback_lines
477        {
478            errors.push(RuntimeOptionError::CapabilityGated {
479                field: FIELD_SCROLLBACK,
480                detail: GatingDetail::ScrollbackBudgetExceeded {
481                    requested: self.scrollback_lines,
482                    budget: caps.max_scrollback_lines,
483                },
484            });
485        }
486        if !caps.supports_renderer(self.renderer) {
487            errors.push(RuntimeOptionError::CapabilityGated {
488                field: FIELD_RENDERER,
489                detail: GatingDetail::RendererUnsupported {
490                    requested: self.renderer,
491                },
492            });
493        }
494
495        errors
496    }
497
498    /// Compute the ordered field-level diff between `self` and `next`.
499    fn diff(&self, next: &Self) -> Vec<OptionFieldChange> {
500        let mut changes = Vec::new();
501        if self.cursor_style != next.cursor_style {
502            changes.push(OptionFieldChange::new(
503                FIELD_CURSOR_STYLE,
504                self.cursor_style.to_string(),
505                next.cursor_style.to_string(),
506            ));
507        }
508        if self.cursor_blink != next.cursor_blink {
509            changes.push(OptionFieldChange::new(
510                FIELD_CURSOR_BLINK,
511                self.cursor_blink.to_string(),
512                next.cursor_blink.to_string(),
513            ));
514        }
515        if self.scrollback_lines != next.scrollback_lines {
516            changes.push(OptionFieldChange::new(
517                FIELD_SCROLLBACK,
518                self.scrollback_lines.to_string(),
519                next.scrollback_lines.to_string(),
520            ));
521        }
522        if self.tab_width != next.tab_width {
523            changes.push(OptionFieldChange::new(
524                FIELD_TAB_WIDTH,
525                self.tab_width.to_string(),
526                next.tab_width.to_string(),
527            ));
528        }
529        if self.convert_eol != next.convert_eol {
530            changes.push(OptionFieldChange::new(
531                FIELD_CONVERT_EOL,
532                self.convert_eol.to_string(),
533                next.convert_eol.to_string(),
534            ));
535        }
536        if self.screen_reader_mode != next.screen_reader_mode {
537            changes.push(OptionFieldChange::new(
538                FIELD_SCREEN_READER,
539                self.screen_reader_mode.to_string(),
540                next.screen_reader_mode.to_string(),
541            ));
542        }
543        if self.bracketed_paste != next.bracketed_paste {
544            changes.push(OptionFieldChange::new(
545                FIELD_BRACKETED_PASTE,
546                self.bracketed_paste.to_string(),
547                next.bracketed_paste.to_string(),
548            ));
549        }
550        if self.minimum_contrast_ratio_x100 != next.minimum_contrast_ratio_x100 {
551            changes.push(OptionFieldChange::new(
552                FIELD_MIN_CONTRAST,
553                self.minimum_contrast_ratio_x100.to_string(),
554                next.minimum_contrast_ratio_x100.to_string(),
555            ));
556        }
557        if self.renderer != next.renderer {
558            changes.push(OptionFieldChange::new(
559                FIELD_RENDERER,
560                self.renderer.to_string(),
561                next.renderer.to_string(),
562            ));
563        }
564        if self.theme != next.theme {
565            changes.push(OptionFieldChange::new(
566                FIELD_THEME,
567                self.theme.foreground.as_hex(),
568                next.theme.foreground.as_hex(),
569            ));
570        }
571        changes
572    }
573
574    /// Stable, ordered CSS data-attribute pairs for the browser host. Mirrors
575    /// the accessibility surface's `web_dataset()` convention so generated DOM
576    /// markup is byte-for-byte reproducible.
577    #[must_use]
578    pub fn web_dataset(&self) -> [(&'static str, String); 3] {
579        [
580            ("data-ftui-cursor-style", self.cursor_style.to_string()),
581            ("data-ftui-renderer", self.renderer.to_string()),
582            (
583                "data-ftui-screen-reader",
584                self.screen_reader_mode.to_string(),
585            ),
586        ]
587    }
588
589    /// Serialise the full option snapshot to a single deterministic JSON line.
590    #[must_use]
591    pub fn to_jsonl(&self, correlation_id: &str) -> String {
592        format!(
593            concat!(
594                "{{\"event\":\"runtime_options_snapshot\",\"correlation_id\":\"{cid}\",",
595                "\"cursor_style\":\"{cs}\",\"cursor_blink\":{cb},\"scrollback_lines\":{sb},",
596                "\"tab_width\":{tw},\"convert_eol\":{ce},\"screen_reader_mode\":{srm},",
597                "\"bracketed_paste\":{bp},\"minimum_contrast_ratio_x100\":{mcr},",
598                "\"renderer\":\"{rt}\",\"theme_foreground\":\"{fg}\",\"theme_background\":\"{bg}\"}}"
599            ),
600            cid = escape_json(correlation_id),
601            cs = self.cursor_style,
602            cb = self.cursor_blink,
603            sb = self.scrollback_lines,
604            tw = self.tab_width,
605            ce = self.convert_eol,
606            srm = self.screen_reader_mode,
607            bp = self.bracketed_paste,
608            mcr = self.minimum_contrast_ratio_x100,
609            rt = self.renderer,
610            fg = self.theme.foreground.as_hex(),
611            bg = self.theme.background.as_hex(),
612        )
613    }
614}
615
616// Canonical field identifiers — single source of truth for diffs, errors, and
617// serialisation so the strings never drift between producers.
618const FIELD_CURSOR_STYLE: &str = "cursor_style";
619const FIELD_CURSOR_BLINK: &str = "cursor_blink";
620const FIELD_SCROLLBACK: &str = "scrollback_lines";
621const FIELD_TAB_WIDTH: &str = "tab_width";
622const FIELD_CONVERT_EOL: &str = "convert_eol";
623const FIELD_SCREEN_READER: &str = "screen_reader_mode";
624const FIELD_BRACKETED_PASTE: &str = "bracketed_paste";
625const FIELD_MIN_CONTRAST: &str = "minimum_contrast_ratio_x100";
626const FIELD_RENDERER: &str = "renderer";
627const FIELD_THEME: &str = "theme";
628
629/// A sparse patch: every field is independently `Some(_)` to mutate or `None` to
630/// leave untouched. Mirrors assigning to a subset of `xterm.js` `term.options`.
631#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
632pub struct RuntimeOptionPatch {
633    /// See [`RuntimeOptions::cursor_style`].
634    pub cursor_style: Option<CursorStyle>,
635    /// See [`RuntimeOptions::cursor_blink`].
636    pub cursor_blink: Option<bool>,
637    /// See [`RuntimeOptions::scrollback_lines`].
638    pub scrollback_lines: Option<u32>,
639    /// See [`RuntimeOptions::tab_width`].
640    pub tab_width: Option<u8>,
641    /// See [`RuntimeOptions::convert_eol`].
642    pub convert_eol: Option<bool>,
643    /// See [`RuntimeOptions::screen_reader_mode`].
644    pub screen_reader_mode: Option<bool>,
645    /// See [`RuntimeOptions::bracketed_paste`].
646    pub bracketed_paste: Option<bool>,
647    /// See [`RuntimeOptions::minimum_contrast_ratio_x100`].
648    pub minimum_contrast_ratio_x100: Option<u16>,
649    /// See [`RuntimeOptions::renderer`].
650    pub renderer: Option<RendererType>,
651    /// See [`RuntimeOptions::theme`].
652    pub theme: Option<ThemePalette>,
653}
654
655impl RuntimeOptionPatch {
656    /// An empty patch (no-op).
657    #[must_use]
658    pub const fn new() -> Self {
659        Self {
660            cursor_style: None,
661            cursor_blink: None,
662            scrollback_lines: None,
663            tab_width: None,
664            convert_eol: None,
665            screen_reader_mode: None,
666            bracketed_paste: None,
667            minimum_contrast_ratio_x100: None,
668            renderer: None,
669            theme: None,
670        }
671    }
672
673    /// `true` when the patch mutates nothing.
674    #[must_use]
675    pub const fn is_empty(&self) -> bool {
676        self.cursor_style.is_none()
677            && self.cursor_blink.is_none()
678            && self.scrollback_lines.is_none()
679            && self.tab_width.is_none()
680            && self.convert_eol.is_none()
681            && self.screen_reader_mode.is_none()
682            && self.bracketed_paste.is_none()
683            && self.minimum_contrast_ratio_x100.is_none()
684            && self.renderer.is_none()
685            && self.theme.is_none()
686    }
687
688    /// Builder: set cursor style.
689    #[must_use]
690    pub const fn with_cursor_style(mut self, v: CursorStyle) -> Self {
691        self.cursor_style = Some(v);
692        self
693    }
694    /// Builder: set scrollback retention.
695    #[must_use]
696    pub const fn with_scrollback_lines(mut self, v: u32) -> Self {
697        self.scrollback_lines = Some(v);
698        self
699    }
700    /// Builder: set tab width.
701    #[must_use]
702    pub const fn with_tab_width(mut self, v: u8) -> Self {
703        self.tab_width = Some(v);
704        self
705    }
706    /// Builder: set screen-reader mode.
707    #[must_use]
708    pub const fn with_screen_reader_mode(mut self, v: bool) -> Self {
709        self.screen_reader_mode = Some(v);
710        self
711    }
712    /// Builder: set minimum contrast ratio ×100.
713    #[must_use]
714    pub const fn with_minimum_contrast_ratio_x100(mut self, v: u16) -> Self {
715        self.minimum_contrast_ratio_x100 = Some(v);
716        self
717    }
718    /// Builder: set renderer backend.
719    #[must_use]
720    pub const fn with_renderer(mut self, v: RendererType) -> Self {
721        self.renderer = Some(v);
722        self
723    }
724    /// Builder: set theme palette.
725    #[must_use]
726    pub const fn with_theme(mut self, v: ThemePalette) -> Self {
727        self.theme = Some(v);
728        self
729    }
730}
731
732/// What the active rendering engine / host advertises as supported. A
733/// schema-valid option can still be gated against this profile.
734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735pub struct OptionCapabilityProfile {
736    /// Whether the DOM renderer is available (effectively always true).
737    pub dom: bool,
738    /// Whether the 2D canvas renderer is available.
739    pub canvas: bool,
740    /// Whether the WebGL renderer is available.
741    pub webgl: bool,
742    /// Whether the WebGPU renderer is available.
743    pub webgpu: bool,
744    /// Whether 24-bit colour is supported (informational; theme colours stay
745    /// host-downgraded rather than gated).
746    pub true_color: bool,
747    /// Maximum scrollback the host will retain in memory.
748    pub max_scrollback_lines: u32,
749}
750
751impl OptionCapabilityProfile {
752    /// A conservative default profile: DOM + Canvas only, modest scrollback.
753    #[must_use]
754    pub const fn minimal() -> Self {
755        Self {
756            dom: true,
757            canvas: true,
758            webgl: false,
759            webgpu: false,
760            true_color: false,
761            max_scrollback_lines: 10_000,
762        }
763    }
764
765    /// A fully-featured profile: every renderer, generous scrollback.
766    #[must_use]
767    pub const fn full() -> Self {
768        Self {
769            dom: true,
770            canvas: true,
771            webgl: true,
772            webgpu: true,
773            true_color: true,
774            max_scrollback_lines: SCROLLBACK_HARD_MAX,
775        }
776    }
777
778    /// Whether the requested renderer backend is available.
779    #[must_use]
780    pub const fn supports_renderer(&self, renderer: RendererType) -> bool {
781        match renderer {
782            RendererType::Dom => self.dom,
783            RendererType::Canvas => self.canvas,
784            RendererType::WebGl => self.webgl,
785            RendererType::WebGpu => self.webgpu,
786        }
787    }
788}
789
790impl Default for OptionCapabilityProfile {
791    fn default() -> Self {
792        Self::minimal()
793    }
794}
795
796/// A single field that changed in a committed update.
797#[derive(Debug, Clone, PartialEq, Eq)]
798pub struct OptionFieldChange {
799    /// Canonical field identifier.
800    pub field: &'static str,
801    /// Previous value, rendered as a stable token.
802    pub old: String,
803    /// New value, rendered as a stable token.
804    pub new: String,
805}
806
807impl OptionFieldChange {
808    fn new(field: &'static str, old: String, new: String) -> Self {
809        Self { field, old, new }
810    }
811}
812
813/// Detail for a capability-gating rejection.
814#[derive(Debug, Clone, Copy, PartialEq, Eq)]
815pub enum GatingDetail {
816    /// The requested renderer is not advertised by the host.
817    RendererUnsupported {
818        /// The renderer that was requested.
819        requested: RendererType,
820    },
821    /// The requested scrollback exceeds the host's memory budget.
822    ScrollbackBudgetExceeded {
823        /// Requested scrollback retention.
824        requested: u32,
825        /// The host's advertised budget.
826        budget: u32,
827    },
828}
829
830/// A single, explicit option validation/gating failure.
831#[derive(Debug, Clone, PartialEq, Eq)]
832pub enum RuntimeOptionError {
833    /// A numeric field is outside its allowed schema range.
834    OutOfRange {
835        /// Canonical field identifier.
836        field: &'static str,
837        /// The offending value.
838        value: u64,
839        /// Inclusive minimum.
840        min: u64,
841        /// Inclusive maximum.
842        max: u64,
843    },
844    /// A theme anchor colour (foreground/background) was not fully opaque.
845    TransparentAnchorColor {
846        /// Canonical field identifier (`theme`).
847        field: &'static str,
848        /// The offending palette slot.
849        slot: &'static str,
850        /// The non-opaque alpha value.
851        alpha: u8,
852    },
853    /// The value is schema-valid but unsupported by the active capability
854    /// profile.
855    CapabilityGated {
856        /// Canonical field identifier.
857        field: &'static str,
858        /// Specific gating reason.
859        detail: GatingDetail,
860    },
861}
862
863impl RuntimeOptionError {
864    /// The canonical field identifier this error concerns.
865    #[must_use]
866    pub const fn field(&self) -> &'static str {
867        match self {
868            Self::OutOfRange { field, .. }
869            | Self::TransparentAnchorColor { field, .. }
870            | Self::CapabilityGated { field, .. } => field,
871        }
872    }
873
874    /// A stable machine token for the error kind (used in structured logs).
875    #[must_use]
876    pub const fn kind(&self) -> &'static str {
877        match self {
878            Self::OutOfRange { .. } => "out_of_range",
879            Self::TransparentAnchorColor { .. } => "transparent_anchor_color",
880            Self::CapabilityGated { .. } => "capability_gated",
881        }
882    }
883}
884
885impl fmt::Display for RuntimeOptionError {
886    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
887        match self {
888            Self::OutOfRange {
889                field,
890                value,
891                min,
892                max,
893            } => write!(
894                f,
895                "option `{field}` value {value} is out of range [{min}, {max}]"
896            ),
897            Self::TransparentAnchorColor { field, slot, alpha } => write!(
898                f,
899                "option `{field}` anchor colour `{slot}` must be opaque (alpha {alpha} != 255)"
900            ),
901            Self::CapabilityGated { field, detail } => match detail {
902                GatingDetail::RendererUnsupported { requested } => write!(
903                    f,
904                    "option `{field}` renderer `{requested}` is not supported by the active host"
905                ),
906                GatingDetail::ScrollbackBudgetExceeded { requested, budget } => write!(
907                    f,
908                    "option `{field}` scrollback {requested} exceeds host budget {budget}"
909                ),
910            },
911        }
912    }
913}
914
915impl std::error::Error for RuntimeOptionError {}
916
917/// The outcome of an [`RuntimeOptions::apply_patch`] call.
918#[derive(Debug, Clone, PartialEq, Eq)]
919pub struct RuntimeOptionUpdate {
920    /// Correlation id carried through for structured-log triage.
921    pub correlation_id: String,
922    /// `true` when the patch was committed; `false` means rejected and rolled
923    /// back (options unchanged).
924    pub applied: bool,
925    /// The fields that actually changed (empty when rejected or a no-op).
926    pub changes: Vec<OptionFieldChange>,
927    /// Every validation/gating error (empty when applied).
928    pub errors: Vec<RuntimeOptionError>,
929    /// Whether the host must repaint the grid to reflect the change.
930    pub requires_repaint: bool,
931    /// Whether the host must tear down and re-create the renderer backend.
932    pub requires_renderer_reinit: bool,
933}
934
935impl RuntimeOptionUpdate {
936    /// `true` when applied but nothing actually changed (idempotent patch).
937    #[must_use]
938    pub fn is_noop(&self) -> bool {
939        self.applied && self.changes.is_empty()
940    }
941
942    /// Serialise the outcome to a single deterministic JSON line, including the
943    /// full change set and (on rejection) every error. This is the canonical
944    /// applied/rejected audit artifact.
945    #[must_use]
946    pub fn to_jsonl(&self) -> String {
947        let mut changes_json = String::from("[");
948        for (i, c) in self.changes.iter().enumerate() {
949            if i > 0 {
950                changes_json.push(',');
951            }
952            changes_json.push_str(&format!(
953                "{{\"field\":\"{}\",\"old\":\"{}\",\"new\":\"{}\"}}",
954                c.field,
955                escape_json(&c.old),
956                escape_json(&c.new),
957            ));
958        }
959        changes_json.push(']');
960
961        let mut errors_json = String::from("[");
962        for (i, e) in self.errors.iter().enumerate() {
963            if i > 0 {
964                errors_json.push(',');
965            }
966            errors_json.push_str(&format!(
967                "{{\"field\":\"{}\",\"kind\":\"{}\",\"detail\":\"{}\"}}",
968                e.field(),
969                e.kind(),
970                escape_json(&e.to_string()),
971            ));
972        }
973        errors_json.push(']');
974
975        format!(
976            concat!(
977                "{{\"event\":\"runtime_option_update\",\"correlation_id\":\"{cid}\",",
978                "\"applied\":{applied},\"requires_repaint\":{rp},",
979                "\"requires_renderer_reinit\":{rr},\"change_count\":{cc},",
980                "\"error_count\":{ec},\"changes\":{changes},\"errors\":{errors}}}"
981            ),
982            cid = escape_json(&self.correlation_id),
983            applied = self.applied,
984            rp = self.requires_repaint,
985            rr = self.requires_renderer_reinit,
986            cc = self.changes.len(),
987            ec = self.errors.len(),
988            changes = changes_json,
989            errors = errors_json,
990        )
991    }
992}
993
994/// Errors from parsing a JSON option patch (shape/syntax), distinct from
995/// validation/gating errors which are surfaced by [`RuntimeOptions::apply_patch`].
996#[cfg(feature = "input-parser")]
997#[derive(Debug, Clone, PartialEq, Eq)]
998pub enum OptionPatchParseError {
999    /// The payload was not valid JSON or not a JSON object.
1000    MalformedJson(String),
1001    /// An unknown option key was present (strict parsing rejects typos).
1002    UnknownKey(String),
1003    /// A key held a value of the wrong JSON type.
1004    TypeMismatch {
1005        /// The offending key.
1006        key: String,
1007        /// The JSON type that was expected.
1008        expected: &'static str,
1009    },
1010    /// An enum-valued field held an unrecognised token.
1011    InvalidToken {
1012        /// The offending key.
1013        key: String,
1014        /// The unrecognised token.
1015        token: String,
1016    },
1017}
1018
1019#[cfg(feature = "input-parser")]
1020impl fmt::Display for OptionPatchParseError {
1021    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1022        match self {
1023            Self::MalformedJson(msg) => write!(f, "malformed option patch JSON: {msg}"),
1024            Self::UnknownKey(key) => write!(f, "unknown option key `{key}`"),
1025            Self::TypeMismatch { key, expected } => {
1026                write!(f, "option key `{key}` expected a {expected}")
1027            }
1028            Self::InvalidToken { key, token } => {
1029                write!(f, "option key `{key}` has invalid token `{token}`")
1030            }
1031        }
1032    }
1033}
1034
1035#[cfg(feature = "input-parser")]
1036impl std::error::Error for OptionPatchParseError {}
1037
1038#[cfg(feature = "input-parser")]
1039impl RuntimeOptionPatch {
1040    /// Parse a sparse option patch from a JSON object (the exact shape a JS host
1041    /// produces from a `term.options = {...}` assignment).
1042    ///
1043    /// Strict: unknown keys, wrong JSON types, and unrecognised enum tokens are
1044    /// rejected with an actionable [`OptionPatchParseError`] rather than silently
1045    /// ignored. Numeric *range* checks are intentionally deferred to
1046    /// [`RuntimeOptions::apply_patch`] so that parse errors (shape) stay distinct
1047    /// from validation errors (semantics).
1048    ///
1049    /// # Errors
1050    ///
1051    /// Returns [`OptionPatchParseError`] when the payload is not a JSON object,
1052    /// contains an unknown key, has a type mismatch, or carries an invalid enum
1053    /// token.
1054    pub fn parse_json(input: &str) -> Result<Self, OptionPatchParseError> {
1055        use serde_json::Value;
1056
1057        let value: Value = serde_json::from_str(input)
1058            .map_err(|e| OptionPatchParseError::MalformedJson(e.to_string()))?;
1059        let obj = value.as_object().ok_or_else(|| {
1060            OptionPatchParseError::MalformedJson("top-level value must be an object".to_owned())
1061        })?;
1062
1063        let mut patch = Self::new();
1064
1065        for (key, val) in obj {
1066            match key.as_str() {
1067                "cursorStyle" => {
1068                    let token = expect_str(key, val)?;
1069                    patch.cursor_style = Some(CursorStyle::from_token(token).ok_or_else(|| {
1070                        OptionPatchParseError::InvalidToken {
1071                            key: key.clone(),
1072                            token: token.to_owned(),
1073                        }
1074                    })?);
1075                }
1076                "cursorBlink" => patch.cursor_blink = Some(expect_bool(key, val)?),
1077                "scrollback" => patch.scrollback_lines = Some(expect_u32(key, val)?),
1078                "tabStopWidth" => patch.tab_width = Some(expect_u8(key, val)?),
1079                "convertEol" => patch.convert_eol = Some(expect_bool(key, val)?),
1080                "screenReaderMode" => patch.screen_reader_mode = Some(expect_bool(key, val)?),
1081                "bracketedPaste" => patch.bracketed_paste = Some(expect_bool(key, val)?),
1082                "minimumContrastRatioX100" => {
1083                    patch.minimum_contrast_ratio_x100 = Some(expect_u16(key, val)?);
1084                }
1085                "rendererType" => {
1086                    let token = expect_str(key, val)?;
1087                    patch.renderer = Some(RendererType::from_token(token).ok_or_else(|| {
1088                        OptionPatchParseError::InvalidToken {
1089                            key: key.clone(),
1090                            token: token.to_owned(),
1091                        }
1092                    })?);
1093                }
1094                "theme" => patch.theme = Some(parse_theme(key, val)?),
1095                other => return Err(OptionPatchParseError::UnknownKey(other.to_owned())),
1096            }
1097        }
1098
1099        Ok(patch)
1100    }
1101}
1102
1103#[cfg(feature = "input-parser")]
1104fn expect_str<'a>(key: &str, val: &'a serde_json::Value) -> Result<&'a str, OptionPatchParseError> {
1105    val.as_str()
1106        .ok_or_else(|| OptionPatchParseError::TypeMismatch {
1107            key: key.to_owned(),
1108            expected: "string",
1109        })
1110}
1111
1112#[cfg(feature = "input-parser")]
1113fn expect_bool(key: &str, val: &serde_json::Value) -> Result<bool, OptionPatchParseError> {
1114    val.as_bool()
1115        .ok_or_else(|| OptionPatchParseError::TypeMismatch {
1116            key: key.to_owned(),
1117            expected: "boolean",
1118        })
1119}
1120
1121#[cfg(feature = "input-parser")]
1122fn expect_u64(key: &str, val: &serde_json::Value) -> Result<u64, OptionPatchParseError> {
1123    val.as_u64()
1124        .ok_or_else(|| OptionPatchParseError::TypeMismatch {
1125            key: key.to_owned(),
1126            expected: "non-negative integer",
1127        })
1128}
1129
1130#[cfg(feature = "input-parser")]
1131fn expect_u32(key: &str, val: &serde_json::Value) -> Result<u32, OptionPatchParseError> {
1132    // Saturate at u32::MAX so an absurd payload becomes a clean range rejection
1133    // at apply time rather than a parse-time overflow.
1134    Ok(u32::try_from(expect_u64(key, val)?).unwrap_or(u32::MAX))
1135}
1136
1137#[cfg(feature = "input-parser")]
1138fn expect_u16(key: &str, val: &serde_json::Value) -> Result<u16, OptionPatchParseError> {
1139    Ok(u16::try_from(expect_u64(key, val)?).unwrap_or(u16::MAX))
1140}
1141
1142#[cfg(feature = "input-parser")]
1143fn expect_u8(key: &str, val: &serde_json::Value) -> Result<u8, OptionPatchParseError> {
1144    Ok(u8::try_from(expect_u64(key, val)?).unwrap_or(u8::MAX))
1145}
1146
1147#[cfg(feature = "input-parser")]
1148fn parse_color(key: &str, val: &serde_json::Value) -> Result<ThemeColor, OptionPatchParseError> {
1149    let token = expect_str(key, val)?;
1150    ThemeColor::from_hex(token).ok_or_else(|| OptionPatchParseError::InvalidToken {
1151        key: key.to_owned(),
1152        token: token.to_owned(),
1153    })
1154}
1155
1156#[cfg(feature = "input-parser")]
1157fn parse_theme(key: &str, val: &serde_json::Value) -> Result<ThemePalette, OptionPatchParseError> {
1158    let obj = val
1159        .as_object()
1160        .ok_or_else(|| OptionPatchParseError::TypeMismatch {
1161            key: key.to_owned(),
1162            expected: "object",
1163        })?;
1164    // Start from the default palette; the theme object overlays named slots.
1165    let mut palette = ThemePalette::dark();
1166    for (slot, color_val) in obj {
1167        match slot.as_str() {
1168            "foreground" => palette.foreground = parse_color(slot, color_val)?,
1169            "background" => palette.background = parse_color(slot, color_val)?,
1170            "cursor" => palette.cursor = parse_color(slot, color_val)?,
1171            "cursorAccent" => palette.cursor_accent = parse_color(slot, color_val)?,
1172            "selectionBackground" => {
1173                palette.selection_background = parse_color(slot, color_val)?;
1174            }
1175            other => {
1176                // Accept ansi0..=ansi15; reject anything else.
1177                if let Some(idx) = other
1178                    .strip_prefix("ansi")
1179                    .and_then(|n| n.parse::<usize>().ok())
1180                    .filter(|i| *i < 16)
1181                {
1182                    palette.ansi[idx] = parse_color(other, color_val)?;
1183                } else {
1184                    return Err(OptionPatchParseError::UnknownKey(format!("theme.{other}")));
1185                }
1186            }
1187        }
1188    }
1189    Ok(palette)
1190}
1191
1192/// Escape a string for embedding inside a JSON double-quoted value.
1193fn escape_json(input: &str) -> String {
1194    let mut out = String::with_capacity(input.len() + 2);
1195    for ch in input.chars() {
1196        match ch {
1197            '"' => out.push_str("\\\""),
1198            '\\' => out.push_str("\\\\"),
1199            '\n' => out.push_str("\\n"),
1200            '\r' => out.push_str("\\r"),
1201            '\t' => out.push_str("\\t"),
1202            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1203            c => out.push(c),
1204        }
1205    }
1206    out
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211    use super::*;
1212
1213    // ── Defaults & schema construction ──────────────────────────────────────
1214
1215    #[test]
1216    fn defaults_are_valid_under_full_caps() {
1217        let opts = RuntimeOptions::default();
1218        assert!(opts.validate(&OptionCapabilityProfile::full()).is_empty());
1219    }
1220
1221    #[test]
1222    fn default_renderer_is_valid_under_minimal_caps() {
1223        // The default renderer (DOM) must be supported by the minimal profile
1224        // so a fresh terminal always boots.
1225        let opts = RuntimeOptions::default();
1226        assert!(
1227            opts.validate(&OptionCapabilityProfile::minimal())
1228                .is_empty()
1229        );
1230    }
1231
1232    #[test]
1233    fn empty_patch_is_noop() {
1234        let mut opts = RuntimeOptions::default();
1235        let before = opts;
1236        let patch = RuntimeOptionPatch::new();
1237        assert!(patch.is_empty());
1238        let update = opts.apply_patch(&patch, &OptionCapabilityProfile::full(), "noop");
1239        assert!(update.applied);
1240        assert!(update.is_noop());
1241        assert!(update.changes.is_empty());
1242        assert_eq!(opts, before);
1243    }
1244
1245    // ── Successful atomic apply ─────────────────────────────────────────────
1246
1247    #[test]
1248    fn apply_commits_and_reports_changed_fields() {
1249        let mut opts = RuntimeOptions::default();
1250        let patch = RuntimeOptionPatch::new()
1251            .with_cursor_style(CursorStyle::Bar)
1252            .with_scrollback_lines(5_000)
1253            .with_screen_reader_mode(true);
1254        let update = opts.apply_patch(&patch, &OptionCapabilityProfile::full(), "c1");
1255        assert!(update.applied);
1256        assert_eq!(update.changes.len(), 3);
1257        assert_eq!(opts.cursor_style, CursorStyle::Bar);
1258        assert_eq!(opts.scrollback_lines, 5_000);
1259        assert!(opts.screen_reader_mode);
1260        // cursor_style + scrollback both demand a repaint; no renderer change.
1261        assert!(update.requires_repaint);
1262        assert!(!update.requires_renderer_reinit);
1263    }
1264
1265    #[test]
1266    fn idempotent_apply_changes_nothing() {
1267        let mut opts = RuntimeOptions::default();
1268        let patch = RuntimeOptionPatch::new().with_cursor_style(opts.cursor_style);
1269        let update = opts.apply_patch(&patch, &OptionCapabilityProfile::full(), "id");
1270        assert!(update.applied);
1271        assert!(update.is_noop());
1272        assert!(!update.requires_repaint);
1273    }
1274
1275    #[test]
1276    fn renderer_change_requires_reinit_and_repaint() {
1277        let mut opts = RuntimeOptions::default();
1278        let patch = RuntimeOptionPatch::new().with_renderer(RendererType::WebGpu);
1279        let update = opts.apply_patch(&patch, &OptionCapabilityProfile::full(), "r1");
1280        assert!(update.applied);
1281        assert!(update.requires_renderer_reinit);
1282        assert!(update.requires_repaint);
1283        assert_eq!(opts.renderer, RendererType::WebGpu);
1284    }
1285
1286    // ── Atomic rollback on invalid payloads ─────────────────────────────────
1287
1288    #[test]
1289    fn out_of_range_rejects_and_rolls_back_entire_patch() {
1290        let mut opts = RuntimeOptions::default();
1291        let before = opts;
1292        // tab_width is valid, but minimum_contrast is absurd → whole patch fails.
1293        let patch = RuntimeOptionPatch::new()
1294            .with_tab_width(4)
1295            .with_minimum_contrast_ratio_x100(9_999);
1296        let update = opts.apply_patch(&patch, &OptionCapabilityProfile::full(), "bad");
1297        assert!(!update.applied);
1298        assert_eq!(update.changes.len(), 0);
1299        assert_eq!(update.errors.len(), 1);
1300        assert_eq!(update.errors[0].field(), FIELD_MIN_CONTRAST);
1301        // Rollback: even the *valid* tab_width must NOT have been applied.
1302        assert_eq!(opts, before);
1303    }
1304
1305    #[test]
1306    fn all_errors_collected_not_just_first() {
1307        let mut opts = RuntimeOptions::default();
1308        let patch = RuntimeOptionPatch::new()
1309            .with_tab_width(0) // below MIN_TAB_WIDTH
1310            .with_minimum_contrast_ratio_x100(5) // below MIN_CONTRAST
1311            .with_scrollback_lines(SCROLLBACK_HARD_MAX + 1); // above hard max
1312        let update = opts.apply_patch(&patch, &OptionCapabilityProfile::full(), "multi");
1313        assert!(!update.applied);
1314        assert_eq!(update.errors.len(), 3);
1315        let fields: Vec<&str> = update
1316            .errors
1317            .iter()
1318            .map(RuntimeOptionError::field)
1319            .collect();
1320        assert!(fields.contains(&FIELD_TAB_WIDTH));
1321        assert!(fields.contains(&FIELD_MIN_CONTRAST));
1322        assert!(fields.contains(&FIELD_SCROLLBACK));
1323    }
1324
1325    #[test]
1326    fn transparent_anchor_color_is_rejected() {
1327        let mut opts = RuntimeOptions::default();
1328        let mut theme = ThemePalette::dark();
1329        theme.background = ThemeColor::rgba(0x10, 0x10, 0x10, 0x80); // semi-transparent
1330        let patch = RuntimeOptionPatch::new().with_theme(theme);
1331        let update = opts.apply_patch(&patch, &OptionCapabilityProfile::full(), "tc");
1332        assert!(!update.applied);
1333        assert!(matches!(
1334            update.errors[0],
1335            RuntimeOptionError::TransparentAnchorColor { .. }
1336        ));
1337    }
1338
1339    #[test]
1340    fn transparent_selection_is_allowed() {
1341        // Only foreground/background anchors must be opaque; selection may be
1342        // translucent (that is its whole purpose).
1343        let mut opts = RuntimeOptions::default();
1344        let mut theme = ThemePalette::dark();
1345        theme.selection_background = ThemeColor::rgba(0x80, 0x80, 0xFF, 0x40);
1346        let patch = RuntimeOptionPatch::new().with_theme(theme);
1347        let update = opts.apply_patch(&patch, &OptionCapabilityProfile::full(), "sel");
1348        assert!(update.applied);
1349    }
1350
1351    // ── Capability / profile gating ─────────────────────────────────────────
1352
1353    #[test]
1354    fn renderer_gated_when_unsupported() {
1355        let mut opts = RuntimeOptions::default();
1356        let before = opts;
1357        let patch = RuntimeOptionPatch::new().with_renderer(RendererType::WebGpu);
1358        let update = opts.apply_patch(&patch, &OptionCapabilityProfile::minimal(), "g1");
1359        assert!(!update.applied);
1360        assert!(matches!(
1361            update.errors[0],
1362            RuntimeOptionError::CapabilityGated {
1363                detail: GatingDetail::RendererUnsupported { .. },
1364                ..
1365            }
1366        ));
1367        assert_eq!(opts, before);
1368    }
1369
1370    #[test]
1371    fn scrollback_gated_by_host_budget() {
1372        let mut opts = RuntimeOptions::default();
1373        let caps = OptionCapabilityProfile::minimal(); // budget 10_000
1374        let patch = RuntimeOptionPatch::new().with_scrollback_lines(50_000);
1375        let update = opts.apply_patch(&patch, &caps, "g2");
1376        assert!(!update.applied);
1377        assert!(matches!(
1378            update.errors[0],
1379            RuntimeOptionError::CapabilityGated {
1380                detail: GatingDetail::ScrollbackBudgetExceeded { .. },
1381                ..
1382            }
1383        ));
1384    }
1385
1386    #[test]
1387    fn same_renderer_under_minimal_caps_is_ok() {
1388        let mut opts = RuntimeOptions::default();
1389        let patch = RuntimeOptionPatch::new().with_renderer(RendererType::Dom);
1390        let update = opts.apply_patch(&patch, &OptionCapabilityProfile::minimal(), "g3");
1391        assert!(update.applied);
1392    }
1393
1394    // ── Determinism & serialisation ─────────────────────────────────────────
1395
1396    #[test]
1397    fn apply_is_deterministic() {
1398        let patch = RuntimeOptionPatch::new()
1399            .with_cursor_style(CursorStyle::Underline)
1400            .with_renderer(RendererType::WebGl);
1401        let caps = OptionCapabilityProfile::full();
1402        let mut a = RuntimeOptions::default();
1403        let mut b = RuntimeOptions::default();
1404        let ua = a.apply_patch(&patch, &caps, "det");
1405        let ub = b.apply_patch(&patch, &caps, "det");
1406        assert_eq!(ua, ub);
1407        assert_eq!(a, b);
1408        assert_eq!(ua.to_jsonl(), ub.to_jsonl());
1409    }
1410
1411    #[test]
1412    fn update_jsonl_is_single_line_and_complete() {
1413        let mut opts = RuntimeOptions::default();
1414        let patch = RuntimeOptionPatch::new().with_cursor_style(CursorStyle::Bar);
1415        let line = opts
1416            .apply_patch(&patch, &OptionCapabilityProfile::full(), "j1")
1417            .to_jsonl();
1418        assert!(line.starts_with('{') && line.ends_with('}'));
1419        assert!(!line.contains('\n'));
1420        assert!(line.contains("\"event\":\"runtime_option_update\""));
1421        assert!(line.contains("\"correlation_id\":\"j1\""));
1422        assert!(line.contains("\"applied\":true"));
1423        assert!(line.contains("\"field\":\"cursor_style\""));
1424        assert!(line.contains("\"old\":\"block\""));
1425        assert!(line.contains("\"new\":\"bar\""));
1426    }
1427
1428    #[test]
1429    fn rejected_update_jsonl_carries_errors() {
1430        let mut opts = RuntimeOptions::default();
1431        let patch = RuntimeOptionPatch::new().with_renderer(RendererType::WebGpu);
1432        let line = opts
1433            .apply_patch(&patch, &OptionCapabilityProfile::minimal(), "j2")
1434            .to_jsonl();
1435        assert!(line.contains("\"applied\":false"));
1436        assert!(line.contains("\"error_count\":1"));
1437        assert!(line.contains("\"kind\":\"capability_gated\""));
1438        assert!(line.contains("\"field\":\"renderer\""));
1439    }
1440
1441    #[test]
1442    fn snapshot_jsonl_round_trips_key_fields() {
1443        let opts = RuntimeOptions::default();
1444        let line = opts.to_jsonl("snap");
1445        assert!(line.contains("\"cursor_style\":\"block\""));
1446        assert!(line.contains("\"renderer\":\"dom\""));
1447        assert!(line.contains("\"scrollback_lines\":1000"));
1448        assert!(line.contains("\"theme_background\":\"#000000ff\""));
1449    }
1450
1451    #[test]
1452    fn web_dataset_keys_are_stable() {
1453        let opts = RuntimeOptions::default();
1454        let ds = opts.web_dataset();
1455        assert_eq!(
1456            ds.clone().map(|(k, _)| k),
1457            [
1458                "data-ftui-cursor-style",
1459                "data-ftui-renderer",
1460                "data-ftui-screen-reader",
1461            ]
1462        );
1463        assert_eq!(ds[0].1, "block");
1464    }
1465
1466    // ── Token & colour helpers ──────────────────────────────────────────────
1467
1468    #[test]
1469    fn cursor_and_renderer_token_round_trip() {
1470        for s in [CursorStyle::Block, CursorStyle::Underline, CursorStyle::Bar] {
1471            assert_eq!(CursorStyle::from_token(s.as_token()), Some(s));
1472        }
1473        for r in [
1474            RendererType::Dom,
1475            RendererType::Canvas,
1476            RendererType::WebGl,
1477            RendererType::WebGpu,
1478        ] {
1479            assert_eq!(RendererType::from_token(r.as_token()), Some(r));
1480        }
1481        assert_eq!(CursorStyle::from_token("nope"), None);
1482        assert_eq!(RendererType::from_token("nope"), None);
1483    }
1484
1485    #[test]
1486    fn theme_color_hex_round_trips() {
1487        let c = ThemeColor::rgba(0x12, 0x34, 0x56, 0x78);
1488        assert_eq!(c.as_hex(), "#12345678");
1489        assert_eq!(ThemeColor::from_hex("#12345678"), Some(c));
1490        // 6-digit form implies opaque.
1491        assert_eq!(
1492            ThemeColor::from_hex("#abcdef"),
1493            Some(ThemeColor::rgb(0xAB, 0xCD, 0xEF))
1494        );
1495        assert_eq!(ThemeColor::from_hex("zzz"), None);
1496        assert_eq!(ThemeColor::from_hex("#12"), None);
1497    }
1498
1499    #[test]
1500    fn jsonl_escapes_correlation_id() {
1501        let mut opts = RuntimeOptions::default();
1502        let update = opts.apply_patch(
1503            &RuntimeOptionPatch::new(),
1504            &OptionCapabilityProfile::full(),
1505            "a\"b\\c",
1506        );
1507        let line = update.to_jsonl();
1508        assert!(line.contains(r#""correlation_id":"a\"b\\c""#));
1509    }
1510
1511    // ── JSON patch parsing (feature-gated) ──────────────────────────────────
1512
1513    #[cfg(feature = "input-parser")]
1514    mod json {
1515        use super::*;
1516
1517        #[test]
1518        fn parses_full_patch() {
1519            let json = r##"{
1520                "cursorStyle":"bar","cursorBlink":true,"scrollback":5000,
1521                "tabStopWidth":4,"convertEol":true,"screenReaderMode":true,
1522                "bracketedPaste":false,"minimumContrastRatioX100":450,
1523                "rendererType":"webgpu",
1524                "theme":{"foreground":"#c0c0c0","background":"#101010","ansi3":"#ffcc00"}
1525            }"##;
1526            let patch = RuntimeOptionPatch::parse_json(json).expect("valid patch");
1527            assert_eq!(patch.cursor_style, Some(CursorStyle::Bar));
1528            assert_eq!(patch.scrollback_lines, Some(5000));
1529            assert_eq!(patch.tab_width, Some(4));
1530            assert_eq!(patch.renderer, Some(RendererType::WebGpu));
1531            let theme = patch.theme.expect("theme present");
1532            assert_eq!(theme.background, ThemeColor::rgb(0x10, 0x10, 0x10));
1533            assert_eq!(theme.ansi[3], ThemeColor::rgb(0xFF, 0xCC, 0x00));
1534        }
1535
1536        #[test]
1537        fn parse_then_apply_end_to_end() {
1538            let mut opts = RuntimeOptions::default();
1539            let patch = RuntimeOptionPatch::parse_json(r#"{"cursorStyle":"underline"}"#).unwrap();
1540            let update = opts.apply_patch(&patch, &OptionCapabilityProfile::full(), "e2e");
1541            assert!(update.applied);
1542            assert_eq!(opts.cursor_style, CursorStyle::Underline);
1543        }
1544
1545        #[test]
1546        fn malformed_json_is_actionable() {
1547            let err = RuntimeOptionPatch::parse_json(r#"{"cursorStyle":"#).unwrap_err();
1548            assert!(matches!(err, OptionPatchParseError::MalformedJson(_)));
1549            assert!(err.to_string().contains("malformed"));
1550        }
1551
1552        #[test]
1553        fn unknown_key_rejected() {
1554            let err = RuntimeOptionPatch::parse_json(r#"{"bogusKey":1}"#).unwrap_err();
1555            assert_eq!(
1556                err,
1557                OptionPatchParseError::UnknownKey("bogusKey".to_owned())
1558            );
1559        }
1560
1561        #[test]
1562        fn type_mismatch_rejected() {
1563            let err = RuntimeOptionPatch::parse_json(r#"{"scrollback":"lots"}"#).unwrap_err();
1564            assert!(matches!(err, OptionPatchParseError::TypeMismatch { .. }));
1565        }
1566
1567        #[test]
1568        fn invalid_enum_token_rejected() {
1569            let err = RuntimeOptionPatch::parse_json(r#"{"rendererType":"crayon"}"#).unwrap_err();
1570            assert!(matches!(
1571                err,
1572                OptionPatchParseError::InvalidToken { ref token, .. } if token == "crayon"
1573            ));
1574        }
1575
1576        #[test]
1577        fn unknown_theme_slot_rejected() {
1578            let err =
1579                RuntimeOptionPatch::parse_json(r##"{"theme":{"sparkle":"#fff"}}"##).unwrap_err();
1580            assert!(
1581                matches!(err, OptionPatchParseError::UnknownKey(ref k) if k == "theme.sparkle")
1582            );
1583        }
1584
1585        #[test]
1586        fn absurd_scrollback_saturates_then_range_rejects() {
1587            // 9e18 overflows u32 → saturates to u32::MAX at parse, then the
1588            // hard-max range check rejects it at apply time (not a parse panic).
1589            let patch =
1590                RuntimeOptionPatch::parse_json(r#"{"scrollback":9000000000000000000}"#).unwrap();
1591            assert_eq!(patch.scrollback_lines, Some(u32::MAX));
1592            let mut opts = RuntimeOptions::default();
1593            let update = opts.apply_patch(&patch, &OptionCapabilityProfile::full(), "sat");
1594            assert!(!update.applied);
1595            assert_eq!(update.errors[0].field(), FIELD_SCROLLBACK);
1596        }
1597    }
1598}