Skip to main content

marlin_binary_transfer/adapters/
common.rs

1//! Shared types used by both the [`blocking`](crate::adapters::blocking) and
2//! [`tokio`](crate::adapters::tokio) adapter modules.
3//!
4//! Living here rather than in either adapter module so that enabling one
5//! adapter doesn't require enabling the other to resolve `UploadOptions` /
6//! `UploadStats` / `UploadError` symbols.
7
8use thiserror::Error;
9
10use crate::file_transfer::{Compression, FileError};
11
12/// Conservative fallback when the device-advertised block size is zero
13/// (which shouldn't happen after a successful SYNC handshake but we
14/// handle it defensively).
15const FALLBACK_CHUNK_SIZE: usize = 256;
16
17/// Resolve the per-WRITE chunk size from caller options and the device
18/// max.
19///
20/// - `requested == 0` means "use the device-advertised max verbatim".
21/// - `requested > 0` is honored, but capped to the device max so a
22///   caller asking for 4096 against a device that says 512 doesn't
23///   blow past what the device can buffer.
24/// - `device_max == 0` falls back to [`FALLBACK_CHUNK_SIZE`].
25pub(crate) fn resolve_chunk_size(requested: usize, device_max: u16) -> usize {
26    let device_max = if device_max == 0 {
27        FALLBACK_CHUNK_SIZE
28    } else {
29        device_max as usize
30    };
31    if requested == 0 {
32        device_max
33    } else {
34        requested.min(device_max)
35    }
36}
37
38/// Per-chunk progress payload passed to a [`ProgressCallback`]. Values are
39/// cumulative across the upload.
40#[derive(Debug, Clone, Copy, Default)]
41pub struct Progress {
42    /// Bytes sent so far across all WRITE packets (post-compression).
43    pub bytes_sent: u64,
44    /// Number of WRITE packets acknowledged so far.
45    pub chunks_sent: u64,
46    /// Total bytes read from `src` (constant across calls within one upload).
47    pub source_bytes: u64,
48}
49
50/// Closure type the adapters invoke after each acknowledged WRITE packet.
51/// Boxed for object safety; `Send` so async callers can ship the callback
52/// into `spawn_blocking`.
53pub type ProgressCallback = Box<dyn FnMut(Progress) + Send>;
54
55/// Caller-supplied options controlling the upload.
56///
57/// Not `Clone` because [`progress`](Self::progress) holds a `FnMut` closure.
58/// `Debug` is implemented manually for the same reason.
59pub struct UploadOptions {
60    /// Destination filename on the device's SD card. Required.
61    pub dest_filename: String,
62    /// Compression preference. Defaults to [`Compression::None`].
63    pub compression: Compression,
64    /// Set to true to make the device pretend to receive a file without
65    /// actually writing it; useful for protocol smoke tests.
66    pub dummy: bool,
67    /// Bytes per WRITE packet. Capped to the device-advertised maximum
68    /// after the SYNC handshake completes. `0` means "use the device's
69    /// max_block_size verbatim".
70    pub chunk_size: usize,
71    /// Optional per-chunk progress callback fired once after each
72    /// acknowledged WRITE. See [`Progress`] for the payload.
73    pub progress: Option<ProgressCallback>,
74}
75
76impl std::fmt::Debug for UploadOptions {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.debug_struct("UploadOptions")
79            .field("dest_filename", &self.dest_filename)
80            .field("compression", &self.compression)
81            .field("dummy", &self.dummy)
82            .field("chunk_size", &self.chunk_size)
83            .field("progress", &self.progress.as_ref().map(|_| "<callback>"))
84            .finish()
85    }
86}
87
88impl Default for UploadOptions {
89    fn default() -> Self {
90        Self {
91            dest_filename: String::new(),
92            compression: Compression::None,
93            dummy: false,
94            chunk_size: 0,
95            progress: None,
96        }
97    }
98}
99
100/// Upload statistics returned on success.
101#[derive(Debug, Clone, Default)]
102pub struct UploadStats {
103    /// Bytes read from `src`.
104    pub source_bytes: u64,
105    /// Bytes written across all WRITE packets (post-compression).
106    pub bytes_sent: u64,
107    /// Number of WRITE packets.
108    pub chunks_sent: u64,
109    /// Compression actually used (resolved from [`Compression::Auto`]).
110    pub compression: Compression,
111}
112
113/// Errors the adapter upload helpers can produce.
114#[derive(Debug, Error)]
115pub enum UploadError {
116    /// Wrapping I/O error from the transport.
117    #[error("transport I/O error: {0}")]
118    Io(#[from] std::io::Error),
119    /// Underlying file-transfer state machine reported a failure.
120    #[error("file transfer failed: {0}")]
121    Transfer(#[from] FileError),
122    /// Reached an unrecoverable protocol state (e.g. device returned
123    /// nothing for too long with no progress).
124    #[error("upload stalled: {0}")]
125    Stalled(&'static str),
126    /// The session never completed the SYNC handshake before the helper
127    /// gave up.
128    #[error("SYNC handshake did not complete")]
129    HandshakeFailed,
130    /// Compression was requested but the `heatshrink` feature is not
131    /// enabled at compile time.
132    #[cfg(not(feature = "heatshrink"))]
133    #[error("heatshrink compression requested but the `heatshrink` feature is disabled")]
134    CompressionFeatureDisabled,
135    /// Heatshrink compression error.
136    #[cfg(feature = "heatshrink")]
137    #[error("heatshrink error: {0}")]
138    Heatshrink(#[from] crate::compression::HeatshrinkError),
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn requested_zero_uses_device_max() {
147        assert_eq!(resolve_chunk_size(0, 512), 512);
148    }
149
150    #[test]
151    fn requested_under_max_is_honored() {
152        assert_eq!(resolve_chunk_size(128, 512), 128);
153    }
154
155    #[test]
156    fn requested_over_max_is_capped() {
157        assert_eq!(resolve_chunk_size(4096, 512), 512);
158    }
159
160    #[test]
161    fn requested_zero_with_zero_device_max_falls_back() {
162        assert_eq!(resolve_chunk_size(0, 0), FALLBACK_CHUNK_SIZE);
163    }
164
165    #[test]
166    fn requested_nonzero_with_zero_device_max_capped_to_fallback() {
167        assert_eq!(resolve_chunk_size(1024, 0), FALLBACK_CHUNK_SIZE);
168    }
169
170    #[test]
171    fn requested_equal_to_max_is_honored() {
172        assert_eq!(resolve_chunk_size(512, 512), 512);
173    }
174}