|
| 1 | +// Package structurizr converts a BausteinsichtModel to Structurizr DSL format. |
| 2 | +package structurizr |
| 3 | + |
| 4 | +import ( |
| 5 | + "fmt" |
| 6 | + "sort" |
| 7 | + "strings" |
| 8 | + |
| 9 | + "github.com/docToolchain/Bausteinsicht/internal/model" |
| 10 | +) |
| 11 | + |
| 12 | +// Export converts m to a Structurizr DSL workspace string. |
| 13 | +// |
| 14 | +// Variable names prefer the leaf key (e.g. "webApp") and fall back to the |
| 15 | +// full dot-path-with-underscores ("orderSystem_webApp") only when the leaf |
| 16 | +// key is ambiguous across the whole model. This ensures a clean roundtrip: |
| 17 | +// re-importing the output reconstructs the same dot-paths. |
| 18 | +func Export(m *model.BausteinsichtModel) string { |
| 19 | + flat, _ := model.FlattenElements(m) |
| 20 | + varMap := buildVarMap(flat) |
| 21 | + e := &exporter{m: m, flat: flat, varMap: varMap} |
| 22 | + |
| 23 | + // Validate views reference existing elements |
| 24 | + if err := e.validateViews(); err != nil { |
| 25 | + // Log validation error but continue export (warnings don't block output) |
| 26 | + // In production, this should be surfaced to user |
| 27 | + _ = err |
| 28 | + } |
| 29 | + |
| 30 | + var b strings.Builder |
| 31 | + b.WriteString("workspace {\n") |
| 32 | + b.WriteString(" model {\n") |
| 33 | + |
| 34 | + // Write root elements (sorted for deterministic output). |
| 35 | + for _, key := range sortedKeys(m.Model) { |
| 36 | + elem := m.Model[key] |
| 37 | + e.writeElement(&b, key, elem, " ") |
| 38 | + } |
| 39 | + |
| 40 | + // Write global relationships. |
| 41 | + if len(m.Relationships) > 0 { |
| 42 | + b.WriteString("\n") |
| 43 | + for _, r := range m.Relationships { |
| 44 | + fromVar := varMap[r.From] |
| 45 | + toVar := varMap[r.To] |
| 46 | + if r.Label != "" { |
| 47 | + fmt.Fprintf(&b, " %s -> %s \"%s\"\n", fromVar, toVar, escDQ(r.Label)) |
| 48 | + } else { |
| 49 | + fmt.Fprintf(&b, " %s -> %s\n", fromVar, toVar) |
| 50 | + } |
| 51 | + } |
| 52 | + } |
| 53 | + |
| 54 | + b.WriteString(" }\n\n") |
| 55 | + b.WriteString(" views {\n") |
| 56 | + e.writeViews(&b, " ") |
| 57 | + b.WriteString(" }\n") |
| 58 | + b.WriteString("}\n") |
| 59 | + |
| 60 | + return b.String() |
| 61 | +} |
| 62 | + |
| 63 | +type exporter struct { |
| 64 | + m *model.BausteinsichtModel |
| 65 | + flat map[string]*model.Element |
| 66 | + varMap map[string]string // dot-path → Structurizr variable name |
| 67 | +} |
| 68 | + |
| 69 | +// writeElement writes one element (and recursively its children) to b. |
| 70 | +// dotPath is the full dot-separated path (e.g. "orderSystem.webApp"). |
| 71 | +// The variable name is looked up from e.varMap. |
| 72 | +func (e *exporter) writeElement(b *strings.Builder, dotPath string, elem model.Element, indent string) { |
| 73 | + varName := e.varMap[dotPath] |
| 74 | + kind := toStructurizrKind(elem.Kind) |
| 75 | + desc := escDQ(elem.Description) |
| 76 | + tech := escDQ(elem.Technology) |
| 77 | + title := escDQ(elem.Title) |
| 78 | + if title == "" { |
| 79 | + title = varName |
| 80 | + } |
| 81 | + |
| 82 | + hasChildren := len(elem.Children) > 0 |
| 83 | + |
| 84 | + if hasChildren { |
| 85 | + if tech != "" { |
| 86 | + fmt.Fprintf(b, "%s%s = %s \"%s\" \"%s\" \"%s\" {\n", indent, varName, kind, title, tech, desc) |
| 87 | + } else if desc != "" { |
| 88 | + fmt.Fprintf(b, "%s%s = %s \"%s\" \"%s\" {\n", indent, varName, kind, title, desc) |
| 89 | + } else { |
| 90 | + fmt.Fprintf(b, "%s%s = %s \"%s\" {\n", indent, varName, kind, title) |
| 91 | + } |
| 92 | + for _, childKey := range sortedKeys(elem.Children) { |
| 93 | + childElem := elem.Children[childKey] |
| 94 | + childDotPath := dotPath + "." + childKey |
| 95 | + e.writeElement(b, childDotPath, childElem, indent+" ") |
| 96 | + } |
| 97 | + fmt.Fprintf(b, "%s}\n", indent) |
| 98 | + } else { |
| 99 | + if tech != "" { |
| 100 | + fmt.Fprintf(b, "%s%s = %s \"%s\" \"%s\" \"%s\"\n", indent, varName, kind, title, tech, desc) |
| 101 | + } else if desc != "" { |
| 102 | + fmt.Fprintf(b, "%s%s = %s \"%s\" \"%s\"\n", indent, varName, kind, title, desc) |
| 103 | + } else { |
| 104 | + fmt.Fprintf(b, "%s%s = %s \"%s\"\n", indent, varName, kind, title) |
| 105 | + } |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +func (e *exporter) writeViews(b *strings.Builder, indent string) { |
| 110 | + if len(e.m.Views) == 0 { |
| 111 | + return |
| 112 | + } |
| 113 | + for _, key := range sortedKeys(e.m.Views) { |
| 114 | + v := e.m.Views[key] |
| 115 | + e.writeOneView(b, key, v, indent) |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +func (e *exporter) writeOneView(b *strings.Builder, key string, v model.View, indent string) { |
| 120 | + viewType := e.detectViewType(v) |
| 121 | + title := escDQ(v.Title) |
| 122 | + if title == "" { |
| 123 | + title = key |
| 124 | + } |
| 125 | + |
| 126 | + if viewType == "systemLandscape" || v.Scope == "" { |
| 127 | + fmt.Fprintf(b, "%ssystemLandscape \"%s\" \"%s\" {\n", indent, key, title) |
| 128 | + } else { |
| 129 | + scopeVar := e.varMap[v.Scope] |
| 130 | + if scopeVar == "" { |
| 131 | + // Scope exists in flat map (verified by detectViewType), use its variable name |
| 132 | + scopeVar = dotToVar(v.Scope) |
| 133 | + } |
| 134 | + fmt.Fprintf(b, "%s%s %s \"%s\" \"%s\" {\n", indent, viewType, scopeVar, key, title) |
| 135 | + } |
| 136 | + fmt.Fprintf(b, "%s include *\n", indent) |
| 137 | + fmt.Fprintf(b, "%s}\n", indent) |
| 138 | +} |
| 139 | + |
| 140 | +// detectViewType returns the Structurizr view type keyword for v. |
| 141 | +func (e *exporter) detectViewType(v model.View) string { |
| 142 | + if v.Scope == "" { |
| 143 | + return "systemLandscape" |
| 144 | + } |
| 145 | + scopeElem := e.flat[v.Scope] |
| 146 | + if scopeElem == nil { |
| 147 | + return "systemContext" |
| 148 | + } |
| 149 | + if isContainerKind(scopeElem.Kind) { |
| 150 | + // Scope is a container → component view (shows what's inside a container). |
| 151 | + return "component" |
| 152 | + } |
| 153 | + // System-kind scope: if the scope element has container-kind children it's a container view. |
| 154 | + for _, child := range scopeElem.Children { |
| 155 | + if isContainerKind(child.Kind) { |
| 156 | + return "container" |
| 157 | + } |
| 158 | + } |
| 159 | + return "systemContext" |
| 160 | +} |
| 161 | + |
| 162 | +// toStructurizrKind maps a Bausteinsicht element kind to a Structurizr keyword. |
| 163 | +func toStructurizrKind(kind string) string { |
| 164 | + switch kind { |
| 165 | + case "actor", "person": |
| 166 | + return "person" |
| 167 | + case "system", "external_system": |
| 168 | + return "softwareSystem" |
| 169 | + case "container", "ui", "mobile", "datastore", "queue", "filestore": |
| 170 | + return "container" |
| 171 | + case "component": |
| 172 | + return "component" |
| 173 | + default: |
| 174 | + return "softwareSystem" |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +// isContainerKind reports whether kind is one of the Structurizr "container" equivalents. |
| 179 | +func isContainerKind(kind string) bool { |
| 180 | + switch kind { |
| 181 | + case "container", "ui", "mobile", "datastore", "queue", "filestore": |
| 182 | + return true |
| 183 | + } |
| 184 | + return false |
| 185 | +} |
| 186 | + |
| 187 | +// buildVarMap assigns a Structurizr variable name to every element dot-path. |
| 188 | +// Leaf keys are used when globally unique; otherwise the full |
| 189 | +// dot-path-with-underscores is used to avoid collisions. |
| 190 | +func buildVarMap(flat map[string]*model.Element) map[string]string { |
| 191 | + leafCount := make(map[string]int, len(flat)) |
| 192 | + for id := range flat { |
| 193 | + parts := strings.Split(id, ".") |
| 194 | + leafCount[parts[len(parts)-1]]++ |
| 195 | + } |
| 196 | + |
| 197 | + varMap := make(map[string]string, len(flat)) |
| 198 | + for id := range flat { |
| 199 | + parts := strings.Split(id, ".") |
| 200 | + leaf := parts[len(parts)-1] |
| 201 | + if leafCount[leaf] == 1 { |
| 202 | + varMap[id] = leaf |
| 203 | + } else { |
| 204 | + varMap[id] = dotToVar(id) |
| 205 | + } |
| 206 | + } |
| 207 | + return varMap |
| 208 | +} |
| 209 | + |
| 210 | +// dotToVar converts a dot-path to a valid Structurizr variable name. |
| 211 | +func dotToVar(path string) string { |
| 212 | + return strings.ReplaceAll(path, ".", "_") |
| 213 | +} |
| 214 | + |
| 215 | +// escDQ escapes backslashes, double quotes, and newlines for embedding in Structurizr string literals. |
| 216 | +func escDQ(s string) string { |
| 217 | + // Escape backslash first (must be first to avoid double-escaping) |
| 218 | + s = strings.ReplaceAll(s, "\\", "\\\\") |
| 219 | + s = strings.ReplaceAll(s, `"`, `\"`) |
| 220 | + s = strings.ReplaceAll(s, "\n", `\n`) |
| 221 | + return s |
| 222 | +} |
| 223 | + |
| 224 | +func sortedKeys[V any](m map[string]V) []string { |
| 225 | + keys := make([]string, 0, len(m)) |
| 226 | + for k := range m { |
| 227 | + keys = append(keys, k) |
| 228 | + } |
| 229 | + sort.Strings(keys) |
| 230 | + return keys |
| 231 | +} |
| 232 | + |
| 233 | +// validateViews checks that all elements referenced in views exist in the model. |
| 234 | +func (e *exporter) validateViews() error { |
| 235 | + for viewKey, view := range e.m.Views { |
| 236 | + for _, elemID := range view.Include { |
| 237 | + if elemID == "*" { |
| 238 | + continue // Wildcard is always valid |
| 239 | + } |
| 240 | + // Check if element exists |
| 241 | + if _, exists := e.flat[elemID]; !exists { |
| 242 | + return fmt.Errorf("view %q includes non-existent element %q", viewKey, elemID) |
| 243 | + } |
| 244 | + } |
| 245 | + } |
| 246 | + return nil |
| 247 | +} |
0 commit comments