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::{RegistryError, Result};
7use clap::{ArgAction, Args as ClapArgs, Parser, Subcommand};
8use std::path::PathBuf;
9
10#[derive(Parser, Debug, Clone)]
11#[command(
12    name = "docker-image-pusher",
13    version = "0.2.0",
14    about = "Docker 镜像操作工具 - 支持4种核心操作模式",
15    long_about = "高性能的 Docker 镜像管理工具,支持从 registry 拉取镜像、从 tar 文件提取镜像、缓存镜像并推送到 registry。"
16)]
17pub struct Args {
18    /// 子命令
19    #[command(subcommand)]
20    pub command: Option<Commands>,
21}
22
23/// 支持的子命令
24#[derive(Subcommand, Debug, Clone)]
25pub enum Commands {
26    /// 从 repository 拉取镜像并缓存
27    Pull(PullArgs),
28
29    /// 从 tar 文件提取镜像并缓存
30    Extract(ExtractArgs),
31
32    /// 推送镜像到 repository
33    Push(PushArgs),
34
35    /// 列出缓存中的镜像
36    List(ListArgs),
37
38    /// 清理缓存
39    Clean(CleanArgs),
40}
41
42impl Args {
43    pub fn parse() -> Self {
44        <Self as Parser>::parse()
45    }
46
47    pub fn try_parse() -> Result<Self> {
48        <Self as Parser>::try_parse()
49            .map_err(|e| RegistryError::Validation(format!("Failed to parse arguments: {}", e)))
50    }
51
52    /// Validate command line arguments
53    pub fn validate(&self) -> Result<()> {
54        match &self.command {
55            Some(cmd) => match cmd {
56                Commands::Pull(args) => args.validate(),
57                Commands::Extract(args) => args.validate(),
58                Commands::Push(args) => args.validate(),
59                Commands::List(args) => args.validate(),
60                Commands::Clean(args) => args.validate(),
61            },
62            None => Err(RegistryError::Validation(
63                "No command provided. Use --help for usage information.".into(),
64            )),
65        }
66    }
67}
68
69/// 拉取镜像的参数
70#[derive(ClapArgs, Debug, Clone)]
71pub struct PullArgs {
72    /// Registry 地址 (默认: https://registry-1.docker.io)
73    #[arg(long, default_value = "https://registry-1.docker.io")]
74    pub registry: String,
75
76    /// Repository 名称 (例如: library/ubuntu)
77    #[arg(short, long)]
78    pub repository: String,
79
80    /// 标签或摘要 (例如: latest 或 sha256:...)
81    #[arg(short, long)]
82    pub reference: String,
83
84    /// Registry 用户名
85    #[arg(short, long)]
86    pub username: Option<String>,
87
88    /// Registry 密码
89    #[arg(short, long)]
90    pub password: Option<String>,
91
92    /// 跳过 TLS 证书验证
93    #[arg(long, action = ArgAction::SetTrue)]
94    pub skip_tls: bool,
95
96    /// 缓存目录 (默认: .cache)
97    #[arg(long, default_value = ".cache")]
98    pub cache_dir: PathBuf,
99
100    /// 启用详细输出
101    #[arg(short, long, action = ArgAction::SetTrue)]
102    pub verbose: bool,
103
104    /// 超时时间(秒)
105    #[arg(short = 't', long, default_value = "3600")]
106    pub timeout: u64,
107}
108
109impl PullArgs {
110    pub fn validate(&self) -> Result<()> {
111        // Validate repository format
112        if self.repository.is_empty() {
113            return Err(RegistryError::Validation(
114                "Repository name cannot be empty".to_string(),
115            ));
116        }
117
118        // Validate reference format
119        if self.reference.is_empty() {
120            return Err(RegistryError::Validation(
121                "Reference cannot be empty".to_string(),
122            ));
123        }
124
125        // Validate registry URL format
126        if !self.registry.starts_with("http://") && !self.registry.starts_with("https://") {
127            return Err(RegistryError::Validation(format!(
128                "Invalid registry URL: {}. Must start with http:// or https://",
129                self.registry
130            )));
131        }
132
133        // Validate authentication configuration consistency
134        if (self.username.is_some() && self.password.is_none())
135            || (self.username.is_none() && self.password.is_some())
136        {
137            return Err(RegistryError::Validation(
138                "Username and password must be provided together".to_string(),
139            ));
140        }
141
142        Ok(())
143    }
144}
145
146/// 从 tar 文件提取镜像的参数
147#[derive(ClapArgs, Debug, Clone)]
148pub struct ExtractArgs {
149    /// Docker 镜像 tar 文件路径
150    #[arg(short, long, value_name = "FILE")]
151    pub file: PathBuf,
152
153    /// 缓存目录 (默认: .cache)
154    #[arg(long, default_value = ".cache")]
155    pub cache_dir: PathBuf,
156
157    /// 启用详细输出
158    #[arg(short, long, action = ArgAction::SetTrue)]
159    pub verbose: bool,
160}
161
162impl ExtractArgs {
163    pub fn validate(&self) -> Result<()> {
164        // Validate file exists
165        if !self.file.exists() {
166            return Err(RegistryError::Validation(format!(
167                "Tar file '{}' does not exist",
168                self.file.display()
169            )));
170        }
171
172        Ok(())
173    }
174}
175
176/// 推送镜像的参数
177#[derive(ClapArgs, Debug, Clone)]
178pub struct PushArgs {
179    /// 源镜像 (格式: repository:tag 或 tar 文件路径)
180    #[arg(short, long)]
181    pub source: String,
182
183    /// 目标 Registry 地址 (默认: https://registry-1.docker.io)
184    #[arg(long, default_value = "https://registry-1.docker.io")]
185    pub registry: String,
186
187    /// 目标 Repository 名称
188    #[arg(short, long)]
189    pub repository: String,
190
191    /// 目标标签
192    #[arg(short, long)]
193    pub reference: String,
194
195    /// Registry 用户名
196    #[arg(short, long)]
197    pub username: Option<String>,
198
199    /// Registry 密码
200    #[arg(short, long)]
201    pub password: Option<String>,
202
203    /// 跳过 TLS 证书验证
204    #[arg(long, action = ArgAction::SetTrue)]
205    pub skip_tls: bool,
206
207    /// 缓存目录 (默认: .cache)
208    #[arg(long, default_value = ".cache")]
209    pub cache_dir: PathBuf,
210
211    /// 启用详细输出
212    #[arg(short, long, action = ArgAction::SetTrue)]
213    pub verbose: bool,
214
215    /// 超时时间(秒)
216    #[arg(short = 't', long, default_value = "7200")]
217    pub timeout: u64,
218
219    /// 重试次数
220    #[arg(long, default_value = "3")]
221    pub retry_attempts: usize,
222
223    /// 最大并发上传数
224    #[arg(long, default_value = "1")]
225    pub max_concurrent: usize,
226
227    /// 大层阈值(字节)
228    #[arg(long, default_value = "1073741824")]
229    pub large_layer_threshold: u64,
230
231    /// 跳过已存在的层
232    #[arg(long, action = ArgAction::SetTrue)]
233    pub skip_existing: bool,
234
235    /// 强制上传即使层已存在
236    #[arg(long, action = ArgAction::SetTrue)]
237    pub force_upload: bool,
238
239    /// 验证模式(不实际上传)
240    #[arg(long, action = ArgAction::SetTrue)]
241    pub dry_run: bool,
242
243    /// 使用高级并发上传器(带动态调节和性能优化)
244    #[arg(long, action = ArgAction::SetTrue)]
245    pub use_concurrent_uploader: bool,
246}
247
248impl PushArgs {
249    pub fn validate(&self) -> Result<()> {
250        // Validate source format
251        if self.source.is_empty() {
252            return Err(RegistryError::Validation(
253                "Source cannot be empty".to_string(),
254            ));
255        }
256
257        // Validate repository format
258        if self.repository.is_empty() {
259            return Err(RegistryError::Validation(
260                "Repository name cannot be empty".to_string(),
261            ));
262        }
263
264        // Validate reference format
265        if self.reference.is_empty() {
266            return Err(RegistryError::Validation(
267                "Reference cannot be empty".to_string(),
268            ));
269        }
270
271        // 验证registry URL格式
272        if !self.registry.starts_with("http://") && !self.registry.starts_with("https://") {
273            return Err(RegistryError::Validation(format!(
274                "Invalid registry URL: {}. Must start with http:// or https://",
275                self.registry
276            )));
277        }
278
279        // 验证认证配置的一致性
280        if (self.username.is_some() && self.password.is_none())
281            || (self.username.is_none() && self.password.is_some())
282        {
283            return Err(RegistryError::Validation(
284                "Username and password must be provided together".to_string(),
285            ));
286        }
287
288        // 验证并发数量
289        if self.max_concurrent == 0 {
290            return Err(RegistryError::Validation(
291                "max_concurrent must be greater than 0".to_string(),
292            ));
293        }
294
295        // 验证冲突选项
296        if self.skip_existing && self.force_upload {
297            return Err(RegistryError::Validation(
298                "Cannot specify both --skip-existing and --force-upload".to_string(),
299            ));
300        }
301
302        // 验证source是tar文件时的路径存在性
303        if self.source.ends_with(".tar") || self.source.ends_with(".tar.gz") {
304            let source_path = std::path::Path::new(&self.source);
305            if !source_path.exists() {
306                return Err(RegistryError::Validation(format!(
307                    "Source tar file '{}' does not exist",
308                    self.source
309                )));
310            }
311        }
312
313        Ok(())
314    }
315
316    /// 判断source是否为tar文件
317    pub fn is_tar_source(&self) -> bool {
318        self.source.ends_with(".tar")
319            || self.source.ends_with(".tar.gz")
320            || self.source.ends_with(".tgz")
321    }
322
323    /// 解析source为repository:reference格式
324    pub fn parse_source_repository(&self) -> Option<(String, String)> {
325        if self.is_tar_source() {
326            return None;
327        }
328
329        if let Some(colon_pos) = self.source.rfind(':') {
330            let repository = self.source[..colon_pos].to_string();
331            let reference = self.source[colon_pos + 1..].to_string();
332            Some((repository, reference))
333        } else {
334            Some((self.source.clone(), "latest".to_string()))
335        }
336    }
337}
338
339/// 列出缓存中的镜像的参数
340#[derive(ClapArgs, Debug, Clone)]
341pub struct ListArgs {
342    /// 缓存目录 (默认: .cache)
343    #[arg(long, default_value = ".cache")]
344    pub cache_dir: PathBuf,
345}
346
347impl ListArgs {
348    pub fn validate(&self) -> Result<()> {
349        Ok(())
350    }
351}
352
353/// 清理缓存的参数
354#[derive(ClapArgs, Debug, Clone)]
355pub struct CleanArgs {
356    /// 缓存目录 (默认: .cache)
357    #[arg(long, default_value = ".cache")]
358    pub cache_dir: PathBuf,
359
360    /// 强制删除所有缓存
361    #[arg(long, action = ArgAction::SetTrue)]
362    pub force: bool,
363}
364
365impl CleanArgs {
366    pub fn validate(&self) -> Result<()> {
367        Ok(())
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn test_validation_no_command() {
377        let args = Args { command: None };
378        assert!(args.validate().is_err());
379    }
380
381    #[test]
382    fn test_validation_credentials_mismatch() {
383        let args = PullArgs {
384            registry: "https://registry.example.com".to_string(),
385            repository: "test".to_string(),
386            reference: "latest".to_string(),
387            username: Some("user".to_string()),
388            password: None, // Missing password
389            skip_tls: false,
390            cache_dir: PathBuf::from(".cache"),
391            verbose: false,
392            timeout: 3600,
393        };
394
395        assert!(args.validate().is_err());
396    }
397
398    #[test]
399    fn test_validation_invalid_registry_url() {
400        let args = PullArgs {
401            registry: "invalid-url".to_string(),
402            repository: "test".to_string(),
403            reference: "latest".to_string(),
404            username: None,
405            password: None,
406            skip_tls: false,
407            cache_dir: PathBuf::from(".cache"),
408            verbose: false,
409            timeout: 3600,
410        };
411
412        assert!(args.validate().is_err());
413    }
414
415    #[test]
416    fn test_validation_max_concurrent() {
417        let args = PushArgs {
418            source: "test:latest".to_string(),
419            registry: "https://registry.example.com".to_string(),
420            repository: "test".to_string(),
421            reference: "latest".to_string(),
422            username: None,
423            password: None,
424            skip_tls: false,
425            cache_dir: PathBuf::from(".cache"),
426            verbose: false,
427            timeout: 7200,
428            retry_attempts: 3,
429            max_concurrent: 0, // Invalid value
430            large_layer_threshold: 1073741824,
431            skip_existing: false,
432            force_upload: false,
433            dry_run: false,
434            use_concurrent_uploader: false,
435        };
436
437        assert!(args.validate().is_err());
438    }
439}