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;
let mut found_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 found_block_start.is_some() {
return Some((found_block_start?, line_start));
}
current_block_start = Some(line_start);
} else if trimmed.starts_with('[') && trimmed != "[[package]]" {
if found_block_start.is_some() {
return Some((found_block_start?, line_start));
}
current_block_start = None;
} else if trimmed == target_name && found_block_start.is_none() {
found_block_start = current_block_start;
}
}
found_block_start.map(|start| (start, cursor))
}
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 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_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"
));
}
}