prosaic_project/
bundle.rs1use 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}