aion-cli 0.31.0

The `aion` command line: operate Aion durable workflows over gRPC and run the Aion server.
//! `aion awl doc` — a workflow's own comments, rendered.
//!
//! One model, four renderings. The `--json` form's bytes ARE the model's
//! canonical serialisation, so the JSON contract a documentation site reads
//! is the model itself and never a second shape assembled here; HTML,
//! Markdown and the plain step list are renderings OF that model, all from
//! `aion_awl::doc`.
//!
//! Two inputs, one answer. A `.awl` file is read with the schema files it
//! imports resolved against its own directory; a `.aion` archive is read
//! with the schema files the archive carries, and nothing else — the point
//! of the archive path is that a published package documents itself with no
//! source tree present.
//!
//! Nothing here spawns a process, opens a network connection, or reads a
//! layout library. `aion` is one embeddable binary and the SERVER renders
//! this same model, so a renderer that shells out to `dot` would be a
//! renderer the server cannot run.

use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use aion_awl::doc::{DocumentDoc, SchemaFiles};
use aion_awl::{Document, TypeBody};
use clap::ValueEnum;

/// Which rendering `aion awl doc` writes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(crate) enum DocFormat {
    /// A self-contained HTML page: prose, declarations, types, the contract,
    /// and an inline SVG of the step graph.
    Html,
    /// The model's canonical JSON — the documentation site's input contract.
    Json,
    /// Markdown, for a README or a wiki.
    Markdown,
    /// The plain step view: the step graph as a numbered list in reading
    /// order, for a reader who is asking what happens rather than what the
    /// shape is.
    Plain,
}

/// The extension that marks a packaged archive rather than a document.
const ARCHIVE_EXTENSION: &str = "aion";

/// Render the documentation for one `.awl` document or `.aion` archive.
pub(crate) fn run(file: &Path, format: DocFormat, output: Option<&Path>) -> ExitCode {
    let model = match load(file) {
        Ok(model) => model,
        Err(message) => {
            eprintln!("{}: error: {message}", file.display());
            return ExitCode::FAILURE;
        }
    };
    let rendered = render(&model, format);
    let rendered = match rendered {
        Ok(rendered) => rendered,
        Err(message) => {
            eprintln!("{}: error: {message}", file.display());
            return ExitCode::FAILURE;
        }
    };
    let Some(path) = output else {
        print!("{rendered}");
        return ExitCode::SUCCESS;
    };
    match fs::write(path, rendered.as_bytes()) {
        Ok(()) => {
            println!("ok: {} -> {}", file.display(), path.display());
            ExitCode::SUCCESS
        }
        Err(error) => {
            eprintln!("{}: error: {error}", path.display());
            ExitCode::FAILURE
        }
    }
}

/// The bytes one format writes.
///
/// # Errors
///
/// Returns the serialisation failure for `--json`; the other three formats
/// cannot fail once the model exists.
pub(crate) fn render(model: &DocumentDoc, format: DocFormat) -> Result<String, String> {
    Ok(match format {
        DocFormat::Html => aion_awl::doc::html::render(model),
        DocFormat::Json => json(model)?,
        DocFormat::Markdown => aion_awl::doc::markdown::render(model),
        DocFormat::Plain => plain(model),
    })
}

/// The model's canonical JSON, ending in a newline.
///
/// Pretty-printed and newline-terminated so a golden diff reads as prose and
/// so the server's response and this command's output are comparable byte
/// for byte — which is what the agreement gate asserts.
///
/// # Errors
///
/// Returns a message when the model does not serialise.
pub(crate) fn json(model: &DocumentDoc) -> Result<String, String> {
    let mut rendered = serde_json::to_string_pretty(model)
        .map_err(|error| format!("the documentation model could not serialise: {error}"))?;
    rendered.push('\n');
    Ok(rendered)
}

/// The plain step view as text: one numbered line per step, indented by how
/// deeply it nests, with the author's sentence where they wrote one.
fn plain(model: &DocumentDoc) -> String {
    use std::fmt::Write as _;
    let mut out = format!("{} {}\n", model.family, model.name);
    if let Some(prose) = &model.prose {
        if let Some(narration) = &prose.narration {
            let _ = writeln!(out, "\n{narration}");
        }
        out.push('\n');
        for step in aion_awl::doc::plain::steps(&prose.graph) {
            let indent = "  ".repeat(step.depth);
            let _ = writeln!(out, "{indent}{}. {}", step.number, step.name);
            if let Some(sentence) = &step.documentation {
                let _ = writeln!(out, "{indent}   {sentence}");
            }
            for inside in &step.contains {
                let _ = writeln!(out, "{indent}   it {inside}");
            }
            for lead in &step.leads_to {
                let _ = writeln!(out, "{indent}   -> {lead}");
            }
        }
    } else {
        out.push_str("\nno authored document is recorded for this revision\n");
    }
    out
}

/// Read the model from either input form.
fn load(file: &Path) -> Result<DocumentDoc, String> {
    if file.extension().is_some_and(|ext| ext == ARCHIVE_EXTENSION) {
        return from_archive(file);
    }
    from_document(file)
}

/// A workspace `.awl` document, with its imports read from its own directory.
fn from_document(file: &Path) -> Result<DocumentDoc, String> {
    let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
    let parsed = aion_awl::parse(&source).map_err(|error| error.message.clone())?;
    let root = file.parent().unwrap_or_else(|| Path::new("."));
    let mut schemas: SchemaFiles = BTreeMap::new();
    for path in imported_schema_paths(&parsed) {
        let full: PathBuf = root.join(&path);
        let text =
            fs::read_to_string(&full).map_err(|error| format!("{}: {error}", full.display()))?;
        schemas.insert(path, text);
    }
    aion_awl::doc::derive(&source, &schemas).map_err(|error| error.to_string())
}

/// A published `.aion` archive, with no source tree present.
fn from_archive(file: &Path) -> Result<DocumentDoc, String> {
    // Unbounded extraction: the caller named this archive on their own
    // command line, so there is no untrusted-upload bound to apply here that
    // would not simply refuse an archive they own.
    let package =
        aion_package::Package::load_from_path(file, aion_package::ExtractionLimits::unbounded())
            .map_err(|error| error.to_string())?;
    // The archive's own PRIMARY entry: a caller naming an archive on the
    // command line has named one package, and the entry it was built around
    // is the one it documents. The server names an entry explicitly, because
    // an HTTP caller asked about one.
    let workflow_type = package.manifest().entry_module.clone();
    aion_awl::doc::from_package(&package, &workflow_type).map_err(|error| error.to_string())
}

/// The document-relative paths of every `schema("path")` import.
///
/// Read from the parse rather than from a directory scan: a document imports
/// exactly what it names, and reading the directory would document files the
/// document never mentions.
fn imported_schema_paths(document: &Document) -> Vec<String> {
    document
        .types
        .iter()
        .filter_map(|declaration| match &declaration.body {
            TypeBody::SchemaImport { path, .. } => Some(path.clone()),
            _ => None,
        })
        .collect()
}

#[cfg(test)]
#[path = "awl_doc_tests.rs"]
mod awl_doc_tests;