cargo_unmaintained/
lib.rs

1//! Implementation details for `cargo-unmaintained`
2//!
3//! `cargo-unmaintained` is a tool for finding unmaintained packages in Rust
4//! projects.
5//!
6//! The tool's main source file is at `src/bin/cargo-unmaintained.rs`.
7
8#![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            // smoelius: I once saw a recommendation to delete a corrupt crates.io index, but I
225            // cannot remember where. Maybe the following?
226            // https://github.com/rust-lang/cargo/issues/2403#issuecomment-186874266
227            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    // smoelius: The next four statics are "in-memory" caches.
248    // smoelius: Note that repositories are (currently) stored in both an in-memory cache and an
249    // on-disk cache. The former is keyed by url; the latter is keyed by package.
250    // smoelius: A reason for having the former is the following. Multiple packages map to the same
251    // url, and multiple urls map to the same shortened url. Thus, a cache keyed by url has a
252    // greater chance of a cache hit.
253    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        // smoelius: Currently, if additional options are passed besides --save-token, they are
272        // ignored and no error is emitted. This is ugly.
273        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            // smoelius: Before considering a package unmaintained, verify that its latest version
326            // would be considered unmaintained as well. Note that we still report the details of
327            // the version currently used. We may want to revisit this in the future.
328            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    // smoelius: See tests/snapbox.rs for another use of this conditional initialization trick.
367    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    // smoelius: If a project relies on multiple versions of a package, check only the latest one.
415    let metadata_latest_version_map = build_metadata_latest_version_map(metadata);
416
417    for pkg in &metadata.packages {
418        // smoelius: Don't consider whether workspace members are unmaintained.
419        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            // smoelius: Mercurial repos get a pass.
535            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        // smoelius: Don't check dependencies in private registries.
617        if dep.registry.is_some() {
618            continue;
619        }
620        // smoelius: Don't check dependencies specified by path.
621        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            // smoelius: I don't understand why a package can fail to be in the index, but I have
630            // seen it happen.
631            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            // smoelius: Require at least one incompatible version of the dependency that is more
638            // than `max_age` days old.
639            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    // smoelius: A dependency could appear more than once, e.g., because it is used with different
661    // features as a normal and as a development dependency.
662    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        // smoelius: Check both the regular and the shortened url.
736        for url in urls(pkg) {
737            if let Some(&repo_status) = timestamp_cache.get(&url) {
738                // smoelius: If a previous attempt to timestamp the repository failed (e.g., because
739                // of spurious network errors), then don't bother checking the repository cache.
740                let Some((url_timestamped, &timestamp)) = repo_status.as_success() else {
741                    return Ok(repo_status);
742                };
743                assert_eq!(url, url_timestamped);
744                // smoelius: `pkg`'s repository could contain other packages that were already
745                // timestamped. Thus, `pkg`'s repository could already be in the timestamp cache.
746                // But in that case, we still need to verify that `pkg` appears in its repository.
747                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            // smoelius: In the event of failure, set all urls associated with the
760            // repository.
761            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            // smoelius: Check all urls associated with the package.
806            for url in urls(pkg) {
807                if let Some(repo_status) = repository_cache.get(&url) {
808                    return Ok(repo_status.clone());
809                }
810            }
811            // smoelius: To make verbose printing easier, "membership" is printed regardless of the
812            // check's purpose, and the `Purpose` type was removed.
813            /* let what = match purpose {
814                Purpose::Membership => "membership",
815                Purpose::Timestamp => "timestamp",
816            }; */
817            verbose::wrap!(
818                || {
819                    let url_and_dir = cache.clone_repository(pkg);
820                    match url_and_dir {
821                        Ok((url_string, repo_dir)) => {
822                            // smoelius: Note the use of `leak` in the next line. But the url is
823                            // acting as a key in a global map, so it is not so bad.
824                            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                                // smoelius: If cloning failed because the repository does not
833                                // exist, adjust the repo status.
834                                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                            // smoelius: In the event of failure, set all urls associated with
846                            // the repository.
847                            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    // smoelius: Even if `purpose` is `Purpose::Timestamp`, verify that `pkg` is a member of the
866    // repository.
867    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        /* smoelius: This "failed to parse" warning is a little too noisy.
900        .map_err(|error| {
901            warn!(
902                "failed to parse {:?}: {}",
903                path,
904                error.to_string().trim_end()
905            );
906        }) */
907        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    // smoelius: Hack. It appears that `cargo tree` does not print proc-macros used by proc-macros.
1019    // For now, check whether stdout begins as expected. If not, ignore it and ultimately emit a
1020    // warning.
1021    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}