1use core::fmt;
49
50pub const SCROLLBACK_HARD_MAX: u32 = 1_000_000;
54
55pub const MIN_CONTRAST_RATIO_X100: u16 = 100;
57pub const MAX_CONTRAST_RATIO_X100: u16 = 2100;
60
61pub const MIN_TAB_WIDTH: u8 = 1;
63pub const MAX_TAB_WIDTH: u8 = 16;
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum CursorStyle {
69 Block,
71 Underline,
73 Bar,
75}
76
77impl CursorStyle {
78 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub enum RendererType {
110 Dom,
112 Canvas,
114 WebGl,
116 WebGpu,
118}
119
120impl RendererType {
121 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
154pub struct ThemeColor(pub u32);
155
156impl ThemeColor {
157 #[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 #[must_use]
165 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
166 Self::rgba(r, g, b, 0xFF)
167 }
168
169 #[must_use]
171 pub const fn r(self) -> u8 {
172 (self.0 >> 24) as u8
173 }
174 #[must_use]
176 pub const fn g(self) -> u8 {
177 (self.0 >> 16) as u8
178 }
179 #[must_use]
181 pub const fn b(self) -> u8 {
182 (self.0 >> 8) as u8
183 }
184 #[must_use]
186 pub const fn a(self) -> u8 {
187 self.0 as u8
188 }
189
190 #[must_use]
192 pub const fn is_opaque(self) -> bool {
193 self.a() == 0xFF
194 }
195
196 #[must_use]
198 pub fn as_hex(self) -> String {
199 format!("#{:08x}", self.0)
200 }
201
202 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
219pub struct ThemePalette {
220 pub foreground: ThemeColor,
222 pub background: ThemeColor,
224 pub cursor: ThemeColor,
226 pub cursor_accent: ThemeColor,
228 pub selection_background: ThemeColor,
230 pub ansi: [ThemeColor; 16],
232}
233
234impl ThemePalette {
235 #[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), ThemeColor::rgb(0xCC, 0x00, 0x00), ThemeColor::rgb(0x4E, 0x9A, 0x06), ThemeColor::rgb(0xC4, 0xA0, 0x00), ThemeColor::rgb(0x34, 0x65, 0xA4), ThemeColor::rgb(0x75, 0x50, 0x7B), ThemeColor::rgb(0x06, 0x98, 0x9A), ThemeColor::rgb(0xD3, 0xD7, 0xCF), ThemeColor::rgb(0x55, 0x57, 0x53), ThemeColor::rgb(0xEF, 0x29, 0x29), ThemeColor::rgb(0x8A, 0xE2, 0x34), ThemeColor::rgb(0xFC, 0xE9, 0x4F), ThemeColor::rgb(0x72, 0x9F, 0xCF), ThemeColor::rgb(0xAD, 0x7F, 0xA8), ThemeColor::rgb(0x34, 0xE2, 0xE2), ThemeColor::rgb(0xEE, 0xEE, 0xEC), ],
262 }
263 }
264
265 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub struct RuntimeOptions {
289 pub cursor_style: CursorStyle,
291 pub cursor_blink: bool,
293 pub scrollback_lines: u32,
295 pub tab_width: u8,
297 pub convert_eol: bool,
299 pub screen_reader_mode: bool,
301 pub bracketed_paste: bool,
303 pub minimum_contrast_ratio_x100: u16,
306 pub renderer: RendererType,
308 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 renderer: RendererType::Dom,
328 theme: ThemePalette::dark(),
329 }
330 }
331}
332
333impl RuntimeOptions {
334 #[must_use]
345 pub fn apply_patch(
346 &mut self,
347 patch: &RuntimeOptionPatch,
348 caps: &OptionCapabilityProfile,
349 correlation_id: &str,
350 ) -> RuntimeOptionUpdate {
351 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 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 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 *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 #[must_use]
429 pub fn validate(&self, caps: &OptionCapabilityProfile) -> Vec<RuntimeOptionError> {
430 let mut errors = Vec::new();
431
432 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 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 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 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 #[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 #[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
616const 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
632pub struct RuntimeOptionPatch {
633 pub cursor_style: Option<CursorStyle>,
635 pub cursor_blink: Option<bool>,
637 pub scrollback_lines: Option<u32>,
639 pub tab_width: Option<u8>,
641 pub convert_eol: Option<bool>,
643 pub screen_reader_mode: Option<bool>,
645 pub bracketed_paste: Option<bool>,
647 pub minimum_contrast_ratio_x100: Option<u16>,
649 pub renderer: Option<RendererType>,
651 pub theme: Option<ThemePalette>,
653}
654
655impl RuntimeOptionPatch {
656 #[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 #[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 #[must_use]
690 pub const fn with_cursor_style(mut self, v: CursorStyle) -> Self {
691 self.cursor_style = Some(v);
692 self
693 }
694 #[must_use]
696 pub const fn with_scrollback_lines(mut self, v: u32) -> Self {
697 self.scrollback_lines = Some(v);
698 self
699 }
700 #[must_use]
702 pub const fn with_tab_width(mut self, v: u8) -> Self {
703 self.tab_width = Some(v);
704 self
705 }
706 #[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 #[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 #[must_use]
720 pub const fn with_renderer(mut self, v: RendererType) -> Self {
721 self.renderer = Some(v);
722 self
723 }
724 #[must_use]
726 pub const fn with_theme(mut self, v: ThemePalette) -> Self {
727 self.theme = Some(v);
728 self
729 }
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735pub struct OptionCapabilityProfile {
736 pub dom: bool,
738 pub canvas: bool,
740 pub webgl: bool,
742 pub webgpu: bool,
744 pub true_color: bool,
747 pub max_scrollback_lines: u32,
749}
750
751impl OptionCapabilityProfile {
752 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
798pub struct OptionFieldChange {
799 pub field: &'static str,
801 pub old: String,
803 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
815pub enum GatingDetail {
816 RendererUnsupported {
818 requested: RendererType,
820 },
821 ScrollbackBudgetExceeded {
823 requested: u32,
825 budget: u32,
827 },
828}
829
830#[derive(Debug, Clone, PartialEq, Eq)]
832pub enum RuntimeOptionError {
833 OutOfRange {
835 field: &'static str,
837 value: u64,
839 min: u64,
841 max: u64,
843 },
844 TransparentAnchorColor {
846 field: &'static str,
848 slot: &'static str,
850 alpha: u8,
852 },
853 CapabilityGated {
856 field: &'static str,
858 detail: GatingDetail,
860 },
861}
862
863impl RuntimeOptionError {
864 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
919pub struct RuntimeOptionUpdate {
920 pub correlation_id: String,
922 pub applied: bool,
925 pub changes: Vec<OptionFieldChange>,
927 pub errors: Vec<RuntimeOptionError>,
929 pub requires_repaint: bool,
931 pub requires_renderer_reinit: bool,
933}
934
935impl RuntimeOptionUpdate {
936 #[must_use]
938 pub fn is_noop(&self) -> bool {
939 self.applied && self.changes.is_empty()
940 }
941
942 #[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#[cfg(feature = "input-parser")]
997#[derive(Debug, Clone, PartialEq, Eq)]
998pub enum OptionPatchParseError {
999 MalformedJson(String),
1001 UnknownKey(String),
1003 TypeMismatch {
1005 key: String,
1007 expected: &'static str,
1009 },
1010 InvalidToken {
1012 key: String,
1014 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 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 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 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 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
1192fn 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 #[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 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 #[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 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 #[test]
1289 fn out_of_range_rejects_and_rolls_back_entire_patch() {
1290 let mut opts = RuntimeOptions::default();
1291 let before = opts;
1292 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 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) .with_minimum_contrast_ratio_x100(5) .with_scrollback_lines(SCROLLBACK_HARD_MAX + 1); 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); 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 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 #[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(); 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 #[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 #[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 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 #[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 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}