From 7fa73c8623f288565c2ee8e93473a85466802b9c Mon Sep 17 00:00:00 2001 From: Martin Mauch Date: Sun, 14 Jun 2026 15:57:09 +0200 Subject: [PATCH] fix(c4): escape double-quotes in rendered PlantUML labels/descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C4/PlantUML macro arguments are double-quoted and have no escape sequence for an embedded `"`. A description or label drawn from a Rust doc comment that itself contains a quote — e.g. `Typed entity name (e.g. "block", "document")` — was interpolated verbatim into `Component(...)`, so the inner quote closed the string early and corrupted the diagram. Add an `escape_label()` helper that replaces `"` with `'` and flattens newlines to spaces, and route every user-derived field (name, pattern, description, relationship label/protocol) through it in both the container and component generators. - Tests: helper unit test + a component-render test proving a quoted description no longer breaks the emitted string. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archidoc-engine/src/plantuml.rs | 67 ++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/core/archidoc-engine/src/plantuml.rs b/core/archidoc-engine/src/plantuml.rs index aa10b33..470d6d0 100644 --- a/core/archidoc-engine/src/plantuml.rs +++ b/core/archidoc-engine/src/plantuml.rs @@ -19,9 +19,9 @@ pub fn generate_container(output_dir: &Path, ir: &ArchitectureIR) { let mut container_defs = String::new(); for dir in &containers { let id = to_puml_id(&dir.path); - let name = to_title_case(&dir.name); - let pattern = dir.pattern.as_deref().unwrap_or("--"); - let desc = dir.description.as_deref().unwrap_or(""); + let name = escape_label(&to_title_case(&dir.name)); + let pattern = escape_label(dir.pattern.as_deref().unwrap_or("--")); + let desc = escape_label(dir.description.as_deref().unwrap_or("")); container_defs.push_str(&format!( " Container({}, \"{}\", \"{}\", \"{}\")\n", id, name, pattern, desc @@ -35,7 +35,10 @@ pub fn generate_container(output_dir: &Path, ir: &ArchitectureIR) { let to_id = to_puml_id(&rel.target); rel_defs.push_str(&format!( "Rel({}, {}, \"{}\", \"{}\")\n", - from_id, to_id, rel.label, rel.protocol + from_id, + to_id, + escape_label(&rel.label), + escape_label(&rel.protocol) )); } } @@ -81,16 +84,16 @@ pub fn generate_component(output_dir: &Path, ir: &ArchitectureIR) { let mut boundary_defs = String::new(); for (parent, component_dirs) in &grouped { let parent_id = to_puml_id(parent); - let parent_name = to_title_case(parent.split('/').last().unwrap_or(parent)); + let parent_name = escape_label(&to_title_case(parent.split('/').last().unwrap_or(parent))); boundary_defs.push_str(&format!( "Container_Boundary({}_boundary, \"{}\") {{\n", parent_id, parent_name )); for dir in component_dirs { let id = to_puml_id(&dir.path); - let name = &dir.name; - let pattern = dir.pattern.as_deref().unwrap_or("--"); - let desc = dir.description.as_deref().unwrap_or(""); + let name = escape_label(&dir.name); + let pattern = escape_label(dir.pattern.as_deref().unwrap_or("--")); + let desc = escape_label(dir.description.as_deref().unwrap_or("")); boundary_defs.push_str(&format!( " Component({}, \"{}\", \"{}\", \"{}\")\n", id, name, pattern, desc @@ -106,7 +109,10 @@ pub fn generate_component(output_dir: &Path, ir: &ArchitectureIR) { let to_id = to_puml_id(&rel.target); rel_defs.push_str(&format!( "Rel({}, {}, \"{}\", \"{}\")\n", - from_id, to_id, rel.label, rel.protocol + from_id, + to_id, + escape_label(&rel.label), + escape_label(&rel.protocol) )); } } @@ -142,3 +148,46 @@ fn to_title_case(s: &str) -> String { .collect::>() .join(" ") } + +/// Sanitize a string for embedding inside a PlantUML double-quoted argument. +/// +/// C4/PlantUML macro arguments are double-quoted and have no escape sequence for +/// an embedded `"`, so a quote in a description or label (e.g. a doc comment +/// reading `e.g. "block"`) would prematurely close the string and corrupt the +/// diagram. Replace any `"` with `'` and flatten newlines to spaces so arbitrary +/// annotation text renders safely. +fn escape_label(s: &str) -> String { + s.replace('"', "'").replace(['\n', '\r'], " ") +} + +#[cfg(test)] +mod tests { + use super::*; + use archidoc_types::ir::{ArchitectureIR, DirNode}; + use archidoc_types::C4Level; + + #[test] + fn escape_label_neutralizes_quotes_and_newlines() { + assert_eq!(escape_label(r#"e.g. "block", "doc""#), "e.g. 'block', 'doc'"); + assert_eq!(escape_label("line1\nline2"), "line1 line2"); + assert_eq!(escape_label("plain"), "plain"); + } + + #[test] + fn component_description_with_quotes_does_not_break_the_string() { + let mut node = DirNode::empty("api", "api"); + node.c4_level = Some(C4Level::Component); + node.description = Some(r#"Typed name (e.g. "block")"#.to_string()); + let mut ir = ArchitectureIR::new("/scan".to_string()); + ir.root.dirs = vec![node]; + + let dir = std::env::temp_dir().join("archidoc_escape_test"); + std::fs::create_dir_all(&dir).unwrap(); + generate_component(&dir, &ir); + let out = std::fs::read_to_string(dir.join("c4-component.puml")).unwrap(); + + // The raw double-quote must not survive into the rendered argument. + assert!(out.contains(r#"Component(api, "api", "--", "Typed name (e.g. 'block')")"#)); + assert!(!out.contains(r#"(e.g. "block")"#)); + } +}