1#![deny(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
9#![cfg_attr(dylint_lib = "general", allow(crate_wide_allow))]
10#![cfg_attr(dylint_lib = "supplementary", allow(nonexistent_path_in_comment))]
11
12use anyhow::{Context, Result, anyhow, bail, ensure};
13use cargo_metadata::{
14 Dependency, DependencyKind, Metadata, MetadataCommand, Package,
15 semver::{Version, VersionReq},
16};
17use clap::{Parser, crate_version};
18use crates_index::{Error, GitIndex};
19use home::cargo_home;
20use std::{
21 cell::RefCell,
22 collections::{HashMap, HashSet},
23 env::args,
24 ffi::OsStr,
25 fmt::Write,
26 fs::File,
27 io::{BufRead, IsTerminal},
28 path::{Path, PathBuf},
29 process::{Command, Stdio, exit},
30 str::FromStr,
31 sync::{
32 LazyLock,
33 atomic::{AtomicBool, Ordering},
34 },
35 time::{Duration, SystemTime},
36};
37use tempfile::TempDir;
38use termcolor::{ColorChoice, ColorSpec, StandardStream, WriteColor};
39use toml::{Table, Value};
40
41pub mod flush;
42pub mod github;
43pub mod packaging;
44
45mod curl;
46mod on_disk_cache;
47mod opts;
48mod progress;
49mod serialize;
50mod verbose;
51
52#[cfg(feature = "lock-index")]
53mod flock;
54
55use github::{Github as _, Impl as Github};
56
57mod repo_status;
58use repo_status::RepoStatus;
59
60mod url;
61use url::{Url, urls};
62
63const SECS_PER_DAY: u64 = 24 * 60 * 60;
64
65#[derive(Debug, Parser)]
66#[clap(bin_name = "cargo", display_name = "cargo")]
67struct Cargo {
68 #[clap(subcommand)]
69 subcmd: CargoSubCommand,
70}
71
72#[derive(Debug, Parser)]
73enum CargoSubCommand {
74 Unmaintained(Opts),
75}
76
77include!(concat!(env!("OUT_DIR"), "/after_help.rs"));
78
79#[allow(clippy::struct_excessive_bools)]
80#[derive(Debug, Parser)]
81#[remain::sorted]
82#[clap(
83 version = crate_version!(),
84 about = "Find unmaintained packages in Rust projects",
85 after_help = AFTER_HELP
86)]
87struct Opts {
88 #[clap(
89 long,
90 help = "When to use color: always, auto, or never",
91 default_value = "auto",
92 value_name = "WHEN"
93 )]
94 color: ColorChoice,
95
96 #[clap(
97 long,
98 help = "Exit as soon as an unmaintained package is found",
99 conflicts_with = "no_exit_code"
100 )]
101 fail_fast: bool,
102
103 #[clap(long, help = "Output JSON (experimental)")]
104 json: bool,
105
106 #[clap(
107 long,
108 help = "Age in days that a repository's last commit must not exceed for the repository to \
109 be considered current; 0 effectively disables this check, though ages are still \
110 reported",
111 value_name = "DAYS",
112 default_value = "365"
113 )]
114 max_age: u64,
115
116 #[cfg(all(feature = "on-disk-cache", not(windows)))]
117 #[clap(long, help = "Do not cache data on disk for future runs")]
118 no_cache: bool,
119
120 #[clap(
121 long,
122 help = "Do not set exit status when unmaintained packages are found",
123 conflicts_with = "fail_fast"
124 )]
125 no_exit_code: bool,
126
127 #[clap(long, help = "Do not show warnings")]
128 no_warnings: bool,
129
130 #[clap(
131 long,
132 short,
133 help = "Check only whether package NAME is unmaintained",
134 value_name = "NAME"
135 )]
136 package: Option<String>,
137
138 #[cfg(all(feature = "on-disk-cache", not(windows)))]
139 #[clap(long, help = "Remove all cached data from disk and exit")]
140 purge: bool,
141
142 #[cfg(not(windows))]
143 #[clap(
144 long,
145 help = "Read a personal access token from standard input and save it to \
146 $HOME/.config/cargo-unmaintained/token.txt"
147 )]
148 save_token: bool,
149
150 #[cfg(windows)]
151 #[clap(
152 long,
153 help = "Read a personal access token from standard input and save it to \
154 %LOCALAPPDATA%\\cargo-unmaintained\\token.txt"
155 )]
156 save_token: bool,
157
158 #[clap(long, help = "Show paths to unmaintained packages")]
159 tree: bool,
160
161 #[clap(long, help = "Show information about what cargo-unmaintained is doing")]
162 verbose: bool,
163}
164
165struct UnmaintainedPkg<'a> {
166 pkg: &'a Package,
167 repo_age: RepoStatus<'a, u64>,
168 newer_version_is_available: bool,
169 outdated_deps: Vec<OutdatedDep<'a>>,
170}
171
172struct OutdatedDep<'a> {
173 dep: &'a Dependency,
174 version_used: &'a Version,
175 version_latest: Version,
176}
177
178struct DepReq<'a> {
179 name: &'a str,
180 req: VersionReq,
181}
182
183impl<'a> DepReq<'a> {
184 #[allow(dead_code)]
185 fn new(name: &'a str, req: VersionReq) -> Self {
186 Self { name, req }
187 }
188
189 fn matches(&self, pkg: &Package) -> bool {
190 self.name == pkg.name && self.req.matches(&pkg.version)
191 }
192}
193
194impl<'a> From<&'a Dependency> for DepReq<'a> {
195 fn from(value: &'a Dependency) -> Self {
196 Self {
197 name: &value.name,
198 req: value.req.clone(),
199 }
200 }
201}
202
203#[macro_export]
204macro_rules! warn {
205 ($fmt:expr, $($arg:tt)*) => {
206 if $crate::opts::get().no_warnings {
207 log::debug!($fmt, $($arg)*);
208 } else {
209 $crate::verbose::newline!();
210 $crate::PROGRESS.with_borrow_mut(|progress| progress.as_mut().map($crate::progress::Progress::newline));
211 eprintln!(concat!("warning: ", $fmt), $($arg)*);
212 }
213 };
214}
215
216#[allow(clippy::unwrap_used)]
217fn create_index() -> GitIndex {
218 let _lock = lock_index().unwrap();
219 #[allow(clippy::panic)]
220 let mut index = match GitIndex::new_cargo_default() {
221 Ok(index) => index,
222 Err(error) => {
223 let mut msg = format!("failed to create index: {error}");
224 if let Error::MissingHead { repo_path, .. } = &error {
228 write!(
229 msg,
230 "\n\nIf the problem persists, consider deleting this directory: {}",
231 repo_path.display()
232 )
233 .unwrap();
234 }
235 panic!("{msg}");
236 }
237 };
238 if let Err(error) = index.update() {
239 warn!("failed to update index: {}", error);
240 }
241 index
242}
243
244thread_local! {
245 static INDEX: LazyLock<GitIndex> = LazyLock::new(create_index);
246 static PROGRESS: RefCell<Option<progress::Progress>> = const { RefCell::new(None) };
247 static GENERAL_STATUS_CACHE: RefCell<HashMap<Url<'static>, RepoStatus<'static, ()>>> = RefCell::new(HashMap::new());
254 static LATEST_VERSION_CACHE: RefCell<HashMap<String, Version>> = RefCell::new(HashMap::new());
255 static TIMESTAMP_CACHE: RefCell<HashMap<Url<'static>, RepoStatus<'static, SystemTime>>> = RefCell::new(HashMap::new());
256 static REPOSITORY_CACHE: RefCell<HashMap<Url<'static>, RepoStatus<'static, PathBuf>>> = RefCell::new(HashMap::new());
257}
258
259static TOKEN_FOUND: AtomicBool = AtomicBool::new(false);
260
261pub fn run() -> Result<()> {
262 env_logger::init();
263
264 let Cargo {
265 subcmd: CargoSubCommand::Unmaintained(opts),
266 } = Cargo::parse_from(args());
267
268 opts::init(opts);
269
270 if opts::get().save_token {
271 return Github::save_token();
274 }
275
276 #[cfg(all(feature = "on-disk-cache", not(windows)))]
277 if opts::get().purge {
278 return on_disk_cache::purge_cache();
279 }
280
281 if Github::load_token(|_| Ok(()))? {
282 TOKEN_FOUND.store(true, Ordering::SeqCst);
283 }
284
285 match unmaintained() {
286 Ok(false) => exit(0),
287 Ok(true) => exit(1),
288 Err(error) => {
289 eprintln!("Error: {error:?}");
290 exit(2);
291 }
292 }
293}
294
295fn unmaintained() -> Result<bool> {
296 let mut unmaintained_pkgs = Vec::new();
297
298 let metadata = metadata()?;
299
300 let packages = packages(&metadata)?;
301
302 eprintln!(
303 "Scanning {} packages and their dependencies{}",
304 packages.len(),
305 if opts::get().verbose {
306 ""
307 } else {
308 " (pass --verbose for more information)"
309 }
310 );
311
312 if std::io::stderr().is_terminal() && !opts::get().verbose {
313 PROGRESS
314 .with_borrow_mut(|progress| *progress = Some(progress::Progress::new(packages.len())));
315 }
316
317 for pkg in packages {
318 PROGRESS.with_borrow_mut(|progress| {
319 progress
320 .as_mut()
321 .map_or(Ok(()), |progress| progress.advance(&pkg.name))
322 })?;
323
324 if let Some(mut unmaintained_pkg) = is_unmaintained_package(&metadata, pkg)? {
325 let newer_version_is_available = newer_version_is_available(pkg)?;
329 if !newer_version_is_available || latest_version_is_unmaintained(&pkg.name)? {
330 unmaintained_pkg.newer_version_is_available = newer_version_is_available;
331 unmaintained_pkgs.push(unmaintained_pkg);
332
333 if opts::get().fail_fast {
334 break;
335 }
336 }
337 }
338 }
339
340 PROGRESS
341 .with_borrow_mut(|progress| progress.as_mut().map_or(Ok(()), progress::Progress::finish))?;
342
343 if opts::get().json {
344 unmaintained_pkgs.sort_by_key(|unmaintained| &unmaintained.pkg.id);
345
346 let json = serde_json::to_string_pretty(&unmaintained_pkgs)?;
347
348 println!("{json}");
349 } else {
350 if unmaintained_pkgs.is_empty() {
351 eprintln!("No unmaintained packages found");
352 return Ok(false);
353 }
354
355 unmaintained_pkgs.sort_by_key(|unmaintained| unmaintained.repo_age.erase_url());
356
357 display_unmaintained_pkgs(&unmaintained_pkgs)?;
358 }
359
360 Ok(!opts::get().no_exit_code)
361}
362
363fn metadata() -> Result<Metadata> {
364 let mut command = MetadataCommand::new();
365
366 let tempdir: TempDir;
368
369 if let Some(name) = &opts::get().package {
370 tempdir = packaging::temp_package(name)?;
371 command.current_dir(tempdir.path());
372 }
373
374 command.exec().map_err(Into::into)
375}
376
377fn packages(metadata: &Metadata) -> Result<Vec<&Package>> {
378 let ignored_packages = ignored_packages(metadata)?;
379
380 for name in &ignored_packages {
381 if !metadata.packages.iter().any(|pkg| pkg.name == *name) {
382 warn!(
383 "workspace metadata says to ignore `{}`, but workspace does not depend upon `{}`",
384 name, name
385 );
386 }
387 }
388
389 filter_packages(metadata, &ignored_packages)
390}
391
392#[derive(serde::Deserialize)]
393struct UnmaintainedMetadata {
394 ignore: Option<Vec<String>>,
395}
396
397fn ignored_packages(metadata: &Metadata) -> Result<HashSet<String>> {
398 let serde_json::Value::Object(object) = &metadata.workspace_metadata else {
399 return Ok(HashSet::default());
400 };
401 let Some(value) = object.get("unmaintained") else {
402 return Ok(HashSet::default());
403 };
404 let metadata = serde_json::value::from_value::<UnmaintainedMetadata>(value.clone())?;
405 Ok(metadata.ignore.unwrap_or_default().into_iter().collect())
406}
407
408fn filter_packages<'a>(
409 metadata: &'a Metadata,
410 ignored_packages: &HashSet<String>,
411) -> Result<Vec<&'a Package>> {
412 let mut packages = Vec::new();
413
414 let metadata_latest_version_map = build_metadata_latest_version_map(metadata);
416
417 for pkg in &metadata.packages {
418 if metadata.workspace_members.contains(&pkg.id) {
420 continue;
421 }
422
423 if ignored_packages.contains(&pkg.name) {
424 continue;
425 }
426
427 #[allow(clippy::panic)]
428 let version = metadata_latest_version_map
429 .get(&pkg.name)
430 .unwrap_or_else(|| {
431 panic!(
432 "`metadata_latest_version_map` does not contain {}",
433 pkg.name
434 )
435 });
436
437 if pkg.version != *version {
438 continue;
439 }
440
441 if let Some(name) = &opts::get().package {
442 if pkg.name != *name {
443 continue;
444 }
445 }
446
447 packages.push(pkg);
448 }
449
450 if let Some(name) = &opts::get().package {
451 if packages.len() >= 2 {
452 bail!("found multiple packages matching `{name}`: {:#?}", packages);
453 }
454
455 if packages.is_empty() {
456 bail!("found no packages matching `{name}`");
457 }
458 }
459
460 Ok(packages)
461}
462
463fn build_metadata_latest_version_map(metadata: &Metadata) -> HashMap<String, Version> {
464 let mut map: HashMap<String, Version> = HashMap::new();
465
466 for pkg in &metadata.packages {
467 if let Some(version) = map.get_mut(&pkg.name) {
468 if *version < pkg.version {
469 *version = pkg.version.clone();
470 }
471 } else {
472 map.insert(pkg.name.clone(), pkg.version.clone());
473 }
474 }
475
476 map
477}
478
479fn newer_version_is_available(pkg: &Package) -> Result<bool> {
480 if pkg
481 .source
482 .as_ref()
483 .is_none_or(|source| !source.is_crates_io())
484 {
485 return Ok(false);
486 }
487
488 let latest_version = latest_version(&pkg.name)?;
489
490 Ok(pkg.version != latest_version)
491}
492
493fn latest_version_is_unmaintained(name: &str) -> Result<bool> {
494 let tempdir = packaging::temp_package(name)?;
495
496 let metadata = MetadataCommand::new().current_dir(tempdir.path()).exec()?;
497
498 #[allow(clippy::panic)]
499 let pkg = metadata
500 .packages
501 .iter()
502 .find(|pkg| name == pkg.name)
503 .unwrap_or_else(|| panic!("failed to find package `{name}`"));
504
505 let unmaintained_package = is_unmaintained_package(&metadata, pkg)?;
506
507 Ok(unmaintained_package.is_some())
508}
509
510fn is_unmaintained_package<'a>(
511 metadata: &'a Metadata,
512 pkg: &'a Package,
513) -> Result<Option<UnmaintainedPkg<'a>>> {
514 if let Some(url_string) = &pkg.repository {
515 let can_use_github_api =
516 TOKEN_FOUND.load(Ordering::SeqCst) && url_string.starts_with("https://github.com/");
517
518 let url = url_string.as_str().into();
519
520 if can_use_github_api {
521 let repo_status = general_status(&pkg.name, url)?;
522 if repo_status.is_failure() {
523 return Ok(Some(UnmaintainedPkg {
524 pkg,
525 repo_age: repo_status.map_failure(),
526 newer_version_is_available: false,
527 outdated_deps: Vec::new(),
528 }));
529 }
530 }
531
532 let repo_status = clone_repository(pkg)?;
533 if repo_status.is_failure() {
534 if matches!(repo_status, RepoStatus::Uncloneable(_)) && curl::is_mercurial_repo(url)? {
536 return Ok(None);
537 }
538 return Ok(Some(UnmaintainedPkg {
539 pkg,
540 repo_age: repo_status.map_failure(),
541 newer_version_is_available: false,
542 outdated_deps: Vec::new(),
543 }));
544 }
545 }
546
547 let outdated_deps = outdated_deps(metadata, pkg)?;
548
549 if outdated_deps.is_empty() {
550 return Ok(None);
551 }
552
553 let repo_age = latest_commit_age(pkg)?;
554
555 if repo_age
556 .as_success()
557 .is_some_and(|(_, &age)| age < opts::get().max_age * SECS_PER_DAY)
558 {
559 return Ok(None);
560 }
561
562 Ok(Some(UnmaintainedPkg {
563 pkg,
564 repo_age,
565 newer_version_is_available: false,
566 outdated_deps,
567 }))
568}
569
570fn general_status(name: &str, url: Url) -> Result<RepoStatus<'static, ()>> {
571 GENERAL_STATUS_CACHE.with_borrow_mut(|general_status_cache| {
572 if let Some(&value) = general_status_cache.get(&url) {
573 return Ok(value);
574 }
575 let to_string: &dyn Fn(&RepoStatus<'static, ()>) -> String;
576 let (use_github_api, what, how) = if TOKEN_FOUND.load(Ordering::SeqCst)
577 && url.as_str().starts_with("https://github.com/")
578 {
579 to_string = &RepoStatus::to_archival_status_string;
580 (true, "archival status", "GitHub API")
581 } else {
582 to_string = &RepoStatus::to_existence_string;
583 (false, "existence", "HTTP request")
584 };
585 verbose::wrap!(
586 || {
587 let repo_status = if use_github_api {
588 Github::archival_status(url)
589 } else {
590 curl::existence(url)
591 }
592 .unwrap_or_else(|error| {
593 warn!("failed to determine `{}` {}: {}", name, what, error);
594 RepoStatus::Success(url, ())
595 })
596 .leak_url();
597 general_status_cache.insert(url.leak(), repo_status);
598 Ok(repo_status)
599 },
600 to_string,
601 "{} of `{}` using {}",
602 what,
603 name,
604 how
605 )
606 })
607}
608
609#[allow(clippy::unnecessary_wraps)]
610fn outdated_deps<'a>(metadata: &'a Metadata, pkg: &'a Package) -> Result<Vec<OutdatedDep<'a>>> {
611 if !published(pkg) {
612 return Ok(Vec::new());
613 }
614 let mut deps = Vec::new();
615 for dep in &pkg.dependencies {
616 if dep.registry.is_some() {
618 continue;
619 }
620 if dep.path.is_some() {
622 continue;
623 }
624 let Some(dep_pkg) = find_packages(metadata, dep.into()).next() else {
625 debug_assert!(dep.kind == DependencyKind::Development || dep.optional);
626 continue;
627 };
628 let Ok(version_latest) = latest_version(&dep.name).map_err(|error| {
629 warn!("failed to get latest version of `{}`: {}", dep.name, error);
632 }) else {
633 continue;
634 };
635 if dep_pkg.version <= version_latest && !dep.req.matches(&version_latest) {
636 let versions = versions(&dep_pkg.name)?;
637 if versions
640 .iter()
641 .try_fold(false, |init, version| -> Result<_> {
642 if init {
643 return Ok(true);
644 }
645 let duration = SystemTime::now().duration_since(version.created_at.into())?;
646 let version_num = Version::parse(&version.num)?;
647 Ok(duration.as_secs() >= opts::get().max_age * SECS_PER_DAY
648 && dep_pkg.version <= version_num
649 && !dep.req.matches(&version_num))
650 })?
651 {
652 deps.push(OutdatedDep {
653 dep,
654 version_used: &dep_pkg.version,
655 version_latest,
656 });
657 }
658 }
659 }
660 deps.dedup_by(|lhs, rhs| lhs.dep.name == rhs.dep.name && lhs.dep.req == rhs.dep.req);
663 Ok(deps)
664}
665
666fn published(pkg: &Package) -> bool {
667 pkg.publish.as_deref() != Some(&[])
668}
669
670fn find_packages<'a>(
671 metadata: &'a Metadata,
672 dep_req: DepReq<'a>,
673) -> impl Iterator<Item = &'a Package> {
674 metadata
675 .packages
676 .iter()
677 .filter(move |pkg| dep_req.matches(pkg))
678}
679
680#[cfg_attr(dylint_lib = "general", allow(non_local_effect_before_error_return))]
681fn latest_version(name: &str) -> Result<Version> {
682 LATEST_VERSION_CACHE.with_borrow_mut(|latest_version_cache| {
683 if let Some(version) = latest_version_cache.get(name) {
684 return Ok(version.clone());
685 }
686 verbose::wrap!(
687 || {
688 let krate = INDEX.with(|index| {
689 let _ = LazyLock::force(index);
690 let _lock = lock_index()?;
691 index
692 .crate_(name)
693 .ok_or_else(|| anyhow!("failed to find `{}` in index", name))
694 })?;
695 let latest_version_index = krate
696 .highest_normal_version()
697 .ok_or_else(|| anyhow!("`{}` has no normal version", name))?;
698 let latest_version = Version::from_str(latest_version_index.version())?;
699 latest_version_cache.insert(name.to_owned(), latest_version.clone());
700 Ok(latest_version)
701 },
702 ToString::to_string,
703 "latest version of `{}` using crates.io index",
704 name,
705 )
706 })
707}
708
709fn versions(name: &str) -> Result<Vec<crates_io_api::Version>> {
710 on_disk_cache::with_cache(|cache| -> Result<_> {
711 verbose::wrap!(
712 || { cache.fetch_versions(name) },
713 |versions: &[crates_io_api::Version]| format!("{} versions", versions.len()),
714 "versions of `{}` using crates.io API",
715 name
716 )
717 })
718}
719
720fn latest_commit_age(pkg: &Package) -> Result<RepoStatus<'_, u64>> {
721 let repo_status = timestamp(pkg)?;
722
723 repo_status
724 .map(|timestamp| {
725 let duration = SystemTime::now().duration_since(timestamp)?;
726
727 Ok(duration.as_secs())
728 })
729 .transpose()
730}
731
732#[cfg_attr(dylint_lib = "general", allow(non_local_effect_before_error_return))]
733fn timestamp(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
734 TIMESTAMP_CACHE.with_borrow_mut(|timestamp_cache| {
735 for url in urls(pkg) {
737 if let Some(&repo_status) = timestamp_cache.get(&url) {
738 let Some((url_timestamped, ×tamp)) = repo_status.as_success() else {
741 return Ok(repo_status);
742 };
743 assert_eq!(url, url_timestamped);
744 let repo_status = clone_repository(pkg)?;
748 let Some((url_cloned, _)) = repo_status.as_success() else {
749 return Ok(repo_status.map_failure());
750 };
751 assert_eq!(url, url_cloned);
752 return Ok(RepoStatus::Success(url, timestamp));
753 }
754 }
755 let repo_status = timestamp_uncached(pkg)?;
756 if let Some((url, _)) = repo_status.as_success() {
757 timestamp_cache.insert(url.leak(), repo_status.leak_url());
758 } else {
759 for url in urls(pkg) {
762 timestamp_cache.insert(url.leak(), repo_status.leak_url());
763 }
764 }
765 Ok(repo_status)
766 })
767}
768
769fn timestamp_uncached(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
770 if pkg.repository.is_none() {
771 return Ok(RepoStatus::Unnamed);
772 }
773
774 timestamp_from_clone(pkg)
775}
776
777fn timestamp_from_clone(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
778 let repo_status = clone_repository(pkg)?;
779
780 let Some((url, repo_dir)) = repo_status.as_success() else {
781 return Ok(repo_status.map_failure());
782 };
783
784 let mut command = Command::new("git");
785 command
786 .args(["log", "-1", "--pretty=format:%ct"])
787 .current_dir(repo_dir);
788 let output = command
789 .output()
790 .with_context(|| format!("failed to run command: {command:?}"))?;
791 ensure!(output.status.success(), "command failed: {command:?}");
792
793 let stdout = std::str::from_utf8(&output.stdout)?;
794 let secs = u64::from_str(stdout.trim_end())?;
795 let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
796
797 Ok(RepoStatus::Success(url, timestamp))
798}
799
800#[cfg_attr(dylint_lib = "general", allow(non_local_effect_before_error_return))]
801#[cfg_attr(dylint_lib = "supplementary", allow(commented_out_code))]
802fn clone_repository(pkg: &Package) -> Result<RepoStatus<'_, PathBuf>> {
803 let repo_status = REPOSITORY_CACHE.with_borrow_mut(|repository_cache| -> Result<_> {
804 on_disk_cache::with_cache(|cache| -> Result<_> {
805 for url in urls(pkg) {
807 if let Some(repo_status) = repository_cache.get(&url) {
808 return Ok(repo_status.clone());
809 }
810 }
811 verbose::wrap!(
818 || {
819 let url_and_dir = cache.clone_repository(pkg);
820 match url_and_dir {
821 Ok((url_string, repo_dir)) => {
822 let url = Url::from(url_string.as_str()).leak();
825 repository_cache
826 .insert(url, RepoStatus::Success(url, repo_dir.clone()).leak_url());
827 Ok(RepoStatus::Success(url, repo_dir))
828 }
829 Err(error) => {
830 let repo_status = if let Some(url_string) = &pkg.repository {
831 let url = url_string.as_str().into();
832 let existence = general_status(&pkg.name, url)?;
835 let repo_status = if existence.is_failure() {
836 existence.map_failure()
837 } else {
838 RepoStatus::Uncloneable(url)
839 };
840 warn!("failed to clone `{}`: {}", url_string, error);
841 repo_status
842 } else {
843 RepoStatus::Unnamed
844 };
845 for url in urls(pkg) {
848 repository_cache.insert(url.leak(), repo_status.clone().leak_url());
849 }
850 Ok(repo_status)
851 }
852 }
853 },
854 RepoStatus::to_membership_string,
855 "membership of `{}` using shallow clone",
856 pkg.name
857 )
858 })
859 })?;
860
861 let Some((url, repo_dir)) = repo_status.as_success() else {
862 return Ok(repo_status);
863 };
864
865 if membership_in_clone(pkg, repo_dir)? {
868 Ok(repo_status)
869 } else {
870 Ok(RepoStatus::Unassociated(url))
871 }
872}
873
874const LINE_PREFIX: &str = "D ";
875
876fn membership_in_clone(pkg: &Package, repo_dir: &Path) -> Result<bool> {
877 let mut command = Command::new("git");
878 command.args(["status", "--porcelain"]);
879 command.current_dir(repo_dir);
880 command.stdout(Stdio::piped());
881 let mut child = command
882 .spawn()
883 .with_context(|| format!("command failed: {command:?}"))?;
884 #[allow(clippy::unwrap_used)]
885 let stdout = child.stdout.take().unwrap();
886 let reader = std::io::BufReader::new(stdout);
887 for result in reader.lines() {
888 let line = result.with_context(|| format!("failed to read `{}`", repo_dir.display()))?;
889 #[allow(clippy::panic)]
890 let path = line.strip_prefix(LINE_PREFIX).map_or_else(
891 || panic!("cache is corrupt at `{}`", repo_dir.display()),
892 Path::new,
893 );
894 if path.file_name() != Some(OsStr::new("Cargo.toml")) {
895 continue;
896 }
897 let contents = show(repo_dir, path)?;
898 let Ok(table) = contents.parse::<Table>()
899 else {
908 continue;
909 };
910 if table
911 .get("package")
912 .and_then(Value::as_table)
913 .and_then(|table| table.get("name"))
914 .and_then(Value::as_str)
915 == Some(&pkg.name)
916 {
917 return Ok(true);
918 }
919 }
920
921 Ok(false)
922}
923
924fn show(repo_dir: &Path, path: &Path) -> Result<String> {
925 let mut command = Command::new("git");
926 command.args(["show", &format!("HEAD:{}", path.display())]);
927 command.current_dir(repo_dir);
928 command.stdout(Stdio::piped());
929 let output = command
930 .output()
931 .with_context(|| format!("failed to run command: {command:?}"))?;
932 if !output.status.success() {
933 let error = String::from_utf8(output.stderr)?;
934 bail!(
935 "failed to read `{}` in `{}`: {}",
936 path.display(),
937 repo_dir.display(),
938 error
939 );
940 }
941 String::from_utf8(output.stdout).map_err(Into::into)
942}
943
944fn display_unmaintained_pkgs(unmaintained_pkgs: &[UnmaintainedPkg]) -> Result<()> {
945 let mut pkgs_needing_warning = Vec::new();
946 let mut at_least_one_newer_version_is_available = false;
947 for unmaintained_pkg in unmaintained_pkgs {
948 at_least_one_newer_version_is_available |= unmaintained_pkg.newer_version_is_available;
949 if display_unmaintained_pkg(unmaintained_pkg)? {
950 pkgs_needing_warning.push(unmaintained_pkg.pkg);
951 }
952 }
953 if at_least_one_newer_version_is_available {
954 println!(
955 "\n* a newer (though still seemingly unmaintained) version of the package is available"
956 );
957 }
958 if !pkgs_needing_warning.is_empty() {
959 warn!(
960 "the following packages' paths could not be printed:{}",
961 pkgs_needing_warning
962 .into_iter()
963 .map(|pkg| format!("\n {}@{}", pkg.name, pkg.version))
964 .collect::<String>()
965 );
966 }
967 Ok(())
968}
969
970#[cfg_attr(dylint_lib = "general", allow(non_local_effect_before_error_return))]
971#[cfg_attr(dylint_lib = "try_io_result", allow(try_io_result))]
972fn display_unmaintained_pkg(unmaintained_pkg: &UnmaintainedPkg) -> Result<bool> {
973 use std::io::Write;
974 let mut stdout = StandardStream::stdout(opts::get().color);
975 let UnmaintainedPkg {
976 pkg,
977 repo_age,
978 newer_version_is_available,
979 outdated_deps,
980 } = unmaintained_pkg;
981 stdout.set_color(ColorSpec::new().set_fg(repo_age.color()))?;
982 write!(stdout, "{}", pkg.name)?;
983 stdout.set_color(ColorSpec::new().set_fg(None))?;
984 write!(stdout, " (")?;
985 repo_age.write(&mut stdout)?;
986 write!(stdout, ")")?;
987 if *newer_version_is_available {
988 write!(stdout, "*")?;
989 }
990 writeln!(stdout)?;
991 for OutdatedDep {
992 dep,
993 version_used,
994 version_latest,
995 } in outdated_deps
996 {
997 println!(
998 " {} (requirement: {}, version used: {}, latest: {})",
999 dep.name, dep.req, version_used, version_latest
1000 );
1001 }
1002 if opts::get().tree {
1003 let need_warning = display_path(&pkg.name, &pkg.version)?;
1004 println!();
1005 Ok(need_warning)
1006 } else {
1007 Ok(false)
1008 }
1009}
1010
1011fn display_path(name: &str, version: &Version) -> Result<bool> {
1012 let spec = format!("{name}@{version}");
1013 let mut command = Command::new("cargo");
1014 command.args(["tree", "--workspace", "--target=all", "--invert", &spec]);
1015 let output = command
1016 .output()
1017 .with_context(|| format!("failed to run command: {command:?}"))?;
1018 let stdout = String::from_utf8(output.stdout)?;
1022 if stdout.split_ascii_whitespace().take(2).collect::<Vec<_>>() == [name, &format!("v{version}")]
1023 {
1024 print!("{stdout}");
1025 Ok(false)
1026 } else {
1027 Ok(true)
1028 }
1029}
1030
1031static INDEX_PATH: LazyLock<PathBuf> = LazyLock::new(|| {
1032 #[allow(clippy::unwrap_used)]
1033 let cargo_home = cargo_home().unwrap();
1034 cargo_home.join("registry/index")
1035});
1036
1037#[cfg(feature = "lock-index")]
1038fn lock_index() -> Result<File> {
1039 flock::lock_path(&INDEX_PATH)
1040 .with_context(|| format!("failed to lock `{}`", INDEX_PATH.display()))
1041}
1042
1043#[cfg(not(feature = "lock-index"))]
1044fn lock_index() -> Result<File> {
1045 File::open(&*INDEX_PATH).with_context(|| format!("failed to open `{}`", INDEX_PATH.display()))
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::*;
1051 use assert_cmd::prelude::*;
1052 use clap::CommandFactory;
1053
1054 #[test]
1055 fn verify_cli() {
1056 Opts::command().debug_assert();
1057 }
1058
1059 #[test]
1060 fn usage() {
1061 std::process::Command::cargo_bin("cargo-unmaintained")
1062 .unwrap()
1063 .args(["unmaintained", "--help"])
1064 .assert()
1065 .success()
1066 .stdout(predicates::str::contains("Usage: cargo unmaintained"));
1067 }
1068
1069 #[test]
1070 fn version() {
1071 std::process::Command::cargo_bin("cargo-unmaintained")
1072 .unwrap()
1073 .args(["unmaintained", "--version"])
1074 .assert()
1075 .success()
1076 .stdout(format!(
1077 "cargo-unmaintained {}\n",
1078 env!("CARGO_PKG_VERSION")
1079 ));
1080 }
1081
1082 #[test]
1083 fn repo_status_ord() {
1084 let ys = vec![
1085 RepoStatus::Uncloneable("f".into()),
1086 RepoStatus::Unnamed,
1087 RepoStatus::Success("e".into(), 0),
1088 RepoStatus::Success("d".into(), 1),
1089 RepoStatus::Unassociated("c".into()),
1090 RepoStatus::Nonexistent("b".into()),
1091 RepoStatus::Archived("a".into()),
1092 ];
1093 let mut xs = ys.clone();
1094 xs.sort_by_key(|repo_status| repo_status.erase_url());
1095 assert_eq!(xs, ys);
1096 }
1097}