use crate::errors::OpenVTCError;
use didwebvh_rs::url::WebVHURL;
const MAX_SLUG_LEN: usize = 32;
const FALLBACK_TOKEN_LEN: usize = 12;
pub const MAX_CONTEXT_DEPTH: usize = 8;
pub const SEPARATOR: char = '/';
pub const MAX_IDENTIFIER_LEN: usize = 64;
pub fn validate_identifier(label: &str, value: &str) -> Result<(), OpenVTCError> {
if value.is_empty() {
return Err(OpenVTCError::Config(format!("{label} must not be empty")));
}
if value.len() > MAX_IDENTIFIER_LEN {
return Err(OpenVTCError::Config(format!(
"{label} is {} bytes; maximum is {MAX_IDENTIFIER_LEN}",
value.len()
)));
}
for (i, ch) in value.chars().enumerate() {
let ok = ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-';
if !ok {
return Err(OpenVTCError::Config(format!(
"{label} contains an invalid character {ch:?} at position {i}; \
allowed: A-Z, a-z, 0-9, '.', '_', '-'"
)));
}
}
Ok(())
}
pub fn validate_context_path(value: &str) -> Result<(), OpenVTCError> {
if value.is_empty() {
return Err(OpenVTCError::Config(
"context path must not be empty".into(),
));
}
if value.starts_with(SEPARATOR) || value.ends_with(SEPARATOR) {
return Err(OpenVTCError::Config(format!(
"context path must not start or end with '{SEPARATOR}'"
)));
}
let segments: Vec<&str> = value.split(SEPARATOR).collect();
if segments.len() > MAX_CONTEXT_DEPTH {
return Err(OpenVTCError::Config(format!(
"context path is {} levels deep; maximum is {MAX_CONTEXT_DEPTH}",
segments.len()
)));
}
for segment in &segments {
if segment.is_empty() {
return Err(OpenVTCError::Config(
"context path must not contain an empty segment ('//')".into(),
));
}
validate_identifier("context path segment", segment)?;
}
Ok(())
}
pub fn child_path(parent: &str, segment: &str) -> Result<String, OpenVTCError> {
validate_identifier("context path segment", segment)?;
let candidate = format!("{parent}{SEPARATOR}{segment}");
validate_context_path(&candidate)?;
Ok(candidate)
}
pub fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len().min(MAX_SLUG_LEN));
let mut pending_dash = false;
for c in name.chars() {
let lc = c.to_ascii_lowercase();
if lc.is_ascii_lowercase() || lc.is_ascii_digit() {
if pending_dash && !out.is_empty() {
out.push('-');
}
pending_dash = false;
out.push(lc);
if out.len() >= MAX_SLUG_LEN {
break;
}
} else {
pending_dash = true;
}
}
out
}
pub fn fallback_token(vtc_did: &str) -> Result<String, OpenVTCError> {
let parsed = WebVHURL::parse_did_url(vtc_did)
.map_err(|e| OpenVTCError::Config(format!("Invalid VTC did:webvh ({vtc_did}): {e}")))?;
let token: String = parsed
.scid
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.take(FALLBACK_TOKEN_LEN)
.collect();
if token.is_empty() {
return Err(OpenVTCError::Config(format!(
"VTC DID has no usable SCID for a context token: {vtc_did}"
)));
}
Ok(token)
}
pub fn build_sub_context_id(
top_context_id: &str,
display_name: Option<&str>,
vtc_did: &str,
is_taken: impl Fn(&str) -> bool,
) -> Result<String, OpenVTCError> {
let base = match display_name.map(slugify) {
Some(slug) if !slug.is_empty() => slug,
_ => fallback_token(vtc_did)?,
};
let mut segment = base.clone();
let mut n = 2u32;
loop {
let candidate = child_path(top_context_id, &segment)?;
if !is_taken(&candidate) {
return Ok(candidate);
}
segment = format!("{base}-{n}");
n += 1;
}
}
pub fn parse_sub_context_id(id: &str) -> Option<(&str, &str)> {
id.rsplit_once('/')
}
pub fn render_for_display(id: &str) -> &str {
id.rsplit_once('/').map_or(id, |(_, slug)| slug)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn slugify_basic_lowercasing_and_separators() {
assert_eq!(slugify("Acme Corp"), "acme-corp");
assert_eq!(slugify("ACME"), "acme");
assert_eq!(slugify("Foo123"), "foo123");
}
#[test]
fn slugify_collapses_runs_and_trims() {
assert_eq!(slugify(" Hello, World!! "), "hello-world");
assert_eq!(slugify("a___b---c"), "a-b-c");
assert_eq!(slugify("--leading and trailing--"), "leading-and-trailing");
assert_eq!(slugify("a - b"), "a-b");
}
#[test]
fn slugify_strips_non_ascii_and_handles_empty() {
assert_eq!(slugify(""), "");
assert_eq!(slugify("!!!"), "");
assert_eq!(slugify("café münchen"), "caf-m-nchen");
}
#[test]
fn slugify_caps_at_max_len() {
let long = "a".repeat(100);
assert_eq!(slugify(&long).len(), MAX_SLUG_LEN);
}
#[test]
fn fallback_token_is_stable_and_clean() {
let did = "did:webvh:zQmTestScidValue:example.com";
let t1 = fallback_token(did).unwrap();
let t2 = fallback_token(did).unwrap();
assert_eq!(t1, t2, "fallback token must be stable for a given DID");
assert!(!t1.is_empty());
assert!(t1.len() <= FALLBACK_TOKEN_LEN);
assert!(
t1.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()),
"token must be a clean slug component, got {t1}"
);
}
#[test]
fn fallback_token_rejects_non_webvh() {
assert!(fallback_token("did:key:z6Mk").is_err());
assert!(fallback_token("not-a-did").is_err());
}
#[test]
fn build_uses_slug_under_top() {
let taken = HashSet::<String>::new();
let id = build_sub_context_id(
"openvtc",
Some("Acme Corp"),
"did:webvh:zScid:example.com",
|c| taken.contains(c),
)
.unwrap();
assert_eq!(id, "openvtc/acme-corp");
}
#[test]
fn build_falls_back_to_did_token_when_no_name() {
let taken = HashSet::<String>::new();
let did = "did:webvh:zQmTestScidValue:example.com";
let expected = format!("openvtc/{}", fallback_token(did).unwrap());
for name in [None, Some(""), Some(" "), Some("!!!")] {
let id = build_sub_context_id("openvtc", name, did, |c| taken.contains(c)).unwrap();
assert_eq!(id, expected, "name {name:?} should fall back to DID token");
}
}
#[test]
fn build_suffixes_on_collision() {
let mut taken = HashSet::new();
taken.insert("openvtc/acme".to_string());
taken.insert("openvtc/acme-2".to_string());
let id = build_sub_context_id(
"openvtc",
Some("ACME"),
"did:webvh:zScid:example.com",
|c| taken.contains(c),
)
.unwrap();
assert_eq!(id, "openvtc/acme-3");
}
#[test]
fn parse_and_render_round_trip() {
assert_eq!(
parse_sub_context_id("openvtc/acme"),
Some(("openvtc", "acme"))
);
assert_eq!(
parse_sub_context_id("org/openvtc/acme"),
Some(("org/openvtc", "acme"))
);
assert_eq!(parse_sub_context_id("openvtc"), None);
assert_eq!(render_for_display("openvtc/acme"), "acme");
assert_eq!(render_for_display("org/openvtc/acme"), "acme");
assert_eq!(render_for_display("openvtc"), "openvtc");
}
#[test]
fn validate_identifier_accepts_common_shapes() {
for ok in ["myapp", "My-App_1", "context.v2", "a", "0", "CamelCase"] {
validate_identifier("id", ok).unwrap_or_else(|e| panic!("{ok:?} rejected: {e:?}"));
}
validate_identifier("id", &"a".repeat(MAX_IDENTIFIER_LEN)).unwrap();
assert!(validate_identifier("id", &"a".repeat(MAX_IDENTIFIER_LEN + 1)).is_err());
}
#[test]
fn validate_identifier_rejects_separators_and_injection() {
for bad in [
"",
"global:evil",
"../../etc",
"a:b:c",
"my/ctx",
"with space",
"café",
] {
assert!(
validate_identifier("id", bad).is_err(),
"{bad:?} must be rejected"
);
}
}
#[test]
fn validate_context_path_accepts_good_paths() {
for p in [
"acme",
"acme/eng",
"openvtc/acme-corp",
"a.b_c/d-e",
"x/y/z",
] {
assert!(validate_context_path(p).is_ok(), "{p} should be valid");
}
}
#[test]
fn validate_context_path_rejects_malformed() {
assert!(validate_context_path("").is_err()); assert!(validate_context_path("/acme").is_err()); assert!(validate_context_path("acme/").is_err()); assert!(validate_context_path("acme//eng").is_err()); assert!(validate_context_path("acme/ev il").is_err()); assert!(validate_context_path("acme/ev:il").is_err()); }
#[test]
fn validate_context_path_enforces_max_depth() {
let deep = (0..=MAX_CONTEXT_DEPTH)
.map(|i| format!("s{i}"))
.collect::<Vec<_>>()
.join("/");
assert!(
validate_context_path(&deep).is_err(),
"{deep} exceeds max depth"
);
let ok = (0..MAX_CONTEXT_DEPTH)
.map(|i| format!("s{i}"))
.collect::<Vec<_>>()
.join("/");
assert!(validate_context_path(&ok).is_ok());
}
#[test]
fn child_path_builds_and_validates() {
assert_eq!(child_path("acme", "eng").unwrap(), "acme/eng");
assert!(child_path("acme", "ev/il").is_err()); assert!(child_path("acme", "").is_err()); let at_cap = (0..MAX_CONTEXT_DEPTH)
.map(|i| format!("s{i}"))
.collect::<Vec<_>>()
.join("/");
assert!(child_path(&at_cap, "more").is_err());
}
#[test]
fn build_always_yields_a_valid_context_path() {
let did = "did:webvh:zQmTestScidValue:example.com";
let cases: &[(Option<&str>, &str)] = &[
(Some("Acme Corp"), "openvtc"),
(Some("!!!"), "openvtc"), (None, "org/openvtc"), ];
for (name, top) in cases {
let id =
build_sub_context_id(top, *name, did, |_| false).expect("build should succeed");
validate_context_path(&id)
.unwrap_or_else(|e| panic!("built id {id:?} failed validation: {e:?}"));
}
}
#[test]
fn build_collision_suffix_stays_a_valid_identifier() {
let mut taken = HashSet::new();
taken.insert("openvtc/acme".to_string());
let id = build_sub_context_id(
"openvtc",
Some("ACME"),
"did:webvh:zScid:example.com",
|c| taken.contains(c),
)
.unwrap();
assert_eq!(id, "openvtc/acme-2");
validate_context_path(&id).unwrap();
}
#[test]
fn build_rejects_an_invalid_top_context() {
let at_cap = (0..MAX_CONTEXT_DEPTH)
.map(|i| format!("s{i}"))
.collect::<Vec<_>>()
.join("/");
let r = build_sub_context_id(&at_cap, Some("acme"), "did:webvh:zScid:example.com", |_| {
false
});
assert!(r.is_err(), "child under a max-depth top must fail");
}
}