nils-markdown 1.0.7

Shared Tera-backed Markdown template layer for the nils-cli workspace.
Documentation
mod cli;

use std::io::{self, Write};
use std::path::Path;

use clap::{CommandFactory, Parser};
use clap_complete::{Shell, generate};
use nils_common::cli_contract::{
    Envelope, EnvelopeError, OutputFormat, emit_parse_error, exit, schema_version_for,
};
use nils_markdown::Engine;
use serde::Serialize;

use crate::cli::{Cli, Command, CompletionShell, RenderArgs};

const BINARY: &str = "md-render";
const RENDER_OPERATION: &str = "render";

#[derive(Debug, Serialize)]
struct RenderPayload {
    template: String,
    body: String,
}

fn main() {
    let cli = parse_or_exit();
    let format = cli.format.unwrap_or_default();
    let exit_code = match cli.command {
        Some(Command::Completion { shell }) => run_completion(shell),
        Some(Command::Render(args)) => run_render(args, format),
        None => run_render(cli.default_render, format),
    };
    std::process::exit(exit_code);
}

fn parse_or_exit() -> Cli {
    match Cli::try_parse() {
        Ok(cli) => cli,
        Err(err) => {
            use clap::error::ErrorKind;
            let kind = err.kind();
            if matches!(
                kind,
                ErrorKind::DisplayHelp
                    | ErrorKind::DisplayVersion
                    | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
            ) {
                err.exit();
            }
            let format = detect_format_from_argv();
            let code = match kind {
                ErrorKind::InvalidSubcommand => "unknown-subcommand",
                _ => "parse-error",
            };
            let message = err.to_string();
            let exit_code = emit_parse_error(BINARY, format, code, &message);
            std::process::exit(exit_code);
        }
    }
}

fn detect_format_from_argv() -> OutputFormat {
    let mut args = std::env::args().skip(1);
    while let Some(arg) = args.next() {
        if arg == "--format"
            && let Some(value) = args.next()
            && value.eq_ignore_ascii_case("json")
        {
            return OutputFormat::Json;
        } else if let Some(value) = arg.strip_prefix("--format=")
            && value.eq_ignore_ascii_case("json")
        {
            return OutputFormat::Json;
        }
    }
    OutputFormat::Text
}

fn run_render(args: RenderArgs, format: OutputFormat) -> i32 {
    let Some(template_path) = args.template else {
        return emit_render_error(format, "missing-argument", "--template is required");
    };
    let Some(data_path) = args.data else {
        return emit_render_error(format, "missing-argument", "--data is required");
    };

    let template_name = match template_stem(&template_path) {
        Some(name) => name,
        None => {
            return emit_render_error(
                format,
                "invalid-template-path",
                &format!(
                    "could not derive a template name from `{}`",
                    template_path.display()
                ),
            );
        }
    };

    let template_body = match std::fs::read_to_string(&template_path) {
        Ok(body) => body,
        Err(err) => {
            return emit_render_error(
                format,
                "template-read-failed",
                &format!("failed to read `{}`: {err}", template_path.display()),
            );
        }
    };

    let data_body = match std::fs::read_to_string(&data_path) {
        Ok(body) => body,
        Err(err) => {
            return emit_render_error(
                format,
                "data-read-failed",
                &format!("failed to read `{}`: {err}", data_path.display()),
            );
        }
    };

    let data: serde_json::Value = match serde_json::from_str(&data_body) {
        Ok(value) => value,
        Err(err) => {
            return emit_render_error(
                format,
                "data-parse-failed",
                &format!("failed to parse `{}`: {err}", data_path.display()),
            );
        }
    };

    let mut engine = Engine::builder().build();
    if let Err(err) = engine.register_template(&template_name, &template_body) {
        return emit_render_error(format, "template-register-failed", &err.to_string());
    }
    let rendered = match engine.render_value(&template_name, &data) {
        Ok(text) => text,
        Err(err) => {
            return emit_render_error(format, "template-render-failed", &err.to_string());
        }
    };

    match format {
        OutputFormat::Text => {
            if let Err(err) = io::stdout().write_all(rendered.as_bytes()) {
                return emit_render_error(
                    OutputFormat::Text,
                    "write-stdout-failed",
                    &err.to_string(),
                );
            }
            exit::SUCCESS
        }
        OutputFormat::Json => {
            let payload = RenderPayload {
                template: template_name,
                body: rendered,
            };
            let envelope =
                Envelope::success(schema_version_for(BINARY, RENDER_OPERATION, 1), payload);
            match serde_json::to_string(&envelope) {
                Ok(serialized) => {
                    println!("{serialized}");
                    exit::SUCCESS
                }
                Err(_) => exit::SOFTWARE,
            }
        }
    }
}

fn emit_render_error(format: OutputFormat, code: &str, message: &str) -> i32 {
    match format {
        OutputFormat::Text => {
            eprintln!("error: {message}");
            exit::USAGE
        }
        OutputFormat::Json => {
            let envelope = Envelope::<()>::failure(
                schema_version_for(BINARY, RENDER_OPERATION, 1),
                EnvelopeError::new(code, message),
            );
            match serde_json::to_string(&envelope) {
                Ok(serialized) => {
                    println!("{serialized}");
                    exit::USAGE
                }
                Err(_) => exit::SOFTWARE,
            }
        }
    }
}

fn template_stem(path: &Path) -> Option<String> {
    let name = path.file_name()?.to_str()?;
    let stem = name
        .strip_suffix(".md.tera")
        .or_else(|| name.strip_suffix(".tera"))
        .unwrap_or_else(|| {
            Path::new(name)
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or(name)
        });
    if stem.is_empty() {
        None
    } else {
        Some(stem.to_string())
    }
}

fn run_completion(shell: CompletionShell) -> i32 {
    let mut command = Cli::command();
    let shell = match shell {
        CompletionShell::Bash => Shell::Bash,
        CompletionShell::Zsh => Shell::Zsh,
    };
    let mut buffer = Vec::new();
    generate(shell, &mut command, BINARY, &mut buffer);
    let rendered = match shell {
        Shell::Bash => {
            let raw = String::from_utf8(buffer).expect("bash completion is valid UTF-8");
            raw.replace("__subcmd__", "__")
                .replace("complete -F _", "complete -o nospace -F _")
                .into_bytes()
        }
        _ => buffer,
    };
    if let Err(err) = io::stdout().write_all(&rendered) {
        eprintln!("error: failed to write completion: {err}");
        return exit::SOFTWARE;
    }
    exit::SUCCESS
}