Skip to main content

cli_engine/
module.rs

1use std::{path::Path, sync::Arc};
2
3use schemars::JsonSchema;
4
5use crate::{
6    FeatureFlag, GuideEntry, HumanViewDef, Middleware, OutputSchema, RuntimeGroupSpec,
7    SchemaRegistry, Stage, parse_guides_from_markdown,
8};
9
10/// Function used by closure-based modules to register a runtime command group.
11pub type ModuleRegister = Arc<dyn Fn(&mut ModuleContext<'_>) -> RuntimeGroupSpec + Send + Sync>;
12
13/// Trait-based module API for larger command domains.
14///
15/// Implement this when a module has dependencies or enough setup logic that a
16/// named type is clearer than a closure.
17pub trait CommandModule: Send + Sync + std::fmt::Debug + 'static {
18    /// Help category used in root command long help.
19    fn category(&self) -> String;
20
21    /// Guide entries contributed by this module.
22    fn guides(&self) -> Vec<GuideEntry> {
23        Vec::new()
24    }
25
26    /// Human views contributed by this module.
27    fn views(&self) -> Vec<HumanViewDef> {
28        Vec::new()
29    }
30
31    /// Registers the module's top-level runtime group.
32    fn register(&self, context: &mut ModuleContext<'_>) -> RuntimeGroupSpec;
33}
34
35/// Domain-bounded unit of CLI functionality.
36///
37/// A module usually maps to a product, platform, resource family, or team
38/// ownership boundary. It contributes one top-level group plus optional guides
39/// and human output views.
40///
41/// Construct with [`Module::new`], then chain `with_*` methods — never as a
42/// struct literal. `#[non_exhaustive]` enforces this so the engine can add
43/// fields without a breaking release.
44#[derive(Clone)]
45#[non_exhaustive]
46pub struct Module {
47    /// Root help category.
48    pub category: String,
49    /// Guide entries merged into the CLI-wide guide command.
50    pub guides: Vec<GuideEntry>,
51    /// Human output views registered before command execution.
52    pub views: Vec<HumanViewDef>,
53    /// This module's own feature-flag declaration, if any.
54    ///
55    /// `None` means the module has no explicit stage declaration of its own,
56    /// in which case its group and descendants inherit their effective stage
57    /// from their nearest ancestor (nested group, then enclosing group, then
58    /// module — nearest declaration wins), implicitly resolving to
59    /// [`Stage::Ga`] if nothing in the ancestor chain declares a flag either;
60    /// see [`Stage`]'s documentation for why that is its default. A module is
61    /// the top-level ancestor in that chain: nothing sits above it. Set with
62    /// [`with_feature_flag`](Module::with_feature_flag). This field only
63    /// records the module's own declaration; cascading resolution (and
64    /// pruning of nodes the active [`FlagPolicy`](crate::FlagPolicy) hides)
65    /// happens when a [`Cli`](crate::Cli) mounts this module via
66    /// [`Cli::add_module`](crate::Cli::add_module).
67    pub feature_flag: Option<FeatureFlag>,
68    /// Registration function that returns the module's runtime group.
69    pub register: ModuleRegister,
70}
71
72impl Module {
73    /// Creates a closure-based module.
74    #[must_use]
75    pub fn new<F>(category: impl Into<String>, register: F) -> Self
76    where
77        F: Fn(&mut ModuleContext<'_>) -> RuntimeGroupSpec + Send + Sync + 'static,
78    {
79        Self {
80            category: category.into(),
81            guides: Vec::new(),
82            views: Vec::new(),
83            feature_flag: None,
84            register: Arc::new(register),
85        }
86    }
87
88    /// Converts a trait-based module into the runtime module type.
89    #[must_use]
90    pub fn from_command_module<M>(module: M) -> Self
91    where
92        M: CommandModule,
93    {
94        let category = module.category();
95        let guides = module.guides();
96        let views = module.views();
97        let module = Arc::new(module);
98        Self {
99            category,
100            guides,
101            views,
102            feature_flag: None,
103            register: Arc::new(move |context| module.register(context)),
104        }
105    }
106
107    /// Adds one guide entry.
108    #[must_use]
109    pub fn with_guide(mut self, guide: GuideEntry) -> Self {
110        self.guides.push(guide);
111        self
112    }
113
114    /// Adds several guide entries.
115    #[must_use]
116    pub fn with_guides(mut self, guides: impl IntoIterator<Item = GuideEntry>) -> Self {
117        self.guides.extend(guides);
118        self
119    }
120
121    /// Parses markdown guide entries from embedded `(path, bytes)` pairs.
122    #[must_use]
123    pub fn with_guides_from_markdown(
124        self,
125        files: impl IntoIterator<Item = (impl AsRef<Path>, impl AsRef<[u8]>)>,
126    ) -> Self {
127        self.with_guides(parse_guides_from_markdown(files))
128    }
129
130    /// Adds one human output view.
131    #[must_use]
132    pub fn with_view(mut self, view: HumanViewDef) -> Self {
133        self.views.push(view);
134        self
135    }
136
137    /// Declares this module's own feature flag: the key used for policy
138    /// overrides and introspection, and the stage at which it becomes visible.
139    #[must_use]
140    pub fn with_feature_flag(mut self, key: impl Into<String>, stage: Stage) -> Self {
141        self.feature_flag = Some(FeatureFlag::new(key, stage));
142        self
143    }
144}
145
146/// Materializes a module's command tree standalone, outside a running
147/// [`Cli`](crate::Cli).
148///
149/// Builds a throwaway [`Middleware`] and runs the module's registration
150/// function against it — the same call [`Cli::new`](crate::Cli::new) makes —
151/// so callers can walk the real [`RuntimeGroupSpec`] — e.g. to derive a
152/// scope→command registry from
153/// [`CommandSpec::metadata`](crate::CommandSpec::metadata) — without
154/// duplicating each module's command declarations. Guides and views the
155/// module registers via [`ModuleContext`] are discarded; only the command
156/// tree is returned.
157///
158/// Unlike mounting a module through [`Cli::new`](crate::Cli::new)/`add_module`,
159/// this does **not** apply feature-flag pruning: the returned tree includes every node
160/// regardless of the active [`FlagPolicy`](crate::FlagPolicy), so it may
161/// contain commands or groups that are actually hidden at runtime. Fine for
162/// callers that only need the static declarations (e.g. scope metadata), but
163/// don't treat the result as "what's mounted right now."
164#[must_use]
165pub fn build_module_group(module: &Module) -> RuntimeGroupSpec {
166    let mut middleware = Middleware::new();
167    let mut ctx = ModuleContext::new(&mut middleware);
168    (module.register)(&mut ctx)
169}
170
171impl std::fmt::Debug for Module {
172    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        formatter
174            .debug_struct("Module")
175            .field("category", &self.category)
176            .field("guides", &self.guides)
177            .field("views", &self.views)
178            .field("feature_flag", &self.feature_flag)
179            .finish_non_exhaustive()
180    }
181}
182
183/// Context available while a module registers itself.
184///
185/// The context gives module code access to shared registries without exposing
186/// parser internals. This keeps module registration declarative and easy to
187/// copy for new teams.
188#[derive(Debug)]
189pub struct ModuleContext<'middleware> {
190    middleware: &'middleware mut Middleware,
191    guides: Vec<GuideEntry>,
192    views: Vec<HumanViewDef>,
193}
194
195impl<'middleware> ModuleContext<'middleware> {
196    pub(crate) fn new(middleware: &'middleware mut Middleware) -> Self {
197        Self {
198            middleware,
199            guides: Vec::new(),
200            views: Vec::new(),
201        }
202    }
203
204    /// Returns a shared view of middleware while registering the module.
205    pub fn middleware(&self) -> &Middleware {
206        self.middleware
207    }
208
209    /// Returns mutable middleware for module-specific setup.
210    pub fn middleware_mut(&mut self) -> &mut Middleware {
211        self.middleware
212    }
213
214    /// Returns the per-application config file as loaded at startup.
215    ///
216    /// Read a consumer-owned section with
217    /// [`ConfigFile::section`](crate::config::ConfigFile::section). This is
218    /// the same startup snapshot surfaced via
219    /// [`CommandContext::config`](crate::command::CommandContext::config); see
220    /// its documentation for snapshot-semantics caveats.
221    pub fn config(&self) -> &crate::config::ConfigFile {
222        &self.middleware.config
223    }
224
225    /// Returns the schema registry for direct registration.
226    pub fn schema_registry(&mut self) -> &mut SchemaRegistry {
227        &mut self.middleware.schema_registry
228    }
229
230    /// Registers a compact framework schema for a command path.
231    pub fn register_schema<T: OutputSchema>(&mut self, command_path: impl Into<String>) {
232        self.middleware
233            .schema_registry
234            .register::<T>(command_path.into());
235    }
236
237    /// Registers JSON Schema generated with `schemars` for a command path.
238    pub fn register_json_schema<T: JsonSchema>(&mut self, command_path: impl Into<String>) {
239        self.middleware
240            .schema_registry
241            .register_json_schema::<T>(command_path.into());
242    }
243
244    /// Registers a human output view and keeps it with the module.
245    pub fn register_view(&mut self, view: HumanViewDef) {
246        self.middleware.human_views.register(view.clone());
247        self.views.push(view);
248    }
249
250    /// Adds one guide entry.
251    pub fn add_guide(&mut self, guide: GuideEntry) {
252        self.guides.push(guide);
253    }
254
255    /// Adds several guide entries.
256    pub fn add_guides(&mut self, guides: impl IntoIterator<Item = GuideEntry>) {
257        self.guides.extend(guides);
258    }
259
260    /// Parses and adds markdown guides from embedded `(path, bytes)` pairs.
261    pub fn add_guides_from_markdown(
262        &mut self,
263        files: impl IntoIterator<Item = (impl AsRef<Path>, impl AsRef<[u8]>)>,
264    ) {
265        self.add_guides(parse_guides_from_markdown(files));
266    }
267
268    pub(crate) fn into_parts(self) -> (Vec<GuideEntry>, Vec<HumanViewDef>) {
269        (self.guides, self.views)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::GroupSpec;
277
278    fn trivial_module(category: &str) -> Module {
279        Module::new(category.to_string(), |_ctx| {
280            RuntimeGroupSpec::new(GroupSpec::new("g", "short"))
281        })
282    }
283
284    #[test]
285    fn module_with_feature_flag_sets_key_and_stage() {
286        let module = trivial_module("cat").with_feature_flag("my-module-flag", Stage::Beta);
287
288        let flag = module.feature_flag.expect("feature flag should be set");
289        assert_eq!(flag.key, "my-module-flag");
290        assert_eq!(flag.stage, Stage::Beta);
291    }
292
293    #[test]
294    fn module_feature_flag_defaults_to_none() {
295        let module = trivial_module("cat");
296
297        assert!(module.feature_flag.is_none());
298    }
299}