use std::path::{Path, PathBuf};
use anyhow::Context;
use scrybe_core::Ast;
use scrybe_mermaid_render::{render_png, source_sha256};
use serde_json::{json, Value};
use crate::{Ctx, DataSchema, EngineFault, Facet, ToolError, ToolOutcome, ToolSpec};
const DATA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FigurePlan {
pub path: PathBuf,
pub source: String,
}
#[derive(Debug, Clone)]
pub struct FigureResult {
pub path: String,
pub uuid: String,
pub sha256: String,
pub bytes: usize,
}
pub fn plan_figures(doc_source: &str, doc_path: &Path) -> Vec<FigurePlan> {
let ast = Ast::parse(doc_source);
let blocks = ast.mermaid_blocks();
let total = blocks.len();
if total == 0 {
return Vec::new();
}
let width = figure_width(total);
let stem = doc_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("document");
let dir = doc_path.parent().filter(|p| !p.as_os_str().is_empty());
blocks
.iter()
.enumerate()
.map(|(i, source)| {
let name = format!("{stem}_fig_{:0width$}.png", i + 1, width = width);
let path = match dir {
Some(d) => d.join(&name),
None => PathBuf::from(&name),
};
FigurePlan {
path,
source: (*source).to_string(),
}
})
.collect()
}
pub(crate) fn figure_width(total: usize) -> usize {
total.to_string().len().max(2)
}
pub fn export_figures(doc_source: &str, doc_path: &Path) -> anyhow::Result<Vec<FigureResult>> {
let plans = plan_figures(doc_source, doc_path);
let mut prepared = Vec::with_capacity(plans.len());
for plan in plans {
let png = render_png(&plan.source)
.with_context(|| format!("render mermaid for {}", plan.path.display()))?;
let uuid = uuid::Uuid::new_v4().to_string();
let embedded = scrybe_mermaid::embed_with_uuid(&png, &plan.source, &uuid)
.with_context(|| format!("embed source for {}", plan.path.display()))?;
let sha256 = source_sha256(&plan.source);
prepared.push((plan.path, uuid, sha256, embedded));
}
if !prepared.is_empty() {
prune_figures(doc_path)?;
}
let mut results = Vec::with_capacity(prepared.len());
for (path, uuid, sha256, embedded) in prepared {
std::fs::write(&path, &embedded).with_context(|| format!("write {}", path.display()))?;
results.push(FigureResult {
path: path.to_string_lossy().into_owned(),
uuid,
sha256,
bytes: embedded.len(),
});
}
Ok(results)
}
fn prune_figures(doc_path: &Path) -> anyhow::Result<()> {
let stem = doc_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("document");
let dir = match doc_path.parent().filter(|p| !p.as_os_str().is_empty()) {
Some(d) => d.to_path_buf(),
None => PathBuf::from("."),
};
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => {
return Err(anyhow::Error::new(e).context(format!("read dir {}", dir.display())));
}
};
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
if is_figure_name(name, stem) {
let p = entry.path();
std::fs::remove_file(&p)
.with_context(|| format!("remove stale figure {}", p.display()))?;
}
}
}
Ok(())
}
pub(crate) fn is_figure_name(name: &str, stem: &str) -> bool {
let Some(rest) = name.strip_prefix(stem) else {
return false;
};
let Some(rest) = rest.strip_prefix("_fig_") else {
return false;
};
let Some(digits) = rest.strip_suffix(".png") else {
return false;
};
!digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
}
pub(crate) fn spec() -> ToolSpec {
ToolSpec {
name: "export_figures",
description: "Export EVERY Mermaid diagram in a Markdown document to \
sibling PNG figures. For `foo.md`, writes `foo_fig_01.png`, \
`foo_fig_02.png`, … in the SAME directory, numbered 1-based in \
document order (zero-padded so they sort next to the document). \
Each PNG embeds its Mermaid source (a per-artifact UUID + the \
source's SHA-256), so the diagrams are losslessly round-trippable \
with `extract`. Re-exporting replaces the document's prior figure \
set (stale `<stem>_fig_NN.png` siblings are pruned), so the output \
always matches the current document. Input: `path` (the Markdown \
document on disk). Returns `{ count, figures: [{ path, uuid, \
sha256, bytes }] }`.",
input_schema,
data_schema: DataSchema {
version: DATA_VERSION,
schema: data_schema,
},
mutates: true,
facet: Facet::Mermaid,
handler,
}
}
fn input_schema() -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Markdown document whose Mermaid diagrams to export."
}
},
"required": ["path"]
})
}
fn data_schema() -> Value {
crate::schema::envelope(
"export_figures",
DATA_VERSION,
json!({
"count": { "type": "integer" },
"figures": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": { "type": "string" },
"uuid": { "type": "string" },
"sha256": { "type": "string" },
"bytes": { "type": "integer" }
},
"required": ["path", "uuid", "sha256", "bytes"]
}
}
}),
&["count", "figures"],
)
}
fn handler(_ctx: &Ctx, args: &Value) -> Result<ToolOutcome, EngineFault> {
let path = args.get("path").and_then(Value::as_str).unwrap_or_default();
let base = json!({ "v": DATA_VERSION, "kind": "export_figures", "path": path });
let source = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
return Ok(ToolOutcome::fail(
base,
ToolError::new("read_failed", format!("could not read {path}: {e}")),
))
}
};
Ok(match export_figures(&source, Path::new(path)) {
Ok(figs) => {
let figures: Vec<Value> = figs
.iter()
.map(|f| {
json!({
"path": f.path,
"uuid": f.uuid,
"sha256": f.sha256,
"bytes": f.bytes,
})
})
.collect();
ToolOutcome::ok(json!({
"v": DATA_VERSION,
"kind": "export_figures",
"count": figures.len(),
"figures": figures,
}))
}
Err(e) => ToolOutcome::fail(
base,
ToolError::new("export_failed", format!("could not export figures: {e}")),
),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Ctx, Registry};
#[test]
fn width_is_at_least_two() {
assert_eq!(figure_width(1), 2);
assert_eq!(figure_width(2), 2);
assert_eq!(figure_width(9), 2);
assert_eq!(figure_width(10), 2);
assert_eq!(figure_width(99), 2);
}
#[test]
fn width_grows_with_magnitude() {
assert_eq!(figure_width(100), 3);
assert_eq!(figure_width(120), 3);
assert_eq!(figure_width(999), 3);
assert_eq!(figure_width(1000), 4);
}
fn two_diagram_doc() -> &'static str {
"# Report\n\n```mermaid\ngraph TD; A-->B\n```\n\n\
Prose.\n\n```mermaid\ngraph LR; C-->D\n```\n"
}
#[test]
fn plan_names_siblings_with_two_pad() {
let plans = plan_figures(two_diagram_doc(), Path::new("/a/b/report.md"));
assert_eq!(plans.len(), 2);
assert_eq!(plans[0].path, PathBuf::from("/a/b/report_fig_01.png"));
assert_eq!(plans[1].path, PathBuf::from("/a/b/report_fig_02.png"));
}
#[test]
fn plan_preserves_document_order() {
let plans = plan_figures(two_diagram_doc(), Path::new("/a/b/report.md"));
assert_eq!(plans[0].source, "graph TD; A-->B");
assert_eq!(plans[1].source, "graph LR; C-->D");
}
#[test]
fn plan_zero_blocks_is_empty() {
let plans = plan_figures("# Just prose\n\nNo diagrams.\n", Path::new("/x/doc.md"));
assert!(plans.is_empty());
}
#[test]
fn plan_stem_comes_from_file_stem() {
let plans = plan_figures(
"```mermaid\ngraph TD; A-->B\n```\n",
Path::new("/deep/nested/my.notes.md"),
);
assert_eq!(
plans[0].path,
PathBuf::from("/deep/nested/my.notes_fig_01.png")
);
}
#[test]
fn plan_no_parent_uses_current_dir() {
let plans = plan_figures("```mermaid\ngraph TD; A-->B\n```\n", Path::new("foo.md"));
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].path, PathBuf::from("foo_fig_01.png"));
}
#[test]
fn plan_pads_to_three_for_many() {
let mut src = String::new();
for _ in 0..100 {
src.push_str("```mermaid\ngraph TD; A-->B\n```\n\n");
}
let plans = plan_figures(&src, Path::new("/d/big.md"));
assert_eq!(plans.len(), 100);
assert_eq!(plans[0].path, PathBuf::from("/d/big_fig_001.png"));
assert_eq!(plans[99].path, PathBuf::from("/d/big_fig_100.png"));
}
#[test]
fn export_writes_embedded_pngs_that_round_trip() {
let dir = tempfile::tempdir().expect("tempdir");
let doc_path = dir.path().join("report.md");
let source = two_diagram_doc();
let results = export_figures(source, &doc_path).expect("export");
assert_eq!(results.len(), 2);
let fig1 = dir.path().join("report_fig_01.png");
let fig2 = dir.path().join("report_fig_02.png");
assert!(fig1.exists(), "fig 1 written");
assert!(fig2.exists(), "fig 2 written");
for (fig, expected) in [(&fig1, "graph TD; A-->B"), (&fig2, "graph LR; C-->D")] {
let bytes = std::fs::read(fig).expect("read png");
assert!(
bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
"real PNG signature"
);
let payload = scrybe_mermaid::extract(&bytes).expect("extract embedded");
assert_eq!(payload.source, expected, "source round-trips in order");
assert!(uuid::Uuid::parse_str(&payload.uuid).is_ok(), "uuid parses");
}
}
#[test]
fn export_zero_blocks_writes_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let doc_path = dir.path().join("plain.md");
let results = export_figures("# No diagrams here\n", &doc_path).expect("export");
assert!(results.is_empty());
let entries: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
assert!(
entries.is_empty(),
"no files written for a diagram-free doc"
);
}
#[test]
fn spec_is_mutating_mermaid_facet() {
let s = spec();
assert_eq!(s.name, "export_figures");
assert!(s.mutates);
assert_eq!(s.facet, Facet::Mermaid);
}
#[test]
fn tool_exports_from_disk_file() {
let dir = tempfile::tempdir().expect("tempdir");
let doc_path = dir.path().join("doc.md");
std::fs::write(&doc_path, two_diagram_doc()).expect("write doc");
let outcome = Registry::default()
.call(
"export_figures",
&Ctx::headless(),
&json!({ "path": doc_path.to_string_lossy() }),
)
.expect("dispatch");
assert!(outcome.is_ok(), "tool_error: {:?}", outcome.tool_error);
let d = &outcome.data;
assert_eq!(d["kind"], "export_figures");
assert_eq!(d["count"], 2);
assert_eq!(d["figures"].as_array().unwrap().len(), 2);
}
#[test]
fn tool_missing_file_is_business_error() {
let outcome = Registry::default()
.call(
"export_figures",
&Ctx::headless(),
&json!({ "path": "/no/such/path/doc.md" }),
)
.expect("dispatch");
assert!(!outcome.is_ok());
assert_eq!(outcome.tool_error.unwrap().code, "read_failed");
}
#[test]
fn tool_missing_path_arg_is_engine_fault() {
let err = Registry::default()
.call("export_figures", &Ctx::headless(), &json!({}))
.unwrap_err();
assert!(
matches!(err, crate::EngineFault::BadArgs(ref m) if m.contains("path")),
"expected BadArgs for missing path, got {err:?}"
);
}
#[test]
fn is_figure_name_matches_only_the_generated_pattern() {
assert!(is_figure_name("foo_fig_01.png", "foo"));
assert!(is_figure_name("foo_fig_001.png", "foo"));
assert!(is_figure_name("foo_fig_7.png", "foo"));
assert!(!is_figure_name("foo.png", "foo"));
assert!(!is_figure_name("foo_fig_.png", "foo")); assert!(!is_figure_name("foo_fig_ab.png", "foo")); assert!(!is_figure_name("foo_fig_01.jpg", "foo")); assert!(!is_figure_name("foo_bar_fig_01.png", "foo"));
assert!(!is_figure_name("other_fig_01.png", "foo"));
}
#[test]
fn re_export_prunes_orphans_when_the_diagram_count_shrinks() {
let dir = tempfile::tempdir().expect("tempdir");
let doc_path = dir.path().join("report.md");
std::fs::write(&doc_path, two_diagram_doc()).expect("write doc");
assert_eq!(
export_figures(two_diagram_doc(), &doc_path).unwrap().len(),
2
);
std::fs::write(dir.path().join("report_fig_03.png"), b"stale").unwrap();
std::fs::write(dir.path().join("report_fig_003.png"), b"stale-wide").unwrap();
std::fs::write(dir.path().join("keepme.png"), b"keep").unwrap();
std::fs::write(dir.path().join("other_fig_01.png"), b"other").unwrap();
let one = "# One\n\n```mermaid\ngraph TD; A-->B\n```\n";
let results = export_figures(one, &doc_path).expect("re-export");
assert_eq!(results.len(), 1);
assert!(dir.path().join("report_fig_01.png").exists());
assert!(
!dir.path().join("report_fig_02.png").exists(),
"shrunk orphan pruned"
);
assert!(
!dir.path().join("report_fig_03.png").exists(),
"stale orphan pruned"
);
assert!(
!dir.path().join("report_fig_003.png").exists(),
"wide-width orphan pruned"
);
assert!(
dir.path().join("keepme.png").exists(),
"unrelated file kept"
);
assert!(
dir.path().join("other_fig_01.png").exists(),
"other doc's figure kept"
);
}
#[test]
fn exporting_a_diagramless_document_never_deletes_siblings() {
let dir = tempfile::tempdir().expect("tempdir");
let doc_path = dir.path().join("notes.md");
std::fs::write(&doc_path, "# Notes\n\nNo diagrams here.\n").expect("write doc");
std::fs::write(dir.path().join("notes_fig_01.png"), b"preexisting").unwrap();
let results = export_figures("# Notes\n\nNo diagrams here.\n", &doc_path).expect("export");
assert!(results.is_empty(), "no diagrams → no figures written");
assert!(
dir.path().join("notes_fig_01.png").exists(),
"a diagram-less export must not delete siblings"
);
}
}