Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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::<T>() -> 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<dyn Any + Send + Sync>`: 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.
5 changes: 3 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
41 changes: 40 additions & 1 deletion src/registry.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<TypeId, Arc<dyn Any + Send + Sync>>,
}

impl Extensions {
/// Store a value, replacing any previous value of the same concrete type.
pub fn insert<T: Any + Send + Sync>(&mut self, value: T) {
self.values.insert(TypeId::of::<T>(), Arc::new(value));
}

/// Borrow a value for the lifetime of this extension map, without cloning it.
pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
self.values.get(&TypeId::of::<T>())?.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.
Expand Down Expand Up @@ -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<Self, ContextError> {
Expand Down
10 changes: 3 additions & 7 deletions tests/context_bridge.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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");
}
117 changes: 117 additions & 0 deletions tests/context_extensions.rs
Original file line number Diff line number Diff line change
@@ -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::<State>().is_none());
ctx.extensions_mut().insert(State("resolved".into()));
assert_eq!(ctx.extensions().get::<State>().unwrap().0, "resolved");
ctx.extensions_mut().insert(State("replacement".into()));
assert_eq!(ctx.extensions().get::<State>().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::<First>().unwrap().0, 1);
assert_eq!(ctx.extensions().get::<Second>().unwrap().0, 2);
assert!(ctx.extensions().get::<u32>().is_none());
assert!(context().extensions().get::<First>().is_none());
}

#[test]
fn dispatcher_passes_resolved_state_to_command_and_subcommand_handlers() {
struct HandlerSnapshot {
entrypoint: String,
calls: Arc<AtomicUsize>,
}
fn handler(ctx: &Context) {
let snapshot = ctx.extensions().get::<HandlerSnapshot>().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(), &registry);
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<T: Send + Sync>() {}
assert_send_sync::<Context>();
}

#[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::<State>().unwrap();
let second = cloned.extensions().get::<State>().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::<State>()
.unwrap()
.0
.load(Ordering::SeqCst),
2
);
drop(original);
assert_eq!(
cloned
.extensions()
.get::<State>()
.unwrap()
.0
.load(Ordering::SeqCst),
3
);
}