docspec-cli 1.7.1

Command-line interface for DocSpec document conversion
use std::path::PathBuf;

use clap::{Parser, Subcommand, ValueEnum};

/// `DocSpec`: streaming document conversion.
#[derive(Parser, Debug)]
#[command(
    name = "docspec",
    version,
    about = "Streaming document conversion CLI",
    subcommand_required = true,
    arg_required_else_help = true
)]
pub struct Cli {
    /// Selected top-level subcommand.
    #[command(subcommand)]
    pub command: Commands,
}

/// Top-level subcommands for `docspec`.
#[derive(Subcommand, Debug)]
#[non_exhaustive]
pub enum Commands {
    /// Convert documents between formats.
    Convert(ConvertArgs),
    /// Run the HTTP API server.
    #[cfg(feature = "http")]
    Http(HttpArgs),
}

/// Arguments for the `convert` subcommand.
#[derive(clap::Args, Debug)]
#[command(
    about = "Convert documents between formats using streaming event pipeline",
    long_about = "Convert documents between formats using streaming event pipeline.\n\nSupports converting Markdown, HTML, or DOCX input to BlockNote JSON, HTML, oxa.dev JSON, or Pandoc native output.\n\nNote: HTML and DOCX input currently preserve only paragraph text. Other HTML input\nelements and non-paragraph output events (headings, lists, tables, formatting, etc.)\nare silently dropped. DOCX input preserves only paragraphs and text; styles, tables,\nlists, images, headers/footers, and tracked changes are silently dropped. Use BlockNote\nJSON output for fuller feature coverage."
)]
pub struct ConvertArgs {
    /// When to use colors.
    #[arg(long, value_name = "WHEN", default_value = "auto")]
    pub color: ColorChoice,

    /// Input format (auto-detected from extension if omitted).
    #[arg(short, long)]
    pub from: Option<CliInputFormat>,

    /// Input file (use `-` or omit for stdin).
    #[arg(value_name = "FILE")]
    pub input: Option<PathBuf>,

    /// Output file (stdout if omitted).
    #[arg(short, long, value_name = "FILE")]
    pub output: Option<PathBuf>,

    /// Output format (auto-detected from extension if omitted).
    #[arg(short, long)]
    pub to: Option<CliOutputFormat>,
}

/// Arguments for the `http` subcommand.
#[cfg(feature = "http")]
#[derive(clap::Args, Debug, Clone)]
#[non_exhaustive]
pub struct HttpArgs {
    /// Host to bind to.
    #[arg(long, default_value = "127.0.0.1")]
    pub host: String,
    /// Port to bind to.
    #[arg(long, default_value_t = 3000)]
    pub port: u16,
}

/// Color output choice.
#[derive(Clone, Copy, Debug, ValueEnum)]
#[non_exhaustive]
pub enum ColorChoice {
    /// Always use colors.
    #[value(name = "always")]
    Always,

    /// Automatically detect color support.
    #[value(name = "auto")]
    Auto,

    /// Never use colors.
    #[value(name = "never")]
    Never,
}

/// Input format for document conversion.
#[derive(Clone, Copy, Debug, ValueEnum)]
#[non_exhaustive]
pub enum CliInputFormat {
    /// DOCX format (paragraphs and text only).
    #[value(name = "docx")]
    Docx,
    /// HTML format (paragraph-only; `<p>` elements and text within them only).
    #[value(name = "html")]
    Html,
    /// Markdown format.
    #[value(name = "markdown")]
    Markdown,
}

/// Output format for document conversion.
#[derive(Clone, Copy, Debug, ValueEnum)]
#[non_exhaustive]
pub enum CliOutputFormat {
    /// `BlockNote` JSON format.
    #[value(name = "blocknote")]
    Blocknote,
    /// HTML5 format.
    #[value(name = "html")]
    Html,
    /// `oxa.dev` JSON format.
    #[value(name = "oxa")]
    Oxa,
    /// Pandoc native block-list syntax.
    #[value(name = "pandoc-native")]
    PandocNative,
}

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

    #[test]
    fn clap_rejects_blocknote_as_input() {
        let result = Cli::try_parse_from(["docspec", "convert", "--from", "blocknote", "x.md"]);
        assert!(
            result.is_err(),
            "blocknote should not be a valid input format"
        );
    }

    #[test]
    fn clap_rejects_markdown_as_output() {
        let result = Cli::try_parse_from(["docspec", "convert", "--to", "markdown", "x.md"]);
        assert!(
            result.is_err(),
            "markdown should not be a valid output format"
        );
    }

    #[test]
    fn clap_accepts_html_as_output_format() {
        let result = Cli::try_parse_from(["docspec", "convert", "--to", "html", "x.md"]);
        assert!(
            result.is_ok(),
            "html should be a valid output format, got error: {:?}",
            result.as_ref().err()
        );
        let cli = result.unwrap_or_else(|_| std::process::abort());
        let args = match cli.command {
            Commands::Convert(args) => args,
            #[cfg(feature = "http")]
            Commands::Http(_) => std::process::abort(),
        };
        assert!(
            matches!(args.to, Some(CliOutputFormat::Html)),
            "expected CliOutputFormat::Html, got {:?}",
            args.to
        );
    }

    #[test]
    fn clap_accepts_oxa_as_output_format() {
        let result = Cli::try_parse_from(["docspec", "convert", "--to", "oxa", "x.md"]);
        assert!(
            result.is_ok(),
            "oxa should be a valid output format, got error: {:?}",
            result.as_ref().err()
        );
        let cli = result.unwrap_or_else(|_| std::process::abort());
        let args = match cli.command {
            Commands::Convert(args) => args,
            #[cfg(feature = "http")]
            Commands::Http(_) => std::process::abort(),
        };
        assert!(
            matches!(args.to, Some(CliOutputFormat::Oxa)),
            "expected CliOutputFormat::Oxa, got {:?}",
            args.to
        );
    }

    #[test]
    fn clap_accepts_pandoc_native_as_output_format() {
        let result = Cli::try_parse_from(["docspec", "convert", "--to", "pandoc-native", "x.md"]);
        assert!(
            result.is_ok(),
            "pandoc-native should be a valid output format, got error: {:?}",
            result.as_ref().err()
        );
        let cli = result.unwrap_or_else(|_| std::process::abort());
        let args = match cli.command {
            Commands::Convert(args) => args,
            #[cfg(feature = "http")]
            Commands::Http(_) => std::process::abort(),
        };
        assert!(
            matches!(args.to, Some(CliOutputFormat::PandocNative)),
            "expected CliOutputFormat::PandocNative, got {:?}",
            args.to
        );
    }
}