1use std::task::{Context, Poll, Waker};
34
35use eframe::egui;
36use serde::Deserialize;
37
38use crate::document::Access;
39use crate::plm::PlmFuture;
40
41pub const CLIENT_ID: &str = "brep-app";
44
45#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
52#[serde(default)]
53pub struct PartDetail {
54 pub id: String,
55 pub number: String,
56 pub name: String,
57 pub document_class: String,
59 pub revision_views: Vec<RevisionView>,
61 pub suggested_label: String,
63 pub multiple_open_drafts: bool,
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
69#[serde(default)]
70pub struct RevisionView {
71 pub id: String,
72 pub label: String,
73 pub lifecycle: String,
75 pub editable: bool,
77 pub created_at: u64,
78 pub modified_at: u64,
79 pub locked_by: Option<String>,
81 pub locked_by_me: bool,
82 pub locked_at: Option<u64>,
83 pub released_by: Option<String>,
84 pub released_at: Option<u64>,
85 pub document_key: String,
87 pub origin: String,
89 pub family: Option<Provenance>,
90 pub template: Option<Provenance>,
91 pub bake: Option<String>,
93 pub bake_error: String,
94 pub hand_edited: bool,
96 pub uses: usize,
98 pub review: Option<ReviewSummary>,
99 pub comments: usize,
100 pub eco: Option<EcoRef>,
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
105#[serde(default)]
106pub struct Provenance {
107 pub part_id: String,
108 pub number: String,
109 pub revision_label: String,
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
114#[serde(default)]
115pub struct ReviewSummary {
116 pub status: String,
117 pub live: bool,
118 pub approvals: usize,
119 pub required_approvals: u32,
120 pub may_decide: bool,
121}
122
123#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
125#[serde(default)]
126pub struct EcoRef {
127 pub id: String,
128 pub number: String,
129 pub state: String,
130 pub action: String,
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
136#[serde(default)]
137pub struct NewRevision {
138 pub id: String,
139 pub label: String,
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
145#[serde(default)]
146pub struct HistoryEvent {
147 pub id: u64,
148 pub at: u64,
149 pub actor: HistoryActor,
150 pub action: String,
151 pub entity: HistoryEntity,
152 pub detail: String,
153}
154
155#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
156#[serde(default)]
157pub struct HistoryActor {
158 pub username: String,
159 pub via: String,
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
164#[serde(default)]
165pub struct HistoryEntity {
166 pub kind: String,
167 pub id: String,
168 pub label: String,
169}
170
171#[derive(Debug, Clone, Copy, Default, PartialEq)]
173pub struct Rights {
174 pub can_author: bool,
175 pub can_checkin: bool,
176}
177
178pub trait PlmLifecycle {
184 fn rights(&self) -> PlmFuture<Rights>;
187 fn part(&self, part: &str) -> PlmFuture<PartDetail>;
189 fn checkout(&self, part: &str, revision: &str) -> PlmFuture<()>;
191 fn checkin(&self, part: &str, revision: &str, force: bool) -> PlmFuture<()>;
193 fn set_state(&self, part: &str, revision: &str, to: &str) -> PlmFuture<Vec<String>>;
195 fn create_revision(&self, part: &str, label: &str) -> PlmFuture<NewRevision>;
198 fn history(&self, part: &str) -> PlmFuture<Vec<HistoryEvent>>;
200}
201
202pub fn newest(part: &PartDetail) -> Option<&RevisionView> {
210 part.revision_views
211 .iter()
212 .enumerate()
213 .max_by_key(|(i, r)| (r.created_at, *i))
214 .map(|(_, r)| r)
215}
216
217pub fn access_for(revision: &RevisionView) -> Access {
219 let label = &revision.label;
220 if !revision.editable {
221 let state = match revision.lifecycle.as_str() {
222 "released" => "released",
223 "superseded" => "superseded",
224 "obsolete" => "obsolete",
225 other => other,
226 };
227 return Access::read_only(format!("revision {label} is {state} and cannot be saved to"));
228 }
229 match (&revision.locked_by, revision.locked_by_me) {
230 (Some(_), true) => Access::Editable,
231 (Some(who), false) => Access::read_only(format!("revision {label} is checked out by {who}")),
232 (None, _) => Access::read_only(format!("revision {label} is not checked out — saving requires checkout")),
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum Verb {
239 CheckOut,
240 CheckIn,
241 BreakLock,
242 Release,
243 NewRevision,
244 Submit,
246 Review,
248 OpenChangeOrder,
250}
251
252impl Verb {
253 pub fn key(self) -> &'static str {
255 match self {
256 Verb::CheckOut => "check-out",
257 Verb::CheckIn => "check-in",
258 Verb::BreakLock => "break-lock",
259 Verb::Release => "release",
260 Verb::NewRevision => "new-revision",
261 Verb::Submit => "submit",
262 Verb::Review => "review",
263 Verb::OpenChangeOrder => "open-change-order",
264 }
265 }
266
267 pub fn label(self) -> &'static str {
268 match self {
269 Verb::CheckOut => "Check out",
270 Verb::CheckIn => "Check in",
271 Verb::BreakLock => "Break lock",
272 Verb::Release => "Release",
273 Verb::NewRevision => "New revision",
274 Verb::Submit => "Submit for review",
275 Verb::Review => "Review…",
276 Verb::OpenChangeOrder => "Open change order",
277 }
278 }
279}
280
281pub fn verbs(part: &PartDetail, revision: &RevisionView, rights: Rights) -> Vec<Verb> {
284 let mut out = Vec::new();
285 if revision.editable {
286 match (&revision.locked_by, revision.locked_by_me) {
287 (None, _) if rights.can_author => out.push(Verb::CheckOut),
288 (Some(_), true) => out.push(Verb::CheckIn),
289 (Some(_), false) if rights.can_checkin => out.push(Verb::BreakLock),
290 _ => {}
291 }
292 let held_by_other = revision.locked_by.is_some() && !revision.locked_by_me;
294 if rights.can_checkin && !held_by_other {
295 out.push(Verb::Release);
296 }
297 }
298 let open_draft = part.revision_views.iter().any(|r| r.editable);
299 if rights.can_author && (part.multiple_open_drafts || !open_draft) {
300 out.push(Verb::NewRevision);
301 }
302 let live_review = revision.review.as_ref().is_some_and(|r| r.live);
303 let held_by_other = revision.locked_by.is_some() && !revision.locked_by_me;
305 if revision.editable && revision.lifecycle == "draft" && rights.can_author && !live_review && !held_by_other {
306 out.push(Verb::Submit);
307 }
308 if revision.review.is_some() {
309 out.push(Verb::Review);
310 }
311 out
312}
313
314pub fn describe(revision: &RevisionView) -> String {
317 let mut parts = vec![format!("{} — {}", revision.label, revision.lifecycle)];
318 match (&revision.locked_by, revision.locked_by_me) {
319 (Some(_), true) => parts.push("checked out by you".into()),
320 (Some(who), false) => parts.push(format!("checked out by {who}")),
321 (None, _) => {}
322 }
323 if let Some(review) = &revision.review {
324 if review.live {
325 parts.push(format!("in review {}/{}", review.approvals, review.required_approvals));
326 } else {
327 parts.push(format!("review {}", review.status));
328 }
329 }
330 if let Some(eco) = &revision.eco {
331 parts.push(format!("{} ({} {})", eco.number, eco.action, eco.state));
332 }
333 if let Some(bake) = &revision.bake {
334 if revision.bake_error.is_empty() {
335 parts.push(format!("bake {bake}"));
336 } else {
337 parts.push(format!("bake {bake}: {}", revision.bake_error));
338 }
339 }
340 if revision.hand_edited {
341 parts.push("hand-edited".into());
342 }
343 if revision.uses > 0 {
344 parts.push(format!("{} uses", revision.uses));
345 }
346 if revision.origin != "authored" && !revision.origin.is_empty() {
347 parts.push(revision.origin.clone());
348 }
349 if let Some(family) = &revision.family {
350 parts.push(format!("from family {} {}", family.number, family.revision_label));
351 }
352 if let Some(template) = &revision.template {
353 parts.push(format!("from template {} {}", template.number, template.revision_label));
354 }
355 parts.join(" · ")
356}
357
358struct Pending<T> {
364 future: PlmFuture<T>,
365}
366
367impl<T> Pending<T> {
368 fn new(future: PlmFuture<T>) -> Self {
369 Self { future }
370 }
371
372 fn poll(&mut self) -> Option<Result<T, String>> {
373 let mut cx = Context::from_waker(Waker::noop());
374 match self.future.as_mut().poll(&mut cx) {
375 Poll::Ready(result) => Some(result),
376 Poll::Pending => None,
377 }
378 }
379}
380
381enum Work {
383 Part(Pending<PartDetail>),
384 Verb(Verb, Pending<String>),
386 History(Pending<Vec<HistoryEvent>>),
387}
388
389#[derive(Debug, Clone, PartialEq)]
391pub enum PanelEvent {
392 Access(Access),
394 Open(String),
396 OpenReview { part: String, revision: String },
399 OpenChangeOrder { eco: String },
402}
403
404pub struct PlmPanel {
406 part_id: String,
407 revision_id: String,
409 rights: Rights,
410 part: Option<PartDetail>,
411 history: Option<Vec<HistoryEvent>>,
412 work: Option<Work>,
413 message: Option<String>,
415 new_label: String,
417 reported: Option<Access>,
419 open_newest_after: bool,
421 confirm_new: bool,
424 release_refused: bool,
427 queued: Vec<PanelEvent>,
429}
430
431impl PlmPanel {
432 pub fn new(server: &dyn PlmLifecycle, part_id: &str, revision_id: &str, rights: Rights) -> Self {
435 let mut panel = Self {
436 part_id: part_id.to_string(),
437 revision_id: revision_id.to_string(),
438 rights,
439 part: None,
440 history: None,
441 work: None,
442 message: None,
443 new_label: String::new(),
444 reported: None,
445 open_newest_after: false,
446 confirm_new: false,
447 release_refused: false,
448 queued: Vec::new(),
449 };
450 panel.work = Some(Work::Part(Pending::new(server.part(part_id))));
451 panel
452 }
453
454 pub(crate) fn matches_document(&self, part: &str, revision: &str) -> bool {
455 self.part_id == part && self.revision_id == revision
456 }
457
458 pub fn part(&self) -> Option<&PartDetail> {
459 self.part.as_ref()
460 }
461
462 pub fn revision(&self) -> Option<&RevisionView> {
463 self.part.as_ref()?.revision_views.iter().find(|r| r.id == self.revision_id)
464 }
465
466 pub fn message(&self) -> Option<&str> {
467 self.message.as_deref()
468 }
469
470 pub fn busy(&self) -> bool {
471 self.work.is_some()
472 }
473
474 fn refreshing(&self) -> bool {
475 self.part.is_some() && matches!(self.work, Some(Work::Part(_)))
476 }
477
478 pub fn offered(&self) -> Vec<Verb> {
481 match (&self.part, self.revision()) {
482 (Some(part), Some(revision)) if self.work.is_none() || self.refreshing() => {
483 let mut offered = verbs(part, revision, self.rights);
484 if self.release_refused && revision.eco.is_some() {
485 offered.push(Verb::OpenChangeOrder);
486 }
487 offered
488 }
489 _ => Vec::new(),
490 }
491 }
492
493 pub fn press(&mut self, server: &dyn PlmLifecycle, verb: Verb) {
496 if !self.offered().contains(&verb) {
497 return;
498 }
499 if self.refreshing() { self.work = None; }
500 let (part, rev) = (self.part_id.clone(), self.revision_id.clone());
501 match verb {
503 Verb::Submit | Verb::Review => {
504 self.queued.push(PanelEvent::OpenReview { part, revision: rev });
505 return;
506 }
507 Verb::OpenChangeOrder => {
508 if let Some(eco) = self.revision().and_then(|r| r.eco.clone()) {
509 self.queued.push(PanelEvent::OpenChangeOrder { eco: eco.id });
510 }
511 return;
512 }
513 _ => {}
514 }
515 self.release_refused = false;
516 let done = |label: &'static str| move |_| label.to_string();
517 let future: PlmFuture<String> = match verb {
518 Verb::CheckOut => map(server.checkout(&part, &rev), done("Checked out")),
519 Verb::CheckIn => map(server.checkin(&part, &rev, false), done("Checked in")),
520 Verb::BreakLock => map(server.checkin(&part, &rev, true), done("Lock broken")),
521 Verb::Release => map(server.set_state(&part, &rev, "released"), |warnings: Vec<String>| {
522 if warnings.is_empty() { "Released".to_string() } else { format!("Released — {}", warnings.join("; ")) }
523 }),
524 Verb::NewRevision => map(server.create_revision(&part, self.new_label.trim()), |r: NewRevision| {
525 format!("Revision {} started", r.label)
526 }),
527 Verb::Submit | Verb::Review | Verb::OpenChangeOrder => unreachable!("opened above"),
528 };
529 self.message = None;
530 self.work = Some(Work::Verb(verb, Pending::new(future)));
531 }
532
533 pub fn refresh(&mut self, server: &dyn PlmLifecycle) {
536 if self.work.is_none() {
537 self.work = Some(Work::Part(Pending::new(server.part(&self.part_id))));
538 }
539 }
540
541 pub fn load_history(&mut self, server: &dyn PlmLifecycle) {
543 if self.refreshing() { self.work = None; }
544 if self.work.is_none() {
545 self.work = Some(Work::History(Pending::new(server.history(&self.part_id))));
546 }
547 }
548
549 pub fn history(&self) -> Option<&[HistoryEvent]> {
550 self.history.as_deref()
551 }
552
553 pub fn poll(&mut self, server: &dyn PlmLifecycle) -> Vec<PanelEvent> {
556 let mut events = std::mem::take(&mut self.queued);
557 let Some(work) = self.work.as_mut() else { return events };
558 match work {
559 Work::Part(pending) => match pending.poll() {
560 None => return events,
561 Some(Ok(part)) => {
562 self.part = Some(part);
563 self.work = None;
564 }
565 Some(Err(error)) => {
566 self.message = Some(error);
567 self.work = None;
568 }
569 },
570 Work::Verb(verb, pending) => {
571 let verb = *verb;
572 match pending.poll() {
573 None => return events,
574 Some(result) => {
575 let new_revision = verb == Verb::NewRevision;
576 self.work = Some(Work::Part(Pending::new(server.part(&self.part_id))));
577 match result {
578 Ok(sentence) => {
579 self.message = Some(sentence);
580 if new_revision {
581 self.new_label.clear();
582 }
583 }
584 Err(error) => {
585 self.release_refused = verb == Verb::Release;
586 self.message = Some(error);
587 }
588 }
589 self.open_newest_after = new_revision;
591 return events;
592 }
593 }
594 }
595 Work::History(pending) => match pending.poll() {
596 None => return events,
597 Some(result) => {
598 self.work = None;
599 match result {
600 Ok(history) => self.history = Some(history),
601 Err(error) => self.message = Some(error),
602 }
603 }
604 },
605 }
606 if std::mem::take(&mut self.open_newest_after) {
607 if let Some(revision) = self.part.as_ref().and_then(newest) {
608 if revision.id != self.revision_id {
609 events.push(PanelEvent::Open(revision.document_key.clone()));
610 }
611 }
612 }
613 if let Some(revision) = self.revision() {
614 let access = access_for(revision);
615 if self.reported.as_ref() != Some(&access) {
616 self.reported = Some(access.clone());
617 events.push(PanelEvent::Access(access));
618 }
619 }
620 events
621 }
622
623 pub fn ui(&mut self, ui: &mut egui::Ui, server: &dyn PlmLifecycle) -> Vec<PanelEvent> {
625 let mut events = self.poll(server);
626 let mut hits = std::collections::HashMap::new();
627 events.extend(self.draw(ui, server, &mut crate::panels::plm_host::SectionHits::new("lifecycle", &mut hits)));
628 events
629 }
630
631 pub fn draw(
640 &mut self,
641 ui: &mut egui::Ui,
642 server: &dyn PlmLifecycle,
643 hits: &mut crate::panels::plm_host::SectionHits<'_>,
644 ) -> Vec<PanelEvent> {
645 self.draw_with(ui, server, hits, &mut |_| {})
646 }
647
648 pub fn draw_with(
654 &mut self,
655 ui: &mut egui::Ui,
656 server: &dyn PlmLifecycle,
657 hits: &mut crate::panels::plm_host::SectionHits<'_>,
658 before_new_revision: &mut dyn FnMut(&mut egui::Ui),
659 ) -> Vec<PanelEvent> {
660 let mut events = Vec::new();
661 if self.busy() {
662 ui.ctx().request_repaint();
663 }
664 let Some(part) = self.part.clone() else {
665 ui.horizontal(|ui| {
666 ui.spinner();
667 ui.label("Reading the part from the PLM…");
668 });
669 if let Some(message) = &self.message {
670 ui.colored_label(ui.visuals().error_fg_color, message);
671 }
672 return events;
673 };
674 ui.heading(format!("{} {}", part.number, part.name));
675 if let Some(revision) = self.revision() {
676 let access = access_for(revision);
677 match &access {
678 Access::Editable => ui.label(format!("Revision {} — checked out by you", revision.label)),
679 Access::ReadOnly { reason } => ui.label(format!("Read-only: {reason}")),
680 };
681 }
682 ui.separator();
683 ui.label("Revisions");
684 for revision in part.revision_views.iter().rev() {
685 let shown = revision.id == self.revision_id;
686 let response = ui.selectable_label(shown, describe(revision));
687 hits.put(&format!("revision:{}", revision.label), response.rect);
688 if response.clicked() && !shown {
689 events.push(PanelEvent::Open(revision.document_key.clone()));
690 }
691 }
692 ui.separator();
693 let offered = self.offered();
694 ui.horizontal_wrapped(|ui| {
695 for verb in offered.iter().copied().filter(|v| *v != Verb::NewRevision) {
696 let label = match (verb, self.revision().and_then(|r| r.eco.as_ref())) {
697 (Verb::OpenChangeOrder, Some(eco)) => format!("Open change order {}", eco.number),
698 _ => verb.label().to_string(),
699 };
700 let button = ui.button(label);
701 hits.put(&format!("verb:{}", verb.key()), button.rect);
702 if button.clicked() {
703 self.press(server, verb);
704 }
705 }
706 });
707 if offered.contains(&Verb::NewRevision) {
708 ui.horizontal(|ui| {
709 let field = ui.add(egui::TextEdit::singleline(&mut self.new_label).hint_text(part.suggested_label.as_str()).desired_width(80.0));
710 hits.put("new-label", field.rect);
711 let button = ui.button(format!("{}…", Verb::NewRevision.label()));
712 hits.put(&format!("verb:{}", Verb::NewRevision.key()), button.rect);
713 if button.clicked() {
714 self.confirm_new = true;
715 }
716 });
717 if self.confirm_new {
718 egui::Frame::group(ui.style()).show(ui, |ui| {
719 ui.label("Before starting a new revision: where this part is used");
720 before_new_revision(ui);
721 ui.horizontal(|ui| {
722 let label = if self.new_label.trim().is_empty() { part.suggested_label.clone() } else { self.new_label.trim().to_string() };
723 let start = ui.button(format!("Start revision {label}"));
724 hits.put("new-revision:start", start.rect);
725 if start.clicked() {
726 self.confirm_new = false;
727 self.press(server, Verb::NewRevision);
728 }
729 let cancel = ui.button("Cancel");
730 hits.put("new-revision:cancel", cancel.rect);
731 if cancel.clicked() {
732 self.confirm_new = false;
733 }
734 });
735 });
736 }
737 } else {
738 self.confirm_new = false;
739 }
740 ui.horizontal(|ui| {
741 let refresh = ui.add_enabled(!self.busy() || self.refreshing(), egui::Button::new("Refresh"));
742 hits.put("refresh", refresh.rect);
743 if refresh.clicked() {
744 self.refresh(server);
745 }
746 if self.busy() && !self.refreshing() {
747 ui.spinner();
748 }
749 });
750 if let Some(message) = &self.message {
751 ui.label(message);
752 }
753 ui.separator();
754 egui::CollapsingHeader::new("History").id_salt("plm-lifecycle-history").show(ui, |ui| {
755 match &self.history {
756 None => {
757 let button = ui.button("Load history");
758 hits.put("history:load", button.rect);
759 if button.clicked() {
760 self.load_history(server);
761 }
762 }
763 Some(events) if events.is_empty() => {
764 ui.label("No changes recorded.");
765 }
766 Some(events) => {
767 for event in events {
768 let what = if event.entity.label.is_empty() { &event.entity.kind } else { &event.entity.label };
769 ui.label(format!("{} {} {} ({})", event.actor.username, event.action, what, event.actor.via));
770 }
771 }
772 }
773 });
774 events
775 }
776}
777
778impl PlmLifecycle for std::rc::Rc<crate::plm::client::PlmClient> {
786 fn rights(&self) -> PlmFuture<Rights> {
787 #[derive(Deserialize, Default)]
788 #[serde(default)]
789 struct Can {
790 can_author: bool,
791 can_checkin: bool,
792 }
793 let client = self.clone();
794 Box::pin(async move {
795 let can: Can = json(client.call("GET", "/api/me", None).await)?;
796 Ok(Rights { can_author: can.can_author, can_checkin: can.can_checkin })
797 })
798 }
799
800 fn part(&self, part: &str) -> PlmFuture<PartDetail> {
801 let (client, path) = (self.clone(), crate::plm::identity::part_path(&part));
802 Box::pin(async move { json(client.call("GET", &path, None).await) })
803 }
804
805 fn checkout(&self, part: &str, revision: &str) -> PlmFuture<()> {
806 let (client, path) = (self.clone(), format!("{}/checkout", crate::plm::identity::revision_path(&part, &revision)));
807 let body = serde_json::json!({ "client_id": CLIENT_ID }).to_string().into_bytes();
808 Box::pin(async move { unit(client.call("POST", &path, Some(body)).await) })
809 }
810
811 fn checkin(&self, part: &str, revision: &str, force: bool) -> PlmFuture<()> {
812 let (client, path) = (self.clone(), format!("{}/checkin", crate::plm::identity::revision_path(&part, &revision)));
813 let body = serde_json::json!({ "force": force }).to_string().into_bytes();
814 Box::pin(async move { unit(client.call("POST", &path, Some(body)).await) })
815 }
816
817 fn set_state(&self, part: &str, revision: &str, to: &str) -> PlmFuture<Vec<String>> {
818 #[derive(Deserialize, Default)]
819 #[serde(default)]
820 struct Answer {
821 warnings: Vec<String>,
822 }
823 let (client, path) = (self.clone(), format!("{}/state", crate::plm::identity::revision_path(&part, &revision)));
824 let body = serde_json::json!({ "to": to }).to_string().into_bytes();
825 Box::pin(async move { json::<Answer>(client.call("POST", &path, Some(body)).await).map(|a| a.warnings) })
826 }
827
828 fn create_revision(&self, part: &str, label: &str) -> PlmFuture<NewRevision> {
829 let (client, path) = (self.clone(), format!("{}/revisions", crate::plm::identity::part_path(&part)));
830 let body = serde_json::json!({ "label": label }).to_string().into_bytes();
831 Box::pin(async move { json(client.call("POST", &path, Some(body)).await) })
832 }
833
834 fn history(&self, part: &str) -> PlmFuture<Vec<HistoryEvent>> {
835 let (client, path) = (self.clone(), format!("{}/history", crate::plm::identity::part_path(&part)));
836 Box::pin(async move { json(client.call("GET", &path, None).await) })
837 }
838}
839
840fn json<T: for<'de> Deserialize<'de>>(
842 answer: Result<crate::plm::PlmResponse, crate::plm::client::PlmError>,
843) -> Result<T, String> {
844 let response = answer.map_err(|e| e.to_string())?;
845 serde_json::from_slice(&response.body).map_err(|e| format!("the PLM answered in a shape this app does not read: {e}"))
846}
847
848fn unit(answer: Result<crate::plm::PlmResponse, crate::plm::client::PlmError>) -> Result<(), String> {
849 answer.map(|_| ()).map_err(|e| e.to_string())
850}
851
852fn map<T: 'static, U: 'static>(future: PlmFuture<T>, f: impl FnOnce(T) -> U + 'static) -> PlmFuture<U> {
854 Box::pin(async move { future.await.map(f) })
855}
856