1use std::path::PathBuf;
2
3use async_trait::async_trait;
4
5use crate::backend::{Backend, Ctx, InstallCtx};
6use crate::error::{Error, Result};
7use crate::pipeline::{self, ArchiveKind, Checksum, HashAlgo, InstallPlan, PipelineCtx};
8use crate::source::Source;
9use crate::version::{ToolVersion, VersionInfo};
10
11#[derive(Clone, Copy)]
12pub enum JvmToolBackend {
13 Maven,
14 Gradle,
15 Kotlin,
16}
17
18struct Release {
19 version: &'static str,
20 file: &'static str,
21 url: &'static str,
22 checksum: &'static str,
23 algorithm: HashAlgo,
24}
25
26impl JvmToolBackend {
27 fn release(self) -> Release {
28 match self {
29 Self::Maven => Release {
30 version: "3.9.16",
31 file: "apache-maven-3.9.16-bin.tar.gz",
32 url: "https://downloads.apache.org/maven/maven-3/3.9.16/binaries/apache-maven-3.9.16-bin.tar.gz",
33 checksum: "831a8591fe20c8243b1dbe7d71e3244f31d1665b0804b2e825e38cbbe5ce0cafb8338851f90780735568773e0a6cd07bbec107cda0b896b008b861075358b6f6",
34 algorithm: HashAlgo::Sha512,
35 },
36 Self::Gradle => Release {
37 version: "9.7.0",
38 file: "gradle-9.7.0-bin.zip",
39 url: "https://services.gradle.org/distributions/gradle-9.7.0-bin.zip",
40 checksum: "84fbba45c7f4c64abc77460e1c00f541e9f960e3c7ed2538f1ede19eacd873ae",
41 algorithm: HashAlgo::Sha256,
42 },
43 Self::Kotlin => Release {
44 version: "2.4.10",
45 file: "kotlin-compiler-2.4.10.zip",
46 url: "https://github.com/JetBrains/kotlin/releases/download/v2.4.10/kotlin-compiler-2.4.10.zip",
47 checksum: "473dd66c7a3ef4b182065b3da670466c1bf2773a9dbb0ed8b33a39fe9d4f876d",
48 algorithm: HashAlgo::Sha256,
49 },
50 }
51 }
52
53 fn bin_path(self) -> &'static str {
54 match self {
55 Self::Kotlin => "bin",
56 Self::Maven | Self::Gradle => "bin",
57 }
58 }
59}
60
61#[async_trait]
62impl Backend for JvmToolBackend {
63 fn id(&self) -> &str {
64 match self {
65 Self::Maven => "maven",
66 Self::Gradle => "gradle",
67 Self::Kotlin => "kotlin",
68 }
69 }
70
71 fn aliases(&self) -> &[&str] {
72 match self {
73 Self::Maven => &["mvn"],
74 Self::Gradle => &[],
75 Self::Kotlin => &["kotlinc"],
76 }
77 }
78
79 fn default_sources(&self) -> Vec<Source> {
80 let release = self.release();
81 let mut sources = vec![Source::official("official", release.url)];
82 if release.url.contains("github.com") {
83 sources.insert(
84 0,
85 Source::mirror(
86 "ghproxy",
87 &format!("https://gh-proxy.com/{}", release.url),
88 10,
89 ),
90 );
91 }
92 sources
93 }
94
95 fn probe_url(&self, _ctx: &Ctx, source: &Source) -> Option<String> {
96 Some(source.download_url.clone())
97 }
98
99 async fn list_remote_versions(&self, _ctx: &Ctx) -> Result<Vec<VersionInfo>> {
100 Ok(vec![VersionInfo::stable(self.release().version)])
101 }
102
103 async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
104 let ctx = ictx.ctx;
105 if tv.version != self.release().version {
106 return Err(Error::VersionResolve {
107 tool: self.id().into(),
108 spec: tv.version.clone(),
109 hint: Some("version is not in the built-in JVM tool catalog".into()),
110 });
111 }
112 if let Some(plan) = pipeline::locked_install_plan(self.id(), tv, true)? {
113 return run_plan(ctx, &plan).await;
114 }
115
116 let release = self.release();
117 let urls = self
118 .default_sources()
119 .into_iter()
120 .map(|source| source.download_url)
121 .collect();
122 let plan = InstallPlan {
123 tool: self.id().into(),
124 version: tv.version.clone(),
125 urls,
126 file_name: release.file.into(),
127 kind: ArchiveKind::from_name(release.file)?,
128 checksum: Some(Checksum {
129 algo: release.algorithm,
130 hex: release.checksum.into(),
131 }),
132 strip_root: true,
133 subdir: None,
134 };
135 run_plan(ctx, &plan).await
136 }
137
138 fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
139 Ok(vec![ctx
140 .dirs
141 .install_path(self.id(), &tv.version)
142 .join(self.bin_path())])
143 }
144
145 fn bin_names(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<Vec<String>> {
146 Ok(match self {
147 Self::Maven => vec!["mvn".into(), "mvnDebug".into()],
148 Self::Gradle => vec!["gradle".into()],
149 Self::Kotlin => vec![
150 "kotlin".into(),
151 "kotlinc".into(),
152 "kotlinc-js".into(),
153 "kotlinc-jvm".into(),
154 ],
155 })
156 }
157
158 fn idiomatic_files(&self) -> &[&str] {
159 match self {
160 Self::Maven => &[".mvn-version"],
161 Self::Gradle => &[".gradle-version"],
162 Self::Kotlin => &[".kotlin-version"],
163 }
164 }
165}
166
167async fn run_plan(ctx: &Ctx, plan: &InstallPlan) -> Result<()> {
168 let pipeline_ctx = PipelineCtx {
169 client: &ctx.client,
170 dirs: &ctx.dirs,
171 cas: &ctx.cas,
172 link_mode: ctx.config.settings.link_mode,
173 show_progress: ctx.show_progress,
174 offline: ctx.config.settings.offline,
175 require_checksums: true,
176 };
177 pipeline::run(plan, &pipeline_ctx).await?;
178 Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[tokio::test]
186 async fn candidates_are_independent_and_checksums_are_required() {
187 let cases = [
188 (JvmToolBackend::Maven, "maven", "3.9.16", "mvn"),
189 (JvmToolBackend::Gradle, "gradle", "9.7.0", "gradle"),
190 (JvmToolBackend::Kotlin, "kotlin", "2.4.10", "kotlinc"),
191 ];
192 for (backend, id, version, binary) in cases {
193 assert_eq!(backend.id(), id);
194 assert_eq!(
195 backend.list_remote_versions(&dummy_ctx()).await.unwrap()[0].version,
196 version
197 );
198 assert!(backend
199 .bin_names(&dummy_ctx(), &ToolVersion::new(id, version))
200 .unwrap()
201 .contains(&binary.to_string()));
202 assert!(!backend.release().checksum.is_empty());
203 }
204 }
205
206 #[tokio::test]
207 async fn every_jvm_tool_installs_from_a_verified_offline_fixture() {
208 let temp = tempfile::tempdir().unwrap();
209 let mut ctx = dummy_ctx_at(temp.path());
210 ctx.config.settings.offline = true;
211 let cases = [
212 (JvmToolBackend::Maven, "mvn"),
213 (JvmToolBackend::Gradle, "gradle"),
214 (JvmToolBackend::Kotlin, "kotlinc"),
215 ];
216 for (backend, binary) in cases {
217 let release = backend.release();
218 let file_name = format!("{}-fixture.tar.gz", backend.id());
219 let archive = temp.path().join(&file_name);
220 write_fixture_archive(&archive, binary);
221 let checksum = pipeline::verify::hash_file(&archive, HashAlgo::Sha256).unwrap();
222 let cached =
223 pipeline::artifact_cache_path(&ctx.dirs, backend.id(), release.version, &file_name)
224 .unwrap();
225 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
226 std::fs::copy(&archive, &cached).unwrap();
227 let mut version = ToolVersion::new(backend.id(), release.version);
228 version.options.extend(std::collections::BTreeMap::from([
229 (
230 pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
231 "https://invalid.example/fixture.tar.gz".into(),
232 ),
233 (pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(), file_name),
234 (
235 pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
236 format!("sha256:{checksum}"),
237 ),
238 ]));
239 backend
240 .install(&InstallCtx { ctx: &ctx }, &version)
241 .await
242 .unwrap();
243 assert!(ctx
244 .dirs
245 .install_path(backend.id(), release.version)
246 .join("bin")
247 .join(binary)
248 .is_file());
249 }
250 }
251
252 fn write_fixture_archive(path: &std::path::Path, binary: &str) {
253 let file = std::fs::File::create(path).unwrap();
254 let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
255 let mut archive = tar::Builder::new(encoder);
256 let contents = b"#!/bin/sh\nexit 0\n";
257 let mut header = tar::Header::new_gnu();
258 header.set_size(contents.len() as u64);
259 header.set_mode(0o755);
260 header.set_cksum();
261 archive
262 .append_data(&mut header, format!("fixture/bin/{binary}"), &contents[..])
263 .unwrap();
264 archive.finish().unwrap();
265 }
266
267 fn dummy_ctx() -> Ctx {
268 let temp = tempfile::tempdir().unwrap().keep();
269 dummy_ctx_at(&temp)
270 }
271
272 fn dummy_ctx_at(temp: &std::path::Path) -> Ctx {
273 let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
274 "OSDK_DATA_DIR" => Some(temp.join("data").display().to_string()),
275 "OSDK_CACHE_DIR" => Some(temp.join("cache").display().to_string()),
276 "OSDK_CONFIG_DIR" => Some(temp.join("config").display().to_string()),
277 _ => None,
278 })
279 .unwrap();
280 dirs.ensure().unwrap();
281 Ctx {
282 cas: std::sync::Arc::new(crate::store::Cas::new(dirs.store.clone())),
283 dirs,
284 platform: crate::platform::Platform::current(),
285 config: crate::config::Config {
286 settings: Default::default(),
287 sources: Default::default(),
288 tools: Default::default(),
289 tool_configs: Default::default(),
290 global_tools: Default::default(),
291 global_tool_configs: Default::default(),
292 tool_origins: Default::default(),
293 aliases: Default::default(),
294 project_config_path: None,
295 },
296 client: reqwest::Client::new(),
297 show_progress: false,
298 }
299 }
300}