docker_image_pusher/cli/
args.rs

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