Skip to main content

spring_batch_rs/tasklet/
ftp.rs

1//! # FTP Tasklet
2//!
3//! This module provides tasklets for FTP file transfer operations (put and get).
4//! It's designed to be similar to Spring Batch's FTP capabilities for batch file transfers.
5//!
6//! ## Features
7//!
8//! - FTP PUT operations (upload files to FTP server)
9//! - FTP GET operations (download files from FTP server)
10//! - FTP PUT FOLDER operations (upload entire folder contents to FTP server)
11//! - FTP GET FOLDER operations (download entire folder contents from FTP server)
12//! - Support for both active and passive FTP modes
13//! - Configurable connection parameters
14//! - Proper error handling and logging
15//! - Builder pattern for easy configuration
16//!
17//! ## Memory Efficiency Features
18//!
19//! **Streaming Downloads (Implemented):**
20//! - Both `FtpGetTasklet` and `FtpGetFolderTasklet` use `retr()` streaming method
21//!   to download files directly from FTP server to local storage without loading
22//!   entire files into memory
23//! - This approach is memory-efficient for files of any size, from small to very large
24//! - Uses proper error type conversion between `std::io::Error` and `FtpError`
25//!   through the `io_error_to_ftp_error` helper function
26//!
27//! **Performance Benefits:**
28//! - Constant memory usage regardless of file size
29//! - Improved performance for large file transfers
30//! - Reduced risk of out-of-memory errors when processing large files
31//! - Direct streaming from network to disk without intermediate buffering
32//!
33//! ## Examples
34//!
35//! ### FTP PUT Operation
36//!
37//! ```rust
38//! use spring_batch_rs::core::step::{StepBuilder, StepExecution, Step};
39//! use spring_batch_rs::tasklet::ftp::FtpPutTaskletBuilder;
40//! use std::path::Path;
41//!
42//! # fn example() -> Result<(), spring_batch_rs::BatchError> {
43//! let ftp_put_tasklet = FtpPutTaskletBuilder::new()
44//!     .host("ftp.example.com")
45//!     .port(21)
46//!     .username("user")
47//!     .password("password")
48//!     .local_file("./local_file.txt")
49//!     .remote_file("/remote/path/file.txt")
50//!     .passive_mode(true)
51//!     .build()?;
52//!
53//! let step = StepBuilder::new("ftp-upload")
54//!     .tasklet(&ftp_put_tasklet)
55//!     .build();
56//!
57//! let mut step_execution = StepExecution::new("ftp-upload");
58//! step.execute(&mut step_execution)?;
59//! # Ok(())
60//! # }
61//! ```
62//!
63//! ### FTP GET Operation (Memory-Efficient Streaming)
64//!
65//! ```rust
66//! use spring_batch_rs::tasklet::ftp::FtpGetTaskletBuilder;
67//!
68//! # fn example() -> Result<(), spring_batch_rs::BatchError> {
69//! // This tasklet streams large files directly to disk without loading into memory
70//! let ftp_get_tasklet = FtpGetTaskletBuilder::new()
71//!     .host("ftp.example.com")
72//!     .username("user")
73//!     .password("password")
74//!     .remote_file("/remote/path/large_file.zip")  // Works efficiently with any file size
75//!     .local_file("./downloaded_large_file.zip")
76//!     .build()?;
77//! # Ok(())
78//! # }
79//! ```
80//!
81//! ### FTPS (Secure FTP) Operations
82//!
83//! ```rust
84//! use spring_batch_rs::tasklet::ftp::{FtpPutTaskletBuilder, FtpGetTaskletBuilder};
85//!
86//! # fn example() -> Result<(), spring_batch_rs::BatchError> {
87//! // Secure upload using FTPS (FTP over TLS)
88//! let secure_upload = FtpPutTaskletBuilder::new()
89//!     .host("secure-ftp.example.com")
90//!     .port(990)  // Common FTPS port
91//!     .username("user")
92//!     .password("password")
93//!     .local_file("./sensitive_data.txt")
94//!     .remote_file("/secure/path/data.txt")
95//!     .secure(true)  // Enable FTPS
96//!     .build()?;
97//!
98//! // Secure download using FTPS with streaming for memory efficiency
99//! let secure_download = FtpGetTaskletBuilder::new()
100//!     .host("secure-ftp.example.com")
101//!     .port(990)
102//!     .username("user")
103//!     .password("password")
104//!     .remote_file("/secure/path/confidential.zip")
105//!     .local_file("./confidential.zip")
106//!     .secure(true)  // Enable FTPS
107//!     .build()?;
108//! # Ok(())
109//! # }
110//! ```
111
112use crate::{
113    BatchError,
114    core::step::{RepeatStatus, StepExecution, Tasklet},
115};
116use log::info;
117use std::{
118    fs::{self, File},
119    io::BufReader,
120    path::{Path, PathBuf},
121    time::Duration,
122};
123use suppaftp::{FtpError, FtpStream, Mode};
124
125#[cfg(feature = "ftp")]
126use suppaftp::{NativeTlsConnector, NativeTlsFtpStream};
127
128#[cfg(feature = "ftp")]
129use suppaftp::native_tls::TlsConnector;
130
131/// Helper function to convert std::io::Error to FtpError for use in suppaftp closures.
132///
133/// This allows us to use std::io operations within FTP streaming closures while
134/// maintaining proper error type compatibility. The suppaftp library's `retr()` method
135/// expects closures to return `Result<T, FtpError>`, but standard I/O operations return
136/// `Result<T, std::io::Error>`. This function bridges that gap by wrapping the I/O error
137/// in a `FtpError::ConnectionError` variant.
138///
139/// # Arguments
140///
141/// * `error` - The std::io::Error to convert
142///
143/// # Returns
144///
145/// An FtpError that can be used in suppaftp closure contexts
146fn io_error_to_ftp_error(error: std::io::Error) -> FtpError {
147    FtpError::ConnectionError(error)
148}
149
150/// Helper function to establish and configure an FTP connection.
151///
152/// This function handles the common setup logic shared by all FTP tasklets:
153/// - Connecting to the FTP server
154/// - Logging in with credentials
155/// - Setting timeouts for read/write operations
156/// - Configuring transfer mode (active/passive)
157///
158/// # Arguments
159///
160/// * `host` - FTP server hostname or IP address
161/// * `port` - FTP server port
162/// * `username` - FTP username
163/// * `password` - FTP password
164/// * `passive_mode` - Whether to use passive mode
165/// * `timeout` - Connection timeout duration
166///
167/// # Returns
168///
169/// Returns a configured `FtpStream` ready for file operations.
170///
171/// # Errors
172///
173/// Returns `BatchError` if connection, login, or configuration fails.
174fn setup_ftp_connection(
175    host: &str,
176    port: u16,
177    username: &str,
178    password: &str,
179    passive_mode: bool,
180    timeout: Duration,
181) -> Result<FtpStream, BatchError> {
182    // Connect to FTP server
183    let mut ftp_stream = FtpStream::connect(format!("{}:{}", host, port)).map_err(|e| {
184        BatchError::Io(std::io::Error::new(
185            std::io::ErrorKind::ConnectionRefused,
186            format!("Failed to connect to FTP server: {}", e),
187        ))
188    })?;
189
190    // Login
191    ftp_stream
192        .login(username, password)
193        .map_err(|e| BatchError::Configuration(format!("FTP login failed: {}", e)))?;
194
195    // Set timeout for control channel commands
196    ftp_stream
197        .get_ref()
198        .set_read_timeout(Some(timeout))
199        .map_err(|e| BatchError::Configuration(format!("Failed to set read timeout: {}", e)))?;
200    ftp_stream
201        .get_ref()
202        .set_write_timeout(Some(timeout))
203        .map_err(|e| BatchError::Configuration(format!("Failed to set write timeout: {}", e)))?;
204
205    // Set transfer mode
206    let mode = if passive_mode {
207        Mode::Passive
208    } else {
209        Mode::Active
210    };
211    ftp_stream.set_mode(mode);
212
213    Ok(ftp_stream)
214}
215
216/// Helper function to establish and configure an FTPS connection.
217///
218/// This function handles the setup logic for secure FTP connections:
219/// - Connecting to the FTP server
220/// - Switching to secure mode (explicit FTPS)
221/// - Logging in with credentials
222/// - Setting timeouts for read/write operations
223/// - Configuring transfer mode (active/passive)
224///
225/// # Arguments
226///
227/// * `host` - FTP server hostname or IP address
228/// * `port` - FTP server port
229/// * `username` - FTP username
230/// * `password` - FTP password
231/// * `passive_mode` - Whether to use passive mode
232/// * `timeout` - Connection timeout duration
233///
234/// # Returns
235///
236/// Returns a configured `NativeTlsFtpStream` ready for secure file operations.
237///
238/// # Errors
239///
240/// Returns `BatchError` if connection, TLS setup, login, or configuration fails.
241#[cfg(feature = "ftp")]
242fn setup_ftps_connection(
243    host: &str,
244    port: u16,
245    username: &str,
246    password: &str,
247    passive_mode: bool,
248    timeout: Duration,
249) -> Result<NativeTlsFtpStream, BatchError> {
250    // Connect to FTP server
251    let plain_stream = NativeTlsFtpStream::connect(format!("{}:{}", host, port)).map_err(|e| {
252        BatchError::Io(std::io::Error::new(
253            std::io::ErrorKind::ConnectionRefused,
254            format!("Failed to connect to FTP server: {}", e),
255        ))
256    })?;
257
258    // Switch to secure mode using explicit FTPS
259    let tls_connector = TlsConnector::new()
260        .map_err(|e| BatchError::Configuration(format!("Failed to create TLS connector: {}", e)))?;
261    let mut ftp_stream = plain_stream
262        .into_secure(NativeTlsConnector::from(tls_connector), host)
263        .map_err(|e| {
264            BatchError::Io(std::io::Error::new(
265                std::io::ErrorKind::ConnectionRefused,
266                format!("Failed to establish FTPS connection: {}", e),
267            ))
268        })?;
269
270    // Login
271    ftp_stream
272        .login(username, password)
273        .map_err(|e| BatchError::Configuration(format!("FTPS login failed: {}", e)))?;
274
275    // Set timeout for control channel commands
276    ftp_stream
277        .get_ref()
278        .set_read_timeout(Some(timeout))
279        .map_err(|e| BatchError::Configuration(format!("Failed to set read timeout: {}", e)))?;
280    ftp_stream
281        .get_ref()
282        .set_write_timeout(Some(timeout))
283        .map_err(|e| BatchError::Configuration(format!("Failed to set write timeout: {}", e)))?;
284
285    // Set transfer mode
286    let mode = if passive_mode {
287        Mode::Passive
288    } else {
289        Mode::Active
290    };
291    ftp_stream.set_mode(mode);
292
293    Ok(ftp_stream)
294}
295
296/// A tasklet for uploading files to an FTP server.
297///
298/// This tasklet provides functionality for uploading local files to an FTP server
299/// as part of a batch processing step. Supports both plain FTP and secure FTPS
300/// (FTP over TLS) connections.
301#[derive(Debug)]
302pub struct FtpPutTasklet {
303    /// FTP server hostname or IP address
304    host: String,
305    /// FTP server port (default: 21)
306    port: u16,
307    /// FTP username
308    username: String,
309    /// FTP password
310    password: String,
311    /// Local file path to upload
312    local_file: PathBuf,
313    /// Remote file path on FTP server
314    remote_file: String,
315    /// Whether to use passive mode (default: true)
316    passive_mode: bool,
317    /// Connection timeout in seconds
318    timeout: Duration,
319    /// Whether to use FTPS (FTP over TLS) for secure communication (default: false)
320    secure: bool,
321}
322
323impl FtpPutTasklet {
324    /// Creates a new FtpPutTasklet with the specified parameters.
325    pub fn new<P: AsRef<Path>>(
326        host: &str,
327        port: u16,
328        username: &str,
329        password: &str,
330        local_file: P,
331        remote_file: &str,
332    ) -> Result<Self, BatchError> {
333        let local_path = local_file.as_ref().to_path_buf();
334
335        // Validate local file exists
336        if !local_path.exists() {
337            return Err(BatchError::Configuration(format!(
338                "Local file does not exist: {}",
339                local_path.display()
340            )));
341        }
342
343        Ok(Self {
344            host: host.to_string(),
345            port,
346            username: username.to_string(),
347            password: password.to_string(),
348            local_file: local_path,
349            remote_file: remote_file.to_string(),
350            passive_mode: true,
351            timeout: Duration::from_secs(30),
352            secure: false,
353        })
354    }
355
356    /// Sets the passive mode for FTP connection.
357    pub fn set_passive_mode(&mut self, passive: bool) {
358        self.passive_mode = passive;
359    }
360
361    /// Sets the connection timeout.
362    pub fn set_timeout(&mut self, timeout: Duration) {
363        self.timeout = timeout;
364    }
365}
366
367impl Tasklet for FtpPutTasklet {
368    fn execute(&self, _step_execution: &StepExecution) -> Result<RepeatStatus, BatchError> {
369        let protocol = if self.secure { "FTPS" } else { "FTP" };
370        info!(
371            "Starting {} PUT: {} -> {}:{}{}",
372            protocol,
373            self.local_file.display(),
374            self.host,
375            self.port,
376            self.remote_file
377        );
378
379        let file = File::open(&self.local_file).map_err(BatchError::Io)?;
380        let mut reader = BufReader::new(file);
381
382        if self.secure {
383            #[cfg(feature = "ftp")]
384            {
385                // Connect using FTPS
386                let mut ftp_stream = setup_ftps_connection(
387                    &self.host,
388                    self.port,
389                    &self.username,
390                    &self.password,
391                    self.passive_mode,
392                    self.timeout,
393                )?;
394
395                // Upload file
396                ftp_stream
397                    .put_file(&self.remote_file, &mut reader)
398                    .map_err(|e| {
399                        BatchError::Io(std::io::Error::other(format!("FTPS upload failed: {}", e)))
400                    })?;
401
402                // Disconnect
403                let _ = ftp_stream.quit();
404            }
405            #[cfg(not(feature = "ftp"))]
406            {
407                return Err(BatchError::Configuration(
408                    "FTPS support requires the 'ftp' feature to be enabled".to_string(),
409                ));
410            }
411        } else {
412            // Connect using plain FTP
413            let mut ftp_stream = setup_ftp_connection(
414                &self.host,
415                self.port,
416                &self.username,
417                &self.password,
418                self.passive_mode,
419                self.timeout,
420            )?;
421
422            // Upload file
423            ftp_stream
424                .put_file(&self.remote_file, &mut reader)
425                .map_err(|e| {
426                    BatchError::Io(std::io::Error::other(format!("FTP upload failed: {}", e)))
427                })?;
428
429            // Disconnect
430            let _ = ftp_stream.quit();
431        }
432
433        info!(
434            "{} PUT completed successfully: {} uploaded to {}:{}{}",
435            protocol,
436            self.local_file.display(),
437            self.host,
438            self.port,
439            self.remote_file
440        );
441
442        Ok(RepeatStatus::Finished)
443    }
444}
445
446/// A tasklet for downloading files from an FTP server.
447///
448/// This tasklet provides functionality for downloading files from an FTP server
449/// to local storage as part of a batch processing step. Supports both plain FTP
450/// and secure FTPS (FTP over TLS) connections.
451#[derive(Debug)]
452pub struct FtpGetTasklet {
453    /// FTP server hostname or IP address
454    host: String,
455    /// FTP server port (default: 21)
456    port: u16,
457    /// FTP username
458    username: String,
459    /// FTP password
460    password: String,
461    /// Remote file path on FTP server
462    remote_file: String,
463    /// Local file path to save downloaded file
464    local_file: PathBuf,
465    /// Whether to use passive mode (default: true)
466    passive_mode: bool,
467    /// Connection timeout in seconds
468    timeout: Duration,
469    /// Whether to use FTPS (FTP over TLS) for secure communication (default: false)
470    secure: bool,
471}
472
473impl FtpGetTasklet {
474    /// Creates a new FtpGetTasklet with the specified parameters.
475    pub fn new<P: AsRef<Path>>(
476        host: &str,
477        port: u16,
478        username: &str,
479        password: &str,
480        remote_file: &str,
481        local_file: P,
482    ) -> Result<Self, BatchError> {
483        let local_path = local_file.as_ref().to_path_buf();
484
485        // Ensure local directory exists
486        if let Some(parent) = local_path.parent()
487            && !parent.exists()
488        {
489            std::fs::create_dir_all(parent).map_err(BatchError::Io)?;
490        }
491
492        Ok(Self {
493            host: host.to_string(),
494            port,
495            username: username.to_string(),
496            password: password.to_string(),
497            remote_file: remote_file.to_string(),
498            local_file: local_path,
499            passive_mode: true,
500            timeout: Duration::from_secs(30),
501            secure: false,
502        })
503    }
504
505    /// Sets the passive mode for FTP connection.
506    pub fn set_passive_mode(&mut self, passive: bool) {
507        self.passive_mode = passive;
508    }
509
510    /// Sets the connection timeout.
511    pub fn set_timeout(&mut self, timeout: Duration) {
512        self.timeout = timeout;
513    }
514}
515
516impl Tasklet for FtpGetTasklet {
517    fn execute(&self, _step_execution: &StepExecution) -> Result<RepeatStatus, BatchError> {
518        let protocol = if self.secure { "FTPS" } else { "FTP" };
519        info!(
520            "Starting {} GET: {}:{}{} -> {}",
521            protocol,
522            self.host,
523            self.port,
524            self.remote_file,
525            self.local_file.display()
526        );
527
528        let local_file_path = self.local_file.clone();
529
530        if self.secure {
531            #[cfg(feature = "ftp")]
532            {
533                // Connect using FTPS
534                let mut ftp_stream = setup_ftps_connection(
535                    &self.host,
536                    self.port,
537                    &self.username,
538                    &self.password,
539                    self.passive_mode,
540                    self.timeout,
541                )?;
542
543                // Stream download directly to file for improved memory efficiency
544                ftp_stream
545                    .retr(&self.remote_file, |stream| {
546                        // Create local file for writing
547                        let mut file =
548                            File::create(&local_file_path).map_err(io_error_to_ftp_error)?;
549
550                        // Copy data from FTP stream to local file using streaming
551                        std::io::copy(stream, &mut file).map_err(io_error_to_ftp_error)?;
552
553                        // Ensure data is flushed to disk
554                        file.sync_all().map_err(io_error_to_ftp_error)?;
555
556                        Ok(())
557                    })
558                    .map_err(|e| {
559                        BatchError::Io(std::io::Error::other(format!(
560                            "FTPS streaming download failed: {}",
561                            e
562                        )))
563                    })?;
564
565                // Disconnect
566                let _ = ftp_stream.quit();
567            }
568            #[cfg(not(feature = "ftp"))]
569            {
570                return Err(BatchError::Configuration(
571                    "FTPS support requires the 'ftp' feature to be enabled".to_string(),
572                ));
573            }
574        } else {
575            // Connect using plain FTP
576            let mut ftp_stream = setup_ftp_connection(
577                &self.host,
578                self.port,
579                &self.username,
580                &self.password,
581                self.passive_mode,
582                self.timeout,
583            )?;
584
585            // Stream download directly to file for improved memory efficiency
586            ftp_stream
587                .retr(&self.remote_file, |stream| {
588                    // Create local file for writing
589                    let mut file = File::create(&local_file_path).map_err(io_error_to_ftp_error)?;
590
591                    // Copy data from FTP stream to local file using streaming
592                    std::io::copy(stream, &mut file).map_err(io_error_to_ftp_error)?;
593
594                    // Ensure data is flushed to disk
595                    file.sync_all().map_err(io_error_to_ftp_error)?;
596
597                    Ok(())
598                })
599                .map_err(|e| {
600                    BatchError::Io(std::io::Error::other(format!(
601                        "FTP streaming download failed: {}",
602                        e
603                    )))
604                })?;
605
606            // Disconnect
607            let _ = ftp_stream.quit();
608        }
609
610        info!(
611            "{} GET completed successfully: {}:{}{} downloaded to {}",
612            protocol,
613            self.host,
614            self.port,
615            self.remote_file,
616            self.local_file.display()
617        );
618
619        Ok(RepeatStatus::Finished)
620    }
621}
622
623/// A tasklet for uploading entire folder contents to an FTP server.
624///
625/// This tasklet provides functionality for uploading all files from a local folder
626/// to a remote folder on an FTP server as part of a batch processing step. Supports
627/// both plain FTP and secure FTPS (FTP over TLS) connections.
628#[derive(Debug)]
629pub struct FtpPutFolderTasklet {
630    /// FTP server hostname or IP address
631    host: String,
632    /// FTP server port (default: 21)
633    port: u16,
634    /// FTP username
635    username: String,
636    /// FTP password
637    password: String,
638    /// Local folder path to upload
639    local_folder: PathBuf,
640    /// Remote folder path on FTP server
641    remote_folder: String,
642    /// Whether to use passive mode (default: true)
643    passive_mode: bool,
644    /// Connection timeout in seconds
645    timeout: Duration,
646    /// Whether to create remote directories if they don't exist
647    create_directories: bool,
648    /// Whether to upload subdirectories recursively
649    recursive: bool,
650    /// Whether to use FTPS (FTP over TLS) for secure communication (default: false)
651    secure: bool,
652}
653
654impl FtpPutFolderTasklet {
655    /// Creates a new FtpPutFolderTasklet with the specified parameters.
656    pub fn new<P: AsRef<Path>>(
657        host: &str,
658        port: u16,
659        username: &str,
660        password: &str,
661        local_folder: P,
662        remote_folder: &str,
663    ) -> Result<Self, BatchError> {
664        let local_path = local_folder.as_ref().to_path_buf();
665
666        // Validate local folder exists
667        if !local_path.exists() {
668            return Err(BatchError::Configuration(format!(
669                "Local folder does not exist: {}",
670                local_path.display()
671            )));
672        }
673
674        if !local_path.is_dir() {
675            return Err(BatchError::Configuration(format!(
676                "Local path is not a directory: {}",
677                local_path.display()
678            )));
679        }
680
681        Ok(Self {
682            host: host.to_string(),
683            port,
684            username: username.to_string(),
685            password: password.to_string(),
686            local_folder: local_path,
687            remote_folder: remote_folder.to_string(),
688            passive_mode: true,
689            timeout: Duration::from_secs(30),
690            create_directories: true,
691            recursive: false,
692            secure: false,
693        })
694    }
695
696    /// Sets the passive mode for FTP connection.
697    pub fn set_passive_mode(&mut self, passive: bool) {
698        self.passive_mode = passive;
699    }
700
701    /// Sets the connection timeout.
702    pub fn set_timeout(&mut self, timeout: Duration) {
703        self.timeout = timeout;
704    }
705
706    /// Sets whether to create remote directories if they don't exist.
707    pub fn set_create_directories(&mut self, create: bool) {
708        self.create_directories = create;
709    }
710
711    /// Sets whether to upload subdirectories recursively.
712    pub fn set_recursive(&mut self, recursive: bool) {
713        self.recursive = recursive;
714    }
715
716    /// Recursively uploads files from a directory.
717    fn upload_directory(
718        &self,
719        ftp_stream: &mut FtpStream,
720        local_dir: &Path,
721        remote_dir: &str,
722    ) -> Result<(), BatchError> {
723        let entries = fs::read_dir(local_dir).map_err(BatchError::Io)?;
724
725        for entry in entries {
726            let entry = entry.map_err(BatchError::Io)?;
727            let local_path = entry.path();
728            let file_name = entry.file_name();
729            let file_name_str = file_name.to_string_lossy();
730            let remote_path = if remote_dir.is_empty() {
731                file_name_str.to_string()
732            } else {
733                format!("{}/{}", remote_dir, file_name_str)
734            };
735
736            if local_path.is_file() {
737                info!(
738                    "Uploading file: {} -> {}",
739                    local_path.display(),
740                    remote_path
741                );
742
743                let file = File::open(&local_path).map_err(BatchError::Io)?;
744                let mut reader = BufReader::new(file);
745
746                ftp_stream
747                    .put_file(&remote_path, &mut reader)
748                    .map_err(|e| {
749                        BatchError::Io(std::io::Error::other(format!(
750                            "FTP upload failed for {}: {}",
751                            local_path.display(),
752                            e
753                        )))
754                    })?;
755            } else if local_path.is_dir() && self.recursive {
756                info!("Creating remote directory: {}", remote_path);
757
758                if self.create_directories {
759                    // Try to create directory, ignore error if it already exists
760                    let _ = ftp_stream.mkdir(&remote_path);
761                }
762
763                // Recursively upload subdirectory
764                self.upload_directory(ftp_stream, &local_path, &remote_path)?;
765            }
766        }
767
768        Ok(())
769    }
770
771    /// Recursively uploads files from a directory using FTPS.
772    #[cfg(feature = "ftp")]
773    fn upload_directory_secure(
774        &self,
775        ftp_stream: &mut NativeTlsFtpStream,
776        local_dir: &Path,
777        remote_dir: &str,
778    ) -> Result<(), BatchError> {
779        let entries = fs::read_dir(local_dir).map_err(BatchError::Io)?;
780
781        for entry in entries {
782            let entry = entry.map_err(BatchError::Io)?;
783            let local_path = entry.path();
784            let file_name = entry.file_name();
785            let file_name_str = file_name.to_string_lossy();
786            let remote_path = if remote_dir.is_empty() {
787                file_name_str.to_string()
788            } else {
789                format!("{}/{}", remote_dir, file_name_str)
790            };
791
792            if local_path.is_file() {
793                info!(
794                    "Uploading file (FTPS): {} -> {}",
795                    local_path.display(),
796                    remote_path
797                );
798
799                let file = File::open(&local_path).map_err(BatchError::Io)?;
800                let mut reader = BufReader::new(file);
801
802                ftp_stream
803                    .put_file(&remote_path, &mut reader)
804                    .map_err(|e| {
805                        BatchError::Io(std::io::Error::other(format!(
806                            "FTPS upload failed for {}: {}",
807                            local_path.display(),
808                            e
809                        )))
810                    })?;
811            } else if local_path.is_dir() && self.recursive {
812                info!("Creating remote directory (FTPS): {}", remote_path);
813
814                if self.create_directories {
815                    // Try to create directory, ignore error if it already exists
816                    let _ = ftp_stream.mkdir(&remote_path);
817                }
818
819                // Recursively upload subdirectory
820                self.upload_directory_secure(ftp_stream, &local_path, &remote_path)?;
821            }
822        }
823
824        Ok(())
825    }
826}
827
828impl Tasklet for FtpPutFolderTasklet {
829    fn execute(&self, _step_execution: &StepExecution) -> Result<RepeatStatus, BatchError> {
830        let protocol = if self.secure { "FTPS" } else { "FTP" };
831        info!(
832            "Starting {} PUT FOLDER: {} -> {}:{}{}",
833            protocol,
834            self.local_folder.display(),
835            self.host,
836            self.port,
837            self.remote_folder
838        );
839
840        if self.secure {
841            #[cfg(feature = "ftp")]
842            {
843                // Connect using FTPS
844                let mut ftp_stream = setup_ftps_connection(
845                    &self.host,
846                    self.port,
847                    &self.username,
848                    &self.password,
849                    self.passive_mode,
850                    self.timeout,
851                )?;
852
853                // Create remote base directory if needed
854                if self.create_directories && !self.remote_folder.is_empty() {
855                    let _ = ftp_stream.mkdir(&self.remote_folder);
856                }
857
858                // Upload folder contents using FTPS
859                self.upload_directory_secure(
860                    &mut ftp_stream,
861                    &self.local_folder,
862                    &self.remote_folder,
863                )?;
864
865                // Disconnect
866                let _ = ftp_stream.quit();
867            }
868            #[cfg(not(feature = "ftp"))]
869            {
870                return Err(BatchError::Configuration(
871                    "FTPS support requires the 'ftp' feature to be enabled".to_string(),
872                ));
873            }
874        } else {
875            // Connect using plain FTP
876            let mut ftp_stream = setup_ftp_connection(
877                &self.host,
878                self.port,
879                &self.username,
880                &self.password,
881                self.passive_mode,
882                self.timeout,
883            )?;
884
885            // Create remote base directory if needed
886            if self.create_directories && !self.remote_folder.is_empty() {
887                let _ = ftp_stream.mkdir(&self.remote_folder);
888            }
889
890            // Upload folder contents
891            self.upload_directory(&mut ftp_stream, &self.local_folder, &self.remote_folder)?;
892
893            // Disconnect
894            let _ = ftp_stream.quit();
895        }
896
897        info!(
898            "{} PUT FOLDER completed successfully: {} uploaded to {}:{}{}",
899            protocol,
900            self.local_folder.display(),
901            self.host,
902            self.port,
903            self.remote_folder
904        );
905
906        Ok(RepeatStatus::Finished)
907    }
908}
909
910/// A tasklet for downloading entire folder contents from an FTP server.
911///
912/// This tasklet provides functionality for downloading all files from a remote folder
913/// on an FTP server to a local folder as part of a batch processing step. Supports
914/// both plain FTP and secure FTPS (FTP over TLS) connections.
915#[derive(Debug)]
916pub struct FtpGetFolderTasklet {
917    /// FTP server hostname or IP address
918    host: String,
919    /// FTP server port (default: 21)
920    port: u16,
921    /// FTP username
922    username: String,
923    /// FTP password
924    password: String,
925    /// Remote folder path on FTP server
926    remote_folder: String,
927    /// Local folder path to save downloaded files
928    local_folder: PathBuf,
929    /// Whether to use passive mode (default: true)
930    passive_mode: bool,
931    /// Connection timeout in seconds
932    timeout: Duration,
933    /// Whether to create local directories if they don't exist
934    create_directories: bool,
935    /// Whether to download subdirectories recursively
936    recursive: bool,
937    /// Whether to use FTPS (FTP over TLS) for secure communication (default: false)
938    secure: bool,
939}
940
941impl FtpGetFolderTasklet {
942    /// Creates a new FtpGetFolderTasklet with the specified parameters.
943    pub fn new<P: AsRef<Path>>(
944        host: &str,
945        port: u16,
946        username: &str,
947        password: &str,
948        remote_folder: &str,
949        local_folder: P,
950    ) -> Result<Self, BatchError> {
951        let local_path = local_folder.as_ref().to_path_buf();
952
953        Ok(Self {
954            host: host.to_string(),
955            port,
956            username: username.to_string(),
957            password: password.to_string(),
958            remote_folder: remote_folder.to_string(),
959            local_folder: local_path,
960            passive_mode: true,
961            timeout: Duration::from_secs(30),
962            create_directories: true,
963            recursive: false,
964            secure: false,
965        })
966    }
967
968    /// Sets the passive mode for FTP connection.
969    pub fn set_passive_mode(&mut self, passive: bool) {
970        self.passive_mode = passive;
971    }
972
973    /// Sets the connection timeout.
974    pub fn set_timeout(&mut self, timeout: Duration) {
975        self.timeout = timeout;
976    }
977
978    /// Sets whether to create local directories if they don't exist.
979    pub fn set_create_directories(&mut self, create: bool) {
980        self.create_directories = create;
981    }
982
983    /// Sets whether to download subdirectories recursively.
984    pub fn set_recursive(&mut self, recursive: bool) {
985        self.recursive = recursive;
986    }
987
988    /// Recursively downloads files from a directory.
989    fn download_directory(
990        &self,
991        ftp_stream: &mut FtpStream,
992        remote_dir: &str,
993        local_dir: &Path,
994    ) -> Result<(), BatchError> {
995        // List remote directory contents
996        let files = ftp_stream.nlst(Some(remote_dir)).map_err(|e| {
997            BatchError::Io(std::io::Error::other(format!(
998                "Failed to list remote directory {}: {}",
999                remote_dir, e
1000            )))
1001        })?;
1002
1003        for file_path in files {
1004            let file_name = Path::new(&file_path)
1005                .file_name()
1006                .and_then(|n| n.to_str())
1007                .unwrap_or(&file_path);
1008
1009            let local_path = local_dir.join(file_name);
1010            let remote_full_path = if remote_dir.is_empty() {
1011                file_path.clone()
1012            } else {
1013                format!("{}/{}", remote_dir, file_name)
1014            };
1015
1016            // Try to determine if it's a file or directory by attempting to stream download
1017            let download_result = {
1018                let local_path_clone = local_path.clone();
1019                ftp_stream.retr(&remote_full_path, |stream| {
1020                    // Create local file for writing
1021                    let mut file =
1022                        File::create(&local_path_clone).map_err(io_error_to_ftp_error)?;
1023
1024                    // Copy data from FTP stream to local file using streaming
1025                    std::io::copy(stream, &mut file).map_err(io_error_to_ftp_error)?;
1026
1027                    // Ensure data is flushed to disk
1028                    file.sync_all().map_err(io_error_to_ftp_error)?;
1029
1030                    Ok(())
1031                })
1032            };
1033
1034            match download_result {
1035                Ok(_) => {
1036                    // It's a file, successfully downloaded using streaming
1037                    info!(
1038                        "Streaming downloaded file: {} -> {}",
1039                        remote_full_path,
1040                        local_path.display()
1041                    );
1042
1043                    if self.create_directories
1044                        && let Some(parent) = local_path.parent()
1045                    {
1046                        fs::create_dir_all(parent).map_err(BatchError::Io)?;
1047                    }
1048                }
1049                Err(_) if self.recursive => {
1050                    // Might be a directory, try to recurse
1051                    info!("Attempting to download directory: {}", remote_full_path);
1052
1053                    if self.create_directories {
1054                        fs::create_dir_all(&local_path).map_err(BatchError::Io)?;
1055                    }
1056
1057                    // Recursively download subdirectory
1058                    if let Err(e) =
1059                        self.download_directory(ftp_stream, &remote_full_path, &local_path)
1060                    {
1061                        // If recursion fails, it might not be a directory, just log and continue
1062                        info!(
1063                            "Failed to download as directory, skipping: {} ({})",
1064                            remote_full_path, e
1065                        );
1066                    }
1067                }
1068                Err(e) => {
1069                    info!(
1070                        "Skipping item that couldn't be downloaded: {} ({})",
1071                        remote_full_path, e
1072                    );
1073                }
1074            }
1075        }
1076
1077        Ok(())
1078    }
1079
1080    /// Recursively downloads files from a directory using FTPS.
1081    #[cfg(feature = "ftp")]
1082    fn download_directory_secure(
1083        &self,
1084        ftp_stream: &mut NativeTlsFtpStream,
1085        remote_dir: &str,
1086        local_dir: &Path,
1087    ) -> Result<(), BatchError> {
1088        // List remote directory contents
1089        let files = ftp_stream.nlst(Some(remote_dir)).map_err(|e| {
1090            BatchError::Io(std::io::Error::other(format!(
1091                "Failed to list remote directory {}: {}",
1092                remote_dir, e
1093            )))
1094        })?;
1095
1096        for file_path in files {
1097            let file_name = Path::new(&file_path)
1098                .file_name()
1099                .and_then(|n| n.to_str())
1100                .unwrap_or(&file_path);
1101
1102            let local_path = local_dir.join(file_name);
1103            let remote_full_path = if remote_dir.is_empty() {
1104                file_path.clone()
1105            } else {
1106                format!("{}/{}", remote_dir, file_name)
1107            };
1108
1109            // Try to determine if it's a file or directory by attempting to stream download
1110            let download_result = {
1111                let local_path_clone = local_path.clone();
1112                ftp_stream.retr(&remote_full_path, |stream| {
1113                    // Create local file for writing
1114                    let mut file =
1115                        File::create(&local_path_clone).map_err(io_error_to_ftp_error)?;
1116
1117                    // Copy data from FTP stream to local file using streaming
1118                    std::io::copy(stream, &mut file).map_err(io_error_to_ftp_error)?;
1119
1120                    // Ensure data is flushed to disk
1121                    file.sync_all().map_err(io_error_to_ftp_error)?;
1122
1123                    Ok(())
1124                })
1125            };
1126
1127            match download_result {
1128                Ok(_) => {
1129                    // It's a file, successfully downloaded using streaming
1130                    info!(
1131                        "Streaming downloaded file (FTPS): {} -> {}",
1132                        remote_full_path,
1133                        local_path.display()
1134                    );
1135
1136                    if self.create_directories
1137                        && let Some(parent) = local_path.parent()
1138                    {
1139                        fs::create_dir_all(parent).map_err(BatchError::Io)?;
1140                    }
1141                }
1142                Err(_) if self.recursive => {
1143                    // Might be a directory, try to recurse
1144                    info!(
1145                        "Attempting to download directory (FTPS): {}",
1146                        remote_full_path
1147                    );
1148
1149                    if self.create_directories {
1150                        fs::create_dir_all(&local_path).map_err(BatchError::Io)?;
1151                    }
1152
1153                    // Recursively download subdirectory
1154                    if let Err(e) =
1155                        self.download_directory_secure(ftp_stream, &remote_full_path, &local_path)
1156                    {
1157                        // If recursion fails, it might not be a directory, just log and continue
1158                        info!(
1159                            "Failed to download as directory, skipping: {} ({})",
1160                            remote_full_path, e
1161                        );
1162                    }
1163                }
1164                Err(e) => {
1165                    info!(
1166                        "Skipping item that couldn't be downloaded (FTPS): {} ({})",
1167                        remote_full_path, e
1168                    );
1169                }
1170            }
1171        }
1172
1173        Ok(())
1174    }
1175}
1176
1177impl Tasklet for FtpGetFolderTasklet {
1178    fn execute(&self, _step_execution: &StepExecution) -> Result<RepeatStatus, BatchError> {
1179        let protocol = if self.secure { "FTPS" } else { "FTP" };
1180        info!(
1181            "Starting {} GET FOLDER: {}:{}{} -> {}",
1182            protocol,
1183            self.host,
1184            self.port,
1185            self.remote_folder,
1186            self.local_folder.display()
1187        );
1188
1189        // Create local base directory if needed
1190        if self.create_directories {
1191            fs::create_dir_all(&self.local_folder).map_err(BatchError::Io)?;
1192        }
1193
1194        if self.secure {
1195            #[cfg(feature = "ftp")]
1196            {
1197                // Connect using FTPS
1198                let mut ftp_stream = setup_ftps_connection(
1199                    &self.host,
1200                    self.port,
1201                    &self.username,
1202                    &self.password,
1203                    self.passive_mode,
1204                    self.timeout,
1205                )?;
1206
1207                // Download folder contents using FTPS
1208                self.download_directory_secure(
1209                    &mut ftp_stream,
1210                    &self.remote_folder,
1211                    &self.local_folder,
1212                )?;
1213
1214                // Disconnect
1215                let _ = ftp_stream.quit();
1216            }
1217            #[cfg(not(feature = "ftp"))]
1218            {
1219                return Err(BatchError::Configuration(
1220                    "FTPS support requires the 'ftp' feature to be enabled".to_string(),
1221                ));
1222            }
1223        } else {
1224            // Connect using plain FTP
1225            let mut ftp_stream = setup_ftp_connection(
1226                &self.host,
1227                self.port,
1228                &self.username,
1229                &self.password,
1230                self.passive_mode,
1231                self.timeout,
1232            )?;
1233
1234            // Download folder contents
1235            self.download_directory(&mut ftp_stream, &self.remote_folder, &self.local_folder)?;
1236
1237            // Disconnect
1238            let _ = ftp_stream.quit();
1239        }
1240
1241        info!(
1242            "{} GET FOLDER completed successfully: {}:{}{} downloaded to {}",
1243            protocol,
1244            self.host,
1245            self.port,
1246            self.remote_folder,
1247            self.local_folder.display()
1248        );
1249
1250        Ok(RepeatStatus::Finished)
1251    }
1252}
1253
1254/// Builder for creating FtpPutTasklet instances with a fluent interface.
1255pub struct FtpPutTaskletBuilder {
1256    host: Option<String>,
1257    port: u16,
1258    username: Option<String>,
1259    password: Option<String>,
1260    local_file: Option<PathBuf>,
1261    remote_file: Option<String>,
1262    passive_mode: bool,
1263    timeout: Duration,
1264    secure: bool,
1265}
1266
1267impl Default for FtpPutTaskletBuilder {
1268    fn default() -> Self {
1269        Self::new()
1270    }
1271}
1272
1273impl FtpPutTaskletBuilder {
1274    /// Creates a new FtpPutTaskletBuilder with default settings.
1275    pub fn new() -> Self {
1276        Self {
1277            host: None,
1278            port: 21,
1279            username: None,
1280            password: None,
1281            local_file: None,
1282            remote_file: None,
1283            passive_mode: true,
1284            timeout: Duration::from_secs(30),
1285            secure: false,
1286        }
1287    }
1288
1289    /// Sets the FTP server hostname or IP address.
1290    pub fn host<S: Into<String>>(mut self, host: S) -> Self {
1291        self.host = Some(host.into());
1292        self
1293    }
1294
1295    /// Sets the FTP server port.
1296    pub fn port(mut self, port: u16) -> Self {
1297        self.port = port;
1298        self
1299    }
1300
1301    /// Sets the FTP username.
1302    pub fn username<S: Into<String>>(mut self, username: S) -> Self {
1303        self.username = Some(username.into());
1304        self
1305    }
1306
1307    /// Sets the FTP password.
1308    pub fn password<S: Into<String>>(mut self, password: S) -> Self {
1309        self.password = Some(password.into());
1310        self
1311    }
1312
1313    /// Sets the local file path to upload.
1314    pub fn local_file<P: AsRef<Path>>(mut self, path: P) -> Self {
1315        self.local_file = Some(path.as_ref().to_path_buf());
1316        self
1317    }
1318
1319    /// Sets the remote file path on the FTP server.
1320    pub fn remote_file<S: Into<String>>(mut self, path: S) -> Self {
1321        self.remote_file = Some(path.into());
1322        self
1323    }
1324
1325    /// Sets whether to use passive mode.
1326    pub fn passive_mode(mut self, passive: bool) -> Self {
1327        self.passive_mode = passive;
1328        self
1329    }
1330
1331    /// Sets the connection timeout.
1332    pub fn timeout(mut self, timeout: Duration) -> Self {
1333        self.timeout = timeout;
1334        self
1335    }
1336
1337    /// Sets whether to use FTPS (FTP over TLS) for secure communication.
1338    ///
1339    /// When enabled, the connection will use explicit FTPS (FTP over TLS) for secure
1340    /// file transfers. This is recommended when handling sensitive data.
1341    ///
1342    /// # Arguments
1343    ///
1344    /// * `secure` - true to enable FTPS, false for plain FTP (default: false)
1345    pub fn secure(mut self, secure: bool) -> Self {
1346        self.secure = secure;
1347        self
1348    }
1349
1350    /// Builds the FtpPutTasklet instance.
1351    pub fn build(self) -> Result<FtpPutTasklet, BatchError> {
1352        let host = self
1353            .host
1354            .ok_or_else(|| BatchError::Configuration("FTP host is required".to_string()))?;
1355        let username = self
1356            .username
1357            .ok_or_else(|| BatchError::Configuration("FTP username is required".to_string()))?;
1358        let password = self
1359            .password
1360            .ok_or_else(|| BatchError::Configuration("FTP password is required".to_string()))?;
1361        let local_file = self
1362            .local_file
1363            .ok_or_else(|| BatchError::Configuration("Local file path is required".to_string()))?;
1364        let remote_file = self
1365            .remote_file
1366            .ok_or_else(|| BatchError::Configuration("Remote file path is required".to_string()))?;
1367
1368        let mut tasklet = FtpPutTasklet::new(
1369            &host,
1370            self.port,
1371            &username,
1372            &password,
1373            &local_file,
1374            &remote_file,
1375        )?;
1376
1377        tasklet.set_passive_mode(self.passive_mode);
1378        tasklet.set_timeout(self.timeout);
1379        tasklet.secure = self.secure;
1380
1381        Ok(tasklet)
1382    }
1383}
1384
1385/// Builder for creating FtpGetTasklet instances with a fluent interface.
1386pub struct FtpGetTaskletBuilder {
1387    host: Option<String>,
1388    port: u16,
1389    username: Option<String>,
1390    password: Option<String>,
1391    remote_file: Option<String>,
1392    local_file: Option<PathBuf>,
1393    passive_mode: bool,
1394    timeout: Duration,
1395    secure: bool,
1396}
1397
1398impl Default for FtpGetTaskletBuilder {
1399    fn default() -> Self {
1400        Self::new()
1401    }
1402}
1403
1404impl FtpGetTaskletBuilder {
1405    /// Creates a new FtpGetTaskletBuilder with default settings.
1406    pub fn new() -> Self {
1407        Self {
1408            host: None,
1409            port: 21,
1410            username: None,
1411            password: None,
1412            remote_file: None,
1413            local_file: None,
1414            passive_mode: true,
1415            timeout: Duration::from_secs(30),
1416            secure: false,
1417        }
1418    }
1419
1420    /// Sets the FTP server hostname or IP address.
1421    pub fn host<S: Into<String>>(mut self, host: S) -> Self {
1422        self.host = Some(host.into());
1423        self
1424    }
1425
1426    /// Sets the FTP server port.
1427    pub fn port(mut self, port: u16) -> Self {
1428        self.port = port;
1429        self
1430    }
1431
1432    /// Sets the FTP username.
1433    pub fn username<S: Into<String>>(mut self, username: S) -> Self {
1434        self.username = Some(username.into());
1435        self
1436    }
1437
1438    /// Sets the FTP password.
1439    pub fn password<S: Into<String>>(mut self, password: S) -> Self {
1440        self.password = Some(password.into());
1441        self
1442    }
1443
1444    /// Sets the remote file path on the FTP server.
1445    pub fn remote_file<S: Into<String>>(mut self, path: S) -> Self {
1446        self.remote_file = Some(path.into());
1447        self
1448    }
1449
1450    /// Sets the local file path to save the downloaded file.
1451    pub fn local_file<P: AsRef<Path>>(mut self, path: P) -> Self {
1452        self.local_file = Some(path.as_ref().to_path_buf());
1453        self
1454    }
1455
1456    /// Sets whether to use passive mode.
1457    pub fn passive_mode(mut self, passive: bool) -> Self {
1458        self.passive_mode = passive;
1459        self
1460    }
1461
1462    /// Sets the connection timeout.
1463    pub fn timeout(mut self, timeout: Duration) -> Self {
1464        self.timeout = timeout;
1465        self
1466    }
1467
1468    /// Sets whether to use FTPS (FTP over TLS) for secure communication.
1469    ///
1470    /// When enabled, the connection will use explicit FTPS (FTP over TLS) for secure
1471    /// file transfers. This is recommended when handling sensitive data.
1472    ///
1473    /// # Arguments
1474    ///
1475    /// * `secure` - true to enable FTPS, false for plain FTP (default: false)
1476    pub fn secure(mut self, secure: bool) -> Self {
1477        self.secure = secure;
1478        self
1479    }
1480
1481    /// Builds the FtpGetTasklet instance.
1482    pub fn build(self) -> Result<FtpGetTasklet, BatchError> {
1483        let host = self
1484            .host
1485            .ok_or_else(|| BatchError::Configuration("FTP host is required".to_string()))?;
1486        let username = self
1487            .username
1488            .ok_or_else(|| BatchError::Configuration("FTP username is required".to_string()))?;
1489        let password = self
1490            .password
1491            .ok_or_else(|| BatchError::Configuration("FTP password is required".to_string()))?;
1492        let remote_file = self
1493            .remote_file
1494            .ok_or_else(|| BatchError::Configuration("Remote file path is required".to_string()))?;
1495        let local_file = self
1496            .local_file
1497            .ok_or_else(|| BatchError::Configuration("Local file path is required".to_string()))?;
1498
1499        let mut tasklet = FtpGetTasklet::new(
1500            &host,
1501            self.port,
1502            &username,
1503            &password,
1504            &remote_file,
1505            &local_file,
1506        )?;
1507
1508        tasklet.set_passive_mode(self.passive_mode);
1509        tasklet.set_timeout(self.timeout);
1510        tasklet.secure = self.secure;
1511
1512        Ok(tasklet)
1513    }
1514}
1515
1516/// Builder for creating FtpPutFolderTasklet instances with a fluent interface.
1517pub struct FtpPutFolderTaskletBuilder {
1518    host: Option<String>,
1519    port: u16,
1520    username: Option<String>,
1521    password: Option<String>,
1522    local_folder: Option<PathBuf>,
1523    remote_folder: Option<String>,
1524    passive_mode: bool,
1525    timeout: Duration,
1526    create_directories: bool,
1527    recursive: bool,
1528    secure: bool,
1529}
1530
1531impl Default for FtpPutFolderTaskletBuilder {
1532    fn default() -> Self {
1533        Self::new()
1534    }
1535}
1536
1537impl FtpPutFolderTaskletBuilder {
1538    /// Creates a new FtpPutFolderTaskletBuilder with default settings.
1539    pub fn new() -> Self {
1540        Self {
1541            host: None,
1542            port: 21,
1543            username: None,
1544            password: None,
1545            local_folder: None,
1546            remote_folder: None,
1547            passive_mode: true,
1548            timeout: Duration::from_secs(30),
1549            create_directories: true,
1550            recursive: false,
1551            secure: false,
1552        }
1553    }
1554
1555    /// Sets the FTP server hostname or IP address.
1556    pub fn host<S: Into<String>>(mut self, host: S) -> Self {
1557        self.host = Some(host.into());
1558        self
1559    }
1560
1561    /// Sets the FTP server port.
1562    pub fn port(mut self, port: u16) -> Self {
1563        self.port = port;
1564        self
1565    }
1566
1567    /// Sets the FTP username.
1568    pub fn username<S: Into<String>>(mut self, username: S) -> Self {
1569        self.username = Some(username.into());
1570        self
1571    }
1572
1573    /// Sets the FTP password.
1574    pub fn password<S: Into<String>>(mut self, password: S) -> Self {
1575        self.password = Some(password.into());
1576        self
1577    }
1578
1579    /// Sets the local folder path to upload.
1580    pub fn local_folder<P: AsRef<Path>>(mut self, path: P) -> Self {
1581        self.local_folder = Some(path.as_ref().to_path_buf());
1582        self
1583    }
1584
1585    /// Sets the remote folder path on the FTP server.
1586    pub fn remote_folder<S: Into<String>>(mut self, path: S) -> Self {
1587        self.remote_folder = Some(path.into());
1588        self
1589    }
1590
1591    /// Sets whether to use passive mode.
1592    pub fn passive_mode(mut self, passive: bool) -> Self {
1593        self.passive_mode = passive;
1594        self
1595    }
1596
1597    /// Sets the connection timeout.
1598    pub fn timeout(mut self, timeout: Duration) -> Self {
1599        self.timeout = timeout;
1600        self
1601    }
1602
1603    /// Sets whether to create remote directories if they don't exist.
1604    pub fn create_directories(mut self, create: bool) -> Self {
1605        self.create_directories = create;
1606        self
1607    }
1608
1609    /// Sets whether to upload subdirectories recursively.
1610    pub fn recursive(mut self, recursive: bool) -> Self {
1611        self.recursive = recursive;
1612        self
1613    }
1614
1615    /// Sets whether to use FTPS (FTP over TLS) for secure communication.
1616    ///
1617    /// When enabled, the connection will use explicit FTPS (FTP over TLS) for secure
1618    /// file transfers. This is recommended when handling sensitive data.
1619    ///
1620    /// # Arguments
1621    ///
1622    /// * `secure` - true to enable FTPS, false for plain FTP (default: false)
1623    pub fn secure(mut self, secure: bool) -> Self {
1624        self.secure = secure;
1625        self
1626    }
1627
1628    /// Builds the FtpPutFolderTasklet instance.
1629    pub fn build(self) -> Result<FtpPutFolderTasklet, BatchError> {
1630        let host = self
1631            .host
1632            .ok_or_else(|| BatchError::Configuration("FTP host is required".to_string()))?;
1633        let username = self
1634            .username
1635            .ok_or_else(|| BatchError::Configuration("FTP username is required".to_string()))?;
1636        let password = self
1637            .password
1638            .ok_or_else(|| BatchError::Configuration("FTP password is required".to_string()))?;
1639        let local_folder = self.local_folder.ok_or_else(|| {
1640            BatchError::Configuration("Local folder path is required".to_string())
1641        })?;
1642        let remote_folder = self.remote_folder.ok_or_else(|| {
1643            BatchError::Configuration("Remote folder path is required".to_string())
1644        })?;
1645
1646        let mut tasklet = FtpPutFolderTasklet::new(
1647            &host,
1648            self.port,
1649            &username,
1650            &password,
1651            &local_folder,
1652            &remote_folder,
1653        )?;
1654
1655        tasklet.set_passive_mode(self.passive_mode);
1656        tasklet.set_timeout(self.timeout);
1657        tasklet.set_create_directories(self.create_directories);
1658        tasklet.set_recursive(self.recursive);
1659        tasklet.secure = self.secure;
1660
1661        Ok(tasklet)
1662    }
1663}
1664
1665/// Builder for creating FtpGetFolderTasklet instances with a fluent interface.
1666pub struct FtpGetFolderTaskletBuilder {
1667    host: Option<String>,
1668    port: u16,
1669    username: Option<String>,
1670    password: Option<String>,
1671    remote_folder: Option<String>,
1672    local_folder: Option<PathBuf>,
1673    passive_mode: bool,
1674    timeout: Duration,
1675    create_directories: bool,
1676    recursive: bool,
1677    secure: bool,
1678}
1679
1680impl Default for FtpGetFolderTaskletBuilder {
1681    fn default() -> Self {
1682        Self::new()
1683    }
1684}
1685
1686impl FtpGetFolderTaskletBuilder {
1687    /// Creates a new FtpGetFolderTaskletBuilder with default settings.
1688    pub fn new() -> Self {
1689        Self {
1690            host: None,
1691            port: 21,
1692            username: None,
1693            password: None,
1694            remote_folder: None,
1695            local_folder: None,
1696            passive_mode: true,
1697            timeout: Duration::from_secs(30),
1698            create_directories: true,
1699            recursive: false,
1700            secure: false,
1701        }
1702    }
1703
1704    /// Sets the FTP server hostname or IP address.
1705    pub fn host<S: Into<String>>(mut self, host: S) -> Self {
1706        self.host = Some(host.into());
1707        self
1708    }
1709
1710    /// Sets the FTP server port.
1711    pub fn port(mut self, port: u16) -> Self {
1712        self.port = port;
1713        self
1714    }
1715
1716    /// Sets the FTP username.
1717    pub fn username<S: Into<String>>(mut self, username: S) -> Self {
1718        self.username = Some(username.into());
1719        self
1720    }
1721
1722    /// Sets the FTP password.
1723    pub fn password<S: Into<String>>(mut self, password: S) -> Self {
1724        self.password = Some(password.into());
1725        self
1726    }
1727
1728    /// Sets the remote folder path on the FTP server.
1729    pub fn remote_folder<S: Into<String>>(mut self, path: S) -> Self {
1730        self.remote_folder = Some(path.into());
1731        self
1732    }
1733
1734    /// Sets the local folder path to save the downloaded files.
1735    pub fn local_folder<P: AsRef<Path>>(mut self, path: P) -> Self {
1736        self.local_folder = Some(path.as_ref().to_path_buf());
1737        self
1738    }
1739
1740    /// Sets whether to use passive mode.
1741    pub fn passive_mode(mut self, passive: bool) -> Self {
1742        self.passive_mode = passive;
1743        self
1744    }
1745
1746    /// Sets the connection timeout.
1747    pub fn timeout(mut self, timeout: Duration) -> Self {
1748        self.timeout = timeout;
1749        self
1750    }
1751
1752    /// Sets whether to create local directories if they don't exist.
1753    pub fn create_directories(mut self, create: bool) -> Self {
1754        self.create_directories = create;
1755        self
1756    }
1757
1758    /// Sets whether to download subdirectories recursively.
1759    pub fn recursive(mut self, recursive: bool) -> Self {
1760        self.recursive = recursive;
1761        self
1762    }
1763
1764    /// Sets whether to use FTPS (FTP over TLS) for secure communication.
1765    ///
1766    /// When enabled, the connection will use explicit FTPS (FTP over TLS) for secure
1767    /// file transfers. This is recommended when handling sensitive data.
1768    ///
1769    /// # Arguments
1770    ///
1771    /// * `secure` - true to enable FTPS, false for plain FTP (default: false)
1772    pub fn secure(mut self, secure: bool) -> Self {
1773        self.secure = secure;
1774        self
1775    }
1776
1777    /// Builds the FtpGetFolderTasklet instance.
1778    pub fn build(self) -> Result<FtpGetFolderTasklet, BatchError> {
1779        let host = self
1780            .host
1781            .ok_or_else(|| BatchError::Configuration("FTP host is required".to_string()))?;
1782        let username = self
1783            .username
1784            .ok_or_else(|| BatchError::Configuration("FTP username is required".to_string()))?;
1785        let password = self
1786            .password
1787            .ok_or_else(|| BatchError::Configuration("FTP password is required".to_string()))?;
1788        let remote_folder = self.remote_folder.ok_or_else(|| {
1789            BatchError::Configuration("Remote folder path is required".to_string())
1790        })?;
1791        let local_folder = self.local_folder.ok_or_else(|| {
1792            BatchError::Configuration("Local folder path is required".to_string())
1793        })?;
1794
1795        let mut tasklet = FtpGetFolderTasklet::new(
1796            &host,
1797            self.port,
1798            &username,
1799            &password,
1800            &remote_folder,
1801            &local_folder,
1802        )?;
1803
1804        tasklet.set_passive_mode(self.passive_mode);
1805        tasklet.set_timeout(self.timeout);
1806        tasklet.set_create_directories(self.create_directories);
1807        tasklet.set_recursive(self.recursive);
1808        tasklet.secure = self.secure;
1809
1810        Ok(tasklet)
1811    }
1812}
1813
1814#[cfg(test)]
1815mod tests {
1816    use super::*;
1817    use crate::core::step::StepExecution;
1818    use std::env::temp_dir;
1819    use std::fs;
1820    #[test]
1821    fn should_convert_io_error_to_ftp_error() {
1822        use suppaftp::FtpError;
1823        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1824        let ftp_err = io_error_to_ftp_error(io_err);
1825        assert!(matches!(ftp_err, FtpError::ConnectionError(_)));
1826    }
1827
1828    #[test]
1829    fn test_ftp_put_tasklet_creation() -> Result<(), BatchError> {
1830        let temp_dir = temp_dir();
1831        let test_file = temp_dir.join("test_upload.txt");
1832        fs::write(&test_file, "test content").unwrap();
1833
1834        let tasklet = FtpPutTasklet::new(
1835            "localhost",
1836            21,
1837            "testuser",
1838            "testpass",
1839            &test_file,
1840            "/remote/test.txt",
1841        )?;
1842
1843        assert_eq!(tasklet.host, "localhost");
1844        assert_eq!(tasklet.port, 21);
1845        assert_eq!(tasklet.username, "testuser");
1846        assert_eq!(tasklet.remote_file, "/remote/test.txt");
1847        assert!(tasklet.passive_mode);
1848
1849        fs::remove_file(&test_file).ok();
1850        Ok(())
1851    }
1852
1853    #[test]
1854    fn test_ftp_get_tasklet_creation() -> Result<(), BatchError> {
1855        let temp_dir = temp_dir();
1856        let local_file = temp_dir.join("downloaded.txt");
1857
1858        let tasklet = FtpGetTasklet::new(
1859            "localhost",
1860            21,
1861            "testuser",
1862            "testpass",
1863            "/remote/test.txt",
1864            &local_file,
1865        )?;
1866
1867        assert_eq!(tasklet.host, "localhost");
1868        assert_eq!(tasklet.port, 21);
1869        assert_eq!(tasklet.username, "testuser");
1870        assert_eq!(tasklet.remote_file, "/remote/test.txt");
1871        assert!(tasklet.passive_mode);
1872
1873        Ok(())
1874    }
1875
1876    #[test]
1877    fn test_ftp_put_builder() -> Result<(), BatchError> {
1878        let temp_dir = temp_dir();
1879        let test_file = temp_dir.join("test_builder.txt");
1880        fs::write(&test_file, "test content").unwrap();
1881
1882        let tasklet = FtpPutTaskletBuilder::new()
1883            .host("ftp.example.com")
1884            .port(2121)
1885            .username("user")
1886            .password("pass")
1887            .local_file(&test_file)
1888            .remote_file("/upload/file.txt")
1889            .passive_mode(false)
1890            .timeout(Duration::from_secs(60))
1891            .build()?;
1892
1893        assert_eq!(tasklet.host, "ftp.example.com");
1894        assert_eq!(tasklet.port, 2121);
1895        assert!(!tasklet.passive_mode);
1896        assert_eq!(tasklet.timeout, Duration::from_secs(60));
1897
1898        fs::remove_file(&test_file).ok();
1899        Ok(())
1900    }
1901
1902    #[test]
1903    fn test_ftp_get_builder() -> Result<(), BatchError> {
1904        let temp_dir = temp_dir();
1905        let local_file = temp_dir.join("download_builder.txt");
1906
1907        let tasklet = FtpGetTaskletBuilder::new()
1908            .host("ftp.example.com")
1909            .port(2121)
1910            .username("user")
1911            .password("pass")
1912            .remote_file("/download/file.txt")
1913            .local_file(&local_file)
1914            .passive_mode(false)
1915            .timeout(Duration::from_secs(60))
1916            .build()?;
1917
1918        assert_eq!(tasklet.host, "ftp.example.com");
1919        assert_eq!(tasklet.port, 2121);
1920        assert!(!tasklet.passive_mode);
1921        assert_eq!(tasklet.timeout, Duration::from_secs(60));
1922
1923        Ok(())
1924    }
1925
1926    #[test]
1927    fn test_builder_validation() {
1928        // Test missing host
1929        let result = FtpPutTaskletBuilder::new()
1930            .username("user")
1931            .password("pass")
1932            .build();
1933        assert!(result.is_err());
1934        assert!(
1935            result
1936                .unwrap_err()
1937                .to_string()
1938                .contains("FTP host is required")
1939        );
1940
1941        // Test missing username
1942        let result = FtpGetTaskletBuilder::new()
1943            .host("localhost")
1944            .password("pass")
1945            .build();
1946        assert!(result.is_err());
1947        assert!(
1948            result
1949                .unwrap_err()
1950                .to_string()
1951                .contains("FTP username is required")
1952        );
1953
1954        // Test missing password
1955        let result = FtpPutTaskletBuilder::new()
1956            .host("localhost")
1957            .username("user")
1958            .build();
1959        assert!(result.is_err());
1960        assert!(
1961            result
1962                .unwrap_err()
1963                .to_string()
1964                .contains("FTP password is required")
1965        );
1966
1967        // Test missing local file for PUT
1968        let result = FtpPutTaskletBuilder::new()
1969            .host("localhost")
1970            .username("user")
1971            .password("pass")
1972            .remote_file("/remote/file.txt")
1973            .build();
1974        assert!(result.is_err());
1975        assert!(
1976            result
1977                .unwrap_err()
1978                .to_string()
1979                .contains("Local file path is required")
1980        );
1981
1982        // Test missing remote file for GET
1983        let result = FtpGetTaskletBuilder::new()
1984            .host("localhost")
1985            .username("user")
1986            .password("pass")
1987            .local_file("/local/file.txt")
1988            .build();
1989        assert!(result.is_err());
1990        assert!(
1991            result
1992                .unwrap_err()
1993                .to_string()
1994                .contains("Remote file path is required")
1995        );
1996    }
1997
1998    #[test]
1999    fn test_nonexistent_local_file() {
2000        let result = FtpPutTasklet::new(
2001            "localhost",
2002            21,
2003            "user",
2004            "pass",
2005            "/nonexistent/file.txt",
2006            "/remote/file.txt",
2007        );
2008        assert!(result.is_err());
2009        assert!(
2010            result
2011                .unwrap_err()
2012                .to_string()
2013                .contains("Local file does not exist")
2014        );
2015    }
2016
2017    #[test]
2018    fn test_ftp_put_tasklet_configuration() -> Result<(), BatchError> {
2019        let temp_dir = temp_dir();
2020        let test_file = temp_dir.join("config_test.txt");
2021        fs::write(&test_file, "test content").unwrap();
2022
2023        let mut tasklet = FtpPutTasklet::new(
2024            "localhost",
2025            21,
2026            "user",
2027            "pass",
2028            &test_file,
2029            "/remote/file.txt",
2030        )?;
2031
2032        // Test default values
2033        assert!(tasklet.passive_mode);
2034        assert_eq!(tasklet.timeout, Duration::from_secs(30));
2035
2036        // Test configuration methods
2037        tasklet.set_passive_mode(false);
2038        tasklet.set_timeout(Duration::from_secs(60));
2039
2040        assert!(!tasklet.passive_mode);
2041        assert_eq!(tasklet.timeout, Duration::from_secs(60));
2042
2043        fs::remove_file(&test_file).ok();
2044        Ok(())
2045    }
2046
2047    #[test]
2048    fn test_ftp_get_tasklet_configuration() -> Result<(), BatchError> {
2049        let temp_dir = temp_dir();
2050        let local_file = temp_dir.join("config_test.txt");
2051
2052        let mut tasklet = FtpGetTasklet::new(
2053            "localhost",
2054            21,
2055            "user",
2056            "pass",
2057            "/remote/file.txt",
2058            &local_file,
2059        )?;
2060
2061        // Test default values
2062        assert!(tasklet.passive_mode);
2063        assert_eq!(tasklet.timeout, Duration::from_secs(30));
2064
2065        // Test configuration methods
2066        tasklet.set_passive_mode(false);
2067        tasklet.set_timeout(Duration::from_secs(120));
2068
2069        assert!(!tasklet.passive_mode);
2070        assert_eq!(tasklet.timeout, Duration::from_secs(120));
2071
2072        Ok(())
2073    }
2074
2075    #[test]
2076    fn test_ftp_put_tasklet_execution_with_connection_error() {
2077        let temp_dir = temp_dir();
2078        let test_file = temp_dir.join("connection_error_test.txt");
2079        fs::write(&test_file, "test content").unwrap();
2080
2081        let tasklet = FtpPutTasklet::new(
2082            "nonexistent.host.invalid",
2083            21,
2084            "user",
2085            "pass",
2086            &test_file,
2087            "/remote/file.txt",
2088        )
2089        .unwrap();
2090
2091        let step_execution = StepExecution::new("test-step");
2092        let result = tasklet.execute(&step_execution);
2093
2094        assert!(result.is_err());
2095        let error = result.unwrap_err();
2096        assert!(matches!(error, BatchError::Io(_)));
2097        assert!(
2098            error
2099                .to_string()
2100                .contains("Failed to connect to FTP server")
2101        );
2102
2103        fs::remove_file(&test_file).ok();
2104    }
2105
2106    #[test]
2107    fn test_ftp_get_tasklet_execution_with_connection_error() {
2108        let temp_dir = temp_dir();
2109        let local_file = temp_dir.join("connection_error_test.txt");
2110
2111        let tasklet = FtpGetTasklet::new(
2112            "nonexistent.host.invalid",
2113            21,
2114            "user",
2115            "pass",
2116            "/remote/file.txt",
2117            &local_file,
2118        )
2119        .unwrap();
2120
2121        let step_execution = StepExecution::new("test-step");
2122        let result = tasklet.execute(&step_execution);
2123
2124        assert!(result.is_err());
2125        let error = result.unwrap_err();
2126        assert!(matches!(error, BatchError::Io(_)));
2127        assert!(
2128            error
2129                .to_string()
2130                .contains("Failed to connect to FTP server")
2131        );
2132    }
2133
2134    #[test]
2135    fn test_ftp_put_tasklet_secure_execution_with_connection_error() {
2136        let temp_dir = temp_dir();
2137        let test_file = temp_dir.join("secure_conn_error_test.txt");
2138        fs::write(&test_file, "test content").unwrap();
2139
2140        let tasklet = FtpPutTaskletBuilder::new()
2141            .host("nonexistent.host.invalid")
2142            .port(990)
2143            .username("user")
2144            .password("pass")
2145            .local_file(&test_file)
2146            .remote_file("/secure/file.txt")
2147            .secure(true)
2148            .build()
2149            .unwrap();
2150
2151        let step_execution = StepExecution::new("test-step");
2152        let result = tasklet.execute(&step_execution);
2153        assert!(result.is_err());
2154        assert!(matches!(result.unwrap_err(), BatchError::Io(_)));
2155
2156        fs::remove_file(&test_file).ok();
2157    }
2158
2159    #[test]
2160    fn test_ftp_get_tasklet_secure_execution_with_connection_error() {
2161        let temp_dir = temp_dir();
2162        let local_file = temp_dir.join("secure_get_conn_error_test.txt");
2163
2164        let tasklet = FtpGetTaskletBuilder::new()
2165            .host("nonexistent.host.invalid")
2166            .port(990)
2167            .username("user")
2168            .password("pass")
2169            .remote_file("/secure/file.txt")
2170            .local_file(&local_file)
2171            .secure(true)
2172            .build()
2173            .unwrap();
2174
2175        let step_execution = StepExecution::new("test-step");
2176        let result = tasklet.execute(&step_execution);
2177        assert!(result.is_err());
2178        assert!(matches!(result.unwrap_err(), BatchError::Io(_)));
2179    }
2180
2181    #[test]
2182    fn test_ftp_put_folder_tasklet_creation() -> Result<(), BatchError> {
2183        let temp_dir = temp_dir();
2184        let test_folder = temp_dir.join("test_upload_folder");
2185        fs::create_dir_all(&test_folder).unwrap();
2186        fs::write(test_folder.join("file1.txt"), "content1").unwrap();
2187        fs::write(test_folder.join("file2.txt"), "content2").unwrap();
2188
2189        let tasklet = FtpPutFolderTasklet::new(
2190            "localhost",
2191            21,
2192            "testuser",
2193            "testpass",
2194            &test_folder,
2195            "/remote/folder",
2196        )?;
2197
2198        assert_eq!(tasklet.host, "localhost");
2199        assert_eq!(tasklet.port, 21);
2200        assert_eq!(tasklet.username, "testuser");
2201        assert_eq!(tasklet.remote_folder, "/remote/folder");
2202        assert!(tasklet.passive_mode);
2203        assert!(tasklet.create_directories);
2204        assert!(!tasklet.recursive);
2205
2206        fs::remove_dir_all(&test_folder).ok();
2207        Ok(())
2208    }
2209
2210    #[test]
2211    fn test_ftp_get_folder_tasklet_creation() -> Result<(), BatchError> {
2212        let temp_dir = temp_dir();
2213        let local_folder = temp_dir.join("download_folder");
2214
2215        let tasklet = FtpGetFolderTasklet::new(
2216            "localhost",
2217            21,
2218            "testuser",
2219            "testpass",
2220            "/remote/folder",
2221            &local_folder,
2222        )?;
2223
2224        assert_eq!(tasklet.host, "localhost");
2225        assert_eq!(tasklet.port, 21);
2226        assert_eq!(tasklet.username, "testuser");
2227        assert_eq!(tasklet.remote_folder, "/remote/folder");
2228        assert!(tasklet.passive_mode);
2229        assert!(tasklet.create_directories);
2230        assert!(!tasklet.recursive);
2231
2232        Ok(())
2233    }
2234
2235    #[test]
2236    fn test_ftp_put_folder_builder() -> Result<(), BatchError> {
2237        let temp_dir = temp_dir();
2238        let test_folder = temp_dir.join("test_builder_folder");
2239        fs::create_dir_all(&test_folder).unwrap();
2240        fs::write(test_folder.join("file.txt"), "content").unwrap();
2241
2242        let tasklet = FtpPutFolderTaskletBuilder::new()
2243            .host("ftp.example.com")
2244            .port(2121)
2245            .username("user")
2246            .password("pass")
2247            .local_folder(&test_folder)
2248            .remote_folder("/upload/folder")
2249            .passive_mode(false)
2250            .timeout(Duration::from_secs(60))
2251            .create_directories(false)
2252            .recursive(true)
2253            .build()?;
2254
2255        assert_eq!(tasklet.host, "ftp.example.com");
2256        assert_eq!(tasklet.port, 2121);
2257        assert!(!tasklet.passive_mode);
2258        assert_eq!(tasklet.timeout, Duration::from_secs(60));
2259        assert!(!tasklet.create_directories);
2260        assert!(tasklet.recursive);
2261
2262        fs::remove_dir_all(&test_folder).ok();
2263        Ok(())
2264    }
2265
2266    #[test]
2267    fn test_ftp_get_folder_builder() -> Result<(), BatchError> {
2268        let temp_dir = temp_dir();
2269        let local_folder = temp_dir.join("download_builder_folder");
2270
2271        let tasklet = FtpGetFolderTaskletBuilder::new()
2272            .host("ftp.example.com")
2273            .port(2121)
2274            .username("user")
2275            .password("pass")
2276            .remote_folder("/download/folder")
2277            .local_folder(&local_folder)
2278            .passive_mode(false)
2279            .timeout(Duration::from_secs(60))
2280            .create_directories(false)
2281            .recursive(true)
2282            .build()?;
2283
2284        assert_eq!(tasklet.host, "ftp.example.com");
2285        assert_eq!(tasklet.port, 2121);
2286        assert!(!tasklet.passive_mode);
2287        assert_eq!(tasklet.timeout, Duration::from_secs(60));
2288        assert!(!tasklet.create_directories);
2289        assert!(tasklet.recursive);
2290
2291        Ok(())
2292    }
2293
2294    #[test]
2295    fn test_folder_builder_validation() {
2296        // Test missing host for folder upload
2297        let result = FtpPutFolderTaskletBuilder::new()
2298            .username("user")
2299            .password("pass")
2300            .build();
2301        assert!(result.is_err());
2302        assert!(
2303            result
2304                .unwrap_err()
2305                .to_string()
2306                .contains("FTP host is required")
2307        );
2308
2309        // Test missing username for folder download
2310        let result = FtpGetFolderTaskletBuilder::new()
2311            .host("localhost")
2312            .password("pass")
2313            .build();
2314        assert!(result.is_err());
2315        assert!(
2316            result
2317                .unwrap_err()
2318                .to_string()
2319                .contains("FTP username is required")
2320        );
2321
2322        // Test missing local folder for PUT
2323        let result = FtpPutFolderTaskletBuilder::new()
2324            .host("localhost")
2325            .username("user")
2326            .password("pass")
2327            .remote_folder("/remote/folder")
2328            .build();
2329        assert!(result.is_err());
2330        assert!(
2331            result
2332                .unwrap_err()
2333                .to_string()
2334                .contains("Local folder path is required")
2335        );
2336
2337        // Test missing remote folder for GET
2338        let result = FtpGetFolderTaskletBuilder::new()
2339            .host("localhost")
2340            .username("user")
2341            .password("pass")
2342            .local_folder("/local/folder")
2343            .build();
2344        assert!(result.is_err());
2345        assert!(
2346            result
2347                .unwrap_err()
2348                .to_string()
2349                .contains("Remote folder path is required")
2350        );
2351    }
2352
2353    #[test]
2354    fn test_nonexistent_local_folder() {
2355        let result = FtpPutFolderTasklet::new(
2356            "localhost",
2357            21,
2358            "user",
2359            "pass",
2360            "/nonexistent/folder",
2361            "/remote/folder",
2362        );
2363        assert!(result.is_err());
2364        assert!(
2365            result
2366                .unwrap_err()
2367                .to_string()
2368                .contains("Local folder does not exist")
2369        );
2370    }
2371
2372    #[test]
2373    fn test_local_file_not_directory() {
2374        let temp_dir = temp_dir();
2375        let test_file = temp_dir.join("not_a_directory.txt");
2376        fs::write(&test_file, "content").unwrap();
2377
2378        let result = FtpPutFolderTasklet::new(
2379            "localhost",
2380            21,
2381            "user",
2382            "pass",
2383            &test_file,
2384            "/remote/folder",
2385        );
2386        assert!(result.is_err());
2387        assert!(
2388            result
2389                .unwrap_err()
2390                .to_string()
2391                .contains("Local path is not a directory")
2392        );
2393
2394        fs::remove_file(&test_file).ok();
2395    }
2396
2397    #[test]
2398    fn test_ftp_put_folder_tasklet_configuration() -> Result<(), BatchError> {
2399        let temp_dir = temp_dir();
2400        let test_folder = temp_dir.join("config_folder_test");
2401        fs::create_dir_all(&test_folder).unwrap();
2402        fs::write(test_folder.join("file.txt"), "content").unwrap();
2403
2404        let mut tasklet = FtpPutFolderTasklet::new(
2405            "localhost",
2406            21,
2407            "user",
2408            "pass",
2409            &test_folder,
2410            "/remote/folder",
2411        )?;
2412
2413        // Test default values
2414        assert!(tasklet.passive_mode);
2415        assert_eq!(tasklet.timeout, Duration::from_secs(30));
2416        assert!(tasklet.create_directories);
2417        assert!(!tasklet.recursive);
2418
2419        // Test configuration methods
2420        tasklet.set_passive_mode(false);
2421        tasklet.set_timeout(Duration::from_secs(90));
2422        tasklet.set_create_directories(false);
2423        tasklet.set_recursive(true);
2424
2425        assert!(!tasklet.passive_mode);
2426        assert_eq!(tasklet.timeout, Duration::from_secs(90));
2427        assert!(!tasklet.create_directories);
2428        assert!(tasklet.recursive);
2429
2430        fs::remove_dir_all(&test_folder).ok();
2431        Ok(())
2432    }
2433
2434    #[test]
2435    fn test_ftp_get_folder_tasklet_configuration() -> Result<(), BatchError> {
2436        let temp_dir = temp_dir();
2437        let local_folder = temp_dir.join("config_folder_test");
2438
2439        let mut tasklet = FtpGetFolderTasklet::new(
2440            "localhost",
2441            21,
2442            "user",
2443            "pass",
2444            "/remote/folder",
2445            &local_folder,
2446        )?;
2447
2448        // Test default values
2449        assert!(tasklet.passive_mode);
2450        assert_eq!(tasklet.timeout, Duration::from_secs(30));
2451        assert!(tasklet.create_directories);
2452        assert!(!tasklet.recursive);
2453
2454        // Test configuration methods
2455        tasklet.set_passive_mode(false);
2456        tasklet.set_timeout(Duration::from_secs(180));
2457        tasklet.set_create_directories(false);
2458        tasklet.set_recursive(true);
2459
2460        assert!(!tasklet.passive_mode);
2461        assert_eq!(tasklet.timeout, Duration::from_secs(180));
2462        assert!(!tasklet.create_directories);
2463        assert!(tasklet.recursive);
2464
2465        Ok(())
2466    }
2467
2468    #[test]
2469    fn test_ftp_put_folder_tasklet_execution_with_connection_error() {
2470        let temp_dir = temp_dir();
2471        let test_folder = temp_dir.join("connection_error_folder_test");
2472        fs::create_dir_all(&test_folder).unwrap();
2473        fs::write(test_folder.join("file.txt"), "content").unwrap();
2474
2475        let tasklet = FtpPutFolderTasklet::new(
2476            "nonexistent.host.invalid",
2477            21,
2478            "user",
2479            "pass",
2480            &test_folder,
2481            "/remote/folder",
2482        )
2483        .unwrap();
2484
2485        let step_execution = StepExecution::new("test-step");
2486        let result = tasklet.execute(&step_execution);
2487
2488        assert!(result.is_err());
2489        let error = result.unwrap_err();
2490        assert!(matches!(error, BatchError::Io(_)));
2491        assert!(
2492            error
2493                .to_string()
2494                .contains("Failed to connect to FTP server")
2495        );
2496
2497        fs::remove_dir_all(&test_folder).ok();
2498    }
2499
2500    #[test]
2501    fn test_ftp_get_folder_tasklet_execution_with_connection_error() {
2502        let temp_dir = temp_dir();
2503        let local_folder = temp_dir.join("connection_error_folder_test");
2504
2505        let tasklet = FtpGetFolderTasklet::new(
2506            "nonexistent.host.invalid",
2507            21,
2508            "user",
2509            "pass",
2510            "/remote/folder",
2511            &local_folder,
2512        )
2513        .unwrap();
2514
2515        let step_execution = StepExecution::new("test-step");
2516        let result = tasklet.execute(&step_execution);
2517
2518        assert!(result.is_err());
2519        let error = result.unwrap_err();
2520        assert!(matches!(error, BatchError::Io(_)));
2521        assert!(
2522            error
2523                .to_string()
2524                .contains("Failed to connect to FTP server")
2525        );
2526    }
2527
2528    #[test]
2529    fn test_builder_default_implementations() {
2530        // Test that all builders implement Default
2531        let _put_builder = FtpPutTaskletBuilder::default();
2532        let _get_builder = FtpGetTaskletBuilder::default();
2533        let _put_folder_builder = FtpPutFolderTaskletBuilder::default();
2534        let _get_folder_builder = FtpGetFolderTaskletBuilder::default();
2535    }
2536
2537    #[test]
2538    fn test_builder_fluent_interface() -> Result<(), BatchError> {
2539        let temp_dir = temp_dir();
2540        let test_file = temp_dir.join("fluent_test.txt");
2541        fs::write(&test_file, "test content").unwrap();
2542
2543        // Test method chaining works correctly
2544        let tasklet = FtpPutTaskletBuilder::new()
2545            .host("example.com")
2546            .port(2121)
2547            .username("testuser")
2548            .password("testpass")
2549            .local_file(&test_file)
2550            .remote_file("/remote/test.txt")
2551            .passive_mode(true)
2552            .timeout(Duration::from_secs(45))
2553            .build()?;
2554
2555        assert_eq!(tasklet.host, "example.com");
2556        assert_eq!(tasklet.port, 2121);
2557        assert_eq!(tasklet.username, "testuser");
2558        assert_eq!(tasklet.password, "testpass");
2559        assert_eq!(tasklet.remote_file, "/remote/test.txt");
2560        assert!(tasklet.passive_mode);
2561        assert_eq!(tasklet.timeout, Duration::from_secs(45));
2562
2563        fs::remove_file(&test_file).ok();
2564        Ok(())
2565    }
2566
2567    #[test]
2568    fn test_error_message_quality() {
2569        // Test that error messages are descriptive and helpful
2570        let result = FtpPutTaskletBuilder::new().build();
2571        assert!(result.is_err());
2572        let error_msg = result.unwrap_err().to_string();
2573        assert!(error_msg.contains("FTP host is required"));
2574
2575        let result = FtpPutTaskletBuilder::new().host("localhost").build();
2576        assert!(result.is_err());
2577        let error_msg = result.unwrap_err().to_string();
2578        assert!(error_msg.contains("FTP username is required"));
2579    }
2580
2581    #[test]
2582    fn test_path_handling() -> Result<(), BatchError> {
2583        let temp_dir = temp_dir();
2584        let test_file = temp_dir.join("path_test.txt");
2585        fs::write(&test_file, "test content").unwrap();
2586
2587        // Test that different path types work
2588        let tasklet1 = FtpPutTasklet::new(
2589            "localhost",
2590            21,
2591            "user",
2592            "pass",
2593            &test_file,
2594            "/remote/file.txt",
2595        )?;
2596
2597        let tasklet2 = FtpPutTasklet::new(
2598            "localhost",
2599            21,
2600            "user",
2601            "pass",
2602            test_file.as_path(),
2603            "/remote/file.txt",
2604        )?;
2605
2606        assert_eq!(tasklet1.local_file, tasklet2.local_file);
2607
2608        fs::remove_file(&test_file).ok();
2609        Ok(())
2610    }
2611
2612    #[test]
2613    fn test_timeout_configuration() -> Result<(), BatchError> {
2614        let temp_dir = temp_dir();
2615        let test_file = temp_dir.join("timeout_test.txt");
2616        fs::write(&test_file, "test content").unwrap();
2617
2618        // Test various timeout values
2619        let tasklet = FtpPutTaskletBuilder::new()
2620            .host("localhost")
2621            .username("user")
2622            .password("pass")
2623            .local_file(&test_file)
2624            .remote_file("/remote/file.txt")
2625            .timeout(Duration::from_millis(500))
2626            .build()?;
2627
2628        assert_eq!(tasklet.timeout, Duration::from_millis(500));
2629
2630        let tasklet = FtpPutTaskletBuilder::new()
2631            .host("localhost")
2632            .username("user")
2633            .password("pass")
2634            .local_file(&test_file)
2635            .remote_file("/remote/file.txt")
2636            .timeout(Duration::from_secs(300))
2637            .build()?;
2638
2639        assert_eq!(tasklet.timeout, Duration::from_secs(300));
2640
2641        fs::remove_file(&test_file).ok();
2642        Ok(())
2643    }
2644
2645    #[test]
2646    fn test_port_configuration() -> Result<(), BatchError> {
2647        let temp_dir = temp_dir();
2648        let test_file = temp_dir.join("port_test.txt");
2649        fs::write(&test_file, "test content").unwrap();
2650
2651        // Test various port values
2652        let tasklet = FtpPutTaskletBuilder::new()
2653            .host("localhost")
2654            .port(990) // FTPS port
2655            .username("user")
2656            .password("pass")
2657            .local_file(&test_file)
2658            .remote_file("/remote/file.txt")
2659            .build()?;
2660
2661        assert_eq!(tasklet.port, 990);
2662
2663        let tasklet = FtpPutTaskletBuilder::new()
2664            .host("localhost")
2665            .port(2121) // Alternative FTP port
2666            .username("user")
2667            .password("pass")
2668            .local_file(&test_file)
2669            .remote_file("/remote/file.txt")
2670            .build()?;
2671
2672        assert_eq!(tasklet.port, 2121);
2673
2674        fs::remove_file(&test_file).ok();
2675        Ok(())
2676    }
2677
2678    #[test]
2679    fn test_passive_mode_configuration() -> Result<(), BatchError> {
2680        let temp_dir = temp_dir();
2681        let test_file = temp_dir.join("passive_test.txt");
2682        fs::write(&test_file, "test content").unwrap();
2683
2684        // Test passive mode true
2685        let tasklet = FtpPutTaskletBuilder::new()
2686            .host("localhost")
2687            .username("user")
2688            .password("pass")
2689            .local_file(&test_file)
2690            .remote_file("/remote/file.txt")
2691            .passive_mode(true)
2692            .build()?;
2693
2694        assert!(tasklet.passive_mode);
2695
2696        // Test passive mode false (active mode)
2697        let tasklet = FtpPutTaskletBuilder::new()
2698            .host("localhost")
2699            .username("user")
2700            .password("pass")
2701            .local_file(&test_file)
2702            .remote_file("/remote/file.txt")
2703            .passive_mode(false)
2704            .build()?;
2705
2706        assert!(!tasklet.passive_mode);
2707
2708        fs::remove_file(&test_file).ok();
2709        Ok(())
2710    }
2711
2712    #[test]
2713    fn test_secure_ftp_configuration() -> Result<(), BatchError> {
2714        let temp_dir = temp_dir();
2715        let test_file = temp_dir.join("secure_test.txt");
2716        fs::write(&test_file, "test content").unwrap();
2717
2718        // Test secure mode disabled (default)
2719        let tasklet = FtpPutTaskletBuilder::new()
2720            .host("localhost")
2721            .username("user")
2722            .password("pass")
2723            .local_file(&test_file)
2724            .remote_file("/remote/file.txt")
2725            .build()?;
2726
2727        assert!(!tasklet.secure);
2728
2729        // Test secure mode enabled (FTPS)
2730        let tasklet = FtpPutTaskletBuilder::new()
2731            .host("secure-ftp.example.com")
2732            .port(990)
2733            .username("user")
2734            .password("pass")
2735            .local_file(&test_file)
2736            .remote_file("/secure/file.txt")
2737            .secure(true)
2738            .build()?;
2739
2740        assert!(tasklet.secure);
2741        assert_eq!(tasklet.port, 990);
2742
2743        // Test secure mode with FtpGetTasklet
2744        let local_file = temp_dir.join("downloaded_secure.txt");
2745        let get_tasklet = FtpGetTaskletBuilder::new()
2746            .host("secure-ftp.example.com")
2747            .port(990)
2748            .username("user")
2749            .password("pass")
2750            .remote_file("/secure/file.txt")
2751            .local_file(&local_file)
2752            .secure(true)
2753            .build()?;
2754
2755        assert!(get_tasklet.secure);
2756        assert_eq!(get_tasklet.port, 990);
2757
2758        fs::remove_file(&test_file).ok();
2759        Ok(())
2760    }
2761
2762    #[test]
2763    fn test_secure_ftp_folder_configuration() -> Result<(), BatchError> {
2764        let temp_dir = temp_dir();
2765        let test_folder = temp_dir.join("secure_folder_test");
2766        fs::create_dir_all(&test_folder).unwrap();
2767        fs::write(test_folder.join("file.txt"), "test content").unwrap();
2768
2769        // Test secure mode disabled (default) for folder upload
2770        let tasklet = FtpPutFolderTaskletBuilder::new()
2771            .host("localhost")
2772            .username("user")
2773            .password("pass")
2774            .local_folder(&test_folder)
2775            .remote_folder("/remote/folder")
2776            .build()?;
2777
2778        assert!(!tasklet.secure);
2779
2780        // Test secure mode enabled (FTPS) for folder upload
2781        let tasklet = FtpPutFolderTaskletBuilder::new()
2782            .host("secure-ftp.example.com")
2783            .port(990)
2784            .username("user")
2785            .password("pass")
2786            .local_folder(&test_folder)
2787            .remote_folder("/secure/folder")
2788            .secure(true)
2789            .build()?;
2790
2791        assert!(tasklet.secure);
2792        assert_eq!(tasklet.port, 990);
2793
2794        // Test secure mode with FtpGetFolderTasklet
2795        let local_folder = temp_dir.join("downloaded_secure_folder");
2796        let get_tasklet = FtpGetFolderTaskletBuilder::new()
2797            .host("secure-ftp.example.com")
2798            .port(990)
2799            .username("user")
2800            .password("pass")
2801            .remote_folder("/secure/folder")
2802            .local_folder(&local_folder)
2803            .secure(true)
2804            .build()?;
2805
2806        assert!(get_tasklet.secure);
2807        assert_eq!(get_tasklet.port, 990);
2808
2809        fs::remove_dir_all(&test_folder).ok();
2810        Ok(())
2811    }
2812}