Skip to main content

scrybe_tools/
figures.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Shawn Hartsock and contributors
3
4//! Export every Mermaid diagram in a document to sibling PNG figures.
5//!
6//! For `foo.md`, figures are written next to the document as
7//! `foo_fig_01.png`, `foo_fig_02.png`, … numbered 1-based in DOCUMENT order.
8//! The zero-pad width is `max(2, digits(total))` so a listing sorts the
9//! figures adjacent to their parent doc (because `_` (0x5F) sorts after `.`
10//! (0x2E), `foo.md` immediately precedes its `foo_fig_NN.png` siblings).
11//!
12//! Each PNG embeds its Mermaid source (per-artifact UUID + SHA-256) via the
13//! same `render_png` → `embed_with_uuid` path as the `mermaid_to_png` tool,
14//! so the diagrams are losslessly round-trippable with `extract`.
15//!
16//! [`plan_figures`] is PURE (enumeration + naming, no IO); [`export_figures`]
17//! is the IO shell (render + embed + write).
18
19use std::path::{Path, PathBuf};
20
21use anyhow::Context;
22use scrybe_core::Ast;
23use scrybe_mermaid_render::{render_png, source_sha256};
24use serde_json::{json, Value};
25
26use crate::{Ctx, DataSchema, EngineFault, Facet, ToolError, ToolOutcome, ToolSpec};
27
28/// Version of the `export_figures` tool's `data` payload.
29const DATA_VERSION: u32 = 1;
30
31/// A planned figure: where its PNG will be written and the Mermaid source it
32/// renders.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct FigurePlan {
35    /// Sibling PNG path: `<stem>_fig_<NN>.png` in the document's directory.
36    pub path: PathBuf,
37    /// The Mermaid source for this figure.
38    pub source: String,
39}
40
41/// The result of writing one figure.
42#[derive(Debug, Clone)]
43pub struct FigureResult {
44    /// The PNG path that was written (as a lossy UTF-8 string).
45    pub path: String,
46    /// The per-artifact UUID embedded in the PNG.
47    pub uuid: String,
48    /// SHA-256 of the Mermaid source.
49    pub sha256: String,
50    /// Size of the written PNG in bytes.
51    pub bytes: usize,
52}
53
54/// Plan the sibling PNG figures for every Mermaid block in `doc_source`.
55///
56/// PURE — enumerates diagrams (via [`Ast::mermaid_blocks`]) and builds sibling
57/// names in `doc_path`'s directory; performs no IO. Returns an empty vec when
58/// the document has no Mermaid blocks. When `doc_path` has no parent directory
59/// component (e.g. `foo.md`), figures are named relative to the current
60/// directory.
61pub fn plan_figures(doc_source: &str, doc_path: &Path) -> Vec<FigurePlan> {
62    let ast = Ast::parse(doc_source);
63    let blocks = ast.mermaid_blocks();
64    let total = blocks.len();
65    if total == 0 {
66        return Vec::new();
67    }
68    let width = figure_width(total);
69    let stem = doc_path
70        .file_stem()
71        .and_then(|s| s.to_str())
72        .unwrap_or("document");
73    // A path like `foo.md` has an empty parent component; treat that as the
74    // current directory so the figure name stays relative.
75    let dir = doc_path.parent().filter(|p| !p.as_os_str().is_empty());
76    blocks
77        .iter()
78        .enumerate()
79        .map(|(i, source)| {
80            let name = format!("{stem}_fig_{:0width$}.png", i + 1, width = width);
81            let path = match dir {
82                Some(d) => d.join(&name),
83                None => PathBuf::from(&name),
84            };
85            FigurePlan {
86                path,
87                source: (*source).to_string(),
88            }
89        })
90        .collect()
91}
92
93/// Zero-pad width for `total` figures: `max(2, digits(total))`.
94pub(crate) fn figure_width(total: usize) -> usize {
95    total.to_string().len().max(2)
96}
97
98/// Render and write every planned figure. The IO shell around [`plan_figures`].
99///
100/// For each plan: `render_png` → `embed_with_uuid` (fresh UUID) → `fs::write`.
101/// Uses the exact render+embed pattern of the `mermaid_to_png` tool, so every
102/// written PNG carries its embedded source.
103pub fn export_figures(doc_source: &str, doc_path: &Path) -> anyhow::Result<Vec<FigureResult>> {
104    let plans = plan_figures(doc_source, doc_path);
105    // Render + embed the whole set into memory FIRST, so a render/embed failure
106    // aborts before we delete or write any file on disk.
107    let mut prepared = Vec::with_capacity(plans.len());
108    for plan in plans {
109        // Same render → embed path as the `mermaid_to_png` tool.
110        let png = render_png(&plan.source)
111            .with_context(|| format!("render mermaid for {}", plan.path.display()))?;
112        let uuid = uuid::Uuid::new_v4().to_string();
113        let embedded = scrybe_mermaid::embed_with_uuid(&png, &plan.source, &uuid)
114            .with_context(|| format!("embed source for {}", plan.path.display()))?;
115        let sha256 = source_sha256(&plan.source);
116        prepared.push((plan.path, uuid, sha256, embedded));
117    }
118    // Prune this document's prior figure set before writing the fresh one, so a
119    // re-export after the diagram count shrinks (5 → 2) or crosses a zero-pad
120    // width boundary (…_fig_08 → …_fig_100) never leaves orphaned/duplicate
121    // figures interleaved beside the document. Only done when there IS a new set
122    // to write: exporting a diagram-less document is a no-op, never a deletion.
123    if !prepared.is_empty() {
124        prune_figures(doc_path)?;
125    }
126    let mut results = Vec::with_capacity(prepared.len());
127    for (path, uuid, sha256, embedded) in prepared {
128        std::fs::write(&path, &embedded).with_context(|| format!("write {}", path.display()))?;
129        results.push(FigureResult {
130            path: path.to_string_lossy().into_owned(),
131            uuid,
132            sha256,
133            bytes: embedded.len(),
134        });
135    }
136    Ok(results)
137}
138
139/// Remove this document's existing auto-generated figures (`<stem>_fig_<NN>.png`
140/// for any zero-pad width), so a re-export produces exactly the current set.
141/// Only files matching the generated pattern for this document's stem are
142/// touched; a missing directory is not an error.
143fn prune_figures(doc_path: &Path) -> anyhow::Result<()> {
144    let stem = doc_path
145        .file_stem()
146        .and_then(|s| s.to_str())
147        .unwrap_or("document");
148    let dir = match doc_path.parent().filter(|p| !p.as_os_str().is_empty()) {
149        Some(d) => d.to_path_buf(),
150        None => PathBuf::from("."),
151    };
152    let entries = match std::fs::read_dir(&dir) {
153        Ok(e) => e,
154        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
155        Err(e) => {
156            return Err(anyhow::Error::new(e).context(format!("read dir {}", dir.display())));
157        }
158    };
159    for entry in entries.flatten() {
160        if let Some(name) = entry.file_name().to_str() {
161            if is_figure_name(name, stem) {
162                let p = entry.path();
163                std::fs::remove_file(&p)
164                    .with_context(|| format!("remove stale figure {}", p.display()))?;
165            }
166        }
167    }
168    Ok(())
169}
170
171/// True when `name` is an auto-generated figure for `stem`: exactly
172/// `<stem>_fig_<NN>.png` where `NN` is one or more ASCII digits (any width).
173pub(crate) fn is_figure_name(name: &str, stem: &str) -> bool {
174    let Some(rest) = name.strip_prefix(stem) else {
175        return false;
176    };
177    let Some(rest) = rest.strip_prefix("_fig_") else {
178        return false;
179    };
180    let Some(digits) = rest.strip_suffix(".png") else {
181        return false;
182    };
183    !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
184}
185
186// ---------------------------------------------------------------------------
187// Tool spec
188// ---------------------------------------------------------------------------
189
190/// The `export_figures` tool spec (shared registry; `Mermaid` facet; mutating).
191pub(crate) fn spec() -> ToolSpec {
192    ToolSpec {
193        name: "export_figures",
194        description: "Export EVERY Mermaid diagram in a Markdown document to \
195             sibling PNG figures. For `foo.md`, writes `foo_fig_01.png`, \
196             `foo_fig_02.png`, … in the SAME directory, numbered 1-based in \
197             document order (zero-padded so they sort next to the document). \
198             Each PNG embeds its Mermaid source (a per-artifact UUID + the \
199             source's SHA-256), so the diagrams are losslessly round-trippable \
200             with `extract`. Re-exporting replaces the document's prior figure \
201             set (stale `<stem>_fig_NN.png` siblings are pruned), so the output \
202             always matches the current document. Input: `path` (the Markdown \
203             document on disk). Returns `{ count, figures: [{ path, uuid, \
204             sha256, bytes }] }`.",
205        input_schema,
206        data_schema: DataSchema {
207            version: DATA_VERSION,
208            schema: data_schema,
209        },
210        mutates: true,
211        facet: Facet::Mermaid,
212        handler,
213    }
214}
215
216fn input_schema() -> Value {
217    json!({
218        "type": "object",
219        "properties": {
220            "path": {
221                "type": "string",
222                "description": "Markdown document whose Mermaid diagrams to export."
223            }
224        },
225        "required": ["path"]
226    })
227}
228
229fn data_schema() -> Value {
230    crate::schema::envelope(
231        "export_figures",
232        DATA_VERSION,
233        json!({
234            "count": { "type": "integer" },
235            "figures": {
236                "type": "array",
237                "items": {
238                    "type": "object",
239                    "properties": {
240                        "path": { "type": "string" },
241                        "uuid": { "type": "string" },
242                        "sha256": { "type": "string" },
243                        "bytes": { "type": "integer" }
244                    },
245                    "required": ["path", "uuid", "sha256", "bytes"]
246                }
247            }
248        }),
249        &["count", "figures"],
250    )
251}
252
253fn handler(_ctx: &Ctx, args: &Value) -> Result<ToolOutcome, EngineFault> {
254    // Required args are gated by the dispatcher.
255    let path = args.get("path").and_then(Value::as_str).unwrap_or_default();
256    let base = json!({ "v": DATA_VERSION, "kind": "export_figures", "path": path });
257
258    // Read the document from disk (a missing/unreadable file is a business
259    // failure, not an engine fault).
260    let source = match std::fs::read_to_string(path) {
261        Ok(s) => s,
262        Err(e) => {
263            return Ok(ToolOutcome::fail(
264                base,
265                ToolError::new("read_failed", format!("could not read {path}: {e}")),
266            ))
267        }
268    };
269
270    Ok(match export_figures(&source, Path::new(path)) {
271        Ok(figs) => {
272            let figures: Vec<Value> = figs
273                .iter()
274                .map(|f| {
275                    json!({
276                        "path": f.path,
277                        "uuid": f.uuid,
278                        "sha256": f.sha256,
279                        "bytes": f.bytes,
280                    })
281                })
282                .collect();
283            ToolOutcome::ok(json!({
284                "v": DATA_VERSION,
285                "kind": "export_figures",
286                "count": figures.len(),
287                "figures": figures,
288            }))
289        }
290        Err(e) => ToolOutcome::fail(
291            base,
292            ToolError::new("export_failed", format!("could not export figures: {e}")),
293        ),
294    })
295}
296
297// ---------------------------------------------------------------------------
298// Tests
299// ---------------------------------------------------------------------------
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::{Ctx, Registry};
305
306    // -- pure: figure_width --------------------------------------------------
307
308    #[test]
309    fn width_is_at_least_two() {
310        assert_eq!(figure_width(1), 2);
311        assert_eq!(figure_width(2), 2);
312        assert_eq!(figure_width(9), 2);
313        assert_eq!(figure_width(10), 2);
314        assert_eq!(figure_width(99), 2);
315    }
316
317    #[test]
318    fn width_grows_with_magnitude() {
319        assert_eq!(figure_width(100), 3);
320        assert_eq!(figure_width(120), 3);
321        assert_eq!(figure_width(999), 3);
322        assert_eq!(figure_width(1000), 4);
323    }
324
325    // -- pure: plan_figures --------------------------------------------------
326
327    fn two_diagram_doc() -> &'static str {
328        "# Report\n\n```mermaid\ngraph TD; A-->B\n```\n\n\
329         Prose.\n\n```mermaid\ngraph LR; C-->D\n```\n"
330    }
331
332    #[test]
333    fn plan_names_siblings_with_two_pad() {
334        let plans = plan_figures(two_diagram_doc(), Path::new("/a/b/report.md"));
335        assert_eq!(plans.len(), 2);
336        assert_eq!(plans[0].path, PathBuf::from("/a/b/report_fig_01.png"));
337        assert_eq!(plans[1].path, PathBuf::from("/a/b/report_fig_02.png"));
338    }
339
340    #[test]
341    fn plan_preserves_document_order() {
342        let plans = plan_figures(two_diagram_doc(), Path::new("/a/b/report.md"));
343        assert_eq!(plans[0].source, "graph TD; A-->B");
344        assert_eq!(plans[1].source, "graph LR; C-->D");
345    }
346
347    #[test]
348    fn plan_zero_blocks_is_empty() {
349        let plans = plan_figures("# Just prose\n\nNo diagrams.\n", Path::new("/x/doc.md"));
350        assert!(plans.is_empty());
351    }
352
353    #[test]
354    fn plan_stem_comes_from_file_stem() {
355        let plans = plan_figures(
356            "```mermaid\ngraph TD; A-->B\n```\n",
357            Path::new("/deep/nested/my.notes.md"),
358        );
359        // `file_stem` strips only the final extension → stem is `my.notes`.
360        assert_eq!(
361            plans[0].path,
362            PathBuf::from("/deep/nested/my.notes_fig_01.png")
363        );
364    }
365
366    #[test]
367    fn plan_no_parent_uses_current_dir() {
368        let plans = plan_figures("```mermaid\ngraph TD; A-->B\n```\n", Path::new("foo.md"));
369        assert_eq!(plans.len(), 1);
370        assert_eq!(plans[0].path, PathBuf::from("foo_fig_01.png"));
371    }
372
373    #[test]
374    fn plan_pads_to_three_for_many() {
375        // Build a document with 100 diagrams; width must widen to 3.
376        let mut src = String::new();
377        for _ in 0..100 {
378            src.push_str("```mermaid\ngraph TD; A-->B\n```\n\n");
379        }
380        let plans = plan_figures(&src, Path::new("/d/big.md"));
381        assert_eq!(plans.len(), 100);
382        assert_eq!(plans[0].path, PathBuf::from("/d/big_fig_001.png"));
383        assert_eq!(plans[99].path, PathBuf::from("/d/big_fig_100.png"));
384    }
385
386    // -- IO shell: export_figures round-trip --------------------------------
387
388    #[test]
389    fn export_writes_embedded_pngs_that_round_trip() {
390        let dir = tempfile::tempdir().expect("tempdir");
391        let doc_path = dir.path().join("report.md");
392        let source = two_diagram_doc();
393
394        let results = export_figures(source, &doc_path).expect("export");
395        assert_eq!(results.len(), 2);
396
397        let fig1 = dir.path().join("report_fig_01.png");
398        let fig2 = dir.path().join("report_fig_02.png");
399        assert!(fig1.exists(), "fig 1 written");
400        assert!(fig2.exists(), "fig 2 written");
401
402        for (fig, expected) in [(&fig1, "graph TD; A-->B"), (&fig2, "graph LR; C-->D")] {
403            let bytes = std::fs::read(fig).expect("read png");
404            assert!(
405                bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
406                "real PNG signature"
407            );
408            let payload = scrybe_mermaid::extract(&bytes).expect("extract embedded");
409            assert_eq!(payload.source, expected, "source round-trips in order");
410            assert!(uuid::Uuid::parse_str(&payload.uuid).is_ok(), "uuid parses");
411        }
412    }
413
414    #[test]
415    fn export_zero_blocks_writes_nothing() {
416        let dir = tempfile::tempdir().expect("tempdir");
417        let doc_path = dir.path().join("plain.md");
418        let results = export_figures("# No diagrams here\n", &doc_path).expect("export");
419        assert!(results.is_empty());
420        // No sibling PNGs materialized.
421        let entries: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
422        assert!(
423            entries.is_empty(),
424            "no files written for a diagram-free doc"
425        );
426    }
427
428    // -- tool spec + handler -------------------------------------------------
429
430    #[test]
431    fn spec_is_mutating_mermaid_facet() {
432        let s = spec();
433        assert_eq!(s.name, "export_figures");
434        assert!(s.mutates);
435        assert_eq!(s.facet, Facet::Mermaid);
436    }
437
438    #[test]
439    fn tool_exports_from_disk_file() {
440        let dir = tempfile::tempdir().expect("tempdir");
441        let doc_path = dir.path().join("doc.md");
442        std::fs::write(&doc_path, two_diagram_doc()).expect("write doc");
443
444        let outcome = Registry::default()
445            .call(
446                "export_figures",
447                &Ctx::headless(),
448                &json!({ "path": doc_path.to_string_lossy() }),
449            )
450            .expect("dispatch");
451        assert!(outcome.is_ok(), "tool_error: {:?}", outcome.tool_error);
452
453        let d = &outcome.data;
454        assert_eq!(d["kind"], "export_figures");
455        assert_eq!(d["count"], 2);
456        assert_eq!(d["figures"].as_array().unwrap().len(), 2);
457    }
458
459    #[test]
460    fn tool_missing_file_is_business_error() {
461        let outcome = Registry::default()
462            .call(
463                "export_figures",
464                &Ctx::headless(),
465                &json!({ "path": "/no/such/path/doc.md" }),
466            )
467            .expect("dispatch");
468        assert!(!outcome.is_ok());
469        assert_eq!(outcome.tool_error.unwrap().code, "read_failed");
470    }
471
472    #[test]
473    fn tool_missing_path_arg_is_engine_fault() {
474        let err = Registry::default()
475            .call("export_figures", &Ctx::headless(), &json!({}))
476            .unwrap_err();
477        assert!(
478            matches!(err, crate::EngineFault::BadArgs(ref m) if m.contains("path")),
479            "expected BadArgs for missing path, got {err:?}"
480        );
481    }
482
483    // -- prune / re-export cleanliness --------------------------------------
484
485    #[test]
486    fn is_figure_name_matches_only_the_generated_pattern() {
487        // Generated figures for stem "foo", any zero-pad width.
488        assert!(is_figure_name("foo_fig_01.png", "foo"));
489        assert!(is_figure_name("foo_fig_001.png", "foo"));
490        assert!(is_figure_name("foo_fig_7.png", "foo"));
491        // Not figures.
492        assert!(!is_figure_name("foo.png", "foo"));
493        assert!(!is_figure_name("foo_fig_.png", "foo")); // no digits
494        assert!(!is_figure_name("foo_fig_ab.png", "foo")); // non-digit
495        assert!(!is_figure_name("foo_fig_01.jpg", "foo")); // wrong ext
496                                                           // A different document's figures must not match this stem.
497        assert!(!is_figure_name("foo_bar_fig_01.png", "foo"));
498        assert!(!is_figure_name("other_fig_01.png", "foo"));
499    }
500
501    #[test]
502    fn re_export_prunes_orphans_when_the_diagram_count_shrinks() {
503        let dir = tempfile::tempdir().expect("tempdir");
504        let doc_path = dir.path().join("report.md");
505
506        // First export: two diagrams → report_fig_01/02.png.
507        std::fs::write(&doc_path, two_diagram_doc()).expect("write doc");
508        assert_eq!(
509            export_figures(two_diagram_doc(), &doc_path).unwrap().len(),
510            2
511        );
512        // Simulate a stale figure from a prior, larger export (and a different
513        // width) that the current run must not leave behind.
514        std::fs::write(dir.path().join("report_fig_03.png"), b"stale").unwrap();
515        std::fs::write(dir.path().join("report_fig_003.png"), b"stale-wide").unwrap();
516        // An unrelated sibling and another doc's figure must survive.
517        std::fs::write(dir.path().join("keepme.png"), b"keep").unwrap();
518        std::fs::write(dir.path().join("other_fig_01.png"), b"other").unwrap();
519
520        // Re-export with a single diagram → exactly report_fig_01.png, orphans gone.
521        let one = "# One\n\n```mermaid\ngraph TD; A-->B\n```\n";
522        let results = export_figures(one, &doc_path).expect("re-export");
523        assert_eq!(results.len(), 1);
524        assert!(dir.path().join("report_fig_01.png").exists());
525        assert!(
526            !dir.path().join("report_fig_02.png").exists(),
527            "shrunk orphan pruned"
528        );
529        assert!(
530            !dir.path().join("report_fig_03.png").exists(),
531            "stale orphan pruned"
532        );
533        assert!(
534            !dir.path().join("report_fig_003.png").exists(),
535            "wide-width orphan pruned"
536        );
537        assert!(
538            dir.path().join("keepme.png").exists(),
539            "unrelated file kept"
540        );
541        assert!(
542            dir.path().join("other_fig_01.png").exists(),
543            "other doc's figure kept"
544        );
545    }
546
547    #[test]
548    fn exporting_a_diagramless_document_never_deletes_siblings() {
549        let dir = tempfile::tempdir().expect("tempdir");
550        let doc_path = dir.path().join("notes.md");
551        std::fs::write(&doc_path, "# Notes\n\nNo diagrams here.\n").expect("write doc");
552        // A pre-existing figure-shaped sibling must NOT be deleted by a no-op export.
553        std::fs::write(dir.path().join("notes_fig_01.png"), b"preexisting").unwrap();
554
555        let results = export_figures("# Notes\n\nNo diagrams here.\n", &doc_path).expect("export");
556        assert!(results.is_empty(), "no diagrams → no figures written");
557        assert!(
558            dir.path().join("notes_fig_01.png").exists(),
559            "a diagram-less export must not delete siblings"
560        );
561    }
562}