1use std::cell::RefCell;
24use std::rc::Rc;
25
26use tilo_actions::door::{ActionAddress, ActionDescriptor, DoorError};
27use tilo_actions::{ActionEnvelope, ActionOutput, ActionTransport};
28use ui_core::application::{
29 ApplicationDefinition, ComponentRunnerInputs, HostContext, HostContextInputs, LaunchTarget,
30 RestoreInput,
31};
32use ui_core::application_view::InstanceViewStore;
33use ui_core::collaboration::DocumentMutation;
34use ui_core::headless::FrameHarness;
35use ui_core::permissions::{PermissionContext, SharedPermissions, UserId};
36use ui_core::portable_storage::{PortableStorage, StorageKey};
37use ui_core::record::{RecordCheckout, RecordOwners, RecordStore};
38use ui_core::state::DocumentFrontier;
39
40const INSTANCE: u64 = 1;
42
43const VIEWPORT: (u32, u32) = (1280, 720);
46
47const DEVICE: &str = "attached-view";
49
50#[derive(Clone, Default)]
52struct Landed(Rc<RefCell<Vec<ActionEnvelope>>>);
53
54impl ActionTransport for Landed {
55 fn replicate(&mut self, env: &ActionEnvelope) {
56 self.0.borrow_mut().push(env.clone());
57 }
58}
59
60#[derive(Default)]
62struct Beside {
63 frontier: DocumentFrontier,
66 unsent: Vec<Vec<u8>>,
68}
69
70pub struct AttachedView {
73 definition: &'static ApplicationDefinition,
74 checkout: RecordCheckout,
75 frame: Box<FrameHarness>,
77 records: RecordStore,
79 permissions: SharedPermissions,
80 me: String,
82 invoker: tilo_actions::ActionInvoker,
84 context: Option<tilo_actions::ActionContext>,
86 landed: Landed,
87 frontier: DocumentFrontier,
90 fresh: bool,
92 unsent: Vec<Vec<u8>>,
94 beside: std::collections::HashMap<ui_core::record::RecordId, Beside>,
97 cache: PortableStorage,
100 workspace: String,
101}
102
103pub struct OpenView<'a> {
106 definition: &'static ApplicationDefinition,
107 workspace: &'a str,
108 checkout: RecordCheckout,
109 document: Vec<u8>,
110 invoker: tilo_actions::ActionInvoker,
111 permissions: PermissionContext,
112 created: bool,
113 content: Option<tilo_content::ContentStore>,
114}
115
116impl OpenView<'_> {
117 #[must_use]
119 pub fn for_invoker(mut self, invoker: &tilo_actions::ActionInvoker) -> Self {
120 self.invoker = invoker.clone();
121 self
122 }
123
124 #[must_use]
126 pub fn with_permissions(mut self, permissions: PermissionContext) -> Self {
127 self.permissions = permissions;
128 self
129 }
130
131 #[must_use]
134 pub const fn created(mut self, created: bool) -> Self {
135 self.created = created;
136 self
137 }
138
139 #[must_use]
142 pub fn lending(mut self, store: tilo_content::ContentStore) -> Self {
143 self.content = Some(store);
144 self
145 }
146
147 pub fn open(self) -> Result<AttachedView, String> {
152 AttachedView::open_view(self)
153 }
154}
155
156impl AttachedView {
157 pub fn open(
161 definition: &'static ApplicationDefinition,
162 workspace: &str,
163 checkout: RecordCheckout,
164 document: Vec<u8>,
165 me: &str,
166 permissions: PermissionContext,
167 created: bool,
168 ) -> Result<Self, String> {
169 Self::open_for(
170 definition,
171 workspace,
172 checkout,
173 document,
174 &tilo_actions::ActionInvoker::actor(me),
175 permissions,
176 created,
177 )
178 }
179
180 pub fn open_for(
187 definition: &'static ApplicationDefinition,
188 workspace: &str,
189 checkout: RecordCheckout,
190 document: Vec<u8>,
191 invoker: &tilo_actions::ActionInvoker,
192 permissions: PermissionContext,
193 created: bool,
194 ) -> Result<Self, String> {
195 Self::opening(definition, workspace, checkout, document)
196 .for_invoker(invoker)
197 .with_permissions(permissions)
198 .created(created)
199 .open()
200 }
201
202 #[must_use]
206 pub fn opening<'a>(
207 definition: &'static ApplicationDefinition,
208 workspace: &'a str,
209 checkout: RecordCheckout,
210 document: Vec<u8>,
211 ) -> OpenView<'a> {
212 OpenView {
213 definition,
214 workspace,
215 checkout,
216 document,
217 invoker: tilo_actions::ActionInvoker::actor("owner"),
218 permissions: PermissionContext::permissive(),
219 created: false,
220 content: None,
221 }
222 }
223
224 fn open_view(view: OpenView<'_>) -> Result<Self, String> {
225 let OpenView {
226 definition,
227 workspace,
228 checkout,
229 document,
230 invoker,
231 permissions,
232 created,
233 content,
234 } = view;
235 let invoker = &invoker;
236 let me = invoker.actor.as_str();
237 let joined_at = (!created).then(|| heads_of(&document)).transpose()?;
238 let cache = PortableStorage::new("attached-views", DEVICE)?;
239 let key = if checkout.id.is_package_primary() {
240 StorageKey::Primary {
241 package: definition.app_id().into(),
242 workspace: workspace.to_owned(),
243 }
244 } else {
245 StorageKey::Record {
246 package: definition.app_id().into(),
247 workspace: workspace.to_owned(),
248 record: checkout.id.as_str().into(),
249 }
250 };
251 cache.hydrate(key, document, None)?;
254 let cache_kept = cache.clone();
255 let records = RecordOwners::portable(cache.clone())
256 .open(definition.app_id(), workspace)
257 .map_err(|error| error.to_string())?;
258 let views = Rc::new(
259 InstanceViewStore::portable(cache, workspace).map_err(|error| error.to_string())?,
260 );
261 let permissions = SharedPermissions::new(permissions);
262 let target = LaunchTarget::Record {
263 checkout: checkout.clone(),
264 reveal: None,
265 };
266 let context = HostContext::prepare(
267 definition,
268 HostContextInputs {
269 records: records.clone(),
270 views,
271 device_id: DEVICE.into(),
272 instance_id: INSTANCE,
273 permissions: permissions.clone(),
274 author: UserId(me.to_owned()),
275 action_context: tilo_actions::ActionContext::default()
276 .with_invoker(invoker.clone()),
277 },
278 &target,
279 )
280 .map_err(|error| error.to_string())?;
281 let mut context = context;
282 if let Some(store) = content {
283 context.provide_content(store);
284 }
285 let component = definition
286 .instantiate(context, target, RestoreInput::None)
287 .map_err(|error| error.to_string())?;
288 let mut frame = FrameHarness::from_component(
289 component,
290 VIEWPORT,
291 ComponentRunnerInputs {
292 theme: ui_core::Theme::default(),
293 text_renderer: ui_core::TextRenderer::new(),
294 },
295 )
296 .map_err(|error| error.to_string())?;
297 let landed = Landed::default();
298 frame.set_action_transport(Box::new(landed.clone()))?;
299 if let Some(joined_at) = &joined_at {
300 refuse_written_on_open(definition, &records, &checkout, joined_at)?;
301 }
302 let mut frontier = DocumentFrontier::default();
307 let start = joined_at
308 .or_else(|| records.with(&checkout.id, ui_core::automerge::AutoCommit::get_heads));
309 if let Some(heads) = start {
310 frontier.advance(heads);
311 }
312 Ok(Self {
313 definition,
314 checkout,
315 frame: Box::new(frame),
316 records,
317 permissions,
318 me: me.to_owned(),
319 invoker: invoker.clone(),
320 context: None,
321 landed,
322 frontier,
323 fresh: created,
324 unsent: Vec::new(),
325 beside: std::collections::HashMap::new(),
326 cache: cache_kept,
327 workspace: workspace.to_owned(),
328 })
329 }
330
331 #[must_use]
333 pub const fn definition(&self) -> &'static ApplicationDefinition {
334 self.definition
335 }
336
337 #[must_use]
338 pub const fn checkout(&self) -> &RecordCheckout {
339 &self.checkout
340 }
341
342 #[must_use]
344 pub fn me(&self) -> &str {
345 &self.me
346 }
347
348 pub fn set_permissions(&mut self, permissions: PermissionContext) {
350 self.permissions.set(permissions);
351 self.frame.relayout();
352 }
353
354 #[must_use]
356 pub fn resources(&self) -> Vec<tilo_actions::ResourceSpec> {
357 let permissions = self.permissions.borrow();
358 self.frame
359 .resources()
360 .into_iter()
361 .filter_map(|resource| {
362 permissions.permitted_resource(self.definition.app_id(), resource)
363 })
364 .collect()
365 }
366
367 #[must_use]
370 pub fn operations(&self) -> Vec<ActionDescriptor> {
371 tilo_actions::door::survey(&self.resources())
372 .into_iter()
373 .filter(|descriptor| descriptor.kind == tilo_actions::OperationKind::Document)
374 .collect()
375 }
376
377 pub fn invoke(
383 &mut self,
384 address: &ActionAddress,
385 arguments: &serde_json::Map<String, serde_json::Value>,
386 confirmed: bool,
387 ) -> Result<ActionOutput, DoorError> {
388 self.invoke_appearing(address, arguments, confirmed, None)
389 }
390
391 pub fn invoke_appearing(
398 &mut self,
399 address: &ActionAddress,
400 arguments: &serde_json::Map<String, serde_json::Value>,
401 confirmed: bool,
402 appearance: Option<tilo_actions::Appearance>,
403 ) -> Result<ActionOutput, DoorError> {
404 let invoker = self.invoker_appearing(appearance);
405 self.invoke_as(invoker, address, arguments, confirmed)
406 }
407
408 #[must_use]
412 pub fn invoker_appearing(
413 &self,
414 appearance: Option<tilo_actions::Appearance>,
415 ) -> tilo_actions::ActionInvoker {
416 self.context
417 .as_ref()
418 .and_then(|context| context.invoker.clone())
419 .filter(|invoker| invoker.actor == self.invoker.actor)
420 .unwrap_or_else(|| self.invoker.clone())
421 .with_appearance(appearance)
422 }
423
424 pub fn invoke_as(
433 &mut self,
434 invoker: tilo_actions::ActionInvoker,
435 address: &ActionAddress,
436 arguments: &serde_json::Map<String, serde_json::Value>,
437 confirmed: bool,
438 ) -> Result<ActionOutput, DoorError> {
439 let (_, started) = self.start_as(invoker, address, arguments, confirmed)?;
440 self.frame.settle_owed();
444 Ok(started.completed().unwrap_or_default())
445 }
446
447 pub fn invoke_as_awaiting(
458 &mut self,
459 invoker: tilo_actions::ActionInvoker,
460 address: &ActionAddress,
461 arguments: &serde_json::Map<String, serde_json::Value>,
462 confirmed: bool,
463 within: ui_core::time::Duration,
464 ) -> Result<ActionOutput, DoorError> {
465 let (invocation, started) = self.start_as(invoker, address, arguments, confirmed)?;
466 if let Some(output) = started.completed() {
467 self.frame.settle_owed();
468 return Ok(output);
469 }
470 let ended = |frame: &FrameHarness| {
471 frame
472 .job_updates(invocation)
473 .iter()
474 .find(|update| update.is_terminal())
475 .cloned()
476 };
477 if self
478 .frame
479 .wait_until(within, |frame| ended(frame).is_some())
480 {
481 self.frame.settle_owed();
483 }
484 match ended(&self.frame) {
485 Some(tilo_actions::InvocationUpdate::Succeeded { output, .. }) => Ok(output),
486 Some(tilo_actions::InvocationUpdate::Failed { error, .. }) => {
487 Err(DoorError::Failed(error.message))
488 }
489 Some(_) => Err(DoorError::Failed(format!(
490 "{} was cancelled",
491 address.action
492 ))),
493 None => {
494 let _ = self.frame.cancel_job(invocation);
495 Err(DoorError::Failed(format!(
496 "{} did not finish within {} seconds",
497 address.action,
498 within.as_secs()
499 )))
500 }
501 }
502 }
503
504 fn start_as(
507 &mut self,
508 invoker: tilo_actions::ActionInvoker,
509 address: &ActionAddress,
510 arguments: &serde_json::Map<String, serde_json::Value>,
511 confirmed: bool,
512 ) -> Result<
513 (
514 tilo_actions::InvocationId,
515 ui_core::host_operation::ResourceOperationStart,
516 ),
517 DoorError,
518 > {
519 if !tilo_actions::door::survey(&self.resources())
520 .iter()
521 .any(|entry| &entry.address == address)
522 {
523 return Err(DoorError::Unknown(address.clone()));
524 }
525 self.landed.0.borrow_mut().clear();
526 let ambient = self.context.clone().unwrap_or_default().enriched_by(
529 &tilo_actions::ActionContext::default()
530 .with_app(self.definition.app_id(), self.definition.title()),
531 );
532 self.frame.start_bound_job_within(
533 Some(invoker),
534 Some(&ambient),
535 &ui_core::bound_dispatch::BoundActionAddress {
536 instance_id: INSTANCE,
537 address: address.clone(),
538 },
539 arguments,
540 confirmed,
541 )
542 }
543
544 pub fn invoke_typed<R: tilo_resource::Resource>(
552 &mut self,
553 invoke: &tilo_resource::Invoke<R>,
554 confirmed: bool,
555 ) -> Result<ActionOutput, DoorError> {
556 if let Some(refusal) = invoke.refusal() {
557 return Err(DoorError::InvalidArguments(refusal.to_owned()));
558 }
559 let app = self.definition.app_id();
563 let resource = self
564 .resources()
565 .into_iter()
566 .find(|resource| resource.kind == R::KIND && resource.app().is_none_or(|of| of == app))
567 .ok_or_else(|| DoorError::Unknown(ActionAddress::new(R::KIND, invoke.id)))?;
568 self.invoke(
569 &ActionAddress::new(resource.id.as_str(), invoke.id),
570 &invoke.arguments,
571 confirmed,
572 )
573 }
574
575 pub fn invoke_at(
583 &mut self,
584 place: &tilo_resource::Place,
585 address: &ActionAddress,
586 arguments: &serde_json::Map<String, serde_json::Value>,
587 confirmed: bool,
588 ) -> Result<ActionOutput, DoorError> {
589 let resources = self.resources();
590 let param = tilo_actions::door::survey(&resources)
591 .into_iter()
592 .find(|entry| &entry.address == address)
593 .ok_or_else(|| DoorError::Unknown(address.clone()))?
594 .params
595 .iter()
596 .find(|param| param.is_place())
597 .map(|param| param.name.to_string())
598 .ok_or_else(|| {
599 DoorError::InvalidArguments(format!(
600 "`{}` takes no place to act at",
601 address.action
602 ))
603 })?;
604 let mut arguments = arguments.clone();
605 arguments.insert(
606 param,
607 serde_json::to_value(place)
608 .map_err(|error| DoorError::InvalidArguments(error.to_string()))?,
609 );
610 self.invoke(address, &arguments, confirmed)
611 }
612
613 pub fn set_context(&mut self, context: Option<tilo_actions::ActionContext>) {
616 self.context = context;
617 }
618
619 #[must_use]
621 pub const fn context(&self) -> Option<&tilo_actions::ActionContext> {
622 self.context.as_ref()
623 }
624
625 pub fn record_run(
629 &mut self,
630 label: &str,
631 issued_by: Option<&str>,
632 decision: Option<&str>,
633 steps: &[ui_core::RunStep],
634 ) -> bool {
635 self.records
636 .with(&self.checkout.id, |doc| {
637 let by = doc.get_actor().to_hex_string();
638 let recorded = ui_core::record_run_with_details(
639 doc,
640 label,
641 Some(&by),
642 issued_by,
643 Some(label),
644 decision,
645 steps,
646 );
647 if recorded.is_some() {
648 tilo_resource::history::commit_stamped_with_message(
649 doc,
650 Some(tilo_actions::FrameworkChange::Run.change_message()),
651 );
652 }
653 recorded.is_some()
654 })
655 .unwrap_or(false)
656 }
657
658 #[must_use]
661 pub fn landed(&self) -> Option<ActionEnvelope> {
662 self.landed.0.borrow().last().cloned()
663 }
664
665 pub fn read<R>(&self, read: impl FnOnce(&ui_core::automerge::AutoCommit) -> R) -> Option<R> {
667 self.records.read(&self.checkout.id, read)
668 }
669
670 pub fn take_changes(&mut self) -> Option<DocumentMutation> {
673 if std::mem::take(&mut self.fresh) {
674 self.unsent.clear();
675 let (bytes, heads) = self.records.replication_snapshot(&self.checkout.id).ok()?;
676 self.frontier.advance(heads);
677 return Some(DocumentMutation::Snapshot(bytes));
678 }
679 self.set_aside_own();
680 let own: Vec<u8> = std::mem::take(&mut self.unsent).concat();
681 (!own.is_empty()).then_some(DocumentMutation::Incremental(own))
682 }
683
684 pub fn merge(&mut self, mutation: &DocumentMutation) -> Result<bool, String> {
688 if let DocumentMutation::Incremental(bytes) = mutation {
689 let mut incoming = ui_core::automerge::AutoCommit::new();
690 if tilo_automerge::ApplyIncoming::load_incoming(&mut incoming, bytes).is_err() {
691 return Ok(false);
692 }
693 let missing = incoming.get_missing_deps(&[]);
694 let known = self
695 .records
696 .with(&self.checkout.id, |doc| {
697 missing
698 .iter()
699 .all(|hash| doc.get_change_by_hash(hash).is_some())
700 })
701 .unwrap_or(false);
702 if !known {
703 return Ok(false);
704 }
705 }
706 if !self.fresh {
708 self.set_aside_own();
709 }
710 let (changed, observed) = self
711 .records
712 .merge_replica(&self.checkout, mutation.clone())?;
713 self.frontier.observe(observed);
714 self.frame.relayout();
715 Ok(changed)
716 }
717
718 #[must_use]
722 pub fn chains(&self) -> Vec<RecordCheckout> {
723 self.frame
725 .resources()
726 .iter()
727 .flat_map(|resource| std::iter::once(resource).chain(&resource.children))
728 .filter_map(|resource| {
729 let chain = super::chain_of_resource_id(resource.id.as_str())?;
730 Some(RecordCheckout::new(
731 chain,
732 ui_core::record::RecordKind::new(resource.kind.clone()),
733 ))
734 })
735 .collect()
736 }
737
738 #[must_use]
742 pub fn beside(&self, record: &ui_core::record::RecordId) -> Option<RecordCheckout> {
743 let (chain, _) = record.chain_part()?;
744 self.chains()
745 .into_iter()
746 .find(|held| held.id == chain)
747 .map(|held| RecordCheckout::new(record.clone(), held.kind))
748 }
749
750 #[must_use]
754 pub fn records_beside(&self) -> Vec<RecordCheckout> {
755 let chains = self.chains();
756 self.records
757 .open_records()
758 .into_iter()
759 .filter(|checkout| {
760 checkout
761 .id
762 .chain_part()
763 .is_some_and(|(chain, _)| chains.iter().any(|held| held.id == chain))
764 })
765 .collect()
766 }
767
768 pub fn read_record<R>(
769 &self,
770 record: &ui_core::record::RecordId,
771 read: impl FnOnce(&ui_core::automerge::AutoCommit) -> R,
772 ) -> Option<R> {
773 self.beside(record)?;
774 self.records.read(record, read)
775 }
776
777 pub fn take_record_changes(
781 &mut self,
782 record: &ui_core::record::RecordId,
783 ) -> Option<DocumentMutation> {
784 if self.beside.contains_key(record) {
785 self.set_aside_beside(record);
786 let held = self.beside.get_mut(record)?;
787 let own: Vec<u8> = std::mem::take(&mut held.unsent).concat();
788 (!own.is_empty()).then_some(DocumentMutation::Incremental(own))
789 } else {
790 let (bytes, heads) = self.records.replication_snapshot(record).ok()?;
791 let mut held = Beside::default();
792 held.frontier.advance(heads);
793 self.beside.insert(record.clone(), held);
794 Some(DocumentMutation::Snapshot(bytes))
795 }
796 }
797
798 pub fn merge_record(
804 &mut self,
805 record: &ui_core::record::RecordId,
806 mutation: &DocumentMutation,
807 ) -> Result<bool, String> {
808 let checkout = self.beside(record).ok_or_else(|| {
809 format!(
810 "{} is not a part of a chain {} keeps",
811 record.as_str(),
812 self.definition.app_id()
813 )
814 })?;
815 if !self.records.is_ready(record) && self.records.try_open_record(&checkout).is_err() {
816 let DocumentMutation::Snapshot(bytes) = mutation else {
818 return Ok(false);
819 };
820 self.cache.hydrate(
821 StorageKey::Record {
822 package: self.definition.app_id().into(),
823 workspace: self.workspace.clone(),
824 record: record.as_str().into(),
825 },
826 bytes.clone(),
827 None,
828 )?;
829 self.records
830 .try_open_record(&checkout)
831 .map_err(|error| error.to_string())?;
832 }
833 if let DocumentMutation::Incremental(bytes) = mutation
834 && !self.builds_on(record, bytes)
835 {
836 return Ok(false);
837 }
838 self.set_aside_beside(record);
840 let (changed, observed) = self.records.merge_replica(&checkout, mutation.clone())?;
841 self.beside
842 .entry(record.clone())
843 .or_default()
844 .frontier
845 .observe(observed);
846 self.frame.relayout();
847 Ok(changed)
848 }
849
850 fn builds_on(&self, record: &ui_core::record::RecordId, bytes: &[u8]) -> bool {
852 let mut incoming = ui_core::automerge::AutoCommit::new();
853 if tilo_automerge::ApplyIncoming::load_incoming(&mut incoming, bytes).is_err() {
854 return false;
855 }
856 let missing = incoming.get_missing_deps(&[]);
857 self.records
858 .with(record, |doc| {
859 missing
860 .iter()
861 .all(|hash| doc.get_change_by_hash(hash).is_some())
862 })
863 .unwrap_or(false)
864 }
865
866 fn set_aside_beside(&mut self, record: &ui_core::record::RecordId) {
869 let Some(held) = self.beside.get_mut(record) else {
870 return;
871 };
872 if let Ok((delta, heads)) = self.records.own_delta(record, &mut held.frontier) {
873 held.frontier.advance(heads);
874 if !delta.is_empty() {
875 held.unsent.push(delta);
876 }
877 }
878 }
879
880 fn set_aside_own(&mut self) {
882 if let Ok((delta, heads)) = self
883 .records
884 .own_delta(&self.checkout.id, &mut self.frontier)
885 {
886 self.frontier.advance(heads);
887 if !delta.is_empty() {
888 self.unsent.push(delta);
889 }
890 }
891 }
892}
893
894fn heads_of(document: &[u8]) -> Result<Vec<ui_core::automerge::ChangeHash>, String> {
898 ui_core::automerge::AutoCommit::load(document)
899 .map(|mut doc| doc.get_heads())
900 .map_err(|error| error.to_string())
901}
902
903fn refuse_written_on_open(
906 definition: &ApplicationDefinition,
907 records: &RecordStore,
908 checkout: &RecordCheckout,
909 joined_at: &[ui_core::automerge::ChangeHash],
910) -> Result<(), String> {
911 match records.with(&checkout.id, |doc| written_on_open(doc, joined_at)) {
912 Some(Some(wrote)) => Err(format!(
913 "{} wrote to a record it joined as it opened ({wrote}): only the record's \
914 creator writes its initial content, and a participant that joins adds \
915 nothing on open. What the record starts with belongs in the app's seed; \
916 what a participant adds belongs behind a control they use",
917 definition.app_id
918 )),
919 _ => Ok(()),
920 }
921}
922
923fn written_on_open(
933 doc: &mut ui_core::automerge::AutoCommit,
934 joined_at: &[ui_core::automerge::ChangeHash],
935) -> Option<String> {
936 use tilo_actions::{ActionContext, Did, FrameworkChange, HistoryOp};
937 let wrote: Vec<String> = doc
938 .get_changes(joined_at)
939 .into_iter()
940 .filter_map(|change| {
941 let message = change.message()?;
942 if message.starts_with(tilo_actions::CONTINUATION_PREFIX)
943 || FrameworkChange::from_change_message(message) == Some(FrameworkChange::Native)
944 {
945 return Some("an edit".to_owned());
946 }
947 Some(match ActionContext::from_change_message(message)?.what? {
948 Did::Verb(verb) => format!("`{}`", verb.action.action),
949 Did::Edit(edit) => format!("an edit of `{}`", edit.field),
950 Did::Group(_) => "a group of edits".to_owned(),
951 Did::History(step) if matches!(step.op, HistoryOp::Migrate { .. }) => return None,
952 Did::History(_) => "a history step".to_owned(),
953 })
954 })
955 .collect();
956 (!wrote.is_empty()).then(|| wrote.join(", "))
957}