rigg 2.0.0

Configuration-as-code CLI for Azure AI Search and Microsoft Foundry
//! Markdown generators for the reference documentation.
//!
//! Two pages are printed from the code rather than written by hand:
//! `docs/reference/cli.md` (the whole clap command tree) and the
//! infrastructure-reference table embedded in
//! `docs/reference/resource-files.md`. `crates/rigg/tests/docs_guards.rs`
//! fails when either drifts from what these functions print.
//!
//! Output is deterministic: clap's own declaration order for commands and
//! arguments, the registry's order for infrastructure references, and no
//! terminal-dependent formatting (`StyledStr`'s `Display` is plain text and
//! `render_usage` does not wrap).

use clap::{Arg, Command, CommandFactory};
use rigg_core::registry;
use rigg_core::resources::ResourceKind;

use crate::cli::Cli;

/// The full CLI reference page, ready to be written to
/// `docs/reference/cli.md`.
pub fn cli_reference_markdown() -> String {
    let mut root = Cli::command();
    // Resolves bin names and expands the tree so `render_usage` and the
    // per-command argument lists are the ones a user would see.
    root.build();

    let globals: Vec<Arg> = root
        .get_arguments()
        .filter(|a| a.is_global_set() && !is_builtin(a) && !a.is_hide_set())
        .cloned()
        .collect();

    let mut out = String::new();
    out.push_str("# CLI reference\n\n");
    out.push_str("<!-- generated:cli-reference:start -->\n");
    out.push_str(
        "Generated by `rigg dev cli-reference` — do not edit by hand.\n\
         Every command, argument and option below is read straight off the\n\
         binary's own command tree; hidden developer commands are omitted.\n\n",
    );
    emit_command(&mut root, "rigg", &globals, &mut out);
    out.push_str("<!-- generated:cli-reference:end -->\n");
    out
}

/// clap adds `help`/`version` itself; they are the same everywhere and only
/// noise in a table.
fn is_builtin(arg: &Arg) -> bool {
    matches!(arg.get_id().as_str(), "help" | "version")
}

fn emit_command(cmd: &mut Command, path: &str, globals: &[Arg], out: &mut String) {
    let is_root = !path.contains(' ');

    out.push_str(&format!("## {path}\n\n"));

    if let Some(text) = cmd
        .get_long_about()
        .or_else(|| cmd.get_about())
        .map(ToString::to_string)
    {
        let text = text.trim();
        if !text.is_empty() {
            out.push_str(text);
            out.push_str("\n\n");
        }
    }

    for line in cmd.render_usage().to_string().lines() {
        let line = line.trim();
        if !line.is_empty() {
            out.push_str(&format!("`{line}`\n\n"));
        }
    }

    let positionals: Vec<Arg> = cmd
        .get_arguments()
        .filter(|a| a.is_positional() && !a.is_hide_set())
        .cloned()
        .collect();
    if !positionals.is_empty() {
        out.push_str("### Arguments\n\n");
        out.push_str("| Argument | Required | Description |\n|---|---|---|\n");
        for arg in &positionals {
            out.push_str(&format!(
                "| `{}` | {} | {} |\n",
                positional_name(arg),
                if arg.is_required_set() { "yes" } else { "no" },
                help_of(arg),
            ));
        }
        out.push('\n');
    }

    let options: Vec<Arg> = cmd
        .get_arguments()
        .filter(|a| {
            !a.is_positional()
                && !a.is_hide_set()
                && !is_builtin(a)
                && (is_root || !a.is_global_set())
        })
        .cloned()
        .collect();
    if !options.is_empty() {
        // The root's options are exactly the global ones, and every
        // subcommand accepts them — list them once, under their own anchor.
        out.push_str(if is_root {
            "### Global options\n\nAccepted by every command.\n\n"
        } else {
            "### Options\n\n"
        });
        out.push_str(&options_table(&options));
    }
    if !is_root && !globals.is_empty() {
        out.push_str("Also accepts the [global options](#global-options).\n\n");
    }

    let children: Vec<String> = cmd
        .get_subcommands()
        // `help` is clap's own, identical everywhere and not worth a page.
        .filter(|c| !c.is_hide_set() && c.get_name() != "help")
        .map(|c| c.get_name().to_string())
        .collect();
    if !children.is_empty() {
        out.push_str("### Subcommands\n\n");
        for name in &children {
            let child_path = format!("{path} {name}");
            let summary = cmd
                .get_subcommands()
                .find(|c| c.get_name() == name.as_str())
                .and_then(|c| c.get_about())
                .map(|a| cell(&a.to_string()))
                .unwrap_or_default();
            let link = format!("[`{child_path}`](#{})", slug(&child_path));
            if summary.is_empty() {
                out.push_str(&format!("- {link}\n"));
            } else {
                out.push_str(&format!("- {link}{summary}\n"));
            }
        }
        out.push('\n');
    }

    for name in children {
        let child_path = format!("{path} {name}");
        let child = cmd
            .get_subcommands_mut()
            .find(|c| c.get_name() == name.as_str())
            .expect("subcommand still present");
        emit_command(child, &child_path, globals, out);
    }
}

