Skip to main content

command_stream/terminal/
capture.rs

1use super::artifacts::{unroll_terminal_frames, write_terminal_artifacts};
2use super::buffer::{
3    take_terminal_input, terminal_parser, valid_dimensions, BufferChange, BufferFrame,
4    BufferPublisher, TerminalParser,
5};
6use super::types::{
7    Asciicast, AsciicastEvent, AsciicastHeader, TerminalCapture, TerminalCaptureError,
8    TerminalCaptureOptions, TerminalCursor, TerminalFrame, TerminalInteraction, TerminalResize,
9};
10use portable_pty::{native_pty_system, Child, CommandBuilder, ExitStatus, MasterPty, PtySize};
11use regex::Regex;
12use std::collections::HashMap;
13use std::io::{Read, Write};
14use std::sync::mpsc;
15use std::time::{Duration, Instant};
16
17const ERASE_SCREEN: &[u8] = b"\x1b[2J";
18
19fn elapsed(started: Instant) -> f64 {
20    (started.elapsed().as_secs_f64() * 1_000_000.0).round() / 1_000_000.0
21}
22
23fn trim_trailing_blank(mut lines: Vec<String>) -> Vec<String> {
24    while lines.last().is_some_and(String::is_empty) {
25        lines.pop();
26    }
27    lines
28}
29
30fn frame(parser: &TerminalParser, started: Instant) -> TerminalFrame {
31    let screen = parser.screen();
32    let (rows, cols) = screen.size();
33    let (cursor_y, cursor_x) = screen.cursor_position();
34    let lines = trim_trailing_blank(screen.rows(0, cols).collect());
35    TerminalFrame {
36        time: elapsed(started),
37        cols,
38        rows,
39        cursor: TerminalCursor {
40            x: cursor_x,
41            y: cursor_y,
42        },
43        alternate: screen.alternate_screen(),
44        screen: lines.clone(),
45        lines,
46    }
47}
48
49fn same_frame(left: &TerminalFrame, right: &TerminalFrame) -> bool {
50    left.cols == right.cols
51        && left.rows == right.rows
52        && left.cursor == right.cursor
53        && left.alternate == right.alternate
54        && left.lines == right.lines
55}
56
57fn append_frame(frames: &mut Vec<TerminalFrame>, parser: &TerminalParser, started: Instant) {
58    let next = frame(parser, started);
59    if frames
60        .last()
61        .is_none_or(|previous| !same_frame(previous, &next))
62    {
63        frames.push(next);
64    }
65}
66
67fn render_segments(data: &[u8]) -> Vec<&[u8]> {
68    let positions = data
69        .windows(ERASE_SCREEN.len())
70        .enumerate()
71        .filter_map(|(index, window)| (window == ERASE_SCREEN).then_some(index))
72        .collect::<Vec<_>>();
73    if positions.is_empty() {
74        return vec![data];
75    }
76
77    let mut segments = Vec::new();
78    if positions[0] > 0 {
79        segments.push(&data[..positions[0]]);
80    }
81    for (index, position) in positions.iter().enumerate() {
82        let end = positions.get(index + 1).copied().unwrap_or(data.len());
83        segments.push(&data[*position..end]);
84    }
85    segments
86}
87
88fn drain_complete_render_data(pending: &mut Vec<u8>) -> Vec<u8> {
89    let maximum = pending.len().min(ERASE_SCREEN.len() - 1);
90    let pending_length = (1..=maximum)
91        .rev()
92        .find(|length| ERASE_SCREEN.starts_with(&pending[pending.len() - length..]))
93        .unwrap_or(0);
94    pending.drain(..pending.len() - pending_length).collect()
95}
96
97fn record(asciicast: &mut Asciicast, started: Instant, code: &str, data: impl Into<String>) {
98    asciicast.events.push(AsciicastEvent {
99        time: elapsed(started),
100        code: code.into(),
101        data: data.into(),
102    });
103}
104
105fn validate_interaction(interaction: &TerminalInteraction) -> Result<(), TerminalCaptureError> {
106    let has_action =
107        interaction.text.is_some() || interaction.key.is_some() || interaction.resize.is_some();
108    let has_wait = interaction.after.is_some()
109        || interaction.after_regex.is_some()
110        || interaction.idle_duration > Duration::ZERO;
111    if has_action || has_wait {
112        return Ok(());
113    }
114
115    Err(TerminalCaptureError::new(
116        format!(
117            "invalid terminal interaction {interaction:?}; expected an action with text, key, or \
118             resize, or a wait with after, after_regex, or idle_duration"
119        ),
120        None,
121    ))
122}
123
124fn apply_interaction(
125    interaction: &TerminalInteraction,
126    writer: &mut dyn Write,
127    master: &dyn MasterPty,
128    parser: &mut TerminalParser,
129    asciicast: &mut Asciicast,
130    started: Instant,
131) -> Result<(), TerminalCaptureError> {
132    validate_interaction(interaction)?;
133    if let Some(text) = &interaction.text {
134        writer
135            .write_all(text.as_bytes())
136            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
137        writer
138            .flush()
139            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
140        record(asciicast, started, "i", text.clone());
141    }
142    if let Some(key) = &interaction.key {
143        writer
144            .write_all(key.sequence().as_bytes())
145            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
146        writer
147            .flush()
148            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
149        record(asciicast, started, "i", key.sequence());
150    }
151    if let Some(resize) = interaction.resize {
152        resize_terminal(master, parser, resize)?;
153        record(
154            asciicast,
155            started,
156            "r",
157            format!("{}x{}", resize.cols, resize.rows),
158        );
159    }
160    Ok(())
161}
162
163fn resize_terminal(
164    master: &dyn MasterPty,
165    parser: &mut TerminalParser,
166    resize: TerminalResize,
167) -> Result<(), TerminalCaptureError> {
168    if !valid_dimensions(resize.rows, resize.cols) {
169        return Err(TerminalCaptureError::new(
170            "invalid terminal dimensions",
171            None,
172        ));
173    }
174    master
175        .resize(PtySize {
176            rows: resize.rows,
177            cols: resize.cols,
178            pixel_width: 0,
179            pixel_height: 0,
180        })
181        .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
182    parser.screen_mut().set_size(resize.rows, resize.cols);
183    Ok(())
184}
185
186fn asciicast(options: &TerminalCaptureOptions) -> Asciicast {
187    let mut env = HashMap::new();
188    env.insert("SHELL".into(), options.file.clone());
189    env.insert(
190        "TERM".into(),
191        options
192            .env
193            .get("TERM")
194            .cloned()
195            .unwrap_or_else(|| "xterm-256color".into()),
196    );
197    Asciicast {
198        header: AsciicastHeader {
199            version: 2,
200            width: options.cols,
201            height: options.rows,
202            timestamp: chrono::Utc::now().timestamp(),
203            env,
204        },
205        events: Vec::new(),
206    }
207}
208
209fn spawn_reader(mut reader: Box<dyn Read + Send>) -> mpsc::Receiver<Vec<u8>> {
210    let (sender, receiver) = mpsc::channel();
211    std::thread::spawn(move || {
212        let mut buffer = [0_u8; 8192];
213        loop {
214            match reader.read(&mut buffer) {
215                Ok(0) | Err(_) => break,
216                Ok(length) => {
217                    if sender.send(buffer[..length].to_vec()).is_err() {
218                        break;
219                    }
220                }
221            }
222        }
223    });
224    receiver
225}
226
227fn capture_result(
228    status: portable_pty::ExitStatus,
229    output: String,
230    frames: Vec<TerminalFrame>,
231    interaction_count: usize,
232    asciicast: Asciicast,
233) -> TerminalCapture {
234    TerminalCapture {
235        exit_code: status.exit_code() as i32,
236        signal: status.signal().map(str::to_owned),
237        transcript: unroll_terminal_frames(&frames),
238        output,
239        frames,
240        interaction_count,
241        asciicast,
242    }
243}
244
245/// Readiness condition for [`TerminalSession::wait_for`], mirroring the
246/// `after` / `after_regex` vocabulary of [`TerminalInteraction`].
247#[derive(Debug, Clone)]
248pub enum TerminalPattern {
249    Text(String),
250    Regex(Regex),
251}
252
253impl TerminalPattern {
254    pub fn text(value: impl Into<String>) -> Self {
255        Self::Text(value.into())
256    }
257
258    pub fn regex(pattern: &str) -> Result<Self, TerminalCaptureError> {
259        Regex::new(pattern).map(Self::Regex).map_err(|error| {
260            TerminalCaptureError::new(format!("invalid terminal pattern regex: {error}"), None)
261        })
262    }
263
264    fn matches(&self, output: &str) -> bool {
265        match self {
266            Self::Text(value) => output.contains(value),
267            Self::Regex(pattern) => pattern.is_match(output),
268        }
269    }
270}
271
272/// A pseudoterminal that stays open until the caller closes it, so input may be
273/// sent long after the process started.
274pub struct TerminalSession {
275    publisher: BufferPublisher,
276    options: TerminalCaptureOptions,
277    interaction_regexes: Vec<Option<Regex>>,
278    master: Box<dyn MasterPty + Send>,
279    writer: Box<dyn Write + Send>,
280    child: Box<dyn Child + Send + Sync>,
281    receiver: mpsc::Receiver<Vec<u8>>,
282    started: Instant,
283    parser: TerminalParser,
284    recording: Asciicast,
285    output: String,
286    frames: Vec<TerminalFrame>,
287    pending_render: Vec<u8>,
288    terminal_has_output: bool,
289    interaction_index: usize,
290    last_output: Option<Instant>,
291    dirty: bool,
292    reader_closed: bool,
293    status: Option<ExitStatus>,
294    timed_out: bool,
295    stop_deadline: Option<Instant>,
296}
297
298impl TerminalSession {
299    fn open(options: TerminalCaptureOptions) -> Result<Self, TerminalCaptureError> {
300        if options.file.is_empty() {
301            return Err(TerminalCaptureError::new(
302                "open_terminal requires a file",
303                None,
304            ));
305        }
306        if !valid_dimensions(options.rows, options.cols) {
307            return Err(TerminalCaptureError::new(
308                "invalid terminal dimensions",
309                None,
310            ));
311        }
312        for interaction in &options.interactions {
313            validate_interaction(interaction)?;
314        }
315        let interaction_regexes = options
316            .interactions
317            .iter()
318            .map(|interaction| {
319                interaction
320                    .after_regex
321                    .as_ref()
322                    .map(|pattern| {
323                        Regex::new(pattern).map_err(|error| {
324                            TerminalCaptureError::new(
325                                format!("invalid terminal interaction regex: {error}"),
326                                None,
327                            )
328                        })
329                    })
330                    .transpose()
331            })
332            .collect::<Result<Vec<_>, _>>()?;
333        let pty = native_pty_system()
334            .openpty(PtySize {
335                rows: options.rows,
336                cols: options.cols,
337                pixel_width: 0,
338                pixel_height: 0,
339            })
340            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
341        let mut command = CommandBuilder::new(&options.file);
342        command.args(&options.args);
343        if let Some(cwd) = &options.cwd {
344            command.cwd(cwd);
345        }
346        command.env(
347            "TERM",
348            options
349                .env
350                .get("TERM")
351                .map_or("xterm-256color", String::as_str),
352        );
353        for (name, value) in &options.env {
354            command.env(name, value);
355        }
356        command.env("COLUMNS", options.cols.to_string());
357        command.env("LINES", options.rows.to_string());
358        let child = pty
359            .slave
360            .spawn_command(command)
361            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
362        drop(pty.slave);
363        let reader = pty
364            .master
365            .try_clone_reader()
366            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
367        let writer = pty
368            .master
369            .take_writer()
370            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
371        let receiver = spawn_reader(reader);
372        let recording = asciicast(&options);
373        let parser = terminal_parser(options.rows, options.cols, 100_000);
374        let publisher = BufferPublisher::new(parser.screen());
375        Ok(Self {
376            publisher,
377            interaction_regexes,
378            master: pty.master,
379            writer,
380            child,
381            receiver,
382            started: Instant::now(),
383            parser,
384            recording,
385            output: String::new(),
386            frames: Vec::new(),
387            pending_render: Vec::new(),
388            terminal_has_output: false,
389            interaction_index: 0,
390            last_output: None,
391            dirty: false,
392            reader_closed: false,
393            status: None,
394            timed_out: false,
395            stop_deadline: None,
396            options,
397        })
398    }
399
400    /// Raw PTY output seen so far.
401    pub fn output(&self) -> &str {
402        &self.output
403    }
404
405    /// Settled states retained so far.
406    pub fn frames(&self) -> &[TerminalFrame] {
407        &self.frames
408    }
409
410    pub fn snapshot(&self) -> BufferFrame {
411        BufferPublisher::frame(self.parser.screen())
412    }
413
414    pub fn text(&self, color: bool) -> String {
415        if color {
416            String::from_utf8_lossy(&self.parser.screen().contents_formatted()).into_owned()
417        } else {
418            self.parser.screen().contents()
419        }
420    }
421
422    /// Notifications advance when `poll`, `wait_for`, `send` or `finish` runs.
423    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<BufferFrame> {
424        self.publisher.frames.subscribe()
425    }
426
427    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<BufferChange> {
428        self.publisher.changes.subscribe()
429    }
430
431    /// Unrolled transcript of the states retained so far.
432    pub fn transcript(&self) -> String {
433        unroll_terminal_frames(&self.frames)
434    }
435
436    /// Whether the child is still alive.
437    pub fn running(&self) -> bool {
438        self.status.is_none()
439    }
440
441    fn trim_recording(&mut self) {
442        if self.options.recording {
443            return;
444        }
445        if self.frames.len() > 1 {
446            self.frames.drain(..self.frames.len() - 1);
447        }
448        self.recording.events.clear();
449        if self.output.len() > 1_000_000 {
450            let mut start = self.output.len() - 1_000_000;
451            while !self.output.is_char_boundary(start) {
452                start += 1;
453            }
454            self.output.drain(..start);
455        }
456    }
457
458    fn read_available(&mut self) {
459        match self.receiver.recv_timeout(Duration::from_millis(5)) {
460            Ok(data) => {
461                let text = String::from_utf8_lossy(&data);
462                self.output.push_str(&text);
463                record(&mut self.recording, self.started, "o", text.into_owned());
464                self.pending_render.extend_from_slice(&data);
465                let render_data = drain_complete_render_data(&mut self.pending_render);
466                let segments = render_segments(&render_data);
467                let segment_count = segments.len();
468                if self.terminal_has_output && render_data.starts_with(ERASE_SCREEN) {
469                    append_frame(&mut self.frames, &self.parser, self.started);
470                }
471                for (index, segment) in segments.into_iter().enumerate() {
472                    self.parser.process(segment);
473                    let response = take_terminal_input(&mut self.parser);
474                    if !response.is_empty() {
475                        let _ = self.writer.write_all(&response);
476                        let _ = self.writer.flush();
477                        record(
478                            &mut self.recording,
479                            self.started,
480                            "i",
481                            String::from_utf8_lossy(&response).into_owned(),
482                        );
483                    }
484                    self.publisher.publish(self.parser.screen());
485                    self.terminal_has_output |= !segment.is_empty();
486                    if self.options.frames_on_write || index + 1 < segment_count {
487                        append_frame(&mut self.frames, &self.parser, self.started);
488                    }
489                }
490                self.last_output = Some(Instant::now());
491                self.dirty = true;
492                if self
493                    .options
494                    .stop_marker
495                    .as_ref()
496                    .is_some_and(|marker| self.output.contains(marker))
497                    && self.stop_deadline.is_none()
498                {
499                    append_frame(&mut self.frames, &self.parser, self.started);
500                    self.stop_deadline = Some(Instant::now() + self.options.stop_marker_grace);
501                }
502            }
503            Err(mpsc::RecvTimeoutError::Disconnected) => self.reader_closed = true,
504            Err(mpsc::RecvTimeoutError::Timeout) => {}
505        }
506        self.trim_recording();
507    }
508
509    fn idle_for(&self) -> Duration {
510        self.last_output
511            .map_or_else(|| self.started.elapsed(), |instant| instant.elapsed())
512    }
513
514    fn apply_scripted_interactions(&mut self) -> Result<(), TerminalCaptureError> {
515        while let Some(interaction) = self.options.interactions.get(self.interaction_index) {
516            if interaction
517                .after
518                .as_ref()
519                .is_some_and(|marker| !self.output.contains(marker))
520            {
521                break;
522            }
523            if self.interaction_regexes[self.interaction_index]
524                .as_ref()
525                .is_some_and(|pattern| !pattern.is_match(&self.output))
526            {
527                break;
528            }
529            if interaction.idle_duration > Duration::ZERO
530                && self.idle_for() < interaction.idle_duration
531            {
532                break;
533            }
534            let interaction = interaction.clone();
535            append_frame(&mut self.frames, &self.parser, self.started);
536            apply_interaction(
537                &interaction,
538                self.writer.as_mut(),
539                self.master.as_ref(),
540                &mut self.parser,
541                &mut self.recording,
542                self.started,
543            )?;
544            self.publisher.publish(self.parser.screen());
545            self.interaction_index += 1;
546        }
547        self.trim_recording();
548        Ok(())
549    }
550
551    /// Advance the capture by one step: read pending output, apply any scripted
552    /// interaction whose readiness condition became true, retain settled states,
553    /// and enforce the optional deadline.
554    pub fn poll(&mut self) -> Result<(), TerminalCaptureError> {
555        self.read_available();
556        self.apply_scripted_interactions()?;
557
558        if self.dirty
559            && self
560                .last_output
561                .is_some_and(|instant| instant.elapsed() >= self.options.settle_duration)
562        {
563            append_frame(&mut self.frames, &self.parser, self.started);
564            self.dirty = false;
565        }
566        if self.status.is_none() {
567            self.status = self
568                .child
569                .try_wait()
570                .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
571        }
572        if self.status.is_none() {
573            let expired = self
574                .options
575                .timeout
576                .is_some_and(|timeout| self.started.elapsed() >= timeout);
577            let stopped = self
578                .stop_deadline
579                .is_some_and(|deadline| Instant::now() >= deadline);
580            if expired || stopped {
581                self.timed_out = expired;
582                self.stop()?;
583            }
584        }
585        Ok(())
586    }
587
588    fn finished(&self) -> bool {
589        self.status.is_some() && self.reader_closed
590    }
591
592    fn stop(&mut self) -> Result<(), TerminalCaptureError> {
593        let _ = self.child.kill();
594        self.status = Some(
595            self.child
596                .wait()
597                .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?,
598        );
599        Ok(())
600    }
601
602    /// Block until `pattern` has been seen and, when `idle` is non-zero, no
603    /// further output arrived for that long. New output restarts the idle wait.
604    pub fn wait_for(
605        &mut self,
606        pattern: &TerminalPattern,
607        idle: Duration,
608        timeout: Option<Duration>,
609    ) -> Result<(), TerminalCaptureError> {
610        let deadline = timeout.map(|limit| Instant::now() + limit);
611        loop {
612            if pattern.matches(&self.output) && self.idle_for() >= idle {
613                return Ok(());
614            }
615            if self.status.is_some() {
616                self.poll()?;
617                if pattern.matches(&self.output) {
618                    return Ok(());
619                }
620                if self.finished() {
621                    return Err(TerminalCaptureError::new(
622                        "terminal exited before the expected output arrived",
623                        None,
624                    ));
625                }
626                continue;
627            }
628            if deadline.is_some_and(|limit| Instant::now() >= limit) {
629                return Err(TerminalCaptureError::new(
630                    format!(
631                        "terminal wait_for timed out after {} ms",
632                        timeout.unwrap_or_default().as_millis()
633                    ),
634                    None,
635                ));
636            }
637            self.poll()?;
638        }
639    }
640
641    /// Send text, a named key, or a resize to the live terminal, using the same
642    /// vocabulary as [`TerminalInteraction`].
643    pub fn send(&mut self, interaction: &TerminalInteraction) -> Result<(), TerminalCaptureError> {
644        validate_interaction(interaction)?;
645        if let Some(marker) = &interaction.after {
646            let pattern = TerminalPattern::text(marker.clone());
647            self.wait_for(&pattern, interaction.idle_duration, None)?;
648        } else if let Some(expression) = &interaction.after_regex {
649            let pattern = TerminalPattern::regex(expression)?;
650            self.wait_for(&pattern, interaction.idle_duration, None)?;
651        } else if interaction.idle_duration > Duration::ZERO {
652            while self.idle_for() < interaction.idle_duration && self.status.is_none() {
653                self.poll()?;
654            }
655        }
656        if self.status.is_some() {
657            return Err(TerminalCaptureError::new(
658                "terminal session has already exited",
659                None,
660            ));
661        }
662        append_frame(&mut self.frames, &self.parser, self.started);
663        apply_interaction(
664            interaction,
665            self.writer.as_mut(),
666            self.master.as_ref(),
667            &mut self.parser,
668            &mut self.recording,
669            self.started,
670        )?;
671        self.publisher.publish(self.parser.screen());
672        self.trim_recording();
673        Ok(())
674    }
675
676    /// Wait for a child that exits on its own, then produce the capture.
677    pub fn finish(mut self) -> Result<TerminalCapture, TerminalCaptureError> {
678        while !self.finished() {
679            self.poll()?;
680        }
681        self.into_capture()
682    }
683
684    /// Stop the child if it is still running, then produce the capture and write
685    /// any configured artifacts.
686    pub fn close(mut self) -> Result<TerminalCapture, TerminalCaptureError> {
687        if self.status.is_none() {
688            self.stop()?;
689        }
690        while !self.finished() {
691            self.read_available();
692        }
693        self.into_capture()
694    }
695
696    fn into_capture(mut self) -> Result<TerminalCapture, TerminalCaptureError> {
697        let pending = std::mem::take(&mut self.pending_render);
698        self.parser.process(&pending);
699        append_frame(&mut self.frames, &self.parser, self.started);
700        self.trim_recording();
701        let capture = capture_result(
702            self.status
703                .clone()
704                .expect("child status is available after the capture loop"),
705            std::mem::take(&mut self.output),
706            std::mem::take(&mut self.frames),
707            self.interaction_index,
708            std::mem::replace(&mut self.recording, asciicast(&self.options)),
709        );
710        if let Some(directory) = &self.options.artifact_directory {
711            write_terminal_artifacts(
712                directory,
713                &capture.frames,
714                &capture.transcript,
715                &capture.asciicast,
716            )?;
717        }
718        if self.timed_out {
719            return Err(TerminalCaptureError::new(
720                format!(
721                    "terminal command timed out after {} ms",
722                    self.options.timeout.unwrap_or_default().as_millis()
723                ),
724                Some(capture),
725            ));
726        }
727        Ok(capture)
728    }
729}
730
731/// Open a terminal session that stays alive until the caller closes it.
732///
733/// Unlike [`capture_terminal`], input may be sent at any later point through
734/// [`TerminalSession::send`], and readiness can be awaited with
735/// [`TerminalSession::wait_for`]. `options.timeout` defaults to `None` here, so
736/// nothing terminates the child until [`TerminalSession::close`] is called.
737pub fn open_terminal(
738    options: TerminalCaptureOptions,
739) -> Result<TerminalSession, TerminalCaptureError> {
740    TerminalSession::open(TerminalCaptureOptions {
741        timeout: None,
742        ..options
743    })
744}
745
746/// Run a command inside a real pseudoterminal and retain its settled TUI states.
747pub fn capture_terminal(
748    options: TerminalCaptureOptions,
749) -> Result<TerminalCapture, TerminalCaptureError> {
750    if options.file.is_empty() {
751        return Err(TerminalCaptureError::new(
752            "capture_terminal requires a file",
753            None,
754        ));
755    }
756    TerminalSession::open(options)?.finish()
757}
758
759pub async fn capture_terminal_async(
760    options: TerminalCaptureOptions,
761) -> Result<TerminalCapture, TerminalCaptureError> {
762    tokio::task::spawn_blocking(move || capture_terminal(options))
763        .await
764        .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?
765}