Skip to main content

zenith_cli/commands/variant/
run.rs

1//! CLI entry point for `zenith variant`.
2//!
3//! [`run_variant`] is the single public entry point.  It loads and parses the
4//! input `.zen`, calls [`expand_variants`], renders each generated variant to
5//! PNG, writes a side-by-side `.zen` for review, and optionally writes a
6//! deterministic generation manifest.
7//!
8//! No clap parsing lives here — argument types are in `cli.rs`.  No FS I/O
9//! lives in `lib.rs` — it is all here or in the callee chain.
10
11use 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// ── Error type ────────────────────────────────────────────────────────────────
28
29/// A fatal error that prevents variant generation from starting.
30///
31/// Exit code 2 for all setup errors (consistent with `MergeError`).
32#[derive(Debug)]
33pub struct VariantCmdErr {
34    /// Human-readable message.
35    pub message: String,
36    /// Recommended exit code (always 2 for setup errors).
37    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// ── Report types ──────────────────────────────────────────────────────────────
50
51/// Output paths for a single generated variant.
52#[derive(Debug)]
53pub struct VariantOutputs {
54    /// The `.zen` source written to disk (relative filename within `out_dir`).
55    pub zen: String,
56    /// The rendered `.png` written to disk (relative filename within `out_dir`).
57    pub png: String,
58}
59
60/// Result record for one variant (generated or failed).
61#[derive(Debug)]
62pub struct VariantResultRecord {
63    /// The variant's stable id.
64    pub id: String,
65    /// The source page id this variant derives from.
66    pub source: String,
67    /// Output files written — `None` when `failure` is set.
68    pub outputs: Option<VariantOutputs>,
69    /// `None` = generated successfully; `Some(reason)` = failed.
70    pub failure: Option<String>,
71}
72
73/// Summary of a completed variant-generation run.
74#[derive(Debug)]
75pub struct VariantReport {
76    /// All per-variant records, in ascending variant-id order.
77    pub variants: Vec<VariantResultRecord>,
78}
79
80impl VariantReport {
81    /// Number of variants that were generated successfully.
82    pub fn generated(&self) -> usize {
83        self.variants.iter().filter(|r| r.failure.is_none()).count()
84    }
85
86    /// References to the records that failed, in id order.
87    pub fn failed(&self) -> Vec<&VariantResultRecord> {
88        self.variants
89            .iter()
90            .filter(|r| r.failure.is_some())
91            .collect()
92    }
93}
94
95// ── run_variant ───────────────────────────────────────────────────────────────
96
97/// Run variant generation for all `variants` blocks in `doc_src`.
98///
99/// # Parameters
100///
101/// - `doc_src`     — UTF-8 source of the input `.zen` document.
102/// - `project_dir` — directory of the `.zen` file (for asset/font resolution).
103/// - `out_dir`     — directory to write `<stem>-<id>.zen` and `<stem>-<id>.png`.
104/// - `stem`        — output file stem (typically the `.zen` filename without extension).
105///
106/// # Errors
107///
108/// Returns [`VariantCmdErr`] (exit code 2) for setup failures that prevent any
109/// variant from being processed.  Per-variant failures are recorded in
110/// [`VariantReport::failed`] and do not cause an `Err` return.
111pub fn run_variant(
112    doc_src: &str,
113    project_dir: Option<&Path>,
114    out_dir: &Path,
115    stem: &str,
116) -> Result<VariantReport, VariantCmdErr> {
117    // ── 1. Parse the input document ───────────────────────────────────────
118    let doc = KdlAdapter
119        .parse(doc_src.as_bytes())
120        .map_err(|e| VariantCmdErr::new(format!("error[parse.error]: {}", e.message)))?;
121
122    // ── 2. Expand variants (pure engine — no I/O) ─────────────────────────
123    let expansion = expand_variants(&doc);
124
125    // An empty expansion (no variants block) is not an error; we return an
126    // empty report so the caller can produce a "0 generated" summary.
127
128    // ── 3. Build font + asset providers ONCE from the original doc ────────
129    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    // ── 4. Ensure output directory exists ─────────────────────────────────
139    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    // ── 5. Pre-flight collision check ─────────────────────────────────────
148    // Distinct variant ids always produce distinct stems, so collisions are
149    // not expected; the check mirrors merge's safety pattern.
150    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    // ── 6. Process each variant result ────────────────────────────────────
174    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                // ── 6a. Write the materialized `.zen` ─────────────────────
191                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                // ── 6b. Find the source page index in the materialized doc ─
215                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                        // Source page missing in materialized doc — clean up the
224                        // .zen we already wrote and record failure.
225                        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                // ── 6c. Gate on hard asset diagnostics ────────────────────
241                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                // ── 6d. Compile the source page ───────────────────────────
261                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                // ── 6e. Render to PNG ─────────────────────────────────────
281                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                // ── 6f. Write PNG ─────────────────────────────────────────
296                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
324// ── build_manifest ────────────────────────────────────────────────────────────
325
326/// Build a deterministic generation manifest from the variant inputs and report.
327///
328/// `source_sha256` is the SHA-256 of the input `.zen` bytes.  No timestamps,
329/// absolute paths, or crate versions are embedded — identical inputs yield a
330/// byte-identical manifest.  Only successfully-generated variants are included.
331pub fn build_manifest(doc_src: &str, report: &VariantReport) -> VariantManifest {
332    use sha2::{Digest, Sha256};
333
334    // Bump only when the manifest structure changes — never on a routine
335    // crate release (that would break CI byte-identical comparison).
336    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
363// ── to_json_output ────────────────────────────────────────────────────────────
364
365/// Convert a completed [`VariantReport`] into the JSON-serialisable envelope.
366pub 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}