peat_mesh/storage/blob_traits.rs
1//! Blob storage trait abstraction (ADR-025)
2//!
3//! This module defines traits for content-addressed blob storage, enabling
4//! backend-agnostic file transfer through the mesh network.
5//!
6//! # Design Philosophy
7//!
8//! - **Separate from documents**: Blobs use different sync protocols optimized for large binaries
9//! - **Content-addressed**: Blobs identified by cryptographic hash (SHA256 or BLAKE3)
10//! - **Progress tracking**: Long transfers provide progress callbacks
11//! - **Resumable**: Interrupted transfers can continue where they left off
12//!
13//! # Example
14//!
15//! ```ignore
16//! use peat_mesh::storage::{BlobStore, BlobMetadata};
17//! use std::path::Path;
18//!
19//! // Create blob from file
20//! let metadata = BlobMetadata {
21//! name: Some("model.onnx".to_string()),
22//! content_type: Some("application/onnx".to_string()),
23//! ..Default::default()
24//! };
25//! let token = blob_store.create_blob(Path::new("/models/yolov8.onnx"), metadata).await?;
26//! println!("Created blob: {}", token.hash.as_hex());
27//!
28//! // Fetch blob with progress
29//! let handle = blob_store.fetch_blob(&token, |progress| {
30//! if let BlobProgress::Downloading { downloaded_bytes, total_bytes } = progress {
31//! println!("Progress: {}/{} bytes", downloaded_bytes, total_bytes);
32//! }
33//! }).await?;
34//! println!("Blob available at: {}", handle.path.display());
35//! ```
36
37use anyhow::Result;
38use serde::{Deserialize, Serialize};
39use std::collections::HashMap;
40use std::fmt;
41use std::path::{Path, PathBuf};
42use std::sync::Arc;
43
44// ============================================================================
45// Core Types
46// ============================================================================
47
48/// Content-addressed blob identifier
49///
50/// Blobs are identified by their cryptographic hash (BLAKE3 via iroh-blobs).
51///
52/// The hash string format is backend-specific but always represents
53/// the content hash of the blob.
54#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
55pub struct BlobHash(pub String);
56
57impl BlobHash {
58 /// Create from hex string (sha256 or blake3)
59 pub fn from_hex(hex: &str) -> Self {
60 Self(hex.to_string())
61 }
62
63 /// Get hex representation
64 pub fn as_hex(&self) -> &str {
65 &self.0
66 }
67}
68
69impl fmt::Display for BlobHash {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 // Show first 16 chars for readability
72 if self.0.len() > 16 {
73 write!(f, "{}...", &self.0[..16])
74 } else {
75 write!(f, "{}", self.0)
76 }
77 }
78}
79
80/// Token referencing a blob with metadata
81///
82/// A BlobToken uniquely identifies a blob and contains all information
83/// needed to fetch it from the mesh. Tokens are serializable and can
84/// be stored in CRDT documents to reference blob content.
85#[derive(Clone, Debug, Serialize, Deserialize)]
86pub struct BlobToken {
87 /// Content hash (BLAKE3 via iroh-blobs)
88 pub hash: BlobHash,
89 /// Size in bytes (known at creation time)
90 pub size_bytes: u64,
91 /// User-defined metadata
92 pub metadata: BlobMetadata,
93}
94
95impl BlobToken {
96 /// Create a new blob token
97 pub fn new(hash: BlobHash, size_bytes: u64, metadata: BlobMetadata) -> Self {
98 Self {
99 hash,
100 size_bytes,
101 metadata,
102 }
103 }
104
105 /// Check if this is a small blob (< 1MB)
106 pub fn is_small(&self) -> bool {
107 self.size_bytes < 1024 * 1024
108 }
109
110 /// Check if this is a large blob (> 100MB)
111 pub fn is_large(&self) -> bool {
112 self.size_bytes > 100 * 1024 * 1024
113 }
114}
115
116/// Metadata attached to blobs
117///
118/// Metadata travels with the blob token and is available before
119/// fetching the blob content. Use this for display names, MIME types,
120/// and application-specific key-value pairs.
121#[derive(Clone, Debug, Default, Serialize, Deserialize)]
122pub struct BlobMetadata {
123 /// Human-readable name (e.g., "yolov8_fp16.onnx")
124 pub name: Option<String>,
125 /// MIME type (e.g., "application/onnx", "application/octet-stream")
126 pub content_type: Option<String>,
127 /// Custom key-value pairs for application-specific data
128 ///
129 /// Examples:
130 /// - "model_id" -> "target_recognition"
131 /// - "version" -> "4.2.1"
132 /// - "precision" -> "fp16"
133 pub custom: HashMap<String, String>,
134}
135
136impl BlobMetadata {
137 /// Create metadata with just a name
138 pub fn with_name(name: impl Into<String>) -> Self {
139 Self {
140 name: Some(name.into()),
141 ..Default::default()
142 }
143 }
144
145 /// Create metadata with name and content type
146 pub fn with_name_and_type(name: impl Into<String>, content_type: impl Into<String>) -> Self {
147 Self {
148 name: Some(name.into()),
149 content_type: Some(content_type.into()),
150 custom: HashMap::new(),
151 }
152 }
153
154 /// Add a custom metadata field
155 pub fn with_custom(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
156 self.custom.insert(key.into(), value.into());
157 self
158 }
159}
160
161/// Progress updates during blob operations
162///
163/// Callbacks receive these updates during long-running blob transfers.
164/// Use for progress bars, logging, and timeout detection.
165#[derive(Clone, Debug)]
166pub enum BlobProgress {
167 /// Transfer started, total size known
168 Started {
169 /// Total bytes to transfer
170 total_bytes: u64,
171 },
172 /// Transfer in progress
173 Downloading {
174 /// Bytes downloaded so far
175 downloaded_bytes: u64,
176 /// Total bytes to download
177 total_bytes: u64,
178 },
179 /// Transfer complete, blob available locally
180 Completed {
181 /// Local filesystem path to blob content
182 local_path: PathBuf,
183 },
184 /// Transfer failed
185 Failed {
186 /// Error description
187 error: String,
188 },
189}
190
191impl BlobProgress {
192 /// Get progress percentage (0.0 to 100.0)
193 pub fn percentage(&self) -> Option<f64> {
194 match self {
195 BlobProgress::Started { .. } => Some(0.0),
196 BlobProgress::Downloading {
197 downloaded_bytes,
198 total_bytes,
199 } => {
200 if *total_bytes == 0 {
201 Some(100.0)
202 } else {
203 Some(*downloaded_bytes as f64 / *total_bytes as f64 * 100.0)
204 }
205 }
206 BlobProgress::Completed { .. } => Some(100.0),
207 BlobProgress::Failed { .. } => None,
208 }
209 }
210
211 /// Check if transfer is complete
212 pub fn is_complete(&self) -> bool {
213 matches!(self, BlobProgress::Completed { .. })
214 }
215
216 /// Check if transfer failed
217 pub fn is_failed(&self) -> bool {
218 matches!(self, BlobProgress::Failed { .. })
219 }
220}
221
222/// Handle to a locally available blob
223///
224/// Returned after successfully fetching a blob. Provides access to
225/// the local file path where the blob content is stored.
226#[derive(Debug)]
227pub struct BlobHandle {
228 /// Token identifying the blob
229 pub token: BlobToken,
230 /// Local filesystem path to blob content
231 pub path: PathBuf,
232}
233
234impl BlobHandle {
235 /// Create a new blob handle
236 pub fn new(token: BlobToken, path: PathBuf) -> Self {
237 Self { token, path }
238 }
239
240 /// Read the blob content into memory
241 ///
242 /// # Warning
243 ///
244 /// Only use for small blobs! For large blobs, use [`open_read_stream`](Self::open_read_stream).
245 pub async fn read_to_vec(&self) -> Result<Vec<u8>> {
246 tokio::fs::read(&self.path)
247 .await
248 .map_err(|e| anyhow::anyhow!("Failed to read blob at {:?}: {}", self.path, e))
249 }
250
251 /// Open the blob content as an async byte stream.
252 ///
253 /// Returns a [`tokio::fs::File`] which implements [`AsyncRead`](tokio::io::AsyncRead).
254 /// Use this instead of [`read_to_vec`](Self::read_to_vec) for large blobs to avoid
255 /// buffering the entire content in memory.
256 pub async fn open_read_stream(&self) -> Result<tokio::fs::File> {
257 tokio::fs::File::open(&self.path)
258 .await
259 .map_err(|e| anyhow::anyhow!("Failed to open blob stream at {:?}: {}", self.path, e))
260 }
261
262 /// Get the blob size in bytes
263 pub fn size(&self) -> u64 {
264 self.token.size_bytes
265 }
266}
267
268// ============================================================================
269// BlobStore Trait
270// ============================================================================
271
272/// Content-addressed blob storage trait
273///
274/// Abstracts over content-addressed blob storage (iroh-blobs).
275/// All blobs are content-addressed: the hash of the content serves as the ID.
276///
277/// # Thread Safety
278///
279/// All methods are safe to call from multiple threads. Implementations use
280/// appropriate synchronization internally.
281///
282/// # Example
283///
284/// ```ignore
285/// // Create blob from file
286/// let token = blob_store.create_blob(
287/// Path::new("/models/yolov8.onnx"),
288/// BlobMetadata::with_name("yolov8.onnx")
289/// ).await?;
290///
291/// // Share token with other nodes via CRDT document
292/// doc.set("model_blob", &token)?;
293///
294/// // Other node fetches blob
295/// let handle = blob_store.fetch_blob(&token, |p| println!("{:?}", p)).await?;
296/// ```
297#[async_trait::async_trait]
298pub trait BlobStore: Send + Sync {
299 /// Create a blob from a file
300 ///
301 /// Reads the file, computes content hash, and stores in blob storage.
302 /// Returns a token that can be used to fetch the blob later.
303 ///
304 /// # Arguments
305 ///
306 /// * `path` - Path to source file (must exist and be readable)
307 /// * `metadata` - User-defined metadata to attach
308 ///
309 /// # Returns
310 ///
311 /// Token identifying the blob (content hash + size + metadata)
312 ///
313 /// # Errors
314 ///
315 /// - File not found or not readable
316 /// - Backend storage failure
317 async fn create_blob(&self, path: &Path, metadata: BlobMetadata) -> Result<BlobToken>;
318
319 /// Create a blob from bytes
320 ///
321 /// Useful for generating blobs programmatically without writing to disk first.
322 ///
323 /// # Arguments
324 ///
325 /// * `data` - Raw blob content
326 /// * `metadata` - User-defined metadata to attach
327 ///
328 /// # Returns
329 ///
330 /// Token identifying the blob
331 async fn create_blob_from_bytes(
332 &self,
333 data: &[u8],
334 metadata: BlobMetadata,
335 ) -> Result<BlobToken>;
336
337 /// Fetch a blob with progress tracking
338 ///
339 /// If the blob exists locally, returns immediately with the local path.
340 /// Otherwise, fetches from mesh peers via the backend's sync protocol.
341 ///
342 /// # Arguments
343 ///
344 /// * `token` - Token identifying the blob to fetch
345 /// * `progress` - Callback invoked with progress updates
346 ///
347 /// # Returns
348 ///
349 /// Handle providing local path to blob content
350 ///
351 /// # Errors
352 ///
353 /// - Blob not found on any peer
354 /// - Network failure
355 /// - Timeout
356 async fn fetch_blob<F>(&self, token: &BlobToken, progress: F) -> Result<BlobHandle>
357 where
358 F: FnMut(BlobProgress) + Send + 'static;
359
360 /// Check if blob exists locally
361 ///
362 /// Returns true if the blob is available locally without network fetch.
363 /// Use this to avoid unnecessary network requests.
364 fn blob_exists_locally(&self, hash: &BlobHash) -> bool;
365
366 /// Get blob info without fetching content
367 ///
368 /// Returns metadata about a known blob, or None if unknown.
369 /// Does not trigger network fetch.
370 fn blob_info(&self, hash: &BlobHash) -> Option<BlobToken>;
371
372 /// Delete a local blob
373 ///
374 /// Removes the blob from local storage. Does not affect other peers.
375 ///
376 /// # Warning
377 ///
378 /// If the blob is referenced by documents, garbage collection may recreate it
379 /// when those documents sync. Use with caution.
380 async fn delete_blob(&self, hash: &BlobHash) -> Result<()>;
381
382 /// List all locally available blobs
383 ///
384 /// Returns tokens for all blobs stored locally. Does not include
385 /// blobs available only on remote peers.
386 fn list_local_blobs(&self) -> Vec<BlobToken>;
387
388 /// Create a blob from an async byte stream.
389 ///
390 /// Streaming alternative to [`create_blob_from_bytes`](Self::create_blob_from_bytes)
391 /// for large blobs. Avoids requiring the caller to buffer the entire blob in memory.
392 ///
393 /// The default implementation buffers the stream and delegates to
394 /// `create_blob_from_bytes`. Backends that support streaming import
395 /// should override this.
396 ///
397 /// # Arguments
398 ///
399 /// * `stream` - Async byte stream of blob content
400 /// * `expected_size` - Size hint for pre-allocation (None if unknown)
401 /// * `metadata` - User-defined metadata to attach
402 async fn create_blob_from_stream(
403 &self,
404 stream: &mut (dyn tokio::io::AsyncRead + Send + Unpin),
405 expected_size: Option<u64>,
406 metadata: BlobMetadata,
407 ) -> Result<BlobToken> {
408 use tokio::io::AsyncReadExt;
409 let mut buf = match expected_size {
410 Some(size) => Vec::with_capacity(size as usize),
411 None => Vec::new(),
412 };
413 stream
414 .read_to_end(&mut buf)
415 .await
416 .map_err(|e| anyhow::anyhow!("Failed to read stream: {}", e))?;
417 self.create_blob_from_bytes(&buf, metadata).await
418 }
419
420 /// Get total size of local blob storage in bytes
421 fn local_storage_bytes(&self) -> u64;
422}
423
424// ============================================================================
425// BlobStoreExt - Extension Trait
426// ============================================================================
427
428/// Extension methods for BlobStore
429///
430/// Provides convenience methods built on top of the core BlobStore trait.
431#[async_trait::async_trait]
432pub trait BlobStoreExt: BlobStore {
433 /// Fetch blob without progress callback
434 ///
435 /// Convenience method when progress tracking isn't needed.
436 async fn fetch_blob_simple(&self, token: &BlobToken) -> Result<BlobHandle> {
437 self.fetch_blob(token, |_| {}).await
438 }
439
440 /// Ensure blob is available locally, fetching if needed
441 ///
442 /// Returns the local path if already present, otherwise fetches.
443 async fn ensure_local(&self, token: &BlobToken) -> Result<PathBuf> {
444 if self.blob_exists_locally(&token.hash) {
445 if let Some(info) = self.blob_info(&token.hash) {
446 // Blob exists locally, but we need the path
447 // This is a limitation - we'd need the handle
448 // For now, just fetch (which should be instant if local)
449 let handle = self
450 .fetch_blob_simple(&BlobToken {
451 hash: info.hash,
452 size_bytes: info.size_bytes,
453 metadata: token.metadata.clone(),
454 })
455 .await?;
456 return Ok(handle.path);
457 }
458 }
459 let handle = self.fetch_blob_simple(token).await?;
460 Ok(handle.path)
461 }
462
463 /// Get storage usage summary
464 fn storage_summary(&self) -> BlobStorageSummary {
465 let blobs = self.list_local_blobs();
466 BlobStorageSummary {
467 blob_count: blobs.len(),
468 total_bytes: self.local_storage_bytes(),
469 largest_blob: blobs.iter().map(|t| t.size_bytes).max(),
470 }
471 }
472}
473
474/// Storage usage summary
475#[derive(Debug, Clone)]
476pub struct BlobStorageSummary {
477 /// Number of blobs stored locally
478 pub blob_count: usize,
479 /// Total bytes used
480 pub total_bytes: u64,
481 /// Size of largest blob (if any)
482 pub largest_blob: Option<u64>,
483}
484
485// Blanket implementation of BlobStoreExt for all BlobStore implementations
486impl<T: BlobStore + ?Sized> BlobStoreExt for T {}
487
488// ============================================================================
489// Type Aliases
490// ============================================================================
491
492/// Arc-wrapped BlobStore for shared ownership
493pub type SharedBlobStore = Arc<dyn BlobStore>;
494
495// ============================================================================
496// Tests
497// ============================================================================
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn test_blob_hash_display() {
505 let hash = BlobHash::from_hex("a7f8b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0");
506 assert_eq!(format!("{}", hash), "a7f8b3c4d5e6f7a8...");
507
508 let short_hash = BlobHash::from_hex("abc123");
509 assert_eq!(format!("{}", short_hash), "abc123");
510 }
511
512 #[test]
513 fn test_blob_token_size_classification() {
514 let small = BlobToken::new(
515 BlobHash::from_hex("abc"),
516 500 * 1024, // 500KB
517 BlobMetadata::default(),
518 );
519 assert!(small.is_small());
520 assert!(!small.is_large());
521
522 let large = BlobToken::new(
523 BlobHash::from_hex("def"),
524 200 * 1024 * 1024, // 200MB
525 BlobMetadata::default(),
526 );
527 assert!(!large.is_small());
528 assert!(large.is_large());
529 }
530
531 #[test]
532 fn test_blob_metadata_builder() {
533 let meta = BlobMetadata::with_name("model.onnx")
534 .with_custom("version", "1.0")
535 .with_custom("precision", "fp16");
536
537 assert_eq!(meta.name, Some("model.onnx".to_string()));
538 assert_eq!(meta.custom.get("version"), Some(&"1.0".to_string()));
539 assert_eq!(meta.custom.get("precision"), Some(&"fp16".to_string()));
540 }
541
542 #[test]
543 fn test_blob_progress_percentage() {
544 let started = BlobProgress::Started { total_bytes: 1000 };
545 assert_eq!(started.percentage(), Some(0.0));
546
547 let downloading = BlobProgress::Downloading {
548 downloaded_bytes: 500,
549 total_bytes: 1000,
550 };
551 assert_eq!(downloading.percentage(), Some(50.0));
552
553 let completed = BlobProgress::Completed {
554 local_path: PathBuf::from("/tmp/blob"),
555 };
556 assert_eq!(completed.percentage(), Some(100.0));
557
558 let failed = BlobProgress::Failed {
559 error: "oops".to_string(),
560 };
561 assert_eq!(failed.percentage(), None);
562 }
563
564 #[test]
565 fn test_blob_token_serialization() {
566 let token = BlobToken::new(
567 BlobHash::from_hex("a7f8b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0"),
568 1024 * 1024,
569 BlobMetadata::with_name_and_type("model.onnx", "application/onnx"),
570 );
571
572 let json = serde_json::to_string(&token).unwrap();
573 let parsed: BlobToken = serde_json::from_str(&json).unwrap();
574
575 assert_eq!(parsed.hash, token.hash);
576 assert_eq!(parsed.size_bytes, token.size_bytes);
577 assert_eq!(parsed.metadata.name, token.metadata.name);
578 }
579
580 #[test]
581 fn test_blob_hash_as_hex() {
582 let hash = BlobHash::from_hex("deadbeef");
583 assert_eq!(hash.as_hex(), "deadbeef");
584 }
585
586 #[test]
587 fn test_blob_hash_display_short() {
588 // 16 chars or fewer: display as-is
589 let hash = BlobHash::from_hex("1234567890abcdef");
590 assert_eq!(format!("{}", hash), "1234567890abcdef");
591 }
592
593 #[test]
594 fn test_blob_hash_display_long() {
595 // More than 16 chars: truncate with ...
596 let hash = BlobHash::from_hex("1234567890abcdef0");
597 assert_eq!(format!("{}", hash), "1234567890abcdef...");
598 }
599
600 #[test]
601 fn test_blob_hash_equality() {
602 let h1 = BlobHash::from_hex("abc123");
603 let h2 = BlobHash::from_hex("abc123");
604 let h3 = BlobHash::from_hex("def456");
605
606 assert_eq!(h1, h2);
607 assert_ne!(h1, h3);
608 }
609
610 #[test]
611 fn test_blob_token_medium_size() {
612 // Between 1MB and 100MB: neither small nor large
613 let medium = BlobToken::new(
614 BlobHash::from_hex("abc"),
615 50 * 1024 * 1024, // 50MB
616 BlobMetadata::default(),
617 );
618 assert!(!medium.is_small());
619 assert!(!medium.is_large());
620 }
621
622 #[test]
623 fn test_blob_token_exact_boundary() {
624 // Exactly 1MB: not small (< 1MB), not large
625 let exactly_1mb = BlobToken::new(
626 BlobHash::from_hex("abc"),
627 1024 * 1024,
628 BlobMetadata::default(),
629 );
630 assert!(!exactly_1mb.is_small());
631 assert!(!exactly_1mb.is_large());
632
633 // Exactly 100MB: not small, not large (> 100MB)
634 let exactly_100mb = BlobToken::new(
635 BlobHash::from_hex("abc"),
636 100 * 1024 * 1024,
637 BlobMetadata::default(),
638 );
639 assert!(!exactly_100mb.is_small());
640 assert!(!exactly_100mb.is_large());
641 }
642
643 #[test]
644 fn test_blob_metadata_default() {
645 let meta = BlobMetadata::default();
646 assert!(meta.name.is_none());
647 assert!(meta.content_type.is_none());
648 assert!(meta.custom.is_empty());
649 }
650
651 #[test]
652 fn test_blob_metadata_with_name() {
653 let meta = BlobMetadata::with_name("test.bin");
654 assert_eq!(meta.name, Some("test.bin".to_string()));
655 assert!(meta.content_type.is_none());
656 }
657
658 #[test]
659 fn test_blob_metadata_with_name_and_type() {
660 let meta = BlobMetadata::with_name_and_type("test.jpg", "image/jpeg");
661 assert_eq!(meta.name, Some("test.jpg".to_string()));
662 assert_eq!(meta.content_type, Some("image/jpeg".to_string()));
663 assert!(meta.custom.is_empty());
664 }
665
666 #[test]
667 fn test_blob_metadata_chained_custom_fields() {
668 let meta = BlobMetadata::with_name("model.onnx")
669 .with_custom("version", "1.0")
670 .with_custom("precision", "fp16")
671 .with_custom("framework", "pytorch");
672
673 assert_eq!(meta.custom.len(), 3);
674 assert_eq!(meta.custom.get("version"), Some(&"1.0".to_string()));
675 assert_eq!(meta.custom.get("framework"), Some(&"pytorch".to_string()));
676 }
677
678 #[test]
679 fn test_blob_progress_started_percentage() {
680 let p = BlobProgress::Started { total_bytes: 5000 };
681 assert_eq!(p.percentage(), Some(0.0));
682 assert!(!p.is_complete());
683 assert!(!p.is_failed());
684 }
685
686 #[test]
687 fn test_blob_progress_downloading_zero_total() {
688 let p = BlobProgress::Downloading {
689 downloaded_bytes: 0,
690 total_bytes: 0,
691 };
692 assert_eq!(p.percentage(), Some(100.0));
693 }
694
695 #[test]
696 fn test_blob_progress_downloading_partial() {
697 let p = BlobProgress::Downloading {
698 downloaded_bytes: 250,
699 total_bytes: 1000,
700 };
701 assert_eq!(p.percentage(), Some(25.0));
702 assert!(!p.is_complete());
703 assert!(!p.is_failed());
704 }
705
706 #[test]
707 fn test_blob_progress_completed() {
708 let p = BlobProgress::Completed {
709 local_path: PathBuf::from("/tmp/blob"),
710 };
711 assert_eq!(p.percentage(), Some(100.0));
712 assert!(p.is_complete());
713 assert!(!p.is_failed());
714 }
715
716 #[test]
717 fn test_blob_progress_failed() {
718 let p = BlobProgress::Failed {
719 error: "network error".to_string(),
720 };
721 assert_eq!(p.percentage(), None);
722 assert!(!p.is_complete());
723 assert!(p.is_failed());
724 }
725
726 #[test]
727 fn test_blob_handle_size() {
728 let token = BlobToken::new(BlobHash::from_hex("abc"), 42000, BlobMetadata::default());
729 let handle = BlobHandle::new(token, PathBuf::from("/tmp/blob"));
730 assert_eq!(handle.size(), 42000);
731 }
732
733 #[test]
734 fn test_blob_storage_summary_debug() {
735 let summary = BlobStorageSummary {
736 blob_count: 5,
737 total_bytes: 1024 * 1024,
738 largest_blob: Some(500_000),
739 };
740 let debug_str = format!("{:?}", summary);
741 assert!(debug_str.contains("blob_count"));
742 assert!(debug_str.contains("5"));
743 }
744}