use crate::checksum::ChecksumAlgo;
use crate::coordinate::Coordinate;
use crate::error::MavenError;
use crate::snapshot::is_snapshot_version;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayoutPath {
pub coordinate: Coordinate,
pub class: PathClass,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathClass {
Artifact,
Checksum(ChecksumAlgo),
Metadata {
version_level: bool,
checksum: Option<ChecksumAlgo>,
},
}
pub fn parse_layout_path(path: &str) -> Result<LayoutPath, MavenError> {
let trimmed = path.trim_start_matches('/');
let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() < 3 {
return Err(MavenError::InvalidPath(format!(
"path `{path}` has fewer than 3 segments"
)));
}
let filename = segments
.last()
.copied()
.ok_or_else(|| MavenError::InvalidPath("path has no filename".into()))?;
if let Some(kind) = maven_metadata_suffix(filename) {
let checksum = match kind {
MetadataKind::Raw => None,
MetadataKind::Sidecar(a) => Some(a),
};
return classify_metadata(&segments, checksum);
}
if segments.len() < 4 {
return Err(MavenError::InvalidPath(format!(
"artifact path `{path}` has fewer than 4 segments"
)));
}
let version = segments[segments.len() - 2];
let artifact_id = segments[segments.len() - 3];
let group_segments = &segments[..segments.len() - 3];
let group_id = group_segments.join(".");
let (stripped, checksum) = strip_checksum_suffix(filename);
let (classifier, extension) = split_filename(artifact_id, version, stripped)?;
let coordinate = Coordinate::new(group_id, artifact_id, version, classifier, extension)
.map_err(|e| MavenError::InvalidPath(format!("{e}")))?;
let class = match checksum {
Some(algo) => PathClass::Checksum(algo),
None => PathClass::Artifact,
};
Ok(LayoutPath { coordinate, class })
}
fn classify_metadata(
segments: &[&str],
checksum: Option<ChecksumAlgo>,
) -> Result<LayoutPath, MavenError> {
if segments.len() < 3 {
return Err(MavenError::InvalidPath(
"metadata path must have at least 2 path components before the filename".into(),
));
}
let before_file = &segments[..segments.len() - 1];
let last = before_file.last().copied().unwrap_or_default();
let version_level = last.chars().any(|c| c.is_ascii_digit());
if version_level && before_file.len() >= 3 {
let version = last.to_string();
let artifact_id = before_file[before_file.len() - 2].to_string();
let group_id = before_file[..before_file.len() - 2].join(".");
let coordinate = Coordinate::new(group_id, artifact_id, version, None::<String>, "pom")
.map_err(|e| MavenError::InvalidPath(format!("{e}")))?;
Ok(LayoutPath {
coordinate,
class: PathClass::Metadata {
version_level: true,
checksum,
},
})
} else {
let artifact_id = last.to_string();
let group_id = before_file[..before_file.len() - 1].join(".");
let coordinate = Coordinate::new(group_id, artifact_id, "index", None::<String>, "pom")
.map_err(|e| MavenError::InvalidPath(format!("{e}")))?;
Ok(LayoutPath {
coordinate,
class: PathClass::Metadata {
version_level: false,
checksum,
},
})
}
}
enum MetadataKind {
Raw,
Sidecar(ChecksumAlgo),
}
fn maven_metadata_suffix(name: &str) -> Option<MetadataKind> {
if name == "maven-metadata.xml" {
return Some(MetadataKind::Raw);
}
let rest = name.strip_prefix("maven-metadata.xml.")?;
ChecksumAlgo::from_extension(rest).map(MetadataKind::Sidecar)
}
fn strip_checksum_suffix(name: &str) -> (&str, Option<ChecksumAlgo>) {
if let Some((stem, ext)) = name.rsplit_once('.')
&& let Some(algo) = ChecksumAlgo::from_extension(ext)
{
return (stem, Some(algo));
}
(name, None)
}
fn split_filename(
artifact_id: &str,
version: &str,
filename: &str,
) -> Result<(Option<String>, String), MavenError> {
let prefix = format!("{artifact_id}-{version}");
let rest = filename.strip_prefix(&prefix).ok_or_else(|| {
MavenError::InvalidPath(format!(
"filename `{filename}` does not start with `{prefix}`"
))
})?;
if let Some(tail) = rest.strip_prefix('-') {
let (classifier, extension) = split_classifier_and_extension(tail).ok_or_else(|| {
MavenError::InvalidPath(format!(
"filename tail `{tail}` must be `classifier.extension`"
))
})?;
Ok((Some(classifier), extension))
} else if let Some(tail) = rest.strip_prefix('.') {
Ok((None, tail.to_string()))
} else {
Err(MavenError::InvalidPath(format!(
"filename `{filename}` has no extension separator"
)))
}
}
fn split_classifier_and_extension(tail: &str) -> Option<(String, String)> {
const COMPOUND: &[&str] = &["tar.gz", "tar.bz2", "tar.xz", "tar.zst"];
for compound in COMPOUND {
let dotted = format!(".{compound}");
if let Some(classifier) = tail.strip_suffix(&dotted)
&& !classifier.is_empty()
{
return Some((classifier.to_string(), (*compound).to_string()));
}
}
let dot = tail.rfind('.')?;
let classifier = &tail[..dot];
let extension = &tail[dot + 1..];
if classifier.is_empty() || extension.is_empty() {
return None;
}
Some((classifier.to_string(), extension.to_string()))
}
#[must_use]
pub fn layout_is_snapshot(path: &LayoutPath) -> bool {
is_snapshot_version(&path.coordinate.version)
}
#[cfg(test)]
mod tests {
use super::{PathClass, parse_layout_path};
use crate::checksum::ChecksumAlgo;
#[test]
fn parses_simple_jar_path() {
let p = parse_layout_path("com/example/foo/1.0/foo-1.0.jar").expect("ok");
assert_eq!(p.coordinate.group_id, "com.example");
assert_eq!(p.coordinate.artifact_id, "foo");
assert_eq!(p.coordinate.version, "1.0");
assert_eq!(p.coordinate.extension, "jar");
assert_eq!(p.coordinate.classifier, None);
assert_eq!(p.class, PathClass::Artifact);
}
#[test]
fn parses_classifier_jar() {
let p = parse_layout_path("com/example/foo/1.0/foo-1.0-sources.jar").expect("ok");
assert_eq!(p.coordinate.classifier.as_deref(), Some("sources"));
assert_eq!(p.coordinate.extension, "jar");
}
#[test]
fn parses_sha1_sidecar() {
let p = parse_layout_path("com/example/foo/1.0/foo-1.0.jar.sha1").expect("ok");
assert_eq!(p.coordinate.extension, "jar");
assert_eq!(p.class, PathClass::Checksum(ChecksumAlgo::Sha1));
}
#[test]
fn parses_pom() {
let p = parse_layout_path("com/example/foo/1.0/foo-1.0.pom").expect("ok");
assert_eq!(p.coordinate.extension, "pom");
}
#[test]
fn parses_metadata_under_artifact_id() {
let p = parse_layout_path("com/example/foo/maven-metadata.xml").expect("ok");
assert!(matches!(
p.class,
PathClass::Metadata {
version_level: false,
checksum: None
}
));
}
#[test]
fn parses_metadata_under_version() {
let p = parse_layout_path("com/example/foo/1.0-SNAPSHOT/maven-metadata.xml").expect("ok");
assert!(matches!(
p.class,
PathClass::Metadata {
version_level: true,
..
}
));
}
#[test]
fn rejects_too_short_path() {
let err = parse_layout_path("foo/1.0").expect_err("reject");
assert!(err.to_string().contains("fewer than 3 segments"));
}
#[test]
fn compound_tar_gz_extension_preserved() {
let p = parse_layout_path("com/example/foo/1.0/foo-1.0-dist.tar.gz").expect("ok");
assert_eq!(p.coordinate.extension, "tar.gz");
assert_eq!(p.coordinate.classifier.as_deref(), Some("dist"));
}
#[test]
fn round_trip_path_to_coordinate_and_back() {
let p = parse_layout_path("com/example/foo/1.0/foo-1.0-sources.jar").expect("ok");
assert_eq!(
p.coordinate.repository_path(),
"com/example/foo/1.0/foo-1.0-sources.jar"
);
}
}