Skip to main content

zenith_cli/commands/
merge.rs

1//! Pure logic for `zenith merge`.
2//!
3//! The public entry point [`run`] operates entirely on in-memory source text
4//! plus filesystem paths for outputs.  The source `.zen` file is NEVER
5//! mutated; each row's document is produced in-memory via the transaction
6//! engine and re-parsed before compilation.
7
8use std::collections::BTreeMap;
9use std::collections::BTreeSet;
10use std::path::Path;
11
12use zenith_core::{AssetKind, BytesAssetProvider, KdlAdapter, KdlSource, Severity};
13use zenith_render::render_png;
14use zenith_scene::compile_page;
15use zenith_tx::{Op, OpSpan, Transaction, TxStatus, run_transaction};
16
17use crate::json_types::{DiagnosticJson, MergeOutput, MergeRowResult};
18
19use crate::commands::render::{
20    build_asset_provider, build_font_provider, collect_missing_asset_diagnostics,
21    resolve_text_sources,
22};
23
24// ── Error type ────────────────────────────────────────────────────────────────
25
26/// A fatal error that prevents the merge from starting.
27///
28/// Exit code 2 for all setup/template errors (consistent with the other
29/// commands whose `RenderCmdErr`/`FmtErr`/`TxCmdErr` all use 2 for this class
30/// of failure).
31#[derive(Debug)]
32pub struct MergeError {
33    /// Human-readable message.
34    pub message: String,
35    /// Recommended exit code (always 2 for template/setup errors).
36    pub exit_code: u8,
37}
38
39impl MergeError {
40    fn new(msg: impl Into<String>) -> Self {
41        Self {
42            message: msg.into(),
43            exit_code: 2,
44        }
45    }
46}
47
48// ── Report types ──────────────────────────────────────────────────────────────
49
50/// Result for one CSV data row.
51#[derive(Debug)]
52pub struct RowResult {
53    /// 0-based CSV data row index.
54    pub row: usize,
55    /// The --name-by cell value, or None.
56    pub key: Option<String>,
57    /// Filenames written (empty on failure), page order.
58    pub outputs: Vec<String>,
59    /// None = ok; Some(reason) = failed.
60    pub failure: Option<String>,
61}
62
63/// Summary of a completed merge run. `rows` is the single source of truth,
64/// in CSV order.
65#[derive(Debug)]
66pub struct MergeReport {
67    pub rows: Vec<RowResult>,
68}
69
70impl MergeReport {
71    /// All successfully-written filenames, in row→page order.
72    pub fn written(&self) -> Vec<String> {
73        self.rows
74            .iter()
75            .flat_map(|r| r.outputs.iter().cloned())
76            .collect()
77    }
78    /// References to the rows that failed, in CSV order.
79    pub fn failed(&self) -> Vec<&RowResult> {
80        self.rows.iter().filter(|r| r.failure.is_some()).collect()
81    }
82}
83
84// ── Internal binding types ────────────────────────────────────────────────────
85
86/// Maps a node id to the CSV column that supplies its replacement text.
87struct DataBinding {
88    node_id: String,
89    column: String,
90}
91
92/// Maps an image node id to the CSV column that supplies the per-row image path.
93struct AssetBinding {
94    node_id: String,
95    column: String,
96}
97
98// ── collect_data_nodes ────────────────────────────────────────────────────────
99
100/// Return an error if `role` starts with `"data."` on a non-text, non-image node.
101///
102/// The error message and format live here exactly once.
103fn reject_data_role_on_non_text(role: Option<&str>, id: &str) -> Result<(), MergeError> {
104    if let Some(role) = role
105        && role.starts_with("data.")
106    {
107        return Err(MergeError::new(format!(
108            "role=\"{}\" on non-text node {}: replace_text supports text nodes only",
109            role, id
110        )));
111    }
112    Ok(())
113}
114
115/// Walk `nodes` recursively and collect every node that carries a
116/// `role="data.<column>"` attribute.
117///
118/// `Node::Text` bindings are collected into `out`; `Node::Image` bindings are
119/// collected into `asset_out`.  Any other variant with a `data.*` role is a
120/// hard [`MergeError`].
121///
122/// Recurses into `Node::Frame`, `Node::Group`, and `Node::Table` cell children.
123fn collect_data_nodes(
124    nodes: &[zenith_core::Node],
125    out: &mut Vec<DataBinding>,
126    asset_out: &mut Vec<AssetBinding>,
127) -> Result<(), MergeError> {
128    for node in nodes {
129        match node {
130            zenith_core::Node::Text(n) => {
131                if let Some(role) = n.role.as_deref()
132                    && let Some(col) = role.strip_prefix("data.")
133                {
134                    out.push(DataBinding {
135                        node_id: n.id.clone(),
136                        column: col.to_owned(),
137                    });
138                }
139            }
140            zenith_core::Node::Image(n) => {
141                if let Some(role) = n.role.as_deref()
142                    && let Some(col) = role.strip_prefix("data.")
143                {
144                    asset_out.push(AssetBinding {
145                        node_id: n.id.clone(),
146                        column: col.to_owned(),
147                    });
148                }
149            }
150            zenith_core::Node::Rect(n) => {
151                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
152            }
153            zenith_core::Node::Ellipse(n) => {
154                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
155            }
156            zenith_core::Node::Line(n) => {
157                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
158            }
159            zenith_core::Node::Code(n) => {
160                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
161            }
162            zenith_core::Node::Frame(n) => {
163                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
164                collect_data_nodes(&n.children, out, asset_out)?;
165            }
166            zenith_core::Node::Group(n) => {
167                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
168                collect_data_nodes(&n.children, out, asset_out)?;
169            }
170            zenith_core::Node::Polygon(n) => {
171                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
172            }
173            zenith_core::Node::Polyline(n) => {
174                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
175            }
176            zenith_core::Node::Instance(n) => {
177                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
178            }
179            zenith_core::Node::Field(n) => {
180                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
181            }
182            zenith_core::Node::Toc(n) => {
183                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
184            }
185            zenith_core::Node::Footnote(n) => {
186                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
187            }
188            zenith_core::Node::Table(n) => {
189                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
190                for row in &n.rows {
191                    for cell in &row.cells {
192                        collect_data_nodes(&cell.children, out, asset_out)?;
193                    }
194                }
195            }
196            zenith_core::Node::Shape(n) => {
197                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
198            }
199            zenith_core::Node::Connector(n) => {
200                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
201            }
202            zenith_core::Node::Pattern(n) => {
203                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
204            }
205            zenith_core::Node::Chart(n) => {
206                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
207            }
208            zenith_core::Node::Light(n) => {
209                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
210            }
211            zenith_core::Node::Mesh(n) => {
212                reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
213            }
214            zenith_core::Node::Unknown(_n) => {
215                // UnknownNode has no id or role field; data.* roles cannot be
216                // placed on unknown nodes (the parser would not parse them).
217            }
218        }
219    }
220    Ok(())
221}
222
223// ── sanitize_filename ─────────────────────────────────────────────────────────
224
225/// Map filesystem-unsafe characters and NUL to `_`, trim leading/trailing
226/// dots and whitespace, and return `"_"` for the empty result.
227pub fn sanitize_filename(s: &str) -> String {
228    let mapped: String = s
229        .chars()
230        .map(|c| match c {
231            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
232            other => other,
233        })
234        .collect();
235    let trimmed = mapped.trim_matches(|c: char| c == '.' || c.is_whitespace());
236    if trimmed.is_empty() {
237        "_".to_owned()
238    } else {
239        trimmed.to_owned()
240    }
241}
242
243// ── Public entry point ────────────────────────────────────────────────────────
244
245/// Run a mail-merge: for each CSV row, build a per-row document (in-memory),
246/// compile it, render it to PNG, and stream the file to `out_dir`.
247///
248/// # Parameters
249///
250/// - `doc_src`     — UTF-8 source of the template `.zen` document.
251/// - `csv_src`     — UTF-8 CSV with a header row.
252/// - `project_dir` — directory of the `.zen` file (for asset resolution).
253/// - `out_dir`     — directory to write one PNG per row into.
254/// - `name_by`     — CSV column to derive filenames from; default `row-NNNN.png`.
255///
256/// # Errors
257///
258/// Returns [`MergeError`] (exit code 2) for template/setup failures that
259/// prevent any row from being processed.  Per-row failures are collected into
260/// [`MergeReport::failed`] and do not cause an `Err` return.
261pub fn run(
262    doc_src: &str,
263    csv_src: &str,
264    project_dir: Option<&Path>,
265    out_dir: &Path,
266    name_by: Option<&str>,
267) -> Result<MergeReport, MergeError> {
268    // ── 1. Parse the template document (once) ─────────────────────────────
269    let doc = KdlAdapter
270        .parse(doc_src.as_bytes())
271        .map_err(|e| MergeError::new(format!("error[parse.error]: {}", e.message)))?;
272
273    // ── 2. Collect data bindings ──────────────────────────────────────────
274    let mut bindings: Vec<DataBinding> = Vec::new();
275    let mut asset_bindings: Vec<AssetBinding> = Vec::new();
276    for page in &doc.body.pages {
277        collect_data_nodes(&page.children, &mut bindings, &mut asset_bindings)?;
278    }
279    if bindings.is_empty() && asset_bindings.is_empty() {
280        return Err(MergeError::new("no role=\"data.*\" template nodes found"));
281    }
282
283    // ── 3. Validate: asset bindings require a project_dir ────────────────
284    if !asset_bindings.is_empty() && project_dir.is_none() {
285        return Err(MergeError::new(
286            "image data bindings require a project directory (the .zen file must be on disk)",
287        ));
288    }
289
290    // ── 4. Parse CSV headers and validate bindings ────────────────────────
291    let mut reader = csv::Reader::from_reader(csv_src.as_bytes());
292    let headers = reader
293        .headers()
294        .map_err(|e| MergeError::new(format!("CSV header error: {}", e)))?
295        .clone();
296
297    // Build a header→index map (BTreeMap for deterministic ordering).
298    let header_index: BTreeMap<String, usize> = headers
299        .iter()
300        .enumerate()
301        .map(|(i, h)| (h.to_owned(), i))
302        .collect();
303
304    // Verify all text-binding columns exist.
305    let unknown: Vec<String> = bindings
306        .iter()
307        .filter(|b| !header_index.contains_key(&b.column))
308        .map(|b| b.column.clone())
309        .collect();
310    if !unknown.is_empty() {
311        return Err(MergeError::new(format!(
312            "CSV column(s) not found in header: {}",
313            unknown.join(", ")
314        )));
315    }
316
317    // Verify all asset-binding columns exist.
318    let unknown_asset: Vec<String> = asset_bindings
319        .iter()
320        .filter(|b| !header_index.contains_key(&b.column))
321        .map(|b| b.column.clone())
322        .collect();
323    if !unknown_asset.is_empty() {
324        return Err(MergeError::new(format!(
325            "CSV column(s) not found in header: {}",
326            unknown_asset.join(", ")
327        )));
328    }
329
330    // Verify name_by column exists.
331    if let Some(col) = name_by
332        && !header_index.contains_key(col)
333    {
334        return Err(MergeError::new(format!(
335            "--name-by column {:?} not found in CSV header",
336            col
337        )));
338    }
339
340    // Pre-resolve column indices (avoids per-cell BTreeMap lookups).
341    // All columns were verified to exist above so `get` never returns None.
342    let binding_indices: Vec<usize> = bindings
343        .iter()
344        .map(|b| -> Result<usize, MergeError> {
345            header_index
346                .get(&b.column)
347                .copied()
348                .ok_or_else(|| MergeError::new(format!("column {:?} not found", b.column)))
349        })
350        .collect::<Result<Vec<usize>, MergeError>>()?;
351
352    let asset_binding_indices: Vec<usize> = asset_bindings
353        .iter()
354        .map(|b| -> Result<usize, MergeError> {
355            header_index
356                .get(&b.column)
357                .copied()
358                .ok_or_else(|| MergeError::new(format!("column {:?} not found", b.column)))
359        })
360        .collect::<Result<Vec<usize>, MergeError>>()?;
361
362    let name_by_index: Option<usize> = match name_by {
363        None => None,
364        Some(col) => Some(
365            header_index
366                .get(col)
367                .copied()
368                .ok_or_else(|| MergeError::new(format!("--name-by column {:?} not found", col)))?,
369        ),
370    };
371
372    // ── 5. Build font + asset providers ONCE from the original doc ────────
373    let fonts =
374        build_font_provider(&doc, project_dir, false).map_err(|e| MergeError::new(e.message))?;
375    // Template assets are loaded once; per-row image bytes are layered on top.
376    let template_assets = match project_dir {
377        Some(dir) => {
378            build_asset_provider(&doc, dir, false).map_err(|e| MergeError::new(e.message))?
379        }
380        None => BytesAssetProvider::new(),
381    };
382
383    // ── 6. Ensure output directory exists ─────────────────────────────────
384    std::fs::create_dir_all(out_dir).map_err(|e| {
385        MergeError::new(format!(
386            "could not create output directory '{}': {}",
387            out_dir.display(),
388            e
389        ))
390    })?;
391
392    // ── 7. Iterate CSV rows ───────────────────────────────────────────────
393    let mut rows: Vec<RowResult> = Vec::new();
394    let mut used_names: BTreeSet<String> = BTreeSet::new();
395
396    for (row_idx, record_result) in reader.records().enumerate() {
397        let record = match record_result {
398            Ok(r) => r,
399            Err(e) => {
400                push_failure(&mut rows, row_idx, None, format!("CSV read error: {}", e));
401                continue;
402            }
403        };
404
405        // Extract the row key once, as early as possible after the record is
406        // obtained, so it is available to every failure path that follows.
407        let row_key: Option<String> =
408            name_by_index.map(|col_idx| record.get(col_idx).unwrap_or("").to_owned());
409
410        // Build Transaction ops: ReplaceText ops first, then asset ops.
411        let mut ops: Vec<Op> = bindings
412            .iter()
413            .zip(binding_indices.iter())
414            .map(|(binding, &col_idx)| {
415                let cell = record.get(col_idx).unwrap_or("");
416                Op::ReplaceText {
417                    node: binding.node_id.clone(),
418                    spans: vec![OpSpan {
419                        text: cell.to_owned(),
420                        fill: None,
421                        font_weight: None,
422                        italic: None,
423                        underline: None,
424                        strikethrough: None,
425                        vertical_align: None,
426                        footnote_ref: None,
427                    }],
428                }
429            })
430            .collect();
431
432        // Append AddAsset + SetAsset ops for non-empty image cells.
433        for (binding, &col_idx) in asset_bindings.iter().zip(asset_binding_indices.iter()) {
434            let cell = record.get(col_idx).unwrap_or("").trim();
435            if cell.is_empty() {
436                // Empty cell → leave template image in place; no op needed.
437                continue;
438            }
439            let asset_id = row_asset_id(row_idx, &binding.column);
440            ops.push(Op::AddAsset {
441                id: asset_id.clone(),
442                kind: "image".to_owned(),
443                src: cell.to_owned(),
444                sha256: None,
445            });
446            ops.push(Op::SetAsset {
447                node_id: binding.node_id.clone(),
448                asset_id,
449            });
450        }
451
452        let tx = Transaction {
453            ops,
454            permissions: Default::default(),
455        };
456
457        // Run transaction.
458        let tx_result = match run_transaction(&doc, &tx) {
459            Ok(r) => r,
460            Err(e) => {
461                push_failure(
462                    &mut rows,
463                    row_idx,
464                    row_key,
465                    format!("transaction engine error: {}", e.message),
466                );
467                continue;
468            }
469        };
470
471        // A Rejected transaction is a per-row failure.
472        if tx_result.status == TxStatus::Rejected {
473            let msgs: Vec<String> = tx_result
474                .diagnostics
475                .iter()
476                .map(|d| {
477                    format!(
478                        "{}[{}]: {}",
479                        crate::json_types::severity_str(&d.severity),
480                        d.code,
481                        d.message
482                    )
483                })
484                .collect();
485            push_failure(
486                &mut rows,
487                row_idx,
488                row_key,
489                format!("transaction rejected: {}", msgs.join("; ")),
490            );
491            continue;
492        }
493
494        // Re-parse source_after → row document.
495        let mut row_doc = match KdlAdapter.parse(tx_result.source_after.as_bytes()) {
496            Ok(d) => d,
497            Err(e) => {
498                push_failure(
499                    &mut rows,
500                    row_idx,
501                    row_key,
502                    format!("post-transaction parse error: {}", e.message),
503                );
504                continue;
505            }
506        };
507
508        // Resolve external text-file sources on the per-row document.
509        // Any text.src_missing Error is a per-row failure (same gate as asset.missing).
510        {
511            let mut text_src_diags: Vec<zenith_core::Diagnostic> = Vec::new();
512            resolve_text_sources(&mut row_doc, project_dir, &mut text_src_diags);
513            let hard: Vec<String> = text_src_diags
514                .iter()
515                .filter(|d| d.severity == Severity::Error)
516                .map(crate::commands::format_error_diag)
517                .collect();
518            if !hard.is_empty() {
519                push_failure(
520                    &mut rows,
521                    row_idx,
522                    row_key,
523                    format!("text source error(s): {}", hard.join("; ")),
524                );
525                continue;
526            }
527        }
528
529        // Build per-row asset provider: template assets + row-specific images.
530        // BytesAssetProvider is not Clone, so we rebuild from template doc and
531        // then layer the row image(s) on top.
532        let row_assets = if asset_bindings.is_empty() {
533            // No image bindings: the pre-built `template_assets` provider is used
534            // directly by the render call below — no per-row provider is needed.
535            None
536        } else {
537            let Some(dir) = project_dir else {
538                push_failure(
539                    &mut rows,
540                    row_idx,
541                    row_key,
542                    "internal: project directory unexpectedly missing".to_owned(),
543                );
544                continue;
545            };
546            // Start with template assets.
547            let mut row_provider =
548                build_asset_provider(&doc, dir, false).map_err(|e| MergeError::new(e.message))?;
549            // Layer in per-row images.
550            let mut row_asset_missing = false;
551            for (binding, &col_idx) in asset_bindings.iter().zip(asset_binding_indices.iter()) {
552                let cell = record.get(col_idx).unwrap_or("").trim();
553                if cell.is_empty() {
554                    continue;
555                }
556                let asset_id = row_asset_id(row_idx, &binding.column);
557                let img_path = dir.join(cell);
558                match std::fs::read(&img_path) {
559                    Ok(bytes) => {
560                        row_provider.register(&asset_id, AssetKind::Image, bytes.into());
561                    }
562                    Err(e) => {
563                        push_failure(
564                            &mut rows,
565                            row_idx,
566                            row_key.clone(),
567                            format!(
568                                "error[asset.missing]: asset '{}' file not found: '{}': {}",
569                                asset_id,
570                                img_path.display(),
571                                e
572                            ),
573                        );
574                        row_asset_missing = true;
575                        break;
576                    }
577                }
578            }
579            if row_asset_missing {
580                continue;
581            }
582            Some(row_provider)
583        };
584
585        // Also gate on collect_missing_asset_diagnostics for any declared-but-missing
586        // template assets now embedded in row_doc (includes the AddAsset entries).
587        if let Some(dir) = project_dir {
588            let missing_diags = collect_missing_asset_diagnostics(&row_doc, dir);
589            let hard: Vec<String> = missing_diags
590                .iter()
591                .filter(|d| d.severity == Severity::Error)
592                .map(crate::commands::format_error_diag)
593                .collect();
594            if !hard.is_empty() {
595                push_failure(
596                    &mut rows,
597                    row_idx,
598                    row_key,
599                    format!("asset error(s): {}", hard.join("; ")),
600                );
601                continue;
602            }
603        }
604
605        // Determine page count; a row document with no pages is a hard failure.
606        let page_count = row_doc.body.pages.len();
607        if page_count == 0 {
608            push_failure(
609                &mut rows,
610                row_idx,
611                row_key,
612                "row document has no pages".to_owned(),
613            );
614            continue;
615        }
616
617        // Derive the row stem once (hoist name logic before per-page work).
618        let row_stem = match name_by_index {
619            Some(col_idx) => sanitize_filename(record.get(col_idx).unwrap_or("")),
620            None => format!("row-{:04}", row_idx + 1),
621        };
622
623        // Build all output filenames for this row upfront.
624        let page_filenames: Vec<String> = (0..page_count)
625            .map(|pi| page_filename(&row_stem, pi, page_count))
626            .collect();
627
628        // Pre-flight collision check: if ANY filename is already taken, fail
629        // the whole row without writing anything.
630        let mut collided = false;
631        for fname in &page_filenames {
632            if used_names.contains(fname) {
633                push_failure(
634                    &mut rows,
635                    row_idx,
636                    row_key.clone(),
637                    format!("output filename collision: {fname}"),
638                );
639                collided = true;
640                break;
641            }
642        }
643        if collided {
644            continue;
645        }
646
647        // Compile and render every page; collect failures.
648        let mut page_failures: Vec<String> = Vec::new();
649        let mut page_pngs: Vec<(String, Vec<u8>)> = Vec::new();
650
651        for (page_index, page_fname) in page_filenames.iter().enumerate() {
652            let compile_result = compile_page(&row_doc, &fonts, page_index, None);
653
654            // Block on Error-severity compile diagnostics.
655            let hard_diags: Vec<String> = compile_result
656                .diagnostics
657                .iter()
658                .filter(|d| d.severity == Severity::Error)
659                .map(crate::commands::format_error_diag)
660                .collect();
661            if !hard_diags.is_empty() {
662                page_failures.push(format!(
663                    "page {}: compile error(s): {}",
664                    page_index + 1,
665                    hard_diags.join("; ")
666                ));
667                continue;
668            }
669
670            // Render to PNG bytes, using row-scoped assets when image bindings exist.
671            let png_result = match &row_assets {
672                Some(ra) => render_png(&compile_result.scene, &fonts, ra),
673                None => render_png(&compile_result.scene, &fonts, &template_assets),
674            };
675            match png_result {
676                Ok(bytes) => {
677                    page_pngs.push((page_fname.clone(), bytes));
678                }
679                Err(e) => {
680                    page_failures.push(format!("page {}: render error: {}", page_index + 1, e));
681                }
682            }
683        }
684
685        // If any page failed, the whole row fails atomically — write nothing.
686        if !page_failures.is_empty() {
687            push_failure(&mut rows, row_idx, row_key, page_failures.join("; "));
688            continue;
689        }
690
691        // All pages rendered successfully — write them out in page order.
692        // Defer registering names into `used_names` until every write succeeds;
693        // otherwise a mid-row write failure would permanently reserve those names.
694        let mut write_failed = false;
695        let mut newly_written: Vec<String> = Vec::new();
696        for (fname, bytes) in page_pngs {
697            let out_path = out_dir.join(&fname);
698            if let Err(e) = std::fs::write(&out_path, &bytes) {
699                push_failure(
700                    &mut rows,
701                    row_idx,
702                    row_key.clone(),
703                    format!("write error '{}': {}", out_path.display(), e),
704                );
705                write_failed = true;
706                break;
707            }
708            newly_written.push(fname);
709        }
710        if write_failed {
711            continue;
712        }
713        for fname in &newly_written {
714            used_names.insert(fname.clone());
715        }
716        rows.push(RowResult {
717            row: row_idx,
718            key: row_key,
719            outputs: newly_written,
720            failure: None,
721        });
722    }
723
724    Ok(MergeReport { rows })
725}
726
727// ── Helpers ───────────────────────────────────────────────────────────────────
728
729/// Push a failure [`RowResult`] and prepare for `continue`.
730///
731/// Extracted to remove the 8-way repetition of the identical struct literal
732/// inside the per-row loop body.
733fn push_failure(rows: &mut Vec<RowResult>, row: usize, key: Option<String>, reason: String) {
734    rows.push(RowResult {
735        row,
736        key,
737        outputs: Vec::new(),
738        failure: Some(reason),
739    });
740}
741
742/// Canonical asset-id used for per-row image bindings.
743///
744/// Must match between the op-building pass (AddAsset/SetAsset) and the
745/// asset-loading pass (register into row_provider) — keeping it here
746/// ensures they can never diverge.
747fn row_asset_id(row_idx: usize, column: &str) -> String {
748    format!("merge.row.{}.asset.{}", row_idx, column)
749}
750
751/// Output filename for one page of one row.
752///
753/// Single-page templates keep the bare stem (preserves existing behavior);
754/// multi-page templates use the `-page-N` suffix (1-based), matching
755/// `zenith render --all-pages`.
756fn page_filename(stem: &str, page_index: usize, page_count: usize) -> String {
757    if page_count == 1 {
758        format!("{stem}.png")
759    } else {
760        format!("{stem}-page-{}.png", page_index + 1)
761    }
762}
763
764/// Build a deterministic generation manifest from the merge inputs and report.
765/// Inputs are hashed as bytes; NO timestamps, absolute paths, or crate version
766/// are embedded, so identical inputs yield a byte-identical manifest. Only
767/// successfully-written rows are included (failed rows produced no output and
768/// their messages may vary across runs).
769pub fn build_manifest(
770    doc_src: &str,
771    csv_src: &str,
772    name_by: Option<&str>,
773    report: &MergeReport,
774) -> crate::json_types::MergeManifest {
775    use sha2::{Digest, Sha256};
776    // Format version of the manifest schema itself. Bump ONLY when the manifest
777    // structure changes — never on a routine crate release (that would break
778    // CI byte-identical comparison).
779    const MANIFEST_FORMAT_VERSION: &str = "1";
780
781    let source_sha256 = format!("{:x}", Sha256::digest(doc_src.as_bytes()));
782    let data_sha256 = format!("{:x}", Sha256::digest(csv_src.as_bytes()));
783    let rows = report
784        .rows
785        .iter()
786        .filter(|r| r.failure.is_none())
787        .map(|r| crate::json_types::ManifestRow {
788            row: r.row,
789            key: r.key.clone(),
790            outputs: r.outputs.clone(),
791        })
792        .collect();
793    crate::json_types::MergeManifest {
794        schema: "zenith-merge-manifest-v1",
795        generator: MANIFEST_FORMAT_VERSION,
796        source_sha256,
797        data_sha256,
798        name_by: name_by.map(str::to_owned),
799        rows,
800    }
801}
802
803/// Convert a completed [`MergeReport`] into the JSON-serialisable envelope.
804pub fn to_json_output(report: &MergeReport) -> MergeOutput {
805    let n_written = report.rows.iter().filter(|r| r.failure.is_none()).count();
806    let n_failed = report.rows.iter().filter(|r| r.failure.is_some()).count();
807    MergeOutput {
808        schema: "zenith-merge-v1",
809        total_rows: report.rows.len(),
810        written: n_written,
811        failed: n_failed,
812        rows: report
813            .rows
814            .iter()
815            .map(|r| MergeRowResult {
816                row: r.row,
817                key: r.key.clone(),
818                status: if r.failure.is_none() { "ok" } else { "failed" },
819                outputs: r.outputs.clone(),
820                diagnostics: match &r.failure {
821                    None => Vec::new(),
822                    Some(reason) => vec![DiagnosticJson {
823                        code: "merge.row.failed".to_owned(),
824                        severity: "error".to_owned(),
825                        message: reason.clone(),
826                        subject_id: None,
827                    }],
828                },
829            })
830            .collect(),
831    }
832}