1use std::collections::{BTreeMap, BTreeSet};
8
9use clap::{Arg, Command};
10use minijinja::{Environment, Error, context};
11use serde::Serialize;
12
13use crate::usage;
14
15pub const VERSION_FRAGMENT: &str = r"{% macro version_line(version) -%}
17version: {{ version }}
18{%- endmacro %}";
19
20pub const INVOCATION_FRAGMENT: &str = r"{% macro mounted_invocation(surface, examples) -%}
22## Invocation
23
24```sh
25{% for example in examples -%}
26mise run {{ surface.mount }} {{ example }}
27{% endfor -%}
28```
29
30Never `mise run {{ surface.mount }} --`. The `--` in `#USAGE mount` is mise's
31completion bootstrap.
32{%- endmacro %}";
33
34pub const COMMANDS_FRAGMENT: &str = r#"{% macro command_inventory(surface) -%}
36## Commands
37
38| Command | Aliases | Purpose |
39|:--|:--|:--|
40{% for command in surface.commands if not command.hidden -%}
41| `{{ command.name }}` | {% if command.visible_aliases %}`{{ command.visible_aliases | join("`, `") }}`{% else %}—{% endif %} | {{ command.about | replace("|", "\\|") | replace("\n", " ") }} |
42{% endfor -%}
43{{- "" -}}
44{%- endmacro %}"#;
45
46const FRAGMENTS: [(&str, &str); 3] = [
47 ("ctl/version.md.jinja", VERSION_FRAGMENT),
48 ("ctl/invocation.md.jinja", INVOCATION_FRAGMENT),
49 ("ctl/commands.md.jinja", COMMANDS_FRAGMENT),
50];
51
52#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
54pub struct Surface {
55 pub binary: String,
57 pub mount: String,
59 pub about: String,
61 pub version: Option<String>,
63 pub arguments: Vec<SurfaceArgument>,
65 pub inherited_arguments: Vec<SurfaceArgument>,
67 pub commands: Vec<SurfaceCommand>,
69 pub usage_kdl: String,
71 pub mount_line: String,
73 pub notes: BTreeMap<String, String>,
75}
76
77#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
79pub struct SurfaceCommand {
80 pub name: String,
82 pub path: String,
84 pub aliases: Vec<String>,
86 pub visible_aliases: Vec<String>,
88 pub hidden: bool,
90 pub about: String,
92 pub arguments: Vec<SurfaceArgument>,
97 pub inherited_arguments: Vec<SurfaceArgument>,
99 pub commands: Vec<Self>,
101}
102
103#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
105pub struct SurfaceArgument {
106 pub id: String,
108 pub index: Option<usize>,
110 pub short: Option<char>,
112 pub long: Option<String>,
114 pub visible_short_aliases: Vec<char>,
116 pub short_aliases: Vec<char>,
118 pub visible_aliases: Vec<String>,
120 pub aliases: Vec<String>,
122 pub value_names: Vec<String>,
124 pub help: String,
126 pub requirement: SurfaceRequirement,
128 pub scope: SurfaceScope,
130 pub hidden: bool,
132 pub takes_values: bool,
134}
135
136#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
138#[serde(rename_all = "snake_case")]
139pub enum SurfaceRequirement {
140 Optional,
142 Required,
144}
145
146#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
148#[serde(rename_all = "snake_case")]
149pub enum SurfaceScope {
150 Local,
152 Global,
154}
155
156impl Surface {
157 #[must_use]
160 pub fn new<C: clap::CommandFactory>(mount: impl Into<String>) -> Self {
161 Self::from_command(C::command(), mount)
162 }
163
164 #[must_use]
166 pub fn from_command(mut command: Command, mount: impl Into<String>) -> Self {
167 let mut declared_arguments = BTreeMap::new();
168 collect_declarations(&command, "", &mut declared_arguments);
169 command.build();
170 let mount = mount.into();
171 let binary = command.get_name().to_owned();
172 let about = command
173 .get_about()
174 .map(ToString::to_string)
175 .unwrap_or_default();
176 let version = command.get_version().map(ToOwned::to_owned);
177 let arguments = declared_arguments_for(&command, "", &declared_arguments);
178 let inherited_arguments = arguments
179 .iter()
180 .filter(|argument| argument.scope == SurfaceScope::Global)
181 .cloned()
182 .collect::<Vec<_>>();
183 let commands = command
184 .get_subcommands()
185 .filter(|child| declared_arguments.contains_key(child.get_name()))
186 .map(|child| surface_command(child, "", &declared_arguments, &inherited_arguments))
187 .collect();
188 let usage_kdl = usage::spec(command, &mount);
189 let mount_line = usage::mount_line(&mount);
190 Self {
191 binary,
192 mount,
193 about,
194 version,
195 arguments,
196 inherited_arguments: Vec::new(),
197 commands,
198 usage_kdl,
199 mount_line,
200 notes: BTreeMap::new(),
201 }
202 }
203
204 #[must_use]
206 pub fn note(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
207 self.notes.insert(name.into(), value.into());
208 self
209 }
210}
211
212pub fn add_fragments(environment: &mut Environment<'static>) -> Result<(), Error> {
214 for (name, source) in FRAGMENTS {
215 environment.add_template(name, source)?;
216 }
217 Ok(())
218}
219
220pub fn environment() -> Result<Environment<'static>, Error> {
222 let mut environment = Environment::new();
223 environment.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
224 environment.set_keep_trailing_newline(true);
225 add_fragments(&mut environment)?;
226 Ok(environment)
227}
228
229pub fn render<T: Serialize>(
231 name: &'static str,
232 source: &'static str,
233 surface: &Surface,
234 content: &T,
235) -> Result<String, Error> {
236 let mut environment = environment()?;
237 environment.add_template(name, source)?;
238 environment
239 .get_template(name)?
240 .render(context! { surface, content })
241}
242
243fn surface_command(
244 command: &Command,
245 parent: &str,
246 declared_arguments: &BTreeMap<String, BTreeSet<String>>,
247 inherited_arguments: &[SurfaceArgument],
248) -> SurfaceCommand {
249 let name = command.get_name().to_owned();
250 let path = if parent.is_empty() {
251 name.clone()
252 } else {
253 format!("{parent} {name}")
254 };
255 let arguments = declared_arguments_for(command, &path, declared_arguments);
256 let mut child_inherited_arguments = inherited_arguments.to_vec();
257 child_inherited_arguments.extend(
258 arguments
259 .iter()
260 .filter(|argument| argument.scope == SurfaceScope::Global)
261 .cloned(),
262 );
263 SurfaceCommand {
264 name,
265 path: path.clone(),
266 aliases: command.get_all_aliases().map(ToOwned::to_owned).collect(),
267 visible_aliases: command
268 .get_visible_aliases()
269 .map(ToOwned::to_owned)
270 .collect(),
271 hidden: command.is_hide_set(),
272 about: command
273 .get_about()
274 .map(ToString::to_string)
275 .unwrap_or_default(),
276 arguments,
277 inherited_arguments: inherited_arguments.to_vec(),
278 commands: command
279 .get_subcommands()
280 .filter(|child| {
281 declared_arguments.contains_key(&format!("{path} {}", child.get_name()))
282 })
283 .map(|child| {
284 surface_command(child, &path, declared_arguments, &child_inherited_arguments)
285 })
286 .collect(),
287 }
288}
289
290fn declared_arguments_for(
291 command: &Command,
292 path: &str,
293 declared_arguments: &BTreeMap<String, BTreeSet<String>>,
294) -> Vec<SurfaceArgument> {
295 command
296 .get_arguments()
297 .filter(|argument| declared_argument(declared_arguments, path, argument))
298 .map(argument)
299 .collect()
300}
301
302fn collect_declarations(
303 command: &Command,
304 path: &str,
305 declared_arguments: &mut BTreeMap<String, BTreeSet<String>>,
306) {
307 declared_arguments.insert(
308 path.to_owned(),
309 command
310 .get_arguments()
311 .map(|argument| argument.get_id().to_string())
312 .collect(),
313 );
314 for child in command.get_subcommands() {
315 let child_path = if path.is_empty() {
316 child.get_name().to_owned()
317 } else {
318 format!("{path} {}", child.get_name())
319 };
320 collect_declarations(child, &child_path, declared_arguments);
321 }
322}
323
324fn declared_argument(
325 declared_arguments: &BTreeMap<String, BTreeSet<String>>,
326 path: &str,
327 argument: &Arg,
328) -> bool {
329 declared_arguments
330 .get(path)
331 .is_some_and(|arguments| arguments.contains(argument.get_id().as_str()))
332}
333
334fn argument(argument: &Arg) -> SurfaceArgument {
335 SurfaceArgument {
336 id: argument.get_id().to_string(),
337 index: argument.get_index(),
338 short: argument.get_short(),
339 long: argument.get_long().map(ToOwned::to_owned),
340 visible_short_aliases: argument.get_visible_short_aliases().unwrap_or_default(),
341 short_aliases: argument.get_all_short_aliases().unwrap_or_default(),
342 visible_aliases: argument
343 .get_visible_aliases()
344 .unwrap_or_default()
345 .iter()
346 .map(|alias| (*alias).to_owned())
347 .collect(),
348 aliases: argument
349 .get_all_aliases()
350 .unwrap_or_default()
351 .iter()
352 .map(|alias| (*alias).to_owned())
353 .collect(),
354 value_names: argument
355 .get_value_names()
356 .unwrap_or_default()
357 .iter()
358 .map(ToString::to_string)
359 .collect(),
360 help: argument
361 .get_help()
362 .map(ToString::to_string)
363 .unwrap_or_default(),
364 requirement: if argument.is_required_set() {
365 SurfaceRequirement::Required
366 } else {
367 SurfaceRequirement::Optional
368 },
369 scope: if argument.is_global_set() {
370 SurfaceScope::Global
371 } else {
372 SurfaceScope::Local
373 },
374 hidden: argument.is_hide_set(),
375 takes_values: argument.get_action().takes_values(),
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use clap::{ArgAction, Parser, Subcommand};
382 use indoc::indoc;
383 use serde::Serialize;
384
385 use super::{Surface, SurfaceArgument, SurfaceScope, render};
386
387 #[derive(Parser)]
388 #[command(name = "toy", version = "1.2.3", about = "Control toys")]
389 struct Cli {
390 #[arg(short, long, global = true, help = "Select a profile")]
391 profile: Option<String>,
392 #[command(subcommand)]
393 command: Commands,
394 }
395
396 #[derive(Subcommand)]
397 enum Commands {
398 #[command(alias = "state", visible_alias = "ls")]
400 Status {
401 #[arg(long, action = ArgAction::SetTrue)]
403 archived: bool,
404 },
405 Item {
407 #[command(subcommand)]
408 command: ItemCommand,
409 },
410 #[command(hide = true)]
411 Internal,
412 }
413
414 #[derive(Subcommand)]
415 enum ItemCommand {
416 Add {
418 name: String,
420 },
421 }
422
423 #[test]
424 fn extracts_the_complete_clap_surface() {
425 let surface = Surface::new::<Cli>("t");
426 assert_eq!(surface.binary, "toy");
427 assert_eq!(surface.mount, "t");
428 assert_eq!(surface.version.as_deref(), Some("1.2.3"));
429 assert_eq!(surface.about, "Control toys");
430 assert_eq!(
431 surface.inherited_arguments.as_slice(),
432 &[] as &[SurfaceArgument]
433 );
434 assert!(surface.usage_kdl.contains("status"));
435 assert_eq!(
436 surface.mount_line,
437 r#"#USAGE mount "mise run --quiet t -- --usage-spec=t""#
438 );
439 let status = &surface.commands[0];
440 assert_eq!(status.visible_aliases, ["ls"]);
441 assert_eq!(status.aliases, ["state", "ls"]);
442 assert_eq!(status.about, "Show current state");
443 assert_eq!(status.arguments[0].long.as_deref(), Some("archived"));
444 assert!(!status.arguments[0].takes_values);
445 assert_eq!(status.arguments.len(), 1);
446 assert_eq!(status.inherited_arguments.len(), 1);
447 assert_eq!(
448 status.inherited_arguments[0].long.as_deref(),
449 Some("profile")
450 );
451 assert_eq!(status.inherited_arguments[0].scope, SurfaceScope::Global);
452 assert!(
453 status
454 .arguments
455 .iter()
456 .all(|argument| !matches!(argument.id.as_str(), "help" | "version" | "profile"))
457 );
458 let item = &surface.commands[1];
459 assert_eq!(item.commands[0].path, "item add");
460 assert_eq!(item.commands[0].arguments[0].index, Some(1));
461 assert_eq!(item.commands[0].inherited_arguments.len(), 1);
462 assert_eq!(
463 item.commands[0].inherited_arguments[0].long.as_deref(),
464 Some("profile")
465 );
466 assert!(surface.commands[2].hidden);
467 assert!(
468 surface
469 .arguments
470 .iter()
471 .any(|arg| arg.long.as_deref() == Some("profile"))
472 );
473 assert!(
474 surface
475 .arguments
476 .iter()
477 .all(|argument| !matches!(argument.id.as_str(), "help" | "version"))
478 );
479 let noted = surface.note("skill", "Prefer the mounted task.");
480 assert_eq!(noted.notes["skill"], "Prefer the mounted task.");
481 }
482
483 #[derive(Serialize)]
484 struct Content<'a> {
485 version: &'a str,
486 invocations: [&'a str; 2],
487 }
488
489 #[test]
490 fn shared_fragments_render_committed_operator_blocks() {
491 let surface = Surface::new::<Cli>("t");
492 let template = indoc! {r#"
493 {%- from "ctl/version.md.jinja" import version_line -%}
494 {%- from "ctl/invocation.md.jinja" import mounted_invocation -%}
495 {%- from "ctl/commands.md.jinja" import command_inventory -%}
496 ---
497 {{ version_line(content.version) }}
498 ---
499
500 {{ mounted_invocation(surface, content.invocations) }}
501
502 {{ command_inventory(surface) -}}
503 "#};
504 let rendered = render(
505 "operator.md.jinja",
506 template,
507 &surface,
508 &Content {
509 version: "1.2.3",
510 invocations: ["status", "item add demo"],
511 },
512 )
513 .unwrap_or_else(|error| panic!("render operator template: {error}"));
514 let expected = indoc! {r"
515 ---
516 version: 1.2.3
517 ---
518
519 ## Invocation
520
521 ```sh
522 mise run t status
523 mise run t item add demo
524 ```
525
526 Never `mise run t --`. The `--` in `#USAGE mount` is mise's
527 completion bootstrap.
528
529 ## Commands
530
531 | Command | Aliases | Purpose |
532 |:--|:--|:--|
533 | `status` | `ls` | Show current state |
534 | `item` | — | Mutate one item |
535 "};
536 assert_eq!(rendered, expected);
537 assert!(!rendered.contains("internal"));
538 }
539}