1use crate::checksum::ChecksumAlgo;
16use crate::coordinate::Coordinate;
17use crate::error::MavenError;
18use crate::snapshot::is_snapshot_version;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct LayoutPath {
23 pub coordinate: Coordinate,
25 pub class: PathClass,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum PathClass {
32 Artifact,
34 Checksum(ChecksumAlgo),
36 Metadata {
40 version_level: bool,
43 checksum: Option<ChecksumAlgo>,
45 },
46}
47
48pub fn parse_layout_path(path: &str) -> Result<LayoutPath, MavenError> {
62 let trimmed = path.trim_start_matches('/');
63 let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
64
65 if segments.len() < 3 {
66 return Err(MavenError::InvalidPath(format!(
67 "path `{path}` has fewer than 3 segments"
68 )));
69 }
70
71 let filename = segments
72 .last()
73 .copied()
74 .ok_or_else(|| MavenError::InvalidPath("path has no filename".into()))?;
75
76 if let Some(kind) = maven_metadata_suffix(filename) {
80 let checksum = match kind {
81 MetadataKind::Raw => None,
82 MetadataKind::Sidecar(a) => Some(a),
83 };
84 return classify_metadata(&segments, checksum);
85 }
86
87 if segments.len() < 4 {
90 return Err(MavenError::InvalidPath(format!(
91 "artifact path `{path}` has fewer than 4 segments"
92 )));
93 }
94
95 let version = segments[segments.len() - 2];
96 let artifact_id = segments[segments.len() - 3];
97 let group_segments = &segments[..segments.len() - 3];
98 let group_id = group_segments.join(".");
99
100 let (stripped, checksum) = strip_checksum_suffix(filename);
101 let (classifier, extension) = split_filename(artifact_id, version, stripped)?;
102
103 let coordinate = Coordinate::new(group_id, artifact_id, version, classifier, extension)
104 .map_err(|e| MavenError::InvalidPath(format!("{e}")))?;
105
106 let class = match checksum {
107 Some(algo) => PathClass::Checksum(algo),
108 None => PathClass::Artifact,
109 };
110
111 Ok(LayoutPath { coordinate, class })
112}
113
114fn classify_metadata(
115 segments: &[&str],
116 checksum: Option<ChecksumAlgo>,
117) -> Result<LayoutPath, MavenError> {
118 if segments.len() < 3 {
124 return Err(MavenError::InvalidPath(
125 "metadata path must have at least 2 path components before the filename".into(),
126 ));
127 }
128 let before_file = &segments[..segments.len() - 1];
129 let last = before_file.last().copied().unwrap_or_default();
130 let version_level = last.chars().any(|c| c.is_ascii_digit());
131
132 if version_level && before_file.len() >= 3 {
133 let version = last.to_string();
134 let artifact_id = before_file[before_file.len() - 2].to_string();
135 let group_id = before_file[..before_file.len() - 2].join(".");
136 let coordinate = Coordinate::new(group_id, artifact_id, version, None::<String>, "pom")
137 .map_err(|e| MavenError::InvalidPath(format!("{e}")))?;
138 Ok(LayoutPath {
139 coordinate,
140 class: PathClass::Metadata {
141 version_level: true,
142 checksum,
143 },
144 })
145 } else {
146 let artifact_id = last.to_string();
147 let group_id = before_file[..before_file.len() - 1].join(".");
148 let coordinate = Coordinate::new(group_id, artifact_id, "index", None::<String>, "pom")
151 .map_err(|e| MavenError::InvalidPath(format!("{e}")))?;
152 Ok(LayoutPath {
153 coordinate,
154 class: PathClass::Metadata {
155 version_level: false,
156 checksum,
157 },
158 })
159 }
160}
161
162enum MetadataKind {
164 Raw,
166 Sidecar(ChecksumAlgo),
168}
169
170fn maven_metadata_suffix(name: &str) -> Option<MetadataKind> {
171 if name == "maven-metadata.xml" {
172 return Some(MetadataKind::Raw);
173 }
174 let rest = name.strip_prefix("maven-metadata.xml.")?;
175 ChecksumAlgo::from_extension(rest).map(MetadataKind::Sidecar)
176}
177
178fn strip_checksum_suffix(name: &str) -> (&str, Option<ChecksumAlgo>) {
179 if let Some((stem, ext)) = name.rsplit_once('.')
180 && let Some(algo) = ChecksumAlgo::from_extension(ext)
181 {
182 return (stem, Some(algo));
183 }
184 (name, None)
185}
186
187fn split_filename(
190 artifact_id: &str,
191 version: &str,
192 filename: &str,
193) -> Result<(Option<String>, String), MavenError> {
194 let prefix = format!("{artifact_id}-{version}");
196 let rest = filename.strip_prefix(&prefix).ok_or_else(|| {
197 MavenError::InvalidPath(format!(
198 "filename `{filename}` does not start with `{prefix}`"
199 ))
200 })?;
201
202 if let Some(tail) = rest.strip_prefix('-') {
203 let (classifier, extension) = split_classifier_and_extension(tail).ok_or_else(|| {
208 MavenError::InvalidPath(format!(
209 "filename tail `{tail}` must be `classifier.extension`"
210 ))
211 })?;
212 Ok((Some(classifier), extension))
213 } else if let Some(tail) = rest.strip_prefix('.') {
214 Ok((None, tail.to_string()))
215 } else {
216 Err(MavenError::InvalidPath(format!(
217 "filename `{filename}` has no extension separator"
218 )))
219 }
220}
221
222fn split_classifier_and_extension(tail: &str) -> Option<(String, String)> {
223 const COMPOUND: &[&str] = &["tar.gz", "tar.bz2", "tar.xz", "tar.zst"];
225 for compound in COMPOUND {
226 let dotted = format!(".{compound}");
227 if let Some(classifier) = tail.strip_suffix(&dotted)
228 && !classifier.is_empty()
229 {
230 return Some((classifier.to_string(), (*compound).to_string()));
231 }
232 }
233 let dot = tail.rfind('.')?;
234 let classifier = &tail[..dot];
235 let extension = &tail[dot + 1..];
236 if classifier.is_empty() || extension.is_empty() {
237 return None;
238 }
239 Some((classifier.to_string(), extension.to_string()))
240}
241
242#[must_use]
245pub fn layout_is_snapshot(path: &LayoutPath) -> bool {
246 is_snapshot_version(&path.coordinate.version)
247}
248
249#[cfg(test)]
250mod tests {
251 use super::{PathClass, parse_layout_path};
252 use crate::checksum::ChecksumAlgo;
253
254 #[test]
255 fn parses_simple_jar_path() {
256 let p = parse_layout_path("com/example/foo/1.0/foo-1.0.jar").expect("ok");
257 assert_eq!(p.coordinate.group_id, "com.example");
258 assert_eq!(p.coordinate.artifact_id, "foo");
259 assert_eq!(p.coordinate.version, "1.0");
260 assert_eq!(p.coordinate.extension, "jar");
261 assert_eq!(p.coordinate.classifier, None);
262 assert_eq!(p.class, PathClass::Artifact);
263 }
264
265 #[test]
266 fn parses_classifier_jar() {
267 let p = parse_layout_path("com/example/foo/1.0/foo-1.0-sources.jar").expect("ok");
268 assert_eq!(p.coordinate.classifier.as_deref(), Some("sources"));
269 assert_eq!(p.coordinate.extension, "jar");
270 }
271
272 #[test]
273 fn parses_sha1_sidecar() {
274 let p = parse_layout_path("com/example/foo/1.0/foo-1.0.jar.sha1").expect("ok");
275 assert_eq!(p.coordinate.extension, "jar");
276 assert_eq!(p.class, PathClass::Checksum(ChecksumAlgo::Sha1));
277 }
278
279 #[test]
280 fn parses_pom() {
281 let p = parse_layout_path("com/example/foo/1.0/foo-1.0.pom").expect("ok");
282 assert_eq!(p.coordinate.extension, "pom");
283 }
284
285 #[test]
286 fn parses_metadata_under_artifact_id() {
287 let p = parse_layout_path("com/example/foo/maven-metadata.xml").expect("ok");
288 assert!(matches!(
289 p.class,
290 PathClass::Metadata {
291 version_level: false,
292 checksum: None
293 }
294 ));
295 }
296
297 #[test]
298 fn parses_metadata_under_version() {
299 let p = parse_layout_path("com/example/foo/1.0-SNAPSHOT/maven-metadata.xml").expect("ok");
300 assert!(matches!(
301 p.class,
302 PathClass::Metadata {
303 version_level: true,
304 ..
305 }
306 ));
307 }
308
309 #[test]
310 fn rejects_too_short_path() {
311 let err = parse_layout_path("foo/1.0").expect_err("reject");
312 assert!(err.to_string().contains("fewer than 3 segments"));
313 }
314
315 #[test]
316 fn compound_tar_gz_extension_preserved() {
317 let p = parse_layout_path("com/example/foo/1.0/foo-1.0-dist.tar.gz").expect("ok");
318 assert_eq!(p.coordinate.extension, "tar.gz");
319 assert_eq!(p.coordinate.classifier.as_deref(), Some("dist"));
320 }
321
322 #[test]
323 fn round_trip_path_to_coordinate_and_back() {
324 let p = parse_layout_path("com/example/foo/1.0/foo-1.0-sources.jar").expect("ok");
325 assert_eq!(
326 p.coordinate.repository_path(),
327 "com/example/foo/1.0/foo-1.0-sources.jar"
328 );
329 }
330}