use std::collections::BTreeSet;
use std::path::Path;
use zenith_core::{BytesAssetProvider, KdlAdapter, KdlSource, Severity};
use zenith_render::render_png;
use zenith_scene::compile_page;
use crate::commands::render::{
build_asset_provider, build_font_provider, collect_missing_asset_diagnostics,
};
use crate::json_types::{
DiagnosticJson, VariantManifest, VariantManifestTarget, VariantOutput, VariantResultJson,
};
use super::engine::{VariantOutcome, expand_variants};
#[derive(Debug)]
pub struct VariantCmdErr {
pub message: String,
pub exit_code: u8,
}
impl VariantCmdErr {
fn new(msg: impl Into<String>) -> Self {
Self {
message: msg.into(),
exit_code: 2,
}
}
}
#[derive(Debug)]
pub struct VariantOutputs {
pub zen: String,
pub png: String,
}
#[derive(Debug)]
pub struct VariantResultRecord {
pub id: String,
pub source: String,
pub outputs: Option<VariantOutputs>,
pub failure: Option<String>,
}
#[derive(Debug)]
pub struct VariantReport {
pub variants: Vec<VariantResultRecord>,
}
impl VariantReport {
pub fn generated(&self) -> usize {
self.variants.iter().filter(|r| r.failure.is_none()).count()
}
pub fn failed(&self) -> Vec<&VariantResultRecord> {
self.variants
.iter()
.filter(|r| r.failure.is_some())
.collect()
}
}
pub fn run_variant(
doc_src: &str,
project_dir: Option<&Path>,
out_dir: &Path,
stem: &str,
) -> Result<VariantReport, VariantCmdErr> {
let doc = KdlAdapter
.parse(doc_src.as_bytes())
.map_err(|e| VariantCmdErr::new(format!("error[parse.error]: {}", e.message)))?;
let expansion = expand_variants(&doc);
let fonts =
build_font_provider(&doc, project_dir, false).map_err(|e| VariantCmdErr::new(e.message))?;
let template_assets = match project_dir {
Some(dir) => {
build_asset_provider(&doc, dir, false).map_err(|e| VariantCmdErr::new(e.message))?
}
None => BytesAssetProvider::new(),
};
std::fs::create_dir_all(out_dir).map_err(|e| {
VariantCmdErr::new(format!(
"could not create output directory '{}': {}",
out_dir.display(),
e
))
})?;
let mut used_names: BTreeSet<String> = BTreeSet::new();
let mut collision_err: Option<String> = None;
for result in &expansion.results {
if !matches!(result.outcome, VariantOutcome::Generated(_)) {
continue;
}
let zen_name = format!("{}-{}.zen", stem, result.id);
let png_name = format!("{}-{}.png", stem, result.id);
for name in [&zen_name, &png_name] {
if used_names.contains(name.as_str()) {
collision_err = Some(format!("output filename collision: {name}"));
break;
}
used_names.insert(name.clone());
}
if collision_err.is_some() {
break;
}
}
if let Some(msg) = collision_err {
return Err(VariantCmdErr::new(msg));
}
let mut records: Vec<VariantResultRecord> = Vec::with_capacity(expansion.results.len());
for result in expansion.results {
match result.outcome {
VariantOutcome::Failed(reason) => {
records.push(VariantResultRecord {
id: result.id,
source: result.source,
outputs: None,
failure: Some(reason),
});
}
VariantOutcome::Generated(materialized) => {
let zen_name = format!("{}-{}.zen", stem, result.id);
let png_name = format!("{}-{}.png", stem, result.id);
let zen_bytes = match KdlAdapter.format(&materialized) {
Ok(b) => b,
Err(e) => {
records.push(VariantResultRecord {
id: result.id,
source: result.source,
outputs: None,
failure: Some(format!("format error: {}", e)),
});
continue;
}
};
let zen_path = out_dir.join(&zen_name);
if let Err(e) = std::fs::write(&zen_path, &zen_bytes) {
records.push(VariantResultRecord {
id: result.id,
source: result.source,
outputs: None,
failure: Some(format!("write error '{}': {}", zen_path.display(), e)),
});
continue;
}
let page_index = match materialized
.body
.pages
.iter()
.position(|p| p.id == result.source)
{
Some(idx) => idx,
None => {
let _ = std::fs::remove_file(&zen_path);
let failure = format!(
"source page '{}' not found in materialized document",
result.source
);
records.push(VariantResultRecord {
id: result.id,
source: result.source,
outputs: None,
failure: Some(failure),
});
continue;
}
};
if let Some(dir) = project_dir {
let missing_diags = collect_missing_asset_diagnostics(&materialized, dir);
let hard: Vec<String> = missing_diags
.iter()
.filter(|d| d.severity == Severity::Error)
.map(crate::commands::format_error_diag)
.collect();
if !hard.is_empty() {
let _ = std::fs::remove_file(&zen_path);
records.push(VariantResultRecord {
id: result.id,
source: result.source,
outputs: None,
failure: Some(format!("asset error(s): {}", hard.join("; "))),
});
continue;
}
}
let compile_result = compile_page(&materialized, &fonts, page_index, None);
let hard_diags: Vec<String> = compile_result
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.map(crate::commands::format_error_diag)
.collect();
if !hard_diags.is_empty() {
let _ = std::fs::remove_file(&zen_path);
records.push(VariantResultRecord {
id: result.id,
source: result.source,
outputs: None,
failure: Some(format!("compile error(s): {}", hard_diags.join("; "))),
});
continue;
}
let png_bytes = match render_png(&compile_result.scene, &fonts, &template_assets) {
Ok(b) => b,
Err(e) => {
let _ = std::fs::remove_file(&zen_path);
records.push(VariantResultRecord {
id: result.id,
source: result.source,
outputs: None,
failure: Some(format!("render error: {}", e)),
});
continue;
}
};
let png_path = out_dir.join(&png_name);
if let Err(e) = std::fs::write(&png_path, &png_bytes) {
let _ = std::fs::remove_file(&zen_path);
records.push(VariantResultRecord {
id: result.id,
source: result.source,
outputs: None,
failure: Some(format!("write error '{}': {}", png_path.display(), e)),
});
continue;
}
records.push(VariantResultRecord {
id: result.id,
source: result.source,
outputs: Some(VariantOutputs {
zen: zen_name,
png: png_name,
}),
failure: None,
});
}
}
}
Ok(VariantReport { variants: records })
}
pub fn build_manifest(doc_src: &str, report: &VariantReport) -> VariantManifest {
use sha2::{Digest, Sha256};
const MANIFEST_FORMAT_VERSION: &str = "1";
let source_sha256 = format!("{:x}", Sha256::digest(doc_src.as_bytes()));
let targets = report
.variants
.iter()
.filter(|r| r.failure.is_none())
.filter_map(|r| {
let outputs = r.outputs.as_ref()?;
Some(VariantManifestTarget {
id: r.id.clone(),
source: r.source.clone(),
outputs_zen: outputs.zen.clone(),
outputs_png: outputs.png.clone(),
})
})
.collect();
VariantManifest {
schema: "zenith-variant-manifest-v1",
generator: MANIFEST_FORMAT_VERSION,
source_sha256,
targets,
}
}
pub fn to_json_output(report: &VariantReport) -> VariantOutput {
let n_generated = report.generated();
let n_failed = report.failed().len();
VariantOutput {
schema: "zenith-variant-v1",
total_variants: report.variants.len(),
generated: n_generated,
failed: n_failed,
variants: report
.variants
.iter()
.map(|r| VariantResultJson {
id: r.id.clone(),
source: r.source.clone(),
status: if r.failure.is_none() { "ok" } else { "failed" },
outputs_zen: r.outputs.as_ref().map(|o| o.zen.clone()),
outputs_png: r.outputs.as_ref().map(|o| o.png.clone()),
diagnostics: match &r.failure {
None => Vec::new(),
Some(reason) => vec![DiagnosticJson {
code: "variant.failed".to_owned(),
severity: "error".to_owned(),
message: reason.clone(),
subject_id: Some(r.id.clone()),
}],
},
})
.collect(),
}
}