use std::sync::OnceLock;
use regex::Regex;
use crate::error::ManifestError;
pub const ID_REGEX_SRC: &str = r"^[a-z][a-z0-9_-]{0,63}$";
pub const MAX_ID_LEN: usize = 64;
pub fn id_regex() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(ID_REGEX_SRC).expect("valid id regex"))
}
pub const RESERVED_IDS: &[&str] = &[
"agent",
"browser",
"core",
"email",
"heartbeat",
"memory",
"telegram",
"whatsapp",
];
static DYNAMIC_RESERVED_IDS: OnceLock<Vec<String>> = OnceLock::new();
pub fn register_reserved_ids(ids: impl IntoIterator<Item = String>) -> Result<(), &'static str> {
let mut v: Vec<String> = ids.into_iter().collect();
v.sort();
v.dedup();
DYNAMIC_RESERVED_IDS
.set(v)
.map_err(|_| "reserved ids already initialized")
}
pub fn is_reserved_id(id: &str) -> bool {
if RESERVED_IDS.contains(&id) {
return true;
}
DYNAMIC_RESERVED_IDS
.get()
.map(|v| v.iter().any(|s| s == id))
.unwrap_or(false)
}
pub fn validate_id(id: &str) -> Result<(), ManifestError> {
if id.len() > MAX_ID_LEN {
return Err(ManifestError::IdInvalid {
id: id.to_string(),
reason: "exceeds max length 64",
});
}
if !id_regex().is_match(id) {
return Err(ManifestError::IdInvalid {
id: id.to_string(),
reason: "must match `^[a-z][a-z0-9_-]{0,63}$`",
});
}
if is_reserved_id(id) {
return Err(ManifestError::IdInvalid {
id: id.to_string(),
reason: "reserved by the framework",
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_regex_accepts_hyphens() {
assert!(id_regex().is_match("agent-creator"));
assert!(id_regex().is_match("template-rust"));
assert!(id_regex().is_match("video-frames"));
}
#[test]
fn id_regex_accepts_underscores() {
assert!(id_regex().is_match("agent_creator"));
assert!(id_regex().is_match("template_plugin_typescript"));
}
#[test]
fn id_regex_rejects_uppercase() {
assert!(!id_regex().is_match("Agent-Creator"));
assert!(!id_regex().is_match("AGENT_CREATOR"));
}
#[test]
fn id_regex_rejects_starts_with_digit() {
assert!(!id_regex().is_match("1foo"));
assert!(!id_regex().is_match("9_plugin"));
}
#[test]
fn id_regex_rejects_starts_with_hyphen() {
assert!(!id_regex().is_match("-agent"));
assert!(!id_regex().is_match("_agent"));
}
#[test]
fn id_regex_max_64_chars() {
let id_64 = "a".repeat(64);
let id_65 = "a".repeat(65);
assert!(id_regex().is_match(&id_64));
assert!(!id_regex().is_match(&id_65));
}
#[test]
fn validate_id_reports_too_long_separately_from_regex() {
let err = validate_id(&"a".repeat(65)).unwrap_err();
match err {
ManifestError::IdInvalid { reason, .. } => {
assert!(reason.contains("max length"), "got: {reason}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn validate_id_reports_regex_mismatch() {
let err = validate_id("Agent-Creator").unwrap_err();
match err {
ManifestError::IdInvalid { reason, .. } => {
assert!(reason.contains("must match"), "got: {reason}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn validate_id_rejects_reserved() {
let err = validate_id("browser").unwrap_err();
match err {
ManifestError::IdInvalid { reason, .. } => {
assert!(reason.contains("reserved"), "got: {reason}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn validate_id_accepts_valid_id() {
validate_id("agent-creator").expect("valid");
validate_id("agent_creator").expect("valid");
validate_id("ventas_etb").expect("valid");
}
#[test]
fn reserved_ids_static_list_is_locked_down() {
let expected: &[&str] = &[
"agent",
"browser",
"core",
"email",
"heartbeat",
"memory",
"telegram",
"whatsapp",
];
assert_eq!(RESERVED_IDS, expected);
}
#[test]
fn is_reserved_id_covers_static_list() {
assert!(is_reserved_id("browser"));
assert!(is_reserved_id("whatsapp"));
assert!(!is_reserved_id("agent-creator"));
assert!(!is_reserved_id("ventas-etb"));
}
}