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