Skip to main content

tui_test/
api.rs

1use std::fmt;
2use std::path::PathBuf;
3
4use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7
8use crate::diagnostics::{FailureArtifactRef, FailureDetails, FailureObservation, FailureReport};
9use crate::shell::Shell;
10
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum AutomaticRecordingMode {
14    #[default]
15    Disabled,
16    OnFailure,
17    Always,
18}
19
20#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(default, deny_unknown_fields)]
22pub struct AutomaticRecording {
23    #[serde(skip)]
24    pub mode: AutomaticRecordingMode,
25    pub directory: Option<PathBuf>,
26}
27
28impl AutomaticRecording {
29    pub fn validate(&self) -> Result<(), TuiTestError> {
30        if self
31            .directory
32            .as_ref()
33            .is_some_and(|directory| directory.as_os_str().is_empty())
34        {
35            return Err(TuiTestError::usage(
36                "automatic recording directory must not be empty",
37            ));
38        }
39        Ok(())
40    }
41}
42
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(default, deny_unknown_fields)]
45pub struct Timeouts {
46    pub text: Option<u64>,
47    pub idle: Option<u64>,
48    pub command: Option<u64>,
49    pub exit: Option<u64>,
50    pub ready: Option<u64>,
51}
52
53impl Timeouts {
54    pub fn get(&self, class: crate::config::TimeoutClass) -> Option<u64> {
55        use crate::config::TimeoutClass::*;
56        match class {
57            Text => self.text,
58            Idle => self.idle,
59            Command => self.command,
60            Exit => self.exit,
61            Ready => self.ready,
62        }
63    }
64
65    /// Apply higher-precedence timeout values over these defaults.
66    pub fn with_overrides(self, overrides: Self) -> Self {
67        Self {
68            text: overrides.text.or(self.text),
69            idle: overrides.idle.or(self.idle),
70            command: overrides.command.or(self.command),
71            exit: overrides.exit.or(self.exit),
72            ready: overrides.ready.or(self.ready),
73        }
74    }
75}
76
77#[derive(Debug, Clone)]
78pub struct OpenOptions {
79    pub backend: crate::terminal::backend::Backend,
80    pub shell: Option<Shell>,
81    /// Terminal settings, already resolved from the config file by the
82    /// client. The daemon never reads that file: it is long-lived and shared,
83    /// so it has no single working directory to resolve a project-local config
84    /// against.
85    pub profile: crate::profile::Profile,
86    pub cols: u16,
87    pub rows: u16,
88    pub cwd: Option<String>,
89    pub env: Vec<(String, String)>,
90    pub wait_ready: Option<bool>,
91    pub restart: bool,
92    pub timeouts: Timeouts,
93    pub recording: AutomaticRecording,
94}
95
96impl Default for OpenOptions {
97    fn default() -> Self {
98        Self {
99            backend: crate::terminal::backend::Backend::default(),
100            shell: None,
101            profile: crate::profile::Profile::default(),
102            cols: crate::config::DEFAULT_COLS,
103            rows: crate::config::DEFAULT_ROWS,
104            cwd: None,
105            env: Vec::new(),
106            wait_ready: None,
107            restart: false,
108            timeouts: Timeouts::default(),
109            recording: AutomaticRecording::default(),
110        }
111    }
112}
113
114#[derive(Debug, Clone)]
115pub struct RunOptions {
116    pub backend: crate::terminal::backend::Backend,
117    pub program: String,
118    pub args: Vec<String>,
119    /// Terminal settings, already resolved from the config file by the
120    /// client. The daemon never reads that file: it is long-lived and shared,
121    /// so it has no single working directory to resolve a project-local config
122    /// against.
123    pub profile: crate::profile::Profile,
124    pub cols: u16,
125    pub rows: u16,
126    pub cwd: Option<String>,
127    pub env: Vec<(String, String)>,
128    pub wait_ready: Option<bool>,
129    pub restart: bool,
130    pub timeouts: Timeouts,
131    pub recording: AutomaticRecording,
132}
133
134/// Clipboard text or regex.
135#[derive(Debug, Clone)]
136pub enum ClipboardPattern {
137    Text(String),
138    Regex(regex::Regex),
139}
140
141impl ClipboardPattern {
142    pub fn text(text: impl Into<String>) -> Self {
143        Self::Text(text.into())
144    }
145
146    pub fn regex(pattern: &str) -> Result<Self, regex::Error> {
147        regex::Regex::new(pattern).map(Self::Regex)
148    }
149
150    pub fn as_str(&self) -> &str {
151        match self {
152            Self::Text(text) => text,
153            Self::Regex(regex) => regex.as_str(),
154        }
155    }
156
157    pub(crate) fn matches(&self, value: &str) -> bool {
158        match self {
159            Self::Text(text) => value.contains(text),
160            Self::Regex(regex) => regex.is_match(value),
161        }
162    }
163}
164
165impl From<String> for ClipboardPattern {
166    fn from(text: String) -> Self {
167        Self::Text(text)
168    }
169}
170
171impl From<&str> for ClipboardPattern {
172    fn from(text: &str) -> Self {
173        Self::Text(text.to_string())
174    }
175}
176
177impl From<regex::Regex> for ClipboardPattern {
178    fn from(regex: regex::Regex) -> Self {
179        Self::Regex(regex)
180    }
181}
182
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "lowercase")]
185pub enum KeyAction {
186    #[default]
187    Press,
188    Down,
189    Repeat,
190    Up,
191}
192
193/// A terminal mouse button.
194#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum MouseButton {
197    #[default]
198    Left,
199    Middle,
200    Right,
201}
202
203/// Button and modifier state for a mouse action.
204///
205/// The default is an unmodified left button.
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(default, deny_unknown_fields)]
208pub struct MouseOptions {
209    pub button: MouseButton,
210    pub alt: bool,
211    pub ctrl: bool,
212    pub shift: bool,
213}
214
215impl MouseOptions {
216    pub const fn new(button: MouseButton) -> Self {
217        Self {
218            button,
219            alt: false,
220            ctrl: false,
221            shift: false,
222        }
223    }
224
225    pub const fn with_alt(mut self) -> Self {
226        self.alt = true;
227        self
228    }
229
230    pub const fn with_ctrl(mut self) -> Self {
231        self.ctrl = true;
232        self
233    }
234
235    pub const fn with_shift(mut self) -> Self {
236        self.shift = true;
237        self
238    }
239
240    /// Return the SGR mouse button code used by terminal protocols.
241    pub const fn sgr_code(self) -> u8 {
242        let button = match self.button {
243            MouseButton::Left => 0,
244            MouseButton::Middle => 1,
245            MouseButton::Right => 2,
246        };
247        button + 4 * self.shift as u8 + 8 * self.alt as u8 + 16 * self.ctrl as u8
248    }
249
250    /// Decode an SGR mouse button code used by protocol adapters.
251    pub const fn from_sgr_code(code: u8) -> Option<Self> {
252        if code & !0b1_1111 != 0 {
253            return None;
254        }
255        let button = match code & 0b11 {
256            0 => MouseButton::Left,
257            1 => MouseButton::Middle,
258            2 => MouseButton::Right,
259            _ => return None,
260        };
261        Some(Self {
262            button,
263            shift: code & 4 != 0,
264            alt: code & 8 != 0,
265            ctrl: code & 16 != 0,
266        })
267    }
268}
269
270impl From<MouseButton> for MouseOptions {
271    fn from(button: MouseButton) -> Self {
272        Self::new(button)
273    }
274}
275
276mod mouse_options_code {
277    use serde::{Deserialize, Deserializer, Serializer};
278
279    use super::MouseOptions;
280
281    pub fn serialize<S>(options: &MouseOptions, serializer: S) -> Result<S::Ok, S::Error>
282    where
283        S: Serializer,
284    {
285        serializer.serialize_u8(options.sgr_code())
286    }
287
288    pub fn deserialize<'de, D>(deserializer: D) -> Result<MouseOptions, D::Error>
289    where
290        D: Deserializer<'de>,
291    {
292        let code = u8::deserialize(deserializer)?;
293        MouseOptions::from_sgr_code(code).ok_or_else(|| {
294            serde::de::Error::custom(format!("invalid SGR mouse button code {code}"))
295        })
296    }
297}
298
299#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(rename_all = "snake_case")]
301pub enum WhitespaceMode {
302    #[default]
303    Exact,
304    Normalize,
305}
306
307#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "snake_case")]
309pub enum MatchOccurrence {
310    Any,
311    #[default]
312    Unique,
313    First,
314    Last,
315    Nth(usize),
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct TextAnchor {
320    pub text: String,
321    #[serde(default)]
322    pub regex: bool,
323    #[serde(default)]
324    pub occurrence: MatchOccurrence,
325}
326
327#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(default)]
329pub struct TextScope {
330    pub after: Option<TextAnchor>,
331    pub before: Option<TextAnchor>,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(default)]
336pub struct TextSelector {
337    pub text: String,
338    pub regex: bool,
339    pub full: bool,
340    pub whitespace: WhitespaceMode,
341    pub scope: TextScope,
342}
343
344impl Default for TextSelector {
345    fn default() -> Self {
346        Self {
347            text: String::new(),
348            regex: false,
349            full: false,
350            whitespace: WhitespaceMode::Exact,
351            scope: TextScope::default(),
352        }
353    }
354}
355
356impl TextSelector {
357    pub fn new(text: impl Into<String>) -> Self {
358        Self {
359            text: text.into(),
360            ..Self::default()
361        }
362    }
363}
364
365impl From<&str> for TextSelector {
366    fn from(text: &str) -> Self {
367        Self::new(text)
368    }
369}
370
371impl From<String> for TextSelector {
372    fn from(text: String) -> Self {
373        Self::new(text)
374    }
375}
376
377#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(default, deny_unknown_fields)]
379pub struct TextStyle {
380    pub foreground: Option<String>,
381    pub background: Option<String>,
382    pub bold: Option<bool>,
383    pub dim: Option<bool>,
384    pub italic: Option<bool>,
385    pub underline_style: Option<String>,
386    pub underline_color: Option<String>,
387    pub inverse: Option<bool>,
388    pub hidden: Option<bool>,
389    pub strikethrough: Option<bool>,
390    pub blink: Option<bool>,
391}
392
393impl TextStyle {
394    pub fn is_empty(&self) -> bool {
395        self == &Self::default()
396    }
397}
398
399#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(default, deny_unknown_fields)]
401/// Select contiguous per-row runs whose cells match every requested style.
402pub struct StyleSelector {
403    pub style: TextStyle,
404    pub full: bool,
405}
406
407impl From<TextStyle> for StyleSelector {
408    fn from(style: TextStyle) -> Self {
409        Self {
410            style,
411            ..Self::default()
412        }
413    }
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(
418    tag = "kind",
419    content = "selector",
420    rename_all = "snake_case",
421    deny_unknown_fields
422)]
423pub enum LocatorSelector {
424    Text(TextSelector),
425    Style(StyleSelector),
426    Link(LinkSelector),
427    And {
428        left: Box<LocatorQuery>,
429        right: Box<LocatorQuery>,
430    },
431    Or {
432        left: Box<LocatorQuery>,
433        right: Box<LocatorQuery>,
434    },
435    Filter {
436        input: Box<LocatorQuery>,
437        has: Option<Box<LocatorQuery>>,
438        has_not: Option<Box<LocatorQuery>>,
439    },
440}
441
442/// Select cells by their exact OSC 8 URI. An empty URI requires no link.
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(deny_unknown_fields)]
445pub struct LinkSelector {
446    pub uri: String,
447    #[serde(default)]
448    pub full: bool,
449}
450
451impl From<&str> for LinkSelector {
452    fn from(uri: &str) -> Self {
453        Self::from(uri.to_owned())
454    }
455}
456
457impl From<String> for LinkSelector {
458    fn from(uri: String) -> Self {
459        Self { uri, full: false }
460    }
461}
462
463impl LocatorSelector {
464    pub fn full(&self) -> bool {
465        match self {
466            Self::Text(selector) => selector.full,
467            Self::Style(selector) => selector.full,
468            Self::Link(selector) => selector.full,
469            _ => self.children().iter().any(|query| query.uses_full_grid()),
470        }
471    }
472
473    pub fn description(&self) -> String {
474        match self {
475            Self::Text(selector) => selector.text.clone(),
476            Self::Style(_) => "style".to_string(),
477            Self::Link(selector) => format!("link {:?}", selector.uri),
478            Self::And { left, right } => format!(
479                "({}) and ({})",
480                left.selector.description(),
481                right.selector.description()
482            ),
483            Self::Or { left, right } => format!(
484                "({}) or ({})",
485                left.selector.description(),
486                right.selector.description()
487            ),
488            Self::Filter {
489                input,
490                has,
491                has_not,
492            } => {
493                let mut description = input.selector.description();
494                if let Some(has) = has {
495                    description.push_str(&format!(" has ({})", has.selector.description()));
496                }
497                if let Some(has_not) = has_not {
498                    description.push_str(&format!(" has not ({})", has_not.selector.description()));
499                }
500                description
501            }
502        }
503    }
504
505    pub fn children(&self) -> Vec<&LocatorQuery> {
506        match self {
507            Self::And { left, right } | Self::Or { left, right } => vec![left, right],
508            Self::Filter {
509                input,
510                has,
511                has_not,
512            } => {
513                let mut children = vec![input.as_ref()];
514                children.extend(has.as_deref());
515                children.extend(has_not.as_deref());
516                children
517            }
518            _ => Vec::new(),
519        }
520    }
521}
522
523#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(rename_all = "snake_case")]
525pub enum LocatorDirection {
526    /// Search inside each selected parent match.
527    #[default]
528    Within,
529    /// Search after each parent, stopping at the next selected parent.
530    After,
531    /// Search before each parent, starting after the previous selected parent.
532    Before,
533}
534
535fn default_locator_occurrence() -> MatchOccurrence {
536    MatchOccurrence::Any
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540#[serde(deny_unknown_fields)]
541/// A lazy locator expression with occurrence selection and optional parent scope.
542pub struct LocatorQuery {
543    pub selector: LocatorSelector,
544    #[serde(default = "default_locator_occurrence")]
545    pub occurrence: MatchOccurrence,
546    #[serde(default)]
547    pub within: Option<Box<LocatorQuery>>,
548    #[serde(default)]
549    pub direction: LocatorDirection,
550    #[serde(default)]
551    pub style: TextStyle,
552}
553
554impl LocatorQuery {
555    pub fn new(selector: LocatorSelector) -> Self {
556        Self {
557            selector,
558            occurrence: MatchOccurrence::Any,
559            within: None,
560            direction: LocatorDirection::Within,
561            style: TextStyle::default(),
562        }
563    }
564
565    pub fn link(selector: impl Into<LinkSelector>) -> Self {
566        Self::new(LocatorSelector::Link(selector.into()))
567    }
568
569    pub fn and(self, other: Self) -> Self {
570        Self::new(LocatorSelector::And {
571            left: Box::new(self),
572            right: Box::new(other),
573        })
574    }
575
576    pub fn or(self, other: Self) -> Self {
577        Self::new(LocatorSelector::Or {
578            left: Box::new(self),
579            right: Box::new(other),
580        })
581    }
582
583    pub fn filter(self, has: Option<Self>, has_not: Option<Self>) -> Self {
584        Self::new(LocatorSelector::Filter {
585            input: Box::new(self),
586            has: has.map(Box::new),
587            has_not: has_not.map(Box::new),
588        })
589    }
590
591    pub fn text(selector: impl Into<TextSelector>) -> Self {
592        Self {
593            selector: LocatorSelector::Text(selector.into()),
594            occurrence: MatchOccurrence::Any,
595            within: None,
596            direction: LocatorDirection::Within,
597            style: TextStyle::default(),
598        }
599    }
600
601    pub fn style(selector: impl Into<StyleSelector>) -> Self {
602        Self {
603            selector: LocatorSelector::Style(selector.into()),
604            occurrence: MatchOccurrence::Any,
605            within: None,
606            direction: LocatorDirection::Within,
607            style: TextStyle::default(),
608        }
609    }
610
611    pub fn uses_full_grid(&self) -> bool {
612        self.selector.full()
613            || self
614                .within
615                .as_deref()
616                .is_some_and(LocatorQuery::uses_full_grid)
617    }
618}
619
620#[derive(Debug, Clone)]
621pub enum Operation {
622    Open(OpenOptions),
623    Run(RunOptions),
624    Restart {
625        graceful_timeout_ms: u64,
626    },
627    Close,
628    FinishTrace {
629        failed: bool,
630    },
631    State,
632    Text {
633        full: bool,
634    },
635    PackedScreen {
636        full: bool,
637    },
638    Cells {
639        x: u16,
640        y: u16,
641        w: u16,
642        h: u16,
643    },
644    GetCommand,
645    GetOutput,
646    GetExitCode,
647    GetCwd,
648    GetCursor,
649    GetModes,
650    GetColors,
651    GetSize,
652    GetTitle,
653    GetClipboard,
654    GetBellCount,
655    GetBellEvents,
656    Write {
657        data: String,
658    },
659    Submit {
660        data: Option<String>,
661    },
662    Key {
663        keys: Vec<String>,
664        action: KeyAction,
665    },
666    Mouse {
667        action: MouseAction,
668    },
669    Resize {
670        cols: u16,
671        rows: u16,
672    },
673    Signal {
674        name: String,
675    },
676    WaitTitle {
677        text: String,
678        regex: bool,
679        timeout_ms: Option<u64>,
680        not: bool,
681    },
682    WaitClipboard {
683        timeout_ms: Option<u64>,
684    },
685    WaitClipboardMatch {
686        pattern: ClipboardPattern,
687        timeout_ms: Option<u64>,
688    },
689    WaitIdle {
690        timeout_ms: Option<u64>,
691    },
692    WaitCommand {
693        timeout_ms: Option<u64>,
694    },
695    WaitExit {
696        timeout_ms: Option<u64>,
697    },
698    WaitReady {
699        timeout_ms: Option<u64>,
700    },
701    WaitBell {
702        timeout_ms: Option<u64>,
703    },
704    FindLocator {
705        query: LocatorQuery,
706    },
707    ResolveLocator {
708        query: LocatorQuery,
709    },
710    WaitLocator {
711        query: LocatorQuery,
712        not: bool,
713        timeout_ms: Option<u64>,
714    },
715    ClickLocator {
716        query: LocatorQuery,
717        options: MouseOptions,
718        clicks: u8,
719        timeout_ms: Option<u64>,
720    },
721    HighlightLocator {
722        query: LocatorQuery,
723        timeout_ms: Option<u64>,
724    },
725    ExpectTitle {
726        text: String,
727        regex: bool,
728        not: bool,
729        timeout_ms: Option<u64>,
730    },
731    ExpectExitCode {
732        code: i32,
733        timeout_ms: Option<u64>,
734    },
735    /// Wait for a terminal mode to reach `enabled`.
736    ExpectMode {
737        mode: String,
738        enabled: bool,
739        timeout_ms: Option<u64>,
740    },
741    /// Wait for the terminal's colors to match those named.
742    ExpectColors {
743        foreground: Option<String>,
744        background: Option<String>,
745        cursor: Option<String>,
746        /// `OSC 4` palette entries to match, as `(index, color)`.
747        palette: Vec<(u8, String)>,
748        timeout_ms: Option<u64>,
749    },
750    /// Wait for the cursor to match every property the caller named.
751    ExpectCursor {
752        visible: Option<bool>,
753        shape: Option<String>,
754        x: Option<u16>,
755        y: Option<u16>,
756        timeout_ms: Option<u64>,
757    },
758    ExpectOutput {
759        text: String,
760        regex: bool,
761    },
762    ExpectBellCount {
763        count: u64,
764        timeout_ms: Option<u64>,
765    },
766    Snapshot {
767        name: String,
768        update: bool,
769        include_style: bool,
770        include_title: bool,
771        cwd: Option<String>,
772    },
773    Screenshot {
774        full: bool,
775        path: Option<String>,
776        zoom: Option<f64>,
777        background: Option<CaptureBackground>,
778    },
779    StartRecording {
780        path: String,
781        format: Option<RecordingFormat>,
782        fps: Option<u8>,
783        speed: Option<f64>,
784        idle_time_limit: Option<f64>,
785        zoom: Option<f64>,
786        background: Option<CaptureBackground>,
787    },
788    StopRecording,
789}
790
791impl Operation {
792    /// Wait for clipboard text or a regex.
793    pub fn wait_clipboard_match(
794        pattern: impl Into<ClipboardPattern>,
795        timeout_ms: Option<u64>,
796    ) -> Self {
797        Self::WaitClipboardMatch {
798            pattern: pattern.into(),
799            timeout_ms,
800        }
801    }
802}
803
804#[derive(Debug, Clone)]
805pub enum OperationResult {
806    Unit,
807    Open(OpenResult),
808    /// Boxed because it is much larger than every other variant, and a
809    /// `Result` of this enum is returned from every operation.
810    State(Box<State>),
811    Text(String),
812    PackedScreen(PackedScreen),
813    Cells(Vec<Cell>),
814    Matches(Vec<TextMatch>),
815    Command(Option<String>),
816    Output(Option<String>),
817    ExitCode(Option<i32>),
818    Cwd(Option<String>),
819    Title(Option<String>),
820    Clipboard(String),
821    Cursor(Cursor),
822    Modes(BTreeMap<String, bool>),
823    Colors(TerminalColors),
824    Size(Size),
825    BellCount(u64),
826    BellEvents(Vec<BellEvent>),
827    Snapshot(SnapshotResult),
828    Screenshot(ScreenshotResult),
829    Recording(String),
830}
831
832#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
833#[serde(rename_all = "snake_case")]
834pub enum ErrorKind {
835    Assertion,
836    Usage,
837    NoSession,
838    Internal,
839}
840
841impl ErrorKind {
842    pub fn exit_code(self) -> i32 {
843        match self {
844            ErrorKind::Assertion => 1,
845            ErrorKind::Usage => 2,
846            ErrorKind::NoSession => 3,
847            ErrorKind::Internal => 5,
848        }
849    }
850
851    pub fn as_str(self) -> &'static str {
852        match self {
853            ErrorKind::Assertion => "assertion",
854            ErrorKind::Usage => "usage",
855            ErrorKind::NoSession => "no_session",
856            ErrorKind::Internal => "internal",
857        }
858    }
859}
860
861#[derive(Debug, Clone)]
862#[non_exhaustive]
863pub struct TuiTestError {
864    pub kind: ErrorKind,
865    pub message: String,
866    pub details: Option<Box<FailureDetails>>,
867    pub artifact: Option<Box<FailureArtifactRef>>,
868    pub(crate) report: Option<Box<FailureReport>>,
869    pub(crate) observation: Option<Box<FailureObservation>>,
870}
871
872impl TuiTestError {
873    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
874        Self {
875            kind,
876            message: message.into(),
877            details: None,
878            artifact: None,
879            report: None,
880            observation: None,
881        }
882    }
883
884    pub fn assertion(message: impl Into<String>) -> Self {
885        Self::new(ErrorKind::Assertion, message)
886    }
887
888    pub fn usage(message: impl Into<String>) -> Self {
889        Self::new(ErrorKind::Usage, message)
890    }
891
892    pub fn no_session() -> Self {
893        Self::new(
894            ErrorKind::NoSession,
895            "no active session; run `tui-test open` (or `tui-test run <program>`) first",
896        )
897    }
898
899    pub fn no_restart_metadata() -> Self {
900        Self::new(
901            ErrorKind::NoSession,
902            "session has no restart metadata; run `tui-test open` or \
903             `tui-test run <program>` before `tui-test restart`",
904        )
905    }
906
907    pub fn internal(message: impl Into<String>) -> Self {
908        Self::new(ErrorKind::Internal, message)
909    }
910
911    pub fn with_details(mut self, details: FailureDetails) -> Self {
912        self.details = Some(Box::new(details));
913        self
914    }
915
916    pub fn with_artifact(mut self, artifact: FailureArtifactRef) -> Self {
917        self.artifact = Some(Box::new(artifact));
918        self
919    }
920
921    pub(crate) fn with_report(mut self, report: FailureReport) -> Self {
922        self.report = Some(Box::new(report));
923        self
924    }
925}
926
927impl fmt::Display for TuiTestError {
928    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
929        formatter.write_str(&self.message)
930    }
931}
932
933impl std::error::Error for TuiTestError {}
934
935#[derive(Debug, Clone, Serialize)]
936pub struct OpenResult {
937    pub shell_pid: Option<u32>,
938    pub session: String,
939    pub ready: bool,
940    pub recording: String,
941}
942
943/// The colors the terminal paints with.
944///
945/// Grouped because they are one concept — the colors a program chooses
946/// rather than the ones a cell names — set by sibling sequences and reset by
947/// `OSC 104` and `OSC 110/111/112`. A slot nothing has overridden reports the
948/// color the session's profile gives it, so every field always has an
949/// answer.
950#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
951pub struct TerminalColors {
952    /// The default foreground (`OSC 10`).
953    pub foreground: String,
954    /// The default background (`OSC 11`).
955    pub background: String,
956    /// The cursor color (`OSC 12`).
957    pub cursor: String,
958    /// Palette entries a program moved with `OSC 4`, keyed by index.
959    ///
960    /// Only the entries that differ from the profile are listed: a program
961    /// that recolors slot 1 is interesting, and the 255 it left alone are
962    /// not.
963    pub palette: std::collections::BTreeMap<u8, String>,
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize)]
967pub struct Cursor {
968    pub x: u16,
969    pub y: u16,
970    /// Whether the cursor is drawn (`DECTCEM`).
971    pub visible: bool,
972    /// `block`, `underline`, or `bar` (`DECSCUSR`).
973    pub shape: String,
974    /// The cursor color as `#rrggbb`, after any `OSC 12` a program sent.
975    pub color: String,
976}
977
978#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
979pub struct Size {
980    pub cols: u16,
981    pub rows: u16,
982}
983
984#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
985pub struct BellEvent {
986    pub sequence: u64,
987    pub elapsed_ms: u64,
988}
989
990#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
991pub struct TextPosition {
992    pub row: u32,
993    pub column: u16,
994}
995
996#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
997pub struct TextSpan {
998    pub row: u32,
999    pub start: u16,
1000    pub end: u16,
1001}
1002
1003#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1004pub struct TextMatch {
1005    pub text: String,
1006    pub start: TextPosition,
1007    /// Exclusive end position.
1008    pub end: TextPosition,
1009    /// Per-row column ranges with exclusive ends.
1010    pub spans: Vec<TextSpan>,
1011}
1012
1013#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1014pub struct EffectiveTimeouts {
1015    pub text: u64,
1016    pub idle: u64,
1017    pub command: u64,
1018    pub exit: u64,
1019    pub ready: u64,
1020}
1021
1022#[derive(Debug, Clone, Serialize)]
1023pub struct State {
1024    pub session_shell: Option<String>,
1025    pub cols: u16,
1026    pub rows: u16,
1027    pub cursor: Cursor,
1028    pub title: Option<String>,
1029    pub cwd: Option<String>,
1030    pub last_command: Option<String>,
1031    pub last_exit: Option<i32>,
1032    /// Signal reported when the process was terminated by one.
1033    pub exit_signal: Option<String>,
1034    pub exited: Option<i32>,
1035    pub ready: bool,
1036    pub bell_count: u64,
1037    /// Terminal modes the child has turned on, by name.
1038    ///
1039    /// A map rather than a list, so a reader can tell "off" from "this build
1040    /// does not know that mode" and every key is always present.
1041    pub modes: BTreeMap<String, bool>,
1042    /// Mouse tracking level: `none`, `click`, `drag`, or `motion`.
1043    ///
1044    /// Separate from `modes` because mouse tracking is not a set of
1045    /// independent switches: `CSI ?1002 h` replaces `CSI ?1000 h` rather than
1046    /// joining it, so reporting it as booleans would say two are on when the
1047    /// terminal only honors the last.
1048    ///
1049    /// Independent of how the child asked for the reports to be encoded.
1050    /// `CSI ?1000 h` on its own is `click`, whether or not `CSI ?1006 h`
1051    /// followed it to ask for SGR coordinates.
1052    pub mouse_mode: String,
1053    /// The terminal's colors, as `#rrggbb`: the three defaults and any
1054    /// palette entry a program overrode.
1055    pub colors: TerminalColors,
1056    pub timeouts: EffectiveTimeouts,
1057    pub text: String,
1058}
1059
1060#[derive(Debug, Clone, PartialEq, Eq)]
1061pub enum CellColor {
1062    Default,
1063    Indexed(u8),
1064    Rgb(u8, u8, u8),
1065}
1066
1067impl Serialize for CellColor {
1068    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1069    where
1070        S: serde::Serializer,
1071    {
1072        match self {
1073            CellColor::Default => serializer.serialize_str("default"),
1074            CellColor::Indexed(index) => serializer.serialize_u8(*index),
1075            CellColor::Rgb(r, g, b) => serializer.serialize_str(&format!("#{r:02x}{g:02x}{b:02x}")),
1076        }
1077    }
1078}
1079
1080#[derive(Debug, Clone, Serialize)]
1081pub struct Cell {
1082    pub x: u16,
1083    pub y: u16,
1084    pub char: String,
1085    pub fg: CellColor,
1086    pub bg: CellColor,
1087    pub bold: bool,
1088    pub dim: bool,
1089    pub italic: bool,
1090    pub inverse: bool,
1091    pub invisible: bool,
1092    pub strike: bool,
1093    pub blink: bool,
1094    pub underline: bool,
1095    pub underline_style: String,
1096    pub underline_color: CellColor,
1097    /// The OSC 8 URI this cell links to, empty when it links nowhere.
1098    pub link: String,
1099    /// The link's `id=` parameter, empty when the sequence carried none.
1100    ///
1101    /// Separate from `link` because it identifies a link across a wrap rather
1102    /// than describing where the link points: a program that wraps its own
1103    /// links tags each run with a shared `id=` so a terminal can treat them as
1104    /// one.
1105    ///
1106    /// Backend-dependent, unlike everything else on a cell. Ghostty's FFI
1107    /// exposes a link's URI and nothing else, so this is always empty there
1108    /// and "no `id=` was sent" cannot be told apart from "this backend cannot
1109    /// see it". An assertion built on it will not mean the same thing on every
1110    /// backend, which is why no locator matches on it.
1111    pub link_id: String,
1112}
1113
1114#[derive(Debug, Clone)]
1115pub struct PackedScreen {
1116    /// Logical terminal dimensions for the newline-delimited UTF-8 snapshot.
1117    /// Rows retain trailing spaces and blank lines; byte offsets are not cell
1118    /// offsets because Unicode graphemes may occupy multiple bytes.
1119    pub cols: u16,
1120    pub rows: u16,
1121    pub utf8: Vec<u8>,
1122}
1123
1124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1125#[serde(rename_all = "lowercase")]
1126pub enum SnapshotResult {
1127    Passed,
1128    Written,
1129    Updated,
1130}
1131
1132#[derive(Debug, Clone)]
1133pub enum ScreenshotResult {
1134    Path(String),
1135    Text(String),
1136}
1137
1138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1139#[serde(rename_all = "lowercase")]
1140pub enum RecordingFormat {
1141    Apng,
1142    Gif,
1143    Mp4,
1144    Cast,
1145}
1146
1147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1148#[serde(rename_all = "snake_case", tag = "kind", content = "color")]
1149pub enum CaptureBackground {
1150    Color(crate::profile::Rgb),
1151    Transparent,
1152}
1153
1154impl CaptureBackground {
1155    pub fn parse(value: &str) -> Result<Self, TuiTestError> {
1156        if value.eq_ignore_ascii_case("transparent") {
1157            return Ok(Self::Transparent);
1158        }
1159        crate::profile::Rgb::parse(value)
1160            .map(Self::Color)
1161            .map_err(TuiTestError::usage)
1162    }
1163}
1164
1165impl RecordingFormat {
1166    pub fn infer(path: &str) -> Option<Self> {
1167        let extension = std::path::Path::new(path)
1168            .extension()?
1169            .to_str()?
1170            .to_ascii_lowercase();
1171        match extension.as_str() {
1172            "png" | "apng" => Some(Self::Apng),
1173            "gif" => Some(Self::Gif),
1174            "mp4" => Some(Self::Mp4),
1175            "cast" => Some(Self::Cast),
1176            _ => None,
1177        }
1178    }
1179}
1180
1181pub(crate) fn resolve_zoom(zoom: Option<f64>) -> Result<f64, TuiTestError> {
1182    let zoom = zoom.unwrap_or(1.0);
1183    if !zoom.is_finite() || zoom <= 0.0 {
1184        return Err(TuiTestError::usage(
1185            "zoom must be finite and greater than zero",
1186        ));
1187    }
1188    if zoom > f64::from(f32::MAX) / 2.0 {
1189        return Err(TuiTestError::usage("zoom is too large"));
1190    }
1191    Ok(zoom)
1192}
1193
1194#[derive(Debug, Clone, Serialize)]
1195pub struct RuntimeStatus {
1196    pub session: String,
1197    pub shell_pid: Option<u32>,
1198    #[serde(skip_serializing_if = "Option::is_none")]
1199    pub cols: Option<u16>,
1200    #[serde(skip_serializing_if = "Option::is_none")]
1201    pub rows: Option<u16>,
1202    #[serde(skip_serializing_if = "Option::is_none")]
1203    pub shell: Option<String>,
1204    #[serde(skip_serializing_if = "Option::is_none")]
1205    pub exited: Option<i32>,
1206    #[serde(skip_serializing_if = "Option::is_none")]
1207    pub timeouts: Option<EffectiveTimeouts>,
1208}
1209
1210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1211#[serde(tag = "op", rename_all = "snake_case")]
1212pub enum MouseAction {
1213    Click {
1214        x: Option<u16>,
1215        y: Option<u16>,
1216        on_text: Option<String>,
1217        #[serde(default, rename = "button", with = "mouse_options_code")]
1218        options: MouseOptions,
1219        clicks: u8,
1220    },
1221    Move {
1222        x: u16,
1223        y: u16,
1224    },
1225    Down {
1226        x: u16,
1227        y: u16,
1228        #[serde(default, rename = "button", with = "mouse_options_code")]
1229        options: MouseOptions,
1230    },
1231    Up {
1232        x: u16,
1233        y: u16,
1234        #[serde(default, rename = "button", with = "mouse_options_code")]
1235        options: MouseOptions,
1236    },
1237    Drag {
1238        x1: u16,
1239        y1: u16,
1240        x2: u16,
1241        y2: u16,
1242        #[serde(default, rename = "button", with = "mouse_options_code")]
1243        options: MouseOptions,
1244    },
1245    Scroll {
1246        direction: String,
1247        amount: u16,
1248    },
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253    use super::*;
1254
1255    #[test]
1256    fn capture_background_accepts_hex_rgb_and_transparency() {
1257        for (value, expected) in [
1258            ("#1aF", "#11aaff"),
1259            ("123456", "#123456"),
1260            (" #AbCdEf ", "#abcdef"),
1261            ("#000000", "#000000"),
1262            ("#ffffff", "#ffffff"),
1263        ] {
1264            let background = CaptureBackground::parse(value).unwrap();
1265            assert_eq!(
1266                background,
1267                CaptureBackground::Color(crate::profile::Rgb::parse(expected).unwrap()),
1268                "{value:?}"
1269            );
1270            let json = serde_json::to_string(&background).unwrap();
1271            assert_eq!(
1272                serde_json::from_str::<CaptureBackground>(&json).unwrap(),
1273                background
1274            );
1275        }
1276        for value in ["transparent", "TRANSPARENT"] {
1277            assert_eq!(
1278                CaptureBackground::parse(value).unwrap(),
1279                CaptureBackground::Transparent
1280            );
1281        }
1282    }
1283
1284    #[test]
1285    fn invalid_capture_background_colors_are_usage_errors() {
1286        for value in [
1287            "",
1288            " ",
1289            "#",
1290            "#12",
1291            "#1234",
1292            "#12345",
1293            "#1234567",
1294            "#12345678",
1295            "#ggg",
1296            "#ff00zz",
1297            "##fff",
1298            "#-12345",
1299            "#12é34",
1300            "123",
1301            "256,0,0",
1302            "-1,0,0",
1303            "1,2",
1304            "1,2,3,4",
1305            "1.5,0,0",
1306            "rgb(256,0,0)",
1307        ] {
1308            let error = CaptureBackground::parse(value).unwrap_err();
1309            assert_eq!(error.kind, ErrorKind::Usage, "{value:?}");
1310            assert!(error.message.contains("color"), "{value:?}: {error}");
1311        }
1312    }
1313
1314    #[test]
1315    fn clipboard_patterns_infer_matching_from_the_rust_type() {
1316        let literal: ClipboardPattern = "ready".into();
1317        assert!(literal.matches("prefix-ready-suffix"));
1318
1319        let regex: ClipboardPattern = regex::Regex::new(r"^build-[0-9]+$").unwrap().into();
1320        assert!(regex.matches("build-123"));
1321        assert!(!regex.matches("prefix-build-123"));
1322
1323        assert!(matches!(
1324            Operation::wait_clipboard_match("ready", Some(5_000)),
1325            Operation::WaitClipboardMatch { .. }
1326        ));
1327    }
1328
1329    #[test]
1330    fn recording_format_is_inferred_from_supported_extensions() {
1331        assert_eq!(
1332            RecordingFormat::infer("demo.png"),
1333            Some(RecordingFormat::Apng)
1334        );
1335        assert_eq!(
1336            RecordingFormat::infer("demo.APNG"),
1337            Some(RecordingFormat::Apng)
1338        );
1339        assert_eq!(
1340            RecordingFormat::infer("demo.gif"),
1341            Some(RecordingFormat::Gif)
1342        );
1343        assert_eq!(
1344            RecordingFormat::infer("demo.MP4"),
1345            Some(RecordingFormat::Mp4)
1346        );
1347        assert_eq!(
1348            RecordingFormat::infer("demo.cast"),
1349            Some(RecordingFormat::Cast)
1350        );
1351        assert_eq!(RecordingFormat::infer("demo.webm"), None);
1352    }
1353
1354    #[test]
1355    fn mouse_options_are_semantic_and_wire_compatible() {
1356        let options = MouseOptions::new(MouseButton::Middle)
1357            .with_ctrl()
1358            .with_shift();
1359        assert_eq!(options.sgr_code(), 21);
1360        assert_eq!(MouseOptions::from_sgr_code(21), Some(options));
1361        assert_eq!(MouseOptions::from_sgr_code(3), None);
1362        assert_eq!(MouseOptions::from_sgr_code(32), None);
1363        assert_eq!(serde_json::to_value(options).unwrap()["button"], "middle");
1364
1365        let action = MouseAction::Down {
1366            x: 4,
1367            y: 7,
1368            options,
1369        };
1370        let value = serde_json::to_value(&action).unwrap();
1371        assert_eq!(value["button"], 21);
1372        assert_eq!(
1373            serde_json::from_value::<MouseAction>(value).unwrap(),
1374            action
1375        );
1376
1377        let defaulted: MouseAction = serde_json::from_str(r#"{"op":"down","x":4,"y":7}"#).unwrap();
1378        assert_eq!(
1379            defaulted,
1380            MouseAction::Down {
1381                x: 4,
1382                y: 7,
1383                options: MouseOptions::default(),
1384            }
1385        );
1386    }
1387
1388    #[test]
1389    fn zoom_defaults_to_one_and_rejects_invalid_values() {
1390        assert_eq!(resolve_zoom(None).unwrap(), 1.0);
1391        assert_eq!(resolve_zoom(Some(0.5)).unwrap(), 0.5);
1392        for zoom in [0.0, -1.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
1393            assert!(resolve_zoom(Some(zoom)).is_err());
1394        }
1395    }
1396
1397    #[test]
1398    fn nested_selectors_use_full_grid_when_any_stage_requests_it() {
1399        let mut parent = TextSelector::new("parent");
1400        parent.full = true;
1401        let child = LocatorQuery {
1402            selector: LocatorSelector::Text(TextSelector::new("child")),
1403            occurrence: MatchOccurrence::Any,
1404            within: Some(Box::new(LocatorQuery::text(parent))),
1405            direction: LocatorDirection::Within,
1406            style: Default::default(),
1407        };
1408        assert!(child.uses_full_grid());
1409
1410        let mut full_child = TextSelector::new("child");
1411        full_child.full = true;
1412        let query = LocatorQuery {
1413            selector: LocatorSelector::Text(full_child),
1414            occurrence: MatchOccurrence::Any,
1415            within: Some(Box::new(LocatorQuery::text("parent"))),
1416            direction: LocatorDirection::Within,
1417            style: Default::default(),
1418        };
1419        assert!(query.uses_full_grid());
1420    }
1421}