Skip to main content

zenodo_rs/
upload.rs

1//! Upload input types and file replacement policies.
2
3use std::fmt;
4use std::io::Read;
5use std::path::PathBuf;
6
7use client_uploader_traits::collect_upload_filenames;
8use mime::Mime;
9
10use crate::error::ZenodoError;
11
12/// Policy for reconciling existing draft files with new uploads.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum FileReplacePolicy {
15    /// Delete all visible draft files before uploading.
16    ReplaceAll,
17    /// Replace files that share the same filename.
18    UpsertByFilename,
19    /// Keep existing files and add new uploads alongside them.
20    KeepExistingAndAdd,
21}
22
23/// Source data for a single upload.
24pub enum UploadSource {
25    /// Upload from a local file path.
26    Path(
27        /// Local source path.
28        PathBuf,
29    ),
30    /// Upload from a blocking reader with an explicit content length.
31    Reader {
32        /// Reader that produces the upload bytes.
33        reader: Box<dyn Read + Send>,
34        /// Exact number of bytes that the reader will produce.
35        content_length: u64,
36    },
37}
38
39impl fmt::Debug for UploadSource {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::Path(path) => f.debug_tuple("Path").field(path).finish(),
43            Self::Reader { content_length, .. } => f
44                .debug_struct("Reader")
45                .field("content_length", content_length)
46                .finish_non_exhaustive(),
47        }
48    }
49}
50
51/// Specification for one file upload.
52#[derive(Debug)]
53pub struct UploadSpec {
54    /// Filename to expose in Zenodo.
55    pub filename: String,
56    /// Upload source.
57    pub source: UploadSource,
58    /// MIME type to send with the upload.
59    pub content_type: Mime,
60}
61
62impl UploadSpec {
63    /// Builds an upload spec from a local path.
64    ///
65    /// Zenodo bucket uploads commonly expect `application/octet-stream`, so
66    /// that is the safe default used here. Callers can still override
67    /// [`Self::content_type`] explicitly before upload when needed.
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// use std::path::PathBuf;
73    /// use zenodo_rs::UploadSpec;
74    ///
75    /// let spec = UploadSpec::from_path(PathBuf::from("/tmp/archive.tar.gz"))?;
76    /// assert_eq!(spec.filename, "archive.tar.gz");
77    /// # Ok::<(), std::io::Error>(())
78    /// ```
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the path does not contain a final filename segment.
83    pub fn from_path(path: impl Into<PathBuf>) -> std::io::Result<Self> {
84        let path = path.into();
85        let filename = path
86            .file_name()
87            .map(|name| name.to_string_lossy().into_owned())
88            .ok_or_else(path_without_filename_error)?;
89
90        Ok(Self {
91            content_type: mime::APPLICATION_OCTET_STREAM,
92            filename,
93            source: UploadSource::Path(path),
94        })
95    }
96
97    /// Builds an upload spec from a local path and explicit uploaded filename.
98    ///
99    /// This is a shorthand for [`Self::from_path`] followed by
100    /// [`Self::with_filename`] when you want the local path and archive filename
101    /// to differ.
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use std::path::PathBuf;
107    /// use zenodo_rs::{UploadSource, UploadSpec};
108    ///
109    /// let spec = UploadSpec::from_path_as(
110    ///     PathBuf::from("/tmp/local-name.bin"),
111    ///     "archive-name.bin",
112    /// )?;
113    ///
114    /// assert_eq!(spec.filename, "archive-name.bin");
115    /// match spec.source {
116    ///     UploadSource::Path(path) => assert_eq!(path, PathBuf::from("/tmp/local-name.bin")),
117    ///     UploadSource::Reader { .. } => unreachable!("expected path source"),
118    /// }
119    /// # Ok::<(), std::io::Error>(())
120    /// ```
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if the path does not contain a final filename segment
125    /// or if the uploaded filename is empty.
126    pub fn from_path_as(
127        path: impl Into<PathBuf>,
128        filename: impl Into<String>,
129    ) -> std::io::Result<Self> {
130        let filename = filename.into();
131        if filename.is_empty() {
132            return Err(empty_upload_filename_error());
133        }
134
135        Ok(Self::from_path(path)?.with_filename(filename))
136    }
137
138    /// Returns this upload spec with a different uploaded filename.
139    ///
140    /// # Examples
141    ///
142    /// ```
143    /// use zenodo_rs::UploadSpec;
144    ///
145    /// let spec = UploadSpec::from_path("/tmp/local-name.bin")?.with_filename("archive-name.bin");
146    /// assert_eq!(spec.filename, "archive-name.bin");
147    /// # Ok::<(), std::io::Error>(())
148    /// ```
149    #[must_use]
150    pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
151        self.filename = filename.into();
152        self
153    }
154
155    /// Builds validated upload specs from `(archive_filename, local_path)` pairs.
156    ///
157    /// This is useful for manifest-driven upload code that already knows the
158    /// final archive filenames up front.
159    ///
160    /// # Examples
161    ///
162    /// ```
163    /// use std::path::PathBuf;
164    /// use zenodo_rs::{UploadSource, UploadSpec};
165    ///
166    /// let specs = UploadSpec::from_named_paths([
167    ///     ("release.tar.gz", "/tmp/build-output.bin"),
168    ///     ("manifest.json", "/tmp/manifest.local.json"),
169    /// ])?;
170    ///
171    /// assert_eq!(specs.len(), 2);
172    /// assert_eq!(specs[0].filename, "release.tar.gz");
173    /// match &specs[0].source {
174    ///     UploadSource::Path(path) => assert_eq!(path, &PathBuf::from("/tmp/build-output.bin")),
175    ///     UploadSource::Reader { .. } => unreachable!("expected path source"),
176    /// }
177    /// # Ok::<(), zenodo_rs::ZenodoError>(())
178    /// ```
179    ///
180    /// # Errors
181    ///
182    /// Returns an error if any local path lacks a final filename segment, if an
183    /// uploaded filename is empty, or if multiple entries target the same final
184    /// filename.
185    pub fn from_named_paths<I, F, P>(entries: I) -> Result<Vec<Self>, ZenodoError>
186    where
187        I: IntoIterator<Item = (F, P)>,
188        F: Into<String>,
189        P: Into<PathBuf>,
190    {
191        let mut specs = Vec::new();
192        for (filename, path) in entries {
193            specs.push(Self::from_path_as(path, filename)?);
194        }
195
196        collect_upload_filenames(specs.iter()).map_err(ZenodoError::from)?;
197        Ok(specs)
198    }
199
200    /// Returns the exact number of bytes that this upload will send.
201    ///
202    /// Path-based uploads read the current local file size. Reader-based uploads
203    /// return the explicit content length supplied at construction time.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if the source path metadata cannot be read.
208    pub fn content_length(&self) -> std::io::Result<u64> {
209        match &self.source {
210            UploadSource::Path(path) => Ok(std::fs::metadata(path)?.len()),
211            UploadSource::Reader { content_length, .. } => Ok(*content_length),
212        }
213    }
214
215    /// Builds an upload spec from a reader and explicit metadata.
216    ///
217    /// # Examples
218    ///
219    /// ```
220    /// use std::io::Cursor;
221    /// use zenodo_rs::{UploadSource, UploadSpec};
222    ///
223    /// let spec = UploadSpec::from_reader(
224    ///     "artifact.bin",
225    ///     Cursor::new(vec![1_u8, 2, 3]),
226    ///     3,
227    ///     mime::APPLICATION_OCTET_STREAM,
228    /// );
229    ///
230    /// assert_eq!(spec.filename, "artifact.bin");
231    /// match spec.source {
232    ///     UploadSource::Reader { content_length, .. } => assert_eq!(content_length, 3),
233    ///     UploadSource::Path(_) => unreachable!("expected reader source"),
234    /// }
235    /// ```
236    #[must_use]
237    pub fn from_reader(
238        filename: impl Into<String>,
239        reader: impl Read + Send + 'static,
240        content_length: u64,
241        content_type: Mime,
242    ) -> Self {
243        Self {
244            filename: filename.into(),
245            source: UploadSource::Reader {
246                reader: Box::new(reader),
247                content_length,
248            },
249            content_type,
250        }
251    }
252}
253
254fn empty_upload_filename_error() -> std::io::Error {
255    std::io::Error::new(
256        std::io::ErrorKind::InvalidInput,
257        "upload filename cannot be empty",
258    )
259}
260
261fn path_without_filename_error() -> std::io::Error {
262    std::io::Error::new(
263        std::io::ErrorKind::InvalidInput,
264        "path has no final file name segment",
265    )
266}
267
268#[cfg(test)]
269mod tests {
270    use std::path::PathBuf;
271
272    use super::{
273        empty_upload_filename_error, path_without_filename_error, UploadSource, UploadSpec,
274    };
275    use crate::error::ZenodoError;
276
277    #[test]
278    fn path_upload_defaults_to_octet_stream() {
279        let spec = UploadSpec::from_path(PathBuf::from("/tmp/archive.tar.gz")).unwrap();
280        assert_eq!(spec.filename, "archive.tar.gz");
281        assert_eq!(spec.content_type, mime::APPLICATION_OCTET_STREAM);
282    }
283
284    #[test]
285    fn path_upload_can_override_uploaded_filename() {
286        let spec =
287            UploadSpec::from_path_as(PathBuf::from("/tmp/local-name.bin"), "archive-name.bin")
288                .unwrap();
289        assert_eq!(spec.filename, "archive-name.bin");
290        match spec.source {
291            UploadSource::Path(path) => assert_eq!(path, PathBuf::from("/tmp/local-name.bin")),
292            UploadSource::Reader { .. } => panic!("expected path source"),
293        }
294    }
295
296    #[test]
297    fn with_filename_renames_existing_upload_spec() {
298        let spec = UploadSpec::from_path(PathBuf::from("/tmp/local-name.bin"))
299            .unwrap()
300            .with_filename("archive-name.bin");
301        assert_eq!(spec.filename, "archive-name.bin");
302        match spec.source {
303            UploadSource::Path(path) => assert_eq!(path, PathBuf::from("/tmp/local-name.bin")),
304            UploadSource::Reader { .. } => panic!("expected path source"),
305        }
306    }
307
308    #[test]
309    fn path_upload_rejects_missing_filename() {
310        let error = UploadSpec::from_path(PathBuf::from("/")).unwrap_err();
311        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
312    }
313
314    #[test]
315    fn missing_filename_error_has_stable_message() {
316        let error = path_without_filename_error();
317        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
318        assert_eq!(error.to_string(), "path has no final file name segment");
319    }
320
321    #[test]
322    fn empty_uploaded_filename_error_has_stable_message() {
323        let error = empty_upload_filename_error();
324        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
325        assert_eq!(error.to_string(), "upload filename cannot be empty");
326    }
327
328    #[test]
329    fn from_named_paths_rejects_duplicate_archive_names() {
330        let error = UploadSpec::from_named_paths([
331            ("artifact.bin", "/tmp/one.bin"),
332            ("artifact.bin", "/tmp/two.bin"),
333        ])
334        .unwrap_err();
335
336        assert!(matches!(
337            error,
338            ZenodoError::DuplicateUploadFilename { filename } if filename == "artifact.bin"
339        ));
340    }
341
342    #[test]
343    fn from_named_paths_preserves_manifest_names_and_paths() {
344        let specs = UploadSpec::from_named_paths([
345            ("first.bin", "/tmp/a.bin"),
346            ("second.bin", "/tmp/b.bin"),
347        ])
348        .unwrap();
349
350        assert_eq!(specs.len(), 2);
351        assert_eq!(specs[0].filename, "first.bin");
352        assert_eq!(specs[1].filename, "second.bin");
353        match &specs[0].source {
354            UploadSource::Path(path) => assert_eq!(path, &PathBuf::from("/tmp/a.bin")),
355            UploadSource::Reader { .. } => panic!("expected path source"),
356        }
357    }
358
359    #[test]
360    fn reader_upload_debug_hides_reader() {
361        let spec = UploadSpec::from_reader(
362            "artifact.bin",
363            std::io::Cursor::new(vec![1, 2, 3]),
364            3,
365            mime::APPLICATION_OCTET_STREAM,
366        );
367
368        match spec.source {
369            UploadSource::Reader { content_length, .. } => assert_eq!(content_length, 3),
370            UploadSource::Path(_) => panic!("expected reader source"),
371        }
372        assert!(format!("{spec:?}").contains("artifact.bin"));
373    }
374
375    #[test]
376    fn path_source_debug_shows_path_variant() {
377        let spec = UploadSpec::from_path(PathBuf::from("/tmp/report.txt")).unwrap();
378        assert!(format!("{:?}", spec.source).contains("Path"));
379        match spec.source {
380            UploadSource::Path(path) => assert_eq!(path, PathBuf::from("/tmp/report.txt")),
381            UploadSource::Reader { .. } => panic!("expected path source"),
382        }
383    }
384
385    #[test]
386    fn content_length_uses_reader_length() {
387        let spec = UploadSpec::from_reader(
388            "artifact.bin",
389            std::io::Cursor::new(vec![1, 2, 3]),
390            3,
391            mime::APPLICATION_OCTET_STREAM,
392        );
393
394        assert_eq!(spec.content_length().unwrap(), 3);
395    }
396
397    #[test]
398    fn content_length_reads_path_metadata() {
399        let dir = tempfile::tempdir().unwrap();
400        let path = dir.path().join("report.txt");
401        std::fs::write(&path, b"hello").unwrap();
402
403        let spec = UploadSpec::from_path(&path).unwrap();
404        assert_eq!(spec.content_length().unwrap(), 5);
405    }
406}