osp_cli/native.rs
1//! In-process native command surface.
2//!
3//! This module exists so `osp` can expose built-in commands through the same
4//! catalog, policy, and dispatch-adjacent shapes that plugin commands use,
5//! without spawning a subprocess.
6//!
7//! High-level flow:
8//!
9//! - register native command implementations in a [`NativeCommandRegistry`]
10//! - describe them through clap-derived metadata
11//! - project that metadata into completion, help, and policy surfaces
12//! - execute them in-process with a small runtime context
13//!
14//! Contract:
15//!
16//! - native commands are the in-process counterpart to plugin commands
17//! - catalog and policy projection should stay aligned with the plugin-facing
18//! protocol types in `crate::core::plugin`
19//!
20//! Public API shape:
21//!
22//! - [`NativeCommandRegistry`] is the canonical registration surface
23//! - catalog/context structs stay plain describe-time or execute-time payloads
24//! - commands should expose behavior through the registry rather than by
25//! leaking host-internal runtime state
26//! - downstream product crates typically build a registry once and pass it
27//! into [`crate::App::with_native_commands`] or
28//! [`crate::AppBuilder::with_native_commands`] as part of their own wrapper
29//! or builder layer
30
31use std::collections::BTreeMap;
32use std::sync::Arc;
33
34use anyhow::Result;
35use clap::Command;
36
37use crate::completion::CommandSpec;
38use crate::config::ResolvedConfig;
39use crate::core::command_policy::CommandPolicyRegistry;
40use crate::core::plugin::{DescribeCommandAuthV1, DescribeCommandV1, ResponseV1};
41use crate::core::runtime::RuntimeHints;
42
43/// Public metadata snapshot for one registered native command.
44///
45/// This is the describe-time surface projected into help, completion, and
46/// policy code. It is not an execution handle; callers should fetch the command
47/// from [`NativeCommandRegistry`] when they need to run it.
48#[derive(Debug, Clone)]
49pub struct NativeCommandCatalogEntry {
50 /// Canonical command path root exposed to CLI and REPL users.
51 pub name: String,
52 /// Short human-facing summary used in listings and overviews.
53 pub about: String,
54 /// Optional auth/visibility metadata projected into policy surfaces.
55 pub auth: Option<DescribeCommandAuthV1>,
56 /// Direct child names available immediately below this command.
57 pub subcommands: Vec<String>,
58 /// Completion tree rooted at this command's describe-time shape.
59 pub completion: CommandSpec,
60}
61
62/// Runtime context passed to native command implementations.
63///
64/// This keeps the command surface small and stable: commands receive the
65/// resolved config snapshot and runtime hints they need to behave like the host
66/// would, without exposing the whole app runtime for ad hoc coupling.
67pub struct NativeCommandContext<'a> {
68 /// Current resolved config snapshot for this execution.
69 pub config: &'a ResolvedConfig,
70 /// Runtime hints that should be propagated to child processes and adapters.
71 pub runtime_hints: RuntimeHints,
72}
73
74impl<'a> NativeCommandContext<'a> {
75 /// Creates the runtime context passed to one native-command execution.
76 pub fn new(config: &'a ResolvedConfig, runtime_hints: RuntimeHints) -> Self {
77 Self {
78 config,
79 runtime_hints,
80 }
81 }
82}
83
84/// Result of executing a native command.
85pub enum NativeCommandOutcome {
86 /// Return rendered help text directly.
87 Help(String),
88 /// Return a protocol response payload.
89 Response(Box<ResponseV1>),
90 /// Exit immediately with the given status code.
91 Exit(i32),
92}
93
94/// Trait implemented by in-process commands registered alongside plugins.
95pub trait NativeCommand: Send + Sync {
96 /// Returns the clap command definition for this command.
97 fn command(&self) -> Command;
98
99 /// Returns optional auth/visibility metadata for the command.
100 fn auth(&self) -> Option<DescribeCommandAuthV1> {
101 None
102 }
103
104 /// Builds the plugin-protocol style description for this command.
105 fn describe(&self) -> DescribeCommandV1 {
106 let mut describe = DescribeCommandV1::from_clap(self.command());
107 describe.auth = self.auth();
108 describe
109 }
110
111 /// Executes the command using already-parsed argument tokens.
112 ///
113 /// `args` contains the tokens after the registered command name. For a
114 /// command registered as `history`, the command line `osp history clear
115 /// --all` reaches `execute` as `["clear", "--all"]`.
116 ///
117 /// The host interprets outcomes as follows:
118 ///
119 /// - [`NativeCommandOutcome::Help`] is rendered as a help/guide response
120 /// - [`NativeCommandOutcome::Exit`] terminates the command immediately with
121 /// that exit code
122 /// - [`NativeCommandOutcome::Response`] is treated like plugin protocol
123 /// output and may still flow through trailing DSL stages
124 ///
125 /// Return `Err` when command execution itself fails. The host formats that
126 /// failure like other command errors.
127 ///
128 /// # Examples
129 ///
130 /// ```
131 /// use anyhow::Result;
132 /// use clap::Command;
133 /// use osp_cli::{NativeCommand, NativeCommandContext, NativeCommandOutcome};
134 ///
135 /// struct HistoryCommand;
136 ///
137 /// impl NativeCommand for HistoryCommand {
138 /// fn command(&self) -> Command {
139 /// Command::new("history").about("Manage local history")
140 /// }
141 ///
142 /// fn execute(
143 /// &self,
144 /// args: &[String],
145 /// _context: &NativeCommandContext<'_>,
146 /// ) -> Result<NativeCommandOutcome> {
147 /// match args {
148 /// [subcommand, flag] if subcommand == "clear" && flag == "--all" => {
149 /// Ok(NativeCommandOutcome::Exit(0))
150 /// }
151 /// _ => Ok(NativeCommandOutcome::Help(
152 /// "usage: history clear --all".to_string(),
153 /// )),
154 /// }
155 /// }
156 /// }
157 /// ```
158 fn execute(
159 &self,
160 args: &[String],
161 context: &NativeCommandContext<'_>,
162 ) -> Result<NativeCommandOutcome>;
163}
164
165/// Registry of in-process native commands exposed alongside plugin commands.
166#[derive(Clone, Default)]
167#[must_use]
168pub struct NativeCommandRegistry {
169 commands: Arc<BTreeMap<String, Arc<dyn NativeCommand>>>,
170}
171
172impl NativeCommandRegistry {
173 /// Creates an empty native command registry.
174 pub fn new() -> Self {
175 Self::default()
176 }
177
178 /// Returns a registry with one additional registered command.
179 ///
180 /// # Examples
181 ///
182 /// ```
183 /// use anyhow::Result;
184 /// use clap::Command;
185 /// use osp_cli::{
186 /// NativeCommand, NativeCommandContext, NativeCommandOutcome, NativeCommandRegistry,
187 /// };
188 ///
189 /// struct VersionCommand;
190 ///
191 /// impl NativeCommand for VersionCommand {
192 /// fn command(&self) -> Command {
193 /// Command::new("version").about("Show version")
194 /// }
195 ///
196 /// fn execute(
197 /// &self,
198 /// _args: &[String],
199 /// _context: &NativeCommandContext<'_>,
200 /// ) -> Result<NativeCommandOutcome> {
201 /// Ok(NativeCommandOutcome::Exit(0))
202 /// }
203 /// }
204 ///
205 /// let registry = NativeCommandRegistry::new().with_command(VersionCommand);
206 /// let catalog = registry.catalog();
207 ///
208 /// assert!(registry.command(" VERSION ").is_some());
209 /// assert_eq!(catalog[0].name, "version");
210 /// assert_eq!(catalog[0].about, "Show version");
211 /// assert!(catalog[0].auth.is_none());
212 /// ```
213 pub fn with_command(mut self, command: impl NativeCommand + 'static) -> Self {
214 self.register(command);
215 self
216 }
217
218 /// Registers or replaces a native command by normalized command name.
219 pub fn register(&mut self, command: impl NativeCommand + 'static) {
220 let mut next = (*self.commands).clone();
221 let command = Arc::new(command) as Arc<dyn NativeCommand>;
222 let name = normalize_name(&command.describe().name);
223 next.insert(name, command);
224 self.commands = Arc::new(next);
225 }
226
227 /// Returns `true` when no native commands are registered.
228 pub fn is_empty(&self) -> bool {
229 self.commands.is_empty()
230 }
231
232 /// Returns a registered command by normalized name.
233 ///
234 /// Lookup is case- and surrounding-whitespace-insensitive so callers can
235 /// reuse human-typed names without normalizing them first.
236 pub fn command(&self, name: &str) -> Option<&Arc<dyn NativeCommand>> {
237 self.commands.get(&normalize_name(name))
238 }
239
240 /// Returns catalog metadata for all registered native commands.
241 pub fn catalog(&self) -> Vec<NativeCommandCatalogEntry> {
242 self.commands
243 .values()
244 .map(|command| {
245 let describe = command.describe();
246 let completion = crate::plugin::conversion::to_command_spec(&describe);
247 NativeCommandCatalogEntry {
248 name: describe.name.clone(),
249 about: describe.about.clone(),
250 auth: describe.auth.clone(),
251 subcommands: crate::plugin::conversion::direct_subcommand_names(&completion),
252 completion,
253 }
254 })
255 .collect()
256 }
257
258 /// Builds a command-policy registry derived from command descriptions.
259 pub fn command_policy_registry(&self) -> CommandPolicyRegistry {
260 let mut registry = CommandPolicyRegistry::new();
261 for command in self.commands.values() {
262 let describe = command.describe();
263 register_describe_command_policies(&mut registry, &describe, &[]);
264 }
265 registry
266 }
267}
268
269fn register_describe_command_policies(
270 registry: &mut CommandPolicyRegistry,
271 command: &DescribeCommandV1,
272 parent: &[String],
273) {
274 let mut segments = parent.to_vec();
275 segments.push(command.name.clone());
276 if let Some(policy) = command.command_policy(crate::core::command_policy::CommandPath::new(
277 segments.clone(),
278 )) {
279 registry.register(policy);
280 }
281 for subcommand in &command.subcommands {
282 register_describe_command_policies(registry, subcommand, &segments);
283 }
284}
285
286fn normalize_name(value: &str) -> String {
287 value.trim().to_ascii_lowercase()
288}
289
290#[cfg(test)]
291mod tests;