use std::collections::HashSet;
use anyhow::{
Context,
Result,
};
use regex::Regex;
use similar::{
ChangeTag,
TextDiff,
};
pub fn apply_version_hunks(
head_content: &str,
working_content: &str,
old_version: &str,
new_version: &str,
) -> Result<String> {
let diff = TextDiff::from_lines(head_content, working_content);
let mut result = Vec::new();
for change in diff.iter_all_changes() {
let line = change.value();
let is_version_related =
line.contains("version") || line.contains(old_version) || line.contains(new_version);
match change.tag() {
ChangeTag::Equal => {
result.push(line);
}
ChangeTag::Delete => {
if is_version_related {
} else {
result.push(line);
}
}
ChangeTag::Insert => {
if is_version_related {
result.push(line);
} else {
}
}
}
}
Ok(result.join(""))
}
pub fn has_non_version_changes(
head_content: &str,
working_content: &str,
old_version: &str,
new_version: &str,
) -> bool {
let diff = TextDiff::from_lines(head_content, working_content);
for change in diff.iter_all_changes() {
if matches!(change.tag(), ChangeTag::Delete | ChangeTag::Insert) {
let line = change.value();
let is_version_related = line.contains("version")
|| line.contains(old_version)
|| line.contains(new_version);
if !is_version_related {
return true;
}
}
}
false
}
fn is_readme_version_line(
line: &str,
crate_name: &str,
old_version: &str,
new_version: &str,
) -> bool {
let hyphenated = crate_name.replace('_', "-");
let underscored = crate_name.replace('-', "_");
for name in [&hyphenated, &underscored] {
for version in [old_version, new_version] {
let pattern = format!(
r#"{}(\s|[-_])?\s*=\s*"{}""#,
regex::escape(name),
regex::escape(version)
);
if let Ok(re) = Regex::new(&pattern)
&& re.is_match(line)
{
return true;
}
}
}
false
}
pub fn apply_readme_version_hunks(
head_content: &str,
working_content: &str,
crate_name: &str,
old_version: &str,
new_version: &str,
) -> Result<String> {
let diff = TextDiff::from_lines(head_content, working_content);
let mut result = Vec::new();
for change in diff.iter_all_changes() {
let line = change.value();
let is_version_related = is_readme_version_line(line, crate_name, old_version, new_version);
match change.tag() {
ChangeTag::Equal => {
result.push(line);
}
ChangeTag::Delete => {
if is_version_related {
} else {
result.push(line);
}
}
ChangeTag::Insert => {
if is_version_related {
result.push(line);
} else {
}
}
}
}
Ok(result.join(""))
}
pub fn has_non_readme_version_changes(
head_content: &str,
working_content: &str,
crate_name: &str,
old_version: &str,
new_version: &str,
) -> bool {
let diff = TextDiff::from_lines(head_content, working_content);
for change in diff.iter_all_changes() {
if matches!(change.tag(), ChangeTag::Delete | ChangeTag::Insert) {
let line = change.value();
let is_version_related =
is_readme_version_line(line, crate_name, old_version, new_version);
if !is_version_related {
return true;
}
}
}
false
}
fn find_package_block(content: &str, crate_name: &str) -> Option<(usize, usize)> {
let target_name = format!(r#"name = "{crate_name}""#);
let mut cursor = 0usize;
let mut current_block_start: Option<usize> = None;
for line in content.split_inclusive('\n') {
let line_start = cursor;
cursor += line.len();
let trimmed = line.trim_end();
if trimmed == "[[package]]" {
if let Some(block_start) = current_block_start
&& is_local_package_block(&content[block_start..line_start], &target_name)
{
return Some((block_start, line_start));
}
current_block_start = Some(line_start);
} else if trimmed.starts_with('[') && trimmed != "[[package]]" {
if let Some(block_start) = current_block_start
&& is_local_package_block(&content[block_start..line_start], &target_name)
{
return Some((block_start, line_start));
}
current_block_start = None;
}
}
current_block_start.and_then(|block_start| {
is_local_package_block(&content[block_start..cursor], &target_name)
.then_some((block_start, cursor))
})
}
fn is_local_package_block(block: &str, target_name: &str) -> bool {
let mut has_target_name = false;
let mut has_source = false;
for line in block.lines() {
let trimmed = line.trim();
has_target_name |= trimmed == target_name;
has_source |= trimmed.starts_with("source = ");
}
has_target_name && !has_source
}
pub fn apply_cargo_lock_version_hunks(
head_content: &str,
working_content: &str,
crate_name: &str,
_old_version: &str,
_new_version: &str,
) -> Result<String> {
let (work_start, work_end) = find_package_block(working_content, crate_name)
.with_context(|| format!("crate `{crate_name}` not found in working Cargo.lock"))?;
let working_block = &working_content[work_start..work_end];
match find_package_block(head_content, crate_name) {
Some((head_start, head_end)) => {
let mut out = String::with_capacity(
head_content.len() + working_block.len() - (head_end - head_start),
);
out.push_str(&head_content[..head_start]);
out.push_str(working_block);
out.push_str(&head_content[head_end..]);
Ok(out)
}
None => Ok(head_content.to_string()),
}
}
pub fn apply_workspace_cargo_lock_version_hunks(
head_content: &str,
working_content: &str,
workspace_package_names: &HashSet<String>,
old_version: &str,
new_version: &str,
) -> Result<String> {
let mut staged = head_content.to_string();
for package_name in workspace_package_names {
if find_package_block(&staged, package_name).is_none() {
continue;
}
staged = apply_cargo_lock_version_hunks(
&staged,
working_content,
package_name,
old_version,
new_version,
)?;
}
Ok(staged)
}
pub fn has_non_workspace_cargo_lock_version_changes(
head_content: &str,
working_content: &str,
workspace_package_names: &HashSet<String>,
old_version: &str,
new_version: &str,
) -> bool {
match apply_workspace_cargo_lock_version_hunks(
head_content,
working_content,
workspace_package_names,
old_version,
new_version,
) {
Ok(staged) => staged != working_content,
Err(_) => true,
}
}
pub fn has_non_cargo_lock_version_changes(
head_content: &str,
working_content: &str,
crate_name: &str,
_old_version: &str,
_new_version: &str,
) -> bool {
match apply_cargo_lock_version_hunks(head_content, working_content, crate_name, "", "") {
Ok(staged) => staged != working_content,
Err(_) => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_version_hunks_only_version_change() {
let head = "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n";
let working = "[package]\nname = \"test\"\nversion = \"0.2.0\"\nedition = \"2021\"\n";
let staged = apply_version_hunks(head, working, "0.1.0", "0.2.0").unwrap();
assert!(staged.contains("version = \"0.2.0\""));
assert!(!staged.contains("0.1.0"));
}
#[test]
fn test_apply_version_hunks_mixed_changes() {
let head = "[package]\nname = \"test\"\nversion = \"0.1.0\"\ndescription = \"old desc\"\n";
let working =
"[package]\nname = \"test\"\nversion = \"0.2.0\"\ndescription = \"new desc\"\n";
let staged = apply_version_hunks(head, working, "0.1.0", "0.2.0").unwrap();
assert!(staged.contains("version = \"0.2.0\""));
assert!(staged.contains("description = \"old desc\""));
assert!(!staged.contains("description = \"new desc\""));
}
#[test]
fn test_has_non_version_changes_true() {
let head = "[package]\nname = \"test\"\nversion = \"0.1.0\"\n";
let working = "[package]\nname = \"test-renamed\"\nversion = \"0.2.0\"\n";
assert!(has_non_version_changes(head, working, "0.1.0", "0.2.0"));
}
#[test]
fn test_has_non_version_changes_false() {
let head = "[package]\nname = \"test\"\nversion = \"0.1.0\"\n";
let working = "[package]\nname = \"test\"\nversion = \"0.2.0\"\n";
assert!(!has_non_version_changes(head, working, "0.1.0", "0.2.0"));
}
#[test]
fn test_apply_version_hunks_multiple_version_fields() {
let head =
"[package]\nversion = \"1.0.0\"\n[dependencies]\ncrate-a = { version = \"1.0.0\" }\n";
let working =
"[package]\nversion = \"2.0.0\"\n[dependencies]\ncrate-a = { version = \"2.0.0\" }\n";
let staged = apply_version_hunks(head, working, "1.0.0", "2.0.0").unwrap();
assert!(staged.contains("version = \"2.0.0\""));
assert!(!staged.contains("1.0.0"));
}
#[test]
fn test_apply_readme_version_hunks_only_version_change() {
let head = r#"# My Crate
Add to Cargo.toml:
```toml
my-crate = "0.1.0"
```
"#;
let working = r#"# My Crate
Add to Cargo.toml:
```toml
my-crate = "0.2.0"
```
"#;
let staged =
apply_readme_version_hunks(head, working, "my-crate", "0.1.0", "0.2.0").unwrap();
assert!(staged.contains(r#"my-crate = "0.2.0""#));
assert!(!staged.contains(r#"my-crate = "0.1.0""#));
}
#[test]
fn test_apply_readme_version_hunks_mixed_changes() {
let head = r#"# My Crate
Old description.
```toml
my-crate = "0.1.0"
```
"#;
let working = r#"# My Crate
New description with more details.
```toml
my-crate = "0.2.0"
```
"#;
let staged =
apply_readme_version_hunks(head, working, "my-crate", "0.1.0", "0.2.0").unwrap();
assert!(staged.contains(r#"my-crate = "0.2.0""#));
assert!(staged.contains("Old description."));
assert!(!staged.contains("New description"));
}
#[test]
fn test_apply_readme_version_hunks_underscored_name() {
let head = r#"my_crate = "1.0.0""#;
let working = r#"my_crate = "1.1.0""#;
let staged =
apply_readme_version_hunks(head, working, "my-crate", "1.0.0", "1.1.0").unwrap();
assert!(staged.contains(r#"my_crate = "1.1.0""#));
}
#[test]
fn test_has_non_readme_version_changes_true() {
let head = "# Readme\nmy-crate = \"0.1.0\"\n";
let working = "# Updated Readme\nmy-crate = \"0.2.0\"\n";
assert!(has_non_readme_version_changes(
head, working, "my-crate", "0.1.0", "0.2.0"
));
}
#[test]
fn test_has_non_readme_version_changes_false() {
let head = "# Readme\nmy-crate = \"0.1.0\"\n";
let working = "# Readme\nmy-crate = \"0.2.0\"\n";
assert!(!has_non_readme_version_changes(
head, working, "my-crate", "0.1.0", "0.2.0"
));
}
#[test]
fn test_apply_cargo_lock_version_hunks_only_our_crate() {
let head = r#"[[package]]
name = "my-crate"
version = "0.1.0"
[[package]]
name = "other-crate"
version = "1.0.0"
"#;
let working = r#"[[package]]
name = "my-crate"
version = "0.2.0"
[[package]]
name = "other-crate"
version = "1.0.0"
"#;
let staged =
apply_cargo_lock_version_hunks(head, working, "my-crate", "0.1.0", "0.2.0").unwrap();
assert!(staged.contains(r#"version = "0.2.0""#));
assert!(!staged.contains(r#"version = "0.1.0""#));
}
#[test]
fn test_apply_cargo_lock_version_hunks_for_all_workspace_members() {
let head = r#"[[package]]
name = "workspace-root"
version = "0.20.1"
[[package]]
name = "workspace-member"
version = "0.20.1"
[[package]]
name = "registry-dependency"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#;
let working = r#"[[package]]
name = "workspace-root"
version = "0.20.2"
[[package]]
name = "workspace-member"
version = "0.20.2"
[[package]]
name = "registry-dependency"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#;
let workspace_packages =
HashSet::from(["workspace-root".to_string(), "workspace-member".to_string()]);
let staged = apply_workspace_cargo_lock_version_hunks(
head,
working,
&workspace_packages,
"0.20.1",
"0.20.2",
)
.unwrap();
assert_eq!(staged.matches(r#"version = "0.20.2""#).count(), 2);
assert!(staged.contains("name = \"registry-dependency\"\nversion = \"1.0.0\""));
assert!(!staged.contains("name = \"registry-dependency\"\nversion = \"2.0.0\""));
}
#[test]
fn test_apply_cargo_lock_version_hunks_mixed_changes() {
let head = r#"[[package]]
name = "my-crate"
version = "0.1.0"
[[package]]
name = "other-crate"
version = "1.0.0"
"#;
let working = r#"[[package]]
name = "my-crate"
version = "0.2.0"
[[package]]
name = "other-crate"
version = "2.0.0"
"#;
let staged =
apply_cargo_lock_version_hunks(head, working, "my-crate", "0.1.0", "0.2.0").unwrap();
assert!(staged.contains(r#"name = "my-crate""#));
assert!(
staged.contains(r#"version = "0.2.0""#),
"Our crate's new version should be included"
);
assert!(staged.contains(r#"name = "other-crate""#));
assert!(
staged.contains(r#"version = "1.0.0""#),
"Other crate's version should stay at 1.0.0"
);
assert!(
!staged.matches(r#"version = "2.0.0""#).count() > 0
|| staged.matches(r#"version = "0.2.0""#).count() == 1,
"Only our crate should have updated version"
);
}
#[test]
fn test_apply_cargo_lock_version_hunks_stale_head_version() {
let head = r#"[[package]]
name = "my-crate"
version = "0.0.15"
dependencies = [
"anyhow",
]
[[package]]
name = "other-crate"
version = "1.0.0"
"#;
let working = r#"[[package]]
name = "my-crate"
version = "0.0.17"
dependencies = [
"anyhow",
]
[[package]]
name = "other-crate"
version = "1.0.0"
"#;
let staged =
apply_cargo_lock_version_hunks(head, working, "my-crate", "0.0.16", "0.0.17").unwrap();
assert_eq!(staged.matches(r#"version = "0.0.17""#).count(), 1);
assert!(!staged.contains(r#"version = "0.0.15""#));
assert!(!staged.contains(r#"version = "0.0.16""#));
}
#[test]
fn test_has_non_cargo_lock_version_changes_true() {
let head = r#"[[package]]
name = "my-crate"
version = "0.1.0"
[[package]]
name = "other-crate"
version = "1.0.0"
"#;
let working = r#"[[package]]
name = "my-crate"
version = "0.2.0"
[[package]]
name = "other-crate"
version = "2.0.0"
"#;
assert!(has_non_cargo_lock_version_changes(
head, working, "my-crate", "0.1.0", "0.2.0"
));
}
#[test]
fn test_has_non_cargo_lock_version_changes_false() {
let head = r#"[[package]]
name = "my-crate"
version = "0.1.0"
"#;
let working = r#"[[package]]
name = "my-crate"
version = "0.2.0"
"#;
assert!(!has_non_cargo_lock_version_changes(
head, working, "my-crate", "0.1.0", "0.2.0"
));
}
}