1use 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 #[command(subcommand)]
20 pub command: Option<Commands>,
21}
22
23#[derive(Subcommand, Debug, Clone)]
25pub enum Commands {
26 Pull(PullArgs),
28
29 Extract(ExtractArgs),
31
32 Push(PushArgs),
34
35 List(ListArgs),
37
38 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 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#[derive(ClapArgs, Debug, Clone)]
71pub struct PullArgs {
72 #[arg(long, default_value = "https://registry-1.docker.io")]
74 pub registry: String,
75
76 #[arg(short, long)]
78 pub repository: String,
79
80 #[arg(short, long)]
82 pub reference: String,
83
84 #[arg(short, long)]
86 pub username: Option<String>,
87
88 #[arg(short, long)]
90 pub password: Option<String>,
91
92 #[arg(long, action = ArgAction::SetTrue)]
94 pub skip_tls: bool,
95
96 #[arg(long, default_value = ".cache")]
98 pub cache_dir: PathBuf,
99
100 #[arg(short, long, action = ArgAction::SetTrue)]
102 pub verbose: bool,
103
104 #[arg(short = 't', long, default_value = "3600")]
106 pub timeout: u64,
107}
108
109impl PullArgs {
110 pub fn validate(&self) -> Result<()> {
111 if self.repository.is_empty() {
113 return Err(RegistryError::Validation(
114 "Repository name cannot be empty".to_string(),
115 ));
116 }
117
118 if self.reference.is_empty() {
120 return Err(RegistryError::Validation(
121 "Reference cannot be empty".to_string(),
122 ));
123 }
124
125 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 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#[derive(ClapArgs, Debug, Clone)]
148pub struct ExtractArgs {
149 #[arg(short, long, value_name = "FILE")]
151 pub file: PathBuf,
152
153 #[arg(long, default_value = ".cache")]
155 pub cache_dir: PathBuf,
156
157 #[arg(short, long, action = ArgAction::SetTrue)]
159 pub verbose: bool,
160}
161
162impl ExtractArgs {
163 pub fn validate(&self) -> Result<()> {
164 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#[derive(ClapArgs, Debug, Clone)]
178pub struct PushArgs {
179 #[arg(short, long)]
181 pub source: String,
182
183 #[arg(long, default_value = "https://registry-1.docker.io")]
185 pub registry: String,
186
187 #[arg(short, long)]
189 pub repository: String,
190
191 #[arg(short, long)]
193 pub reference: String,
194
195 #[arg(short, long)]
197 pub username: Option<String>,
198
199 #[arg(short, long)]
201 pub password: Option<String>,
202
203 #[arg(long, action = ArgAction::SetTrue)]
205 pub skip_tls: bool,
206
207 #[arg(long, default_value = ".cache")]
209 pub cache_dir: PathBuf,
210
211 #[arg(short, long, action = ArgAction::SetTrue)]
213 pub verbose: bool,
214
215 #[arg(short = 't', long, default_value = "7200")]
217 pub timeout: u64,
218
219 #[arg(long, default_value = "3")]
221 pub retry_attempts: usize,
222
223 #[arg(long, default_value = "1")]
225 pub max_concurrent: usize,
226
227 #[arg(long, default_value = "1073741824")]
229 pub large_layer_threshold: u64,
230
231 #[arg(long, action = ArgAction::SetTrue)]
233 pub skip_existing: bool,
234
235 #[arg(long, action = ArgAction::SetTrue)]
237 pub force_upload: bool,
238
239 #[arg(long, action = ArgAction::SetTrue)]
241 pub dry_run: bool,
242
243 #[arg(long, action = ArgAction::SetTrue)]
245 pub use_concurrent_uploader: bool,
246}
247
248impl PushArgs {
249 pub fn validate(&self) -> Result<()> {
250 if self.source.is_empty() {
252 return Err(RegistryError::Validation(
253 "Source cannot be empty".to_string(),
254 ));
255 }
256
257 if self.repository.is_empty() {
259 return Err(RegistryError::Validation(
260 "Repository name cannot be empty".to_string(),
261 ));
262 }
263
264 if self.reference.is_empty() {
266 return Err(RegistryError::Validation(
267 "Reference cannot be empty".to_string(),
268 ));
269 }
270
271 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 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 if self.max_concurrent == 0 {
290 return Err(RegistryError::Validation(
291 "max_concurrent must be greater than 0".to_string(),
292 ));
293 }
294
295 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 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 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 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#[derive(ClapArgs, Debug, Clone)]
341pub struct ListArgs {
342 #[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#[derive(ClapArgs, Debug, Clone)]
355pub struct CleanArgs {
356 #[arg(long, default_value = ".cache")]
358 pub cache_dir: PathBuf,
359
360 #[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, 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, 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}