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, {