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