Skip to main content

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}