From 54c44b407ee5f3c9855b94f91797f9e09614fae7 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:14:49 -0600 Subject: [PATCH] Remove the old leaf declaration path Delete the deprecated Leaf derive path, migrate the remaining tests and example to leaf!, and add direct coverage for endpoint-only, TUI-only, and shared-host leaf declarations. --- examples/protocol/leaf_derive.rs | 90 +++++++--- src/lib.rs | 2 +- unshell-leaves/src/lib.rs | 2 +- unshell-leaves/src/remote_shell/mod.rs | 19 +++ unshell-macros/ABOUT.md | 17 +- unshell-macros/src/leaf.rs | 161 ------------------ unshell-macros/src/leaf_decl.rs | 72 ++++---- unshell-macros/src/lib.rs | 50 ++---- unshell-macros/src/procedure.rs | 4 +- unshell-macros/src/procedures.rs | 38 +---- unshell-macros/src/utils.rs | 45 ----- unshell-protocol/src/lib.rs | 2 +- unshell-protocol/src/protocol/tests/call.rs | 10 +- .../src/protocol/tests/leaf_decl.rs | 98 +++++++++++ unshell-protocol/src/protocol/tests/mod.rs | 1 + .../src/protocol/tests/procedure.rs | 30 ++-- .../src/protocol/tree/procedure.rs | 23 ++- 17 files changed, 284 insertions(+), 380 deletions(-) delete mode 100644 unshell-macros/src/leaf.rs create mode 100644 unshell-protocol/src/protocol/tests/leaf_decl.rs diff --git a/examples/protocol/leaf_derive.rs b/examples/protocol/leaf_derive.rs index 57dbaf1..643fdfe 100644 --- a/examples/protocol/leaf_derive.rs +++ b/examples/protocol/leaf_derive.rs @@ -1,22 +1,28 @@ -//! Small end-to-end example for the `Leaf` and `procedures` derive macros. +//! Small end-to-end example for the `leaf!` and `Procedure` macros. //! -//! This stays entirely local. A controller endpoint opens one call against a single in-process -//! leaf runtime, and the example decodes the returned reply payload. +//! This stays entirely local. A controller endpoint opens one hook-backed procedure against a +//! single in-process leaf runtime, and the example decodes the returned reply payload. use std::error::Error; -use std::{convert::Infallible, string::String}; +use std::{collections::BTreeMap, convert::Infallible, string::String}; use rkyv::{Archive, Deserialize, Serialize}; use unshell::protocol::tree::{ - Call, CallLeaf, ChildRoute, EndpointOutcome, Ingress, LeafRuntime, ProtocolEndpoint, + Call, ChildRoute, EndpointOutcome, HookKey, Ingress, OutgoingData, Procedure, ProcedureEffect, + ProcedureRuntime, ProcedureStore, ProtocolEndpoint, }; use unshell::protocol::{PacketType, decode_frame}; -use unshell::{Leaf, procedures}; +use unshell::{Procedure, leaf}; -#[derive(Leaf)] -#[leaf(org = "org", product = "example", version = "v1", leaf_name = "echo")] +#[derive(Default)] struct EchoLeaf { - prefix: String, + sessions: BTreeMap, +} + +leaf! { + id = "org.example.v1.echo", + procedures = [EchoOpen], + endpoint_struct = EchoLeaf, } #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -29,18 +35,53 @@ struct EchoResponse { text: String, } -#[procedures(error = Infallible)] -impl EchoLeaf { - #[call] - fn echo(&mut self, request: Call) -> EchoResponse { - EchoResponse { - text: format!("{}{}", self.prefix, request.input.text), - } +#[derive(Debug, Clone, PartialEq, Eq, Procedure)] +#[procedure(leaf = EchoLeaf, name = "echo")] +struct EchoOpen { + prefix: String, + return_path: Vec, + hook_id: u64, + sent_reply: bool, +} + +impl ProcedureStore for EchoLeaf { + fn procedure_sessions(&mut self) -> &mut BTreeMap { + &mut self.sessions } } -impl CallLeaf for EchoLeaf { +impl Procedure for EchoOpen { type Error = Infallible; + type Input = EchoRequest; + + fn open(_leaf: &mut EchoLeaf, call: Call) -> Result { + let response_hook = call + .response_hook + .expect("example call declares a response hook"); + Ok(Self { + prefix: call.input.text, + return_path: response_hook.return_path, + hook_id: response_hook.hook_id, + sent_reply: false, + }) + } + + fn poll(_leaf: &mut EchoLeaf, session: &mut Self) -> Result { + if session.sent_reply { + return Ok(ProcedureEffect::default()); + } + session.sent_reply = true; + Ok(ProcedureEffect::close(vec![OutgoingData { + dst_path: session.return_path.clone(), + hook_id: session.hook_id, + procedure_id: EchoOpen::protocol_procedure_id(), + data: unshell::protocol::tree::encode_call_reply(&EchoResponse { + text: format!("echo: {}", session.prefix), + }) + .expect("response should encode"), + end_hook: true, + }])) + } } fn path(parts: &[&str]) -> Vec { @@ -54,12 +95,7 @@ fn main() -> Result<(), Box> { Vec::new(), vec![EchoLeaf::protocol_leaf_spec()], ); - let mut runtime = LeafRuntime::new( - endpoint, - EchoLeaf { - prefix: String::from("echo: "), - }, - ); + let mut runtime = ProcedureRuntime::::new(endpoint, EchoLeaf::default()); let mut controller = ProtocolEndpoint::new( Vec::new(), @@ -74,7 +110,7 @@ fn main() -> Result<(), Box> { let controller_outcome = controller.send_call( path(&["agent"]), Some(EchoLeaf::protocol_leaf_name()), - EchoLeaf::protocol_procedure_id("echo").expect("known procedure suffix"), + EchoOpen::protocol_procedure_id(), Some(hook_id), unshell::protocol::tree::encode_call_reply(&EchoRequest { text: String::from("hello leaf"), @@ -84,7 +120,9 @@ fn main() -> Result<(), Box> { return Err("expected controller to forward call".into()); }; - let outcome = runtime.receive(&Ingress::Parent, frame)?; + let receive_outcome = runtime.receive(&Ingress::Parent, frame)?; + assert!(receive_outcome.frames.is_empty()); + let outcome = runtime.poll()?; let [response_frame] = outcome.frames.as_slice() else { return Err("expected one response frame".into()); }; @@ -100,7 +138,7 @@ fn main() -> Result<(), Box> { println!( "leaf={} procedure={} response={}", EchoLeaf::protocol_leaf_name(), - EchoLeaf::protocol_procedure_id("echo").expect("known procedure suffix"), + EchoOpen::protocol_procedure_id(), response.text, ); Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 7b05349..4e6260d 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, leaf, procedures}; +pub use unshell_macros::{Procedure, leaf, procedures}; // pub use ush_obfuscate as obfuscate; diff --git a/unshell-leaves/src/lib.rs b/unshell-leaves/src/lib.rs index ed07f1e..6a390d7 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, leaf, procedures}; +pub use unshell_macros::{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/mod.rs b/unshell-leaves/src/remote_shell/mod.rs index 1936d7e..b908b0a 100644 --- a/unshell-leaves/src/remote_shell/mod.rs +++ b/unshell-leaves/src/remote_shell/mod.rs @@ -6,6 +6,8 @@ //! - `leaf_tui` builds a placeholder client-side TUI surface use rkyv::{Archive, Deserialize, Serialize}; +#[cfg(not(feature = "leaf_endpoint"))] +use std::string::String; #[cfg(feature = "leaf_endpoint")] pub mod endpoint; @@ -24,6 +26,23 @@ pub use tui::RemoteShellTui; /// not built, so the leaf declaration still validates its declared inventory. pub struct Open; +#[cfg(not(feature = "leaf_endpoint"))] +#[doc(hidden)] +pub struct RemoteShellDeclarationPlaceholder; + +#[cfg(not(feature = "leaf_endpoint"))] +impl crate::protocol::tree::ProtocolLeaf for RemoteShellDeclarationPlaceholder { + fn leaf_name() -> String { + String::from("remote_shell") + } +} + +#[cfg(not(feature = "leaf_endpoint"))] +impl crate::protocol::tree::ProcedureMetadata for Open { + type Leaf = RemoteShellDeclarationPlaceholder; + const PROCEDURE_SUFFIX: &'static str = "open"; +} + /// Open-request payload for the remote shell leaf. /// /// The shell currently needs no structured arguments, but a named payload type is diff --git a/unshell-macros/ABOUT.md b/unshell-macros/ABOUT.md index 6f0a690..fc55f01 100644 --- a/unshell-macros/ABOUT.md +++ b/unshell-macros/ABOUT.md @@ -14,7 +14,6 @@ compile time. In practical terms, the macro system is responsible for: -- deriving canonical leaf identities - deriving canonical procedure identifiers - generating compile-time procedure inventories for leaves - binding one leaf declaration to separate endpoint and TUI host structs without @@ -57,18 +56,14 @@ compile-time inventory instead of handwritten lists. ## Current direction -The current migration keeps the older derive-based APIs working while adding a -new declaration-first API. +The public declaration model is now centered on `leaf!`. -That migration is intentionally incremental: +- `leaf!` declares the canonical protocol surface once +- `#[derive(Procedure)]` derives stateful procedure metadata +- `#[procedures]` derives one-shot call dispatch for simple leaves -1. keep `#[derive(Leaf)]`, `#[derive(Procedure)]`, and `#[procedures]` working -2. introduce one declaration macro for compile-time leaf metadata -3. let endpoint and TUI structs bind to the declaration instead of duplicating - metadata -4. remove leaf-owned endpoint-construction boilerplate by generating leaf specs - from the declaration -5. add typed remote-method metadata on top of the same declaration model +The next evolution from here is typed remote-method metadata on top of the same +declaration model. ## Design constraints diff --git a/unshell-macros/src/leaf.rs b/unshell-macros/src/leaf.rs deleted file mode 100644 index 2e9d964..0000000 --- a/unshell-macros/src/leaf.rs +++ /dev/null @@ -1,161 +0,0 @@ -use quote::quote; -use syn::{Attribute, Data, DeriveInput, Error, Ident, LitStr, Result}; - -use crate::utils::{looks_like_canonical_leaf_name, option_litstr_tokens}; - -#[derive(Default)] -struct LeafAttributes { - name: Option, - id: Option, - org: Option, - product: Option, - version: Option, - leaf_name: Option, -} - -impl LeafAttributes { - fn parse_from(attrs: &[Attribute]) -> Result { - let mut parsed = Self::default(); - - for attr in attrs { - if !attr.path().is_ident("leaf") { - continue; - } - - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("name") { - if parsed.name.is_some() { - return Err(meta.error("duplicate leaf name attribute")); - } - parsed.name = Some(meta.value()?.parse()?); - return Ok(()); - } - - if meta.path.is_ident("id") { - if parsed.id.is_some() { - return Err(meta.error("duplicate leaf id attribute")); - } - parsed.id = Some(meta.value()?.parse()?); - return Ok(()); - } - - if meta.path.is_ident("org") { - if parsed.org.is_some() { - return Err(meta.error("duplicate leaf org attribute")); - } - parsed.org = Some(meta.value()?.parse()?); - return Ok(()); - } - - if meta.path.is_ident("product") { - if parsed.product.is_some() { - return Err(meta.error("duplicate leaf product attribute")); - } - parsed.product = Some(meta.value()?.parse()?); - return Ok(()); - } - - if meta.path.is_ident("version") { - if parsed.version.is_some() { - return Err(meta.error("duplicate leaf version attribute")); - } - parsed.version = Some(meta.value()?.parse()?); - return Ok(()); - } - - if meta.path.is_ident("leaf_name") { - if parsed.leaf_name.is_some() { - return Err(meta.error("duplicate leaf_name attribute")); - } - parsed.leaf_name = Some(meta.value()?.parse()?); - return Ok(()); - } - - Err(meta.error("unsupported #[leaf(...)] attribute")) - })?; - } - - Ok(parsed) - } - - fn explicit_id_value(&self) -> Option<&LitStr> { - self.id.as_ref().or(self.name.as_ref()) - } - - fn leaf_name_expression(&self, struct_name: &Ident) -> proc_macro2::TokenStream { - let id = option_litstr_tokens(self.id.as_ref().or(self.name.as_ref())); - let org = option_litstr_tokens(self.org.as_ref()); - let product = option_litstr_tokens(self.product.as_ref()); - let version = option_litstr_tokens(self.version.as_ref()); - let leaf_name = option_litstr_tokens(self.leaf_name.as_ref()); - - quote! { - ::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!(#struct_name), - #org, - #product, - #version, - #leaf_name, - #id, - ) - } - } -} - -pub(crate) fn expand_leaf(input: DeriveInput) -> Result { - let struct_name = input.ident; - match input.data { - Data::Struct(_) => {} - _ => { - return Err(Error::new_spanned( - struct_name, - "Leaf can only be derived for structs", - )); - } - }; - - let parsed = LeafAttributes::parse_from(&input.attrs)?; - let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); - let leaf_name_expr = parsed.leaf_name_expression(&struct_name); - let warning_note = parsed - .explicit_id_value() - .as_ref() - .filter(|name| !name.value().is_empty()) - .filter(|name| !looks_like_canonical_leaf_name(&name.value())) - .map(|name| { - LitStr::new( - &format!( - "leaf id `{}` does not follow the recommended dotted format `org.product.vN.leaf_name[.part]`", - name.value() - ), - proc_macro2::Span::call_site(), - ) - }) - .map(|note| quote! { #[deprecated(note = #note)] }); - let leaf_name_warning_attr = warning_note.unwrap_or_else(|| quote! {}); - - Ok(quote! { - impl #impl_generics ::unshell::protocol::tree::ProtocolLeaf for #struct_name #ty_generics #where_clause { - fn leaf_name() -> ::unshell::alloc::string::String { - #leaf_name_expr - } - } - - impl #impl_generics ::unshell::protocol::tree::LeafBinding for #struct_name #ty_generics #where_clause { - type Declaration = Self; - } - - impl #impl_generics #struct_name #ty_generics #where_clause { - /// Returns the canonical dotted leaf name declared for this type. - #leaf_name_warning_attr - pub fn protocol_leaf_name() -> ::unshell::alloc::string::String { - ::leaf_name() - } - } - }) -} diff --git a/unshell-macros/src/leaf_decl.rs b/unshell-macros/src/leaf_decl.rs index 818c7c4..6fe51fe 100644 --- a/unshell-macros/src/leaf_decl.rs +++ b/unshell-macros/src/leaf_decl.rs @@ -17,7 +17,7 @@ pub(crate) struct LeafDeclarationInput { version: Option, endpoint_struct: Option, tui_struct: Option, - procedures: Vec, + procedures: Vec, } impl Parse for LeafDeclarationInput { @@ -119,7 +119,21 @@ enum LeafAssignment { Version(LitStr), EndpointStruct(Ident), TuiStruct(Ident), - Procedures(Vec), + Procedures(Vec), +} + +enum ProcedureRef { + Symbol(Ident), + Suffix(LitStr), +} + +impl Parse for ProcedureRef { + fn parse(input: ParseStream<'_>) -> Result { + if input.peek(LitStr) { + return Ok(Self::Suffix(input.parse()?)); + } + Ok(Self::Symbol(input.parse()?)) + } } impl Parse for LeafAssignment { @@ -137,7 +151,7 @@ impl Parse for LeafAssignment { "procedures" => { let content; syn::bracketed!(content in input); - let values = Punctuated::::parse_terminated(&content)? + let values = Punctuated::::parse_terminated(&content)? .into_iter() .collect::>(); Ok(Self::Procedures(values)) @@ -165,9 +179,20 @@ pub(crate) fn expand_leaf_declaration(input: LeafDeclarationInput) -> Result { + quote! { <#procedure as ::unshell::protocol::tree::ProcedureMetadata>::PROCEDURE_SUFFIX } + } + ProcedureRef::Suffix(suffix) => quote! { #suffix }, + }) .collect::>(); - let procedure_type_checks = input.procedures.iter(); + let procedure_type_checks = input + .procedures + .iter() + .filter_map(|procedure| match procedure { + ProcedureRef::Symbol(procedure) => Some(procedure), + ProcedureRef::Suffix(_) => None, + }); let endpoint_impl = input .endpoint_struct @@ -273,40 +298,3 @@ fn expand_binding_impl(host: &Ident, declaration: &Ident) -> TokenStream { } } } - -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 a469d55..46c79f6 100644 --- a/unshell-macros/src/lib.rs +++ b/unshell-macros/src/lib.rs @@ -1,6 +1,5 @@ //! Proc macros for `unshell` application-layer leaf declarations. -mod leaf; mod leaf_decl; mod procedure; mod procedures; @@ -38,33 +37,6 @@ pub fn leaf(input: TokenStream) -> TokenStream { } } -/// Derives canonical protocol-leaf identity helpers for one host type. -/// -/// What it is: a derive macro that implements `ProtocolLeaf` and generates the -/// `protocol_leaf_name()` convenience method. -/// -/// Why it exists: simple leaves and compatibility paths still need a lightweight -/// way to say "this host type exposes this canonical wire name" without writing -/// the trait implementation by hand. -/// -/// # Example -/// ```ignore -/// use unshell::Leaf; -/// -/// #[derive(Leaf)] -/// #[leaf(leaf_name = "echo")] -/// struct EchoLeaf; -/// -/// assert!(EchoLeaf::protocol_leaf_name().contains("echo")); -/// ``` -#[proc_macro_derive(Leaf, attributes(leaf))] -pub fn derive_leaf(input: TokenStream) -> TokenStream { - match leaf::expand_leaf(parse_macro_input!(input as DeriveInput)) { - Ok(tokens) => tokens.into(), - Err(error) => error.to_compile_error().into(), - } -} - /// Derives canonical stateful-procedure metadata for one procedure type. /// /// What it is: a derive macro that records one procedure suffix and generates @@ -75,10 +47,16 @@ pub fn derive_leaf(input: TokenStream) -> TokenStream { /// /// # Example /// ```ignore -/// use unshell::{Leaf, Procedure}; +/// use unshell::{Procedure, leaf}; +/// +/// struct ShellLeaf; +/// +/// leaf! { +/// name = "shell", +/// procedures = [OpenSession], +/// endpoint_struct = ShellLeaf, +/// } /// -/// #[derive(Leaf)] -/// #[leaf(leaf_name = "shell")] /// struct ShellLeaf; /// /// #[derive(Procedure)] @@ -106,12 +84,16 @@ pub fn derive_procedure(input: TokenStream) -> TokenStream { /// /// # Example /// ```ignore -/// use unshell::{Leaf, procedures}; +/// use unshell::{leaf, procedures}; /// -/// #[derive(Leaf)] -/// #[leaf(id = "org.example.v1.echo")] /// struct EchoLeaf; /// +/// leaf! { +/// id = "org.example.v1.echo", +/// procedures = [echo], +/// endpoint_struct = EchoLeaf, +/// } +/// /// #[procedures(error = core::convert::Infallible)] /// impl EchoLeaf { /// #[call] diff --git a/unshell-macros/src/procedure.rs b/unshell-macros/src/procedure.rs index 4bbe4b5..99d01d7 100644 --- a/unshell-macros/src/procedure.rs +++ b/unshell-macros/src/procedure.rs @@ -95,9 +95,7 @@ pub(crate) fn expand_procedure(input: DeriveInput) -> Result &'static str { - #suffix - } + const PROCEDURE_SUFFIX: &'static str = #suffix; } impl #impl_generics #procedure_name #ty_generics #where_clause { diff --git a/unshell-macros/src/procedures.rs b/unshell-macros/src/procedures.rs index 9ecac79..aa05e42 100644 --- a/unshell-macros/src/procedures.rs +++ b/unshell-macros/src/procedures.rs @@ -108,25 +108,11 @@ pub(crate) fn expand_procedures( )); } - let suffix_literals = dispatch_arms - .iter() - .map(|arm| arm.suffix_literal.clone()) - .collect::>(); - let procedure_matches = dispatch_arms.iter().map(|arm| { - let suffix = &arm.suffix_literal; - 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::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; @@ -142,22 +128,6 @@ 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() - } - - /// 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> { - match suffix { - #(#procedure_matches)* - _ => ::core::option::Option::None, - } - } - } }) } @@ -165,8 +135,12 @@ 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) - .expect("generated procedure id must exist") + { + let mut __unshell_id = ::leaf_name(); + __unshell_id.push('.'); + __unshell_id.push_str(#suffix_literal); + __unshell_id + } }; let inputs = method diff --git a/unshell-macros/src/utils.rs b/unshell-macros/src/utils.rs index 587858a..53c47dd 100644 --- a/unshell-macros/src/utils.rs +++ b/unshell-macros/src/utils.rs @@ -9,33 +9,6 @@ pub(crate) fn option_litstr_tokens(value: Option<&LitStr>) -> TokenStream { } } -pub(crate) fn looks_like_canonical_leaf_name(name: &str) -> bool { - let segments = name.split('.').collect::>(); - if segments.len() < 4 { - return false; - } - - for segment in &segments { - if segment.is_empty() { - return false; - } - - if !segment.chars().all(|character| { - character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' - }) { - return false; - } - } - - if !segments[2].starts_with('v') || segments[2].len() <= 1 { - return false; - } - - segments[2][1..] - .chars() - .all(|character| character.is_ascii_digit() || character == '_') -} - pub(crate) fn extract_outer_type_argument<'a>(ty: &'a Type, expected: &str) -> Option<&'a Type> { let Type::Path(TypePath { path, .. }) = ty else { return None; @@ -85,21 +58,3 @@ pub(crate) fn take_call_attr(attrs: &mut Vec) -> bool { attrs.retain(|attr| !attr.path().is_ident("call")); original_len != attrs.len() } - -#[cfg(test)] -mod tests { - use super::looks_like_canonical_leaf_name; - - #[test] - fn canonical_leaf_name_accepts_minimal_valid_shape() { - assert!(looks_like_canonical_leaf_name("org.example.v1.echo")); - assert!(looks_like_canonical_leaf_name("org.example.v1.echo.abc123")); - } - - #[test] - fn canonical_leaf_name_rejects_wrong_shapes() { - assert!(!looks_like_canonical_leaf_name("org.example.echo")); - assert!(!looks_like_canonical_leaf_name("org.example.1.echo")); - assert!(!looks_like_canonical_leaf_name("Org.example.v1.echo")); - } -} diff --git a/unshell-protocol/src/lib.rs b/unshell-protocol/src/lib.rs index 456eafa..ebe2a98 100644 --- a/unshell-protocol/src/lib.rs +++ b/unshell-protocol/src/lib.rs @@ -17,4 +17,4 @@ pub mod protocol; pub use protocol::*; #[cfg(test)] -pub use unshell_macros::{Leaf, Procedure, procedures}; +pub use unshell_macros::{Procedure, leaf, procedures}; diff --git a/unshell-protocol/src/protocol/tests/call.rs b/unshell-protocol/src/protocol/tests/call.rs index 7cb629c..93f9ae8 100644 --- a/unshell-protocol/src/protocol/tests/call.rs +++ b/unshell-protocol/src/protocol/tests/call.rs @@ -8,18 +8,22 @@ use crate::protocol::tree::{ decode_call_input, encode_call_reply, }; use crate::protocol::{PacketType, decode_frame}; -use crate::{Leaf, procedures}; +use crate::{leaf, procedures}; fn path(parts: &[&str]) -> Vec { parts.iter().map(|part| (*part).to_owned()).collect() } -#[derive(Leaf)] -#[leaf(id = "org.example.v1.echo")] struct EchoLeaf { prefix: String, } +leaf! { + id = "org.example.v1.echo", + endpoint_struct = EchoLeaf, + procedures = ["echo"], +} + #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] struct EchoRequest { text: String, diff --git a/unshell-protocol/src/protocol/tests/leaf_decl.rs b/unshell-protocol/src/protocol/tests/leaf_decl.rs new file mode 100644 index 0000000..649e9b5 --- /dev/null +++ b/unshell-protocol/src/protocol/tests/leaf_decl.rs @@ -0,0 +1,98 @@ +use alloc::{string::String, vec}; + +use crate::leaf; +use crate::protocol::tree::{LeafBinding, LeafDeclaration, ProcedureMetadata, ProtocolLeaf}; + +struct EndpointHost; +struct Open; +struct Reset; + +impl ProcedureMetadata for Open { + type Leaf = EndpointHost; + const PROCEDURE_SUFFIX: &'static str = "open"; +} + +impl ProcedureMetadata for Reset { + type Leaf = EndpointHost; + const PROCEDURE_SUFFIX: &'static str = "reset"; +} + +leaf! { + id = "org.example.v1.demo", + procedures = [Open, Reset], + endpoint_struct = EndpointHost, +} + +struct EndpointHalf; +struct TuiHalf; +struct Connect; + +impl ProcedureMetadata for Connect { + type Leaf = EndpointHalf; + const PROCEDURE_SUFFIX: &'static str = "connect"; +} + +leaf! { + name = "chat", + org = "org", + product = "example", + version = "v2", + procedures = [Connect], + endpoint_struct = EndpointHalf, + tui_struct = TuiHalf, +} + +struct TuiOnly; +struct Tail; + +impl ProcedureMetadata for Tail { + type Leaf = TuiOnly; + const PROCEDURE_SUFFIX: &'static str = "tail"; +} + +leaf! { + id = "org.example.v1.transcript", + procedures = [Tail], + tui_struct = TuiOnly, +} + +#[test] +fn leaf_declaration_generates_endpoint_host_metadata() { + assert_eq!(EndpointHost::protocol_leaf_name(), "org.example.v1.demo"); + assert_eq!( + EndpointHost::protocol_leaf_spec().procedures, + vec![ + String::from("org.example.v1.demo.open"), + String::from("org.example.v1.demo.reset"), + ] + ); + assert_eq!( + ::Declaration::leaf_name(), + "org.example.v1.demo" + ); +} + +#[test] +fn leaf_declaration_shares_metadata_between_endpoint_and_tui_hosts() { + assert_eq!( + EndpointHalf::protocol_leaf_name(), + TuiHalf::protocol_leaf_name() + ); + assert_eq!( + EndpointHalf::protocol_leaf_spec().procedures, + TuiHalf::protocol_leaf_spec().procedures + ); + assert_eq!( + ::Declaration::procedure_id("connect"), + Some(String::from("org.example.v2.chat.connect")) + ); +} + +#[test] +fn leaf_declaration_supports_tui_only_hosts() { + assert_eq!(TuiOnly::protocol_leaf_name(), "org.example.v1.transcript"); + assert_eq!( + ::procedure_id("tail"), + Some(String::from("org.example.v1.transcript.tail")) + ); +} diff --git a/unshell-protocol/src/protocol/tests/mod.rs b/unshell-protocol/src/protocol/tests/mod.rs index bb6bf46..46d6021 100644 --- a/unshell-protocol/src/protocol/tests/mod.rs +++ b/unshell-protocol/src/protocol/tests/mod.rs @@ -1,4 +1,5 @@ mod call; +mod leaf_decl; mod procedure; mod protocol; mod tree; diff --git a/unshell-protocol/src/protocol/tests/procedure.rs b/unshell-protocol/src/protocol/tests/procedure.rs index 2ce3372..e3ef10e 100644 --- a/unshell-protocol/src/protocol/tests/procedure.rs +++ b/unshell-protocol/src/protocol/tests/procedure.rs @@ -6,18 +6,23 @@ use crate::protocol::tree::{ ProcedureEffect, ProcedureRuntime, ProcedureStore, ProtocolEndpoint, encode_call_reply, }; use crate::protocol::{PacketType, decode_frame}; -use crate::{Leaf, Procedure}; +use crate::{Procedure, leaf}; fn path(parts: &[&str]) -> Vec { parts.iter().map(|part| (*part).to_owned()).collect() } -#[derive(Default, Leaf)] -#[leaf(id = "org.example.v1.stream")] +#[derive(Default)] struct StreamLeaf { sessions: BTreeMap, } +leaf! { + id = "org.example.v1.stream", + procedures = [ProcedureOpen], + endpoint_struct = StreamLeaf, +} + impl ProcedureStore for StreamLeaf { fn procedure_sessions(&mut self) -> &mut BTreeMap { &mut self.sessions @@ -67,10 +72,7 @@ fn procedure_runtime_routes_data_to_stored_session() { path(&["agent"]), Some(Vec::new()), Vec::new(), - vec![crate::protocol::tree::LeafSpec { - name: StreamLeaf::protocol_leaf_name(), - procedures: vec![ProcedureOpen::protocol_procedure_id()], - }], + vec![StreamLeaf::protocol_leaf_spec()], ); let mut runtime = ProcedureRuntime::::new(endpoint, StreamLeaf::default()); @@ -139,12 +141,17 @@ fn procedure_runtime_routes_data_to_stored_session() { assert!(runtime.leaf_mut().procedure_sessions().is_empty()); } -#[derive(Default, Leaf)] -#[leaf(id = "org.example.v1.duplex")] +#[derive(Default)] struct DuplexLeaf { sessions: BTreeMap, } +leaf! { + id = "org.example.v1.duplex", + procedures = [DuplexProcedure], + endpoint_struct = DuplexLeaf, +} + impl ProcedureStore for DuplexLeaf { fn procedure_sessions(&mut self) -> &mut BTreeMap { &mut self.sessions @@ -197,10 +204,7 @@ fn procedure_runtime_keeps_session_after_local_end_until_explicit_close() { path(&["agent"]), Some(Vec::new()), Vec::new(), - vec![crate::protocol::tree::LeafSpec { - name: DuplexLeaf::protocol_leaf_name(), - procedures: vec![DuplexProcedure::protocol_procedure_id()], - }], + vec![DuplexLeaf::protocol_leaf_spec()], ); let mut runtime = ProcedureRuntime::::new(endpoint, DuplexLeaf::default()); diff --git a/unshell-protocol/src/protocol/tree/procedure.rs b/unshell-protocol/src/protocol/tree/procedure.rs index df80a8e..47f9385 100644 --- a/unshell-protocol/src/protocol/tree/procedure.rs +++ b/unshell-protocol/src/protocol/tree/procedure.rs @@ -46,7 +46,7 @@ use super::{ /// struct Open; /// impl ProcedureMetadata for Open { /// type Leaf = ExampleLeaf; -/// fn procedure_suffix() -> &'static str { "open" } +/// const PROCEDURE_SUFFIX: &'static str = "open"; /// } /// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open"); /// ``` @@ -55,7 +55,12 @@ pub trait ProcedureMetadata: Sized { type Leaf: ProtocolLeaf; /// Returns the local suffix used to derive the full canonical `procedure_id`. - fn procedure_suffix() -> &'static str; + const PROCEDURE_SUFFIX: &'static str; + + /// Returns the local suffix used to derive the full canonical `procedure_id`. + fn procedure_suffix() -> &'static str { + Self::PROCEDURE_SUFFIX + } /// Returns the canonical `procedure_id` for this procedure. fn procedure_id() -> String { @@ -81,8 +86,7 @@ pub trait ProcedureMetadata: Sized { /// struct Open; /// impl ProcedureMetadata for Open { /// type Leaf = ExampleLeaf; -/// -/// fn procedure_suffix() -> &'static str { "open" } +/// const PROCEDURE_SUFFIX: &'static str = "open"; /// } /// fn _compat>() {} /// _compat::(); @@ -133,15 +137,20 @@ pub trait ProcedureStore

{ /// ```rust /// use std::collections::BTreeMap; /// use std::string::String; -/// use unshell::{Leaf, Procedure}; +/// use unshell::{Procedure, leaf}; /// use unshell::protocol::tree::{Call, HookKey, Procedure, ProcedureEffect, ProcedureStore}; /// -/// #[derive(Default, Leaf)] -/// #[leaf(id = "org.example.v1.stream")] +/// #[derive(Default)] /// struct StreamLeaf { /// sessions: BTreeMap, /// } /// +/// leaf! { +/// id = "org.example.v1.stream", +/// procedures = [OpenProcedure], +/// endpoint_struct = StreamLeaf, +/// } +/// /// impl ProcedureStore for StreamLeaf { /// fn procedure_sessions(&mut self) -> &mut BTreeMap { /// &mut self.sessions