cargo_unmaintained/
lib.rs

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