docker_image_pusher/cli/
args.rs

1//! Command line argument parsing and validation
2//!
3//! This module defines the [`Args`] struct for parsing CLI arguments using `clap`,
4//! and provides validation logic for user input.
5
6use crate::error::handlers::ValidationErrorHandler;
7use crate::error::{PusherError, Result};
8use clap::Parser;
9
10#[derive(Parser, Debug, Clone)]
11#[command(
12    name = "docker-image-pusher",
13    version = "0.1.3",
14    about = "Push Docker images to registries with optimized large layer handling",
15    long_about = None
16)]
17pub struct Args {
18    /// Path to the Docker image tar file
19    #[arg(short, long, value_name = "FILE")]
20    pub file: String,
21
22    /// Repository URL (e.g., https://registry.example.com/my-app:latest)
23    #[arg(short, long, value_name = "URL")]
24    pub repository_url: String,
25
26    /// Registry username
27    #[arg(short, long)]
28    pub username: Option<String>,
29
30    /// Registry password
31    #[arg(short, long)]
32    pub password: Option<String>,
33
34    /// Timeout in seconds for uploads (default: 7200)
35    #[arg(short = 't', long, default_value = "7200")]
36    pub timeout: u64,
37
38    /// Skip TLS certificate verification
39    #[arg(long)]
40    pub skip_tls: bool,
41
42    /// Enable verbose output
43    #[arg(short, long)]
44    pub verbose: bool,
45
46    /// Suppress all output except errors
47    #[arg(short, long)]
48    pub quiet: bool,
49
50    /// Perform a dry run without actually uploading
51    #[arg(long)]
52    pub dry_run: bool,
53
54    /// Threshold for large layer optimization in bytes (default: 1GB)
55    #[arg(long, default_value = "1073741824")]
56    pub large_layer_threshold: u64,
57
58    /// Maximum concurrent uploads (default: 1)
59    #[arg(long, default_value = "1")]
60    pub max_concurrent: usize,
61
62    /// Number of retry attempts for failed uploads (default: 3)
63    #[arg(long, default_value = "3")]
64    pub retry_attempts: usize,
65
66    /// Enable exponential backoff for storage backend errors (default: true)
67    #[arg(long, default_value = "true")]
68    pub storage_error_backoff: bool,
69
70    /// Skip uploading layers that already exist in the registry
71    #[arg(long)]
72    pub skip_existing: bool,
73
74    /// Force upload even if layers already exist
75    #[arg(long)]
76    pub force_upload: bool,
77}
78
79impl Args {
80    /// Parse command line arguments using clap
81    /// This will exit the process if parsing fails (clap's default behavior)
82    pub fn parse() -> Self {
83        <Self as Parser>::parse()
84    }
85
86    /// Try to parse command line arguments, returning a Result
87    /// Use this when you want to handle parsing errors yourself
88    pub fn try_parse() -> Result<Self> {
89        <Self as Parser>::try_parse()
90            .map_err(|e| PusherError::Validation(format!("Failed to parse arguments: {}", e)))
91    }
92    pub fn validate(&self) -> Result<()> {
93        // Use standardized file validation
94        ValidationErrorHandler::validate_file_path(&self.file)?;
95
96        // Use standardized URL validation
97        ValidationErrorHandler::validate_repository_url(&self.repository_url)?;
98
99        // Use standardized timeout validation
100        ValidationErrorHandler::validate_timeout(self.timeout)?;
101
102        // Use standardized credential validation
103        ValidationErrorHandler::validate_credentials(&self.username, &self.password)?;
104
105        // Validate large layer threshold
106        if self.large_layer_threshold == 0 {
107            return Err(PusherError::Validation(
108                "Large layer threshold must be greater than 0".to_string(),
109            ));
110        }
111
112        // Validate max concurrent
113        if self.max_concurrent == 0 {
114            return Err(PusherError::Validation(
115                "Max concurrent uploads must be at least 1".to_string(),
116            ));
117        }
118
119        if self.max_concurrent > 10 {
120            return Err(PusherError::Validation(
121                "Max concurrent uploads cannot exceed 10".to_string(),
122            ));
123        }
124
125        // Validate retry attempts
126        if self.retry_attempts > 10 {
127            return Err(PusherError::Validation(
128                "Retry attempts cannot exceed 10".to_string(),
129            ));
130        }
131
132        // Validate credentials consistency
133        match (&self.username, &self.password) {
134            (Some(_), None) => {
135                return Err(PusherError::Validation(
136                    "Password is required when username is provided".to_string(),
137                ));
138            }
139            (None, Some(_)) => {
140                return Err(PusherError::Validation(
141                    "Username is required when password is provided".to_string(),
142                ));
143            }
144            _ => {} // Both provided or both None is fine
145        }
146
147        // Validate mutually exclusive flags
148        if self.verbose && self.quiet {
149            return Err(PusherError::Validation(
150                "Cannot specify both --verbose and --quiet flags".to_string(),
151            ));
152        }
153
154        if self.skip_existing && self.force_upload {
155            return Err(PusherError::Validation(
156                "Cannot specify both --skip-existing and --force-upload flags".to_string(),
157            ));
158        }
159
160        Ok(())
161    }
162
163    pub fn get_file_size(&self) -> Result<u64> {
164        let metadata = std::fs::metadata(&self.file)
165            .map_err(|e| PusherError::Io(format!("Failed to read file metadata: {}", e)))?;
166        Ok(metadata.len())
167    }
168
169    pub fn has_credentials(&self) -> bool {
170        self.username.is_some() && self.password.is_some()
171    }
172
173    pub fn get_credentials(&self) -> Option<(String, String)> {
174        match (&self.username, &self.password) {
175            (Some(u), Some(p)) => Some((u.clone(), p.clone())),
176            _ => None,
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_validation_missing_file() {
187        let args = Args {
188            file: "nonexistent.tar".to_string(),
189            repository_url: "https://registry.example.com/test:latest".to_string(),
190            username: None,
191            password: None,
192            timeout: 7200,
193            skip_tls: false,
194            verbose: false,
195            quiet: false,
196            dry_run: false,
197            large_layer_threshold: 1073741824,
198            max_concurrent: 1,
199            retry_attempts: 3,
200            skip_existing: false,
201            force_upload: false,
202            storage_error_backoff: true,
203        };
204
205        assert!(args.validate().is_err());
206    }
207
208    #[test]
209    fn test_validation_invalid_url() {
210        let args = Args {
211            file: "test.tar".to_string(),
212            repository_url: "invalid-url".to_string(),
213            username: None,
214            password: None,
215            timeout: 7200,
216            skip_tls: false,
217            verbose: false,
218            quiet: false,
219            dry_run: false,
220            large_layer_threshold: 1073741824,
221            max_concurrent: 1,
222            retry_attempts: 3,
223            skip_existing: false,
224            force_upload: false,
225            storage_error_backoff: true,
226        };
227
228        assert!(args.validate().is_err());
229    }
230
231    #[test]
232    fn test_validation_credentials_mismatch() {
233        let args = Args {
234            file: "test.tar".to_string(),
235            repository_url: "https://registry.example.com/test:latest".to_string(),
236            username: Some("user".to_string()),
237            password: None, // Missing password
238            timeout: 7200,
239            skip_tls: false,
240            verbose: false,
241            quiet: false,
242            dry_run: false,
243            large_layer_threshold: 1073741824,
244            max_concurrent: 1,
245            retry_attempts: 3,
246            skip_existing: false,
247            force_upload: false,
248            storage_error_backoff: true,
249        };
250
251        assert!(args.validate().is_err());
252    }
253}