prosaic-project 0.6.1

Folder-of-files project format and bundler for Prosaic templates.
Documentation
//! Project → portable bundle (JSON or generated Rust source).

use serde::Serialize;

use crate::error::ProjectError;
use crate::project::Project;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildTarget {
    JsonManifest,
    RustModule,
    Both,
}

#[derive(Debug, Clone, Default)]
pub struct BuildOutput {
    pub json: Option<String>,
    pub rust: Option<String>,
}

#[derive(Serialize)]
struct JsonBundle<'a> {
    schema_version: u32,
    name: &'a str,
    version: &'a str,
    language: &'a str,
    engine: &'a crate::manifest::EngineSettings,
    templates: Vec<&'a crate::template::TemplateFile>,
    partials: Vec<&'a crate::partial::PartialFile>,
}

pub fn build_bundle(project: &Project, target: BuildTarget) -> Result<BuildOutput, ProjectError> {
    let mut out = BuildOutput::default();
    if matches!(target, BuildTarget::JsonManifest | BuildTarget::Both) {
        out.json = Some(build_json(project)?);
    }
    if matches!(target, BuildTarget::RustModule | BuildTarget::Both) {
        out.rust = Some(build_rust(project)?);
    }
    Ok(out)
}

fn build_json(project: &Project) -> Result<String, ProjectError> {
    let bundle = JsonBundle {
        schema_version: 1,
        name: &project.manifest.name,
        version: &project.manifest.version,
        language: &project.manifest.language,
        engine: &project.manifest.engine,
        templates: project.templates.values().collect(),
        partials: project.partials.values().collect(),
    };
    serde_json::to_string_pretty(&bundle).map_err(|e| ProjectError::JsonParse {
        file: "(bundle)".to_string(),
        cause: e.to_string(),
    })
}

fn build_rust(project: &Project) -> Result<String, ProjectError> {
    use std::fmt::Write;
    let mut s = String::new();
    writeln!(s, "// Generated by `prosaic build` — do not edit by hand.").unwrap();
    writeln!(s, "// schema_version = 1").unwrap();
    writeln!(s, "use prosaic_core::{{Engine, Salience}};").unwrap();
    writeln!(s).unwrap();
    writeln!(
        s,
        "pub fn register(engine: &mut Engine) -> Result<(), prosaic_core::ProsaicError> {{"
    )
    .unwrap();
    for partial in project.partials.values() {
        writeln!(
            s,
            "    engine.register_partial({:?}, {:?})?;",
            partial.name, partial.body
        )
        .unwrap();
    }
    for template in project.templates.values() {
        for variant in &template.variants {
            let salience = match variant.salience.as_str() {
                "low" => "Salience::Low",
                "high" => "Salience::High",
                _ => "Salience::Medium",
            };
            let lang = variant.language.as_deref().unwrap_or("");
            let style = variant.style.as_deref().unwrap_or("");
            if lang.is_empty() && style.is_empty() {
                writeln!(
                    s,
                    "    engine.register_template_at({:?}, {:?}, {})?;",
                    template.key, variant.body, salience
                )
                .unwrap();
            } else {
                writeln!(
                    s,
                    "    engine.register_template_with_language_and_style_at({:?}, {:?}, {}, {}, {})?;",
                    template.key,
                    variant.body,
                    salience,
                    option_str_literal(lang),
                    option_str_literal(style)
                )
                .unwrap();
            }
        }
    }
    writeln!(s, "    Ok(())").unwrap();
    writeln!(s, "}}").unwrap();
    Ok(s)
}

fn option_str_literal(value: &str) -> String {
    if value.is_empty() {
        "None".to_string()
    } else {
        format!("Some({value:?})")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::project::Project;
    use std::path::Path;

    fn project() -> Project {
        Project::load_from_dir(
            Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/multi-variant"),
        )
        .unwrap()
    }

    #[test]
    fn json_bundle_contains_template() {
        let bundle = build_bundle(&project(), BuildTarget::JsonManifest).unwrap();
        let json = bundle.json.unwrap();
        assert!(json.contains("\"schema_version\""));
        assert!(json.contains("\"code.modified\""));
        assert!(json.contains("\"impact_tail\""));
    }

    #[test]
    fn rust_bundle_emits_register_calls() {
        let bundle = build_bundle(&project(), BuildTarget::RustModule).unwrap();
        let rust = bundle.rust.unwrap();
        assert!(rust.contains("register_partial(\"impact_tail\""));
        assert!(rust.contains("register_template_at(\"code.modified\""));
        assert!(rust.contains("Salience::Low"));
        assert!(rust.contains("Salience::Medium"));
        assert!(rust.contains("Salience::High"));
    }

    #[test]
    fn rust_bundle_emits_style_tagged_register_call() {
        let mut p = project();
        let t = p.templates.get_mut("code.modified").unwrap();
        t.variants[0].style = Some("executive".to_string());

        let bundle = build_bundle(&p, BuildTarget::RustModule).unwrap();
        let rust = bundle.rust.unwrap();
        assert!(rust.contains("register_template_with_language_and_style_at(\"code.modified\""));
        assert!(rust.contains("Some(\"executive\")"));
    }

    #[test]
    fn both_target_produces_both() {
        let bundle = build_bundle(&project(), BuildTarget::Both).unwrap();
        assert!(bundle.json.is_some());
        assert!(bundle.rust.is_some());
    }
}