crates_toml.rs raw

use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result};
use percent_encoding::percent_decode_str;
use serde::Deserialize;

use super::metadata::resolve_packages;
use super::{Project, ProjectSource, SourceKind};

/// The prefix cargo gives a source id for a package installed from a local path.
const PATH_SOURCE_PREFIX: &str = "path+file://";

/// Projects recorded in `$CARGO_HOME/.crates.toml`.
///
/// Cargo writes an entry here for every `cargo install`, and for path installs
/// the entry names the directory it was installed from. That makes this the one
/// project source that costs nothing and needs no configuration — grunk knows
/// about these projects the moment it is installed.
///
/// It is not complete. It sees only packages installed with `cargo install
/// --path`, which leaves out libraries and any binary never installed. The
/// other sources exist to cover that gap.
pub struct CratesToml {
    path: PathBuf,
}

impl CratesToml {
    pub fn new() -> Result<Self> {
        let cargo_home = home::cargo_home().context("locating CARGO_HOME")?;
        Ok(Self::at(cargo_home.join(".crates.toml")))
    }

    pub fn at(path: PathBuf) -> Self {
        Self { path }
    }

    /// The package directories of every path install, in file order.
    fn package_dirs(&self) -> Result<Vec<PathBuf>> {
        let text = match fs::read_to_string(&self.path) {
            Ok(text) => text,
            // No installs have ever been recorded; that is not a failure.
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
            Err(err) => {
                return Err(err).with_context(|| format!("reading {}", self.path.display()));
            }
        };

        let file: CratesTomlFile =
            toml::from_str(&text).with_context(|| format!("parsing {}", self.path.display()))?;

        Ok(file
            .v1
            .keys()
            .filter_map(|spec| path_install_dir(spec))
            .collect())
    }
}

impl ProjectSource for CratesToml {
    fn kind(&self) -> SourceKind {
        SourceKind::CratesToml
    }

    fn projects(&self, warnings: &mut Vec<String>) -> Result<Vec<Project>> {
        // A recorded install whose directory has since been deleted is normal
        // and not worth a warning, so those are dropped before resolution.
        let dirs = self
            .package_dirs()?
            .into_iter()
            .filter(|dir| dir.is_dir())
            .collect::<Vec<_>>();

        Ok(resolve_packages(self.kind(), dirs, warnings))
    }
}

#[derive(Debug, Deserialize)]
struct CratesTomlFile {
    /// Keys look like `name version (source-id)`; values list the binaries.
    #[serde(default)]
    v1: BTreeMap<String, Vec<String>>,
}

/// Pull the install directory out of a package spec, if it was a path install.
///
/// Specs look like `remote 0.1.0 (path+file:///home/djarb/source/remote)`, and
/// registry and git installs — which have no local project directory — look the
/// same but for the source id, so they simply yield `None`.
fn path_install_dir(spec: &str) -> Option<PathBuf> {
    let source = spec.strip_suffix(')')?;
    let (_, source) = source.rsplit_once(" (")?;
    let url = source.strip_prefix(PATH_SOURCE_PREFIX)?;

    // Cargo percent-encodes the path into the URL, so undo that.
    let decoded = percent_decode_str(url).decode_utf8().ok()?;
    Some(PathBuf::from(decoded.as_ref()))
}

#[cfg(test)]
mod tests {
    use super::*;

    use tempfile::TempDir;

    #[test]
    fn extracts_path_installs() {
        assert_eq!(
            path_install_dir("remote 0.1.0 (path+file:///home/djarb/source/remote)"),
            Some(PathBuf::from("/home/djarb/source/remote"))
        );
    }

    #[test]
    fn ignores_installs_with_no_local_directory() {
        assert_eq!(
            path_install_dir("arti 1.2.6 (registry+https://github.com/rust-lang/crates.io-index)"),
            None
        );
        assert_eq!(
            path_install_dir("foo 0.1.0 (git+https://example.com/foo?branch=main#abc123)"),
            None
        );
    }

    #[test]
    fn decodes_percent_escapes() {
        assert_eq!(
            path_install_dir("foo 0.1.0 (path+file:///home/djarb/my%20project)"),
            Some(PathBuf::from("/home/djarb/my project"))
        );
    }

    #[test]
    fn rejects_malformed_specs() {
        assert_eq!(path_install_dir(""), None);
        assert_eq!(path_install_dir("no-parens-here"), None);
        assert_eq!(
            path_install_dir("foo 0.1.0 (path+file:///unterminated"),
            None
        );
    }

    #[test]
    fn a_version_containing_parens_does_not_confuse_the_split() {
        // The source id is the last parenthesised group, so anything earlier
        // in the spec is irrelevant.
        assert_eq!(
            path_install_dir("foo 0.1.0 (weird) (path+file:///home/djarb/foo)"),
            Some(PathBuf::from("/home/djarb/foo"))
        );
    }

    #[test]
    fn a_missing_file_yields_no_projects() {
        let tmp = TempDir::new().unwrap();
        let source = CratesToml::at(tmp.path().join("absent.toml"));

        assert!(source.package_dirs().unwrap().is_empty());
    }

    #[test]
    fn reads_only_the_path_installs_from_a_real_file() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join(".crates.toml");
        fs::write(
            &path,
            r#"
[v1]
"arti 1.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = ["arti"]
"remote 0.1.0 (path+file:///home/djarb/source/remote)" = ["remote"]
"quibble 0.1.0 (path+file:///home/djarb/source/quibble/quibble)" = ["quibble"]
"#,
        )
        .unwrap();

        assert_eq!(
            CratesToml::at(path).package_dirs().unwrap(),
            vec![
                PathBuf::from("/home/djarb/source/quibble/quibble"),
                PathBuf::from("/home/djarb/source/remote"),
            ]
        );
    }
}