1use crate::client::CrateClient;
4use crate::config::{AppConfig, EnvironmentConfig};
5use crate::error::Result;
6use crate::server::start_server;
7use crate::types::*;
8use crate::utils::{
9 create_example_batch_inputs, format_download_count, parse_json_file, parse_json_input,
10 parse_timeout, truncate_text, validate_batch_input,
11};
12use crate::DEFAULT_SERVER_PORT;
13use clap::{Parser, Subcommand, ValueEnum};
14use serde::Serialize;
15use serde_json;
16use std::path::PathBuf;
17use tabled::{Table, Tabled};
18use tracing::{error, info, warn};
19
20#[derive(Parser)]
22#[command(
23 name = "crate-checker",
24 version = "1.0.0",
25 about = "Check crate existence, versions, dependencies and more from crates.io",
26 long_about = "A comprehensive tool for retrieving information about Rust crates from crates.io.
27Supports checking crate existence, getting version information, searching crates,
28batch operations, and running as an HTTP API server."
29)]
30pub struct Cli {
31 #[arg(short, long, global = true, value_enum, default_value = "table")]
33 pub format: OutputFormat,
34
35 #[arg(long, global = true)]
37 pub verbose: bool,
38
39 #[arg(short, long, global = true)]
41 pub quiet: bool,
42
43 #[arg(long, long, global = true)]
45 pub config: Option<PathBuf>,
46
47 #[arg(long, global = true)]
49 pub timeout: Option<String>,
50
51 #[arg(long, global = true)]
53 pub api_url: Option<String>,
54
55 #[command(subcommand)]
56 pub command: Commands,
57}
58
59#[derive(Subcommand)]
61pub enum Commands {
62 Check {
64 crate_name: String,
66
67 #[arg(short, long)]
69 version: Option<String>,
70 },
71
72 CheckMultiple {
74 crate_names: Vec<String>,
76
77 #[arg(short, long)]
79 summary_only: bool,
80
81 #[arg(long)]
83 fail_on_missing: bool,
84 },
85
86 Info {
88 crate_name: String,
90
91 #[arg(short, long)]
93 deps: bool,
94
95 #[arg(short, long)]
97 stats: bool,
98 },
99
100 Versions {
102 crate_name: String,
104
105 #[arg(long)]
107 no_yanked: bool,
108
109 #[arg(short, long)]
111 limit: Option<usize>,
112 },
113
114 Search {
116 query: String,
118
119 #[arg(short, long, default_value = "10")]
121 limit: usize,
122
123 #[arg(short, long)]
125 exact: bool,
126 },
127
128 Deps {
130 crate_name: String,
132
133 #[arg(short, long)]
135 version: Option<String>,
136
137 #[arg(long)]
139 runtime_only: bool,
140 },
141
142 Stats {
144 crate_name: String,
146
147 #[arg(short, long)]
149 versions: bool,
150 },
151
152 Batch {
154 #[arg(long, long, conflicts_with = "file")]
156 json: Option<String>,
157
158 #[arg(long, long, conflicts_with = "json")]
160 file: Option<PathBuf>,
161
162 #[arg(short, long)]
164 parallel: bool,
165 },
166
167 Server {
169 #[arg(short, long, default_value_t = DEFAULT_SERVER_PORT)]
171 port: u16,
172
173 #[arg(long, default_value = "0.0.0.0")]
175 host: String,
176
177 #[arg(long)]
179 cors: bool,
180
181 #[arg(short, long)]
183 config: Option<PathBuf>,
184 },
185
186 Config {
188 #[arg(short, long)]
190 output: Option<PathBuf>,
191 },
192
193 Examples,
195}
196
197#[derive(ValueEnum, Clone, Debug, Default)]
199pub enum OutputFormat {
200 #[default]
202 Table,
203 Json,
205 Yaml,
207 Compact,
209 Csv,
211}
212
213#[derive(Tabled)]
215struct CrateInfoDisplay {
216 #[tabled(rename = "Name")]
217 name: String,
218 #[tabled(rename = "Version")]
219 version: String,
220 #[tabled(rename = "Downloads")]
221 downloads: String,
222 #[tabled(rename = "Description")]
223 description: String,
224}
225
226#[derive(Tabled)]
228struct VersionDisplay {
229 #[tabled(rename = "Version")]
230 version: String,
231 #[tabled(rename = "Downloads")]
232 downloads: String,
233 #[tabled(rename = "Published")]
234 published: String,
235 #[tabled(rename = "Yanked")]
236 yanked: String,
237}
238
239#[derive(Tabled)]
241struct SearchResultDisplay {
242 #[tabled(rename = "Name")]
243 name: String,
244 #[tabled(rename = "Version")]
245 version: String,
246 #[tabled(rename = "Downloads")]
247 downloads: String,
248 #[tabled(rename = "Description")]
249 description: String,
250}
251
252#[derive(Tabled)]
254struct DependencyDisplay {
255 #[tabled(rename = "Name")]
256 name: String,
257 #[tabled(rename = "Version")]
258 version: String,
259 #[tabled(rename = "Kind")]
260 kind: String,
261 #[tabled(rename = "Optional")]
262 optional: String,
263}
264
265#[derive(Tabled)]
267struct MultiCheckDisplay {
268 #[tabled(rename = "Crate")]
269 name: String,
270 #[tabled(rename = "Status")]
271 status: String,
272 #[tabled(rename = "Latest Version")]
273 version: String,
274}
275
276#[derive(Serialize)]
278struct MultiCheckSummary {
279 total_checked: usize,
280 existing: usize,
281 missing: usize,
282 existing_crates: Vec<String>,
283 missing_crates: Vec<String>,
284}
285
286pub async fn run() -> Result<()> {
288 let cli = Cli::parse();
289
290 init_logging(cli.verbose, cli.quiet, &cli.format);
292
293 let config = if let Some(config_path) = &cli.config {
295 AppConfig::load_from_file(Some(config_path))?
296 } else {
297 AppConfig::load()?
298 };
299
300 let env_config = EnvironmentConfig::detect();
302 let mut final_config = config;
303 env_config.apply_overrides(&mut final_config);
304
305 let mut client_builder = CrateClient::builder();
307
308 if let Some(url) = &cli.api_url {
309 client_builder = client_builder.base_url(url);
310 } else {
311 client_builder = client_builder.base_url(&final_config.crates_io.api_url);
312 }
313
314 if let Some(timeout_str) = &cli.timeout {
315 let timeout = parse_timeout(timeout_str)?;
316 client_builder = client_builder.timeout(timeout);
317 } else {
318 client_builder = client_builder.timeout(std::time::Duration::from_secs(
319 final_config.crates_io.timeout_seconds,
320 ));
321 }
322
323 let client = client_builder.build()?;
324
325 match cli.command {
327 Commands::Check {
328 crate_name,
329 version,
330 } => {
331 handle_check(client, &crate_name, version.as_deref(), &cli.format).await?;
332 }
333 Commands::CheckMultiple {
334 crate_names,
335 summary_only,
336 fail_on_missing,
337 } => {
338 handle_check_multiple(
339 client,
340 crate_names,
341 summary_only,
342 fail_on_missing,
343 &cli.format,
344 )
345 .await?;
346 }
347 Commands::Info {
348 crate_name,
349 deps,
350 stats,
351 } => {
352 handle_info(client, &crate_name, deps, stats, &cli.format).await?;
353 }
354 Commands::Versions {
355 crate_name,
356 no_yanked,
357 limit,
358 } => {
359 handle_versions(client, &crate_name, no_yanked, limit, &cli.format).await?;
360 }
361 Commands::Search {
362 query,
363 limit,
364 exact,
365 } => {
366 handle_search(client, &query, limit, exact, &cli.format).await?;
367 }
368 Commands::Deps {
369 crate_name,
370 version,
371 runtime_only,
372 } => {
373 handle_deps(
374 client,
375 &crate_name,
376 version.as_deref(),
377 runtime_only,
378 &cli.format,
379 )
380 .await?;
381 }
382 Commands::Stats {
383 crate_name,
384 versions,
385 } => {
386 handle_stats(client, &crate_name, versions, &cli.format).await?;
387 }
388 Commands::Batch {
389 json,
390 file,
391 parallel,
392 } => {
393 handle_batch(
394 client,
395 json.as_deref(),
396 file.as_deref(),
397 parallel,
398 &cli.format,
399 )
400 .await?;
401 }
402 Commands::Server {
403 port,
404 host,
405 cors,
406 config,
407 } => {
408 let mut server_config = final_config;
409 server_config.server.port = port;
410 server_config.server.host = host;
411 server_config.server.enable_cors = cors;
412
413 if let Some(config_path) = config {
414 server_config = AppConfig::load_from_file(Some(config_path))?;
415 }
416
417 start_server(server_config).await?;
418 }
419 Commands::Config { output } => {
420 handle_config(output.as_deref())?;
421 }
422 Commands::Examples => {
423 handle_examples();
424 }
425 }
426
427 Ok(())
428}
429
430async fn handle_check(
432 client: CrateClient,
433 crate_name: &str,
434 version: Option<&str>,
435 format: &OutputFormat,
436) -> Result<()> {
437 if let Some(version) = version {
438 let versions = client.get_all_versions(crate_name).await?;
440 let version_exists = versions.iter().any(|v| v.num == version);
441
442 let result = serde_json::json!({
443 "crate": crate_name,
444 "version": version,
445 "exists": version_exists
446 });
447
448 output_result(&serde_json::to_value(result)?, format)?;
449
450 if !version_exists {
451 std::process::exit(1);
452 }
453 } else {
454 let exists = client.crate_exists(crate_name).await?;
456 let result = serde_json::json!({
457 "crate": crate_name,
458 "exists": exists
459 });
460
461 output_result(&serde_json::to_value(&result)?, format)?;
462
463 if !exists {
464 std::process::exit(1);
465 }
466 }
467
468 Ok(())
469}
470
471async fn handle_check_multiple(
473 client: CrateClient,
474 crate_names: Vec<String>,
475 summary_only: bool,
476 fail_on_missing: bool,
477 format: &OutputFormat,
478) -> Result<()> {
479 use crate::error::CrateCheckerError;
480
481 if crate_names.is_empty() {
482 return Err(CrateCheckerError::ValidationError(
483 "At least one crate name must be provided".to_string(),
484 ));
485 }
486
487 info!("Checking {} crates", crate_names.len());
488
489 let mut existing_crates = Vec::new();
490 let mut missing_crates = Vec::new();
491 let mut results = Vec::new();
492
493 for crate_name in &crate_names {
495 match client.crate_exists(crate_name).await {
496 Ok(exists) => {
497 let version = if exists {
498 match client.get_latest_version(crate_name).await {
499 Ok(v) => v,
500 Err(_) => "unknown".to_string(),
501 }
502 } else {
503 "N/A".to_string()
504 };
505
506 let status = if exists { "EXISTS" } else { "MISSING" };
507
508 results.push(MultiCheckDisplay {
509 name: crate_name.clone(),
510 status: status.to_string(),
511 version,
512 });
513
514 if exists {
515 existing_crates.push(crate_name.clone());
516 } else {
517 missing_crates.push(crate_name.clone());
518 }
519 }
520 Err(e) => {
521 error!("Error checking crate '{}': {}", crate_name, e);
522 results.push(MultiCheckDisplay {
523 name: crate_name.clone(),
524 status: "ERROR".to_string(),
525 version: "N/A".to_string(),
526 });
527 missing_crates.push(crate_name.clone());
528 }
529 }
530 }
531
532 let summary = MultiCheckSummary {
534 total_checked: crate_names.len(),
535 existing: existing_crates.len(),
536 missing: missing_crates.len(),
537 existing_crates: existing_crates.clone(),
538 missing_crates: missing_crates.clone(),
539 };
540
541 match format {
543 OutputFormat::Table => {
544 if !summary_only {
545 println!("{}", Table::new(results));
546 println!();
547 }
548
549 println!("=== SUMMARY ===");
551 println!("Total checked: {}", summary.total_checked);
552 println!(
553 "Existing: {} ({}%)",
554 summary.existing,
555 (summary.existing as f32 / summary.total_checked as f32 * 100.0).round()
556 );
557 println!(
558 "Missing: {} ({}%)",
559 summary.missing,
560 (summary.missing as f32 / summary.total_checked as f32 * 100.0).round()
561 );
562
563 if !summary.existing_crates.is_empty() {
564 println!("\nExisting crates:");
565 for crate_name in &summary.existing_crates {
566 println!(" ✓ {}", crate_name);
567 }
568 }
569
570 if !summary.missing_crates.is_empty() {
571 println!("\nMissing crates:");
572 for crate_name in &summary.missing_crates {
573 println!(" ✗ {}", crate_name);
574 }
575 }
576 }
577 _ => {
578 let output_data = if summary_only {
579 serde_json::to_value(&summary)?
580 } else {
581 serde_json::json!({
582 "results": results.into_iter().map(|r| serde_json::json!({
583 "crate": r.name,
584 "status": r.status,
585 "version": r.version
586 })).collect::<Vec<_>>(),
587 "summary": summary
588 })
589 };
590 output_result(&output_data, format)?;
591 }
592 }
593
594 if fail_on_missing && !missing_crates.is_empty() {
596 std::process::exit(1);
597 }
598
599 Ok(())
600}
601
602async fn handle_info(
604 client: CrateClient,
605 crate_name: &str,
606 include_deps: bool,
607 include_stats: bool,
608 format: &OutputFormat,
609) -> Result<()> {
610 let info = client.get_crate_info(crate_name).await?;
611
612 match format {
613 OutputFormat::Table => {
614 let display = CrateInfoDisplay {
615 name: info.name.clone(),
616 version: info.newest_version.clone(),
617 downloads: format_download_count(info.downloads),
618 description: info.description.as_deref().unwrap_or("N/A").to_string(),
619 };
620 println!("{}", Table::new([display]));
621
622 if !info.keywords.is_empty() {
623 println!("\nKeywords: {}", info.keywords.join(", "));
624 }
625 if !info.categories.is_empty() {
626 println!("Categories: {}", info.categories.join(", "));
627 }
628 if let Some(repo) = &info.repository {
629 println!("Repository: {}", repo);
630 }
631 if let Some(homepage) = &info.homepage {
632 println!("Homepage: {}", homepage);
633 }
634 }
635 _ => {
636 let mut result = serde_json::to_value(&info)?;
637
638 if include_deps {
639 if let Ok(deps) = client
640 .get_crate_dependencies(crate_name, &info.newest_version)
641 .await
642 {
643 result["dependencies"] = serde_json::to_value(deps)?;
644 }
645 }
646
647 if include_stats {
648 if let Ok(stats) = client.get_download_stats(crate_name).await {
649 result["download_stats"] = serde_json::to_value(stats)?;
650 }
651 }
652
653 output_result(&result, format)?;
654 }
655 }
656
657 Ok(())
658}
659
660async fn handle_versions(
662 client: CrateClient,
663 crate_name: &str,
664 no_yanked: bool,
665 limit: Option<usize>,
666 format: &OutputFormat,
667) -> Result<()> {
668 let mut versions = client.get_all_versions(crate_name).await?;
669
670 if no_yanked {
671 versions.retain(|v| !v.yanked);
672 }
673
674 if let Some(limit) = limit {
675 versions.truncate(limit);
676 }
677
678 match format {
679 OutputFormat::Table => {
680 let displays: Vec<VersionDisplay> = versions
681 .into_iter()
682 .map(|v| VersionDisplay {
683 version: v.num,
684 downloads: format_download_count(v.downloads),
685 published: v.created_at.format("%Y-%m-%d").to_string(),
686 yanked: if v.yanked { "Yes" } else { "No" }.to_string(),
687 })
688 .collect();
689 println!("{}", Table::new(displays));
690 }
691 _ => {
692 output_result(&serde_json::to_value(&versions)?, format)?;
693 }
694 }
695
696 Ok(())
697}
698
699async fn handle_search(
701 client: CrateClient,
702 query: &str,
703 limit: usize,
704 exact: bool,
705 format: &OutputFormat,
706) -> Result<()> {
707 let mut results = client.search_crates(query, Some(limit)).await?;
708
709 if exact {
710 results.retain(|r| r.exact_match);
711 }
712
713 match format {
714 OutputFormat::Table => {
715 let displays: Vec<SearchResultDisplay> = results
716 .into_iter()
717 .map(|r| SearchResultDisplay {
718 name: r.name,
719 version: r.newest_version,
720 downloads: format_download_count(r.downloads),
721 description: truncate_text(r.description.as_deref().unwrap_or("N/A"), 50),
722 })
723 .collect();
724 println!("{}", Table::new(displays));
725 }
726 _ => {
727 output_result(&serde_json::to_value(&results)?, format)?;
728 }
729 }
730
731 Ok(())
732}
733
734async fn handle_deps(
736 client: CrateClient,
737 crate_name: &str,
738 version: Option<&str>,
739 runtime_only: bool,
740 format: &OutputFormat,
741) -> Result<()> {
742 let version = if let Some(v) = version {
743 v.to_string()
744 } else {
745 client.get_latest_version(crate_name).await?
746 };
747
748 let mut deps = client.get_crate_dependencies(crate_name, &version).await?;
749
750 if runtime_only {
751 deps.retain(|d| d.kind == "normal");
752 }
753
754 match format {
755 OutputFormat::Table => {
756 let displays: Vec<DependencyDisplay> = deps
757 .into_iter()
758 .map(|d| {
759 DependencyDisplay {
760 name: d.name,
761 version: d.req, kind: d.kind,
763 optional: if d.optional { "Yes" } else { "No" }.to_string(),
764 }
765 })
766 .collect();
767 println!("{}", Table::new(displays));
768 }
769 _ => {
770 output_result(&serde_json::to_value(&deps)?, format)?;
771 }
772 }
773
774 Ok(())
775}
776
777async fn handle_stats(
779 client: CrateClient,
780 crate_name: &str,
781 show_versions: bool,
782 format: &OutputFormat,
783) -> Result<()> {
784 let stats = client.get_download_stats(crate_name).await?;
785
786 match format {
787 OutputFormat::Table => {
788 println!("Download Statistics for '{}':", crate_name);
789 println!("Total Downloads: {}", format_download_count(stats.total));
790
791 if show_versions && !stats.versions.is_empty() {
792 println!("\nVersion Downloads:");
793 let version_displays: Vec<_> = stats
794 .versions
795 .into_iter()
796 .take(10)
797 .map(|v| (v.version, format_download_count(v.downloads)))
798 .collect();
799
800 for (version, downloads) in version_displays {
801 println!(" {}: {}", version, downloads);
802 }
803 }
804 }
805 _ => {
806 output_result(&serde_json::to_value(&stats)?, format)?;
807 }
808 }
809
810 Ok(())
811}
812
813async fn handle_batch(
815 client: CrateClient,
816 json: Option<&str>,
817 file: Option<&std::path::Path>,
818 parallel: bool,
819 format: &OutputFormat,
820) -> Result<()> {
821 let batch_input = if let Some(json_str) = json {
822 parse_json_input(json_str)?
823 } else if let Some(file_path) = file {
824 parse_json_file(file_path)?
825 } else {
826 return Err(crate::error::CrateCheckerError::ValidationError(
827 "Either --json or --file must be provided".to_string(),
828 ));
829 };
830
831 validate_batch_input(&batch_input)?;
832
833 info!(
834 "Processing batch request with {} mode",
835 if parallel { "parallel" } else { "sequential" }
836 );
837
838 let result = match batch_input {
839 BatchInput::CrateVersionMap(map) => client.process_crate_version_map(map).await?,
840 BatchInput::CrateList { crates } => {
841 let results = client.process_crate_list(crates).await?;
842 BatchResult {
843 results,
844 total_processed: 0,
845 successful: 0,
846 failed: 0,
847 processing_time_ms: 0,
848 }
849 }
850 BatchInput::Operations { operations } => {
851 client.process_batch_operations(operations).await?.result
852 }
853 };
854
855 output_result(&serde_json::to_value(&result)?, format)?;
856
857 Ok(())
858}
859
860fn handle_config(output: Option<&std::path::Path>) -> Result<()> {
862 let sample_config = AppConfig::create_sample_config();
863
864 if let Some(path) = output {
865 std::fs::write(path, sample_config)?;
866 println!("Configuration written to: {}", path.display());
867 } else {
868 println!("{}", sample_config);
869 }
870
871 Ok(())
872}
873
874fn handle_examples() {
876 println!("JSON Batch Input Examples:\n");
877
878 let examples = create_example_batch_inputs();
879 for (title, example) in examples {
880 println!("{}:", title);
881 println!("{}\n", example);
882 }
883
884 println!("Usage:");
885 println!(" crate-checker batch --json '<json_string>'");
886 println!(" crate-checker batch --file input.json");
887}
888
889fn output_result(value: &serde_json::Value, format: &OutputFormat) -> Result<()> {
891 match format {
892 OutputFormat::Json => {
893 println!("{}", serde_json::to_string_pretty(value)?);
894 }
895 OutputFormat::Yaml => {
896 println!("{}", serde_yaml::to_string(value)?);
897 }
898 OutputFormat::Compact => {
899 println!("{}", serde_json::to_string(value)?);
900 }
901 OutputFormat::Csv => {
902 if let Some(array) = value.as_array() {
904 if let Some(first) = array.first() {
905 if let Some(obj) = first.as_object() {
906 let headers: Vec<String> = obj.keys().map(|k| k.to_string()).collect();
908 println!("{}", headers.join(","));
909
910 for item in array {
912 if let Some(obj) = item.as_object() {
913 let values: Vec<_> = headers
914 .iter()
915 .map(|h| obj.get(h).and_then(|v| v.as_str()).unwrap_or("N/A"))
916 .collect();
917 println!("{}", values.join(","));
918 }
919 }
920 }
921 }
922 } else {
923 warn!("CSV format is only supported for array structures");
924 println!("{}", serde_json::to_string_pretty(value)?);
925 }
926 }
927 OutputFormat::Table => {
928 println!("{}", serde_json::to_string_pretty(value)?);
930 }
931 }
932
933 Ok(())
934}
935
936fn init_logging(verbose: bool, quiet: bool, format: &OutputFormat) {
938 let should_suppress = matches!(
941 format,
942 OutputFormat::Json | OutputFormat::Yaml | OutputFormat::Csv | OutputFormat::Compact
943 );
944
945 let level = if quiet || should_suppress {
946 tracing::Level::ERROR
947 } else if verbose {
948 tracing::Level::DEBUG
949 } else {
950 tracing::Level::INFO
951 };
952
953 tracing_subscriber::fmt()
955 .with_max_level(level)
956 .with_target(false)
957 .with_writer(std::io::stderr) .init();
959}