use crate::properties_version::{PropertyAssignment, property_assignments};
use crate::read_gradle_build_file;
#[cfg(test)]
use crate::version_lexer::GradleDialect;
use crate::version_lexer::{candidate_ranges, gradle_dialect_for};
use anyhow::{Context, Result, bail};
#[cfg(test)]
use std::borrow::Cow;
use std::io::ErrorKind;
use std::ops::{Index, Range, RangeFrom, RangeTo};
use std::path::Path;
use tokio::fs::{read, write};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GradleVersionScope {
ScriptOnly,
ScriptAndAllProjects,
}
trait Spliceable:
Index<RangeTo<usize>, Output = Self> + Index<RangeFrom<usize>, Output = Self>
{
type Spliced;
fn byte_len(&self) -> usize;
fn spliced_with_capacity(capacity: usize) -> Self::Spliced;
fn append_to(&self, spliced: &mut Self::Spliced);
}
impl Spliceable for str {
type Spliced = String;
fn byte_len(&self) -> usize {
self.len()
}
fn spliced_with_capacity(capacity: usize) -> String {
String::with_capacity(capacity)
}
fn append_to(&self, spliced: &mut String) {
spliced.push_str(self);
}
}
impl Spliceable for [u8] {
type Spliced = Vec<u8>;
fn byte_len(&self) -> usize {
self.len()
}
fn spliced_with_capacity(capacity: usize) -> Vec<u8> {
Vec::with_capacity(capacity)
}
fn append_to(&self, spliced: &mut Vec<u8>) {
spliced.extend_from_slice(self);
}
}
fn splice_range<S: Spliceable + ?Sized>(
content: &S,
range: &Range<usize>,
replacement: &S,
) -> S::Spliced {
let mut spliced =
S::spliced_with_capacity(content.byte_len() - range.len() + replacement.byte_len());
content[..range.start].append_to(&mut spliced);
replacement.append_to(&mut spliced);
content[range.end..].append_to(&mut spliced);
spliced
}
#[cfg(test)]
fn replace_candidate<'a>(
content: &'a str,
new_version: &str,
candidates: Vec<Range<usize>>,
) -> Result<Cow<'a, str>> {
match candidates.as_slice() {
[] => bail!("No supported editable version declaration found"),
[candidate] => Ok(Cow::Owned(splice_range(content, candidate, new_version))),
candidates => bail!(
"Ambiguous supported editable version declarations found ({} candidates)",
candidates.len()
),
}
}
#[cfg(test)]
pub(crate) fn update_version_in_kts<'a>(
content: &'a str,
new_version: &str,
policy: GradleVersionScope,
) -> Result<Cow<'a, str>> {
replace_candidate(
content,
new_version,
candidate_ranges(content, policy, GradleDialect::Kotlin).editable,
)
}
#[cfg(test)]
pub(crate) fn update_version_in_groovy<'a>(
content: &'a str,
new_version: &str,
policy: GradleVersionScope,
) -> Result<Cow<'a, str>> {
replace_candidate(
content,
new_version,
candidate_ranges(content, policy, GradleDialect::Groovy).editable,
)
}
pub async fn write_gradle_version(
path: &Path,
new_version: &str,
policy: GradleVersionScope,
) -> Result<()> {
let content = read_gradle_build_file(path).await?;
let script_candidates = candidate_ranges(&content, policy, gradle_dialect_for(path));
let properties_path = path.with_file_name("gradle.properties");
let properties_content = match read(&properties_path).await {
Ok(content) => Some(content),
Err(error) if error.kind() == ErrorKind::NotFound => None,
Err(error) => {
return Err(error).with_context(|| {
format!(
"Failed to read Gradle properties file {}",
properties_path.display()
)
});
}
};
let property_assignments = properties_content
.as_deref()
.map(property_assignments)
.unwrap_or_default();
if script_candidates.editable.len() > 1 {
bail!(
"Ambiguous supported editable version declarations found ({} candidates) in Gradle build file {}",
script_candidates.editable.len(),
path.display()
);
}
if property_assignments.len() > 1 {
bail!(
"Ambiguous active version assignments found ({} candidates) in Gradle properties file {}",
property_assignments.len(),
properties_path.display()
);
}
if matches!(
property_assignments.as_slice(),
[PropertyAssignment::Unsupported]
) {
bail!(
"The active version assignment is computed, continued, or otherwise non-literal in Gradle properties file {}",
properties_path.display()
);
}
if !script_candidates.editable.is_empty() && !property_assignments.is_empty() {
bail!(
"Ambiguous editable version sources found in both Gradle build file {} and Gradle properties file {}",
path.display(),
properties_path.display()
);
}
if let [candidate] = script_candidates.editable.as_slice() {
let updated_content = splice_range(content.as_str(), candidate, new_version);
write(path, &updated_content)
.await
.with_context(|| format!("Failed to write Gradle build file {}", path.display()))?;
return Ok(());
}
if script_candidates.has_unsupported {
bail!(
"The Gradle version source is computed or provider-backed in Gradle build file {}",
path.display()
);
}
if let (Some(properties_content), [PropertyAssignment::Literal(candidate)]) = (
properties_content.as_deref(),
property_assignments.as_slice(),
) {
let updated = splice_range(properties_content, candidate, new_version.as_bytes());
write(&properties_path, updated).await.with_context(|| {
format!(
"Failed to write Gradle properties file {}",
properties_path.display()
)
})?;
return Ok(());
}
bail!(
"No supported editable version declaration found in Gradle build file {} or Gradle properties file {}",
path.display(),
properties_path.display()
)
}
#[cfg(test)]
mod tests {
use super::{GradleVersionScope, write_gradle_version};
use changepacks_utils::test_support;
#[tokio::test]
async fn test_write_gradle_version_build_file_read_error_names_context_and_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let build_path = temp_dir.path().join("missing").join("build.gradle.kts");
let error = write_gradle_version(&build_path, "2.0.0", GradleVersionScope::ScriptOnly)
.await
.expect_err("an unreadable Gradle build file must fail the update");
let chain = format!("{error:#}");
assert!(
chain.contains(&format!(
"Failed to read Gradle build file {}",
build_path.display()
)),
"error chain should carry the build file read context and path, got: {chain}"
);
assert!(
error
.chain()
.any(|cause| cause.downcast_ref::<std::io::Error>().is_some()),
"failure must originate from the read itself, got: {chain}"
);
}
#[tokio::test]
async fn test_write_gradle_version_build_file_write_error_names_context_and_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let build_path = temp_dir.path().join("build.gradle.kts");
std::fs::write(&build_path, "version = \"1.0.0\"\n").unwrap();
test_support::set_readonly(&build_path, true);
let result =
write_gradle_version(&build_path, "2.0.0", GradleVersionScope::ScriptOnly).await;
test_support::set_readonly(&build_path, false);
let error = result.expect_err("write to a readonly Gradle build file must fail");
let chain = format!("{error:#}");
assert!(
chain.contains(&format!(
"Failed to write Gradle build file {}",
build_path.display()
)),
"error chain should carry the build file write context, got: {chain}"
);
}
#[tokio::test]
async fn test_write_gradle_version_properties_read_error_names_context_and_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let build_path = temp_dir.path().join("build.gradle.kts");
let build_source = "version = \"1.0.0\"\n";
std::fs::write(&build_path, build_source).unwrap();
let properties_path = temp_dir.path().join("gradle.properties");
std::fs::create_dir(&properties_path).unwrap();
let error = write_gradle_version(&build_path, "2.0.0", GradleVersionScope::ScriptOnly)
.await
.expect_err("an unreadable gradle.properties must not be treated as absent");
let chain = format!("{error:#}");
assert!(
chain.contains(&format!(
"Failed to read Gradle properties file {}",
properties_path.display()
)),
"error chain should carry the properties read context and path, got: {chain}"
);
assert!(
error
.chain()
.any(|cause| cause.downcast_ref::<std::io::Error>().is_some()),
"failure must originate from the read itself, got: {chain}"
);
assert_eq!(
std::fs::read_to_string(&build_path).unwrap(),
build_source,
"the build file must stay untouched when the properties read fails"
);
}
#[tokio::test]
async fn test_write_gradle_version_properties_write_error_names_context_and_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let build_path = temp_dir.path().join("build.gradle.kts");
std::fs::write(&build_path, "plugins {\n id(\"java\")\n}\n").unwrap();
let properties_path = temp_dir.path().join("gradle.properties");
let properties_source = b"group=com.example\nversion=1.0.0\n";
std::fs::write(&properties_path, properties_source).unwrap();
test_support::set_readonly(&properties_path, true);
let result =
write_gradle_version(&build_path, "2.0.0", GradleVersionScope::ScriptOnly).await;
test_support::set_readonly(&properties_path, false);
let error = result.expect_err("write to a readonly gradle.properties must fail");
let chain = format!("{error:#}");
assert!(
chain.contains(&format!(
"Failed to write Gradle properties file {}",
properties_path.display()
)),
"error chain should carry the properties write context and path, got: {chain}"
);
assert!(
error
.chain()
.any(|cause| cause.downcast_ref::<std::io::Error>().is_some()),
"failure must originate from the write itself, got: {chain}"
);
assert_eq!(
std::fs::read(&properties_path).unwrap(),
properties_source,
"a properties file that could not be written must stay byte-identical"
);
}
}