aion-cli 0.30.0

The `aion` command line: operate Aion durable workflows over gRPC and run the Aion server.
//! `aion awl doc`: the archive documents itself, and the JSON is the model.

use std::collections::BTreeMap;
use std::fs;

use aion_awl::doc::{DocumentDoc, SourceState};
use aion_package::{ExtractionLimits, Package};

use super::{DocFormat, json, render};

/// A document with prose at every newly admitted site, so the archive path
/// is measured against a model that actually carries prose to lose.
const DOCUMENTED: &str = "\
//! Grading a batch.

workflow grade
  /// The essay under review.
  input essay: String
  /// How far along the marking is.
  query progress: String
  /// The mark the grader settled on.
  outcome graded: type String, route success

/// A grade.
type Grade =
  /// Nothing to fix.
  | Pass
  | Fail

worker marker
  /// Read the essay.
  action mark(
    /// The text to read.
    essay: String,
  ) -> Grade

/// Read it once.
step mark
  mark(essay: essay) -> grade
  answer progress(\"done\")
  outcome graded: else, route graded(\"done\")
";

/// The ROOT fields the archive path answers differently from the source path,
/// each with the reason. The enumeration lives HERE so a field that later
/// drops silently out of the archive path is RED rather than unnoticed.
///
/// - `content_hash`: a workspace document has no identity until it is
///   packaged, so the source path has nothing to report.
/// - `contract`: the archive reports what the version hash COMMITS TO, read
///   from `contract.json`; the source path derives what would be committed.
///   The two are compared FIELD BY FIELD below rather than exempted whole —
///   a blanket exemption here is what hid `query_schema` going permanently
///   absent on the archive path with no page saying so.
const ARCHIVE_ONLY_ROOT_FIELDS: &[&str] = &["content_hash", "contract"];

/// The CONTRACT fields the archive genuinely cannot supply, each with the
/// reason. Everything not named here must agree between the two paths.
///
/// - `query_schema`: a package's committed contract carries no query set, so a
///   query list read for a deployed revision would not be attributable to its
///   content hash. The model says so in `unattributable` and every renderer
///   prints "not reported for this revision" rather than nothing.
/// - `unattributable`: it IS the declaration of the line above, so the two
///   paths necessarily differ in it — that is the field working.
const ARCHIVE_UNSUPPLIABLE_CONTRACT_FIELDS: &[&str] = &["query_schema", "unattributable"];

fn source_model() -> anyhow::Result<DocumentDoc> {
    Ok(aion_awl::doc::derive(DOCUMENTED, &BTreeMap::new())?)
}

fn archive_model() -> anyhow::Result<DocumentDoc> {
    let temp = tempfile::tempdir()?;
    let prepared =
        aion_awl_package::compile_and_assemble_awl(DOCUMENTED, temp.path(), "grade.awl")?;
    let package = Package::load_from_bytes(prepared.archive, ExtractionLimits::unbounded())?;
    let entry = package.manifest().entry_module.clone();
    Ok(aion_awl::doc::from_package(&package, &entry)?)
}

#[test]
fn the_model_derives_from_a_published_archive_alone() -> anyhow::Result<()> {
    let model = archive_model()?;
    assert_eq!(model.source_state, SourceState::Available);
    let prose = model
        .prose
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("the archive carries the authored document"))?;
    assert_eq!(
        prose.narration.as_deref(),
        Some("Grading a batch."),
        "the archived document's narration reaches the model"
    );
    let input = prose
        .declarations
        .iter()
        .find(|declaration| declaration.kind == aion_awl::doc::DeclarationKind::Input)
        .ok_or_else(|| anyhow::anyhow!("the model names the document's input"))?;
    assert_eq!(
        input.documentation.as_deref(),
        Some("The essay under review.")
    );
    assert!(
        model.content_hash.is_some(),
        "a model derived for a revision names that revision"
    );
    Ok(())
}

#[test]
fn the_archive_and_the_source_agree_outside_the_enumerated_fields() -> anyhow::Result<()> {
    let from_source = serde_json::to_value(source_model()?)?;
    let from_archive = serde_json::to_value(archive_model()?)?;
    let (Some(source_object), Some(archive_object)) =
        (from_source.as_object(), from_archive.as_object())
    else {
        anyhow::bail!("the model serialises as a JSON object");
    };
    assert_eq!(
        source_object.keys().collect::<Vec<_>>(),
        archive_object.keys().collect::<Vec<_>>(),
        "both paths produce the same field set"
    );
    for key in source_object.keys() {
        if ARCHIVE_ONLY_ROOT_FIELDS.contains(&key.as_str()) {
            continue;
        }
        assert_eq!(
            source_object.get(key),
            archive_object.get(key),
            "`{key}` differs between the archive path and the source path, and is not one of the \
             enumerated fields the archive answers differently"
        );
    }
    // The enumerated fields are enumerated because they DIFFER, not to hide an
    // accidental equality: a field listed here that no longer differs is a
    // stale exemption.
    assert_ne!(
        source_object.get("content_hash"),
        archive_object.get("content_hash"),
        "`content_hash` is enumerated because the source path has none; if they now agree, the \
         exemption is stale"
    );
    Ok(())
}

#[test]
fn the_contract_exemption_is_one_named_field_and_not_a_blanket() -> anyhow::Result<()> {
    // The `contract` exemption above covers a whole object, and a whole-object
    // exemption is exactly how `query_schema` came to be permanently absent on
    // the archive path with nothing on any page saying so. So the object is
    // opened and compared key by key, and only the fields the archive
    // genuinely cannot supply are allowed to differ.
    let source = serde_json::to_value(source_model()?)?;
    let archive = serde_json::to_value(archive_model()?)?;
    let (Some(source_contract), Some(archive_contract)) = (
        source
            .get("contract")
            .and_then(serde_json::Value::as_object),
        archive
            .get("contract")
            .and_then(serde_json::Value::as_object),
    ) else {
        anyhow::bail!("the contract half serialises as a JSON object");
    };
    assert_eq!(
        source_contract.keys().collect::<Vec<_>>(),
        archive_contract.keys().collect::<Vec<_>>(),
        "both paths produce the same contract field set"
    );
    for key in source_contract.keys() {
        if ARCHIVE_UNSUPPLIABLE_CONTRACT_FIELDS.contains(&key.as_str()) {
            continue;
        }
        assert_eq!(
            source_contract.get(key),
            archive_contract.get(key),
            "contract field `{key}` differs between the archive path and the source path and is \
             not named as archive-unsuppliable"
        );
    }
    Ok(())
}

#[test]
fn a_deployed_revision_states_the_absence_of_its_query_schema() -> anyhow::Result<()> {
    // The fixture DECLARES a query, so a silent absence here would be a page
    // telling a reader the workflow has none. Proved on both arms: the archive
    // path names the field unreported with a reason, the source path reports
    // it.
    let archive = archive_model()?;
    assert!(
        !archive.contract.reports(aion_awl::doc::QUERY_SCHEMA_FIELD),
        "the archive path cannot supply a query schema and must say so"
    );
    let reason = archive
        .contract
        .why_unreported(aion_awl::doc::QUERY_SCHEMA_FIELD)
        .ok_or_else(|| anyhow::anyhow!("the absence must carry its reason"))?;
    assert!(
        reason.contains("content hash"),
        "the reason must say why it is not attributable: {reason}"
    );

    let source = source_model()?;
    assert!(
        source.contract.reports(aion_awl::doc::QUERY_SCHEMA_FIELD),
        "the source path supplies it, so the absence is the ARCHIVE's and not the model's"
    );
    assert!(
        source.contract.query_schema.is_some(),
        "the fixture declares a query, so the source path derives its schema"
    );

    // And every renderer says it rather than showing nothing.
    let page = render(&archive, DocFormat::Html).map_err(|error| anyhow::anyhow!(error))?;
    assert!(
        page.contains("data-unreported=\"query_schema\""),
        "the HTML page states it"
    );
    assert!(page.contains("Not reported for this revision"));
    let markdown = render(&archive, DocFormat::Markdown).map_err(|error| anyhow::anyhow!(error))?;
    assert!(
        markdown.contains("Not reported for this revision"),
        "the Markdown states it"
    );
    Ok(())
}

#[test]
fn the_json_form_is_the_models_own_serialisation() -> anyhow::Result<()> {
    let model = source_model()?;
    let rendered = render(&model, DocFormat::Json).map_err(|error| anyhow::anyhow!(error))?;
    let direct = json(&model).map_err(|error| anyhow::anyhow!(error))?;
    assert_eq!(
        rendered, direct,
        "the `--json` form must BE the model's serialisation, never a second shape"
    );
    let decoded: DocumentDoc = serde_json::from_str(&rendered)?;
    assert_eq!(decoded, model, "the JSON round-trips back to the model");
    assert!(rendered.ends_with('\n'));
    Ok(())
}

#[test]
fn every_rendering_is_byte_identical_for_byte_identical_input() -> anyhow::Result<()> {
    let first = source_model()?;
    let second = source_model()?;
    for format in [
        DocFormat::Html,
        DocFormat::Json,
        DocFormat::Markdown,
        DocFormat::Plain,
    ] {
        let left = render(&first, format).map_err(|error| anyhow::anyhow!(error))?;
        let right = render(&second, format).map_err(|error| anyhow::anyhow!(error))?;
        assert_eq!(left, right, "{format:?} is not byte-stable");
    }
    Ok(())
}

#[test]
fn the_html_page_is_self_contained_and_marks_its_provenance() -> anyhow::Result<()> {
    let page = render(&source_model()?, DocFormat::Html).map_err(|error| anyhow::anyhow!(error))?;
    assert!(page.starts_with("<!doctype html>"));
    assert!(page.contains("<svg "), "the step graph is inline SVG");
    assert!(
        !page.contains("<script"),
        "the page carries no script: there is no JS runtime in the CLI and none in the page"
    );
    // "Fetches nothing" is measured by the forms a browser fetches THROUGH —
    // a resource attribute, a stylesheet link, an import, a CSS url() — and
    // not by the presence of a URL as text. The SVG's namespace identifier
    // IS a URL and is fetched by nothing; it is pinned below as the positive
    // control, so the carve-out is explicit rather than silent.
    for fetch in ["src=\"", "href=\"http", "<link", "@import", "url(http"] {
        assert!(
            !page.contains(fetch),
            "the page fetches nothing, but carries `{fetch}`"
        );
    }
    assert!(
        page.contains("<svg xmlns=\"http://www.w3.org/2000/svg\""),
        "the inline SVG names its namespace, so it renders when copied out on its own"
    );
    assert!(
        page.contains("data-provenance=\"provenance\""),
        "a source-derived section is marked as provenance"
    );
    assert!(
        page.contains("data-provenance=\"contract\""),
        "a contract-committed section is marked as contract"
    );
    assert!(
        !page.contains("Undocumented"),
        "no placeholder prose reaches the page"
    );
    Ok(())
}

#[test]
fn the_plain_view_numbers_the_steps_in_reading_order() -> anyhow::Result<()> {
    let text =
        render(&source_model()?, DocFormat::Plain).map_err(|error| anyhow::anyhow!(error))?;
    assert!(text.contains("1. mark"), "got:\n{text}");
    assert!(text.contains("Read it once."), "got:\n{text}");
    assert!(
        !text.contains("Undocumented"),
        "the plain view invents no prose"
    );
    Ok(())
}

#[test]
fn the_command_writes_exactly_the_librarys_rendering() -> anyhow::Result<()> {
    // The CLI's plain form and the library's `plain::steps` are one rendering,
    // not two: the golden checked in under `aion-awl` is what this command
    // writes, so a reader comparing the two is comparing the same bytes.
    let model = source_model()?;
    let text = render(&model, DocFormat::Plain).map_err(|error| anyhow::anyhow!(error))?;
    let prose = model
        .prose
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("the fixture parses"))?;
    let steps = aion_awl::doc::plain::steps(&prose.graph);
    assert!(!steps.is_empty(), "the fixture declares a step");
    for step in &steps {
        assert!(
            text.contains(&format!("{}. {}", step.number, step.name)),
            "the command must write every numbered step the library produces; missing {}",
            step.name
        );
    }
    Ok(())
}

#[test]
fn the_doc_path_spawns_no_process() -> anyhow::Result<()> {
    // The CLI and the SERVER render the same model, so a renderer that
    // shelled out would be one the server cannot run. The census reads this
    // module's own source rather than trusting its module comment.
    let source = fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/awl_doc.rs"))?;
    let code: String = source
        .lines()
        .filter(|line| !line.trim_start().starts_with("//"))
        .collect::<Vec<_>>()
        .join("\n");
    for forbidden in [
        "Command::new",
        "std::process::Command",
        "graphviz",
        "dot -T",
    ] {
        assert!(
            !code.contains(forbidden),
            "src/awl_doc.rs names `{forbidden}` in code"
        );
    }
    Ok(())
}