Skip to main content

prosaic_project/
bundle.rs

1//! Project → portable bundle (JSON or generated Rust source).
2
3use serde::Serialize;
4
5use crate::error::ProjectError;
6use crate::project::Project;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum BuildTarget {
10    JsonManifest,
11    RustModule,
12    Both,
13}
14
15#[derive(Debug, Clone, Default)]
16pub struct BuildOutput {
17    pub json: Option<String>,
18    pub rust: Option<String>,
19}
20
21#[derive(Serialize)]
22struct JsonBundle<'a> {
23    schema_version: u32,
24    name: &'a str,
25    version: &'a str,
26    language: &'a str,
27    engine: &'a crate::manifest::EngineSettings,
28    templates: Vec<&'a crate::template::TemplateFile>,
29    partials: Vec<&'a crate::partial::PartialFile>,
30}
31
32pub fn build_bundle(project: &Project, target: BuildTarget) -> Result<BuildOutput, ProjectError> {
33    let mut out = BuildOutput::default();
34    if matches!(target, BuildTarget::JsonManifest | BuildTarget::Both) {
35        out.json = Some(build_json(project)?);
36    }
37    if matches!(target, BuildTarget::RustModule | BuildTarget::Both) {
38        out.rust = Some(build_rust(project)?);
39    }
40    Ok(out)
41}
42
43fn build_json(project: &Project) -> Result<String, ProjectError> {
44    let bundle = JsonBundle {
45        schema_version: 1,
46        name: &project.manifest.name,
47        version: &project.manifest.version,
48        language: &project.manifest.language,
49        engine: &project.manifest.engine,
50        templates: project.templates.values().collect(),
51        partials: project.partials.values().collect(),
52    };
53    serde_json::to_string_pretty(&bundle).map_err(|e| ProjectError::JsonParse {
54        file: "(bundle)".to_string(),
55        cause: e.to_string(),
56    })
57}
58
59fn build_rust(project: &Project) -> Result<String, ProjectError> {
60    use std::fmt::Write;
61    let mut s = String::new();
62    writeln!(s, "// Generated by `prosaic build` — do not edit by hand.").unwrap();
63    writeln!(s, "// schema_version = 1").unwrap();
64    writeln!(s, "use prosaic_core::{{Engine, Salience}};").unwrap();
65    writeln!(s).unwrap();
66    writeln!(
67        s,
68        "pub fn register(engine: &mut Engine) -> Result<(), prosaic_core::ProsaicError> {{"
69    )
70    .unwrap();
71    for partial in project.partials.values() {
72        writeln!(
73            s,
74            "    engine.register_partial({:?}, {:?})?;",
75            partial.name, partial.body
76        )
77        .unwrap();
78    }
79    for template in project.templates.values() {
80        for variant in &template.variants {
81            let salience = match variant.salience.as_str() {
82                "low" => "Salience::Low",
83                "high" => "Salience::High",
84                _ => "Salience::Medium",
85            };
86            let lang = variant.language.as_deref().unwrap_or("");
87            let style = variant.style.as_deref().unwrap_or("");
88            if lang.is_empty() && style.is_empty() {
89                writeln!(
90                    s,
91                    "    engine.register_template_at({:?}, {:?}, {})?;",
92                    template.key, variant.body, salience
93                )
94                .unwrap();
95            } else {
96                writeln!(
97                    s,
98                    "    engine.register_template_with_language_and_style_at({:?}, {:?}, {}, {}, {})?;",
99                    template.key,
100                    variant.body,
101                    salience,
102                    option_str_literal(lang),
103                    option_str_literal(style)
104                )
105                .unwrap();
106            }
107        }
108    }
109    writeln!(s, "    Ok(())").unwrap();
110    writeln!(s, "}}").unwrap();
111    Ok(s)
112}
113
114fn option_str_literal(value: &str) -> String {
115    if value.is_empty() {
116        "None".to_string()
117    } else {
118        format!("Some({value:?})")
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::project::Project;
126    use std::path::Path;
127
128    fn project() -> Project {
129        Project::load_from_dir(
130            Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/multi-variant"),
131        )
132        .unwrap()
133    }
134
135    #[test]
136    fn json_bundle_contains_template() {
137        let bundle = build_bundle(&project(), BuildTarget::JsonManifest).unwrap();
138        let json = bundle.json.unwrap();
139        assert!(json.contains("\"schema_version\""));
140        assert!(json.contains("\"code.modified\""));
141        assert!(json.contains("\"impact_tail\""));
142    }
143
144    #[test]
145    fn rust_bundle_emits_register_calls() {
146        let bundle = build_bundle(&project(), BuildTarget::RustModule).unwrap();
147        let rust = bundle.rust.unwrap();
148        assert!(rust.contains("register_partial(\"impact_tail\""));
149        assert!(rust.contains("register_template_at(\"code.modified\""));
150        assert!(rust.contains("Salience::Low"));
151        assert!(rust.contains("Salience::Medium"));
152        assert!(rust.contains("Salience::High"));
153    }
154
155    #[test]
156    fn rust_bundle_emits_style_tagged_register_call() {
157        let mut p = project();
158        let t = p.templates.get_mut("code.modified").unwrap();
159        t.variants[0].style = Some("executive".to_string());
160
161        let bundle = build_bundle(&p, BuildTarget::RustModule).unwrap();
162        let rust = bundle.rust.unwrap();
163        assert!(rust.contains("register_template_with_language_and_style_at(\"code.modified\""));
164        assert!(rust.contains("Some(\"executive\")"));
165    }
166
167    #[test]
168    fn both_target_produces_both() {
169        let bundle = build_bundle(&project(), BuildTarget::Both).unwrap();
170        assert!(bundle.json.is_some());
171        assert!(bundle.rust.is_some());
172    }
173}