schemaui-cli 0.7.2

CLI wrapper for schemaui, rendering JSON Schemas as TUIs
Documentation
use std::path::PathBuf;

#[cfg(feature = "web")]
use std::net::IpAddr;

#[cfg(feature = "completion")]
use clap::ValueEnum;
use clap::{ArgAction, Args, CommandFactory, Parser, Subcommand, value_parser};

#[derive(Parser, Debug, Clone, Default, PartialEq, Eq)]
#[command(
    name = "schemaui",
    about = "Render JSON Schemas as interactive TUIs or Web UIs",
    version,
    propagate_version = true,
    disable_help_subcommand = true,
    subcommand_precedence_over_arg = true
)]
pub struct Cli {
    #[command(flatten)]
    pub common: CommonArgs,
    #[command(subcommand)]
    pub command: Option<Commands>,
}

#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
pub enum Commands {
    #[cfg(feature = "completion")]
    #[command(about = "Generate shell completion scripts for the schemaui CLI")]
    Completion(CompletionCommand),
    #[cfg(feature = "tui")]
    #[command(about = "Launch the interactive terminal UI")]
    Tui(TuiCommand),
    #[cfg(feature = "web")]
    #[command(about = "Launch the interactive web UI instead of the terminal UI")]
    Web(WebCommand),
    #[cfg(feature = "web")]
    #[command(about = "Precompute Web session snapshots instead of launching the UI")]
    WebSnapshot(WebSnapshotCommand),
    #[cfg(feature = "tui")]
    #[command(
        about = "Precompute TUI FormSchema/LayoutNavModel modules instead of launching the UI"
    )]
    TuiSnapshot(TuiSnapshotCommand),
}

#[cfg(feature = "tui")]
#[derive(Args, Debug, Clone, Default, PartialEq, Eq)]
pub struct TuiCommand {
    #[command(flatten)]
    pub common: CommonArgs,
}

#[cfg(feature = "completion")]
#[derive(Args, Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompletionCommand {
    #[arg(help = "target shell: bash, zsh, fish, or powershell")]
    pub shell: CompletionShell,
}

#[cfg(feature = "completion")]
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompletionShell {
    #[value(name = "bash")]
    Bash,
    #[value(name = "zsh")]
    Zsh,
    #[value(name = "fish")]
    Fish,
    #[value(name = "powershell")]
    PowerShell,
}

#[cfg(feature = "web")]
#[derive(Args, Debug, Clone, PartialEq, Eq)]
pub struct WebCommand {
    #[command(flatten)]
    pub common: CommonArgs,
    #[arg(
        short = 'l',
        long = "host",
        visible_aliases = ["bind", "listen"],
        value_name = "IP",
        help = "bind address for the temporary HTTP server",
        value_parser = value_parser!(IpAddr),
        default_value = "127.0.0.1"
    )]
    pub host: IpAddr,
    #[arg(
        short = 'p',
        long = "port",
        value_name = "PORT",
        help = "bind port for the temporary HTTP server (0 picks a random free port)",
        value_parser = value_parser!(u16),
        default_value_t = 0
    )]
    pub port: u16,
}

#[cfg(feature = "web")]
#[derive(Args, Debug, Clone, PartialEq, Eq)]
pub struct WebSnapshotCommand {
    #[command(flatten)]
    pub common: CommonArgs,
    #[arg(
        long = "out-dir",
        value_name = "DIR",
        help = "output directory for generated Web snapshots (JSON + TS)",
        value_parser = value_parser!(PathBuf),
        default_value = "web_snapshots"
    )]
    pub out_dir: PathBuf,
    #[arg(
        long = "ts-export",
        value_name = "NAME",
        help = "name of the exported constant in the generated TS module",
        default_value = "SessionSnapshot"
    )]
    pub ts_export: String,
}

#[cfg(feature = "tui")]
#[derive(Args, Debug, Clone, PartialEq, Eq)]
pub struct TuiSnapshotCommand {
    #[command(flatten)]
    pub common: CommonArgs,
    #[arg(
        long = "out-dir",
        value_name = "DIR",
        help = "output directory for generated TUI artifact modules (Rust source)",
        value_parser = value_parser!(PathBuf),
        default_value = "tui_artifacts"
    )]
    pub out_dir: PathBuf,
    #[arg(
        long = "tui-fn",
        value_name = "NAME",
        help = "name of the generated TuiArtifacts constructor function",
        default_value = "tui_artifacts"
    )]
    pub tui_fn: String,
    #[arg(
        long = "form-fn",
        value_name = "NAME",
        help = "name of the generated FormSchema constructor function",
        default_value = "tui_form_schema"
    )]
    pub form_fn: String,
    #[arg(
        long = "layout-fn",
        value_name = "NAME",
        help = "name of the generated LayoutNavModel constructor function",
        default_value = "tui_layout_nav"
    )]
    pub layout_fn: String,
}

