use crate::identifier::{ValidationError, validate_identifier};
pub const MAX_CONTEXT_DEPTH: usize = 8;
pub const SEPARATOR: char = '/';
pub fn validate_context_path(value: &str) -> Result<(), ValidationError> {
if value.is_empty() {
return Err(ValidationError("context path must not be empty".into()));
}
if value.starts_with(SEPARATOR) || value.ends_with(SEPARATOR) {
return Err(ValidationError(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(ValidationError(format!(
"context path is {} levels deep; maximum is {MAX_CONTEXT_DEPTH}",
segments.len()
)));
}
for segment in &segments {
if segment.is_empty() {
return Err(ValidationError(
"context path must not contain an empty segment ('//')".into(),
));
}
validate_identifier("context path segment", segment)?;
}
Ok(())
}
fn segments(path: &str) -> impl Iterator<Item = &str> {
path.split(SEPARATOR)
}
pub fn is_ancestor_or_self(ancestor: &str, descendant: &str) -> bool {
if ancestor.is_empty() || descendant.is_empty() {
return false;
}
let mut anc = segments(ancestor);
let mut desc = segments(descendant);
loop {
match anc.next() {
None => return true,
Some(a) => match desc.next() {
None => return false,
Some(d) if a != d => return false,
Some(_) => continue,
},
}
}
}
pub fn parent_path(path: &str) -> Option<&str> {
path.rsplit_once(SEPARATOR).map(|(parent, _)| parent)
}
pub fn depth(path: &str) -> usize {
if path.is_empty() {
return 0;
}
path.split(SEPARATOR).count()
}
pub fn child_path(parent: &str, segment: &str) -> Result<String, ValidationError> {
validate_identifier("context path segment", segment)?;
let candidate = format!("{parent}{SEPARATOR}{segment}");
validate_context_path(&candidate)?;
Ok(candidate)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_good_paths() {
for p in ["acme", "acme/eng", "acme/eng/team-a", "a.b_c/d-e", "x/y/z"] {
assert!(validate_context_path(p).is_ok(), "{p} should be valid");
}
}
#[test]
fn rejects_malformed_paths() {
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/..").is_ok());
}
#[test]
fn 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 ancestry_is_segment_aware_not_string_prefix() {
assert!(is_ancestor_or_self("acme", "acme"));
assert!(is_ancestor_or_self("acme/eng", "acme/eng"));
assert!(is_ancestor_or_self("acme", "acme/eng"));
assert!(is_ancestor_or_self("acme", "acme/eng/team-a"));
assert!(is_ancestor_or_self("acme/eng", "acme/eng/team-a"));
assert!(!is_ancestor_or_self("acme", "acme-evil"));
assert!(!is_ancestor_or_self("acme/eng", "acme/engineering"));
assert!(!is_ancestor_or_self("ac", "acme"));
assert!(!is_ancestor_or_self("acme/eng", "acme"));
assert!(!is_ancestor_or_self("acme/eng", "acme/ops"));
assert!(!is_ancestor_or_self("", "acme"));
assert!(!is_ancestor_or_self("acme", ""));
}
#[test]
fn parent_and_depth() {
assert_eq!(parent_path("acme"), None);
assert_eq!(parent_path("acme/eng"), Some("acme"));
assert_eq!(parent_path("acme/eng/team-a"), Some("acme/eng"));
assert_eq!(depth("acme"), 1);
assert_eq!(depth("acme/eng/team-a"), 3);
assert_eq!(depth(""), 0);
}
#[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()); }
}