use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::{Diagnostic, DiagnosticCode, DiagnosticSeverity, PackageId, Version, VersionGrammar, VersionParseError};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TagTemplate {
prefix: String,
suffix: String,
}
impl TagTemplate {
pub fn parse(raw: &str) -> Result<Self, TagTemplateError> {
let version_marker = "{version}";
let count = raw.matches(version_marker).count();
if count == 0 {
if let Some(start) = raw.find('{') {
if let Some(end) = raw[start..].find('}') {
let placeholder = &raw[start + 1..start + end];
return Err(TagTemplateError::UnknownPlaceholder {
template: raw.to_string(),
placeholder: placeholder.to_string(),
});
}
}
return Err(TagTemplateError::MissingVersionPlaceholder {
template: raw.to_string(),
});
}
if count > 1 {
return Err(TagTemplateError::MultipleVersionPlaceholders {
template: raw.to_string(),
count,
});
}
let (prefix, suffix) = raw.split_once(version_marker).unwrap();
if prefix.is_empty() && suffix.is_empty() {
return Err(TagTemplateError::NoLiteralAnchor {
template: raw.to_string(),
});
}
for (part, _name) in [(prefix, "prefix"), (suffix, "suffix")] {
for ch in part.chars() {
if matches!(ch, '*' | '?' | '[' | ']') {
return Err(TagTemplateError::GlobMetacharacterInLiteral {
template: raw.to_string(),
ch,
});
}
}
}
let test_render = format!("{prefix}1.0.0{suffix}");
if !is_valid_git_ref_name(&test_render) {
return Err(TagTemplateError::InvalidGitRefName {
template: raw.to_string(),
rendered: test_render,
});
}
Ok(TagTemplate {
prefix: prefix.to_string(),
suffix: suffix.to_string(),
})
}
pub fn default_for(id: &PackageId) -> Self {
TagTemplate {
prefix: format!("{}@", id.display_name()),
suffix: String::new(),
}
}
pub fn render(&self, version: &Version) -> TagName {
TagName(format!("{}{}{}", self.prefix, version.render(), self.suffix))
}
pub fn render_floating_major(&self, version: &Version) -> Option<TagName> {
let major = version.major()?;
let rendered = format!("{}{}{}", self.prefix, major, self.suffix);
if is_valid_git_ref_name(&rendered) {
Some(TagName(rendered))
} else {
None
}
}
pub fn glob(&self) -> String {
format!("{}*{}", self.prefix, self.suffix)
}
pub fn extract_version_str<'a>(&self, tag: &'a str) -> Option<&'a str> {
if !tag.starts_with(&self.prefix) || !tag.ends_with(&self.suffix) {
return None;
}
let end_idx = tag.len() - self.suffix.len();
if end_idx < self.prefix.len() {
return None;
}
Some(&tag[self.prefix.len()..end_idx])
}
pub fn as_str(&self) -> String {
format!("{}{{version}}{}", self.prefix, self.suffix)
}
}
impl Serialize for TagTemplate {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.as_str())
}
}
impl<'de> Deserialize<'de> for TagTemplate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
TagTemplate::parse(&s).map_err(serde::de::Error::custom)
}
}
fn is_valid_git_ref_name(s: &str) -> bool {
if s.is_empty() || s.starts_with('/') || s.ends_with('/') || s.contains("//") || s == "@" {
return false;
}
if s.starts_with('-') {
return false;
}
if s.contains("..") || s.contains("@{") {
return false;
}
for component in s.split('/') {
if component.starts_with('.') || component.ends_with(".lock") {
return false;
}
}
for ch in s.chars() {
if ch.is_ascii_control() || ch == ' ' || matches!(ch, '~' | '^' | ':' | '?' | '*' | '[' | '\\') {
return false;
}
}
true
}
use schemars::JsonSchema;
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
#[schemars(with = "String")]
#[serde(transparent)]
pub struct TagName(pub String);
impl TagName {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for TagName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LastTag {
pub name: TagName,
pub version: Version,
}
#[derive(Clone, Debug, Default)]
pub struct LastTagSelection {
pub chosen: Option<LastTag>,
pub skipped: Vec<Diagnostic>,
}
pub fn select_last_tag<'a>(
template: &TagTemplate,
grammar: VersionGrammar,
candidates: impl IntoIterator<Item = &'a str>,
) -> Result<LastTagSelection, VersionParseError> {
let mut chosen: Option<LastTag> = None;
let mut skipped = Vec::new();
for candidate in candidates {
let Some(extracted) = template.extract_version_str(candidate) else {
continue;
};
match Version::parse(extracted, grammar) {
Ok(ver) => {
let tag = LastTag {
name: TagName(candidate.to_string()),
version: ver,
};
match &chosen {
None => chosen = Some(tag),
Some(prev) => {
let ord = tag.version.compare(&prev.version).map_err(|_err| VersionParseError {
raw: extracted.to_string(),
grammar,
message: "grammar mismatch during candidate selection".to_string(),
})?;
match ord {
std::cmp::Ordering::Greater => chosen = Some(tag),
std::cmp::Ordering::Equal => {
if tag.name.as_str() > prev.name.as_str() {
chosen = Some(tag);
}
}
std::cmp::Ordering::Less => {}
}
}
}
}
Err(_) => {
skipped.push(Diagnostic {
code: DiagnosticCode::TagGlobNonVersionMatch,
severity: DiagnosticSeverity::Warning,
message: format!(
"tag candidate `{candidate}` matched glob but placeholder `{extracted}` is not a valid {grammar:?} version"
),
package: None,
path: None,
escalated_by: None,
governed_by: None,
});
}
}
}
Ok(LastTagSelection { chosen, skipped })
}
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum TagTemplateError {
#[error("tag template `{template}` contains no `{{version}}` placeholder")]
MissingVersionPlaceholder { template: String },
#[error("tag template `{template}` contains `{{version}}` {count} times; exactly one is required")]
MultipleVersionPlaceholders { template: String, count: usize },
#[error("tag template `{template}` contains unknown placeholder `{{{placeholder}}}`; the only placeholder is `{{version}}`")]
UnknownPlaceholder { template: String, placeholder: String },
#[error("tag template `{template}` contains glob metacharacter `{ch}` outside the `{{version}}` placeholder")]
GlobMetacharacterInLiteral { template: String, ch: char },
#[error("tag template `{template}` has no literal text around `{{version}}`; its tag glob would be `*`")]
NoLiteralAnchor { template: String },
#[error("tag template `{template}` renders `{rendered}`, which is not a legal git ref name")]
InvalidGitRefName { template: String, rendered: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_tag_template() {
let tmpl = TagTemplate::parse("v{version}").unwrap();
assert_eq!(tmpl.glob(), "v*");
let ver = Version::parse("1.2.3", VersionGrammar::SemVer).unwrap();
assert_eq!(tmpl.render(&ver).as_str(), "v1.2.3");
assert_eq!(tmpl.render_floating_major(&ver).unwrap().as_str(), "v1");
assert_eq!(tmpl.extract_version_str("v1.2.3"), Some("1.2.3"));
}
#[test]
fn test_render_floating_major_scoped_package() {
let tmpl = TagTemplate::parse("@scope/pkg@{version}").unwrap();
let ver = Version::parse("2.4.0", VersionGrammar::SemVer).unwrap();
assert_eq!(tmpl.render(&ver).as_str(), "@scope/pkg@2.4.0");
assert_eq!(tmpl.render_floating_major(&ver).unwrap().as_str(), "@scope/pkg@2");
}
#[test]
fn rejects_no_anchor() {
let err = TagTemplate::parse("{version}").unwrap_err();
assert!(matches!(err, TagTemplateError::NoLiteralAnchor { .. }));
}
#[test]
fn rejects_template_rendering_a_leading_hyphen() {
let err = TagTemplate::parse("-f{version}").unwrap_err();
assert!(
matches!(err, TagTemplateError::InvalidGitRefName { .. }),
"expected InvalidGitRefName, got {err:?}"
);
}
}