spring_batch_rs/tasklet/s3/get.rs
1//! S3 GET tasklets for downloading files and folders from Amazon S3.
2
3use crate::{
4 BatchError,
5 core::step::{RepeatStatus, StepExecution, Tasklet},
6 tasklet::s3::{S3ClientConfig, build_s3_client},
7};
8use log::{debug, info};
9use std::path::{Path, PathBuf};
10use tokio::runtime::Handle;
11
12/// A tasklet that downloads a single S3 object to a local file.
13///
14/// The object body is streamed directly to the local file without loading it into memory,
15/// making it safe for large files common in batch processing.
16///
17/// # Examples
18///
19/// ```rust,no_run
20/// use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
21///
22/// # fn example() -> Result<(), spring_batch_rs::BatchError> {
23/// let tasklet = S3GetTaskletBuilder::new()
24/// .bucket("my-bucket")
25/// .key("imports/file.csv")
26/// .local_file("./input/file.csv")
27/// .region("eu-west-1")
28/// .build()?;
29/// # Ok(())
30/// # }
31/// ```
32///
33/// # Errors
34///
35/// Returns [`BatchError::ItemReader`] if the S3 download fails.
36/// Returns [`BatchError::Io`] if the local file cannot be written.
37#[derive(Debug)]
38pub struct S3GetTasklet {
39 bucket: String,
40 key: String,
41 local_file: PathBuf,
42 config: S3ClientConfig,
43}
44
45impl S3GetTasklet {
46 async fn execute_async(&self) -> Result<RepeatStatus, BatchError> {
47 info!(
48 "Downloading s3://{}/{} -> {}",
49 self.bucket,
50 self.key,
51 self.local_file.display()
52 );
53
54 let client = build_s3_client(&self.config).await?;
55
56 if let Some(parent) = self.local_file.parent() {
57 std::fs::create_dir_all(parent).map_err(BatchError::Io)?;
58 }
59
60 let resp = client
61 .get_object()
62 .bucket(&self.bucket)
63 .key(&self.key)
64 .send()
65 .await
66 .map_err(|e| {
67 BatchError::ItemReader(format!("S3 get_object failed for {}: {}", self.key, e))
68 })?;
69
70 let mut body = resp.body.into_async_read();
71 let mut file = tokio::fs::File::create(&self.local_file)
72 .await
73 .map_err(BatchError::Io)?;
74 let bytes_written = tokio::io::copy(&mut body, &mut file)
75 .await
76 .map_err(BatchError::Io)?;
77
78 info!(
79 "Download complete: {} bytes written to {}",
80 bytes_written,
81 self.local_file.display()
82 );
83 Ok(RepeatStatus::Finished)
84 }
85}
86
87impl Tasklet for S3GetTasklet {
88 fn execute(&self, _step_execution: &StepExecution) -> Result<RepeatStatus, BatchError> {
89 tokio::task::block_in_place(|| Handle::current().block_on(self.execute_async()))
90 }
91}
92
93/// Builder for [`S3GetTasklet`].
94///
95/// # Examples
96///
97/// ```rust,no_run
98/// use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
99///
100/// # fn example() -> Result<(), spring_batch_rs::BatchError> {
101/// let tasklet = S3GetTaskletBuilder::new()
102/// .bucket("my-bucket")
103/// .key("imports/file.csv")
104/// .local_file("./input/file.csv")
105/// .region("eu-west-1")
106/// .build()?;
107/// # Ok(())
108/// # }
109/// ```
110///
111/// # Errors
112///
113/// Returns [`BatchError::Configuration`] if `bucket`, `key`, or `local_file` are not set.
114#[derive(Debug, Default)]
115pub struct S3GetTaskletBuilder {
116 bucket: Option<String>,
117 key: Option<String>,
118 local_file: Option<PathBuf>,
119 config: S3ClientConfig,
120}
121
122impl S3GetTaskletBuilder {
123 /// Creates a new builder with default settings.
124 ///
125 /// # Examples
126 ///
127 /// ```
128 /// use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
129 ///
130 /// let builder = S3GetTaskletBuilder::new();
131 /// ```
132 pub fn new() -> Self {
133 Self::default()
134 }
135
136 /// Sets the S3 bucket name.
137 ///
138 /// # Examples
139 ///
140 /// ```
141 /// use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
142 ///
143 /// let builder = S3GetTaskletBuilder::new().bucket("my-bucket");
144 /// ```
145 pub fn bucket<S: Into<String>>(mut self, bucket: S) -> Self {
146 self.bucket = Some(bucket.into());
147 self
148 }
149
150 /// Sets the S3 object key (path within the bucket).
151 ///
152 /// # Examples
153 ///
154 /// ```
155 /// use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
156 ///
157 /// let builder = S3GetTaskletBuilder::new().key("imports/file.csv");
158 /// ```
159 pub fn key<S: Into<String>>(mut self, key: S) -> Self {
160 self.key = Some(key.into());
161 self
162 }
163
164 /// Sets the local file path to write the downloaded object to.
165 ///
166 /// Parent directories are created automatically during execution.
167 ///
168 /// # Examples
169 ///
170 /// ```
171 /// use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
172 ///
173 /// let builder = S3GetTaskletBuilder::new().local_file("./input/file.csv");
174 /// ```
175 pub fn local_file<P: AsRef<Path>>(mut self, path: P) -> Self {
176 self.local_file = Some(path.as_ref().to_path_buf());
177 self
178 }
179
180 /// Sets the AWS region.
181 ///
182 /// Falls back to the `AWS_REGION` environment variable (or `AWS_DEFAULT_REGION`)
183 /// when not set.
184 ///
185 /// # Examples
186 ///
187 /// ```
188 /// use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
189 ///
190 /// let builder = S3GetTaskletBuilder::new().region("eu-west-1");
191 /// ```
192 pub fn region<S: Into<String>>(mut self, region: S) -> Self {
193 self.config.region = Some(region.into());
194 self
195 }
196
197 /// Sets a custom endpoint URL for S3-compatible services (MinIO, LocalStack).
198 ///
199 /// When set, path-style addressing is enabled automatically.
200 ///
201 /// # Examples
202 ///
203 /// ```
204 /// use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
205 ///
206 /// let builder = S3GetTaskletBuilder::new().endpoint_url("http://localhost:9000");
207 /// ```
208 pub fn endpoint_url<S: Into<String>>(mut self, url: S) -> Self {
209 self.config.endpoint_url = Some(url.into());
210 self
211 }
212
213 /// Sets the AWS access key ID for explicit credential configuration.
214 ///
215 /// Must be combined with [`secret_access_key`](Self::secret_access_key).
216 /// Falls back to the AWS default credential chain when not set.
217 ///
218 /// # Examples
219 ///
220 /// ```
221 /// use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
222 ///
223 /// let builder = S3GetTaskletBuilder::new().access_key_id("AKIAIOSFODNN7EXAMPLE");
224 /// ```
225 pub fn access_key_id<S: Into<String>>(mut self, key_id: S) -> Self {
226 self.config.access_key_id = Some(key_id.into());
227 self
228 }
229
230 /// Sets the AWS secret access key for explicit credential configuration.
231 ///
232 /// Must be combined with [`access_key_id`](Self::access_key_id).
233 ///
234 /// # Examples
235 ///
236 /// ```
237 /// use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
238 ///
239 /// let builder = S3GetTaskletBuilder::new().secret_access_key("wJalrXUtnFEMI/K7MDENG");
240 /// ```
241 pub fn secret_access_key<S: Into<String>>(mut self, secret: S) -> Self {
242 self.config.secret_access_key = Some(secret.into());
243 self
244 }
245
246 /// Builds the [`S3GetTasklet`].
247 ///
248 /// # Errors
249 ///
250 /// Returns [`BatchError::Configuration`] if `bucket`, `key`, or `local_file` are not set.
251 ///
252 /// # Examples
253 ///
254 /// ```rust,no_run
255 /// use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
256 ///
257 /// # fn example() -> Result<(), spring_batch_rs::BatchError> {
258 /// let tasklet = S3GetTaskletBuilder::new()
259 /// .bucket("my-bucket")
260 /// .key("file.csv")
261 /// .local_file("./input/file.csv")
262 /// .build()?;
263 /// # Ok(())
264 /// # }
265 /// ```
266 pub fn build(self) -> Result<S3GetTasklet, BatchError> {
267 let bucket = self.bucket.ok_or_else(|| {
268 BatchError::Configuration("S3GetTasklet: 'bucket' is required".to_string())
269 })?;
270 let key = self.key.ok_or_else(|| {
271 BatchError::Configuration("S3GetTasklet: 'key' is required".to_string())
272 })?;
273 let local_file = self.local_file.ok_or_else(|| {
274 BatchError::Configuration("S3GetTasklet: 'local_file' is required".to_string())
275 })?;
276
277 Ok(S3GetTasklet {
278 bucket,
279 key,
280 local_file,
281 config: self.config,
282 })
283 }
284}
285
286// ---------------------------------------------------------------------------
287// S3GetFolderTasklet
288// ---------------------------------------------------------------------------
289
290/// A tasklet that downloads all S3 objects under a given prefix to a local folder.
291///
292/// Objects are listed with `list_objects_v2` (with pagination support) and downloaded
293/// sequentially. Parent directories are created automatically. If the prefix matches
294/// no objects, the tasklet completes successfully with 0 files downloaded.
295///
296/// # Examples
297///
298/// ```rust,no_run
299/// use spring_batch_rs::tasklet::s3::get::S3GetFolderTaskletBuilder;
300///
301/// # fn example() -> Result<(), spring_batch_rs::BatchError> {
302/// let tasklet = S3GetFolderTaskletBuilder::new()
303/// .bucket("my-bucket")
304/// .prefix("backups/2026-04-10/")
305/// .local_folder("./imports/")
306/// .region("eu-west-1")
307/// .build()?;
308/// # Ok(())
309/// # }
310/// ```
311///
312/// # Errors
313///
314/// Returns [`BatchError::ItemReader`] if listing or downloading any object fails.
315/// Returns [`BatchError::Io`] if writing any local file fails.
316#[derive(Debug)]
317pub struct S3GetFolderTasklet {
318 bucket: String,
319 prefix: String,
320 local_folder: PathBuf,
321 config: S3ClientConfig,
322}
323
324impl S3GetFolderTasklet {
325 async fn execute_async(&self) -> Result<RepeatStatus, BatchError> {
326 info!(
327 "Downloading s3://{}/{} -> {}",
328 self.bucket,
329 self.prefix,
330 self.local_folder.display()
331 );
332
333 let client = build_s3_client(&self.config).await?;
334 std::fs::create_dir_all(&self.local_folder).map_err(BatchError::Io)?;
335
336 let mut continuation_token: Option<String> = None;
337 let mut total_files = 0usize;
338
339 loop {
340 let mut req = client
341 .list_objects_v2()
342 .bucket(&self.bucket)
343 .prefix(&self.prefix);
344
345 if let Some(token) = continuation_token {
346 req = req.continuation_token(token);
347 }
348
349 let list_resp = req
350 .send()
351 .await
352 .map_err(|e| BatchError::ItemReader(format!("list_objects_v2 failed: {}", e)))?;
353
354 for object in list_resp.contents() {
355 let key = object.key().unwrap_or_default();
356 // Strip prefix to get relative path within the local folder
357 let relative = key.strip_prefix(self.prefix.as_str()).unwrap_or(key);
358 let relative = relative.strip_prefix('/').unwrap_or(relative);
359 if relative.is_empty() {
360 continue; // skip the prefix "directory" placeholder object
361 }
362 let local_path = self.local_folder.join(relative);
363
364 if let Some(parent) = local_path.parent() {
365 std::fs::create_dir_all(parent).map_err(BatchError::Io)?;
366 }
367
368 debug!(
369 "Downloading s3://{}/{} -> {}",
370 self.bucket,
371 key,
372 local_path.display()
373 );
374
375 let resp = client
376 .get_object()
377 .bucket(&self.bucket)
378 .key(key)
379 .send()
380 .await
381 .map_err(|e| {
382 BatchError::ItemReader(format!("get_object failed for {}: {}", key, e))
383 })?;
384
385 let mut body = resp.body.into_async_read();
386 let mut file = tokio::fs::File::create(&local_path)
387 .await
388 .map_err(BatchError::Io)?;
389 tokio::io::copy(&mut body, &mut file)
390 .await
391 .map_err(BatchError::Io)?;
392 total_files += 1;
393 }
394
395 if list_resp.is_truncated().unwrap_or(false) {
396 continuation_token = list_resp.next_continuation_token().map(str::to_string);
397 } else {
398 break;
399 }
400 }
401
402 info!(
403 "Folder download complete: {} files downloaded to {}",
404 total_files,
405 self.local_folder.display()
406 );
407 Ok(RepeatStatus::Finished)
408 }
409}
410
411impl Tasklet for S3GetFolderTasklet {
412 fn execute(&self, _step_execution: &StepExecution) -> Result<RepeatStatus, BatchError> {
413 tokio::task::block_in_place(|| Handle::current().block_on(self.execute_async()))
414 }
415}
416
417/// Builder for [`S3GetFolderTasklet`].
418///
419/// # Examples
420///
421/// ```rust,no_run
422/// use spring_batch_rs::tasklet::s3::get::S3GetFolderTaskletBuilder;
423///
424/// # fn example() -> Result<(), spring_batch_rs::BatchError> {
425/// let tasklet = S3GetFolderTaskletBuilder::new()
426/// .bucket("my-bucket")
427/// .prefix("backups/2026-04-10/")
428/// .local_folder("./imports/")
429/// .build()?;
430/// # Ok(())
431/// # }
432/// ```
433///
434/// # Errors
435///
436/// Returns [`BatchError::Configuration`] if `bucket`, `prefix`, or `local_folder` are not set.
437#[derive(Debug, Default)]
438pub struct S3GetFolderTaskletBuilder {
439 bucket: Option<String>,
440 prefix: Option<String>,
441 local_folder: Option<PathBuf>,
442 config: S3ClientConfig,
443}
444
445impl S3GetFolderTaskletBuilder {
446 /// Creates a new builder with default settings.
447 ///
448 /// # Examples
449 ///
450 /// ```
451 /// use spring_batch_rs::tasklet::s3::get::S3GetFolderTaskletBuilder;
452 ///
453 /// let builder = S3GetFolderTaskletBuilder::new();
454 /// ```
455 pub fn new() -> Self {
456 Self::default()
457 }
458
459 /// Sets the S3 bucket name.
460 ///
461 /// # Examples
462 ///
463 /// ```
464 /// use spring_batch_rs::tasklet::s3::get::S3GetFolderTaskletBuilder;
465 ///
466 /// let builder = S3GetFolderTaskletBuilder::new().bucket("my-bucket");
467 /// ```
468 pub fn bucket<S: Into<String>>(mut self, bucket: S) -> Self {
469 self.bucket = Some(bucket.into());
470 self
471 }
472
473 /// Sets the S3 key prefix to list and download.
474 ///
475 /// All objects whose key starts with this prefix will be downloaded.
476 ///
477 /// # Examples
478 ///
479 /// ```
480 /// use spring_batch_rs::tasklet::s3::get::S3GetFolderTaskletBuilder;
481 ///
482 /// let builder = S3GetFolderTaskletBuilder::new().prefix("backups/2026-04-10/");
483 /// ```
484 pub fn prefix<S: Into<String>>(mut self, prefix: S) -> Self {
485 self.prefix = Some(prefix.into());
486 self
487 }
488
489 /// Sets the local folder path to write downloaded objects to.
490 ///
491 /// Created automatically if it does not exist.
492 ///
493 /// # Examples
494 ///
495 /// ```
496 /// use spring_batch_rs::tasklet::s3::get::S3GetFolderTaskletBuilder;
497 ///
498 /// let builder = S3GetFolderTaskletBuilder::new().local_folder("./imports/");
499 /// ```
500 pub fn local_folder<P: AsRef<Path>>(mut self, path: P) -> Self {
501 self.local_folder = Some(path.as_ref().to_path_buf());
502 self
503 }
504
505 /// Sets the AWS region.
506 ///
507 /// Falls back to the `AWS_REGION` environment variable (or `AWS_DEFAULT_REGION`)
508 /// when not set.
509 ///
510 /// # Examples
511 ///
512 /// ```
513 /// use spring_batch_rs::tasklet::s3::get::S3GetFolderTaskletBuilder;
514 ///
515 /// let builder = S3GetFolderTaskletBuilder::new().region("eu-west-1");
516 /// ```
517 pub fn region<S: Into<String>>(mut self, region: S) -> Self {
518 self.config.region = Some(region.into());
519 self
520 }
521
522 /// Sets a custom endpoint URL for S3-compatible services (MinIO, LocalStack).
523 ///
524 /// # Examples
525 ///
526 /// ```
527 /// use spring_batch_rs::tasklet::s3::get::S3GetFolderTaskletBuilder;
528 ///
529 /// let builder = S3GetFolderTaskletBuilder::new().endpoint_url("http://localhost:9000");
530 /// ```
531 pub fn endpoint_url<S: Into<String>>(mut self, url: S) -> Self {
532 self.config.endpoint_url = Some(url.into());
533 self
534 }
535
536 /// Sets the AWS access key ID for explicit credential configuration.
537 ///
538 /// Must be combined with [`secret_access_key`](Self::secret_access_key).
539 ///
540 /// # Examples
541 ///
542 /// ```
543 /// use spring_batch_rs::tasklet::s3::get::S3GetFolderTaskletBuilder;
544 ///
545 /// let builder = S3GetFolderTaskletBuilder::new().access_key_id("AKIAIOSFODNN7EXAMPLE");
546 /// ```
547 pub fn access_key_id<S: Into<String>>(mut self, key_id: S) -> Self {
548 self.config.access_key_id = Some(key_id.into());
549 self
550 }
551
552 /// Sets the AWS secret access key for explicit credential configuration.
553 ///
554 /// Must be combined with [`access_key_id`](Self::access_key_id).
555 ///
556 /// # Examples
557 ///
558 /// ```
559 /// use spring_batch_rs::tasklet::s3::get::S3GetFolderTaskletBuilder;
560 ///
561 /// let builder = S3GetFolderTaskletBuilder::new().secret_access_key("wJalrXUtnFEMI/K7MDENG");
562 /// ```
563 pub fn secret_access_key<S: Into<String>>(mut self, secret: S) -> Self {
564 self.config.secret_access_key = Some(secret.into());
565 self
566 }
567
568 /// Builds the [`S3GetFolderTasklet`].
569 ///
570 /// # Errors
571 ///
572 /// Returns [`BatchError::Configuration`] if `bucket`, `prefix`, or `local_folder` are not set.
573 ///
574 /// # Examples
575 ///
576 /// ```rust,no_run
577 /// use spring_batch_rs::tasklet::s3::get::S3GetFolderTaskletBuilder;
578 ///
579 /// # fn example() -> Result<(), spring_batch_rs::BatchError> {
580 /// let tasklet = S3GetFolderTaskletBuilder::new()
581 /// .bucket("my-bucket")
582 /// .prefix("backups/")
583 /// .local_folder("./imports/")
584 /// .build()?;
585 /// # Ok(())
586 /// # }
587 /// ```
588 pub fn build(self) -> Result<S3GetFolderTasklet, BatchError> {
589 let bucket = self.bucket.ok_or_else(|| {
590 BatchError::Configuration("S3GetFolderTasklet: 'bucket' is required".to_string())
591 })?;
592 let prefix = self.prefix.ok_or_else(|| {
593 BatchError::Configuration("S3GetFolderTasklet: 'prefix' is required".to_string())
594 })?;
595 let local_folder = self.local_folder.ok_or_else(|| {
596 BatchError::Configuration("S3GetFolderTasklet: 'local_folder' is required".to_string())
597 })?;
598
599 Ok(S3GetFolderTasklet {
600 bucket,
601 prefix,
602 local_folder,
603 config: self.config,
604 })
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 // --- S3GetTaskletBuilder tests ---
613
614 #[test]
615 fn should_fail_build_when_bucket_missing() {
616 let result = S3GetTaskletBuilder::new()
617 .key("file.csv")
618 .local_file("/tmp/file.csv")
619 .build();
620 assert!(result.is_err(), "build should fail without bucket");
621 assert!(result.unwrap_err().to_string().contains("bucket"));
622 }
623
624 #[test]
625 fn should_fail_build_when_key_missing() {
626 let result = S3GetTaskletBuilder::new()
627 .bucket("my-bucket")
628 .local_file("/tmp/file.csv")
629 .build();
630 assert!(result.is_err(), "build should fail without key");
631 assert!(result.unwrap_err().to_string().contains("key"));
632 }
633
634 #[test]
635 fn should_fail_build_when_local_file_missing() {
636 let result = S3GetTaskletBuilder::new()
637 .bucket("my-bucket")
638 .key("file.csv")
639 .build();
640 assert!(result.is_err(), "build should fail without local_file");
641 assert!(result.unwrap_err().to_string().contains("local_file"));
642 }
643
644 #[test]
645 fn should_build_with_required_fields() {
646 let result = S3GetTaskletBuilder::new()
647 .bucket("my-bucket")
648 .key("file.csv")
649 .local_file("/tmp/file.csv")
650 .build();
651 assert!(
652 result.is_ok(),
653 "build should succeed with required fields: {:?}",
654 result.err()
655 );
656 }
657
658 #[test]
659 fn should_store_optional_config_fields() {
660 let tasklet = S3GetTaskletBuilder::new()
661 .bucket("b")
662 .key("k")
663 .local_file("/tmp/f")
664 .region("eu-west-1")
665 .endpoint_url("http://localhost:9000")
666 .access_key_id("AKID")
667 .secret_access_key("SECRET")
668 .build()
669 .unwrap(); // required fields set — cannot fail
670 assert_eq!(tasklet.config.region.as_deref(), Some("eu-west-1"));
671 assert_eq!(
672 tasklet.config.endpoint_url.as_deref(),
673 Some("http://localhost:9000")
674 );
675 assert_eq!(tasklet.config.access_key_id.as_deref(), Some("AKID"));
676 assert_eq!(tasklet.config.secret_access_key.as_deref(), Some("SECRET"));
677 }
678
679 // --- S3GetFolderTaskletBuilder tests ---
680
681 #[test]
682 fn should_fail_folder_build_when_bucket_missing() {
683 let result = S3GetFolderTaskletBuilder::new()
684 .prefix("backups/")
685 .local_folder("/tmp/imports")
686 .build();
687 assert!(result.is_err(), "build should fail without bucket");
688 assert!(result.unwrap_err().to_string().contains("bucket"));
689 }
690
691 #[test]
692 fn should_fail_folder_build_when_prefix_missing() {
693 let result = S3GetFolderTaskletBuilder::new()
694 .bucket("my-bucket")
695 .local_folder("/tmp/imports")
696 .build();
697 assert!(result.is_err(), "build should fail without prefix");
698 assert!(result.unwrap_err().to_string().contains("prefix"));
699 }
700
701 #[test]
702 fn should_fail_folder_build_when_local_folder_missing() {
703 let result = S3GetFolderTaskletBuilder::new()
704 .bucket("my-bucket")
705 .prefix("backups/")
706 .build();
707 assert!(result.is_err(), "build should fail without local_folder");
708 assert!(result.unwrap_err().to_string().contains("local_folder"));
709 }
710
711 #[test]
712 fn should_build_folder_with_required_fields() {
713 let result = S3GetFolderTaskletBuilder::new()
714 .bucket("my-bucket")
715 .prefix("backups/")
716 .local_folder("/tmp/imports")
717 .build();
718 assert!(result.is_ok(), "build should succeed: {:?}", result.err());
719 }
720
721 /// Verify that strip_prefix + leading-slash stripping produces a relative path
722 /// regardless of whether the prefix ends with a slash.
723 #[test]
724 fn should_strip_leading_slash_from_relative_key() {
725 let prefix_with_slash = "backups/2026/";
726 let prefix_without_slash = "backups/2026";
727 let key = "backups/2026/file.csv";
728 let local_folder = std::path::Path::new("/tmp/imports");
729
730 // Prefix ends with slash: strip_prefix returns "file.csv" (no leading slash)
731 let relative = key.strip_prefix(prefix_with_slash).unwrap_or(key);
732 let relative = relative.strip_prefix('/').unwrap_or(relative);
733 let path = local_folder.join(relative);
734 assert_eq!(
735 path,
736 std::path::Path::new("/tmp/imports/file.csv"),
737 "trailing-slash prefix should produce a correct local path"
738 );
739
740 // Prefix without slash: strip_prefix returns "/file.csv" (leading slash)
741 let relative = key.strip_prefix(prefix_without_slash).unwrap_or(key);
742 let relative = relative.strip_prefix('/').unwrap_or(relative);
743 let path = local_folder.join(relative);
744 assert_eq!(
745 path,
746 std::path::Path::new("/tmp/imports/file.csv"),
747 "non-trailing-slash prefix must not produce an absolute path"
748 );
749 }
750}