Skip to main content

changepacks_java/
version_updater.rs

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/// Select which Gradle scopes may own the project version declaration.
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum GradleVersionScope {
17    /// Only a declaration in the build script's outermost scope.
18    ScriptOnly,
19    /// An outermost declaration or a direct declaration in a top-level
20    /// `allprojects { ... }` block.
21    ScriptAndAllProjects,
22}
23
24/// A buffer whose byte ranges can be spliced.
25///
26/// The two Gradle version sources disagree only on their buffer flavour: a
27/// build script is decoded UTF-8 text (`str` spliced into a `String`) while
28/// `gradle.properties` stays raw bytes (`[u8]` spliced into a `Vec<u8>`) so
29/// that a non-UTF-8 properties file survives byte-for-byte. This trait names
30/// exactly that difference, letting [`splice_range`] hold the one copy of the
31/// prefix/replacement/suffix concatenation.
32trait Spliceable:
33    Index<RangeTo<usize>, Output = Self> + Index<RangeFrom<usize>, Output = Self>
34{
35    /// The owned buffer a splice produces.
36    type Spliced;
37
38    /// Length in bytes, matching the units of the spliced range.
39    fn byte_len(&self) -> usize;
40
41    /// Allocate an empty spliced buffer able to hold `capacity` bytes.
42    fn spliced_with_capacity(capacity: usize) -> Self::Spliced;
43
44    /// Append `self` to an in-progress spliced buffer.
45    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
80/// Replace the byte range `range` of `content` with `replacement`, leaving
81/// every byte outside the range untouched.
82fn 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/// Test-only: the production path splices the single candidate directly via
96/// [`splice_range`]; this wrapper adds the empty/ambiguous arbitration that
97/// only the isolated build-script helpers below need.
98#[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/// Update version in build.gradle.kts content
115///
116/// Test-only: production version writing goes through [`write_gradle_version`],
117/// which owns the build-script/`gradle.properties` arbitration. This helper
118/// only exercises the build-script replacement half in isolation.
119///
120/// # Errors
121/// Returns an error unless exactly one declaration exists in a supported scope.
122#[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/// Update version in build.gradle (Groovy) content
136///
137/// Test-only: production version writing goes through [`write_gradle_version`],
138/// which owns the build-script/`gradle.properties` arbitration. This helper
139/// only exercises the build-script replacement half in isolation.
140///
141/// # Errors
142/// Returns an error unless exactly one declaration exists in a supported scope.
143#[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
156/// Write `new_version` into a Gradle build file (`.kts` or Groovy),
157/// preserving formatting.
158///
159/// # Errors
160/// Returns an error if the file cannot be read or written, or unless exactly
161/// one editable version declaration exists in a supported scope.
162pub 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    /// The build-script READ is the first I/O in the function, and it is
261    /// delegated to [`crate::read_gradle_build_file`] — the single helper that
262    /// attaches the `Failed to read Gradle build file <path>` context, shared
263    /// with the finder's manifest read. Pin it so a missing or unreadable
264    /// build script stays attributable to that file rather than surfacing as
265    /// a bare `os error`.
266    ///
267    /// The fixture points at a build script inside a subdirectory that is
268    /// never created, so the read fails on every supported platform without
269    /// depending on permission bits.
270    #[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    /// The build-script write-back is the only place that attaches the
296    /// `Failed to write Gradle build file <path>` context. Pin it so a
297    /// permission failure stays attributable to the build script rather than
298    /// surfacing as a bare `os error`.
299    #[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        // The read succeeds (readonly still permits reads); it is the
306        // write-back that must fail, so flip the readonly bit after seeding.
307        test_support::set_readonly(&build_path, true);
308
309        // A NEW version guarantees the write is actually attempted against the
310        // readonly file rather than being short-circuited as an unchanged no-op.
311        let result =
312            write_gradle_version(&build_path, "2.0.0", GradleVersionScope::ScriptOnly).await;
313
314        // Restore write permission BEFORE asserting so `TempDir` cleanup
315        // succeeds even if an assertion panics.
316        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    /// The `gradle.properties` READ arm distinguishes "absent" (a legitimate
330    /// `None`) from "unreadable" (a hard failure). Only a non-`NotFound` error
331    /// reaches the context branch, so the fixture makes `gradle.properties` a
332    /// DIRECTORY: reading it fails on every supported platform (`EISDIR` on
333    /// Unix, access-denied on Windows) without depending on permission bits.
334    ///
335    /// The build script deliberately carries a perfectly editable declaration,
336    /// pinning that an unreadable properties file aborts the whole update
337    /// instead of silently falling through to the build-script write — the
338    /// ambiguity checks cannot run without the properties content.
339    #[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    /// The `gradle.properties` WRITE arm is only reached when the build script
375    /// declares no editable version, so the fixture keeps the script version
376    /// free and lets the properties file own the literal. A readonly properties
377    /// file then fails the write, which must stay attributable to the
378    /// properties file rather than surfacing as a bare `os error`.
379    #[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        // The read succeeds (readonly still permits reads); it is the
390        // write-back that must fail, so flip the readonly bit after seeding.
391        test_support::set_readonly(&properties_path, true);
392
393        // A NEW version guarantees the write is actually attempted against the
394        // readonly file rather than being short-circuited as an unchanged no-op.
395        let result =
396            write_gradle_version(&build_path, "2.0.0", GradleVersionScope::ScriptOnly).await;
397
398        // Restore write permission BEFORE asserting so `TempDir` cleanup
399        // succeeds even if an assertion panics.
400        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}