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"),
]
);
}
}