1use std::path::{Path, PathBuf};
4
5use mime::Mime;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum FileConflictPolicy {
10 Error,
12 Skip,
14 Overwrite,
16 OverwriteKeepingHistory,
18}
19
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
22pub struct UploadOptions {
23 pub skip_derive: bool,
25 pub keep_old_version: bool,
27 pub interactive_priority: bool,
29 pub size_hint: Option<u64>,
31}
32
33#[derive(Clone, Debug, PartialEq, Eq, Default)]
35pub struct DeleteOptions {
36 pub cascade_delete: bool,
38 pub keep_old_version: bool,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum UploadSource {
45 Path(PathBuf),
47 Bytes(Vec<u8>),
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
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 AsRef<Path>) -> Result<Self, std::io::Error> {
69 let path = path.as_ref();
70 let filename = path_filename(path)?;
71
72 Ok(Self {
73 filename,
74 source: UploadSource::Path(path.to_path_buf()),
75 content_type: guess_content_type(path, None, None),
76 })
77 }
78
79 pub fn from_path_as(
88 path: impl AsRef<Path>,
89 filename: impl Into<String>,
90 ) -> Result<Self, std::io::Error> {
91 let path = path.as_ref();
92 let filename = validate_archive_filename(filename.into())?;
93
94 Ok(Self {
95 content_type: guess_content_type(path, Some(&filename), None),
96 filename,
97 source: UploadSource::Path(path.to_path_buf()),
98 })
99 }
100
101 pub fn from_manifest<I, F, P>(entries: I) -> Result<Vec<Self>, std::io::Error>
107 where
108 I: IntoIterator<Item = (F, P)>,
109 F: Into<String>,
110 P: AsRef<Path>,
111 {
112 entries
113 .into_iter()
114 .map(|(filename, path)| Self::from_path_as(path, filename))
115 .collect()
116 }
117
118 #[must_use]
120 pub fn from_bytes(filename: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
121 let filename = filename.into();
122 Self {
123 content_type: guess_content_type(Path::new(&filename), Some(&filename), None),
124 filename,
125 source: UploadSource::Bytes(bytes.into()),
126 }
127 }
128
129 #[must_use]
134 pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
135 let filename = filename.into();
136 self.content_type = match &self.source {
137 UploadSource::Path(path) => {
138 guess_content_type(path, Some(&filename), Some(self.content_type.clone()))
139 }
140 UploadSource::Bytes(_) => guess_content_type(
141 Path::new(&filename),
142 Some(&filename),
143 Some(self.content_type.clone()),
144 ),
145 };
146 self.filename = filename;
147 self
148 }
149
150 #[must_use]
152 pub fn with_content_type(mut self, content_type: Mime) -> Self {
153 self.content_type = content_type;
154 self
155 }
156}
157
158fn path_filename(path: &Path) -> Result<String, std::io::Error> {
159 path.file_name()
160 .and_then(|value| value.to_str())
161 .ok_or_else(|| {
162 std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no filename")
163 })
164 .map(str::to_owned)
165}
166
167fn validate_archive_filename(filename: String) -> Result<String, std::io::Error> {
168 if filename.is_empty() {
169 return Err(std::io::Error::new(
170 std::io::ErrorKind::InvalidInput,
171 "archive filename cannot be empty",
172 ));
173 }
174 Ok(filename)
175}
176
177fn guess_content_type(path: &Path, archive_filename: Option<&str>, fallback: Option<Mime>) -> Mime {
178 archive_filename
179 .and_then(|filename| mime_guess::from_path(filename).first())
180 .or_else(|| mime_guess::from_path(path).first())
181 .or(fallback)
182 .unwrap_or(mime::APPLICATION_OCTET_STREAM)
183}
184
185#[cfg(test)]
186mod tests {
187 use std::path::Path;
188
189 use super::{FileConflictPolicy, UploadOptions, UploadSource, UploadSpec};
190
191 #[test]
192 fn upload_spec_from_bytes_guesses_content_type() {
193 let spec = UploadSpec::from_bytes("demo.txt", b"hello");
194 assert_eq!(spec.filename, "demo.txt");
195 assert_eq!(spec.content_type, mime::TEXT_PLAIN);
196 }
197
198 #[test]
199 fn upload_options_default_to_safe_values() {
200 let options = UploadOptions::default();
201 assert!(!options.skip_derive);
202 assert!(!options.keep_old_version);
203 assert_eq!(
204 FileConflictPolicy::OverwriteKeepingHistory,
205 FileConflictPolicy::OverwriteKeepingHistory
206 );
207 }
208
209 #[test]
210 fn upload_spec_from_path_and_content_type_override_work() {
211 let directory = tempfile::tempdir().unwrap();
212 let path = directory.path().join("artifact.bin");
213 std::fs::write(&path, [1_u8, 2, 3]).unwrap();
214
215 let spec = UploadSpec::from_path(&path)
216 .unwrap()
217 .with_content_type(mime::APPLICATION_OCTET_STREAM);
218
219 assert_eq!(spec.filename, "artifact.bin");
220 assert_eq!(spec.content_type, mime::APPLICATION_OCTET_STREAM);
221 assert!(matches!(spec.source, UploadSource::Path(ref source) if source == &path));
222 }
223
224 #[test]
225 fn upload_spec_from_path_as_uses_archive_filename_for_name_and_content_type() {
226 let directory = tempfile::tempdir().unwrap();
227 let path = directory.path().join("artifact.bin");
228 std::fs::write(&path, [1_u8, 2, 3]).unwrap();
229
230 let spec = UploadSpec::from_path_as(&path, "artifact.txt").unwrap();
231
232 assert_eq!(spec.filename, "artifact.txt");
233 assert_eq!(spec.content_type, mime::TEXT_PLAIN);
234 assert!(matches!(spec.source, UploadSource::Path(ref source) if source == &path));
235 }
236
237 #[test]
238 fn upload_spec_with_filename_refreshes_content_type() {
239 let directory = tempfile::tempdir().unwrap();
240 let path = directory.path().join("artifact.bin");
241 std::fs::write(&path, [1_u8, 2, 3]).unwrap();
242
243 let spec = UploadSpec::from_path(&path)
244 .unwrap()
245 .with_filename("artifact.txt");
246
247 assert_eq!(spec.filename, "artifact.txt");
248 assert_eq!(spec.content_type, mime::TEXT_PLAIN);
249 assert!(matches!(spec.source, UploadSource::Path(ref source) if source == &path));
250 }
251
252 #[test]
253 fn upload_spec_from_manifest_builds_renamed_specs_in_order() {
254 let directory = tempfile::tempdir().unwrap();
255 let first = directory.path().join("first.bin");
256 let second = directory.path().join("second.bin");
257 std::fs::write(&first, [1_u8]).unwrap();
258 std::fs::write(&second, [2_u8]).unwrap();
259
260 let specs = UploadSpec::from_manifest([
261 ("release/first.txt", first.as_path()),
262 ("release/second.bin", second.as_path()),
263 ])
264 .unwrap();
265
266 assert_eq!(specs.len(), 2);
267 assert_eq!(specs[0].filename, "release/first.txt");
268 assert_eq!(specs[0].content_type, mime::TEXT_PLAIN);
269 assert_eq!(specs[1].filename, "release/second.bin");
270 assert_eq!(specs[1].content_type, mime::APPLICATION_OCTET_STREAM);
271 assert!(matches!(specs[0].source, UploadSource::Path(ref source) if source == &first));
272 assert!(matches!(specs[1].source, UploadSource::Path(ref source) if source == &second));
273 }
274
275 #[test]
276 fn upload_spec_from_path_rejects_paths_without_a_filename() {
277 let error = UploadSpec::from_path(Path::new("/")).unwrap_err();
278 assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
279 }
280
281 #[test]
282 fn upload_spec_from_path_as_rejects_empty_archive_filename() {
283 let directory = tempfile::tempdir().unwrap();
284 let path = directory.path().join("artifact.bin");
285 std::fs::write(&path, [1_u8, 2, 3]).unwrap();
286
287 let error = UploadSpec::from_path_as(&path, "").unwrap_err();
288 assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
289 }
290}