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