Skip to main content

strop_engine/editor/
io.rs

1//! File I/O is owned work. Only matching completions may publish into a view.
2mod codec;
3pub(super) mod native;
4mod remote;
5#[cfg(test)]
6mod remote_tests;
7mod save;
8use super::{Document, Editor};
9use crate::files::FileTarget;
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::mpsc::{self, Receiver, Sender};
13use strop_core::id::{BufferRevision, ByteColumn, DocumentId, LineIndex};
14use strop_core::worker::{self, Completion, FailureKind, Outcome, Ticket, WorkerId};
15use strop_core::{Buffer, SaveReceipt};
16
17#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
18pub enum OpenIntent {
19    Switch {
20        readonly: bool,
21    },
22    Split {
23        vertical: bool,
24    },
25    AtLine {
26        line: LineIndex,
27    },
28    Refresh,
29    Browse,
30    DirectoryParent {
31        child: strop_workspace::RemoteFile,
32    },
33    RemoteDestination,
34    RemoteView {
35        view: super::remote::RemoteView,
36        line: Option<LineIndex>,
37    },
38    Grep {
39        line: LineIndex,
40        column: ByteColumn,
41    },
42    LspLocation {
43        context: strop_lsp::ReplyContext,
44        position: strop_lsp::ServerPosition,
45    },
46    Replace {
47        hits: Vec<(usize, usize, usize, String)>,
48        replacement: String,
49        /// The pending replace review this open feeds (0051 §6 R04);
50        /// a stale generation merges nowhere.
51        review: usize,
52    },
53    /// Open without focus (0044 v2): collection builds load sources in
54    /// the background; the picker keeps focus and focus never moves.
55    CollectionSource {
56        owner: WorkerId,
57    },
58}
59impl OpenIntent {
60    fn requires_file(&self) -> bool {
61        match self {
62            Self::AtLine { .. } | Self::Grep { .. } | Self::LspLocation { .. } => true,
63            Self::RemoteView { view, line } => {
64                line.is_some() || *view != super::remote::RemoteView::default()
65            }
66            _ => false,
67        }
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
72pub struct OpenKey {
73    pub path: FileTarget,
74    pub origin: DocumentId,
75    pub revision: BufferRevision,
76    pub focus: u64,
77    pub intent: OpenIntent,
78    pub selection: strop_remote::ReadSelection,
79}
80
81pub struct Opened {
82    pub document: Document,
83    pub canonical: FileTarget,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
87pub struct SaveKey {
88    pub document: DocumentId,
89    pub revision: BufferRevision,
90    pub focus: u64,
91    pub close: bool,
92    #[serde(with = "strop_core::path_serde::option")]
93    pub target: Option<PathBuf>,
94    pub force: bool,
95}
96
97#[derive(serde::Serialize, serde::Deserialize)]
98pub enum IoEvent {
99    Open(Box<Completion<OpenKey, Opened>>),
100    Save(Box<Completion<SaveKey, SaveReceipt>>),
101    Native(Box<Completion<native::NativeKey, native::NativeResult>>),
102    Remote(super::remote::RemoteEvent),
103    Session {
104        request: WorkerId,
105        outcome: Outcome<()>,
106    },
107}
108
109pub struct IoState {
110    pub tx: Sender<IoEvent>,
111    pub rx: Option<Receiver<IoEvent>>,
112    pub open: HashMap<WorkerId, OpenKey>,
113    navigation: Option<WorkerId>,
114    saves: HashMap<DocumentId, Ticket<SaveKey>>,
115    session: Option<WorkerId>,
116    queued_session: Option<crate::session::SaveRequest>,
117    native: HashMap<WorkerId, native::NativeKey>,
118    pub session_error: Option<String>,
119    pub(crate) format_warnings: HashMap<DocumentId, String>,
120}
121
122impl Default for IoState {
123    fn default() -> Self {
124        let (tx, rx) = mpsc::channel();
125        Self {
126            tx,
127            rx: Some(rx),
128            open: HashMap::new(),
129            navigation: None,
130            saves: HashMap::new(),
131            session: None,
132            queued_session: None,
133            native: HashMap::new(),
134            session_error: None,
135            format_warnings: HashMap::new(),
136        }
137    }
138}
139
140impl Editor {
141    pub fn request_open(&mut self, path: PathBuf, intent: OpenIntent) {
142        self.request_target(FileTarget::Local(path), intent);
143    }
144
145    pub fn request_target(&mut self, target: FileTarget, intent: OpenIntent) {
146        if matches!(intent, OpenIntent::Refresh) && self.remote_write_blocks_refresh(self.current())
147        {
148            self.message =
149                "remote save pending or unconfirmed; settle or :remote verify before refresh"
150                    .into();
151            return;
152        }
153        let selection = match &intent {
154            OpenIntent::RemoteView { view, .. } => view.selection(),
155            OpenIntent::Refresh => self
156                .cur()
157                .remote_metadata()
158                .map_or(strop_remote::ReadSelection::Full, |source| source.selection),
159            _ => strop_remote::ReadSelection::Full,
160        };
161        let requires_file = intent.requires_file();
162        let browse = matches!(
163            intent,
164            OpenIntent::Browse | OpenIntent::DirectoryParent { .. }
165        );
166        if matches!(target, FileTarget::Local(_))
167            && (browse
168                || matches!(
169                    intent,
170                    OpenIntent::RemoteView { .. } | OpenIntent::RemoteDestination
171                ))
172        {
173            self.message = "range/tail/follow views require a remote target".into();
174            return;
175        }
176        let path = match target {
177            FileTarget::Local(path) => FileTarget::Local(self.cwd.join(path)),
178            remote => remote,
179        };
180        if !matches!(intent, OpenIntent::Replace { .. }) {
181            self.cancel_open(worker::CancelReason::Superseded);
182        }
183        let existing = self.docs.iter().find_map(|(id, document)| {
184            (document.matches_target(&path)
185                && document
186                    .remote_metadata()
187                    .is_none_or(|source| source.selection == selection))
188            .then_some(id)
189        });
190        if let Some(id) = existing.filter(|&id| {
191            !matches!(intent, OpenIntent::Refresh)
192                && (!matches!(intent, OpenIntent::Browse | OpenIntent::RemoteDestination)
193                    || self.doc(id).directory_metadata_ref().is_none())
194        }) {
195            if (requires_file && self.doc(id).directory_metadata_ref().is_some())
196                || (browse && self.doc(id).directory_metadata_ref().is_none())
197            {
198                self.message = if browse {
199                    "browse requires a directory"
200                } else {
201                    "this view requires a regular file"
202                }
203                .into();
204                return;
205            }
206            self.finish_open(id, intent);
207            return;
208        }
209        let request = match self.worker_ids.allocate() {
210            Ok(request) => request,
211            Err(error) => {
212                self.message = error.message;
213                return;
214            }
215        };
216        let key = OpenKey {
217            path: path.clone(),
218            origin: self.current(),
219            revision: self.buf().revision(),
220            focus: self.focus_epoch,
221            intent,
222            selection,
223        };
224        if !matches!(
225            key.intent,
226            OpenIntent::Replace { .. } | OpenIntent::CollectionSource { .. }
227        ) {
228            self.io.navigation = Some(request);
229        }
230        self.io.open.insert(request, key.clone());
231        self.message = format!("loading {path}");
232        let tx = self.io.tx.clone();
233        let ticket = Ticket { request, key };
234        match self.tape.request("io.open", &ticket) {
235            Ok(false) => return,
236            Ok(true) => {}
237            Err(error) => {
238                self.handle_io(IoEvent::Open(Box::new(Completion {
239                    ticket,
240                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
241                })));
242                return;
243            }
244        }
245        let client = self.remote_client();
246        let handle = worker::spawn(
247            "strop-open",
248            move |outcome| {
249                let _ = tx.send(IoEvent::Open(Box::new(Completion { ticket, outcome })));
250            },
251            move |cancel| match path {
252                FileTarget::Local(path) => match Buffer::open(&path) {
253                    Ok(buffer) => {
254                        let canonical = buffer
255                            .file_identity()
256                            .map_or_else(|| path.clone(), ToOwned::to_owned);
257                        Outcome::Success(Opened {
258                            document: Document::new(buffer),
259                            canonical: FileTarget::Local(canonical),
260                        })
261                    }
262                    Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
263                },
264                // Container documents open through :containers owned jobs;
265                // a container FileTarget never reads a local path.
266                FileTarget::Container { .. } => Outcome::failed(
267                    FailureKind::InvalidInput,
268                    "container documents open through :containers".to_string(),
269                ),
270                FileTarget::Remote(location) => match if browse {
271                    client
272                        .list(&location, &cancel)
273                        .map(strop_remote::RemoteResource::Directory)
274                } else {
275                    client.open(&location, selection, &cancel)
276                } {
277                    Ok(strop_remote::RemoteResource::File(snapshot)) => {
278                        let canonical = FileTarget::Remote(snapshot.file.clone().into());
279                        Outcome::Success(Opened {
280                            document: Document::remote_snapshot(*snapshot, selection),
281                            canonical,
282                        })
283                    }
284                    Ok(strop_remote::RemoteResource::Directory(snapshot)) => {
285                        if requires_file {
286                            return Outcome::failed(
287                                FailureKind::InvalidInput,
288                                "range/tail/follow requires a regular file",
289                            );
290                        }
291                        let canonical = FileTarget::Remote(snapshot.directory.clone().into());
292                        Outcome::Success(Opened {
293                            document: Document::remote_directory(snapshot),
294                            canonical,
295                        })
296                    }
297                    Err(error) if error.is_cancellation() => {
298                        Outcome::Cancelled(worker::CancelReason::OwnerClosed)
299                    }
300                    Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
301                },
302            },
303        );
304        self.worker_handles.insert(request, handle);
305    }
306
307    fn open_fresh(&self, key: &OpenKey) -> bool {
308        if self.finishing {
309            return false;
310        }
311        // Background work owns its operation generation, not the current focus.
312        match key.intent {
313            OpenIntent::CollectionSource { owner } => {
314                return !self.docs.is_empty()
315                    && self
316                        .collection_build
317                        .as_ref()
318                        .is_some_and(|build| build.owner == owner)
319            }
320            OpenIntent::Replace { review, .. } => {
321                return !self.docs.is_empty()
322                    && self
323                        .review
324                        .replace
325                        .as_ref()
326                        .is_some_and(|pending| pending.id == review)
327            }
328            _ => {}
329        }
330        if let OpenIntent::LspLocation { context, .. } = &key.intent {
331            if !self.lsp_context_fresh(context) {
332                return false;
333            }
334        }
335        !self.docs.is_empty()
336            && self.current() == key.origin
337            && self.focus_epoch == key.focus
338            && self.buf().revision() == key.revision
339    }
340
341    /// Re-resolve every open document (config lands after the startup
342    /// buffer's construction in main).
343    pub fn reresolve_indents(&mut self) {
344        let ids: Vec<_> = self.docs.iter().map(|(id, _)| id).collect();
345        for id in ids {
346            self.resolve_indent_for(id);
347        }
348    }
349
350    /// Indent resolution (0051 R08): manual override → confident
351    /// detection → config, decided independently for style and width.
352    /// Overrides live on the document, so reloads and config refreshes
353    /// preserve them; a detected Tab style never dictates a width —
354    /// display width falls through to the configured/manual width.
355    pub(crate) fn resolve_indent_for(&mut self, document: DocumentId) {
356        use super::document::{Detection, IndentSource};
357        let detection = if self.config.indent_detect {
358            self.docs.get(document).and_then(|doc| doc.detection)
359        } else {
360            None
361        };
362        let configured = super::document::Indent {
363            style: self.config.indent_style,
364            width: self.config.tab_size,
365            style_source: IndentSource::Configured,
366            width_source: IndentSource::Configured,
367        };
368        let Some(doc) = self.docs.get_mut(document) else {
369            return;
370        };
371        let (style, style_source) = match (doc.indent_override.style, detection) {
372            (Some(style), _) => (style, IndentSource::Manual),
373            (None, Some(Detection::Tabs { .. })) => {
374                (crate::config::IndentStyle::Tabs, IndentSource::Detected)
375            }
376            (None, Some(Detection::Spaces { .. })) => {
377                (crate::config::IndentStyle::Spaces, IndentSource::Detected)
378            }
379            (None, _) => (configured.style, IndentSource::Configured),
380        };
381        let (width, width_source) = match (doc.indent_override.width, detection, style_source) {
382            (Some(width), _, _) => (width, IndentSource::Manual),
383            // A detected width is meaningful only with the detected
384            // spaces style it was measured on.
385            (None, Some(Detection::Spaces { width, .. }), IndentSource::Detected) => {
386                (width, IndentSource::Detected)
387            }
388            (None, _, _) => (configured.width, IndentSource::Configured),
389        };
390        doc.indent = super::document::Indent {
391            style,
392            width,
393            style_source,
394            width_source,
395        };
396    }
397
398    fn finish_open(&mut self, document: DocumentId, intent: OpenIntent) {
399        self.resolve_indent_for(document);
400        match intent {
401            OpenIntent::LspLocation { context, position } => {
402                self.finish_lsp_jump(document, position, context)
403            }
404            // 0051 §6 R04: the loaded target joins the pending review —
405            // verified against its fresh text, never auto-applied and
406            // never auto-saved.
407            OpenIntent::Replace {
408                hits,
409                replacement,
410                review,
411            } => self.complete_replace_open(document, &hits, &replacement, review),
412            OpenIntent::Split { vertical } => self.split_document(vertical, document),
413            // Background opens never move focus; a pending collection
414            // build counts down and assembles when its sources land.
415            OpenIntent::CollectionSource { owner } => self.collection_source_ready(owner),
416            intent => {
417                self.switch_to(document);
418                self.set_head(0);
419                self.view_mut().view_top = 0;
420                match intent {
421                    OpenIntent::Switch { readonly: true } => self.buf_mut().readonly = true,
422                    OpenIntent::DirectoryParent { child } => {
423                        if let Some(line) = self
424                            .remote_directory()
425                            .and_then(|directory| directory.line_for(&child))
426                        {
427                            self.set_head(self.buf().line_start(line));
428                        }
429                    }
430                    OpenIntent::AtLine { line } => {
431                        self.set_head(
432                            self.buf()
433                                .line_start(line.get().min(self.buf().last_content_line())),
434                        );
435                        self.run_motion("^");
436                    }
437                    OpenIntent::RemoteView { view, line } => {
438                        if let Some(line) = line {
439                            self.set_head(
440                                self.buf()
441                                    .line_start(line.get().min(self.buf().last_content_line())),
442                            );
443                            self.run_motion("^");
444                        } else if view.follow_limit().is_some() {
445                            self.set_head(super::remote::follow::last_position(self.buf().text()));
446                        }
447                        if let Some(limit) = view.follow_limit() {
448                            self.start_remote_follow(document, limit);
449                        }
450                    }
451                    OpenIntent::Grep { line, column } => {
452                        let line = line.get().min(self.buf().last_content_line());
453                        let offset = self
454                            .buf()
455                            .line_start(line)
456                            .saturating_add(column.get())
457                            .min(self.buf().line_end(line));
458                        self.set_head(self.buf().clamp_boundary(offset));
459                        // grep/symbol landings use the 0051 §7
460                        // placement: center unless comfortably visible
461                        self.place_jump_target();
462                    }
463                    _ => {}
464                }
465                self.remember_remote_destination();
466                self.discover_git();
467                self.lsp_maybe_attach();
468            }
469        }
470    }
471
472    pub(crate) fn request_session_save(&mut self) {
473        let Some(work) = crate::session::capture_save(self) else {
474            return;
475        };
476        if self.io.session.is_some() {
477            // Serialized writes; newest queued capture replaces an unwritten one.
478            self.io.queued_session = Some(work);
479        } else {
480            self.start_session_save(work);
481        }
482    }
483
484    fn start_session_save(&mut self, work: crate::session::SaveRequest) {
485        let request = match self.worker_ids.allocate() {
486            Ok(request) => request,
487            Err(error) => {
488                self.message = error.message;
489                return;
490            }
491        };
492        self.io.session = Some(request);
493        match self.tape.request("io.session", &request) {
494            Ok(false) => return,
495            Ok(true) => {}
496            Err(error) => {
497                self.handle_io(IoEvent::Session {
498                    request,
499                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
500                });
501                return;
502            }
503        }
504        let tx = self.io.tx.clone();
505        let handle = worker::spawn(
506            "strop-session",
507            move |outcome| {
508                let _ = tx.send(IoEvent::Session { request, outcome });
509            },
510            move |_| match work.persist() {
511                Ok(()) => Outcome::Success(()),
512                Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
513            },
514        );
515        self.worker_handles.insert(request, handle);
516    }
517
518    pub fn handle_io(&mut self, event: IoEvent) {
519        super::trace::services::io(&event);
520        match event {
521            IoEvent::Native(completion) => self.handle_native(*completion),
522            IoEvent::Remote(event) => self.handle_remote_event(event),
523            IoEvent::Open(completion) => {
524                let request = completion.ticket.request;
525                if self.io.open.get(&request) != Some(&completion.ticket.key) {
526                    return;
527                }
528                let Some(key) = self.io.open.remove(&request) else {
529                    return;
530                };
531                self.worker_handles.remove(&request);
532                if self.io.navigation == Some(request) {
533                    self.io.navigation = None;
534                }
535                if !self.open_fresh(&key) {
536                    return;
537                }
538                match completion.outcome {
539                    Outcome::Success(mut opened) => {
540                        if matches!(key.intent, OpenIntent::Refresh) {
541                            self.revoke_remote_write(key.origin);
542                            self.finish_remote_refresh(key.origin, opened.document);
543                            return;
544                        }
545                        opened.document.set_return_point(self.jump_record());
546                        let existing = self.docs.iter().find_map(|(id, document)| {
547                            (document.matches_target(&opened.canonical)
548                                && document
549                                    .remote_metadata()
550                                    .is_none_or(|source| source.selection == key.selection))
551                            .then_some(id)
552                        });
553                        let id = if let Some(id) = existing {
554                            if matches!(
555                                key.intent,
556                                OpenIntent::Browse | OpenIntent::RemoteDestination
557                            ) && self.doc(id).directory_metadata_ref().is_some()
558                            {
559                                if let Err(error) =
560                                    self.publish_remote_snapshot(id, opened.document, false)
561                                {
562                                    self.message = error.to_string();
563                                    return;
564                                }
565                            }
566                            id
567                        } else {
568                            // Background loads never steal the view: the
569                            // pristine scratch stays until a foreground
570                            // open or the built collection replaces it.
571                            let takes_focus = !matches!(
572                                key.intent,
573                                OpenIntent::CollectionSource { .. } | OpenIntent::Replace { .. }
574                            );
575                            // A first open on an endpoint binds its workspace
576                            // context (0042 slice 2); rebinds are idempotent.
577                            let endpoint = opened
578                                .document
579                                .remote_metadata()
580                                .map(|source| source.file.endpoint().clone())
581                                .or_else(|| {
582                                    opened
583                                        .document
584                                        .directory_metadata_ref()
585                                        .map(|directory| directory.directory.endpoint().clone())
586                                });
587                            if let Some(endpoint) = endpoint {
588                                self.workspaces
589                                    .bind(strop_workspace::Filesystem::Remote(endpoint), None);
590                            }
591                            let id = self.docs.insert(opened.document);
592                            if takes_focus {
593                                self.drop_stale_scratch(id);
594                            }
595                            self.generation += 1;
596                            self.mru.push(id);
597                            id
598                        };
599                        self.message.clear();
600                        self.finish_open(id, key.intent);
601                    }
602                    Outcome::Failed { failure, .. } => {
603                        if let OpenIntent::CollectionSource { owner } = key.intent {
604                            self.collection_source_ready(owner);
605                        }
606                        // 0051 §6 R04: a failed replace target is a named
607                        // refusal in the pending review, never a silent drop.
608                        if let OpenIntent::Replace { review, .. } = &key.intent {
609                            let path = match &key.path {
610                                FileTarget::Local(path) => path.clone(),
611                                other => PathBuf::from(other.to_string()),
612                            };
613                            self.refuse_replace_open(
614                                &path,
615                                format!("open failed: {}", failure.message),
616                                *review,
617                            );
618                        } else {
619                            self.message = format!("open {}: {}", key.path, failure.message);
620                        }
621                    }
622                    Outcome::Cancelled(_) => {}
623                }
624            }
625            IoEvent::Save(completion) => {
626                let request = completion.ticket.request;
627                if self.io.saves.get(&completion.ticket.key.document) != Some(&completion.ticket) {
628                    return;
629                }
630                let key = completion.ticket.key;
631                self.io.saves.remove(&key.document);
632                self.worker_handles.remove(&request);
633                match completion.outcome {
634                    Outcome::Success(receipt) => {
635                        let Some(document) = self.docs.get_mut(key.document) else {
636                            self.message = "snapshot written; source buffer closed".into();
637                            self.finish_save_feedback(key.document);
638                            self.collection_save_progress(key.document, false);
639                            return;
640                        };
641                        let previous_path = document.buf.path.clone();
642                        let saved = document.buf.accept_save(receipt);
643                        let renamed = previous_path != document.buf.path;
644                        if renamed {
645                            self.lsp_close_document(key.document);
646                            if !self.docs.is_empty() && self.current() == key.document {
647                                self.lsp_maybe_attach();
648                            }
649                        }
650                        self.message = if saved {
651                            "written"
652                        } else {
653                            "snapshot written; newer edits remain unsaved"
654                        }
655                        .into();
656                        self.request_session_save();
657                        self.collection_save_progress(key.document, saved);
658                        if saved
659                            && key.close
660                            && !self.docs.is_empty()
661                            && self.current() == key.document
662                            && self.focus_epoch == key.focus
663                        {
664                            self.close_pane_or_buffer(false);
665                        }
666                    }
667                    Outcome::Failed { failure, .. } => {
668                        self.collection_save_progress(key.document, false);
669                        self.message = format!("write failed: {}", failure.message)
670                    }
671                    Outcome::Cancelled(_) => {
672                        self.collection_save_progress(key.document, false);
673                        self.message = "write cancelled".into();
674                    }
675                }
676                self.finish_save_feedback(key.document);
677            }
678            IoEvent::Session { request, outcome } => {
679                if self.io.session != Some(request) {
680                    return;
681                }
682                self.io.session = None;
683                self.worker_handles.remove(&request);
684                if let Outcome::Failed { failure, .. } = outcome {
685                    self.message = format!("session save failed: {}", failure.message);
686                    self.io.session_error = Some(self.message.clone());
687                }
688                if let Some(work) = self.io.queued_session.take() {
689                    self.start_session_save(work);
690                }
691            }
692        }
693    }
694
695    pub fn io_pending(&self) -> bool {
696        !self.io.open.is_empty()
697            || !self.io.saves.is_empty()
698            || self.io.session.is_some()
699            || !self.io.native.is_empty()
700            || self.remote_work_pending()
701    }
702}
703
704impl Editor {
705    pub(crate) fn io_write_pending(&self, request: WorkerId) -> bool {
706        self.io.session == Some(request)
707            || self.remote_write_pending(request)
708            || self.destination_write_pending(request)
709            || self
710                .io
711                .saves
712                .values()
713                .any(|ticket| ticket.request == request)
714            || self.io.native.get(&request).is_some_and(|key| {
715                matches!(
716                    key.operation,
717                    native::Operation::Trust { .. } | native::Operation::TrustRemote { .. }
718                )
719            })
720    }
721    pub fn io_status(&self) -> Option<&'static str> {
722        if let Some(status) = self.remote_write_status() {
723            return Some(status);
724        }
725        if !self.io.saves.is_empty() {
726            Some("saving")
727        } else if !self.io.open.is_empty() {
728            Some("loading")
729        } else {
730            None
731        }
732    }
733
734    pub(crate) fn remote_refresh_pending(&self, document: DocumentId) -> bool {
735        self.io
736            .open
737            .values()
738            .any(|key| key.origin == document && matches!(key.intent, OpenIntent::Refresh))
739    }
740}
741
742impl IoState {
743    /// In-flight native tickets — tests answer a tape-suppressed
744    /// launch by feeding `handle_io` a crafted completion.
745    #[cfg(test)]
746    pub(crate) fn native_tickets(&self) -> Vec<Ticket<native::NativeKey>> {
747        self.native
748            .iter()
749            .map(|(request, key)| Ticket {
750                request: *request,
751                key: key.clone(),
752            })
753            .collect()
754    }
755}