fn options_table(options: &[Arg]) -> String {
    let mut out =
        String::from("| Option | Value | Default | Env | Description |\n|---|---|---|---|---|\n");
    for arg in options {
        let mut names = Vec::new();
        if let Some(long) = arg.get_long() {
            names.push(format!("`--{long}`"));
        }
        if let Some(short) = arg.get_short() {
            names.push(format!("`-{short}`"));
        }
        let value = if arg.get_action().takes_values() {
            arg.get_value_names()
                .map(|names| {
                    names
                        .iter()
                        .map(|n| format!("`<{n}>`"))
                        .collect::<Vec<_>>()
                        .join(" ")
                })
                .unwrap_or_else(|| format!("`<{}>`", arg.get_id().as_str().to_uppercase()))
        } else {
            String::new()
        };
        // A flag's `false`/`0` default is clap bookkeeping, not information.
        let default = if arg.get_action().takes_values() {
            arg.get_default_values()
                .iter()
                .map(|v| format!("`{}`", v.to_string_lossy()))
                .collect::<Vec<_>>()
                .join(", ")
        } else {
            String::new()
        };
        let env = arg
            .get_env()
            .map(|e| format!("`{}`", e.to_string_lossy()))
            .unwrap_or_default();
        out.push_str(&format!(
            "| {} | {} | {} | {} | {} |\n",
            names.join(", "),
            value,
            default,
            env,
            help_of(arg),
        ));
    }
    out.push('\n');
    out
}

fn positional_name(arg: &Arg) -> String {
    let name = arg
        .get_value_names()
        .and_then(|n| n.first().map(|n| n.to_string()))
        .unwrap_or_else(|| arg.get_id().as_str().to_uppercase());
    if arg.is_required_set() {
        format!("<{name}>")
    } else {
        format!("[{name}]")
    }
}

fn help_of(arg: &Arg) -> String {
    arg.get_help()
        .map(|h| cell(&h.to_string()))
        .unwrap_or_default()
}

/// Flatten a help string into one Markdown table cell: one line, pipes
/// escaped, and `<angle brackets>` outside code spans turned into entities
/// so a Markdown renderer does not swallow them as HTML tags.
fn cell(text: &str) -> String {
    let flat: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
    let mut out = String::with_capacity(flat.len());
    let mut in_code = false;
    for ch in flat.chars() {
        match ch {
            '`' => {
                in_code = !in_code;
                out.push('`');
            }
            '|' => out.push_str("\\|"),
            '<' if !in_code => out.push_str("&lt;"),
            '>' if !in_code => out.push_str("&gt;"),
            c => out.push(c),
        }
    }
    out
}

/// GitHub's heading slug: lowercase, spaces to `-`, everything but
/// `[a-z0-9-]` dropped.
pub fn slug(heading: &str) -> String {
    heading
        .to_lowercase()
        .chars()
        .filter_map(|c| match c {
            ' ' => Some('-'),
            c if c.is_ascii_alphanumeric() || c == '-' => Some(c),
            _ => None,
        })
        .collect()
}

/// The infrastructure-reference table for every kind that has one, for the
/// generated region of `docs/reference/resource-files.md`.
pub fn infra_table_markdown() -> String {
    let mut out = String::new();
    for kind in ResourceKind::all() {
        let refs = registry::infra_refs(*kind);
        if refs.is_empty() {
            continue;
        }
        out.push_str(&format!("### {}\n\n", kind.directory_name()));
        out.push_str(
            "| Path | Binding type | Form | Only for `@odata.type` |\n|---|---|---|---|\n",
        );
        for r in refs {
            out.push_str(&format!(
                "| `{}` | {} | `{:?}` | {} |\n",
                r.path,
                r.form.binding_type_label(),
                r.form,
                r.only_odata_type
                    .map(|t| format!("`{t}`"))
                    .unwrap_or_else(|| "".to_string()),
            ));
        }
        out.push('\n');
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cli_reference_covers_the_visible_tree_and_links_resolve() {
        let md = cli_reference_markdown();
        assert!(md.starts_with("# CLI reference\n"));
        assert!(md.contains("## rigg\n"));
        assert!(md.contains("### Global options\n"));
        assert!(md.contains("## rigg push\n"));
        assert!(md.contains("[`rigg push`](#rigg-push)"));
        // Hidden commands stay out of the user-facing reference.
        assert!(!md.contains("## rigg dev"));
        // No ANSI escapes leak in from clap's styling.
        assert!(!md.contains('\u{1b}'));
    }

    #[test]
    fn infra_table_has_a_section_per_kind_with_refs() {
        let md = infra_table_markdown();
        assert!(md.contains("### data-sources\n"));
        assert!(md.contains("| `credentials.connectionString` | storage |"));
        // Kinds without infrastructure references get no heading.
        assert!(!md.contains("### synonym-maps"));
    }

    #[test]
    fn slugs_follow_github_rules() {
        assert_eq!(slug("rigg env add"), "rigg-env-add");
        assert_eq!(slug("Global options"), "global-options");
        assert_eq!(
            slug("`x-rigg-ref`: what it does"),
            "x-rigg-ref-what-it-does"
        );
    }
}