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