1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum FileReplacePolicy {
15 ReplaceAll,
17 UpsertByFilename,
19 KeepExistingAndAdd,
21}
22
23pub enum UploadSource {
25 Path(
27 PathBuf,
29 ),
30 Reader {
32 reader: Box<dyn Read + Send>,
34 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#[derive(Debug)]
53pub struct UploadSpec {
54 pub filename: String,
56 pub source: UploadSource,
58 pub content_type: Mime,
60}
61
62impl UploadSpec {
63 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 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 #[must_use]
150 pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
151 self.filename = filename.into();
152 self
153 }
154
155 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 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 #[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}