use std::fmt;
use std::sync::OnceLock;
use color_eyre::eyre::{Result, bail};
use regex::Regex;
use serde::{Deserialize, Serialize};
pub const GRAPH_ID_MAX_LEN: usize = 64;
#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize)]
#[serde(transparent)]
pub struct GraphId(String);
impl GraphId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for GraphId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for GraphId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for GraphId {
type Error = color_eyre::eyre::Error;
fn try_from(value: String) -> Result<Self> {
validate(value.as_str())?;
Ok(Self(value))
}
}
impl TryFrom<&str> for GraphId {
type Error = color_eyre::eyre::Error;
fn try_from(value: &str) -> Result<Self> {
validate(value)?;
Ok(Self(value.to_string()))
}
}
impl<'de> Deserialize<'de> for GraphId {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::try_from(s).map_err(serde::de::Error::custom)
}
}
fn validate(value: &str) -> Result<()> {
if value.is_empty() {
bail!("graph_id must not be empty");
}
if value.len() > GRAPH_ID_MAX_LEN {
bail!(
"graph_id '{}' is {} chars; max {}",
value,
value.len(),
GRAPH_ID_MAX_LEN
);
}
if !regex().is_match(value) {
bail!(
"graph_id '{}' must match ^[a-zA-Z0-9-]{{1,64}}$ — \
no underscores (engine reserves them), no path separators, no unicode",
value
);
}
if is_reserved(value) {
bail!(
"graph_id '{}' is reserved (would collide with engine-managed names or \
future cluster routes)",
value
);
}
Ok(())
}
fn regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[a-zA-Z0-9-]{1,64}$").expect("regex literal"))
}
fn is_reserved(value: &str) -> bool {
matches!(value, "policies" | "healthz" | "openapi" | "graphs")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_simple_alphanumeric_ids() {
for ok in ["alpha", "beta", "tenant-001", "A", "g", "X-9-z"] {
GraphId::try_from(ok).unwrap_or_else(|_| panic!("expected accept: {ok}"));
}
}
#[test]
fn accepts_64_char_max() {
let max = "a".repeat(64);
GraphId::try_from(max.as_str()).unwrap();
}
#[test]
fn rejects_empty() {
assert!(GraphId::try_from("").is_err());
}
#[test]
fn rejects_over_64_chars() {
let too_long = "a".repeat(65);
assert!(GraphId::try_from(too_long.as_str()).is_err());
}
#[test]
fn rejects_leading_underscore() {
assert!(GraphId::try_from("_internal").is_err());
assert!(GraphId::try_from("__manifest").is_err());
}
#[test]
fn rejects_underscores_anywhere() {
assert!(GraphId::try_from("tenant_alpha").is_err());
}
#[test]
fn rejects_path_separators() {
for bad in ["alpha/beta", "../etc", "..", "alpha\\beta"] {
assert!(GraphId::try_from(bad).is_err(), "expected reject: {bad}");
}
}
#[test]
fn rejects_unicode() {
assert!(GraphId::try_from("αlpha").is_err());
assert!(GraphId::try_from("graph-✨").is_err());
}
#[test]
fn rejects_whitespace() {
assert!(GraphId::try_from(" alpha").is_err());
assert!(GraphId::try_from("alpha ").is_err());
assert!(GraphId::try_from("alpha beta").is_err());
assert!(GraphId::try_from("\talpha").is_err());
}
#[test]
fn rejects_dots() {
assert!(GraphId::try_from(".").is_err());
assert!(GraphId::try_from("alpha.beta").is_err());
assert!(GraphId::try_from("alpha.").is_err());
}
#[test]
fn rejects_reserved_route_names() {
for bad in ["policies", "healthz", "openapi", "graphs"] {
assert!(
GraphId::try_from(bad).is_err(),
"expected reject (reserved): {bad}"
);
}
}
#[test]
fn display_returns_inner_string() {
let id = GraphId::try_from("alpha").unwrap();
assert_eq!(format!("{id}"), "alpha");
assert_eq!(id.as_str(), "alpha");
}
#[test]
fn serialize_round_trips_via_json() {
let id = GraphId::try_from("tenant-007").unwrap();
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "\"tenant-007\"");
let back: GraphId = serde_json::from_str(&json).unwrap();
assert_eq!(back, id);
}
#[test]
fn deserialize_runs_validation() {
let bad = serde_json::from_str::<GraphId>("\"_evil\"");
assert!(bad.is_err());
let bad = serde_json::from_str::<GraphId>("\"../../etc\"");
assert!(bad.is_err());
}
#[test]
fn hash_equality_works_for_use_as_map_key() {
use std::collections::HashMap;
let a = GraphId::try_from("alpha").unwrap();
let b = GraphId::try_from("alpha").unwrap();
let mut m = HashMap::new();
m.insert(a, 1u32);
assert_eq!(m.get(&b), Some(&1));
}
}