Skip to main content

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;