usage-cli 7.1.2

CLI for working with usage-based CLIs
Documentation
use std::path::PathBuf;

use super::{parse_file_or_stdin, select_view, write_or_stdout};
use usage::docs::markdown::{MarkdownRenderer, MarkdownTemplate};
use usage::error::UsageErr;
use usage_rs::Args;

/// Generate Markdown documentation from a usage spec
///
/// One page by default, or a page per command with --multi. Every part of the output comes
/// from a Tera template that --template can replace.
#[derive(Args)]
#[usage(alias = "md", effect = "read")]
pub struct Markdown {
    /// A usage spec file, or a script with a usage shebang; "-" reads stdin
    #[usage(short, long)]
    file: PathBuf,

    /// Render one spec-declared executable view
    #[usage(long)]
    view: Option<String>,
    // /// Pass a usage spec in an argument instead of a file
    // #[usage(short, long, required_unless = "--file", overrides = "--file")]
    // spec: Option<String>,
    /// Render each subcommand as a separate markdown file
    #[usage(short, long, conflicts = "--out-file")]
    multi: bool,

    /// Escape HTML in markdown
    #[usage(long)]
    html_encode: bool,

    /// Directory for the per-command pages (required with --multi)
    #[usage(
        long,
        value_hint = usage_rs::ValueHint::DirPath,
        requires = "--multi",
        required_if = "--multi",
        effect = "write"
    )]
    out_dir: Option<PathBuf>,

    /// Output file path for single-file markdown generation, or "-" for stdout (default)
    #[usage(
        long,
        value_hint = usage_rs::ValueHint::FilePath,
        effect = "write"
    )]
    out_file: Option<PathBuf>,

    /// Turn four-space indented blocks in help text into markdown code fences
    ///
    /// `--replace-pre-with-code-fences` is the former name and still works. It was always a
    /// misnomer: this has never looked at `<pre>` tags.
    #[usage(
        long = "indented-blocks-to-code-fences",
        alias = "replace-pre-with-code-fences"
    )]
    indented_blocks_to_code_fences: bool,

    /// Prefix for the links between pages, such as /cli/reference
    #[usage(long)]
    url_prefix: Option<String>,

    /// Extension for page links (including the dot); empty means extensionless URLs
    #[usage(long, default = ".md")]
    link_extension: String,

    /// Replace a built-in Tera template, as NAME=PATH; the names are spec, index, command, argument, flag, and config
    #[usage(long)]
    template: Vec<String>,
}

impl usage_rs::Run for Markdown {
    type Output = usage::miette::Result<()>;

    fn run(self) -> Self::Output {
        // The banner belongs to every generated document, so build it in one place rather
        // than once per output path.
        let render = |md: &str| {
            format!(
                "<!-- @generated by usage-cli from usage spec -->\n{}\n",
                md.trim()
            )
        };
        // File-only, deliberately: every path this receives is a join onto `--out-dir`, so a
        // `-` meaning stdout cannot turn up here.
        let write = |path: &PathBuf, md: &str| -> usage::miette::Result<()> {
            eprintln!("writing to {}", path.display());
            super::write_file(path, render(md))?;
            Ok(())
        };
        let spec = select_view(parse_file_or_stdin(&self.file)?, self.view.as_deref())?;
        let mut ctx = MarkdownRenderer::new(spec.clone())
            .with_link_extension(self.link_extension)
            .with_html_encode(self.html_encode)
            .with_indented_blocks_to_code_fences(self.indented_blocks_to_code_fences);
        for value in &self.template {
            let Some((name, path)) = value.split_once('=') else {
                usage::miette::bail!("invalid template `{value}`; expected NAME=PATH");
            };
            let template = match name {
                "spec" => MarkdownTemplate::Spec,
                "index" => MarkdownTemplate::Index,
                "command" => MarkdownTemplate::Command,
                "argument" => MarkdownTemplate::Argument,
                "flag" => MarkdownTemplate::Flag,
                "config" => MarkdownTemplate::Config,
                _ => usage::miette::bail!(
                    "unknown template `{name}`; expected spec, index, command, argument, flag, or config"
                ),
            };
            let path = PathBuf::from(path);
            let source = std::fs::read_to_string(&path)
                .map_err(|err| UsageErr::FileError(err, path.clone()))?;
            ctx = ctx.with_template(template, source);
        }
        if let Some(url_prefix) = &self.url_prefix {
            ctx = ctx.with_url_prefix(url_prefix);
        }
        if self.multi {
            ctx = ctx.with_multi(true);
            // Before anything is written, so a refusal does not leave a half-built directory.
            //
            // Refused rather than warned about: writing anyway destroys the command's own
            // documentation, and a warning printed into a docs build is a warning nobody reads.
            // No CLI in the fleet has a `configuration` command — they call it `config` — so
            // this asks the one author who ever hits it to pick a name, rather than quietly
            // losing a page for everybody who does not.
            if !ctx.render_config()?.trim().is_empty()
                && let Some(cmd) = ctx.config_page_collision()
            {
                usage::miette::bail!(
                    "the `{cmd}` command's page and the settings page would both be written \
                         to {}; rename the command, or hide it, to generate both",
                    ctx.config_page()
                );
            }
            let commands = spec.cmd.all_subcommands().into_iter().filter(|c| !c.hide);
            for cmd in commands {
                let md = ctx.render_cmd(cmd)?;
                let dir = cmd
                    .full_cmd
                    .iter()
                    .take(cmd.full_cmd.len() - 1)
                    .map(|c| c.to_string())
                    .collect::<Vec<_>>()
                    .join("/");
                let path = self
                    .out_dir
                    .as_ref()
                    .unwrap()
                    .join(dir)
                    .join(format!("{}.md", cmd.name));
                write(&path, &md)?;
            }
            let md_idx = ctx.render_index()?;
            let path_idx = self.out_dir.as_ref().unwrap().join("index.md");
            write(&path_idx, &md_idx)?;
            // Its own page, and only when there is something to put on it: a CLI with no
            // settings should not gain an empty file in its docs directory.
            let md_config = ctx.render_config()?;
            if !md_config.trim().is_empty() {
                // The same call the index links by, so the two cannot disagree about where the
                // page went.
                let path = self.out_dir.as_ref().unwrap().join(ctx.config_page());
                write(&path, &md_config)?;
            }
        } else {
            let md = ctx.render_spec()?;
            write_or_stdout(self.out_file.as_deref(), &render(&md))?;
        }
        Ok(())
    }
}