From 64d77ec5dccbc35d26d90a114deb0845b2371a7f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 29 Jun 2026 11:19:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(draw):=20d2=20diagram=20template=20?= =?UTF-8?q?=E2=80=94=20compile=20d2=20source=20to=20SVG=20(T-494)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The d2 drawing template compiles a diagram's source to SVG through the d2 binary (resolved via the D-104 path layer), then paints it with the same renderer the svg card uses. `clide draw --file x.d2` infers the type from the extension; `.svg` files render directly. Template handlers now return a DrawResult so a compile failure or an unresolved d2 surface as an honest userError with an install hint, not a generic "no SVG". Real d2 0.7.1 verified end to end. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 20 ++++++ .pql/changelog/tickets/2026-06.sql | 22 +++++++ CHANGELOG.md | 4 ++ lib/main.dart | 3 +- lib/src/daemon/draw_commands.dart | 28 +++++--- lib/src/draw/d2_template.dart | 80 +++++++++++++++++++++++ lib/src/draw/draw_dispatch.dart | 9 +-- test/daemon/draw_commands_test.dart | 20 +++++- test/draw/d2_template_test.dart | 59 +++++++++++++++++ test/draw/draw_dispatch_test.dart | 8 +-- 10 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 lib/src/draw/d2_template.dart create mode 100644 test/draw/d2_template_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 608f35e2..f13a5571 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -8867,3 +8867,23 @@ First run: auto-detect each tool ONCE — probe the login-shell PATH + well-know Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494). RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.', NULL, '2026-06-28 21:08:40', '2026-06-28 21:08:40.178', '2026-06-28 21:08:40.178', NULL, 'f155f8e589cd19c3cc52409495d24f50', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'description', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name → absolute path), consulted FIRST. + +Resolution order: (1) explicit settings path → use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH. + +First run: auto-detect each tool ONCE — probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) — and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action. + +Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494). + +RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name → absolute path), consulted FIRST. + +Resolution order: (1) explicit settings path → use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH. + +First run: auto-detect each tool ONCE — probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) — and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action. + +Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494). + +RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools. + +DONE (2026-06-29): full D-104 surface landed + fast suite green. UI complete — Tools settings category (per-tool path field app.tools. + Re-detect action tools.detect, live resolver rebuild on edit, en+nl catalogs) in new builtin.tools-settings; Problems panel flags a stale/broken pinned path. Storage is per-tool keys + a first-run marker. Commits dab0b319/0adccc5c/62418b40/bd4a8332/3be063d6/eac9612a/67edf732.', NULL, '2026-06-29 07:32:43', '2026-06-29 07:32:43.640', '2026-06-29 07:32:43.640', NULL, '0dfb6eeb1bcde2f386379f029fa4d0f6', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'status', 'in_progress', 'done', NULL, '2026-06-29 07:32:43', '2026-06-29 07:32:43.682', '2026-06-29 07:32:43.682', NULL, '45aeefee25353af62e4aa9f7b1f3265c', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 7e7d6624..79b321a7 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -10656,3 +10656,25 @@ First run: auto-detect each tool ONCE — probe the login-shell PATH + well-know Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494). RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.', 'in_progress', 'medium', NULL, NULL, 'D-104', '2026-06-28 17:50:02.366', '2026-06-28 21:08:40.177', NULL, '7e1f7077abd788309d38a51f8a3b1348', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'task', NULL, 'Supporter-binary path resolution: explicit user-scope overrides + first-run auto-detect', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name → absolute path), consulted FIRST. + +Resolution order: (1) explicit settings path → use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH. + +First run: auto-detect each tool ONCE — probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) — and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action. + +Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494). + +RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools. + +DONE (2026-06-29): full D-104 surface landed + fast suite green. UI complete — Tools settings category (per-tool path field app.tools. + Re-detect action tools.detect, live resolver rebuild on edit, en+nl catalogs) in new builtin.tools-settings; Problems panel flags a stale/broken pinned path. Storage is per-tool keys + a first-run marker. Commits dab0b319/0adccc5c/62418b40/bd4a8332/3be063d6/eac9612a/67edf732.', 'in_progress', 'medium', NULL, NULL, 'D-104', '2026-06-28 17:50:02.366', '2026-06-29 07:32:43.640', NULL, '5047cde2263e4df79fef6f4718f948fa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'task', NULL, 'Supporter-binary path resolution: explicit user-scope overrides + first-run auto-detect', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name → absolute path), consulted FIRST. + +Resolution order: (1) explicit settings path → use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH. + +First run: auto-detect each tool ONCE — probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) — and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action. + +Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494). + +RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools. + +DONE (2026-06-29): full D-104 surface landed + fast suite green. UI complete — Tools settings category (per-tool path field app.tools. + Re-detect action tools.detect, live resolver rebuild on edit, en+nl catalogs) in new builtin.tools-settings; Problems panel flags a stale/broken pinned path. Storage is per-tool keys + a first-run marker. Commits dab0b319/0adccc5c/62418b40/bd4a8332/3be063d6/eac9612a/67edf732.', 'done', 'medium', NULL, NULL, 'D-104', '2026-06-28 17:50:02.366', '2026-06-29 07:32:43.682', NULL, '70724bd4ac9c1da06f8d1c252492a4b0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b58f514..29892474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **D2 diagram cards.** `clide draw --file diagram.d2` compiles a d2 diagram to + SVG (via the `d2` binary) and renders it in the conversation; `template:"d2"` + with inline source works too, and a `.svg` file renders directly. Honest error + if d2 isn't installed or the source doesn't compile. (T-494, D-103) - **Tool path resolution + settings.** clide resolves supporter binaries (claude, d2) via an explicit per-tool path, else PATH and the common install dirs — now including Homebrew-on-Linux. A Tools settings category edits the paths and diff --git a/lib/main.dart b/lib/main.dart index 11c5aed9..2a50ea23 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -41,6 +41,7 @@ import 'package:clide/clide.dart' show clideVersion; import 'package:clide/src/daemon/claude_account_commands.dart'; import 'package:clide/src/daemon/dispatcher.dart'; import 'package:clide/src/daemon/draw_commands.dart'; +import 'package:clide/src/draw/d2_template.dart' show d2TemplateHandler; import 'package:clide/src/daemon/editor_commands.dart'; import 'package:clide/src/daemon/files_commands.dart'; import 'package:clide/src/daemon/git_commands.dart'; @@ -376,7 +377,7 @@ Future main() async { registerDrawCommands( dispatcher, () => kernelMessages?.publish, - registry: DrawingRegistry(), + registry: DrawingRegistry()..register('d2', d2TemplateHandler()), readFile: (path) async { final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path'); try { diff --git a/lib/src/daemon/draw_commands.dart b/lib/src/daemon/draw_commands.dart index 83b3729c..f3a34313 100644 --- a/lib/src/daemon/draw_commands.dart +++ b/lib/src/daemon/draw_commands.dart @@ -18,7 +18,7 @@ import 'dart:convert'; import '../draw/draw_dispatch.dart'; import '../draw/draw_doc.dart'; -export '../draw/draw_dispatch.dart' show DrawingFileReader, DrawingRegistry, DrawingTemplateHandler; +export '../draw/draw_dispatch.dart' show DrawErr, DrawOk, DrawResult, DrawingFileReader, DrawingRegistry, DrawingTemplateHandler; import '../ipc/command_schema.dart'; import '../ipc/envelope.dart'; import '../ipc/schema_v1.dart'; @@ -66,16 +66,26 @@ Future _draw(IpcRequest req, MessagePublisher? Function() publisher ); } - Object? decoded; - try { - decoded = jsonDecode(raw); - } on FormatException catch (e) { - return _userErr(req.id, 'invalid JSON in $file: ${e.message}'); + // Type inference from the extension (T-494): a `.d2`/`.svg` file is the raw + // source, not a JSON envelope — wrap it in the matching doc. Everything else + // is a drawing-card JSON document. + final lower = file.toLowerCase(); + final DrawingCardDoc? doc; + if (lower.endsWith('.d2')) { + doc = DrawingCardDoc(template: 'd2', fields: {'template': 'd2', 'source': raw}); + } else if (lower.endsWith('.svg')) { + doc = DrawingCardDoc(svg: raw); + } else { + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException catch (e) { + return _userErr(req.id, 'invalid JSON in $file: ${e.message}'); + } + doc = parseDrawingCardDoc(decoded); + if (doc == null) return _userErr(req.id, 'a drawing-card document must be a JSON object'); } - final doc = parseDrawingCardDoc(decoded); - if (doc == null) return _userErr(req.id, 'a drawing-card document must be a JSON object'); - final result = await resolveDrawingSvg(doc, registry, readFile: readFile); if (result is DrawErr) return _userErr(req.id, result.message); final svg = (result as DrawOk).svg; diff --git a/lib/src/draw/d2_template.dart b/lib/src/draw/d2_template.dart new file mode 100644 index 00000000..865418d0 --- /dev/null +++ b/lib/src/draw/d2_template.dart @@ -0,0 +1,80 @@ +/// The `d2` drawing-card template (T-494 / D-91 / D-103). +/// +/// A d2 diagram is just an SVG card with a compile step in front: the doc's +/// `source` (d2 diagram text) is compiled to SVG, then painted by the SAME +/// renderer the `svg` card uses (T-320). The compile shells out to the `d2` +/// binary — the supporter-tool pattern (peer of pql/git, D-3/D-5), resolved via +/// the D-104 path layer (T-495). No second core language, no vendored Go. +/// +/// Honest failures (D-103): a missing source, an unresolved `d2`, or a compile +/// error each return a [DrawErr] with a user-facing message + hint, which the +/// command layer turns into an IpcError userError — never a throw. +/// +/// Flutter-free: pure Dart (dart:io), runs under `dart test`. The process spawn +/// is injectable ([D2Compiler]) so the handler is tested without a real binary. +library; + +import 'dart:convert'; +import 'dart:io'; + +import '../env/supporter_binaries.dart'; +import 'draw_dispatch.dart'; + +/// Compiles d2 [source] to an SVG [DrawResult]. Injected into +/// [d2TemplateHandler] so it is testable; the default is [d2CompileViaBinary]. +typedef D2Compiler = Future Function(String source); + +/// Handler for `template: "d2"` — reads the doc's `source` field (the diagram +/// text) and compiles it. Register this in the [DrawingRegistry]. +DrawingTemplateHandler d2TemplateHandler({D2Compiler compile = d2CompileViaBinary}) { + return (doc) async { + final source = doc.fields['source']; + if (source is! String || source.trim().isEmpty) { + return const DrawErr('the d2 template needs a non-empty "source" field (the d2 diagram text)'); + } + return compile(source); + }; +} + +/// One run of the d2 binary: its exit code, stdout (SVG) and stderr. +typedef D2RunResult = ({int code, String out, String err}); + +/// Runs the d2 [exe] over [source]. Injected so [d2CompileViaBinary] is tested +/// without a real binary; the default is [_spawnD2]. +typedef D2Run = Future Function(String exe, String source); + +/// Resolve the `d2` binary (D-104) and compile [source] through it. Failure keys +/// off the exit code — d2 logs `success:` to stderr on a clean compile, so a +/// non-empty stderr is not itself an error. [resolveD2] and [run] are injectable +/// for testing; the defaults use [activeSupporterBinaries] and a real spawn. +Future d2CompileViaBinary(String source, {String? Function()? resolveD2, D2Run run = _spawnD2}) async { + final d2 = (resolveD2 ?? _defaultResolveD2)(); + if (d2 == null) { + return const DrawErr('d2 not found — install it from https://d2lang.com, or set its path in Settings → Tools'); + } + final D2RunResult r; + try { + r = await run(d2, source); + } catch (e) { + return DrawErr('could not run d2 ($d2): $e'); + } + if (r.code != 0) { + return DrawErr('d2 compile failed: ${r.err.trim().isEmpty ? 'exit ${r.code}' : r.err.trim()}'); + } + if (r.out.trim().isEmpty) return const DrawErr('d2 produced no SVG'); + return DrawOk(r.out); +} + +String? _defaultResolveD2() => (activeSupporterBinaries ?? SupporterBinaries()).resolve('d2'); + +/// `d2 - -` — read source on stdin, write SVG to stdout. Drains stdout/stderr +/// concurrently with the stdin write to avoid a pipe deadlock on a big diagram. +Future _spawnD2(String exe, String source) async { + final proc = await Process.start(exe, const ['-', '-']); + final outF = proc.stdout.transform(utf8.decoder).join(); + final errF = proc.stderr.transform(utf8.decoder).join(); + proc.stdin.write(source); + await proc.stdin.close(); + final code = await proc.exitCode; + return (code: code, out: await outF, err: await errF); +} diff --git a/lib/src/draw/draw_dispatch.dart b/lib/src/draw/draw_dispatch.dart index be57bb2f..1186e5b8 100644 --- a/lib/src/draw/draw_dispatch.dart +++ b/lib/src/draw/draw_dispatch.dart @@ -17,8 +17,10 @@ library; import 'draw_doc.dart'; -/// Produces an SVG string for a template-mode doc, or `null` on failure. -typedef DrawingTemplateHandler = Future Function(DrawingCardDoc doc); +/// Lowers a template-mode doc to SVG, as a [DrawResult] — [DrawOk] with the SVG +/// or [DrawErr] carrying an honest, user-facing message (e.g. a compile failure +/// or an unresolved tool, with a hint). +typedef DrawingTemplateHandler = Future Function(DrawingCardDoc doc); /// Reads a file's contents, or `null` if unreadable. Injected for testability. typedef DrawingFileReader = Future Function(String path); @@ -65,6 +67,5 @@ Future resolveDrawingSvg(DrawingCardDoc doc, DrawingRegistry registr final handler = registry.handlerFor(doc.template!); if (handler == null) return DrawErr('unknown drawing template: ${doc.template}'); - final svg = await handler(doc); - return svg == null ? DrawErr('template ${doc.template} produced no SVG') : DrawOk(svg); + return handler(doc); } diff --git a/test/daemon/draw_commands_test.dart b/test/daemon/draw_commands_test.dart index 378a6ba9..6caa8c79 100644 --- a/test/daemon/draw_commands_test.dart +++ b/test/daemon/draw_commands_test.dart @@ -48,7 +48,7 @@ void main() { test('template doc lowers via the registry before publishing', () async { wire(); - registry.register('d2', (doc) async => ''); + registry.register('d2', (doc) async => DrawOk('')); files['c.json'] = '{"template":"d2","source":"a -> b"}'; final r = await draw('c.json'); expect(r.ok, isTrue, reason: r.error?.message); @@ -56,6 +56,24 @@ void main() { expect(r.data['template'], 'd2'); }); + test('a .d2 file is wrapped as a d2 template and lowered (T-494)', () async { + wire(); + registry.register('d2', (doc) async => DrawOk('')); + files['pipeline.d2'] = 'a -> b'; + final r = await draw('pipeline.d2'); + expect(r.ok, isTrue, reason: r.error?.message); + expect(published.single.data['svg'], ''); + expect(r.data['template'], 'd2'); + }); + + test('a .svg file renders as a primitive card (T-494)', () async { + wire(); + files['logo.svg'] = ''; + final r = await draw('logo.svg'); + expect(r.ok, isTrue, reason: r.error?.message); + expect(published.single.data['svg'], ''); + }); + test('a missing file → notFound, nothing published', () async { wire(); final r = await draw('nope.json'); diff --git a/test/draw/d2_template_test.dart b/test/draw/d2_template_test.dart new file mode 100644 index 00000000..1651605d --- /dev/null +++ b/test/draw/d2_template_test.dart @@ -0,0 +1,59 @@ +import 'package:clide/src/draw/d2_template.dart'; +import 'package:clide/src/draw/draw_dispatch.dart'; +import 'package:clide/src/draw/draw_doc.dart'; +import 'package:test/test.dart'; + +void main() { + DrawingCardDoc d2doc(Object? source) => DrawingCardDoc(template: 'd2', fields: {'template': 'd2', if (source != null) 'source': source}); + + group('d2TemplateHandler', () { + test('compiles the source field via the injected compiler', () async { + final h = d2TemplateHandler(compile: (s) async => DrawOk('')); + final r = await h(d2doc('a -> b')); + expect((r as DrawOk).svg, ''); + }); + + test('a missing source is an honest error', () async { + final h = d2TemplateHandler(compile: (_) async => const DrawOk('x')); + expect(await h(d2doc(null)), isA()); + }); + + test('a blank source is an honest error', () async { + final h = d2TemplateHandler(compile: (_) async => const DrawOk('x')); + expect(await h(d2doc(' ')), isA()); + }); + + test('propagates a compiler error message', () async { + final h = d2TemplateHandler(compile: (_) async => const DrawErr('boom')); + expect((await h(d2doc('a -> b')) as DrawErr).message, 'boom'); + }); + }); + + group('d2CompileViaBinary', () { + Future compile({String? bin = '/x/d2', D2Run? run}) => + d2CompileViaBinary('a -> b', resolveD2: () => bin, run: run ?? (exe, src) async => (code: 0, out: '', err: 'success:')); + + test('unresolved d2 hints how to install', () async { + expect((await compile(bin: null) as DrawErr).message, contains('d2 not found')); + }); + + test('a clean compile returns the SVG (stderr ignored)', () async { + expect((await compile() as DrawOk).svg, ''); + }); + + test('a non-zero exit is a compile error carrying stderr', () async { + final r = await compile(run: (exe, src) async => (code: 1, out: '', err: 'line 2: syntax error')); + expect((r as DrawErr).message, contains('syntax error')); + }); + + test('empty output is an error', () async { + final r = await compile(run: (exe, src) async => (code: 0, out: ' ', err: '')); + expect(r, isA()); + }); + + test('a spawn failure is reported honestly', () async { + final r = await compile(run: (exe, src) async => throw 'ENOENT'); + expect((r as DrawErr).message, contains('could not run d2')); + }); + }); +} diff --git a/test/draw/draw_dispatch_test.dart b/test/draw/draw_dispatch_test.dart index 1e1c161e..a9b22352 100644 --- a/test/draw/draw_dispatch_test.dart +++ b/test/draw/draw_dispatch_test.dart @@ -37,15 +37,15 @@ void main() { }); test('template: a registered handler lowers the doc to SVG', () async { - final reg = DrawingRegistry()..register('d2', (doc) async => ''); + final reg = DrawingRegistry()..register('d2', (doc) async => DrawOk('')); final r = await resolve(parseDrawingCardDoc({'template': 'd2', 'source': 'a -> b'})!, reg); expect((r as DrawOk).svg, ''); }); - test('template: a handler that returns null is an error', () async { - final reg = DrawingRegistry()..register('d2', (_) async => null); + test('template: a handler error propagates with its message', () async { + final reg = DrawingRegistry()..register('d2', (_) async => const DrawErr('d2 not found')); final r = await resolve(parseDrawingCardDoc({'template': 'd2'})!, reg); - expect(r, isA()); + expect((r as DrawErr).message, contains('d2 not found')); }); }); }