sofie-cli 0.4.0

The `sofie` command: gen, check and the tools reading the app model.
Documentation
use std::fmt::Write as _;

use sofie_model::App;

use super::{
    Answer, Api, Endpoint, Property, Scalar, Schema, Shape, VALUE_SHAPES, value_properties,
};

pub(crate) const TYPESCRIPT_HEADER: &str = "// @generated by `sofie typescript`; do not edit.\n";

#[must_use]
pub fn typescript(app: &App) -> String {
    let api = Api::of(app);
    let mut text = format!(
        "{TYPESCRIPT_HEADER}// The JSON endpoints of {}.\n\n/** What a committed record answers. */\nexport interface Committed {{\n  \
         id: string;\n  version: number;\n}}\n",
        api.title
    );
    for (name, shape) in VALUE_SHAPES {
        if api.mentions_value(name) {
            text.push_str(&interface(name, &value_properties(shape)));
        }
    }
    for (name, schema) in &api.schemas {
        match schema {
            Schema::Enum(names) => {
                let quoted: Vec<String> = names.iter().map(|name| format!("\"{name}\"")).collect();
                let _ = write!(text, "\nexport type {name} = {};\n", quoted.join(" | "));
            }
            Schema::Object(properties) => text.push_str(&interface(name, properties)),
        }
    }
    text.push_str("\nexport interface Endpoints {\n");
    for endpoint in &api.endpoints {
        text.push_str(&endpoint_entry(endpoint));
    }
    text.push_str("}\n");
    text
}

fn interface(name: &str, properties: &[Property]) -> String {
    let mut text = format!("\nexport interface {name} {{\n");
    for property in properties {
        let optional = if property.required { "" } else { "?" };
        let _ = writeln!(
            text,
            "  {}{optional}: {};",
            property.name,
            type_of(&property.shape)
        );
    }
    text.push_str("}\n");
    text
}

fn endpoint_entry(endpoint: &Endpoint) -> String {
    let named: Vec<String> = endpoint
        .parameters
        .iter()
        .map(|name| format!("{name}: string"))
        .collect();
    let parameters = if named.is_empty() {
        "{}".to_owned()
    } else {
        format!("{{ {} }}", named.join("; "))
    };
    let output = match &endpoint.output {
        Answer::Empty => "void".to_owned(),
        Answer::Data(shape) => type_of(shape),
    };
    let query = endpoint
        .query
        .as_ref()
        .map(|query| format!("\n    query: {};", query.name))
        .unwrap_or_default();
    let mut text = String::new();
    if let Some(summary) = &endpoint.summary {
        let _ = writeln!(text, "  /** {} */", summary.replace("*/", "* /"));
    }
    let _ = writeln!(
        text,
        "  \"{}\": {{\n    area: \"{}\";\n    method: \"{}\";\n    path: \"{}\";\n    parameters: {parameters};{query}\n    \
         input: {};\n    output: {output};\n  }};",
        endpoint.name,
        endpoint.area,
        endpoint.method.name(),
        endpoint.path,
        endpoint.input.as_deref().unwrap_or("never"),
    );
    text
}

fn type_of(shape: &Shape) -> String {
    match shape {
        Shape::Scalar(Scalar::Integer | Scalar::Number) => "number".to_owned(),
        Shape::Scalar(Scalar::Boolean) => "boolean".to_owned(),
        Shape::Scalar(_) => "string".to_owned(),
        Shape::Value(name) => (*name).to_owned(),
        Shape::Named(name) => name.clone(),
        Shape::Committed => "Committed".to_owned(),
        Shape::List(item) => match item.as_ref() {
            Shape::Optional(_) | Shape::Tested(_) => format!("({})[]", type_of(item)),
            _ => format!("{}[]", type_of(item)),
        },
        Shape::Optional(inner) => format!("{} | null", type_of(inner)),
        Shape::Tested(inner) => format!("{} | boolean", type_of(inner)),
        Shape::Unknown(written) => format!("unknown /* {written} */"),
    }
}

#[cfg(test)]
mod tests {
    use super::typescript;
    use crate::api::tests::shop;

    #[test]
    fn types_the_endpoints_their_schemas_and_values() {
        let text = typescript(&shop());

        assert_eq!(text, include_str!("../../tests/golden/shop.ts"), "{text}");
    }
}