zenith_cli/commands/variant/
run.rs1use std::collections::BTreeSet;
12use std::path::Path;
13
14use zenith_core::{BytesAssetProvider, KdlAdapter, KdlSource, Severity};
15use zenith_render::render_png;
16use zenith_scene::compile_page;
17
18use crate::commands::render::{
19 build_asset_provider, build_font_provider, collect_missing_asset_diagnostics,
20};
21use crate::json_types::{
22 DiagnosticJson, VariantManifest, VariantManifestTarget, VariantOutput, VariantResultJson,
23};
24
25use super::engine::{VariantOutcome, expand_variants};
26
27#[derive(Debug)]
33pub struct VariantCmdErr {
34 pub message: String,
36 pub exit_code: u8,
38}
39
40impl VariantCmdErr {
41 fn new(msg: impl Into<String>) -> Self {
42 Self {
43 message: msg.into(),
44 exit_code: 2,
45 }
46 }
47}
48
49#[derive(Debug)]
53pub struct VariantOutputs {
54 pub zen: String,
56 pub png: String,
58}
59
60#[derive(Debug)]
62pub struct VariantResultRecord {
63 pub id: String,
65 pub source: String,
67 pub outputs: Option<VariantOutputs>,
69 pub failure: Option<String>,
71}
72
73#[derive(Debug)]
75pub struct VariantReport {
76 pub variants: Vec<VariantResultRecord>,
78}
79
80impl VariantReport {
81 pub fn generated(&self) -> usize {
83 self.variants.iter().filter(|r| r.failure.is_none()).count()
84 }
85
86 pub fn failed(&self) -> Vec<&VariantResultRecord> {
88 self.variants
89 .iter()
90 .filter(|r| r.failure.is_some())
91 .collect()
92 }
93}
94
95pub fn run_variant(
112 doc_src: &str,
113 project_dir: Option<&Path>,
114 out_dir: &Path,
115 stem: &str,
116) -> Result<VariantReport, VariantCmdErr> {
117 let doc = KdlAdapter
119 .parse(doc_src.as_bytes())
120 .map_err(|e| VariantCmdErr::new(format!("error[parse.error]: {}", e.message)))?;
121
122 let expansion = expand_variants(&doc);
124
125 let fonts =
130 build_font_provider(&doc, project_dir, false).map_err(|e| VariantCmdErr::new(e.message))?;
131 let template_assets = match project_dir {
132 Some(dir) => {
133 build_asset_provider(&doc, dir, false).map_err(|e| VariantCmdErr::new(e.message))?
134 }
135 None => BytesAssetProvider::new(),
136 };
137
138 std::fs::create_dir_all(out_dir).map_err(|e| {
140 VariantCmdErr::new(format!(
141 "could not create output directory '{}': {}",
142 out_dir.display(),
143 e
144 ))
145 })?;
146
147 let mut used_names: BTreeSet<String> = BTreeSet::new();
151 let mut collision_err: Option<String> = None;
152 for result in &expansion.results {
153 if !matches!(result.outcome, VariantOutcome::Generated(_)) {
154 continue;
155 }
156 let zen_name = format!("{}-{}.zen", stem, result.id);
157 let png_name = format!("{}-{}.png", stem, result.id);
158 for name in [&zen_name, &png_name] {
159 if used_names.contains(name.as_str()) {
160 collision_err = Some(format!("output filename collision: {name}"));
161 break;
162 }
163 used_names.insert(name.clone());
164 }
165 if collision_err.is_some() {
166 break;
167 }
168 }
169 if let Some(msg) = collision_err {
170 return Err(VariantCmdErr::new(msg));
171 }
172
173 let mut records: Vec<VariantResultRecord> = Vec::with_capacity(expansion.results.len());
175
176 for result in expansion.results {
177 match result.outcome {
178 VariantOutcome::Failed(reason) => {
179 records.push(VariantResultRecord {
180 id: result.id,
181 source: result.source,
182 outputs: None,
183 failure: Some(reason),
184 });
185 }
186 VariantOutcome::Generated(materialized) => {
187 let zen_name = format!("{}-{}.zen", stem, result.id);
188 let png_name = format!("{}-{}.png", stem, result.id);
189
190 let zen_bytes = match KdlAdapter.format(&materialized) {
192 Ok(b) => b,
193 Err(e) => {
194 records.push(VariantResultRecord {
195 id: result.id,
196 source: result.source,
197 outputs: None,
198 failure: Some(format!("format error: {}", e)),
199 });
200 continue;
201 }
202 };
203 let zen_path = out_dir.join(&zen_name);
204 if let Err(e) = std::fs::write(&zen_path, &zen_bytes) {
205 records.push(VariantResultRecord {
206 id: result.id,
207 source: result.source,
208 outputs: None,
209 failure: Some(format!("write error '{}': {}", zen_path.display(), e)),
210 });
211 continue;
212 }
213
214 let page_index = match materialized
216 .body
217 .pages
218 .iter()
219 .position(|p| p.id == result.source)
220 {
221 Some(idx) => idx,
222 None => {
223 let _ = std::fs::remove_file(&zen_path);
226 let failure = format!(
227 "source page '{}' not found in materialized document",
228 result.source
229 );
230 records.push(VariantResultRecord {
231 id: result.id,
232 source: result.source,
233 outputs: None,
234 failure: Some(failure),
235 });
236 continue;
237 }
238 };
239
240 if let Some(dir) = project_dir {
242 let missing_diags = collect_missing_asset_diagnostics(&materialized, dir);
243 let hard: Vec<String> = missing_diags
244 .iter()
245 .filter(|d| d.severity == Severity::Error)
246 .map(crate::commands::format_error_diag)
247 .collect();
248 if !hard.is_empty() {
249 let _ = std::fs::remove_file(&zen_path);
250 records.push(VariantResultRecord {
251 id: result.id,
252 source: result.source,
253 outputs: None,
254 failure: Some(format!("asset error(s): {}", hard.join("; "))),
255 });
256 continue;
257 }
258 }
259
260 let compile_result = compile_page(&materialized, &fonts, page_index, None);
262
263 let hard_diags: Vec<String> = compile_result
264 .diagnostics
265 .iter()
266 .filter(|d| d.severity == Severity::Error)
267 .map(crate::commands::format_error_diag)
268 .collect();
269 if !hard_diags.is_empty() {
270 let _ = std::fs::remove_file(&zen_path);
271 records.push(VariantResultRecord {
272 id: result.id,
273 source: result.source,
274 outputs: None,
275 failure: Some(format!("compile error(s): {}", hard_diags.join("; "))),
276 });
277 continue;
278 }
279
280 let png_bytes = match render_png(&compile_result.scene, &fonts, &template_assets) {
282 Ok(b) => b,
283 Err(e) => {
284 let _ = std::fs::remove_file(&zen_path);
285 records.push(VariantResultRecord {
286 id: result.id,
287 source: result.source,
288 outputs: None,
289 failure: Some(format!("render error: {}", e)),
290 });
291 continue;
292 }
293 };
294
295 let png_path = out_dir.join(&png_name);
297 if let Err(e) = std::fs::write(&png_path, &png_bytes) {
298 let _ = std::fs::remove_file(&zen_path);
299 records.push(VariantResultRecord {
300 id: result.id,
301 source: result.source,
302 outputs: None,
303 failure: Some(format!("write error '{}': {}", png_path.display(), e)),
304 });
305 continue;
306 }
307
308 records.push(VariantResultRecord {
309 id: result.id,
310 source: result.source,
311 outputs: Some(VariantOutputs {
312 zen: zen_name,
313 png: png_name,
314 }),
315 failure: None,
316 });
317 }
318 }
319 }
320
321 Ok(VariantReport { variants: records })
322}
323
324pub fn build_manifest(doc_src: &str, report: &VariantReport) -> VariantManifest {
332 use sha2::{Digest, Sha256};
333
334 const MANIFEST_FORMAT_VERSION: &str = "1";
337
338 let source_sha256 = format!("{:x}", Sha256::digest(doc_src.as_bytes()));
339
340 let targets = report
341 .variants
342 .iter()
343 .filter(|r| r.failure.is_none())
344 .filter_map(|r| {
345 let outputs = r.outputs.as_ref()?;
346 Some(VariantManifestTarget {
347 id: r.id.clone(),
348 source: r.source.clone(),
349 outputs_zen: outputs.zen.clone(),
350 outputs_png: outputs.png.clone(),
351 })
352 })
353 .collect();
354
355 VariantManifest {
356 schema: "zenith-variant-manifest-v1",
357 generator: MANIFEST_FORMAT_VERSION,
358 source_sha256,
359 targets,
360 }
361}
362
363pub fn to_json_output(report: &VariantReport) -> VariantOutput {
367 let n_generated = report.generated();
368 let n_failed = report.failed().len();
369 VariantOutput {
370 schema: "zenith-variant-v1",
371 total_variants: report.variants.len(),
372 generated: n_generated,
373 failed: n_failed,
374 variants: report
375 .variants
376 .iter()
377 .map(|r| VariantResultJson {
378 id: r.id.clone(),
379 source: r.source.clone(),
380 status: if r.failure.is_none() { "ok" } else { "failed" },
381 outputs_zen: r.outputs.as_ref().map(|o| o.zen.clone()),
382 outputs_png: r.outputs.as_ref().map(|o| o.png.clone()),
383 diagnostics: match &r.failure {
384 None => Vec::new(),
385 Some(reason) => vec![DiagnosticJson {
386 code: "variant.failed".to_owned(),
387 severity: "error".to_owned(),
388 message: reason.clone(),
389 subject_id: Some(r.id.clone()),
390 }],
391 },
392 })
393 .collect(),
394 }
395}