harn-cli 0.10.152

CLI for the Harn programming language — run, test, REPL, format, and lint
//! A versioned projection of the same Clap tree that parses `harn` invocations.

use clap::{Arg, ArgAction, Command, CommandFactory};
use serde::Serialize;

use crate::cli::Cli;
use crate::json_envelope::{to_string_pretty, JsonEnvelope};

const SCHEMA_VERSION: u32 = 1;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CommandSchema {
    name: String,
    aliases: Vec<String>,
    subcommand_required: bool,
    arguments: Vec<ArgumentSchema>,
    subcommands: Vec<CommandSchema>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ArgumentSchema {
    id: String,
    kind: &'static str,
    long: Option<String>,
    short: Option<char>,
    long_aliases: Vec<String>,
    short_aliases: Vec<char>,
    index: Option<usize>,
    required: bool,
    global: bool,
    trailing: bool,
    action: &'static str,
    min_values: usize,
    max_values: Option<usize>,
    value_names: Vec<String>,
    possible_values: Vec<String>,
}

pub(crate) fn run() {
    println!(
        "{}",
        to_string_pretty(&JsonEnvelope::ok(SCHEMA_VERSION, native_schema()))
    );
}

const SURFACE_HEADER: &str = "\
# Every `harn` command, alias, flag, and positional argument a caller can type.
# GENERATED by `make gen-cli-surface` from the live Clap parser; do not edit.
# A pull request that removes or renames a line here breaks a caller, so it
# must add a `changelog.d/<id>.breaking.md` fragment with a `Migration:`
# section. Adding lines is not breaking.
";

/// Write or verify the flat, sorted CLI surface listing at `output`.
pub(crate) fn dump_surface(output: &str, check: bool) {
    let generated = surface_listing(&native_schema());
    let path = std::path::Path::new(output);
    if check {
        let existing = std::fs::read_to_string(path).unwrap_or_default();
        if existing.replace("\r\n", "\n") != generated {
            eprintln!(
                "error: {} is stale relative to the Clap parser.",
                path.display()
            );
            eprintln!("hint: run `make gen-cli-surface` to regenerate.");
            std::process::exit(1);
        }
        return;
    }
    if let Err(error) = std::fs::write(path, &generated) {
        eprintln!("error: cannot write {}: {error}", path.display());
        std::process::exit(1);
    }
    println!("wrote {}", path.display());
}

/// One line per invocable spelling, keyed so a removal or rename shows up as
/// a deleted line: commands and their aliases, each long and short flag
/// spelling, and each positional slot.
///
/// Clap's generated `help` subcommand, help flags, and version flags are
/// omitted, and a global argument is listed once, where it is declared.
/// Enumerated values are omitted too: most of them are the provider model
/// catalog, which changes as data, not as CLI surface.
fn surface_listing(root: &CommandSchema) -> String {
    let mut lines = std::collections::BTreeSet::new();
    collect_surface(root, &root.name, &[], &mut lines);
    let mut listing = SURFACE_HEADER.to_owned();
    for line in lines {
        listing.push_str(&line);
        listing.push('\n');
    }
    listing
}

fn collect_surface(
    command: &CommandSchema,
    path: &str,
    inherited_globals: &[&str],
    lines: &mut std::collections::BTreeSet<String>,
) {
    lines.insert(path.to_owned());
    let mut globals = inherited_globals.to_vec();
    for argument in &command.arguments {
        let generated = matches!(
            argument.action,
            "help" | "helpShort" | "helpLong" | "version"
        );
        if generated || (argument.global && inherited_globals.contains(&argument.id.as_str())) {
            continue;
        }
        if argument.global {
            globals.push(&argument.id);
        }
        let mut spellings: Vec<String> = Vec::new();
        spellings.extend(argument.long.iter().map(|long| format!("--{long}")));
        spellings.extend(argument.long_aliases.iter().map(|long| format!("--{long}")));
        spellings.extend(argument.short.iter().map(|short| format!("-{short}")));
        spellings.extend(
            argument
                .short_aliases
                .iter()
                .map(|short| format!("-{short}")),
        );
        spellings.extend(argument.index.map(|index| format!("<positional {index}>")));
        for spelling in spellings {
            lines.insert(format!("{path} {spelling}"));
        }
    }
    let parent = path.rsplit_once(' ').map_or("", |(parent, _)| parent);
    for alias in &command.aliases {
        lines.insert(format!("{parent} {alias} (alias of {path})"));
    }
    for subcommand in &command.subcommands {
        if subcommand.name == "help" {
            continue;
        }
        let child = format!("{path} {}", subcommand.name);
        collect_surface(subcommand, &child, &globals, lines);
    }
}

fn native_schema() -> CommandSchema {
    let mut command = Cli::command();
    command.build();
    project_command(&command)
}

fn project_command(command: &Command) -> CommandSchema {
    CommandSchema {
        name: command.get_name().to_owned(),
        aliases: command.get_all_aliases().map(str::to_owned).collect(),
        subcommand_required: command.is_subcommand_required_set(),
        arguments: command.get_arguments().map(project_argument).collect(),
        subcommands: command.get_subcommands().map(project_command).collect(),
    }
}

fn project_argument(argument: &Arg) -> ArgumentSchema {
    let range = argument.get_num_args();
    let action = argument.get_action();
    ArgumentSchema {
        id: argument.get_id().to_string(),
        kind: if argument.get_index().is_some() {
            "positional"
        } else if range.is_some_and(|range| range.takes_values()) {
            "option"
        } else {
            "flag"
        },
        long: argument.get_long().map(str::to_owned),
        short: argument.get_short(),
        long_aliases: argument
            .get_all_aliases()
            .unwrap_or_default()
            .into_iter()
            .map(str::to_owned)
            .collect(),
        short_aliases: argument.get_all_short_aliases().unwrap_or_default(),
        index: argument.get_index(),
        required: argument.is_required_set(),
        global: argument.is_global_set(),
        trailing: argument.is_trailing_var_arg_set(),
        action: match action {
            ArgAction::Set => "set",
            ArgAction::Append => "append",
            ArgAction::SetTrue => "setTrue",
            ArgAction::SetFalse => "setFalse",
            ArgAction::Count => "count",
            ArgAction::Help => "help",
            ArgAction::HelpShort => "helpShort",
            ArgAction::HelpLong => "helpLong",
            ArgAction::Version => "version",
            _ => "other",
        },
        min_values: range.map_or(0, |range| range.min_values()),
        max_values: range
            .and_then(|range| (range.max_values() != usize::MAX).then_some(range.max_values())),
        value_names: argument.get_value_names().map_or_else(Vec::new, |names| {
            names.iter().map(ToString::to_string).collect()
        }),
        possible_values: argument
            .get_possible_values()
            .iter()
            .map(|value| value.get_name().to_owned())
            .collect(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::{error::ErrorKind, Parser};

    fn find<'a>(schema: &'a CommandSchema, path: &[&str]) -> &'a CommandSchema {
        path.iter().fold(schema, |parent, name| {
            parent
                .subcommands
                .iter()
                .find(|child| child.name == *name)
                .expect("native command")
        })
    }

    #[test]
    fn nested_native_flags_come_from_the_parser_and_reject_a_fake_flag() {
        let schema = native_schema();
        let ladder = find(&schema, &["merge-captain", "ladder"]);
        assert!(ladder
            .arguments
            .iter()
            .any(|arg| arg.long.as_deref() == Some("report-out") && arg.min_values == 1));
        assert!(ladder
            .arguments
            .iter()
            .any(|arg| arg.long.as_deref() == Some("format")
                && arg.possible_values.contains(&"json".to_owned())));
        let mut command = Cli::command();
        let help = command
            .find_subcommand_mut("merge-captain")
            .expect("parent")
            .find_subcommand_mut("ladder")
            .expect("child")
            .render_long_help()
            .to_string();
        assert!(help.contains("--report-out"));
        let error = Cli::try_parse_from([
            "harn",
            "merge-captain",
            "ladder",
            "manifest.toml",
            "--not-a-harn-flag",
        ])
        .expect_err("fake flag must fail");
        assert_eq!(error.kind(), ErrorKind::UnknownArgument);
    }

    #[test]
    fn surface_lists_every_spelling_a_caller_can_type() {
        let listing = surface_listing(&native_schema());
        let lines: Vec<&str> = listing.lines().collect();
        for expected in [
            "harn merge-captain ladder",
            "harn merge-captain ladder --report-out",
            "harn merge-captain ladder --format",
            "harn run",
            "harn run <positional 1>",
        ] {
            assert!(lines.contains(&expected), "missing `{expected}`");
        }
        assert!(!lines.iter().any(|line| line.ends_with(" --help")));
        assert!(!lines.iter().any(|line| line.starts_with("harn help")));
        let body: Vec<&str> = lines
            .iter()
            .copied()
            .filter(|line| !line.starts_with('#'))
            .collect();
        let mut sorted = body.clone();
        sorted.sort_unstable();
        sorted.dedup();
        assert_eq!(body, sorted, "listing must be sorted and unique");
    }

    #[test]
    fn schema_is_a_versioned_json_envelope() {
        let value =
            serde_json::to_value(JsonEnvelope::ok(SCHEMA_VERSION, native_schema())).unwrap();
        assert_eq!(value["schemaVersion"], 1);
        assert_eq!(value["ok"], true);
        assert!(value["data"]["subcommands"].as_array().unwrap().len() > 20);
    }
}