Skip to main content

kopi/locking/
package_coordinate.rs

1// Copyright 2025 dentsusoken
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::error::{KopiError, Result};
16use crate::models::api::Package;
17use crate::paths::shared::sanitize_segment;
18use std::fmt;
19
20/// Represents the type of Java package being coordinated for locking.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum PackageKind {
23    Jdk,
24    Jre,
25}
26
27impl PackageKind {
28    fn slug_segment(self) -> &'static str {
29        match self {
30            PackageKind::Jdk => "jdk",
31            PackageKind::Jre => "jre",
32        }
33    }
34
35    pub fn try_from_str(value: &str) -> Result<Self> {
36        match value.to_ascii_lowercase().as_str() {
37            "jdk" => Ok(PackageKind::Jdk),
38            "jre" => Ok(PackageKind::Jre),
39            other => Err(KopiError::ValidationError(format!(
40                "Unsupported package type '{other}'"
41            ))),
42        }
43    }
44}
45
46impl fmt::Display for PackageKind {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(f, "{}", self.slug_segment())
49    }
50}
51
52/// Coordinate that uniquely identifies a package for lock scoping.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct PackageCoordinate {
55    distribution: String,
56    major_version: u32,
57    package_kind: PackageKind,
58    architecture: Option<String>,
59    operating_system: Option<String>,
60    libc_variant: Option<String>,
61    javafx_bundled: bool,
62    variant_tags: Vec<String>,
63}
64
65impl PackageCoordinate {
66    /// Creates a new coordinate with the required fields.
67    pub fn new<S: Into<String>>(
68        distribution: S,
69        major_version: u32,
70        package_kind: PackageKind,
71    ) -> Self {
72        Self {
73            distribution: distribution.into(),
74            major_version,
75            package_kind,
76            architecture: None,
77            operating_system: None,
78            libc_variant: None,
79            javafx_bundled: false,
80            variant_tags: Vec::new(),
81        }
82    }
83
84    pub fn distribution(&self) -> &str {
85        &self.distribution
86    }
87
88    pub fn major_version(&self) -> u32 {
89        self.major_version
90    }
91
92    pub fn package_kind(&self) -> PackageKind {
93        self.package_kind
94    }
95
96    pub fn architecture(&self) -> Option<&str> {
97        self.architecture.as_deref()
98    }
99
100    pub fn operating_system(&self) -> Option<&str> {
101        self.operating_system.as_deref()
102    }
103
104    pub fn libc_variant(&self) -> Option<&str> {
105        self.libc_variant.as_deref()
106    }
107
108    pub fn variant_tags(&self) -> &[String] {
109        &self.variant_tags
110    }
111
112    pub fn javafx_bundled(&self) -> bool {
113        self.javafx_bundled
114    }
115
116    pub fn with_architecture<S: Into<String>>(mut self, architecture: Option<S>) -> Self {
117        self.architecture = architecture.map(|value| value.into());
118        self
119    }
120
121    pub fn with_operating_system<S: Into<String>>(mut self, operating_system: Option<S>) -> Self {
122        self.operating_system = operating_system.map(|value| value.into());
123        self
124    }
125
126    pub fn with_libc_variant<S: Into<String>>(mut self, libc_variant: Option<S>) -> Self {
127        self.libc_variant = libc_variant.map(|value| value.into());
128        self
129    }
130
131    pub fn with_javafx(mut self, javafx_bundled: bool) -> Self {
132        self.javafx_bundled = javafx_bundled;
133        self
134    }
135
136    pub fn with_variant_tags<I, S>(mut self, tags: I) -> Self
137    where
138        I: IntoIterator<Item = S>,
139        S: Into<String>,
140    {
141        self.variant_tags = tags.into_iter().map(|tag| tag.into()).collect();
142        self
143    }
144
145    /// Generates a deterministic slug suitable for filesystem lock names.
146    pub fn slug(&self) -> String {
147        let mut segments = Vec::new();
148
149        if let Some(segment) = sanitize_segment(&self.distribution) {
150            segments.push(segment);
151        }
152
153        segments.push(self.major_version.to_string());
154        segments.push(self.package_kind.slug_segment().to_string());
155
156        if let Some(architecture) = self
157            .architecture
158            .as_ref()
159            .and_then(|value| sanitize_segment(value))
160        {
161            segments.push(architecture);
162        }
163
164        if let Some(os) = self
165            .operating_system
166            .as_ref()
167            .and_then(|value| sanitize_segment(value))
168        {
169            segments.push(os);
170        }
171
172        if let Some(libc) = self
173            .libc_variant
174            .as_ref()
175            .and_then(|value| sanitize_segment(value))
176        {
177            segments.push(libc);
178        }
179
180        let mut extras: Vec<String> = self
181            .variant_tags
182            .iter()
183            .filter_map(|value| sanitize_segment(value))
184            .collect();
185        extras.sort();
186        extras.dedup();
187        segments.extend(extras);
188
189        if self.javafx_bundled {
190            segments.push("javafx".to_string());
191        }
192
193        segments.join("-")
194    }
195
196    /// Attempts to build a coordinate from a metadata package definition.
197    pub fn try_from_package(package: &Package) -> Result<Self> {
198        let kind = PackageKind::try_from_str(&package.package_type)?;
199        let variants = build_variant_tags(package);
200
201        Ok(
202            Self::new(package.distribution.clone(), package.major_version, kind)
203                .with_architecture(package.architecture.clone())
204                .with_operating_system(Some(package.operating_system.clone()))
205                .with_libc_variant(package.lib_c_type.clone())
206                .with_javafx(package.javafx_bundled)
207                .with_variant_tags(variants),
208        )
209    }
210}
211
212fn build_variant_tags(package: &Package) -> Vec<String> {
213    let mut tags = Vec::new();
214
215    if let Some(term) = &package.term_of_support {
216        tags.push(term.clone());
217    }
218
219    if let Some(status) = &package.release_status {
220        tags.push(status.clone());
221    }
222
223    if package.latest_build_available.unwrap_or(false) {
224        tags.push("latest".to_string());
225    }
226
227    tags
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn sample_package() -> Package {
235        Package {
236            id: "pkg-id".to_string(),
237            archive_type: "tar.gz".to_string(),
238            distribution: "Temurin".to_string(),
239            major_version: 21,
240            java_version: "21.0.2".to_string(),
241            distribution_version: "21.0.2".to_string(),
242            jdk_version: 21,
243            directly_downloadable: true,
244            filename: "openjdk.tar.gz".to_string(),
245            links: crate::models::api::Links {
246                pkg_download_redirect: "https://example.com".to_string(),
247                pkg_info_uri: Some("https://example.com/info".to_string()),
248            },
249            free_use_in_production: true,
250            tck_tested: "yes".to_string(),
251            size: 1024,
252            operating_system: "linux".to_string(),
253            architecture: Some("x64".to_string()),
254            lib_c_type: Some("gnu".to_string()),
255            package_type: "JDK".to_string(),
256            javafx_bundled: true,
257            term_of_support: Some("lts".to_string()),
258            release_status: Some("ga".to_string()),
259            latest_build_available: Some(true),
260        }
261    }
262
263    #[test]
264    fn slug_includes_expected_segments() {
265        let coordinate = PackageCoordinate::new("Temurin", 21, PackageKind::Jdk)
266            .with_architecture(Some("x64"))
267            .with_javafx(true);
268
269        assert_eq!(coordinate.slug(), "temurin-21-jdk-x64-javafx");
270    }
271
272    #[test]
273    fn slug_is_deterministic_with_variants() {
274        let coordinate = PackageCoordinate::new("Temurin", 21, PackageKind::Jdk)
275            .with_architecture(Some("x64"))
276            .with_operating_system(Some("Linux"))
277            .with_libc_variant(Some("gnu"))
278            .with_variant_tags(["ga", "lts", "ga"])
279            .with_javafx(false);
280
281        assert_eq!(coordinate.slug(), "temurin-21-jdk-x64-linux-gnu-ga-lts");
282    }
283
284    #[test]
285    fn try_from_package_populates_fields() {
286        let package = sample_package();
287        let coordinate = PackageCoordinate::try_from_package(&package).unwrap();
288
289        assert_eq!(coordinate.distribution(), "Temurin");
290        assert_eq!(coordinate.major_version(), 21);
291        assert_eq!(coordinate.package_kind(), PackageKind::Jdk);
292        assert_eq!(coordinate.architecture(), Some("x64"));
293        assert_eq!(coordinate.operating_system(), Some("linux"));
294        assert_eq!(coordinate.libc_variant(), Some("gnu"));
295        assert!(coordinate.javafx_bundled());
296        assert!(coordinate.variant_tags().iter().any(|tag| tag == "lts"));
297    }
298
299    #[test]
300    fn sanitize_segment_removes_duplicates_and_case() {
301        assert_eq!(sanitize_segment(" Tem urin "), Some("tem-urin".to_string()));
302        assert_eq!(sanitize_segment("***"), None);
303    }
304}