use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Tag(String);
impl serde::Serialize for Tag {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl Tag {
pub fn new(s: &str) -> anyhow::Result<Self> {
if is_valid_tag(s) {
Ok(Tag(s.to_string()))
} else {
anyhow::bail!(
"invalid tag '{s}': must match [a-z0-9][a-z0-9-]* \
or [a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*"
)
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn as_kv(&self) -> Option<(&str, &str)> {
self.0.split_once(':')
}
pub fn key(&self) -> Option<&str> {
self.as_kv().map(|(k, _)| k)
}
pub fn value(&self) -> Option<&str> {
self.as_kv().map(|(_, v)| v)
}
}
fn is_valid_tag(s: &str) -> bool {
match s.split_once(':') {
None => super::is_valid_kebab_lowercase(s),
Some((key, value)) => {
super::is_valid_kebab_lowercase(key) && super::is_valid_kebab_lowercase(value)
}
}
}
impl fmt::Display for Tag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for Tag {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Tag::new(s)
}
}
#[cfg(test)]
pub mod strategy {
use super::Tag;
use proptest::prelude::*;
pub fn simple_tag() -> impl Strategy<Value = Tag> {
"[a-z][a-z0-9-]{0,19}".prop_map(|s| Tag::new(&s).unwrap())
}
pub fn structured_tag() -> impl Strategy<Value = Tag> {
("[a-z][a-z0-9-]{0,9}", "[a-z][a-z0-9-]{0,9}")
.prop_map(|(k, v)| Tag::new(&format!("{k}:{v}")).unwrap())
}
pub fn valid_tag() -> impl Strategy<Value = Tag> {
prop_oneof![simple_tag(), structured_tag()]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_accepts_simple_tag() {
assert!(Tag::new("auth").is_ok());
}
#[test]
fn new_accepts_tag_with_hyphen() {
assert!(Tag::new("sprint-3").is_ok());
}
#[test]
fn new_accepts_tag_with_digit() {
assert!(Tag::new("v2").is_ok());
}
#[test]
fn new_rejects_empty_string() {
assert!(Tag::new("").is_err());
}
#[test]
fn new_rejects_leading_hyphen() {
assert!(Tag::new("-auth").is_err());
}
#[test]
fn new_rejects_uppercase() {
assert!(Tag::new("Auth").is_err());
}
#[test]
fn new_rejects_spaces() {
assert!(Tag::new("invalid tag").is_err());
}
#[test]
fn display_roundtrips() {
let tag = Tag::new("backend").unwrap();
assert_eq!(tag.to_string(), "backend");
}
#[test]
fn from_str_roundtrips() {
let tag: Tag = "sprint-3".parse().unwrap();
assert_eq!(tag.as_str(), "sprint-3");
}
#[test]
fn equality_holds_for_same_value() {
assert_eq!(Tag::new("auth").unwrap(), Tag::new("auth").unwrap());
}
#[test]
fn ordering_is_lexicographic() {
let a = Tag::new("auth").unwrap();
let b = Tag::new("backend").unwrap();
assert!(a < b);
}
#[test]
fn new_accepts_structured_tag() {
assert!(Tag::new("area:backend").is_ok());
}
#[test]
fn new_accepts_structured_with_hyphens() {
assert!(Tag::new("priority:high").is_ok());
assert!(Tag::new("area:back-end").is_ok());
}
#[test]
fn new_rejects_empty_value_after_colon() {
assert!(Tag::new("area:").is_err());
}
#[test]
fn new_rejects_empty_key_before_colon() {
assert!(Tag::new(":backend").is_err());
}
#[test]
fn new_rejects_multiple_colons() {
assert!(Tag::new("area:backend:more").is_err());
}
#[test]
fn new_rejects_uppercase_in_value() {
assert!(Tag::new("area:Backend").is_err());
}
#[test]
fn as_kv_returns_pair_for_structured_tag() {
let tag = Tag::new("area:backend").unwrap();
assert_eq!(tag.as_kv(), Some(("area", "backend")));
}
#[test]
fn as_kv_returns_none_for_simple_tag() {
let tag = Tag::new("backend").unwrap();
assert_eq!(tag.as_kv(), None);
}
#[test]
fn key_returns_only_the_key() {
let structured = Tag::new("area:backend").unwrap();
let simple = Tag::new("backend").unwrap();
assert_eq!(structured.key(), Some("area"));
assert_eq!(simple.key(), None);
}
#[test]
fn value_returns_only_the_value() {
let structured = Tag::new("area:backend").unwrap();
let simple = Tag::new("backend").unwrap();
assert_eq!(structured.value(), Some("backend"));
assert_eq!(simple.value(), None);
}
use super::strategy;
proptest::proptest! {
#[test]
fn prop_display_roundtrips(tag in strategy::valid_tag()) {
proptest::prop_assert_eq!(tag.to_string(), tag.as_str());
}
#[test]
fn prop_strategy_always_produces_valid_tags(tag in strategy::valid_tag()) {
proptest::prop_assert!(Tag::new(tag.as_str()).is_ok());
}
}
}