katana-document-viewer 0.1.4

KatanA document viewer artifact, render evaluation, and export foundation.
Documentation
use katana_document_viewer::{
    BuildProfile, BuildRequest, DiagramRenderingBackend, DocumentSnapshotFactory, DocumentSource,
    EvaluationCoverageMatrix, ExportFormat, ExportOutput, ExportRequest, ForgePipeline,
    KdvThemeSnapshot, KrrDiagramRenderEngine, SourceKind, SourceRevision, SourceUri,
};
use katana_markdown_model::{KatanaMarkdownModel, MarkdownInput};
use serde::Serialize;
use std::error::Error;
use std::fs;
use std::path::Path;

#[path = "export_debug/args.rs"]
mod args;
use args::{CommandArgs, CommandArgsParser, EXPORT_FORMATS};

#[derive(Debug, Serialize)]
struct ExportDebugSummary {
    input_path: String,
    output_dir: String,
    document_id: String,
    node_count: usize,
    missing_implementation_count: usize,
    external_backend_required_count: usize,
    export_files: Vec<ExportDebugFile>,
}

#[derive(Debug, Serialize)]
struct ExportDebugFile {
    format: String,
    artifact_file: String,
    artifact_id: String,
    backend: String,
    byte_len: u64,
}

struct ExportDebugCommand;

impl ExportDebugCommand {
    fn run(args: CommandArgs) -> Result<(), Box<dyn Error>> {
        let content = fs::read_to_string(&args.input_path)?;
        let content = content.replace("\r\n", "\n").replace('\r', "\n");
        let source = Self::source(&args.input_path, &content);
        let document = KatanaMarkdownModel::parse(MarkdownInput::from_content(
            args.input_path.display().to_string(),
            content,
        ))?;
        let snapshot = DocumentSnapshotFactory::from_kmm(source, document);
        let request = BuildRequest {
            snapshot,
            profile: BuildProfile::markdown_export(),
            theme: args.theme.clone(),
        };
        let pipeline = ForgePipeline::new(DiagramRenderingBackend::new(KrrDiagramRenderEngine));
        let graph = pipeline.build(&request)?;
        let coverage = EvaluationCoverageMatrix::v0_1();
        prepare_output_dir(&args.output_dir)?;
        write_toml(&args.output_dir.join("build-graph.toml"), &graph)?;
        write_toml(&args.output_dir.join("coverage-matrix.toml"), &coverage)?;
        let export_files = Self::write_exports(&args.output_dir, &pipeline, &graph, &args.theme)?;
        let summary = ExportDebugSummary {
            input_path: args.input_path.display().to_string(),
            output_dir: args.output_dir.display().to_string(),
            document_id: graph.snapshot.id.0.clone(),
            node_count: graph.snapshot.document.nodes.len(),
            missing_implementation_count: coverage
                .status_count(katana_document_viewer::CoverageStatus::MissingImplementation),
            external_backend_required_count: coverage
                .status_count(katana_document_viewer::CoverageStatus::ExternalBackendRequired),
            export_files,
        };
        write_toml(&args.output_dir.join("summary.toml"), &summary)?;
        Ok(())
    }

    fn source(input_path: &Path, content: &str) -> DocumentSource {
        DocumentSource {
            uri: SourceUri(format!("file://{}", input_path.display())),
            kind: SourceKind::Markdown,
            revision: SourceRevision("debug".to_string()),
            content: content.to_string(),
        }
    }

    fn write_exports(
        output_dir: &Path,
        pipeline: &ForgePipeline<DiagramRenderingBackend<KrrDiagramRenderEngine>>,
        graph: &katana_document_viewer::BuildGraph,
        theme: &KdvThemeSnapshot,
    ) -> Result<Vec<ExportDebugFile>, Box<dyn Error>> {
        let mut files = Vec::new();
        for format in EXPORT_FORMATS {
            let output = pipeline.export(&ExportRequest {
                graph: graph.clone(),
                format,
                theme: theme.clone(),
            })?;
            files.push(write_export(output_dir, format, &output)?);
        }
        Ok(files)
    }
}

fn write_export(
    output_dir: &Path,
    format: ExportFormat,
    output: &ExportOutput,
) -> Result<ExportDebugFile, Box<dyn Error>> {
    let label = format_label(format);
    let artifact_path = export_artifact_path(output_dir, format);
    fs::write(&artifact_path, &output.artifact.bytes.bytes)?;
    let byte_len = fs::metadata(&artifact_path)?.len();
    if byte_len == 0 {
        return Err(invalid_input("export artifact file is empty").into());
    }
    Ok(ExportDebugFile {
        format: label.to_string(),
        artifact_file: artifact_path.display().to_string(),
        artifact_id: output.artifact.manifest.id.0.clone(),
        backend: output.artifact.manifest.backend.clone(),
        byte_len,
    })
}

fn export_artifact_path(output_dir: &Path, format: ExportFormat) -> std::path::PathBuf {
    output_dir
        .join("exports")
        .join(format!("sample.ja.{}", format_label(format)))
}

fn format_label(format: ExportFormat) -> &'static str {
    match format {
        ExportFormat::Html => "html",
        ExportFormat::Pdf => "pdf",
        ExportFormat::Png => "png",
        ExportFormat::Jpeg => "jpg",
    }
}

fn write_toml<T: Serialize>(path: &Path, value: &T) -> Result<(), Box<dyn Error>> {
    fs::write(path, toml::to_string_pretty(value)?)?;
    Ok(())
}

fn invalid_input(message: &'static str) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
}

fn prepare_output_dir(output_dir: &Path) -> Result<(), Box<dyn Error>> {
    if output_dir.exists() {
        ensure_empty_dir(output_dir)?;
    } else {
        fs::create_dir_all(output_dir)?;
    }
    fs::create_dir_all(output_dir.join("exports"))?;
    Ok(())
}

fn ensure_empty_dir(output_dir: &Path) -> Result<(), Box<dyn Error>> {
    let mut entries = fs::read_dir(output_dir)?;
    if entries.next().transpose()?.is_some() {
        return Err(invalid_input("output directory must be empty").into());
    }
    Ok(())
}

fn main() -> Result<(), Box<dyn Error>> {
    ExportDebugCommand::run(CommandArgsParser::parse()?)
}