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 { 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> { 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) -> Result> { // 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::>(); 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>, } /// 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 { 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"), ] ); } }