#[derive(Args, Debug, Clone, Default, PartialEq, Eq)]
pub struct CommonArgs {
    #[arg(
        short = 's',
        long = "schema",
        help = "schema spec: local path, file/HTTP URL, inline payload, or \"-\" for stdin",
        allow_hyphen_values = true
    )]
    pub schema: Option<String>,
    #[arg(
        short = 'c',
        long = "config",
        visible_alias = "data",
        help = "config spec: local path, file/HTTP URL, inline payload, or \"-\" for stdin",
        allow_hyphen_values = true
    )]
    pub config: Option<String>,
    #[arg(
        long = "title",
        help = "title shown at the top of the UI",
        allow_hyphen_values = true
    )]
    pub title: Option<String>,
    #[arg(
        long = "description",
        help = "description shown under the title in the active UI",
        allow_hyphen_values = true
    )]
    pub description: Option<String>,
    #[arg(
        short = 'o',
        long = "output",
        value_name = "DEST",
        help = "output destinations (\"-\" writes to stdout). Repeat the flag to add more",
        action = ArgAction::Append,
        num_args = 1..,
        allow_hyphen_values = true
    )]
    pub outputs: Vec<String>,
    #[arg(
        long = "temp-file",
        value_name = "PATH",
        help = "write to PATH when no destinations are set (stdout remains the default)",
        value_parser = value_parser!(PathBuf)
    )]
    pub temp_file: Option<PathBuf>,
    #[arg(
        long = "no-temp-file",
        help = "compatibility no-op: stdout is already the default when no destinations are set",
        action = ArgAction::SetTrue
    )]
    pub no_temp_file: bool,
    #[arg(
        long = "no-pretty",
        help = "emit compact JSON/TOML rather than pretty formatting",
        action = ArgAction::SetTrue
    )]
    pub no_pretty: bool,
    #[arg(
        short = 'f',
        long = "force",
        visible_short_alias = 'y',
        visible_alias = "yes",
        help = "overwrite output files even if they already exist",
        action = ArgAction::SetTrue
    )]
    pub force: bool,
}

impl CommonArgs {
    pub fn merged_with(&self, local: &Self) -> Self {
        let mut outputs = self.outputs.clone();
        outputs.extend(local.outputs.clone());

        Self {
            schema: local.schema.clone().or_else(|| self.schema.clone()),
            config: local.config.clone().or_else(|| self.config.clone()),
            title: local.title.clone().or_else(|| self.title.clone()),
            description: local
                .description
                .clone()
                .or_else(|| self.description.clone()),
            outputs,
            temp_file: local.temp_file.clone().or_else(|| self.temp_file.clone()),
            no_temp_file: self.no_temp_file || local.no_temp_file,
            no_pretty: self.no_pretty || local.no_pretty,
            force: self.force || local.force,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliParseExit {
    pub output: String,
    pub status: Result<(), ()>,
}

impl CliParseExit {
    fn success(output: String) -> Self {
        Self {
            output,
            status: Ok(()),
        }
    }

    fn error(output: String) -> Self {
        Self {
            output,
            status: Err(()),
        }
    }
}

impl Cli {
    pub fn parse() -> Self {
        Self::from_env_or_exit()
    }

    pub fn from_env_or_exit() -> Self {
        match Self::try_parse_from(std::env::args()) {
            Ok(cli) => cli,
            Err(exit) => {
                if exit.status.is_ok() {
                    print!("{}", exit.output);
                    std::process::exit(0);
                }
                eprint!("{}", exit.output);
                std::process::exit(1);
            }
        }
    }

    pub fn parse_from<I, T>(args: I) -> Self
    where
        I: IntoIterator<Item = T>,
        T: Into<String>,
    {
        Self::try_parse_from(args).unwrap_or_else(|exit| {
            panic!("failed to parse args: {}", exit.output);
        })
    }

    pub fn try_parse_from<I, T>(args: I) -> Result<Self, CliParseExit>
    where
        I: IntoIterator<Item = T>,
        T: Into<String>,
    {
        let argv = args.into_iter().map(Into::into).collect::<Vec<_>>();
        <Self as Parser>::try_parse_from(argv).map_err(clap_error_to_exit)
    }
}

pub fn command_info() -> clap::Command {
    <Cli as CommandFactory>::command()
}

fn clap_error_to_exit(err: clap::Error) -> CliParseExit {
    let output = err.to_string();
    match err.kind() {
        clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
            CliParseExit::success(output)
        }
        _ => CliParseExit::error(output),
    }
}