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