Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions core/archidoc-engine/src/plantuml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
));
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Comment on lines 111 to +115
));
}
}
Expand Down Expand Up @@ -142,3 +148,46 @@ fn to_title_case(s: &str) -> String {
.collect::<Vec<_>>()
.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();
Comment on lines +184 to +187

// 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")"#));
}
}