use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use semver::Version;
use toml_edit::{DocumentMut, TableLike, Value};
use crate::Result;
use crate::version;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Table {
Package,
WorkspacePackage,
}
const DEPENDENCY_SETS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
#[derive(Debug)]
pub struct Manifest {
path: PathBuf,
doc: DocumentMut,
table: Option<Table>,
}
impl Manifest {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.map_err(|err| format!("cannot read {}: {err}", path.display()))?;
let doc: DocumentMut = text
.parse()
.map_err(|err| format!("cannot parse {}: {err}", path.display()))?;
let table = version_table(&doc);
Ok(Self {
path: path.to_path_buf(),
doc,
table,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn has_version(&self) -> bool {
self.table.is_some()
}
pub fn name(&self) -> String {
package_name(&self.doc)
.or_else(|| workspace_name(&self.doc))
.unwrap_or_else(|| String::from("workspace"))
}
pub fn version(&self) -> Result<Version> {
let raw = self
.table
.and_then(|table| raw_version(&self.doc, table))
.ok_or_else(|| format!("{}: {}", self.path.display(), missing_version(&self.doc)))?;
version::parse(raw).map_err(|err| format!("{}: {err}", self.path.display()))
}
pub fn rewrite(&mut self, next: &Version, crates: &BTreeSet<String>) -> usize {
let text = next.to_string();
let mut moved = 0;
for table in [Table::Package, Table::WorkspacePackage] {
if let Some(current) = version_value_mut(&mut self.doc, table) {
moved += usize::from(set_string(current, &text));
}
}
moved += rewrite_dependencies(&mut self.doc, &text, crates);
moved
}
pub fn save(&self) -> Result<()> {
std::fs::write(&self.path, self.doc.to_string())
.map_err(|err| format!("cannot write {}: {err}", self.path.display()))
}
}
fn version_table(doc: &DocumentMut) -> Option<Table> {
[Table::Package, Table::WorkspacePackage]
.into_iter()
.find(|table| raw_version(doc, *table).is_some())
}
fn raw_version(doc: &DocumentMut, table: Table) -> Option<&str> {
table_ref(doc, table)?.get("version")?.as_str()
}
fn missing_version(doc: &DocumentMut) -> String {
let inherited = package_table(doc)
.and_then(|package| package.get("version"))
.is_some();
if inherited {
String::from(
"its version comes from `version.workspace = true`, so point at the workspace root",
)
} else {
String::from("no version in [package] or [workspace.package]")
}
}
fn package_name(doc: &DocumentMut) -> Option<String> {
Some(package_table(doc)?.get("name")?.as_str()?.to_string())
}
fn workspace_name(doc: &DocumentMut) -> Option<String> {
Some(
workspace_package_table(doc)?
.get("name")?
.as_str()?
.to_string(),
)
}
fn package_table(doc: &DocumentMut) -> Option<&dyn TableLike> {
doc.as_table().get("package")?.as_table_like()
}
fn workspace_package_table(doc: &DocumentMut) -> Option<&dyn TableLike> {
doc.as_table()
.get("workspace")?
.as_table_like()?
.get("package")?
.as_table_like()
}
fn table_ref(doc: &DocumentMut, table: Table) -> Option<&dyn TableLike> {
match table {
Table::Package => package_table(doc),
Table::WorkspacePackage => workspace_package_table(doc),
}
}
fn table_mut(doc: &mut DocumentMut, table: Table) -> Option<&mut dyn TableLike> {
match table {
Table::Package => doc.as_table_mut().get_mut("package")?.as_table_like_mut(),
Table::WorkspacePackage => {
let workspace = doc
.as_table_mut()
.get_mut("workspace")?
.as_table_like_mut()?;
workspace.get_mut("package")?.as_table_like_mut()
}
}
}
fn version_value_mut(doc: &mut DocumentMut, table: Table) -> Option<&mut Value> {
table_mut(doc, table)?.get_mut("version")?.as_value_mut()
}
fn rewrite_dependencies(doc: &mut DocumentMut, next: &str, crates: &BTreeSet<String>) -> usize {
let mut moved = rewrite_dependency_sets(doc.as_table_mut(), next, crates);
let targets: Vec<String> = doc
.get("target")
.and_then(|item| item.as_table_like())
.map(|table| table.iter().map(|(key, _)| key.to_string()).collect())
.unwrap_or_default();
for target in targets {
let table = doc
.get_mut("target")
.and_then(|item| item.as_table_like_mut())
.and_then(|targets| targets.get_mut(target.as_str()))
.and_then(|item| item.as_table_like_mut());
if let Some(table) = table {
moved += rewrite_dependency_sets(table, next, crates);
}
}
moved
}
fn rewrite_dependency_sets(
parent: &mut dyn TableLike,
next: &str,
crates: &BTreeSet<String>,
) -> usize {
let mut moved = 0;
for set in DEPENDENCY_SETS {
let dependencies = parent
.get_mut(set)
.and_then(|item| item.as_table_like_mut());
if let Some(dependencies) = dependencies {
moved += rewrite_pins(dependencies, next, crates);
}
}
moved
}
fn rewrite_pins(dependencies: &mut dyn TableLike, next: &str, crates: &BTreeSet<String>) -> usize {
let names: Vec<String> = dependencies
.iter()
.map(|(key, _)| key.to_string())
.collect();
let mut moved = 0;
for name in names {
if !crates.contains(&name) {
continue;
}
let pinned = dependencies
.get_mut(name.as_str())
.and_then(|item| item.as_value_mut())
.and_then(Value::as_inline_table_mut)
.filter(|dependency| dependency.contains_key("path"))
.and_then(|dependency| dependency.get_mut("version"));
if let Some(pinned) = pinned {
moved += usize::from(set_string(pinned, next));
}
}
moved
}
fn set_string(value: &mut Value, text: &str) -> bool {
if value.as_str() == Some(text) {
return false;
}
let prefix = value.decor().prefix().cloned();
let suffix = value.decor().suffix().cloned();
let mut replaced = Value::from(text.to_string());
let decor = replaced.decor_mut();
if let Some(prefix) = prefix {
decor.set_prefix(prefix);
}
if let Some(suffix) = suffix {
decor.set_suffix(suffix);
}
*value = replaced;
true
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest_dir(text: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("Cargo.toml"), text).expect("write manifest");
dir
}
fn reload(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("Cargo.toml")).expect("read manifest")
}
fn crates(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|name| (*name).to_string()).collect()
}
#[test]
fn reads_a_package_version() {
let dir = manifest_dir("[package]\nname = \"demo\"\nversion = \"1.2.3-rc.0\"\n");
let manifest = Manifest::load(&dir.path().join("Cargo.toml")).expect("load");
assert!(manifest.has_version());
assert_eq!(manifest.name(), "demo");
assert_eq!(
manifest.version().expect("version").to_string(),
"1.2.3-rc.0"
);
}
#[test]
fn falls_back_to_workspace_package() {
let dir = manifest_dir(
"[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n\n[workspace.package]\nversion = \"0.4.0\"\n",
);
let manifest = Manifest::load(&dir.path().join("Cargo.toml")).expect("load");
assert_eq!(manifest.version().expect("version").to_string(), "0.4.0");
assert_eq!(manifest.name(), "workspace");
}
#[test]
fn a_version_less_root_manifest_is_reported_but_still_parses() {
let dir = manifest_dir("[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n");
let manifest = Manifest::load(&dir.path().join("Cargo.toml")).expect("load");
assert!(!manifest.has_version());
let err = manifest.version().expect_err("has no version");
assert!(err.contains("no version"), "{err}");
}
#[test]
fn reports_an_inherited_version_as_needing_the_root() {
let dir = manifest_dir("[package]\nname = \"demo\"\nversion.workspace = true\n");
let manifest = Manifest::load(&dir.path().join("Cargo.toml")).expect("load");
assert!(!manifest.has_version());
let err = manifest.version().expect_err("has no version of its own");
assert!(err.contains("workspace root"), "{err}");
}
#[test]
fn rejects_a_non_semver_version() {
let dir = manifest_dir("[package]\nname = \"demo\"\nversion = \"1.2\"\n");
let manifest = Manifest::load(&dir.path().join("Cargo.toml")).expect("load");
let err = manifest.version().expect_err("should fail");
assert!(err.contains("`1.2`"), "{err}");
}
#[test]
fn moves_the_version_and_every_workspace_pin() {
let source = concat!(
"[package]\n",
"name = \"app-api\" # the name\n",
"version = \"0.1.0\" # release version\n",
"edition = \"2024\"\n",
"\n",
"[dependencies]\n",
"http = { version = \"0.1.0\", path = \"../http\" } # keep me\n",
"serde = { version = \"1.0.228\", features = [\"derive\"] }\n",
"external = { version = \"0.9.0\", path = \"../../outside/external\" }\n",
"\n",
"[dev-dependencies]\n",
"telemetry = { version = \"0.1.0\", path = \"../telemetry\" }\n",
"\n",
"[target.'cfg(unix)'.dependencies]\n",
"provider = { version = \"0.1.0\", path = \"../provider\" }\n",
);
let dir = manifest_dir(source);
let path = dir.path().join("Cargo.toml");
let mut manifest = Manifest::load(&path).expect("load");
let moved = manifest.rewrite(
&version::parse("0.2.0").expect("version"),
&crates(&["app-api", "http", "telemetry", "provider"]),
);
manifest.save().expect("save");
assert_eq!(moved, 4, "the version plus three workspace pins");
assert_eq!(
reload(&dir),
concat!(
"[package]\n",
"name = \"app-api\" # the name\n",
"version = \"0.2.0\" # release version\n",
"edition = \"2024\"\n",
"\n",
"[dependencies]\n",
"http = { version = \"0.2.0\", path = \"../http\" } # keep me\n",
"serde = { version = \"1.0.228\", features = [\"derive\"] }\n",
"external = { version = \"0.9.0\", path = \"../../outside/external\" }\n",
"\n",
"[dev-dependencies]\n",
"telemetry = { version = \"0.2.0\", path = \"../telemetry\" }\n",
"\n",
"[target.'cfg(unix)'.dependencies]\n",
"provider = { version = \"0.2.0\", path = \"../provider\" }\n",
)
);
}
#[test]
fn rewriting_an_already_matching_manifest_moves_nothing() {
let dir = manifest_dir("[package]\nname = \"demo\"\nversion = \"0.2.0\"\n");
let mut manifest = Manifest::load(&dir.path().join("Cargo.toml")).expect("load");
let moved = manifest.rewrite(&version::parse("0.2.0").expect("version"), &crates(&[]));
assert_eq!(moved, 0);
}
}