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