From bc22d349bfd33316750bf7153f333fa026b74481 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:54:44 -0600 Subject: [PATCH] Add compile-time leaf declarations Introduce a function-like leaf declaration macro, bind endpoint and TUI hosts to shared generated metadata, and move remote shell endpoint construction out of the leaf module into the examples and runtime assembly code. --- examples/protocol/remote_shell_endpoint.rs | 20 +- examples/protocol/remote_shell_receive.rs | 13 +- .../protocol/remote_shell_single_endpoint.rs | 5 +- src/lib.rs | 2 +- unshell-leaves/src/lib.rs | 2 +- unshell-leaves/src/remote_shell/endpoint.rs | 48 +-- .../src/remote_shell/endpoint/session.rs | 6 +- unshell-leaves/src/remote_shell/mod.rs | 23 ++ unshell-leaves/src/remote_shell/tui.rs | 4 +- unshell-macros/src/leaf.rs | 4 + unshell-macros/src/leaf_decl.rs | 307 ++++++++++++++++++ unshell-macros/src/lib.rs | 30 ++ unshell-macros/src/procedure.rs | 6 +- unshell-macros/src/procedures.rs | 14 +- unshell-protocol/src/protocol/tree/leaf.rs | 212 +++++++----- unshell-protocol/src/protocol/tree/mod.rs | 6 +- .../src/protocol/tree/procedure.rs | 66 +++- 17 files changed, 598 insertions(+), 170 deletions(-) create mode 100644 unshell-macros/src/leaf_decl.rs diff --git a/examples/protocol/remote_shell_endpoint.rs b/examples/protocol/remote_shell_endpoint.rs index 264fd15..8015ef2 100644 --- a/examples/protocol/remote_shell_endpoint.rs +++ b/examples/protocol/remote_shell_endpoint.rs @@ -10,12 +10,24 @@ use std::sync::mpsc::RecvTimeoutError; use std::time::Duration; use unshell::leaves::remote_shell; -use unshell::protocol::tree::Ingress; +use unshell::protocol::tree::{Ingress, ProcedureRuntime, ProtocolEndpoint}; fn main() -> Result<(), Box> { let mut stream = TcpStream::connect(remote_shell::endpoint::LISTEN_ADDR)?; let frame_rx = remote_shell::endpoint::spawn_frame_reader(stream.try_clone()?); - let mut runtime = remote_shell::endpoint::build_agent_runtime(); + let endpoint = ProtocolEndpoint::new( + agent_path(), + Some(Vec::new()), + Vec::new(), + vec![remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()], + ); + let mut runtime = ProcedureRuntime::< + remote_shell::endpoint::RemoteShellEndpoint, + remote_shell::endpoint::Open, + >::new( + endpoint, + remote_shell::endpoint::RemoteShellEndpoint::default(), + ); println!( "connected to controller at {}", @@ -39,3 +51,7 @@ fn main() -> Result<(), Box> { Ok(()) } + +fn agent_path() -> Vec { + vec![String::from("agent")] +} diff --git a/examples/protocol/remote_shell_receive.rs b/examples/protocol/remote_shell_receive.rs index 69b6945..0f55452 100644 --- a/examples/protocol/remote_shell_receive.rs +++ b/examples/protocol/remote_shell_receive.rs @@ -9,7 +9,9 @@ use std::net::TcpListener; use unshell::leaves::remote_shell; use unshell::leaves::remote_shell::OpenRequest; use unshell::protocol::tree::encode_call_reply; -use unshell::protocol::tree::{Endpoint, EndpointOutcome, Ingress, LocalEvent}; +use unshell::protocol::tree::{ + ChildRoute, Endpoint, EndpointOutcome, Ingress, LocalEvent, ProtocolEndpoint, +}; fn main() -> Result<(), Box> { let listener = TcpListener::bind(remote_shell::endpoint::LISTEN_ADDR)?; @@ -19,10 +21,15 @@ fn main() -> Result<(), Box> { println!("accepted endpoint connection from {peer_addr}"); let frame_rx = remote_shell::endpoint::spawn_frame_reader(stream.try_clone()?); - let mut endpoint = remote_shell::endpoint::build_controller_endpoint(); + let mut endpoint = ProtocolEndpoint::new( + Vec::new(), + None, + vec![ChildRoute::registered(agent_path())], + Vec::new(), + ); let hook_id = endpoint.allocate_hook_id(); let shell_leaf_name = remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_name(); - let open_procedure = remote_shell::endpoint::ProcedureOpen::protocol_procedure_id(); + let open_procedure = remote_shell::endpoint::Open::protocol_procedure_id(); remote_shell::endpoint::send_forward( &mut stream, diff --git a/examples/protocol/remote_shell_single_endpoint.rs b/examples/protocol/remote_shell_single_endpoint.rs index 8f21a79..e9ae47f 100644 --- a/examples/protocol/remote_shell_single_endpoint.rs +++ b/examples/protocol/remote_shell_single_endpoint.rs @@ -16,10 +16,7 @@ fn main() -> Result<(), Box> { agent_path(), Some(Vec::new()), Vec::new(), - vec![unshell::protocol::tree::LeafSpec { - name: remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_name(), - procedures: vec![remote_shell::endpoint::ProcedureOpen::protocol_procedure_id()], - }], + vec![remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()], ); let hook_id = endpoint.allocate_hook_id(); diff --git a/src/lib.rs b/src/lib.rs index a47b5ed..7b05349 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,6 @@ pub use unshell_protocol as protocol; /// Re-export the leaf library crate behind the historical `unshell::leaves` path pub use unshell_leaves as leaves; -pub use unshell_macros::{Leaf, Procedure, procedures}; +pub use unshell_macros::{Leaf, Procedure, leaf, procedures}; // pub use ush_obfuscate as obfuscate; diff --git a/unshell-leaves/src/lib.rs b/unshell-leaves/src/lib.rs index 2b463aa..ed07f1e 100644 --- a/unshell-leaves/src/lib.rs +++ b/unshell-leaves/src/lib.rs @@ -12,7 +12,7 @@ pub extern crate alloc; use unshell_protocol::DataMessage; -pub use unshell_macros::{Leaf, Procedure, procedures}; +pub use unshell_macros::{Leaf, Procedure, leaf, procedures}; pub use unshell_protocol as protocol; /// Re-exports one role-specific type behind a stable public alias. diff --git a/unshell-leaves/src/remote_shell/endpoint.rs b/unshell-leaves/src/remote_shell/endpoint.rs index b4c437b..c5a4d3f 100644 --- a/unshell-leaves/src/remote_shell/endpoint.rs +++ b/unshell-leaves/src/remote_shell/endpoint.rs @@ -6,13 +6,10 @@ mod transport; use std::collections::BTreeMap; -use unshell::Leaf; -use unshell::protocol::tree::{ - Call, HookKey, Procedure, ProcedureEffect, ProcedureRuntime, ProcedureStore, ProtocolEndpoint, -}; +use unshell::protocol::tree::{Call, HookKey, Procedure, ProcedureEffect, ProcedureStore}; pub use errors::ShellLeafError; -pub use session::ProcedureOpen; +pub use session::Open; pub use transport::{LISTEN_ADDR, send_forward, spawn_frame_reader, write_frames}; use super::OpenRequest; @@ -22,25 +19,24 @@ use super::OpenRequest; /// The endpoint keeps each live shell session in an explicit map keyed by the /// caller-owned hook identity. That makes ownership and cleanup of hook-backed /// shell processes easy to inspect during debugging. -#[derive(Default, Leaf)] -#[leaf(leaf_name = "remote_shell")] +#[derive(Default)] pub struct RemoteShellEndpoint { - sessions: BTreeMap, + sessions: BTreeMap, } -impl ProcedureStore for RemoteShellEndpoint { - fn procedure_sessions(&mut self) -> &mut BTreeMap { +impl ProcedureStore for RemoteShellEndpoint { + fn procedure_sessions(&mut self) -> &mut BTreeMap { &mut self.sessions } } -impl Procedure for ProcedureOpen { +impl Procedure for Open { type Error = ShellLeafError; type Input = OpenRequest; fn open(_leaf: &mut RemoteShellEndpoint, call: Call) -> Result { let hook_key = call.response_hook.ok_or(ShellLeafError::MissingHook)?; - ProcedureOpen::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id) + Open::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id) } fn on_data( @@ -70,31 +66,3 @@ impl Procedure for ProcedureOpen { session.terminate() } } - -/// Builds the controller endpoint used by the receiver example. -pub fn build_controller_endpoint() -> ProtocolEndpoint { - ProtocolEndpoint::new( - Vec::new(), - None, - vec![unshell::protocol::tree::ChildRoute::registered(agent_path())], - Vec::new(), - ) -} - -/// Builds the stateful shell runtime used by the endpoint example. -pub fn build_agent_runtime() -> ProcedureRuntime { - let endpoint = ProtocolEndpoint::new( - agent_path(), - Some(Vec::new()), - Vec::new(), - vec![unshell::protocol::tree::LeafSpec { - name: RemoteShellEndpoint::protocol_leaf_name(), - procedures: vec![ProcedureOpen::protocol_procedure_id()], - }], - ); - ProcedureRuntime::new(endpoint, RemoteShellEndpoint::default()) -} - -fn agent_path() -> Vec { - vec![String::from("agent")] -} diff --git a/unshell-leaves/src/remote_shell/endpoint/session.rs b/unshell-leaves/src/remote_shell/endpoint/session.rs index 65afd2a..16cc0c0 100644 --- a/unshell-leaves/src/remote_shell/endpoint/session.rs +++ b/unshell-leaves/src/remote_shell/endpoint/session.rs @@ -23,7 +23,7 @@ use super::errors::ShellLeafError; /// one opening procedure and one live hook remains direct and visible. #[derive(Procedure)] #[procedure(leaf = RemoteShellEndpoint, name = "open")] -pub struct ProcedureOpen { +pub struct Open { /// Spawned PTY child process. pub(super) child: Box, /// Process-group leader used for Unix hangup and kill signaling. @@ -52,7 +52,7 @@ enum OutputEvent { ReaderClosed, } -impl ProcedureOpen { +impl Open { pub(super) fn spawn( return_path: Vec, hook_id: u64, @@ -213,7 +213,7 @@ impl ProcedureOpen { } } -impl Drop for ProcedureOpen { +impl Drop for Open { fn drop(&mut self) { let _ = self.terminate(); } diff --git a/unshell-leaves/src/remote_shell/mod.rs b/unshell-leaves/src/remote_shell/mod.rs index 80ea83d..0eeb2d8 100644 --- a/unshell-leaves/src/remote_shell/mod.rs +++ b/unshell-leaves/src/remote_shell/mod.rs @@ -24,6 +24,29 @@ pub use tui::RemoteShellTui; #[derive(Archive, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct OpenRequest; +#[cfg(any(feature = "leaf_endpoint", feature = "leaf_tui"))] +macro_rules! declare_remote_shell_leaf { + ($($role_args:tt)*) => { + crate::leaf! { + name = "remote_shell", + procedures = [Open], + $($role_args)* + } + }; +} + +#[cfg(all(feature = "leaf_endpoint", not(feature = "leaf_tui")))] +declare_remote_shell_leaf!(endpoint_struct = RemoteShellEndpoint,); + +#[cfg(all(not(feature = "leaf_endpoint"), feature = "leaf_tui"))] +declare_remote_shell_leaf!(tui_struct = RemoteShellTui,); + +#[cfg(all(feature = "leaf_endpoint", feature = "leaf_tui"))] +declare_remote_shell_leaf!( + endpoint_struct = RemoteShellEndpoint, + tui_struct = RemoteShellTui, +); + crate::role_leaf! { /// Feature-selected remote shell surface. pub type RemoteShell { diff --git a/unshell-leaves/src/remote_shell/tui.rs b/unshell-leaves/src/remote_shell/tui.rs index aebf52e..9bfb8ec 100644 --- a/unshell-leaves/src/remote_shell/tui.rs +++ b/unshell-leaves/src/remote_shell/tui.rs @@ -7,14 +7,12 @@ use std::string::String; use std::vec::Vec; -use unshell::Leaf; use unshell::protocol::DataMessage; use crate::{LeafTui, TuiError}; /// Stub TUI surface for the remote shell leaf. -#[derive(Default, Leaf)] -#[leaf(leaf_name = "remote_shell")] +#[derive(Default)] pub struct RemoteShellTui { transcript: Vec, } diff --git a/unshell-macros/src/leaf.rs b/unshell-macros/src/leaf.rs index b927be7..2e9d964 100644 --- a/unshell-macros/src/leaf.rs +++ b/unshell-macros/src/leaf.rs @@ -146,6 +146,10 @@ pub(crate) fn expand_leaf(input: DeriveInput) -> Result, + id: Option, + org: Option, + product: Option, + version: Option, + endpoint_struct: Option, + tui_struct: Option, + procedures: Vec, +} + +impl Parse for LeafDeclarationInput { + fn parse(input: ParseStream<'_>) -> Result { + let visibility = if input.peek(Token![pub]) { + input.parse()? + } else { + Visibility::Inherited + }; + + let assignments = Punctuated::::parse_terminated(input)?; + let mut parsed = Self { + visibility, + name: None, + id: None, + org: None, + product: None, + version: None, + endpoint_struct: None, + tui_struct: None, + procedures: Vec::new(), + }; + + for assignment in assignments { + match assignment { + LeafAssignment::Name(value) => { + if parsed.name.is_some() { + return Err(Error::new_spanned(value, "duplicate leaf name")); + } + parsed.name = Some(value); + } + LeafAssignment::Id(value) => { + if parsed.id.is_some() { + return Err(Error::new_spanned(value, "duplicate leaf id")); + } + parsed.id = Some(value); + } + LeafAssignment::Org(value) => { + if parsed.org.is_some() { + return Err(Error::new_spanned(value, "duplicate leaf org")); + } + parsed.org = Some(value); + } + LeafAssignment::Product(value) => { + if parsed.product.is_some() { + return Err(Error::new_spanned(value, "duplicate leaf product")); + } + parsed.product = Some(value); + } + LeafAssignment::Version(value) => { + if parsed.version.is_some() { + return Err(Error::new_spanned(value, "duplicate leaf version")); + } + parsed.version = Some(value); + } + LeafAssignment::EndpointStruct(value) => { + if parsed.endpoint_struct.is_some() { + return Err(Error::new_spanned(value, "duplicate endpoint_struct")); + } + parsed.endpoint_struct = Some(value); + } + LeafAssignment::TuiStruct(value) => { + if parsed.tui_struct.is_some() { + return Err(Error::new_spanned(value, "duplicate tui_struct")); + } + parsed.tui_struct = Some(value); + } + LeafAssignment::Procedures(values) => { + if !parsed.procedures.is_empty() { + return Err(Error::new(input.span(), "duplicate procedures list")); + } + parsed.procedures = values; + } + } + } + + if parsed.name.is_none() && parsed.id.is_none() { + return Err(Error::new( + input.span(), + "leaf! requires either `name = \"...\"` or `id = \"...\"`", + )); + } + if parsed.endpoint_struct.is_none() && parsed.tui_struct.is_none() { + return Err(Error::new( + input.span(), + "leaf! requires at least one of `endpoint_struct = ...` or `tui_struct = ...`", + )); + } + + Ok(parsed) + } +} + +enum LeafAssignment { + Name(LitStr), + Id(LitStr), + Org(LitStr), + Product(LitStr), + Version(LitStr), + EndpointStruct(Ident), + TuiStruct(Ident), + Procedures(Vec), +} + +impl Parse for LeafAssignment { + fn parse(input: ParseStream<'_>) -> Result { + let name: Ident = input.parse()?; + input.parse::()?; + match name.to_string().as_str() { + "name" => Ok(Self::Name(input.parse()?)), + "id" => Ok(Self::Id(input.parse()?)), + "org" => Ok(Self::Org(input.parse()?)), + "product" => Ok(Self::Product(input.parse()?)), + "version" => Ok(Self::Version(input.parse()?)), + "endpoint_struct" => Ok(Self::EndpointStruct(input.parse()?)), + "tui_struct" => Ok(Self::TuiStruct(input.parse()?)), + "procedures" => { + let content; + syn::bracketed!(content in input); + let values = Punctuated::::parse_terminated(&content)? + .into_iter() + .collect::>(); + Ok(Self::Procedures(values)) + } + _ => Err(Error::new_spanned(name, "unsupported leaf! assignment")), + } + } +} + +pub(crate) fn expand_leaf_declaration(input: LeafDeclarationInput) -> Result { + let _visibility = input.visibility; + let declaration_ident = format_ident!( + "__UnshellLeafDecl_{}", + input + .endpoint_struct + .as_ref() + .or(input.tui_struct.as_ref()) + .expect("leaf declaration requires at least one host") + ); + let id = option_litstr_tokens(input.id.as_ref()); + let org = option_litstr_tokens(input.org.as_ref()); + let product = option_litstr_tokens(input.product.as_ref()); + let version = option_litstr_tokens(input.version.as_ref()); + let leaf_name = option_litstr_tokens(input.name.as_ref()); + let procedure_suffixes = input + .procedures + .iter() + .map(|procedure| LitStr::new(&normalize_suffix(&procedure.to_string()), procedure.span())) + .collect::>(); + + let endpoint_impl = input + .endpoint_struct + .as_ref() + .map(|endpoint_struct| expand_binding_impl(endpoint_struct, &declaration_ident)); + let tui_impl = input + .tui_struct + .as_ref() + .map(|tui_struct| expand_binding_impl(tui_struct, &declaration_ident)); + + Ok(quote! { + #[allow(non_camel_case_types)] + #[doc(hidden)] + pub struct #declaration_ident; + + impl ::unshell::protocol::tree::ProtocolLeaf for #declaration_ident { + fn leaf_name() -> ::unshell::alloc::string::String { + ::unshell::protocol::tree::derive_leaf_name( + ::core::env!("CARGO_PKG_NAME"), + ::core::env!("CARGO_PKG_VERSION_MAJOR"), + ::core::env!("CARGO_PKG_VERSION_MINOR"), + ::core::env!("CARGO_PKG_VERSION_PATCH"), + ::core::module_path!(), + ::core::stringify!(#declaration_ident), + #org, + #product, + #version, + #leaf_name, + #id, + ) + } + } + + impl ::unshell::protocol::tree::LeafDeclaration for #declaration_ident { + fn procedure_suffixes() -> &'static [&'static str] { + &[#(#procedure_suffixes),*] + } + } + + impl #declaration_ident { + /// Returns the canonical dotted leaf name declared for this surface. + pub fn protocol_leaf_name() -> ::unshell::alloc::string::String { + ::leaf_name() + } + + /// Returns the canonical protocol leaf metadata for this surface. + pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec { + ::leaf_spec() + } + + /// Resolves one local procedure suffix to its full canonical `procedure_id`. + pub fn protocol_procedure_id( + suffix: &str, + ) -> ::core::option::Option<::unshell::alloc::string::String> { + ::procedure_id(suffix) + } + } + + #endpoint_impl + #tui_impl + }) +} + +fn expand_binding_impl(host: &Ident, declaration: &Ident) -> TokenStream { + quote! { + impl ::unshell::protocol::tree::ProtocolLeaf for #host { + fn leaf_name() -> ::unshell::alloc::string::String { + <#declaration as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name() + } + } + + impl ::unshell::protocol::tree::LeafBinding for #host { + type Declaration = #declaration; + } + + impl ::unshell::protocol::tree::LeafDeclaration for #host { + fn procedure_suffixes() -> &'static [&'static str] { + <#declaration as ::unshell::protocol::tree::LeafDeclaration>::procedure_suffixes() + } + } + + impl #host { + /// Returns the canonical dotted leaf name declared for this host. + pub fn protocol_leaf_name() -> ::unshell::alloc::string::String { + ::leaf_name() + } + + /// Returns the canonical protocol leaf metadata for this host. + pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec { + ::leaf_spec() + } + + /// Resolves one local procedure suffix to its full canonical `procedure_id`. + pub fn protocol_procedure_id( + suffix: &str, + ) -> ::core::option::Option<::unshell::alloc::string::String> { + ::procedure_id(suffix) + } + } + } +} + +fn normalize_suffix(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("procedure") + } else { + normalized + } +} diff --git a/unshell-macros/src/lib.rs b/unshell-macros/src/lib.rs index 17ae581..a469d55 100644 --- a/unshell-macros/src/lib.rs +++ b/unshell-macros/src/lib.rs @@ -1,6 +1,7 @@ //! Proc macros for `unshell` application-layer leaf declarations. mod leaf; +mod leaf_decl; mod procedure; mod procedures; mod utils; @@ -8,6 +9,35 @@ mod utils; use proc_macro::TokenStream; use syn::{DeriveInput, ItemImpl, parse_macro_input}; +/// Declares one compile-time leaf surface and binds it to endpoint and/or TUI +/// host structs. +/// +/// What it is: a function-like macro that generates the shared protocol-visible +/// metadata for one leaf and applies that metadata to the listed host structs. +/// +/// Why it exists: endpoint and TUI hosts should not each have to repeat the leaf +/// name and procedure inventory, and endpoint construction should not need a +/// handwritten list of procedure ids. +/// +/// # Example +/// ```ignore +/// unshell::leaf! { +/// name = "remote_shell", +/// procedures = [Open, Reset, whoami], +/// endpoint_struct = RemoteShellEndpoint, +/// tui_struct = RemoteShellTui, +/// } +/// ``` +#[proc_macro] +pub fn leaf(input: TokenStream) -> TokenStream { + match leaf_decl::expand_leaf_declaration(parse_macro_input!( + input as leaf_decl::LeafDeclarationInput + )) { + Ok(tokens) => tokens.into(), + Err(error) => error.to_compile_error().into(), + } +} + /// Derives canonical protocol-leaf identity helpers for one host type. /// /// What it is: a derive macro that implements `ProtocolLeaf` and generates the diff --git a/unshell-macros/src/procedure.rs b/unshell-macros/src/procedure.rs index c221a23..4bbe4b5 100644 --- a/unshell-macros/src/procedure.rs +++ b/unshell-macros/src/procedure.rs @@ -88,11 +88,13 @@ pub(crate) fn expand_procedure(input: DeriveInput) -> Result + impl #impl_generics ::unshell::protocol::tree::ProcedureMetadata for #procedure_name #ty_generics #where_clause where #leaf_ty: ::unshell::protocol::tree::ProtocolLeaf, { + type Leaf = #leaf_ty; + fn procedure_suffix() -> &'static str { #suffix } @@ -101,7 +103,7 @@ pub(crate) fn expand_procedure(input: DeriveInput) -> Result ::unshell::alloc::string::String { - >::procedure_id() + ::procedure_id() } } }) diff --git a/unshell-macros/src/procedures.rs b/unshell-macros/src/procedures.rs index ba60b59..9ecac79 100644 --- a/unshell-macros/src/procedures.rs +++ b/unshell-macros/src/procedures.rs @@ -114,19 +114,21 @@ pub(crate) fn expand_procedures( .collect::>(); let procedure_matches = dispatch_arms.iter().map(|arm| { let suffix = &arm.suffix_literal; - quote! { #suffix => ::procedure_id(#suffix), } + quote! { #suffix => ::procedure_id(#suffix), } }); let dispatch_checks = dispatch_arms.iter().map(|arm| arm.dispatch_tokens.clone()); Ok(quote! { #item - impl #impl_generics_tokens ::unshell::protocol::tree::CallProcedures for #self_ty #where_clause { - type Error = #error_ty; - + impl #impl_generics_tokens ::unshell::protocol::tree::LeafDeclaration for #self_ty #where_clause { fn procedure_suffixes() -> &'static [&'static str] { &[#(#suffix_literals),*] } + } + + impl #impl_generics_tokens ::unshell::protocol::tree::CallProcedures for #self_ty #where_clause { + type Error = #error_ty; fn dispatch_call( &mut self, @@ -143,7 +145,7 @@ pub(crate) fn expand_procedures( impl #impl_generics_tokens #self_ty #where_clause { /// Returns the canonical protocol leaf metadata for this type. pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec { - ::leaf_spec() + ::leaf_spec() } /// Resolves one local procedure suffix to its full canonical `procedure_id`. @@ -163,7 +165,7 @@ fn expand_call_arm(method: &ImplItemFn) -> Result { let method_name = &method.sig.ident; let suffix_literal = call_suffix_literal(method)?; let call_id_expr = quote! { - ::procedure_id(#suffix_literal) + ::procedure_id(#suffix_literal) .expect("generated procedure id must exist") }; diff --git a/unshell-protocol/src/protocol/tree/leaf.rs b/unshell-protocol/src/protocol/tree/leaf.rs index e34fc59..e42d36d 100644 --- a/unshell-protocol/src/protocol/tree/leaf.rs +++ b/unshell-protocol/src/protocol/tree/leaf.rs @@ -1,8 +1,9 @@ //! Application-facing leaf metadata helpers. //! //! The protocol runtime itself only knows about `LeafSpec` metadata and validated -//! `LocalEvent` delivery. `ProtocolLeaf` owns the canonical dotted leaf id, while -//! `CallProcedures` owns generated procedure ids and initial call dispatch. +//! `LocalEvent` delivery. `ProtocolLeaf` owns canonical identity, `LeafDeclaration` +//! owns the compile-time procedure inventory for one leaf surface, and +//! `CallProcedures` adds local call dispatch on top of that inventory. use alloc::{string::String, vec::Vec}; @@ -37,6 +38,92 @@ pub trait ProtocolLeaf { fn leaf_name() -> String; } +/// Compile-time declaration metadata for one leaf surface. +/// +/// What it is: a trait for types that can describe the complete protocol-visible +/// surface of one leaf at compile time. +/// +/// Why it exists: endpoint construction should not need handwritten procedure +/// lists. A leaf declaration can generate the canonical suffix inventory once and +/// let both endpoint and TUI host types reuse it. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{LeafDeclaration, ProtocolLeaf}; +/// struct ExampleLeaf; +/// impl ProtocolLeaf for ExampleLeaf { +/// fn leaf_name() -> String { "org.example.v1.echo".into() } +/// } +/// impl LeafDeclaration for ExampleLeaf { +/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] } +/// } +/// assert_eq!(ExampleLeaf::leaf_spec().procedures, vec![String::from("org.example.v1.echo.invoke")]); +/// ``` +pub trait LeafDeclaration: ProtocolLeaf { + /// 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 { + 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 { + 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(), + } + } +} + +/// Declares that one host struct is bound to one compile-time leaf declaration. +/// +/// What it is: a trait that links a concrete host type, such as an endpoint or +/// TUI struct, back to the declaration that owns its shared protocol metadata. +/// +/// Why it exists: endpoint and TUI hosts often need different state and behavior, +/// but they should still share one canonical leaf identity and procedure list. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{LeafBinding, LeafDeclaration, ProtocolLeaf}; +/// struct ExampleDecl; +/// impl ProtocolLeaf for ExampleDecl { +/// fn leaf_name() -> String { "org.example.v1.echo".into() } +/// } +/// impl LeafDeclaration for ExampleDecl { +/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] } +/// } +/// struct ExampleHost; +/// impl ProtocolLeaf for ExampleHost { +/// fn leaf_name() -> String { ExampleDecl::leaf_name() } +/// } +/// impl LeafBinding for ExampleHost { +/// type Declaration = ExampleDecl; +/// } +/// assert_eq!(::Declaration::leaf_name(), "org.example.v1.echo"); +/// ``` +pub trait LeafBinding: ProtocolLeaf { + /// Shared declaration that owns the canonical metadata for this host type. + type Declaration: ProtocolLeaf; +} + /// Generated call metadata and initial `Call` dispatch for one leaf. /// /// This exists so one leaf type can advertise which procedure suffixes it serves and convert an @@ -58,95 +145,10 @@ pub trait ProtocolLeaf { /// } /// assert_eq!(ExampleLeaf::procedure_id("invoke").unwrap(), "org.example.v1.echo.invoke"); /// ``` -pub trait CallProcedures: ProtocolLeaf { +pub trait CallProcedures: LeafDeclaration { /// Leaf-specific error surfaced when generated call dispatch fails. type Error; - /// Returns the local procedure suffixes supported by this leaf. - /// - /// # Example - /// ```rust - /// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf}; - /// struct ExampleLeaf; - /// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } } - /// impl CallProcedures for ExampleLeaf { - /// type Error = core::convert::Infallible; - /// fn procedure_suffixes() -> &'static [&'static str] { &["invoke", "stream"] } - /// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result> { Ok(unshell::protocol::tree::CallReply::NoReply) } - /// } - /// assert_eq!(ExampleLeaf::procedure_suffixes(), &["invoke", "stream"]); - /// ``` - fn procedure_suffixes() -> &'static [&'static str]; - - /// Resolves one local procedure suffix to its full canonical `procedure_id`. - /// - /// # Example - /// ```rust - /// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf}; - /// struct ExampleLeaf; - /// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } } - /// impl CallProcedures for ExampleLeaf { - /// type Error = core::convert::Infallible; - /// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] } - /// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result> { Ok(unshell::protocol::tree::CallReply::NoReply) } - /// } - /// assert!(ExampleLeaf::procedure_id("invoke").is_some()); - /// assert!(ExampleLeaf::procedure_id("missing").is_none()); - /// ``` - fn procedure_id(suffix: &str) -> Option { - 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. - /// - /// # Example - /// ```rust - /// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf}; - /// struct ExampleLeaf; - /// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } } - /// impl CallProcedures for ExampleLeaf { - /// type Error = core::convert::Infallible; - /// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] } - /// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result> { Ok(unshell::protocol::tree::CallReply::NoReply) } - /// } - /// assert_eq!(ExampleLeaf::procedure_ids(), vec![String::from("org.example.v1.echo.invoke")]); - /// ``` - fn procedure_ids() -> Vec { - Self::procedure_suffixes() - .iter() - .filter_map(|suffix| Self::procedure_id(suffix)) - .collect() - } - - /// Materializes the runtime leaf metadata consumed by `ProtocolEndpoint`. - /// - /// # Example - /// ```rust - /// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf}; - /// struct ExampleLeaf; - /// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } } - /// impl CallProcedures for ExampleLeaf { - /// type Error = core::convert::Infallible; - /// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] } - /// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result> { Ok(unshell::protocol::tree::CallReply::NoReply) } - /// } - /// let spec = ExampleLeaf::leaf_spec(); - /// assert_eq!(spec.name, "org.example.v1.echo"); - /// ``` - fn leaf_spec() -> LeafSpec { - LeafSpec { - name: Self::leaf_name(), - procedures: Self::procedure_ids(), - } - } - /// Dispatches one initial `Call` that targeted this leaf. /// /// Implementations may assume the endpoint already proved the call targets this leaf. @@ -313,7 +315,9 @@ fn normalize_leaf_segment(value: &str) -> String { #[cfg(test)] mod tests { - use super::derive_leaf_name; + use alloc::string::String; + + use super::{LeafBinding, LeafDeclaration, ProtocolLeaf, derive_leaf_name}; #[test] fn derive_leaf_name_normalizes_inputs_into_dotted_segments() { @@ -374,4 +378,34 @@ mod tests { "org.example.v1.echo.abc" ); } + + #[test] + fn bound_hosts_can_share_one_declaration() { + struct SharedDecl; + impl ProtocolLeaf for SharedDecl { + fn leaf_name() -> String { + String::from("org.example.v1.echo") + } + } + impl LeafDeclaration for SharedDecl { + fn procedure_suffixes() -> &'static [&'static str] { + &["invoke"] + } + } + + struct Host; + impl ProtocolLeaf for Host { + fn leaf_name() -> String { + SharedDecl::leaf_name() + } + } + impl LeafBinding for Host { + type Declaration = SharedDecl; + } + + assert_eq!( + ::Declaration::leaf_spec().name, + "org.example.v1.echo" + ); + } } diff --git a/unshell-protocol/src/protocol/tree/mod.rs b/unshell-protocol/src/protocol/tree/mod.rs index 9c22f32..71e432a 100644 --- a/unshell-protocol/src/protocol/tree/mod.rs +++ b/unshell-protocol/src/protocol/tree/mod.rs @@ -24,10 +24,10 @@ pub use endpoint::{ ProtocolEndpoint, }; pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook}; -pub use leaf::{CallProcedures, ProtocolLeaf, derive_leaf_name}; +pub use leaf::{CallProcedures, LeafBinding, LeafDeclaration, ProtocolLeaf, derive_leaf_name}; pub use procedure::{ - Procedure, ProcedureEffect, ProcedureRuntime, ProcedureRuntimeError, ProcedureRuntimeOutcome, - ProcedureStore, StatefulProcedureMetadata, + Procedure, ProcedureEffect, ProcedureMetadata, ProcedureRuntime, ProcedureRuntimeError, + ProcedureRuntimeOutcome, ProcedureStore, StatefulProcedureMetadata, }; pub use routing::{ CompiledRoutes, DefaultRouteProvider, LeafNode, RouteDecision, RouteProvider, TreeNode, diff --git a/unshell-protocol/src/protocol/tree/procedure.rs b/unshell-protocol/src/protocol/tree/procedure.rs index 4e7c062..df80a8e 100644 --- a/unshell-protocol/src/protocol/tree/procedure.rs +++ b/unshell-protocol/src/protocol/tree/procedure.rs @@ -27,6 +27,45 @@ use super::{ LocalEvent, OutgoingData, ProtocolEndpoint, ProtocolLeaf, decode_call_input, }; +/// Canonical compile-time metadata for one procedure surface. +/// +/// What it is: a trait that defines the leaf type and local suffix used to derive +/// one stable protocol `procedure_id`. +/// +/// Why it exists: compile-time leaf declarations and future typed remote methods +/// need to talk about procedures without hand-assembling identifiers at each use +/// site. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{ProcedureMetadata, ProtocolLeaf}; +/// struct ExampleLeaf; +/// impl ProtocolLeaf for ExampleLeaf { +/// fn leaf_name() -> String { "org.example.v1.shell".into() } +/// } +/// struct Open; +/// impl ProcedureMetadata for Open { +/// type Leaf = ExampleLeaf; +/// fn procedure_suffix() -> &'static str { "open" } +/// } +/// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open"); +/// ``` +pub trait ProcedureMetadata: Sized { + /// Leaf surface this procedure belongs to. + type Leaf: ProtocolLeaf; + + /// Returns the local suffix used to derive the full canonical `procedure_id`. + fn procedure_suffix() -> &'static str; + + /// Returns the canonical `procedure_id` for this procedure. + fn procedure_id() -> String { + let mut procedure_id = ::leaf_name(); + procedure_id.push('.'); + procedure_id.push_str(Self::procedure_suffix()); + procedure_id + } +} + /// Generated metadata for one stateful procedure bound to one leaf type. /// /// This metadata is intentionally tiny: one procedure suffix plus the derived @@ -34,31 +73,32 @@ use super::{ /// /// # Example /// ```rust -/// use unshell::protocol::tree::{ProtocolLeaf, StatefulProcedureMetadata}; +/// use unshell::protocol::tree::{ProcedureMetadata, ProtocolLeaf, StatefulProcedureMetadata}; /// struct ExampleLeaf; /// impl ProtocolLeaf for ExampleLeaf { /// fn leaf_name() -> String { "org.example.v1.shell".into() } /// } /// struct Open; -/// impl StatefulProcedureMetadata for Open { +/// impl ProcedureMetadata for Open { +/// type Leaf = ExampleLeaf; +/// /// fn procedure_suffix() -> &'static str { "open" } /// } +/// fn _compat>() {} +/// _compat::(); /// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open"); /// ``` -pub trait StatefulProcedureMetadata: Sized +pub trait StatefulProcedureMetadata: ProcedureMetadata + Sized where L: ProtocolLeaf, { - /// Returns the local suffix used to derive the full canonical `procedure_id`. - fn procedure_suffix() -> &'static str; +} - /// Returns the canonical `procedure_id` for this procedure. - fn procedure_id() -> String { - let mut procedure_id = L::leaf_name(); - procedure_id.push('.'); - procedure_id.push_str(Self::procedure_suffix()); - procedure_id - } +impl StatefulProcedureMetadata for T +where + T: ProcedureMetadata, + L: ProtocolLeaf, +{ } /// Explicit storage access for one procedure session map inside the leaf. @@ -133,7 +173,7 @@ pub trait ProcedureStore

{ /// } /// } /// ``` -pub trait Procedure: StatefulProcedureMetadata + Sized +pub trait Procedure: ProcedureMetadata + Sized where L: ProtocolLeaf, {