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}