use super::InstallError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageSpec {
pub namespace: String,
pub name: String,
pub version: String,
}
pub fn parse_spec(raw: &str) -> Result<PackageSpec, InstallError> {
let Some(after_preview) = raw.strip_prefix("@preview/") else {
let reason = if raw.starts_with('@') {
"only the @preview/ namespace is supported in v1".to_owned()
} else {
"spec must start with @preview/".to_owned()
};
return Err(InstallError::InvalidSpec {
raw: raw.to_owned(),
reason,
});
};
let Some((name, version)) = after_preview.rsplit_once(':') else {
return Err(InstallError::InvalidSpec {
raw: raw.to_owned(),
reason: "missing `:<version>` after package name".to_owned(),
});
};
if name.is_empty() {
return Err(InstallError::InvalidSpec {
raw: raw.to_owned(),
reason: "package name is empty".to_owned(),
});
}
if version.is_empty() {
return Err(InstallError::InvalidSpec {
raw: raw.to_owned(),
reason: "version string is empty".to_owned(),
});
}
validate_component("name", name, raw)?;
validate_component("version", version, raw)?;
Ok(PackageSpec {
namespace: "preview".to_owned(),
name: name.to_owned(),
version: version.to_owned(),
})
}
fn validate_component(what: &'static str, value: &str, raw: &str) -> Result<(), InstallError> {
if value == "." || value == ".." {
return Err(InstallError::InvalidSpec {
raw: raw.to_owned(),
reason: format!("{what} must not be `.` or `..`"),
});
}
for ch in value.chars() {
if ch.is_whitespace()
|| ch.is_control()
|| ch == '/'
|| ch == '\\'
|| ch == ':'
|| ch == '?'
|| ch == '#'
{
return Err(InstallError::InvalidSpec {
raw: raw.to_owned(),
reason: format!("{what} contains illegal character `{}`", ch.escape_debug()),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_canonical_spec() {
let spec = parse_spec("@preview/basic-resume:0.2.8").expect("canonical spec parses");
assert_eq!(spec.namespace, "preview");
assert_eq!(spec.name, "basic-resume");
assert_eq!(spec.version, "0.2.8");
}
#[test]
fn parses_prerelease_version() {
let spec =
parse_spec("@preview/cetz:0.3.1-beta.2").expect("prerelease versions pass through");
assert_eq!(spec.version, "0.3.1-beta.2");
}
#[test]
fn rejects_missing_prefix() {
let err = parse_spec("basic-resume:0.2.8").expect_err("bare name must fail");
match err {
InstallError::InvalidSpec { raw, reason } => {
assert_eq!(raw, "basic-resume:0.2.8");
assert!(
reason.contains("@preview/"),
"error must hint at @preview/ prefix: {reason}"
);
}
other => panic!("expected InvalidSpec, got {other:?}"),
}
}
#[test]
fn rejects_non_preview_namespace() {
let err = parse_spec("@local/mine:1.0").expect_err("@local/ is not supported in v1");
match err {
InstallError::InvalidSpec { raw: _, reason } => {
assert!(
reason.contains("@preview/"),
"error must point at @preview/ as the supported namespace: {reason}"
);
}
other => panic!("expected InvalidSpec, got {other:?}"),
}
}
#[test]
fn rejects_missing_version() {
let err = parse_spec("@preview/basic-resume").expect_err("missing version must fail");
assert!(matches!(err, InstallError::InvalidSpec { .. }));
}
#[test]
fn rejects_empty_name() {
let err = parse_spec("@preview/:1.0").expect_err("empty name must fail");
assert!(matches!(err, InstallError::InvalidSpec { .. }));
}
#[test]
fn rejects_empty_version() {
let err = parse_spec("@preview/foo:").expect_err("empty version must fail");
assert!(matches!(err, InstallError::InvalidSpec { .. }));
}
#[test]
fn rejects_path_traversal_in_name() {
let err = parse_spec("@preview/../evil:1.0").expect_err("path separator rejected");
assert!(matches!(err, InstallError::InvalidSpec { .. }));
}
#[test]
fn rejects_whitespace_in_version() {
let err = parse_spec("@preview/foo:1.0 ").expect_err("whitespace rejected");
assert!(matches!(err, InstallError::InvalidSpec { .. }));
}
}