1use 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
28const DATA_VERSION: u32 = 1;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct FigurePlan {
35 pub path: PathBuf,
37 pub source: String,
39}
40
41#[derive(Debug, Clone)]
43pub struct FigureResult {
44 pub path: String,
46 pub uuid: String,
48 pub sha256: String,
50 pub bytes: usize,
52}
53
54pub 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 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
93pub(crate) fn figure_width(total: usize) -> usize {
95 total.to_string().len().max(2)
96}
97
98pub fn export_figures(doc_source: &str, doc_path: &Path) -> anyhow::Result<Vec<FigureResult>> {
104 let plans = plan_figures(doc_source, doc_path);
105 let mut prepared = Vec::with_capacity(plans.len());
108 for plan in plans {
109 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 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
139fn 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
171pub(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
186pub(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 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 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#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::{Ctx, Registry};
305
306 #[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 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 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 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 #[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 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 #[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 #[test]
486 fn is_figure_name_matches_only_the_generated_pattern() {
487 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 assert!(!is_figure_name("foo.png", "foo"));
493 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"));
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 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 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 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 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 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}