use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use crate::config::{FileFormat, VersionedFile};
use crate::error_code::{self, ErrorCodeExt};
const JSON_SECTIONS: &[&str] = &[
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
];
const TOML_SECTIONS: &[&str] = &["dependencies", "dev-dependencies", "build-dependencies"];
pub struct PlannedUpdate {
path: PathBuf,
content: String,
}
impl PlannedUpdate {
pub fn apply(&self) -> Result<()> {
std::fs::write(&self.path, &self.content)
.with_context(|| format!("Cannot write {}", self.path.display()))
.error_code(error_code::JSON_WRITE)
}
}
pub fn plan_dependency_update(
vf: &VersionedFile,
repo_root: &Path,
dep_name: &str,
new_version: &str,
) -> Result<Option<PlannedUpdate>> {
let path = super::join_within_repo(repo_root, &vf.path)?;
let Ok(content) = std::fs::read_to_string(&path) else {
return Ok(None);
};
let updated = match vf.format {
FileFormat::Json => rewrite_json(&content, dep_name, new_version),
FileFormat::Toml => rewrite_toml(&content, dep_name, new_version),
_ => None,
};
Ok(updated.map(|content| PlannedUpdate { path, content }))
}
pub fn supports_dependency_updates(format: &FileFormat) -> bool {
matches!(format, FileFormat::Json | FileFormat::Toml)
}
fn rewrite_constraint(existing: &str, new_version: &str) -> Option<String> {
let trimmed = existing.trim();
if trimmed.is_empty()
|| trimmed.contains(char::is_whitespace)
|| trimmed.contains(':')
|| trimmed.contains(',')
|| trimmed.contains("||")
{
return None;
}
let digit_at = trimmed.find(|c: char| c.is_ascii_digit())?;
let (prefix, version) = trimmed.split_at(digit_at);
if !prefix
.chars()
.all(|c| matches!(c, '^' | '~' | '>' | '<' | '=' | 'v'))
{
return None;
}
if version
.chars()
.any(|c| matches!(c, 'x' | 'X' | '*' | '|' | ' '))
{
return None;
}
if !version
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
{
return None;
}
Some(format!("{prefix}{new_version}"))
}
fn rewrite_json(content: &str, dep_name: &str, new_version: &str) -> Option<String> {
if serde_json::from_str::<serde_json::Value>(content).is_err() {
return None;
}
let mut out = content.to_string();
let mut changed = false;
let mut spans: Vec<(usize, usize)> = JSON_SECTIONS
.iter()
.filter_map(|section| {
super::json::find_nested_string_value_span(content, section, dep_name)
})
.collect();
spans.sort_unstable();
spans.dedup();
for (start, end) in spans.into_iter().rev() {
let existing = &content[start..end];
let Some(replacement) = rewrite_constraint(existing, new_version) else {
continue;
};
if replacement == existing {
continue;
}
out.replace_range(start..end, &replacement);
changed = true;
}
changed.then_some(out)
}
fn rewrite_toml(content: &str, dep_name: &str, new_version: &str) -> Option<String> {
let mut doc = content.parse::<toml_edit::DocumentMut>().ok()?;
let mut changed = false;
for section in TOML_SECTIONS {
let Some(table) = doc.get_mut(section).and_then(|i| i.as_table_like_mut()) else {
continue;
};
let Some(entry) = table.get_mut(dep_name) else {
continue;
};
if let Some(existing) = entry.as_str() {
if let Some(replacement) = rewrite_constraint(existing, new_version)
&& replacement != existing
{
*entry = toml_edit::value(replacement);
changed = true;
}
} else if let Some(inline) = entry.as_table_like_mut()
&& let Some(version) = inline.get_mut("version")
&& let Some(existing) = version.as_str()
&& let Some(replacement) = rewrite_constraint(existing, new_version)
&& replacement != existing
{
*version = toml_edit::value(replacement);
changed = true;
}
}
changed.then(|| doc.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_the_caret_and_replaces_the_version() {
assert_eq!(rewrite_constraint("^1.2.3", "2.0.0").unwrap(), "^2.0.0");
}
#[test]
fn keeps_other_operators() {
for (existing, expected) in [
("~1.2.3", "~2.0.0"),
(">=1.2.3", ">=2.0.0"),
("=1.2.3", "=2.0.0"),
("v1.2.3", "v2.0.0"),
("1.2.3", "2.0.0"),
] {
assert_eq!(rewrite_constraint(existing, "2.0.0").unwrap(), expected);
}
}
#[test]
fn refuses_specs_it_cannot_preserve() {
for existing in [
"*",
"workspace:*",
"workspace:^1.2.3",
"file:../core",
"git:git@github.com:a/b.git",
"npm:core@1.2.3",
">=1.0.0 <2.0.0",
"^1.0.0 || ^2.0.0",
"1.x",
"1.*",
"latest",
"",
] {
assert!(
rewrite_constraint(existing, "2.0.0").is_none(),
"{existing:?} must be left untouched"
);
}
}
#[test]
fn rewrites_a_package_json_dependency_in_place() {
let original = "{\n \"name\": \"cli\",\n \"version\": \"2.1.0\",\n \"dependencies\": {\n \"core\": \"^1.4.0\",\n \"other\": \"^9.9.9\"\n }\n}\n";
let out = rewrite_json(original, "core", "2.0.0").expect("rewrites");
assert!(out.contains("\"core\": \"^2.0.0\""));
assert!(
out.contains("\"other\": \"^9.9.9\""),
"unrelated dependencies must not move"
);
assert!(
out.contains("\"version\": \"2.1.0\""),
"the package's own version is not this module's job"
);
}
#[test]
fn rewrites_every_section_that_mentions_the_dependency() {
let original = "{\n \"dependencies\": {\n \"core\": \"^1.4.0\"\n },\n \"devDependencies\": {\n \"core\": \"~1.4.0\"\n },\n \"peerDependencies\": {\n \"core\": \"1.4.0\"\n }\n}\n";
let out = rewrite_json(original, "core", "2.0.0").expect("rewrites");
assert!(out.contains("\"core\": \"^2.0.0\""));
assert!(out.contains("\"core\": \"~2.0.0\""));
assert!(out.contains("\"core\": \"2.0.0\""));
}
#[test]
fn a_missing_json_dependency_changes_nothing() {
let original = "{\n \"dependencies\": {\n \"other\": \"^1.0.0\"\n }\n}\n";
assert!(rewrite_json(original, "core", "2.0.0").is_none());
}
#[test]
fn rewrites_a_cargo_string_dependency() {
let original = "[package]\nname = \"cli\"\nversion = \"2.1.0\"\n\n[dependencies]\ncore = \"1.4\"\nserde = \"1\"\n";
let out = rewrite_toml(original, "core", "2.0.0").expect("rewrites");
assert!(out.contains("core = \"2.0.0\""));
assert!(out.contains("serde = \"1\""), "unrelated deps stay put");
assert!(out.contains("version = \"2.1.0\""), "own version untouched");
}
#[test]
fn rewrites_the_version_inside_an_inline_table() {
let original = "[dependencies]\ncore = { version = \"1.4\", path = \"../core\", features = [\"a\"] }\n";
let out = rewrite_toml(original, "core", "2.0.0").expect("rewrites");
assert!(out.contains("version = \"2.0.0\""));
assert!(
out.contains("path = \"../core\"") && out.contains("features = [\"a\"]"),
"the rest of the entry must survive: {out}"
);
}
#[test]
fn leaves_a_dependency_with_no_version_alone() {
let original = "[dependencies]\ncore = { path = \"../core\" }\n";
assert!(rewrite_toml(original, "core", "2.0.0").is_none());
}
#[test]
fn rewrites_dev_and_build_dependencies_too() {
let original =
"[dev-dependencies]\ncore = \"1.4\"\n\n[build-dependencies]\ncore = \"~1.4\"\n";
let out = rewrite_toml(original, "core", "2.0.0").expect("rewrites");
assert!(out.contains("core = \"2.0.0\""));
assert!(out.contains("core = \"~2.0.0\""));
}
fn versioned(path: &str, format: FileFormat) -> VersionedFile {
VersionedFile {
path: path.to_string(),
format,
selector: None,
}
}
#[test]
fn planning_reports_the_change_without_writing_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("package.json");
let original = "{\n \"dependencies\": {\n \"core\": \"^1.0.0\"\n }\n}\n";
std::fs::write(&path, original).unwrap();
let vf = versioned("package.json", FileFormat::Json);
let planned = plan_dependency_update(&vf, dir.path(), "core", "2.0.0")
.unwrap()
.expect("a rewrite is planned");
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
planned.apply().unwrap();
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains("\"core\": \"^2.0.0\"")
);
}
#[test]
fn an_already_current_manifest_plans_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("package.json");
std::fs::write(
&path,
"{\n \"dependencies\": {\n \"core\": \"^2.0.0\"\n }\n}\n",
)
.unwrap();
let vf = versioned("package.json", FileFormat::Json);
assert!(
plan_dependency_update(&vf, dir.path(), "core", "2.0.0")
.unwrap()
.is_none()
);
}
#[test]
fn a_missing_manifest_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let vf = versioned("package.json", FileFormat::Json);
assert!(
plan_dependency_update(&vf, dir.path(), "core", "2.0.0")
.unwrap()
.is_none()
);
}
#[test]
fn only_json_and_toml_are_supported() {
assert!(supports_dependency_updates(&FileFormat::Json));
assert!(supports_dependency_updates(&FileFormat::Toml));
for other in [FileFormat::Gradle, FileFormat::Xml, FileFormat::Txt] {
assert!(!supports_dependency_updates(&other));
}
}
}