vsd 0.5.0

A command-line utility and library for downloading streams from DASH manifests and HLS playlists.
Documentation
use clap::{Arg, Command, CommandFactory};
use std::collections::BTreeMap;
use std::{env, fs, path::Path};

fn main() {
    fs::write(
        Path::new(env!("CARGO_MANIFEST_DIR")).join("../docs/cli.md"),
        generate_markdown(&vsd::Args::command()),
    )
    .unwrap();
}

fn generate_markdown(cmd: &Command) -> String {
    let mut buffer = String::new();

    buffer.push_str(&format!(
        "---\nicon: lucide/terminal\n---\n\n\
        # {} CLI\n\n\
        This document contains cli reference for the `vsd` command-line program.\n\n",
        cmd.get_name().to_uppercase(),
    ));

    let mut all_commands = Vec::new();
    collect_commands(cmd, &[], &mut all_commands);

    buffer.push_str("## Command Overview\n\n");
    for cmd_path in &all_commands {
        let anchor = cmd_path.replace(' ', "-");
        buffer.push_str(&format!("- [`{}`↴](#{})\n", cmd_path, anchor));
    }
    buffer.push('\n');
    write_command(&mut buffer, cmd, &[], 2);
    buffer
}

fn collect_commands(cmd: &Command, parents: &[&str], result: &mut Vec<String>) {
    let cmd_path = parents
        .iter()
        .copied()
        .chain(std::iter::once(cmd.get_name()))
        .collect::<Vec<_>>();
    let full_name = cmd_path.join(" ");
    result.push(full_name);

    for sub in cmd.get_subcommands().filter(|s| !s.is_hide_set()) {
        collect_commands(sub, &cmd_path, result);
    }
}

fn write_command(buffer: &mut String, cmd: &Command, parents: &[&str], level: usize) {
    let cmd_path = parents
        .iter()
        .copied()
        .chain(std::iter::once(cmd.get_name()))
        .collect::<Vec<_>>();
    let full_name = cmd_path.join(" ");

    buffer.push_str(&format!("{} `{}`\n\n", "#".repeat(level), full_name));

    if let Some(about) = cmd.get_long_about().or(cmd.get_about()) {
        buffer.push_str(&format!("{}\n\n", about));
    }

    buffer.push_str("```\n");
    buffer.push_str(&format!("{} [OPTIONS]", full_name));

    let positionals = cmd.get_positionals().collect::<Vec<_>>();
    for arg in &positionals {
        if arg.is_required_set() {
            buffer.push_str(&format!(" <{}>", arg.get_id().to_string().to_uppercase()));
        } else {
            buffer.push_str(&format!(" [{}]", arg.get_id().to_string().to_uppercase()));
        }
    }

    if cmd.has_subcommands() {
        buffer.push_str(" <COMMAND>");
    }
    buffer.push_str("\n```\n\n");

    if !positionals.is_empty() {
        buffer.push_str("**Arguments:**\n\n");
        for arg in &positionals {
            write_arg(buffer, arg);
        }
        buffer.push('\n');
    }

    let subcommands = cmd
        .get_subcommands()
        .filter(|s| !s.is_hide_set())
        .collect::<Vec<_>>();

    if !subcommands.is_empty() {
        buffer.push_str("**Subcommands:**\n\n");
        buffer.push_str("| Command | Description |\n");
        buffer.push_str("|---------|-------------|\n");
        for sub in &subcommands {
            let about = sub.get_about().map(|s| s.to_string()).unwrap_or_default();
            buffer.push_str(&format!("| `{}` | {} |\n", sub.get_name(), about));
        }
        buffer.push('\n');
    }

    let options: Vec<_> = cmd
        .get_arguments()
        .filter(|a| !a.is_positional() && !a.is_hide_set())
        .collect();

    if !options.is_empty() {
        let mut grouped: BTreeMap<Option<String>, Vec<&Arg>> = BTreeMap::new();
        for arg in &options {
            let heading = arg.get_help_heading().map(|s| s.to_string());
            grouped.entry(heading).or_default().push(arg);
        }

        for (heading, args) in grouped {
            let heading_str = heading.as_deref().unwrap_or("Options");
            buffer.push_str(&format!("**{}:**\n\n", heading_str));
            buffer.push_str("| Flag | Description |\n");
            buffer.push_str("|------|-------------|\n");

            for arg in args {
                write_option(buffer, arg);
            }
            buffer.push('\n');
        }
    }

    buffer.push_str("[↑ Back to top](#command-overview)\n\n");

    for sub in subcommands {
        write_command(buffer, sub, &cmd_path, level + 1);
    }
}

fn write_arg(buffer: &mut String, arg: &Arg) {
    let required = if arg.is_required_set() {
        " *(required)*"
    } else {
        ""
    };
    let help = arg
        .get_long_help()
        .or(arg.get_help())
        .map(|s| s.to_string())
        .unwrap_or_default();

    buffer.push_str(&format!(
        "- `<{}>`: {}{}\n",
        arg.get_id().to_string().to_uppercase(),
        help,
        required
    ));
}

fn write_option(buffer: &mut String, arg: &Arg) {
    let mut flags = Vec::new();
    if let Some(short) = arg.get_short() {
        flags.push(format!("-{}", short));
    }
    if let Some(long) = arg.get_long() {
        flags.push(format!("--{}", long));
    }
    let flag_str = flags.join(", ");

    let mut help = arg
        .get_long_help()
        .or(arg.get_help())
        .map(|s| s.to_string())
        .unwrap_or_default();

    let possible_values: Vec<_> = arg.get_possible_values();
    let is_bool = possible_values.len() == 2
        && possible_values
            .iter()
            .all(|v| v.get_name() == "true" || v.get_name() == "false");
    if !possible_values.is_empty() && !is_bool {
        let values: Vec<_> = possible_values.iter().map(|v| v.get_name()).collect();
        help.push_str(&format!("<br>*Possible values:* `{}`", values.join("`, `")));
    }

    if let Some(default) = arg.get_default_values().first().filter(|_| !arg.is_hide_default_value_set()) {
        help.push_str(&format!("<br>*Default:* `{}`", default.to_string_lossy()));
    }

    let help = help.replace('|', "\\|").replace('\n', "<br>");
    buffer.push_str(&format!("| `{}` | {} |\n", flag_str, help));
}