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    core::step::{RepeatStatus, StepExecution, Tasklet},
114    BatchError,
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            if !parent.exists() {
488                std::fs::create_dir_all(parent).map_err(BatchError::Io)?;
489            }
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                        if let Some(parent) = local_path.parent() {
1045                            fs::create_dir_all(parent).map_err(BatchError::Io)?;
1046                        }
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                        if let Some(parent) = local_path.parent() {
1138                            fs::create_dir_all(parent).map_err(BatchError::Io)?;
1139                        }
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!(result
1935            .unwrap_err()
1936            .to_string()
1937            .contains("FTP host is required"));
1938
1939        // Test missing username
1940        let result = FtpGetTaskletBuilder::new()
1941            .host("localhost")
1942            .password("pass")
1943            .build();
1944        assert!(result.is_err());
1945        assert!(result
1946            .unwrap_err()
1947            .to_string()
1948            .contains("FTP username is required"));
1949
1950        // Test missing password
1951        let result = FtpPutTaskletBuilder::new()
1952            .host("localhost")
1953            .username("user")
1954            .build();
1955        assert!(result.is_err());
1956        assert!(result
1957            .unwrap_err()
1958            .to_string()
1959            .contains("FTP password is required"));
1960
1961        // Test missing local file for PUT
1962        let result = FtpPutTaskletBuilder::new()
1963            .host("localhost")
1964            .username("user")
1965            .password("pass")
1966            .remote_file("/remote/file.txt")
1967            .build();
1968        assert!(result.is_err());
1969        assert!(result
1970            .unwrap_err()
1971            .to_string()
1972            .contains("Local file path is required"));
1973
1974        // Test missing remote file for GET
1975        let result = FtpGetTaskletBuilder::new()
1976            .host("localhost")
1977            .username("user")
1978            .password("pass")
1979            .local_file("/local/file.txt")
1980            .build();
1981        assert!(result.is_err());
1982        assert!(result
1983            .unwrap_err()
1984            .to_string()
1985            .contains("Remote file path is required"));
1986    }
1987
1988    #[test]
1989    fn test_nonexistent_local_file() {
1990        let result = FtpPutTasklet::new(
1991            "localhost",
1992            21,
1993            "user",
1994            "pass",
1995            "/nonexistent/file.txt",
1996            "/remote/file.txt",
1997        );
1998        assert!(result.is_err());
1999        assert!(result
2000            .unwrap_err()
2001            .to_string()
2002            .contains("Local file does not exist"));
2003    }
2004
2005    #[test]
2006    fn test_ftp_put_tasklet_configuration() -> Result<(), BatchError> {
2007        let temp_dir = temp_dir();
2008        let test_file = temp_dir.join("config_test.txt");
2009        fs::write(&test_file, "test content").unwrap();
2010
2011        let mut tasklet = FtpPutTasklet::new(
2012            "localhost",
2013            21,
2014            "user",
2015            "pass",
2016            &test_file,
2017            "/remote/file.txt",
2018        )?;
2019
2020        // Test default values
2021        assert!(tasklet.passive_mode);
2022        assert_eq!(tasklet.timeout, Duration::from_secs(30));
2023
2024        // Test configuration methods
2025        tasklet.set_passive_mode(false);
2026        tasklet.set_timeout(Duration::from_secs(60));
2027
2028        assert!(!tasklet.passive_mode);
2029        assert_eq!(tasklet.timeout, Duration::from_secs(60));
2030
2031        fs::remove_file(&test_file).ok();
2032        Ok(())
2033    }
2034
2035    #[test]
2036    fn test_ftp_get_tasklet_configuration() -> Result<(), BatchError> {
2037        let temp_dir = temp_dir();
2038        let local_file = temp_dir.join("config_test.txt");
2039
2040        let mut tasklet = FtpGetTasklet::new(
2041            "localhost",
2042            21,
2043            "user",
2044            "pass",
2045            "/remote/file.txt",
2046            &local_file,
2047        )?;
2048
2049        // Test default values
2050        assert!(tasklet.passive_mode);
2051        assert_eq!(tasklet.timeout, Duration::from_secs(30));
2052
2053        // Test configuration methods
2054        tasklet.set_passive_mode(false);
2055        tasklet.set_timeout(Duration::from_secs(120));
2056
2057        assert!(!tasklet.passive_mode);
2058        assert_eq!(tasklet.timeout, Duration::from_secs(120));
2059
2060        Ok(())
2061    }
2062
2063    #[test]
2064    fn test_ftp_put_tasklet_execution_with_connection_error() {
2065        let temp_dir = temp_dir();
2066        let test_file = temp_dir.join("connection_error_test.txt");
2067        fs::write(&test_file, "test content").unwrap();
2068
2069        let tasklet = FtpPutTasklet::new(
2070            "nonexistent.host.invalid",
2071            21,
2072            "user",
2073            "pass",
2074            &test_file,
2075            "/remote/file.txt",
2076        )
2077        .unwrap();
2078
2079        let step_execution = StepExecution::new("test-step");
2080        let result = tasklet.execute(&step_execution);
2081
2082        assert!(result.is_err());
2083        let error = result.unwrap_err();
2084        assert!(matches!(error, BatchError::Io(_)));
2085        assert!(error
2086            .to_string()
2087            .contains("Failed to connect to FTP server"));
2088
2089        fs::remove_file(&test_file).ok();
2090    }
2091
2092    #[test]
2093    fn test_ftp_get_tasklet_execution_with_connection_error() {
2094        let temp_dir = temp_dir();
2095        let local_file = temp_dir.join("connection_error_test.txt");
2096
2097        let tasklet = FtpGetTasklet::new(
2098            "nonexistent.host.invalid",
2099            21,
2100            "user",
2101            "pass",
2102            "/remote/file.txt",
2103            &local_file,
2104        )
2105        .unwrap();
2106
2107        let step_execution = StepExecution::new("test-step");
2108        let result = tasklet.execute(&step_execution);
2109
2110        assert!(result.is_err());
2111        let error = result.unwrap_err();
2112        assert!(matches!(error, BatchError::Io(_)));
2113        assert!(error
2114            .to_string()
2115            .contains("Failed to connect to FTP server"));
2116    }
2117
2118    #[test]
2119    fn test_ftp_put_tasklet_secure_execution_with_connection_error() {
2120        let temp_dir = temp_dir();
2121        let test_file = temp_dir.join("secure_conn_error_test.txt");
2122        fs::write(&test_file, "test content").unwrap();
2123
2124        let tasklet = FtpPutTaskletBuilder::new()
2125            .host("nonexistent.host.invalid")
2126            .port(990)
2127            .username("user")
2128            .password("pass")
2129            .local_file(&test_file)
2130            .remote_file("/secure/file.txt")
2131            .secure(true)
2132            .build()
2133            .unwrap();
2134
2135        let step_execution = StepExecution::new("test-step");
2136        let result = tasklet.execute(&step_execution);
2137        assert!(result.is_err());
2138        assert!(matches!(result.unwrap_err(), BatchError::Io(_)));
2139
2140        fs::remove_file(&test_file).ok();
2141    }
2142
2143    #[test]
2144    fn test_ftp_get_tasklet_secure_execution_with_connection_error() {
2145        let temp_dir = temp_dir();
2146        let local_file = temp_dir.join("secure_get_conn_error_test.txt");
2147
2148        let tasklet = FtpGetTaskletBuilder::new()
2149            .host("nonexistent.host.invalid")
2150            .port(990)
2151            .username("user")
2152            .password("pass")
2153            .remote_file("/secure/file.txt")
2154            .local_file(&local_file)
2155            .secure(true)
2156            .build()
2157            .unwrap();
2158
2159        let step_execution = StepExecution::new("test-step");
2160        let result = tasklet.execute(&step_execution);
2161        assert!(result.is_err());
2162        assert!(matches!(result.unwrap_err(), BatchError::Io(_)));
2163    }
2164
2165    #[test]
2166    fn test_ftp_put_folder_tasklet_creation() -> Result<(), BatchError> {
2167        let temp_dir = temp_dir();
2168        let test_folder = temp_dir.join("test_upload_folder");
2169        fs::create_dir_all(&test_folder).unwrap();
2170        fs::write(test_folder.join("file1.txt"), "content1").unwrap();
2171        fs::write(test_folder.join("file2.txt"), "content2").unwrap();
2172
2173        let tasklet = FtpPutFolderTasklet::new(
2174            "localhost",
2175            21,
2176            "testuser",
2177            "testpass",
2178            &test_folder,
2179            "/remote/folder",
2180        )?;
2181
2182        assert_eq!(tasklet.host, "localhost");
2183        assert_eq!(tasklet.port, 21);
2184        assert_eq!(tasklet.username, "testuser");
2185        assert_eq!(tasklet.remote_folder, "/remote/folder");
2186        assert!(tasklet.passive_mode);
2187        assert!(tasklet.create_directories);
2188        assert!(!tasklet.recursive);
2189
2190        fs::remove_dir_all(&test_folder).ok();
2191        Ok(())
2192    }
2193
2194    #[test]
2195    fn test_ftp_get_folder_tasklet_creation() -> Result<(), BatchError> {
2196        let temp_dir = temp_dir();
2197        let local_folder = temp_dir.join("download_folder");
2198
2199        let tasklet = FtpGetFolderTasklet::new(
2200            "localhost",
2201            21,
2202            "testuser",
2203            "testpass",
2204            "/remote/folder",
2205            &local_folder,
2206        )?;
2207
2208        assert_eq!(tasklet.host, "localhost");
2209        assert_eq!(tasklet.port, 21);
2210        assert_eq!(tasklet.username, "testuser");
2211        assert_eq!(tasklet.remote_folder, "/remote/folder");
2212        assert!(tasklet.passive_mode);
2213        assert!(tasklet.create_directories);
2214        assert!(!tasklet.recursive);
2215
2216        Ok(())
2217    }
2218
2219    #[test]
2220    fn test_ftp_put_folder_builder() -> Result<(), BatchError> {
2221        let temp_dir = temp_dir();
2222        let test_folder = temp_dir.join("test_builder_folder");
2223        fs::create_dir_all(&test_folder).unwrap();
2224        fs::write(test_folder.join("file.txt"), "content").unwrap();
2225
2226        let tasklet = FtpPutFolderTaskletBuilder::new()
2227            .host("ftp.example.com")
2228            .port(2121)
2229            .username("user")
2230            .password("pass")
2231            .local_folder(&test_folder)
2232            .remote_folder("/upload/folder")
2233            .passive_mode(false)
2234            .timeout(Duration::from_secs(60))
2235            .create_directories(false)
2236            .recursive(true)
2237            .build()?;
2238
2239        assert_eq!(tasklet.host, "ftp.example.com");
2240        assert_eq!(tasklet.port, 2121);
2241        assert!(!tasklet.passive_mode);
2242        assert_eq!(tasklet.timeout, Duration::from_secs(60));
2243        assert!(!tasklet.create_directories);
2244        assert!(tasklet.recursive);
2245
2246        fs::remove_dir_all(&test_folder).ok();
2247        Ok(())
2248    }
2249
2250    #[test]
2251    fn test_ftp_get_folder_builder() -> Result<(), BatchError> {
2252        let temp_dir = temp_dir();
2253        let local_folder = temp_dir.join("download_builder_folder");
2254
2255        let tasklet = FtpGetFolderTaskletBuilder::new()
2256            .host("ftp.example.com")
2257            .port(2121)
2258            .username("user")
2259            .password("pass")
2260            .remote_folder("/download/folder")
2261            .local_folder(&local_folder)
2262            .passive_mode(false)
2263            .timeout(Duration::from_secs(60))
2264            .create_directories(false)
2265            .recursive(true)
2266            .build()?;
2267
2268        assert_eq!(tasklet.host, "ftp.example.com");
2269        assert_eq!(tasklet.port, 2121);
2270        assert!(!tasklet.passive_mode);
2271        assert_eq!(tasklet.timeout, Duration::from_secs(60));
2272        assert!(!tasklet.create_directories);
2273        assert!(tasklet.recursive);
2274
2275        Ok(())
2276    }
2277
2278    #[test]
2279    fn test_folder_builder_validation() {
2280        // Test missing host for folder upload
2281        let result = FtpPutFolderTaskletBuilder::new()
2282            .username("user")
2283            .password("pass")
2284            .build();
2285        assert!(result.is_err());
2286        assert!(result
2287            .unwrap_err()
2288            .to_string()
2289            .contains("FTP host is required"));
2290
2291        // Test missing username for folder download
2292        let result = FtpGetFolderTaskletBuilder::new()
2293            .host("localhost")
2294            .password("pass")
2295            .build();
2296        assert!(result.is_err());
2297        assert!(result
2298            .unwrap_err()
2299            .to_string()
2300            .contains("FTP username is required"));
2301
2302        // Test missing local folder for PUT
2303        let result = FtpPutFolderTaskletBuilder::new()
2304            .host("localhost")
2305            .username("user")
2306            .password("pass")
2307            .remote_folder("/remote/folder")
2308            .build();
2309        assert!(result.is_err());
2310        assert!(result
2311            .unwrap_err()
2312            .to_string()
2313            .contains("Local folder path is required"));
2314
2315        // Test missing remote folder for GET
2316        let result = FtpGetFolderTaskletBuilder::new()
2317            .host("localhost")
2318            .username("user")
2319            .password("pass")
2320            .local_folder("/local/folder")
2321            .build();
2322        assert!(result.is_err());
2323        assert!(result
2324            .unwrap_err()
2325            .to_string()
2326            .contains("Remote folder path is required"));
2327    }
2328
2329    #[test]
2330    fn test_nonexistent_local_folder() {
2331        let result = FtpPutFolderTasklet::new(
2332            "localhost",
2333            21,
2334            "user",
2335            "pass",
2336            "/nonexistent/folder",
2337            "/remote/folder",
2338        );
2339        assert!(result.is_err());
2340        assert!(result
2341            .unwrap_err()
2342            .to_string()
2343            .contains("Local folder does not exist"));
2344    }
2345
2346    #[test]
2347    fn test_local_file_not_directory() {
2348        let temp_dir = temp_dir();
2349        let test_file = temp_dir.join("not_a_directory.txt");
2350        fs::write(&test_file, "content").unwrap();
2351
2352        let result = FtpPutFolderTasklet::new(
2353            "localhost",
2354            21,
2355            "user",
2356            "pass",
2357            &test_file,
2358            "/remote/folder",
2359        );
2360        assert!(result.is_err());
2361        assert!(result
2362            .unwrap_err()
2363            .to_string()
2364            .contains("Local path is not a directory"));
2365
2366        fs::remove_file(&test_file).ok();
2367    }
2368
2369    #[test]
2370    fn test_ftp_put_folder_tasklet_configuration() -> Result<(), BatchError> {
2371        let temp_dir = temp_dir();
2372        let test_folder = temp_dir.join("config_folder_test");
2373        fs::create_dir_all(&test_folder).unwrap();
2374        fs::write(test_folder.join("file.txt"), "content").unwrap();
2375
2376        let mut tasklet = FtpPutFolderTasklet::new(
2377            "localhost",
2378            21,
2379            "user",
2380            "pass",
2381            &test_folder,
2382            "/remote/folder",
2383        )?;
2384
2385        // Test default values
2386        assert!(tasklet.passive_mode);
2387        assert_eq!(tasklet.timeout, Duration::from_secs(30));
2388        assert!(tasklet.create_directories);
2389        assert!(!tasklet.recursive);
2390
2391        // Test configuration methods
2392        tasklet.set_passive_mode(false);
2393        tasklet.set_timeout(Duration::from_secs(90));
2394        tasklet.set_create_directories(false);
2395        tasklet.set_recursive(true);
2396
2397        assert!(!tasklet.passive_mode);
2398        assert_eq!(tasklet.timeout, Duration::from_secs(90));
2399        assert!(!tasklet.create_directories);
2400        assert!(tasklet.recursive);
2401
2402        fs::remove_dir_all(&test_folder).ok();
2403        Ok(())
2404    }
2405
2406    #[test]
2407    fn test_ftp_get_folder_tasklet_configuration() -> Result<(), BatchError> {
2408        let temp_dir = temp_dir();
2409        let local_folder = temp_dir.join("config_folder_test");
2410
2411        let mut tasklet = FtpGetFolderTasklet::new(
2412            "localhost",
2413            21,
2414            "user",
2415            "pass",
2416            "/remote/folder",
2417            &local_folder,
2418        )?;
2419
2420        // Test default values
2421        assert!(tasklet.passive_mode);
2422        assert_eq!(tasklet.timeout, Duration::from_secs(30));
2423        assert!(tasklet.create_directories);
2424        assert!(!tasklet.recursive);
2425
2426        // Test configuration methods
2427        tasklet.set_passive_mode(false);
2428        tasklet.set_timeout(Duration::from_secs(180));
2429        tasklet.set_create_directories(false);
2430        tasklet.set_recursive(true);
2431
2432        assert!(!tasklet.passive_mode);
2433        assert_eq!(tasklet.timeout, Duration::from_secs(180));
2434        assert!(!tasklet.create_directories);
2435        assert!(tasklet.recursive);
2436
2437        Ok(())
2438    }
2439
2440    #[test]
2441    fn test_ftp_put_folder_tasklet_execution_with_connection_error() {
2442        let temp_dir = temp_dir();
2443        let test_folder = temp_dir.join("connection_error_folder_test");
2444        fs::create_dir_all(&test_folder).unwrap();
2445        fs::write(test_folder.join("file.txt"), "content").unwrap();
2446
2447        let tasklet = FtpPutFolderTasklet::new(
2448            "nonexistent.host.invalid",
2449            21,
2450            "user",
2451            "pass",
2452            &test_folder,
2453            "/remote/folder",
2454        )
2455        .unwrap();
2456
2457        let step_execution = StepExecution::new("test-step");
2458        let result = tasklet.execute(&step_execution);
2459
2460        assert!(result.is_err());
2461        let error = result.unwrap_err();
2462        assert!(matches!(error, BatchError::Io(_)));
2463        assert!(error
2464            .to_string()
2465            .contains("Failed to connect to FTP server"));
2466
2467        fs::remove_dir_all(&test_folder).ok();
2468    }
2469
2470    #[test]
2471    fn test_ftp_get_folder_tasklet_execution_with_connection_error() {
2472        let temp_dir = temp_dir();
2473        let local_folder = temp_dir.join("connection_error_folder_test");
2474
2475        let tasklet = FtpGetFolderTasklet::new(
2476            "nonexistent.host.invalid",
2477            21,
2478            "user",
2479            "pass",
2480            "/remote/folder",
2481            &local_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!(error
2492            .to_string()
2493            .contains("Failed to connect to FTP server"));
2494    }
2495
2496    #[test]
2497    fn test_builder_default_implementations() {
2498        // Test that all builders implement Default
2499        let _put_builder = FtpPutTaskletBuilder::default();
2500        let _get_builder = FtpGetTaskletBuilder::default();
2501        let _put_folder_builder = FtpPutFolderTaskletBuilder::default();
2502        let _get_folder_builder = FtpGetFolderTaskletBuilder::default();
2503    }
2504
2505    #[test]
2506    fn test_builder_fluent_interface() -> Result<(), BatchError> {
2507        let temp_dir = temp_dir();
2508        let test_file = temp_dir.join("fluent_test.txt");
2509        fs::write(&test_file, "test content").unwrap();
2510
2511        // Test method chaining works correctly
2512        let tasklet = FtpPutTaskletBuilder::new()
2513            .host("example.com")
2514            .port(2121)
2515            .username("testuser")
2516            .password("testpass")
2517            .local_file(&test_file)
2518            .remote_file("/remote/test.txt")
2519            .passive_mode(true)
2520            .timeout(Duration::from_secs(45))
2521            .build()?;
2522
2523        assert_eq!(tasklet.host, "example.com");
2524        assert_eq!(tasklet.port, 2121);
2525        assert_eq!(tasklet.username, "testuser");
2526        assert_eq!(tasklet.password, "testpass");
2527        assert_eq!(tasklet.remote_file, "/remote/test.txt");
2528        assert!(tasklet.passive_mode);
2529        assert_eq!(tasklet.timeout, Duration::from_secs(45));
2530
2531        fs::remove_file(&test_file).ok();
2532        Ok(())
2533    }
2534
2535    #[test]
2536    fn test_error_message_quality() {
2537        // Test that error messages are descriptive and helpful
2538        let result = FtpPutTaskletBuilder::new().build();
2539        assert!(result.is_err());
2540        let error_msg = result.unwrap_err().to_string();
2541        assert!(error_msg.contains("FTP host is required"));
2542
2543        let result = FtpPutTaskletBuilder::new().host("localhost").build();
2544        assert!(result.is_err());
2545        let error_msg = result.unwrap_err().to_string();
2546        assert!(error_msg.contains("FTP username is required"));
2547    }
2548
2549    #[test]
2550    fn test_path_handling() -> Result<(), BatchError> {
2551        let temp_dir = temp_dir();
2552        let test_file = temp_dir.join("path_test.txt");
2553        fs::write(&test_file, "test content").unwrap();
2554
2555        // Test that different path types work
2556        let tasklet1 = FtpPutTasklet::new(
2557            "localhost",
2558            21,
2559            "user",
2560            "pass",
2561            &test_file,
2562            "/remote/file.txt",
2563        )?;
2564
2565        let tasklet2 = FtpPutTasklet::new(
2566            "localhost",
2567            21,
2568            "user",
2569            "pass",
2570            test_file.as_path(),
2571            "/remote/file.txt",
2572        )?;
2573
2574        assert_eq!(tasklet1.local_file, tasklet2.local_file);
2575
2576        fs::remove_file(&test_file).ok();
2577        Ok(())
2578    }
2579
2580    #[test]
2581    fn test_timeout_configuration() -> Result<(), BatchError> {
2582        let temp_dir = temp_dir();
2583        let test_file = temp_dir.join("timeout_test.txt");
2584        fs::write(&test_file, "test content").unwrap();
2585
2586        // Test various timeout values
2587        let tasklet = FtpPutTaskletBuilder::new()
2588            .host("localhost")
2589            .username("user")
2590            .password("pass")
2591            .local_file(&test_file)
2592            .remote_file("/remote/file.txt")
2593            .timeout(Duration::from_millis(500))
2594            .build()?;
2595
2596        assert_eq!(tasklet.timeout, Duration::from_millis(500));
2597
2598        let tasklet = FtpPutTaskletBuilder::new()
2599            .host("localhost")
2600            .username("user")
2601            .password("pass")
2602            .local_file(&test_file)
2603            .remote_file("/remote/file.txt")
2604            .timeout(Duration::from_secs(300))
2605            .build()?;
2606
2607        assert_eq!(tasklet.timeout, Duration::from_secs(300));
2608
2609        fs::remove_file(&test_file).ok();
2610        Ok(())
2611    }
2612
2613    #[test]
2614    fn test_port_configuration() -> Result<(), BatchError> {
2615        let temp_dir = temp_dir();
2616        let test_file = temp_dir.join("port_test.txt");
2617        fs::write(&test_file, "test content").unwrap();
2618
2619        // Test various port values
2620        let tasklet = FtpPutTaskletBuilder::new()
2621            .host("localhost")
2622            .port(990) // FTPS port
2623            .username("user")
2624            .password("pass")
2625            .local_file(&test_file)
2626            .remote_file("/remote/file.txt")
2627            .build()?;
2628
2629        assert_eq!(tasklet.port, 990);
2630
2631        let tasklet = FtpPutTaskletBuilder::new()
2632            .host("localhost")
2633            .port(2121) // Alternative FTP port
2634            .username("user")
2635            .password("pass")
2636            .local_file(&test_file)
2637            .remote_file("/remote/file.txt")
2638            .build()?;
2639
2640        assert_eq!(tasklet.port, 2121);
2641
2642        fs::remove_file(&test_file).ok();
2643        Ok(())
2644    }
2645
2646    #[test]
2647    fn test_passive_mode_configuration() -> Result<(), BatchError> {
2648        let temp_dir = temp_dir();
2649        let test_file = temp_dir.join("passive_test.txt");
2650        fs::write(&test_file, "test content").unwrap();
2651
2652        // Test passive mode true
2653        let tasklet = FtpPutTaskletBuilder::new()
2654            .host("localhost")
2655            .username("user")
2656            .password("pass")
2657            .local_file(&test_file)
2658            .remote_file("/remote/file.txt")
2659            .passive_mode(true)
2660            .build()?;
2661
2662        assert!(tasklet.passive_mode);
2663
2664        // Test passive mode false (active mode)
2665        let tasklet = FtpPutTaskletBuilder::new()
2666            .host("localhost")
2667            .username("user")
2668            .password("pass")
2669            .local_file(&test_file)
2670            .remote_file("/remote/file.txt")
2671            .passive_mode(false)
2672            .build()?;
2673
2674        assert!(!tasklet.passive_mode);
2675
2676        fs::remove_file(&test_file).ok();
2677        Ok(())
2678    }
2679
2680    #[test]
2681    fn test_secure_ftp_configuration() -> Result<(), BatchError> {
2682        let temp_dir = temp_dir();
2683        let test_file = temp_dir.join("secure_test.txt");
2684        fs::write(&test_file, "test content").unwrap();
2685
2686        // Test secure mode disabled (default)
2687        let tasklet = FtpPutTaskletBuilder::new()
2688            .host("localhost")
2689            .username("user")
2690            .password("pass")
2691            .local_file(&test_file)
2692            .remote_file("/remote/file.txt")
2693            .build()?;
2694
2695        assert!(!tasklet.secure);
2696
2697        // Test secure mode enabled (FTPS)
2698        let tasklet = FtpPutTaskletBuilder::new()
2699            .host("secure-ftp.example.com")
2700            .port(990)
2701            .username("user")
2702            .password("pass")
2703            .local_file(&test_file)
2704            .remote_file("/secure/file.txt")
2705            .secure(true)
2706            .build()?;
2707
2708        assert!(tasklet.secure);
2709        assert_eq!(tasklet.port, 990);
2710
2711        // Test secure mode with FtpGetTasklet
2712        let local_file = temp_dir.join("downloaded_secure.txt");
2713        let get_tasklet = FtpGetTaskletBuilder::new()
2714            .host("secure-ftp.example.com")
2715            .port(990)
2716            .username("user")
2717            .password("pass")
2718            .remote_file("/secure/file.txt")
2719            .local_file(&local_file)
2720            .secure(true)
2721            .build()?;
2722
2723        assert!(get_tasklet.secure);
2724        assert_eq!(get_tasklet.port, 990);
2725
2726        fs::remove_file(&test_file).ok();
2727        Ok(())
2728    }
2729
2730    #[test]
2731    fn test_secure_ftp_folder_configuration() -> Result<(), BatchError> {
2732        let temp_dir = temp_dir();
2733        let test_folder = temp_dir.join("secure_folder_test");
2734        fs::create_dir_all(&test_folder).unwrap();
2735        fs::write(test_folder.join("file.txt"), "test content").unwrap();
2736
2737        // Test secure mode disabled (default) for folder upload
2738        let tasklet = FtpPutFolderTaskletBuilder::new()
2739            .host("localhost")
2740            .username("user")
2741            .password("pass")
2742            .local_folder(&test_folder)
2743            .remote_folder("/remote/folder")
2744            .build()?;
2745
2746        assert!(!tasklet.secure);
2747
2748        // Test secure mode enabled (FTPS) for folder upload
2749        let tasklet = FtpPutFolderTaskletBuilder::new()
2750            .host("secure-ftp.example.com")
2751            .port(990)
2752            .username("user")
2753            .password("pass")
2754            .local_folder(&test_folder)
2755            .remote_folder("/secure/folder")
2756            .secure(true)
2757            .build()?;
2758
2759        assert!(tasklet.secure);
2760        assert_eq!(tasklet.port, 990);
2761
2762        // Test secure mode with FtpGetFolderTasklet
2763        let local_folder = temp_dir.join("downloaded_secure_folder");
2764        let get_tasklet = FtpGetFolderTaskletBuilder::new()
2765            .host("secure-ftp.example.com")
2766            .port(990)
2767            .username("user")
2768            .password("pass")
2769            .remote_folder("/secure/folder")
2770            .local_folder(&local_folder)
2771            .secure(true)
2772            .build()?;
2773
2774        assert!(get_tasklet.secure);
2775        assert_eq!(get_tasklet.port, 990);
2776
2777        fs::remove_dir_all(&test_folder).ok();
2778        Ok(())
2779    }
2780}