1use std::path::{Path, PathBuf};
4
5use bytes::Bytes;
6use futures::Stream;
7use rskit_errors::{AppError, AppResult, ErrorCode};
8use rskit_fs::async_io::file;
9use tokio::io::AsyncRead;
10
11use crate::TempFile;
12
13#[derive(Debug)]
21pub enum FileSource {
22 Path(PathBuf),
24 Url(String),
26 Bytes(Bytes),
28 Temp(TempFile),
30}
31
32impl Clone for FileSource {
33 fn clone(&self) -> Self {
34 match self {
35 Self::Path(path) => Self::Path(path.clone()),
36 Self::Url(url) => Self::Url(url.clone()),
37 Self::Bytes(bytes) => Self::Bytes(bytes.clone()),
38 Self::Temp(temp) => match temp.try_clone() {
39 Ok(cloned) => Self::Temp(cloned),
40 Err(error) => {
41 tracing::warn!(
42 error = ?error,
43 path = %temp.path().display(),
44 "FileSource::Temp clone failed, falling back to Path"
45 );
46 Self::Path(temp.path().to_path_buf())
47 }
48 },
49 }
50 }
51}
52
53mod serde_impl {
56 use super::*;
57 use serde::{Deserialize, Deserializer, Serialize, Serializer};
58
59 #[derive(Serialize, Deserialize)]
60 #[serde(tag = "type", content = "value")]
61 enum FileSourceRepr {
62 Path(PathBuf),
63 Url(String),
64 Bytes(Vec<u8>),
65 }
66
67 impl Serialize for FileSource {
68 fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
69 match self {
70 FileSource::Path(p) => FileSourceRepr::Path(p.clone()).serialize(ser),
71 FileSource::Url(u) => FileSourceRepr::Url(u.clone()).serialize(ser),
72 FileSource::Bytes(b) => FileSourceRepr::Bytes(b.to_vec()).serialize(ser),
73 FileSource::Temp(t) => FileSourceRepr::Path(t.path().to_path_buf()).serialize(ser),
74 }
75 }
76 }
77
78 impl<'de> Deserialize<'de> for FileSource {
79 fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
80 let repr = FileSourceRepr::deserialize(de)?;
81 Ok(match repr {
82 FileSourceRepr::Path(p) => FileSource::Path(p),
83 FileSourceRepr::Url(u) => FileSource::Url(u),
84 FileSourceRepr::Bytes(b) => FileSource::Bytes(Bytes::from(b)),
85 })
86 }
87 }
88}
89
90impl FileSource {
91 pub fn from_path(p: impl Into<PathBuf>) -> Self {
93 Self::Path(p.into())
94 }
95
96 pub fn from_url(url: impl Into<String>) -> Self {
98 Self::Url(url.into())
99 }
100
101 pub fn from_bytes(b: impl Into<Bytes>) -> Self {
103 Self::Bytes(b.into())
104 }
105
106 pub async fn reader(&self) -> AppResult<Box<dyn AsyncRead + Send + Unpin>> {
108 match self {
109 Self::Path(p) => {
110 let file = file::open(p).await.map_err(|e| {
111 AppError::new(
112 ErrorCode::NotFound,
113 format!("failed to open {}: {e}", p.display()),
114 )
115 })?;
116 Ok(Box::new(file))
117 }
118 Self::Bytes(b) => Ok(Box::new(std::io::Cursor::new(b.clone()))),
119 Self::Temp(t) => {
120 let file = file::open(t.path()).await.map_err(|e| {
121 AppError::new(
122 ErrorCode::Internal,
123 format!("failed to open temp file {}: {e}", t.path().display()),
124 )
125 })?;
126 Ok(Box::new(file))
127 }
128 Self::Url(_url) => Err(AppError::new(
129 ErrorCode::InvalidInput,
130 "URL sources require an HTTP client; use to_local_path() first",
131 )),
132 }
133 }
134
135 pub async fn stream(&self) -> AppResult<impl Stream<Item = AppResult<Bytes>> + '_> {
137 use futures::stream;
138 use tokio::io::AsyncReadExt;
139
140 let mut reader = self.reader().await?;
141 let (tx, rx) = tokio::sync::mpsc::channel::<AppResult<Bytes>>(8);
142
143 let mut buf = vec![0u8; 64 * 1024];
144 tokio::spawn(async move {
145 loop {
146 match reader.read(&mut buf).await {
147 Ok(0) => break,
148 Ok(n) => {
149 if tx
150 .send(Ok(Bytes::copy_from_slice(&buf[..n])))
151 .await
152 .is_err()
153 {
154 break;
155 }
156 }
157 Err(e) => {
158 let _ = tx
159 .send(Err(AppError::new(
160 ErrorCode::Internal,
161 format!("stream read error: {e}"),
162 )))
163 .await;
164 break;
165 }
166 }
167 }
168 });
169
170 Ok(stream::unfold(rx, |mut rx| async move {
171 rx.recv().await.map(|item| (item, rx))
172 }))
173 }
174
175 pub async fn read_all(&self) -> AppResult<Bytes> {
177 match self {
178 Self::Bytes(b) => Ok(b.clone()),
179 _ => {
180 use tokio::io::AsyncReadExt;
181 let mut reader = self.reader().await?;
182 let mut buf = Vec::new();
183 reader.read_to_end(&mut buf).await.map_err(|e| {
184 AppError::new(ErrorCode::Internal, format!("failed to read file: {e}"))
185 })?;
186 Ok(Bytes::from(buf))
187 }
188 }
189 }
190
191 pub async fn size(&self) -> AppResult<Option<u64>> {
193 match self {
194 Self::Path(p) => {
195 let meta = file::metadata(p).await.map_err(|e| {
196 AppError::new(
197 ErrorCode::NotFound,
198 format!("failed to stat {}: {e}", p.display()),
199 )
200 })?;
201 Ok(Some(meta.len))
202 }
203 Self::Bytes(b) => Ok(Some(b.len() as u64)),
204 Self::Temp(t) => {
205 let meta = file::metadata(t.path()).await.map_err(|e| {
206 AppError::new(
207 ErrorCode::Internal,
208 format!("failed to stat temp file: {e}"),
209 )
210 })?;
211 Ok(Some(meta.len))
212 }
213 Self::Url(_) => Ok(None),
214 }
215 }
216
217 pub async fn to_local_path(&self) -> AppResult<ResolvedPath> {
219 match self {
220 Self::Path(p) => Ok(ResolvedPath {
221 path: p.clone(),
222 _temp: None,
223 }),
224 Self::Temp(t) => Ok(ResolvedPath {
225 path: t.path().to_path_buf(),
226 _temp: None,
227 }),
228 Self::Bytes(b) => {
229 let tmp = TempFile::new()?;
230 file::write(tmp.path(), b).await.map_err(|e| {
231 AppError::new(
232 ErrorCode::Internal,
233 format!("failed to write bytes to temp file: {e}"),
234 )
235 })?;
236 let path = tmp.path().to_path_buf();
237 Ok(ResolvedPath {
238 path,
239 _temp: Some(tmp),
240 })
241 }
242 Self::Url(_url) => Err(AppError::new(
243 ErrorCode::InvalidInput,
244 "URL download not yet implemented; use an HTTP client externally",
245 )),
246 }
247 }
248
249 pub fn extension(&self) -> Option<&str> {
251 match self {
252 Self::Path(p) => p.extension().and_then(|e| e.to_str()),
253 Self::Url(url) => {
254 let path = url.split('?').next().unwrap_or(url);
255 path.rsplit('.').next().filter(|ext| ext.len() <= 10)
256 }
257 Self::Temp(t) => t.path().extension().and_then(|e| e.to_str()),
258 Self::Bytes(_) => None,
259 }
260 }
261}
262
263pub struct ResolvedPath {
266 path: PathBuf,
267 _temp: Option<TempFile>,
268}
269
270impl ResolvedPath {
271 pub fn path(&self) -> &Path {
273 &self.path
274 }
275}
276
277impl AsRef<Path> for ResolvedPath {
278 fn as_ref(&self) -> &Path {
279 &self.path
280 }
281}
282
283impl std::fmt::Debug for ResolvedPath {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 f.debug_struct("ResolvedPath")
286 .field("path", &self.path)
287 .finish()
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use futures::StreamExt;
294 use serde_json::json;
295 use tokio::io::AsyncReadExt;
296
297 use super::*;
298
299 #[tokio::test]
300 async fn file_source_serializes_paths_urls_bytes_and_temp_as_stable_representations() {
301 let path = FileSource::from_path("data/file.txt");
302 assert_eq!(
303 serde_json::to_value(&path).unwrap(),
304 json!({"type":"Path","value":"data/file.txt"})
305 );
306 assert!(matches!(path.clone(), FileSource::Path(_)));
307
308 let url = FileSource::from_url("https://example.invalid/file.bin");
309 assert_eq!(
310 serde_json::to_value(&url).unwrap(),
311 json!({"type":"Url","value":"https://example.invalid/file.bin"})
312 );
313 assert!(matches!(url.clone(), FileSource::Url(_)));
314
315 let bytes = FileSource::from_bytes(Bytes::from_static(b"abc"));
316 assert_eq!(
317 serde_json::to_value(&bytes).unwrap(),
318 json!({"type":"Bytes","value":[97,98,99]})
319 );
320 assert!(matches!(bytes.clone(), FileSource::Bytes(_)));
321
322 let temp = TempFile::new().unwrap();
323 let value = serde_json::to_value(FileSource::Temp(temp)).unwrap();
324 assert_eq!(value["type"], "Path");
325
326 let decoded: FileSource =
327 serde_json::from_value(json!({"type":"Bytes","value":[1,2,3]})).unwrap();
328 assert_eq!(
329 decoded.read_all().await.unwrap(),
330 Bytes::from_static(&[1, 2, 3])
331 );
332 }
333
334 #[tokio::test]
335 async fn file_source_reads_sizes_streams_and_resolves_local_paths() {
336 let dir = tempfile::tempdir().unwrap();
337 let path = dir.path().join("input.txt");
338 tokio::fs::write(&path, b"path-data").await.unwrap();
339
340 let path_source = FileSource::from_path(&path);
341 assert_eq!(path_source.extension(), Some("txt"));
342 assert_eq!(path_source.size().await.unwrap(), Some(9));
343 assert_eq!(
344 path_source.read_all().await.unwrap(),
345 Bytes::from_static(b"path-data")
346 );
347 assert_eq!(
348 path_source.to_local_path().await.unwrap().path(),
349 path.as_path()
350 );
351
352 let mut reader = FileSource::from_bytes(Bytes::from_static(b"reader"))
353 .reader()
354 .await
355 .unwrap();
356 let mut data = Vec::new();
357 reader.read_to_end(&mut data).await.unwrap();
358 assert_eq!(data, b"reader");
359
360 let bytes_source = FileSource::from_bytes(Bytes::from_static(b"stream-data"));
361 let streamed = {
362 let stream = bytes_source.stream().await.unwrap();
363 futures::pin_mut!(stream);
364 let mut streamed = Vec::new();
365 while let Some(chunk) = stream.next().await {
366 streamed.extend_from_slice(&chunk.unwrap());
367 }
368 streamed
369 };
370 assert_eq!(streamed, b"stream-data");
371
372 let resolved = bytes_source.to_local_path().await.unwrap();
373 assert_eq!(
374 tokio::fs::read(resolved.path()).await.unwrap(),
375 b"stream-data"
376 );
377 assert!(format!("{resolved:?}").contains("ResolvedPath"));
378 assert_eq!(resolved.as_ref(), resolved.path());
379 }
380
381 #[tokio::test]
382 async fn file_source_handles_temp_url_and_missing_file_branches() {
383 let temp = TempFile::new().unwrap();
384 tokio::fs::write(temp.path(), b"temporary").await.unwrap();
385 let source = FileSource::Temp(temp);
386 assert_eq!(source.size().await.unwrap(), Some(9));
387 assert_eq!(
388 source.read_all().await.unwrap(),
389 Bytes::from_static(b"temporary")
390 );
391 assert_eq!(
392 source.to_local_path().await.unwrap().path().extension(),
393 None
394 );
395 assert!(matches!(source.clone(), FileSource::Temp(_)));
396
397 let url = FileSource::from_url("https://example.invalid/file.tar.gz?token=redacted");
398 assert_eq!(url.extension(), Some("gz"));
399 assert_eq!(url.size().await.unwrap(), None);
400 assert_eq!(
401 url.reader().await.err().unwrap().code(),
402 ErrorCode::InvalidInput
403 );
404 assert_eq!(
405 url.to_local_path().await.unwrap_err().code(),
406 ErrorCode::InvalidInput
407 );
408
409 let missing = FileSource::from_path("missing-file.bin");
410 assert_eq!(
411 missing.reader().await.err().unwrap().code(),
412 ErrorCode::NotFound
413 );
414 assert_eq!(
415 missing.size().await.unwrap_err().code(),
416 ErrorCode::NotFound
417 );
418 }
419
420 #[tokio::test]
421 async fn url_and_bytes_metadata_cover_fallback_mime_paths() {
422 let url = FileSource::from_url("https://example.invalid/archive.tar.gz?token=redacted");
423 let meta = crate::file_meta(&url).await.unwrap();
424 assert_eq!(meta.name.as_deref(), Some("archive.tar.gz"));
425 assert_eq!(meta.extension.as_deref(), Some("gz"));
426 assert_eq!(meta.size, None);
427 assert_eq!(
428 crate::detect_kind(&url).await.unwrap(),
429 crate::FileKind::Archive
430 );
431
432 let bytes = FileSource::from_bytes(Bytes::from_static(b"plain text"));
433 let meta = crate::file_meta(&bytes).await.unwrap();
434 assert_eq!(meta.name, None);
435 assert_eq!(meta.size, Some(10));
436 assert_eq!(meta.mime_type, "application/octet-stream");
437 assert_eq!(bytes.extension(), None);
438 }
439
440 #[tokio::test]
441 async fn path_metadata_uses_filesystem_timestamps_and_extension_mime() {
442 let dir = tempfile::tempdir().unwrap();
443 let path = dir.path().join("image.png");
444 tokio::fs::write(&path, b"not a real png").await.unwrap();
445 let source = FileSource::from_path(&path);
446
447 let meta = crate::file_meta(&source).await.unwrap();
448
449 assert_eq!(meta.name.as_deref(), Some("image.png"));
450 assert_eq!(meta.extension.as_deref(), Some("png"));
451 assert_eq!(meta.mime_type, "image/png");
452 assert_eq!(meta.size, Some(14));
453 assert!(meta.modified_at.is_some());
454 }
455
456 #[test]
457 fn extension_filters_overlong_url_suffixes() {
458 let url = FileSource::from_url("https://example.invalid/file.thisextensionistoolong");
459
460 assert_eq!(url.extension(), None);
461 }
462}