1use chrono::{DateTime, Utc};
4use rustrails_support::runtime;
5use thiserror::Error;
6use uuid::Uuid;
7
8use crate::{
9 blob::Blob,
10 service::{StorageError, StorageService},
11};
12
13#[derive(Debug, Error)]
15pub enum AttachmentError {
16 #[error("attachment name must not be empty")]
18 EmptyName,
19 #[error(transparent)]
21 Storage(#[from] StorageError),
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Attachment {
27 id: Uuid,
28 record_type: String,
29 record_id: String,
30 name: String,
31 blob: Blob,
32 created_at: DateTime<Utc>,
33}
34
35impl Attachment {
36 pub fn new(
42 record_type: impl Into<String>,
43 record_id: impl Into<String>,
44 name: impl Into<String>,
45 blob: Blob,
46 ) -> Result<Self, AttachmentError> {
47 let name = name.into();
48 if name.trim().is_empty() {
49 return Err(AttachmentError::EmptyName);
50 }
51 Ok(Self {
52 id: Uuid::now_v7(),
53 record_type: record_type.into(),
54 record_id: record_id.into(),
55 name,
56 blob,
57 created_at: Utc::now(),
58 })
59 }
60
61 #[must_use]
63 pub fn id(&self) -> Uuid {
64 self.id
65 }
66
67 #[must_use]
69 pub fn record_type(&self) -> &str {
70 &self.record_type
71 }
72
73 #[must_use]
75 pub fn record_id(&self) -> &str {
76 &self.record_id
77 }
78
79 #[must_use]
81 pub fn name(&self) -> &str {
82 &self.name
83 }
84
85 #[must_use]
87 pub fn blob(&self) -> &Blob {
88 &self.blob
89 }
90
91 #[must_use]
93 pub fn blob_id(&self) -> Uuid {
94 self.blob.id()
95 }
96
97 #[must_use]
99 pub fn created_at(&self) -> DateTime<Utc> {
100 self.created_at
101 }
102
103 pub async fn purge<S: StorageService + ?Sized>(
109 &self,
110 service: &S,
111 ) -> Result<(), AttachmentError> {
112 service.delete(self.blob.key()).await?;
113 Ok(())
114 }
115
116 pub fn purge_sync<S: StorageService + ?Sized>(
122 &self,
123 service: &S,
124 ) -> Result<(), AttachmentError> {
125 runtime::block_on(self.purge(service))
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct HasOneAttached {
132 pub name: String,
134}
135
136impl HasOneAttached {
137 pub fn new(name: impl Into<String>) -> Result<Self, AttachmentError> {
143 Ok(Self {
144 name: validated_name(name.into())?,
145 })
146 }
147
148 #[must_use]
150 pub fn bind(
151 &self,
152 record_type: impl Into<String>,
153 record_id: impl Into<String>,
154 ) -> OneAttachment {
155 OneAttachment::new(record_type, record_id, self.name.clone())
156 }
157}
158
159pub fn has_one_attached(name: impl Into<String>) -> Result<HasOneAttached, AttachmentError> {
165 HasOneAttached::new(name)
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct HasManyAttached {
171 pub name: String,
173}
174
175impl HasManyAttached {
176 pub fn new(name: impl Into<String>) -> Result<Self, AttachmentError> {
182 Ok(Self {
183 name: validated_name(name.into())?,
184 })
185 }
186
187 #[must_use]
189 pub fn bind(
190 &self,
191 record_type: impl Into<String>,
192 record_id: impl Into<String>,
193 ) -> ManyAttachments {
194 ManyAttachments::new(record_type, record_id, self.name.clone())
195 }
196}
197
198pub fn has_many_attached(name: impl Into<String>) -> Result<HasManyAttached, AttachmentError> {
204 HasManyAttached::new(name)
205}
206
207#[derive(Debug, Clone)]
209pub struct OneAttachment {
210 record_type: String,
211 record_id: String,
212 name: String,
213 attachment: Option<Attachment>,
214}
215
216impl OneAttachment {
217 #[must_use]
219 pub fn new(
220 record_type: impl Into<String>,
221 record_id: impl Into<String>,
222 name: impl Into<String>,
223 ) -> Self {
224 Self {
225 record_type: record_type.into(),
226 record_id: record_id.into(),
227 name: name.into(),
228 attachment: None,
229 }
230 }
231
232 pub fn attach(&mut self, blob: Blob) -> Result<&Attachment, AttachmentError> {
238 if self
239 .attachment
240 .as_ref()
241 .is_some_and(|current| current.blob_id() == blob.id())
242 {
243 return self.attachment.as_ref().ok_or(AttachmentError::EmptyName);
244 }
245 self.attachment = Some(Attachment::new(
246 self.record_type.clone(),
247 self.record_id.clone(),
248 self.name.clone(),
249 blob,
250 )?);
251 self.attachment.as_ref().ok_or(AttachmentError::EmptyName)
252 }
253
254 #[must_use]
256 pub fn is_attached(&self) -> bool {
257 self.attachment.is_some()
258 }
259
260 #[must_use]
262 pub fn attachment(&self) -> Option<&Attachment> {
263 self.attachment.as_ref()
264 }
265
266 pub fn detach(&mut self) -> Option<Attachment> {
268 self.attachment.take()
269 }
270
271 pub async fn purge<S: StorageService + ?Sized>(
277 &mut self,
278 service: &S,
279 ) -> Result<Option<Attachment>, AttachmentError> {
280 if let Some(attachment) = &self.attachment {
281 attachment.purge(service).await?;
282 }
283 Ok(self.detach())
284 }
285
286 pub fn purge_sync<S: StorageService + ?Sized>(
292 &mut self,
293 service: &S,
294 ) -> Result<Option<Attachment>, AttachmentError> {
295 runtime::block_on(self.purge(service))
296 }
297}
298
299#[derive(Debug, Clone)]
301pub struct ManyAttachments {
302 record_type: String,
303 record_id: String,
304 name: String,
305 attachments: Vec<Attachment>,
306}
307
308impl ManyAttachments {
309 #[must_use]
311 pub fn new(
312 record_type: impl Into<String>,
313 record_id: impl Into<String>,
314 name: impl Into<String>,
315 ) -> Self {
316 Self {
317 record_type: record_type.into(),
318 record_id: record_id.into(),
319 name: name.into(),
320 attachments: Vec::new(),
321 }
322 }
323
324 pub fn attach(&mut self, blob: Blob) -> Result<&Attachment, AttachmentError> {
330 let attachment = Attachment::new(
331 self.record_type.clone(),
332 self.record_id.clone(),
333 self.name.clone(),
334 blob,
335 )?;
336 self.attachments.push(attachment);
337 self.attachments.last().ok_or(AttachmentError::EmptyName)
338 }
339
340 pub fn attach_many<I>(&mut self, blobs: I) -> Result<(), AttachmentError>
346 where
347 I: IntoIterator<Item = Blob>,
348 {
349 for blob in blobs {
350 let _ = self.attach(blob)?;
351 }
352 Ok(())
353 }
354
355 #[must_use]
357 pub fn is_attached(&self) -> bool {
358 !self.attachments.is_empty()
359 }
360
361 #[must_use]
363 pub fn len(&self) -> usize {
364 self.attachments.len()
365 }
366
367 #[must_use]
369 pub fn is_empty(&self) -> bool {
370 self.attachments.is_empty()
371 }
372
373 #[must_use]
375 pub fn attachments(&self) -> &[Attachment] {
376 &self.attachments
377 }
378
379 pub fn blobs(&self) -> impl Iterator<Item = &Blob> {
381 self.attachments.iter().map(Attachment::blob)
382 }
383
384 pub fn detach_blob(&mut self, blob_id: Uuid) -> Option<Attachment> {
386 self.attachments
387 .iter()
388 .position(|attachment| attachment.blob_id() == blob_id)
389 .map(|index| self.attachments.remove(index))
390 }
391
392 pub async fn purge_all<S: StorageService + ?Sized>(
398 &mut self,
399 service: &S,
400 ) -> Result<Vec<Attachment>, AttachmentError> {
401 for attachment in &self.attachments {
402 attachment.purge(service).await?;
403 }
404 Ok(std::mem::take(&mut self.attachments))
405 }
406
407 pub fn purge_all_sync<S: StorageService + ?Sized>(
413 &mut self,
414 service: &S,
415 ) -> Result<Vec<Attachment>, AttachmentError> {
416 runtime::block_on(self.purge_all(service))
417 }
418}
419
420fn validated_name(name: String) -> Result<String, AttachmentError> {
421 if name.trim().is_empty() {
422 Err(AttachmentError::EmptyName)
423 } else {
424 Ok(name)
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use std::collections::BTreeMap;
431
432 use bytes::Bytes;
433 use rustrails_support::runtime;
434
435 use super::*;
436 use crate::{blob::Blob, service::memory::MemoryService, test_support::run_sync_test};
437
438 fn blob(filename: &str) -> Blob {
439 Blob::create(
440 Bytes::from(filename.as_bytes().to_vec()),
441 filename.to_owned(),
442 None,
443 BTreeMap::new(),
444 "memory",
445 )
446 .expect("blob should build")
447 }
448
449 #[test]
450 fn test_attachment_new_captures_record_information() {
451 let attachment = Attachment::new("User", "1", "avatar", blob("avatar.png"))
452 .expect("attachment should build");
453 assert_eq!(attachment.record_type(), "User");
454 assert_eq!(attachment.record_id(), "1");
455 assert_eq!(attachment.name(), "avatar");
456 }
457
458 #[test]
459 fn test_attachment_rejects_empty_name() {
460 let error = Attachment::new("User", "1", "", blob("avatar.png"))
461 .expect_err("attachment should fail");
462 assert!(matches!(error, AttachmentError::EmptyName));
463 }
464
465 #[test]
466 fn test_one_attachment_attach_sets_current_attachment() {
467 let mut one = OneAttachment::new("User", "1", "avatar");
468 let attachment = one
469 .attach(blob("avatar.png"))
470 .expect("attach should succeed");
471 assert_eq!(attachment.name(), "avatar");
472 assert!(one.is_attached());
473 }
474
475 #[test]
476 fn test_one_attachment_replace_swaps_blob() {
477 let mut one = OneAttachment::new("User", "1", "avatar");
478 let _ = one.attach(blob("old.png")).expect("attach should succeed");
479 let attachment = one.attach(blob("new.png")).expect("attach should succeed");
480 assert_eq!(attachment.blob().filename(), "new.png");
481 }
482
483 #[test]
484 fn test_one_attachment_attaching_same_blob_keeps_attachment() {
485 let mut one = OneAttachment::new("User", "1", "avatar");
486 let avatar = blob("avatar.png");
487 let first_id = one
488 .attach(avatar.clone())
489 .expect("attach should succeed")
490 .id();
491 let second_id = one.attach(avatar).expect("attach should succeed").id();
492 assert_eq!(first_id, second_id);
493 }
494
495 #[test]
496 fn test_one_attachment_detach_returns_previous_attachment() {
497 let mut one = OneAttachment::new("User", "1", "avatar");
498 let _ = one
499 .attach(blob("avatar.png"))
500 .expect("attach should succeed");
501 let detached = one.detach().expect("attachment should exist");
502 assert_eq!(detached.blob().filename(), "avatar.png");
503 assert!(!one.is_attached());
504 }
505
506 #[tokio::test]
507 async fn test_one_attachment_purge_deletes_backing_blob() {
508 let service = MemoryService::new("memory").expect("service should build");
509 let blob = blob("avatar.png");
510 service
511 .upload(blob.key(), Bytes::from_static(b"avatar"))
512 .await
513 .expect("upload should succeed");
514 let mut one = OneAttachment::new("User", "1", "avatar");
515 let _ = one.attach(blob.clone()).expect("attach should succeed");
516 let purged = one.purge(&service).await.expect("purge should succeed");
517 assert_eq!(
518 purged.expect("attachment should be returned").blob_id(),
519 blob.id()
520 );
521 assert!(
522 !service
523 .exists(blob.key())
524 .await
525 .expect("exists should succeed")
526 );
527 }
528
529 #[test]
530 fn test_attachment_purge_sync_deletes_backing_blob() {
531 run_sync_test(|| {
532 let service = MemoryService::new("memory").expect("service should build");
533 let blob = blob("avatar.png");
534 runtime::block_on(service.upload(blob.key(), Bytes::from_static(b"avatar")))
535 .expect("upload should succeed");
536 let attachment = Attachment::new("User", "1", "avatar", blob.clone())
537 .expect("attachment should build");
538
539 attachment
540 .purge_sync(&service)
541 .expect("purge_sync should succeed");
542
543 assert!(!runtime::block_on(service.exists(blob.key())).expect("exists should succeed"));
544 });
545 }
546
547 #[test]
548 fn test_one_attachment_purge_sync_deletes_backing_blob() {
549 run_sync_test(|| {
550 let service = MemoryService::new("memory").expect("service should build");
551 let blob = blob("avatar.png");
552 runtime::block_on(service.upload(blob.key(), Bytes::from_static(b"avatar")))
553 .expect("upload should succeed");
554 let mut one = OneAttachment::new("User", "1", "avatar");
555 let _ = one.attach(blob.clone()).expect("attach should succeed");
556
557 let purged = one.purge_sync(&service).expect("purge_sync should succeed");
558
559 assert_eq!(
560 purged.expect("attachment should be returned").blob_id(),
561 blob.id()
562 );
563 assert!(!runtime::block_on(service.exists(blob.key())).expect("exists should succeed"));
564 });
565 }
566
567 #[test]
568 fn test_many_attachments_attach_preserves_order() {
569 let mut many = ManyAttachments::new("User", "1", "photos");
570 let _ = many.attach(blob("one.png")).expect("attach should succeed");
571 let _ = many.attach(blob("two.png")).expect("attach should succeed");
572 assert_eq!(many.attachments()[0].blob().filename(), "one.png");
573 assert_eq!(many.attachments()[1].blob().filename(), "two.png");
574 }
575
576 #[test]
577 fn test_many_attachments_attach_many_adds_all_blobs() {
578 let mut many = ManyAttachments::new("User", "1", "photos");
579 many.attach_many([blob("one.png"), blob("two.png")])
580 .expect("attach should succeed");
581 assert_eq!(many.len(), 2);
582 }
583
584 #[test]
585 fn test_many_attachments_reports_empty_state() {
586 let many = ManyAttachments::new("User", "1", "photos");
587 assert!(many.is_empty());
588 assert!(!many.is_attached());
589 }
590
591 #[test]
592 fn test_many_attachments_blobs_iterator_exposes_blob_names() {
593 let mut many = ManyAttachments::new("User", "1", "photos");
594 many.attach_many([blob("one.png"), blob("two.png")])
595 .expect("attach should succeed");
596 let names: Vec<_> = many
597 .blobs()
598 .map(|blob| blob.filename().to_owned())
599 .collect();
600 assert_eq!(names, ["one.png", "two.png"]);
601 }
602
603 #[test]
604 fn test_many_attachments_detach_blob_removes_only_matching_blob() {
605 let mut many = ManyAttachments::new("User", "1", "photos");
606 let first = blob("one.png");
607 let first_id = first.id();
608 many.attach_many([first, blob("two.png")])
609 .expect("attach should succeed");
610 let detached = many.detach_blob(first_id).expect("attachment should exist");
611 assert_eq!(detached.blob().filename(), "one.png");
612 assert_eq!(many.len(), 1);
613 assert_eq!(many.attachments()[0].blob().filename(), "two.png");
614 }
615
616 #[test]
617 fn test_many_attachments_detach_blob_returns_none_for_missing_blob() {
618 let mut many = ManyAttachments::new("User", "1", "photos");
619 many.attach(blob("one.png")).expect("attach should succeed");
620 assert!(many.detach_blob(Uuid::now_v7()).is_none());
621 }
622
623 #[tokio::test]
624 async fn test_many_attachments_purge_all_deletes_backing_blobs() {
625 let service = MemoryService::new("memory").expect("service should build");
626 let first = blob("one.png");
627 let second = blob("two.png");
628 service
629 .upload(first.key(), Bytes::from_static(b"one"))
630 .await
631 .expect("upload should succeed");
632 service
633 .upload(second.key(), Bytes::from_static(b"two"))
634 .await
635 .expect("upload should succeed");
636 let mut many = ManyAttachments::new("User", "1", "photos");
637 many.attach_many([first.clone(), second.clone()])
638 .expect("attach should succeed");
639 let purged = many
640 .purge_all(&service)
641 .await
642 .expect("purge should succeed");
643 assert_eq!(purged.len(), 2);
644 assert!(
645 !service
646 .exists(first.key())
647 .await
648 .expect("exists should succeed")
649 );
650 assert!(
651 !service
652 .exists(second.key())
653 .await
654 .expect("exists should succeed")
655 );
656 assert!(many.is_empty());
657 }
658
659 #[test]
660 fn test_many_attachments_purge_all_sync_deletes_backing_blobs() {
661 run_sync_test(|| {
662 let service = MemoryService::new("memory").expect("service should build");
663 let first = blob("one.png");
664 let second = blob("two.png");
665 runtime::block_on(service.upload(first.key(), Bytes::from_static(b"one")))
666 .expect("upload should succeed");
667 runtime::block_on(service.upload(second.key(), Bytes::from_static(b"two")))
668 .expect("upload should succeed");
669 let mut many = ManyAttachments::new("User", "1", "photos");
670 many.attach_many([first.clone(), second.clone()])
671 .expect("attach should succeed");
672
673 let purged = many
674 .purge_all_sync(&service)
675 .expect("purge_all_sync should succeed");
676
677 assert_eq!(purged.len(), 2);
678 assert!(
679 !runtime::block_on(service.exists(first.key())).expect("exists should succeed")
680 );
681 assert!(
682 !runtime::block_on(service.exists(second.key())).expect("exists should succeed")
683 );
684 assert!(many.is_empty());
685 });
686 }
687
688 #[test]
689 fn test_many_attachments_can_attach_duplicate_filenames() {
690 let mut many = ManyAttachments::new("User", "1", "photos");
691 many.attach_many([blob("same.png"), blob("same.png")])
692 .expect("attach should succeed");
693 assert_eq!(many.len(), 2);
694 assert_ne!(
695 many.attachments()[0].blob_id(),
696 many.attachments()[1].blob_id()
697 );
698 }
699
700 #[test]
701 fn test_attachment_created_at_is_recent() {
702 let attachment = Attachment::new("User", "1", "avatar", blob("avatar.png"))
703 .expect("attachment should build");
704 assert!(attachment.created_at() <= Utc::now());
705 }
706
707 #[test]
708 fn test_one_attachment_empty_relation_has_no_attachment() {
709 let one = OneAttachment::new("User", "1", "avatar");
710 assert!(one.attachment().is_none());
711 }
712
713 #[test]
714 fn test_many_attachments_share_name_for_each_attachment() {
715 let mut many = ManyAttachments::new("User", "1", "photos");
716 many.attach_many([blob("one.png"), blob("two.png")])
717 .expect("attach should succeed");
718 assert!(
719 many.attachments()
720 .iter()
721 .all(|attachment| attachment.name() == "photos")
722 );
723 }
724
725 #[test]
726 fn test_has_one_attached_metadata_binds_to_record() {
727 let metadata = has_one_attached("avatar").expect("metadata should build");
728 let relation = metadata.bind("User", "1");
729 assert!(!relation.is_attached());
730 assert_eq!(metadata.name, "avatar");
731 }
732
733 #[test]
734 fn test_has_many_attached_metadata_binds_to_record() {
735 let metadata = has_many_attached("photos").expect("metadata should build");
736 let relation = metadata.bind("User", "1");
737 assert!(relation.is_empty());
738 assert_eq!(metadata.name, "photos");
739 }
740
741 #[test]
742 fn test_attachment_metadata_rejects_blank_name() {
743 assert!(matches!(
744 has_one_attached(" "),
745 Err(AttachmentError::EmptyName)
746 ));
747 assert!(matches!(
748 has_many_attached(""),
749 Err(AttachmentError::EmptyName)
750 ));
751 }
752}