diff --git a/README.md b/README.md index 49d1785..85fb848 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,9 @@ monitor tests construct handler-bearing contexts. `tests/context_bridge.rs` checks the args/cwd projection and dispatch; the existing registry bridge tests continue to check mutable registration and `Context::new(args)` unchanged. -Rust source compatibility caveat: adding the public `env` field requires old -`Context { args }` struct literals to supply `env` or use `Context::new(args)`. -The constructor, args field, parser, registry, and dispatch signatures are -unchanged; this field addition cannot preserve args-only struct literals. +Rust source compatibility caveat: context struct literals must migrate to +`Context::new(args)` (see consumer extensions below). The constructor, public +args/env fields, parser, registry, and dispatch signatures are unchanged. ## Build and test @@ -129,3 +128,21 @@ This repository's history was extracted, full ancestry intact, from an earlier internal monorepo location and then renamed to its current crate name (`deka-cli-core`). Nothing about that predecessor affects the public API described above. + +### Consumer extensions + +Dispatchers can populate `context.extensions_mut().insert(state)` before handing +`&Context` to command or subcommand handlers. Handlers retrieve concrete state via +`context.extensions().get::() -> Option<&T>`; the borrow lasts as long as the +extension map borrow. Missing types return `None`; inserting the same type replaces +its previous value. Values require `Any + Send + Sync`, but not `Clone` or `Debug`. + +`Context` remains `Clone`, `Debug`, `Send`, and `Sync`. Extensions store +`TypeId -> Arc`: cloning a context copies its map and shares +its values with cheap Arc clones. Replacement in one clone leaves the other map +unchanged. Interior mutations of shared values are visible through both contexts. +The registry-only implementation uses std and remains wasm32 compatible. + +The private extension map means consumers using `Context { args, env }` literals +must migrate to `Context::new(args)` and then assign `context.env = env` when +preserving an existing working directory. diff --git a/src/lib.rs b/src/lib.rs index 7cb5e99..f94d82c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,8 +19,9 @@ pub mod whoami; #[cfg(feature = "registry")] pub use registry::{ - Args, BuildError, CommandSpec, Context, ContextError, DispatchError, EnvContext, FlagSpec, - ParamSpec, ParseError, ParseErrorKind, ParseOutcome, Registry, RegistryBuilder, SubcommandSpec, + Args, BuildError, CommandSpec, Context, ContextError, DispatchError, EnvContext, Extensions, + FlagSpec, ParamSpec, ParseError, ParseErrorKind, ParseOutcome, Registry, RegistryBuilder, + SubcommandSpec, }; #[cfg(feature = "native")] diff --git a/src/registry.rs b/src/registry.rs index bbba752..9c3d9fc 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -1,9 +1,11 @@ //! Portable command registration, parsing, and dispatch for Tana CLIs. +use std::any::{Any, TypeId}; use std::collections::{HashMap, HashSet}; use std::error::Error; use std::fmt; use std::path::PathBuf; +use std::sync::Arc; #[derive(Debug, Clone)] pub struct CommandSpec { @@ -37,11 +39,37 @@ pub struct ParamSpec { pub description: &'static str, } -/// Parsed input and working directory passed to command and subcommand handlers. +/// Consumer-owned state, keyed by its concrete type. +/// +/// Populate once in the dispatcher before handing the context to handlers. +/// Cloning copies the map and shares its values through cheap `Arc` clones; +/// inserting a replacement affects only the map being modified. +#[derive(Debug, Clone, Default)] +pub struct Extensions { + values: HashMap>, +} + +impl Extensions { + /// Store a value, replacing any previous value of the same concrete type. + pub fn insert(&mut self, value: T) { + self.values.insert(TypeId::of::(), Arc::new(value)); + } + + /// Borrow a value for the lifetime of this extension map, without cloning it. + pub fn get(&self) -> Option<&T> { + self.values.get(&TypeId::of::())?.downcast_ref() + } +} + +/// Parsed input, working directory, and consumer state passed to handlers. +/// +/// Cloning shares extension values without requiring them to implement `Clone`. +/// Construct with `Context::new`, then populate extensions before dispatch. #[derive(Debug, Clone)] pub struct Context { pub args: Args, pub env: EnvContext, + extensions: Extensions, } /// Working directory only; loading this context never reads environment variables. @@ -72,9 +100,20 @@ impl Context { Self { args, env: EnvContext::load(), + extensions: Extensions::default(), } } + /// Read consumer-owned state populated before handler dispatch. + pub fn extensions(&self) -> &Extensions { + &self.extensions + } + + /// Populate consumer-owned state before handler dispatch. + pub fn extensions_mut(&mut self) -> &mut Extensions { + &mut self.extensions + } + /// Parse process arguments and capture the working directory. #[cfg(not(target_arch = "wasm32"))] pub fn from_env(registry: &Registry) -> Result { diff --git a/tests/context_bridge.rs b/tests/context_bridge.rs index 3b8ce60..c4e40e9 100644 --- a/tests/context_bridge.rs +++ b/tests/context_bridge.rs @@ -1,6 +1,6 @@ #![cfg(feature = "registry")] -use deka_cli_core::{Args, CommandSpec, Context, EnvContext, Registry}; +use deka_cli_core::{Args, CommandSpec, Context, Registry}; use std::path::PathBuf; #[test] @@ -32,12 +32,8 @@ fn consumer_owned_state_maps_to_shared_dispatch_without_recapturing_cwd() { cwd: PathBuf::from("consumer/workspace"), handler: "consumer-owned handler resolution".into(), }; - let shared = Context { - args: product.args.clone(), - env: EnvContext { - cwd: product.cwd.clone(), - }, - }; + let mut shared = Context::new(product.args.clone()); + shared.env.cwd = product.cwd.clone(); registry.dispatch(&shared).unwrap(); assert_eq!(product.handler, "consumer-owned handler resolution"); } diff --git a/tests/context_extensions.rs b/tests/context_extensions.rs new file mode 100644 index 0000000..83aaa54 --- /dev/null +++ b/tests/context_extensions.rs @@ -0,0 +1,117 @@ +#![cfg(feature = "registry")] + +use deka_cli_core::{Args, CommandSpec, Context, Registry, SubcommandSpec}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +fn context() -> Context { + Context::new(Args::collect(Vec::new(), &Registry::new()).args) +} + +#[test] +fn insert_get_roundtrip_and_replacement() { + // Consumer state need not implement Clone or Debug. + struct State(String); + let mut ctx = context(); + assert!(ctx.extensions().get::().is_none()); + ctx.extensions_mut().insert(State("resolved".into())); + assert_eq!(ctx.extensions().get::().unwrap().0, "resolved"); + ctx.extensions_mut().insert(State("replacement".into())); + assert_eq!(ctx.extensions().get::().unwrap().0, "replacement"); +} + +#[test] +fn types_and_contexts_are_isolated() { + struct First(u32); + struct Second(u32); + let mut ctx = context(); + ctx.extensions_mut().insert(First(1)); + ctx.extensions_mut().insert(Second(2)); + assert_eq!(ctx.extensions().get::().unwrap().0, 1); + assert_eq!(ctx.extensions().get::().unwrap().0, 2); + assert!(ctx.extensions().get::().is_none()); + assert!(context().extensions().get::().is_none()); +} + +#[test] +fn dispatcher_passes_resolved_state_to_command_and_subcommand_handlers() { + struct HandlerSnapshot { + entrypoint: String, + calls: Arc, + } + fn handler(ctx: &Context) { + let snapshot = ctx.extensions().get::().unwrap(); + assert_eq!(snapshot.entrypoint, "consumer/main.ds"); + assert_eq!(ctx.args.positionals, ["input.ds"]); + snapshot.calls.fetch_add(1, Ordering::SeqCst); + } + let mut registry = Registry::new(); + registry.add_command(CommandSpec { + name: "run", + owner: "consumer", + category: "test", + summary: "resolved consumer handler", + aliases: &[], + subcommands: &[SubcommandSpec { + name: "check", + summary: "check resolved consumer handler", + aliases: &[], + handler, + }], + handler, + }); + let calls = Arc::new(AtomicUsize::new(0)); + for tokens in [vec!["run", "input.ds"], vec!["run", "check", "input.ds"]] { + let parsed = Args::collect(tokens.into_iter().map(str::to_owned).collect(), ®istry); + assert!(parsed.errors.is_empty()); + let mut ctx = Context::new(parsed.args); + // The consumer dispatcher resolves state before handing off fn(&Context). + ctx.extensions_mut().insert(HandlerSnapshot { + entrypoint: "consumer/main.ds".into(), + calls: Arc::clone(&calls), + }); + registry.dispatch(&ctx).unwrap(); + } + assert_eq!(calls.load(Ordering::SeqCst), 2); +} + +#[test] +fn context_remains_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} + +#[test] +fn clone_shares_extensions_but_replacement_is_local() { + // Neither Clone nor Debug is required on stored values. + struct State(AtomicUsize); + let mut original = context(); + original.extensions_mut().insert(State(AtomicUsize::new(1))); + let mut cloned = original.clone(); + let first = original.extensions().get::().unwrap(); + let second = cloned.extensions().get::().unwrap(); + assert!(std::ptr::eq(first, second)); + second.0.store(2, Ordering::SeqCst); + assert_eq!(first.0.load(Ordering::SeqCst), 2); + + cloned.extensions_mut().insert(State(AtomicUsize::new(3))); + assert_eq!( + original + .extensions() + .get::() + .unwrap() + .0 + .load(Ordering::SeqCst), + 2 + ); + drop(original); + assert_eq!( + cloned + .extensions() + .get::() + .unwrap() + .0 + .load(Ordering::SeqCst), + 3 + ); +}