Skip to main content

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}