structfs_path_validation/lib.rs
1//! The canonical StructFS path component grammar.
2//!
3//! This crate exists so that everything that validates path components —
4//! `structfs-core-store` at runtime and `structfs-path-macro` at compile
5//! time — shares one implementation. A forked grammar can drift; a shared
6//! one cannot.
7//!
8//! # Grammar
9//!
10//! A path component is one of:
11//!
12//! - A **pure numeric string** (`0`, `42`) — used for array indexing.
13//! - A **Unicode identifier** per UAX#31: first char is `XID_Start`, or an
14//! underscore followed by at least one `XID_Continue` char; remaining
15//! chars are `XID_Continue`.
16//!
17//! Empty components, bare `_`, and components containing `/`, `-`, spaces,
18//! or other punctuation are invalid. Arbitrary strings can be made valid
19//! with Namecode encoding (see `PathComponent::encode` in core-store).
20
21/// Validate a single path component against the StructFS grammar.
22///
23/// Returns a human-readable description of the problem on failure.
24pub fn validate_component(component: &str) -> Result<(), String> {
25 if component.is_empty() {
26 return Err("empty component".to_string());
27 }
28
29 // Allow pure numeric strings (for array indexing)
30 if component.chars().all(|c| c.is_ascii_digit()) {
31 return Ok(());
32 }
33
34 let mut chars = component.chars();
35 let first = chars.next().unwrap();
36
37 // First char: XID_Start or underscore followed by XID_Continue
38 let valid_start = unicode_ident::is_xid_start(first)
39 || (first == '_'
40 && chars
41 .clone()
42 .next()
43 .is_some_and(unicode_ident::is_xid_continue));
44
45 if !valid_start {
46 return Err("must start with a letter or underscore followed by letter/digit".to_string());
47 }
48
49 // Rest: XID_Continue
50 for c in chars {
51 if !unicode_ident::is_xid_continue(c) {
52 return Err(format!("invalid character '{}' in identifier", c));
53 }
54 }
55
56 Ok(())
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62
63 #[test]
64 fn valid_components() {
65 for s in ["foo", "_foo", "café", "名前", "0", "42", "a1", "_1"] {
66 assert!(validate_component(s).is_ok(), "expected valid: {s:?}");
67 }
68 }
69
70 #[test]
71 fn invalid_components() {
72 for s in ["", "_", "-", "a-b", "a b", ".hidden", "1abc", "a/b", "a$b"] {
73 assert!(validate_component(s).is_err(), "expected invalid: {s:?}");
74 }
75 }
76}