1use crate::properties_version::{PropertyAssignment, property_assignments};
2use crate::read_gradle_build_file;
3#[cfg(test)]
4use crate::version_lexer::GradleDialect;
5use crate::version_lexer::{candidate_ranges, gradle_dialect_for};
6use anyhow::{Context, Result, bail};
7#[cfg(test)]
8use std::borrow::Cow;
9use std::io::ErrorKind;
10use std::ops::{Index, Range, RangeFrom, RangeTo};
11use std::path::Path;
12use tokio::fs::{read, write};
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum GradleVersionScope {
17 ScriptOnly,
19 ScriptAndAllProjects,
22}
23
24trait Spliceable:
33 Index<RangeTo<usize>, Output = Self> + Index<RangeFrom<usize>, Output = Self>
34{
35 type Spliced;
37
38 fn byte_len(&self) -> usize;
40
41 fn spliced_with_capacity(capacity: usize) -> Self::Spliced;
43
44 fn append_to(&self, spliced: &mut Self::Spliced);
46}
47
48impl Spliceable for str {
49 type Spliced = String;
50
51 fn byte_len(&self) -> usize {
52 self.len()
53 }
54
55 fn spliced_with_capacity(capacity: usize) -> String {
56 String::with_capacity(capacity)
57 }
58
59 fn append_to(&self, spliced: &mut String) {
60 spliced.push_str(self);
61 }
62}
63
64impl Spliceable for [u8] {
65 type Spliced = Vec<u8>;
66
67 fn byte_len(&self) -> usize {
68 self.len()
69 }
70
71 fn spliced_with_capacity(capacity: usize) -> Vec<u8> {
72 Vec::with_capacity(capacity)
73 }
74
75 fn append_to(&self, spliced: &mut Vec<u8>) {
76 spliced.extend_from_slice(self);
77 }
78}
79
80fn splice_range<S: Spliceable + ?Sized>(
83 content: &S,
84 range: &Range<usize>,
85 replacement: &S,
86) -> S::Spliced {
87 let mut spliced =
88 S::spliced_with_capacity(content.byte_len() - range.len() + replacement.byte_len());
89 content[..range.start].append_to(&mut spliced);
90 replacement.append_to(&mut spliced);
91 content[range.end..].append_to(&mut spliced);
92 spliced
93}
94
95#[cfg(test)]
99fn replace_candidate<'a>(
100 content: &'a str,
101 new_version: &str,
102 candidates: Vec<Range<usize>>,
103) -> Result<Cow<'a, str>> {
104 match candidates.as_slice() {
105 [] => bail!("No supported editable version declaration found"),
106 [candidate] => Ok(Cow::Owned(splice_range(content, candidate, new_version))),
107 candidates => bail!(
108 "Ambiguous supported editable version declarations found ({} candidates)",
109 candidates.len()
110 ),
111 }
112}
113
114#[cfg(test)]
123pub(crate) fn update_version_in_kts<'a>(
124 content: &'a str,
125 new_version: &str,
126 policy: GradleVersionScope,
127) -> Result<Cow<'a, str>> {
128 replace_candidate(
129 content,
130 new_version,
131 candidate_ranges(content, policy, GradleDialect::Kotlin).editable,
132 )
133}
134
135#[cfg(test)]
144pub(crate) fn update_version_in_groovy<'a>(
145 content: &'a str,
146 new_version: &str,
147 policy: GradleVersionScope,
148) -> Result<Cow<'a, str>> {
149 replace_candidate(
150 content,
151 new_version,
152 candidate_ranges(content, policy, GradleDialect::Groovy).editable,
153 )
154}
155
156pub async fn write_gradle_version(
163 path: &Path,
164 new_version: &str,
165 policy: GradleVersionScope,
166) -> Result<()> {
167 let content = read_gradle_build_file(path).await?;
168
169 let script_candidates = candidate_ranges(&content, policy, gradle_dialect_for(path));
170 let properties_path = path.with_file_name("gradle.properties");
171 let properties_content = match read(&properties_path).await {
172 Ok(content) => Some(content),
173 Err(error) if error.kind() == ErrorKind::NotFound => None,
174 Err(error) => {
175 return Err(error).with_context(|| {
176 format!(
177 "Failed to read Gradle properties file {}",
178 properties_path.display()
179 )
180 });
181 }
182 };
183 let property_assignments = properties_content
184 .as_deref()
185 .map(property_assignments)
186 .unwrap_or_default();
187
188 if script_candidates.editable.len() > 1 {
189 bail!(
190 "Ambiguous supported editable version declarations found ({} candidates) in Gradle build file {}",
191 script_candidates.editable.len(),
192 path.display()
193 );
194 }
195 if property_assignments.len() > 1 {
196 bail!(
197 "Ambiguous active version assignments found ({} candidates) in Gradle properties file {}",
198 property_assignments.len(),
199 properties_path.display()
200 );
201 }
202 if matches!(
203 property_assignments.as_slice(),
204 [PropertyAssignment::Unsupported]
205 ) {
206 bail!(
207 "The active version assignment is computed, continued, or otherwise non-literal in Gradle properties file {}",
208 properties_path.display()
209 );
210 }
211 if !script_candidates.editable.is_empty() && !property_assignments.is_empty() {
212 bail!(
213 "Ambiguous editable version sources found in both Gradle build file {} and Gradle properties file {}",
214 path.display(),
215 properties_path.display()
216 );
217 }
218
219 if let [candidate] = script_candidates.editable.as_slice() {
220 let updated_content = splice_range(content.as_str(), candidate, new_version);
221
222 write(path, &updated_content)
223 .await
224 .with_context(|| format!("Failed to write Gradle build file {}", path.display()))?;
225 return Ok(());
226 }
227 if script_candidates.has_unsupported {
228 bail!(
229 "The Gradle version source is computed or provider-backed in Gradle build file {}",
230 path.display()
231 );
232 }
233 if let (Some(properties_content), [PropertyAssignment::Literal(candidate)]) = (
234 properties_content.as_deref(),
235 property_assignments.as_slice(),
236 ) {
237 let updated = splice_range(properties_content, candidate, new_version.as_bytes());
238
239 write(&properties_path, updated).await.with_context(|| {
240 format!(
241 "Failed to write Gradle properties file {}",
242 properties_path.display()
243 )
244 })?;
245 return Ok(());
246 }
247
248 bail!(
249 "No supported editable version declaration found in Gradle build file {} or Gradle properties file {}",
250 path.display(),
251 properties_path.display()
252 )
253}
254
255#[cfg(test)]
256mod tests {
257 use super::{GradleVersionScope, write_gradle_version};
258 use changepacks_utils::test_support;
259
260 #[tokio::test]
271 async fn test_write_gradle_version_build_file_read_error_names_context_and_path() {
272 let temp_dir = tempfile::TempDir::new().unwrap();
273 let build_path = temp_dir.path().join("missing").join("build.gradle.kts");
274
275 let error = write_gradle_version(&build_path, "2.0.0", GradleVersionScope::ScriptOnly)
276 .await
277 .expect_err("an unreadable Gradle build file must fail the update");
278
279 let chain = format!("{error:#}");
280 assert!(
281 chain.contains(&format!(
282 "Failed to read Gradle build file {}",
283 build_path.display()
284 )),
285 "error chain should carry the build file read context and path, got: {chain}"
286 );
287 assert!(
288 error
289 .chain()
290 .any(|cause| cause.downcast_ref::<std::io::Error>().is_some()),
291 "failure must originate from the read itself, got: {chain}"
292 );
293 }
294
295 #[tokio::test]
300 async fn test_write_gradle_version_build_file_write_error_names_context_and_path() {
301 let temp_dir = tempfile::TempDir::new().unwrap();
302 let build_path = temp_dir.path().join("build.gradle.kts");
303 std::fs::write(&build_path, "version = \"1.0.0\"\n").unwrap();
304
305 test_support::set_readonly(&build_path, true);
308
309 let result =
312 write_gradle_version(&build_path, "2.0.0", GradleVersionScope::ScriptOnly).await;
313
314 test_support::set_readonly(&build_path, false);
317
318 let error = result.expect_err("write to a readonly Gradle build file must fail");
319 let chain = format!("{error:#}");
320 assert!(
321 chain.contains(&format!(
322 "Failed to write Gradle build file {}",
323 build_path.display()
324 )),
325 "error chain should carry the build file write context, got: {chain}"
326 );
327 }
328
329 #[tokio::test]
340 async fn test_write_gradle_version_properties_read_error_names_context_and_path() {
341 let temp_dir = tempfile::TempDir::new().unwrap();
342 let build_path = temp_dir.path().join("build.gradle.kts");
343 let build_source = "version = \"1.0.0\"\n";
344 std::fs::write(&build_path, build_source).unwrap();
345
346 let properties_path = temp_dir.path().join("gradle.properties");
347 std::fs::create_dir(&properties_path).unwrap();
348
349 let error = write_gradle_version(&build_path, "2.0.0", GradleVersionScope::ScriptOnly)
350 .await
351 .expect_err("an unreadable gradle.properties must not be treated as absent");
352
353 let chain = format!("{error:#}");
354 assert!(
355 chain.contains(&format!(
356 "Failed to read Gradle properties file {}",
357 properties_path.display()
358 )),
359 "error chain should carry the properties read context and path, got: {chain}"
360 );
361 assert!(
362 error
363 .chain()
364 .any(|cause| cause.downcast_ref::<std::io::Error>().is_some()),
365 "failure must originate from the read itself, got: {chain}"
366 );
367 assert_eq!(
368 std::fs::read_to_string(&build_path).unwrap(),
369 build_source,
370 "the build file must stay untouched when the properties read fails"
371 );
372 }
373
374 #[tokio::test]
380 async fn test_write_gradle_version_properties_write_error_names_context_and_path() {
381 let temp_dir = tempfile::TempDir::new().unwrap();
382 let build_path = temp_dir.path().join("build.gradle.kts");
383 std::fs::write(&build_path, "plugins {\n id(\"java\")\n}\n").unwrap();
384
385 let properties_path = temp_dir.path().join("gradle.properties");
386 let properties_source = b"group=com.example\nversion=1.0.0\n";
387 std::fs::write(&properties_path, properties_source).unwrap();
388
389 test_support::set_readonly(&properties_path, true);
392
393 let result =
396 write_gradle_version(&build_path, "2.0.0", GradleVersionScope::ScriptOnly).await;
397
398 test_support::set_readonly(&properties_path, false);
401
402 let error = result.expect_err("write to a readonly gradle.properties must fail");
403 let chain = format!("{error:#}");
404 assert!(
405 chain.contains(&format!(
406 "Failed to write Gradle properties file {}",
407 properties_path.display()
408 )),
409 "error chain should carry the properties write context and path, got: {chain}"
410 );
411 assert!(
412 error
413 .chain()
414 .any(|cause| cause.downcast_ref::<std::io::Error>().is_some()),
415 "failure must originate from the write itself, got: {chain}"
416 );
417 assert_eq!(
418 std::fs::read(&properties_path).unwrap(),
419 properties_source,
420 "a properties file that could not be written must stay byte-identical"
421 );
422 }
423}