use std::{fmt, hash::Hash};
use serde::{Deserialize, Serialize};
macro_rules! string_newtype {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(pub String);
impl $name {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for $name {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl From<String> for $name {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for $name {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl From<$name> for String {
fn from(n: $name) -> String {
n.0
}
}
impl PartialEq<str> for $name {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for $name {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<String> for $name {
fn eq(&self, other: &String) -> bool {
self.0 == *other
}
}
impl std::borrow::Borrow<str> for $name {
fn borrow(&self) -> &str {
&self.0
}
}
};
}
string_newtype!(
ThreadName
);
string_newtype!(
MarkerName
);
pub const RESERVED_REF_SEGMENT: &str = "heddle";
pub fn is_reserved_heddle_namespace(name: &str) -> bool {
let mut parts = name.split('/');
match (parts.next(), parts.next()) {
(Some(first), Some(_)) => first.eq_ignore_ascii_case(RESERVED_REF_SEGMENT),
_ => false,
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"ref name '{name}' is reserved: the heddle/ namespace is internal and cannot be a user thread or marker"
)]
pub struct ReservedRefNameError {
pub name: String,
}
impl ThreadName {
pub fn try_new(s: impl Into<String>) -> Result<Self, ReservedRefNameError> {
let name = s.into();
if is_reserved_heddle_namespace(&name) {
return Err(ReservedRefNameError { name });
}
Ok(Self(name))
}
}
impl MarkerName {
pub fn try_new(s: impl Into<String>) -> Result<Self, ReservedRefNameError> {
let name = s.into();
if is_reserved_heddle_namespace(&name) {
return Err(ReservedRefNameError { name });
}
Ok(Self(name))
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid Git ref name: {0}")]
pub struct GitRefNameError(pub String);
impl ThreadName {
pub fn from_git_branch(name: &str) -> Result<Self, GitRefNameError> {
if name == "HEAD"
|| name.len() + "refs/heads/".len() > 1024
|| sley_refs::BranchRefNameBuf::from_branch_name(name).is_err()
{
return Err(GitRefNameError(name.to_owned()));
}
Ok(Self(crate::name_encoding::native_git_name(name)))
}
}
impl MarkerName {
pub fn from_git_tag(name: &str) -> Result<Self, GitRefNameError> {
let full = format!("refs/tags/{name}");
if full.len() > 1024 || sley_refs::check_refname_format(&full, false).is_err() {
return Err(GitRefNameError(full));
}
Ok(Self(crate::name_encoding::native_git_name(name)))
}
}
string_newtype!(
Scope
);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_branch_admission_matches_git_and_rejects_invalid_names() {
for name in [
"feat/mcp=timeout",
"a,b",
"ünicode/ブランチ",
"@",
"x+y",
"trailing\u{a0}",
"literal\u{fffd}",
"heddle/foo",
&"界".repeat(337),
] {
let output = std::process::Command::new("git")
.args(["check-ref-format", "--branch", name])
.output()
.expect("Git oracle");
assert!(output.status.success(), "Git rejects {name:?}");
let native = ThreadName::from_git_branch(name).expect("Sley branch");
assert_eq!(crate::name_encoding::git_name(&native), name);
}
for name in [
"a..b",
"a@{b",
"a.lock",
"a.lock/b",
"a\n",
"-flag",
"HEAD",
".",
"team:scope",
"a~b",
"a^b",
"a?b",
"a*b",
"a[b",
"a\\b",
"a//b",
"a/",
"a.",
"a\x7f",
] {
assert!(
ThreadName::from_git_branch(name).is_err(),
"accepted {name:?}"
);
}
assert!(ThreadName::from_git_branch(&"界".repeat(338)).is_err());
}
#[test]
fn thread_name_display() {
let t = ThreadName::new("main");
assert_eq!(t.0, "main");
assert_eq!(t.0, "main");
assert_eq!(&*t, "main");
}
#[test]
fn serde_transparent_roundtrip() {
let t = ThreadName::new("feature/foo");
let json = serde_json::to_string(&t).unwrap();
assert_eq!(json, "\"feature/foo\"");
let back: ThreadName = serde_json::from_str(&json).unwrap();
assert_eq!(back, t);
}
#[test]
fn marker_name_distinct_from_thread_name() {
let _t: ThreadName = "main".into();
let _m: MarkerName = "v1.0".into();
}
#[test]
fn comparison_with_str() {
let t = ThreadName::from("main");
assert!(t == "main");
assert!(t == *"main");
let owned = String::from("main");
assert!(t == owned);
}
#[test]
fn borrow_for_hashmap_lookup() {
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert(ThreadName::new("main"), 1);
assert_eq!(map.get("main"), Some(&1));
}
#[test]
fn reserved_namespace_is_heddle_rooted_only() {
assert!(!is_reserved_heddle_namespace("heddle"));
assert!(!is_reserved_heddle_namespace("heddlefoo"));
assert!(!is_reserved_heddle_namespace("my/heddle"));
assert!(!is_reserved_heddle_namespace("main@review"));
assert!(is_reserved_heddle_namespace("heddle/frontier/main/hc-abc"));
assert!(is_reserved_heddle_namespace("Heddle/x"));
}
#[test]
fn try_new_rejects_reserved_thread_and_marker_names() {
assert!(ThreadName::try_new("heddle/frontier/main/hc-1").is_err());
assert!(MarkerName::try_new("heddle/notes").is_err());
assert_eq!(ThreadName::try_new("heddle").unwrap().as_str(), "heddle");
assert_eq!(
ThreadName::try_new("main@hd-abc").unwrap().as_str(),
"main@hd-abc"
);
}
}