Add derive-based protocol leaf declarations

This commit is contained in:
Michael Mikovsky
2026-04-25 14:41:00 -06:00
parent 3e764610eb
commit b1ebe34ec1
11 changed files with 1056 additions and 1 deletions
+7 -1
View File
@@ -11,9 +11,15 @@
#![no_std]
extern crate alloc;
pub extern crate alloc;
// Re-export derive macros against a stable `::unshell` path, including when the
// macros are used inside this crate's own examples and tests.
#[allow(unused_extern_crates)]
extern crate self as unshell;
pub mod logger;
pub mod protocol;
pub use unshell_macros::Leaf;
// pub use ush_obfuscate as obfuscate;
+224
View File
@@ -0,0 +1,224 @@
//! Application-facing leaf metadata helpers.
//!
//! The protocol runtime itself only knows about `LeafSpec` metadata and validated
//! `LocalEvent::Call` delivery. This trait sits one layer above that runtime so
//! application code can declare canonical leaf names and procedure ids once and
//! then reuse the generated metadata when building endpoints and dispatching calls.
use alloc::{string::String, vec::Vec};
use super::LeafSpec;
/// Static metadata for one application-defined protocol leaf.
pub trait ProtocolLeaf {
/// Returns the canonical dotted leaf name hosted by this type.
fn leaf_name() -> String;
/// Returns the local procedure suffixes supported by this leaf.
fn procedure_suffixes() -> &'static [&'static str];
/// Resolves one local procedure suffix to its full canonical `procedure_id`.
fn procedure_id(suffix: &str) -> Option<String> {
if !Self::procedure_suffixes().contains(&suffix) {
return None;
}
let mut procedure_id = Self::leaf_name();
procedure_id.push('.');
procedure_id.push_str(suffix);
Some(procedure_id)
}
/// Returns the full canonical `procedure_id` values supported by this leaf.
fn procedure_ids() -> Vec<String> {
Self::procedure_suffixes()
.iter()
.filter_map(|suffix| Self::procedure_id(suffix))
.collect()
}
/// Materializes the runtime leaf metadata consumed by `ProtocolEndpoint`.
fn leaf_spec() -> LeafSpec {
LeafSpec {
name: Self::leaf_name(),
procedures: Self::procedure_ids(),
}
}
}
/// Builds one canonical dotted leaf id from crate-local metadata plus optional
/// user overrides.
///
/// Rationale: derive macros cannot reliably inspect Cargo workspace metadata, but
/// they can always access the current package name, module path, crate version,
/// and Rust type name at the expansion site. This helper normalizes those inputs
/// into one stable dotted identifier without leaking Rust separators or casing
/// into protocol-visible names.
pub fn derive_leaf_name(
package_name: &str,
version_major: &str,
version_minor: &str,
version_patch: &str,
module_path: &str,
type_name: &str,
org: Option<&str>,
product: Option<&str>,
version: Option<&str>,
leaf_name: Option<&str>,
id: Option<&str>,
) -> String {
if let Some(id) = id.filter(|value| !value.is_empty()) {
return normalize_leaf_path(id);
}
let package_segment = normalize_leaf_segment(package_name);
let mut segments = Vec::new();
segments.push(normalize_leaf_segment(org.unwrap_or(package_name)));
segments.push(normalize_leaf_segment(product.unwrap_or(package_name)));
segments.push(normalize_version_segment(version.unwrap_or(
&alloc::format!("v{}_{}_{}", version_major, version_minor, version_patch),
)));
if let Some(leaf_name) = leaf_name.filter(|value| !value.is_empty()) {
segments.extend(split_leaf_path(leaf_name));
} else {
let mut module_segments = module_path
.split("::")
.map(normalize_leaf_segment)
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if module_segments
.first()
.is_some_and(|segment| segment == &package_segment)
{
module_segments.remove(0);
}
segments.extend(module_segments);
segments.push(normalize_leaf_segment(type_name));
}
segments.join(".")
}
fn normalize_leaf_path(value: &str) -> String {
split_leaf_path(value).join(".")
}
fn split_leaf_path(value: &str) -> Vec<String> {
value
.split('.')
.map(normalize_leaf_segment)
.filter(|segment| !segment.is_empty())
.collect()
}
fn normalize_version_segment(value: &str) -> String {
let normalized = normalize_leaf_segment(value);
if normalized.starts_with('v') && normalized.len() > 1 {
normalized
} else {
alloc::format!("v{}", normalized)
}
}
fn normalize_leaf_segment(value: &str) -> String {
let mut normalized = String::with_capacity(value.len());
let mut previous_was_separator = false;
for character in value.chars() {
if character.is_ascii_uppercase() {
if !normalized.is_empty() && !previous_was_separator {
normalized.push('_');
}
normalized.push(character.to_ascii_lowercase());
previous_was_separator = false;
continue;
}
if character.is_ascii_lowercase() || character.is_ascii_digit() {
normalized.push(character);
previous_was_separator = false;
continue;
}
if !normalized.is_empty() && !previous_was_separator {
normalized.push('_');
previous_was_separator = true;
}
}
while normalized.ends_with('_') {
normalized.pop();
}
if normalized.is_empty() {
String::from("leaf")
} else {
normalized
}
}
#[cfg(test)]
mod tests {
use super::derive_leaf_name;
#[test]
fn derive_leaf_name_normalizes_inputs_into_dotted_segments() {
assert_eq!(
derive_leaf_name(
"unshell-core",
"0",
"1",
"0",
"unshell_core::examples::demo_shell",
"ShellLeaf",
None,
None,
None,
None,
None,
),
"unshell_core.unshell_core.v0_1_0.examples.demo_shell.shell_leaf"
);
}
#[test]
fn derive_leaf_name_applies_partial_overrides() {
assert_eq!(
derive_leaf_name(
"unshell-core",
"0",
"1",
"0",
"unshell_core::examples::demo_shell",
"ShellLeaf",
Some("org"),
Some("product"),
Some("v1.2.3.4"),
Some("echo.shell"),
None,
),
"org.product.v1_2_3_4.echo.shell"
);
}
#[test]
fn derive_leaf_name_id_override_wins() {
assert_eq!(
derive_leaf_name(
"unshell-core",
"0",
"1",
"0",
"unshell_core::examples::demo_shell",
"ShellLeaf",
Some("org"),
Some("product"),
Some("v1"),
Some("echo"),
Some("org.example.v1.echo.abc"),
),
"org.example.v1.echo.abc"
);
}
}
+2
View File
@@ -7,6 +7,7 @@
mod endpoint;
mod hook;
mod leaf;
mod routing;
pub use endpoint::{
@@ -14,6 +15,7 @@ pub use endpoint::{
LocalEvent, ProtocolEndpoint,
};
pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook};
pub use leaf::{ProtocolLeaf, derive_leaf_name};
pub use routing::{
CompiledRoutes, DefaultRouteProvider, LeafNode, RouteDecision, RouteProvider, TreeNode,
is_prefix, route_destination,