use crate::core::config::{CitationAuthor, CitationConfig};
use std::sync::LazyLock;
use tracing::debug;
pub(super) fn sync_gemfile_lock(content: &str, new_ruby_version: &str) -> Option<String> {
use std::sync::LazyLock;
static GEM_VERSION_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?m)^([ \t]*)([A-Za-z0-9_-]+) \(([^)]+)\)$").expect("valid regex")
});
let path_gem_names: std::collections::HashSet<String> = {
let mut names = std::collections::HashSet::new();
let mut in_specs = false;
for line in content.lines() {
if line.trim_start().starts_with("specs:") {
}
if line == "PATH" {
in_specs = true;
continue;
}
if in_specs && line.starts_with(" specs:") {
continue;
}
if in_specs && line.starts_with(" ") {
if let Some(caps) = GEM_VERSION_RE.captures(line) {
let indent = &caps[1];
let name = &caps[2];
if indent.len() == 4 {
names.insert(name.to_string());
}
}
continue;
}
if in_specs
&& !line.starts_with(" ")
&& !line.trim().is_empty()
&& line != "PATH"
&& !line.starts_with(" ")
{
in_specs = false;
}
}
names
};
if path_gem_names.is_empty() {
return None;
}
let mut changed = false;
let new_content = content
.lines()
.map(|line| {
if let Some(caps) = GEM_VERSION_RE.captures(line) {
let gem_name = &caps[2];
let current_version = &caps[3];
if path_gem_names.contains(gem_name) && current_version != new_ruby_version {
changed = true;
let indent = &caps[1];
return format!("{indent}{gem_name} ({new_ruby_version})");
}
}
line.to_string()
})
.collect::<Vec<_>>()
.join("\n");
let new_content = if content.ends_with('\n') {
format!("{new_content}\n")
} else {
new_content
};
if changed { Some(new_content) } else { None }
}
pub(super) fn sync_e2e_java_pom(content: &str, new_version: &str) -> Option<String> {
use std::sync::LazyLock;
static DEP_BLOCK_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?s)<dependency>(.*?)</dependency>").expect("valid regex"));
static VERSION_TAG_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"<version>([^<]*)</version>").expect("valid regex"));
static SYSTEM_PATH_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(<systemPath>[^<]*?-)(\d+\.\d+\.\d+(?:-[A-Za-z0-9._]+)*)(\.[a-zA-Z]+</systemPath>)")
.expect("valid regex")
});
let mut result = content.to_string();
let mut changed = false;
let dep_matches: Vec<(usize, usize, String)> = DEP_BLOCK_RE
.find_iter(content)
.filter_map(|m| {
let block = m.as_str();
if !block.contains("<systemPath>") {
return None;
}
let new_block = VERSION_TAG_RE
.replace(block, |caps: ®ex::Captures<'_>| {
let ver = &caps[1];
if ver != new_version && !ver.contains('$') && !ver.contains('.') && ver.parse::<u64>().is_err() {
format!("<version>{ver}</version>")
} else if ver != new_version && ver.contains('.') && !ver.contains('$') {
format!("<version>{new_version}</version>")
} else {
format!("<version>{ver}</version>")
}
})
.into_owned();
let new_block = SYSTEM_PATH_RE
.replace(&new_block, |caps: ®ex::Captures<'_>| {
format!("{}{}{}", &caps[1], new_version, &caps[3])
})
.into_owned();
if new_block != block {
Some((m.start(), m.end(), new_block))
} else {
None
}
})
.collect();
for (start, end, new_block) in dep_matches.into_iter().rev() {
result.replace_range(start..end, &new_block);
changed = true;
}
if changed { Some(result) } else { None }
}
pub(super) fn sync_e2e_go_mod(content: &str, module_path_fragment: &str, new_version: &str) -> Option<String> {
let mut changed = false;
let lines: Vec<String> = content
.lines()
.map(|line| {
let trimmed = line.trim();
if trimmed.starts_with(module_path_fragment) || line.trim_start().starts_with(module_path_fragment) {
if let Some(pos) = trimmed.rfind(" v") {
let current_ver = &trimmed[pos + 2..]; if current_ver != new_version {
changed = true;
let indent = &line[..line.len() - line.trim_start().len()];
let module_path = &trimmed[..pos];
return format!("{indent}{module_path} v{new_version}");
}
}
}
line.to_string()
})
.collect();
if !changed {
return None;
}
let new_content = lines.join("\n");
let new_content = if content.ends_with('\n') {
format!("{new_content}\n")
} else {
new_content
};
Some(new_content)
}
pub(super) fn sync_e2e_dart_pubspec_lock(content: &str, new_version: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
let n = lines.len();
let mut result: Vec<String> = Vec::with_capacity(n);
let mut changed = false;
let mut i = 0;
while i < n {
let line = lines[i];
if line.starts_with(" ") && !line.starts_with(" ") && line.trim_end().ends_with(':') {
let block_start = i;
i += 1;
while i < n && (lines[i].starts_with(" ") || lines[i].trim().is_empty()) {
i += 1;
}
let block = &lines[block_start..i];
let is_path_source = block.iter().any(|l| l.trim() == "source: path");
if is_path_source {
for &bline in block {
let trimmed = bline.trim();
if trimmed.starts_with("version:") {
let val = trimmed.trim_start_matches("version:").trim().trim_matches('"');
if val != new_version {
changed = true;
let indent = &bline[..bline.len() - bline.trim_start().len()];
result.push(format!("{indent}version: \"{new_version}\""));
} else {
result.push(bline.to_string());
}
} else {
result.push(bline.to_string());
}
}
} else {
for &bline in block {
result.push(bline.to_string());
}
}
} else {
result.push(line.to_string());
i += 1;
}
}
if !changed {
return None;
}
let new_content = result.join("\n");
let new_content = if content.ends_with('\n') {
format!("{new_content}\n")
} else {
new_content
};
Some(new_content)
}
pub(super) fn read_workspace_license(version_from: &str) -> Option<String> {
let content = std::fs::read_to_string(version_from).ok()?;
let value: toml::Value = toml::from_str(&content).ok()?;
value
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("license"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
value
.get("package")
.and_then(|p| p.get("license"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
}
pub(super) fn render_citation_cff(citation: &CitationConfig, version: &str, fallback_license: Option<&str>) -> String {
let mut out = String::new();
out.push_str("# This file is generated by alef sync-versions; do not edit by hand.\n");
out.push_str("# Source: [workspace.citation] in alef.toml + workspace version in Cargo.toml.\n");
out.push_str("cff-version: 1.2.0\n");
out.push_str(&format!("message: {}\n", yaml_scalar(&citation.message)));
out.push_str(&format!("title: {}\n", yaml_scalar(&citation.title)));
out.push_str(&format!("abstract: {}\n", yaml_scalar(&citation.abstract_)));
out.push_str("authors:\n");
for author in &citation.authors {
out.push_str(&render_citation_author(author));
}
out.push_str(&format!(
"repository-code: {}\n",
yaml_scalar(&citation.repository_code)
));
if let Some(url) = &citation.url {
out.push_str(&format!("url: {}\n", yaml_scalar(url)));
}
let license = citation.license.as_deref().or(fallback_license);
if let Some(license) = license {
out.push_str(&format!("license: {}\n", yaml_scalar(license)));
}
out.push_str(&format!("version: {version}\n"));
if let Some(date) = &citation.date_released {
out.push_str(&format!("date-released: {}\n", yaml_scalar(date)));
}
if let Some(doi) = &citation.doi {
out.push_str(&format!("doi: {}\n", yaml_scalar(doi)));
}
out
}
fn render_citation_author(author: &CitationAuthor) -> String {
let mut entry = String::new();
let person_form = author.family_names.is_some() || author.given_names.is_some();
if person_form {
if let Some(family) = &author.family_names {
entry.push_str(&format!(" - family-names: {}\n", yaml_scalar(family)));
if let Some(given) = &author.given_names {
entry.push_str(&format!(" given-names: {}\n", yaml_scalar(given)));
}
} else if let Some(given) = &author.given_names {
entry.push_str(&format!(" - given-names: {}\n", yaml_scalar(given)));
}
if let Some(email) = &author.email {
entry.push_str(&format!(" email: {}\n", yaml_scalar(email)));
}
if let Some(orcid) = &author.orcid {
entry.push_str(&format!(" orcid: {}\n", yaml_scalar(orcid)));
}
} else if let Some(name) = &author.name {
entry.push_str(&format!(" - name: {}\n", yaml_scalar(name)));
if let Some(email) = &author.email {
entry.push_str(&format!(" email: {}\n", yaml_scalar(email)));
}
if let Some(orcid) = &author.orcid {
entry.push_str(&format!(" orcid: {}\n", yaml_scalar(orcid)));
}
}
entry
}
pub(super) fn yaml_scalar(value: &str) -> String {
let needs_quoting = value.is_empty()
|| value.contains(':')
|| value.contains('#')
|| value.contains('"')
|| value.contains('\\')
|| value.contains('\n')
|| value.contains('\t')
|| value.contains(' ')
|| value.contains('\'')
|| value.contains('@')
|| value.starts_with(['!', '&', '*', '?', '|', '>', '"', '%', '`', '[', ']', '{', '}', ',']);
if needs_quoting {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
} else {
value.to_string()
}
}
static CITATION_VERSION_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"(?m)^(version:[ \t]+)(?:"([^"\n]*)"|'([^'\n]*)'|([^\s#'"]+))[ \t]*(?:#[^\n]*)?$"#)
.expect("valid regex")
});
pub(super) fn replace_citation_version(content: &str, new_version: &str) -> Option<String> {
let captures = CITATION_VERSION_RE.captures(content)?;
let (current, replacement) = if let Some(value) = captures.get(2) {
(value.as_str(), format!("{}\"{new_version}\"", &captures[1]))
} else if let Some(value) = captures.get(3) {
(value.as_str(), format!("{}'{new_version}'", &captures[1]))
} else if let Some(value) = captures.get(4) {
(value.as_str(), format!("{}{new_version}", &captures[1]))
} else {
return None;
};
if current == new_version {
return None;
}
let new_content = CITATION_VERSION_RE.replace(content, replacement.as_str()).into_owned();
if new_content == content {
return None;
}
Some(new_content)
}
pub(super) fn replace_version_pattern(content: &str, pattern: &str, version: &str) -> Option<String> {
let regex = regex::Regex::new(pattern).ok()?;
let captures = regex.captures(content)?;
let matched = captures.get(0)?.as_str();
if matched_version_equals(matched, version) {
return None;
}
let replacement = match pattern {
p if p.contains("version =") && !p.contains("spec") && !p.contains("VERSION") => {
format!(r#"version = "{version}""#)
}
p if p.contains("\"version\"") && p.contains("\"") => format!(r#""version": "{version}""#),
p if p.contains("spec") => format!("spec.version = \"{version}\""),
p if p.contains("<version>") => format!("<version>{version}</version>"),
p if p.contains("<Version>") => format!("<Version>{version}</Version>"),
p if p.contains("@version") => format!(r#"@version "{version}""#),
p if p.contains("version:") && p.contains(":") => format!(r#"version: "{version}""#),
p if p.contains("__version__") => format!(r#"__version__ = "{version}""#),
p if p.contains("defaultFFIVersion") => format!(r#"defaultFFIVersion = "{version}""#),
p if p.contains("moduleVersion") => format!(r#"moduleVersion = "{version}""#),
p if p.contains("Version:") => format!("Version: {version}"),
p if p.contains("from:") => format!(r#"from: "{version}""#),
p if p.contains("VERSION=\"") => format!(r#"VERSION="{version}""#),
p if p.contains("VERSION") => format!("VERSION = \"{version}\""),
_ => return None,
};
let new_content = regex.replace(content, replacement.as_str()).to_string();
if new_content == content {
return None;
}
Some(new_content)
}
pub(super) fn matched_version_equals(matched: &str, target: &str) -> bool {
extract_version_literal(matched).is_some_and(|v| v == target)
}
pub(super) fn restore_gleam_dep_ranges(content: &str) -> String {
use crate::core::template_versions::hex;
static GLEAM_DEP_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"(?m)^(gleam_stdlib|gleeunit)\s*=\s*"([^"]*)""#).expect("valid regex")
});
GLEAM_DEP_RE
.replace_all(content, |caps: ®ex::Captures<'_>| {
let name = &caps[1];
let canonical = match name {
"gleam_stdlib" => hex::GLEAM_STDLIB_VERSION_RANGE,
"gleeunit" => hex::GLEEUNIT_VERSION_RANGE,
_ => return caps[0].to_string(),
};
format!("{name} = \"{canonical}\"")
})
.into_owned()
}
fn extract_version_literal(matched: &str) -> Option<&str> {
if let Some(start) = matched.find(['"', '\'']) {
let quote = matched.as_bytes()[start];
let rest = &matched[start + 1..];
if let Some(end) = rest.find(quote as char) {
return Some(&rest[..end]);
}
}
if let Some(close) = matched.find('>') {
let rest = &matched[close + 1..];
if let Some(end) = rest.find('<') {
return Some(&rest[..end]);
}
}
if let Some(colon) = matched.find(':') {
return Some(matched[colon + 1..].trim());
}
if let Some(eq) = matched.find('=') {
return Some(matched[eq + 1..].trim());
}
None
}
pub(super) fn replace_gradle_project_version(content: &str, new_version: &str) -> Option<String> {
static GRADLE_VERSION_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"(?m)^(\s*)version\s*=\s*"[^"]*""#).expect("valid regex"));
let captures = GRADLE_VERSION_RE.captures(content)?;
let matched = captures.get(0)?.as_str();
if matched_version_equals(matched, new_version) {
return None;
}
let indent = captures.get(1).map(|m| m.as_str()).unwrap_or("");
let replacement = format!(r#"{indent}version = "{new_version}""#);
let new_content = GRADLE_VERSION_RE.replace(content, replacement.as_str()).into_owned();
if new_content == content {
return None;
}
Some(new_content)
}
pub(super) fn sync_cargo_lock_path_versions(content: &str, new_version: &str) -> Option<String> {
let mut out = String::with_capacity(content.len());
let mut changed = false;
let mut block: Vec<&str> = Vec::new();
let mut in_package_block = false;
let flush = |block: &mut Vec<&str>, out: &mut String, changed: &mut bool| {
if block.is_empty() {
return;
}
let is_package = block.first().is_some_and(|l| l.trim() == "[[package]]");
let has_source = block.iter().any(|l| l.trim_start().starts_with("source = "));
for line in block.iter() {
if is_package && !has_source && line.trim_start().starts_with("version = ") {
let indent_len = line.len() - line.trim_start().len();
let indent = &line[..indent_len];
let rewritten = format!(r#"{indent}version = "{new_version}""#);
if rewritten != *line {
*changed = true;
}
out.push_str(&rewritten);
} else {
out.push_str(line);
}
out.push('\n');
}
block.clear();
};
for line in content.lines() {
if line.trim() == "[[package]]" {
flush(&mut block, &mut out, &mut changed);
in_package_block = true;
block.push(line);
} else if in_package_block {
block.push(line);
} else {
out.push_str(line);
out.push('\n');
}
}
flush(&mut block, &mut out, &mut changed);
if !changed {
return None;
}
if !content.ends_with('\n') {
out.pop();
}
Some(out)
}
pub(super) fn sync_docs_version_badges(docs_reference_dir: &std::path::Path, new_version: &str) -> Vec<String> {
static BADGE_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"(<span class="version-badge">v)[^<]*(</span>)"#).expect("valid regex"));
let mut updated = Vec::new();
let pattern = docs_reference_dir.join("api-*.md");
let Some(pattern_str) = pattern.to_str() else {
return updated;
};
for entry in glob::glob(pattern_str).into_iter().flatten().flatten() {
let Ok(content) = std::fs::read_to_string(&entry) else {
continue;
};
let replacement = format!("${{1}}{new_version}${{2}}");
let new_content = BADGE_RE.replace_all(&content, replacement.as_str()).into_owned();
if new_content != content {
if let Err(e) = std::fs::write(&entry, &new_content) {
debug!("Could not write {}: {e}", entry.display());
} else {
updated.push(entry.to_string_lossy().to_string());
}
}
}
updated
}