📊 Multi-OS Test Report Dashboard

Cross-Platform Test Coverage & Performance Analysis

🔄 Platform Comparison

Platform Total Tests Passed Failed Skipped Pass Rate Duration Coverage
🐧 Linux 832 829 0 3 99.6% 7.22s 69.0%
🪟 Windows 832 829 0 3 99.6% 12.74s 68.9%
🍎 macOS 832 829 0 3 99.6% 6.84s 69.0%
832
Total Tests
99.6%
Pass Rate
69.0%
Coverage
7.22s
Duration

📈 Changes from Previous Run

Metric Change

📦 Test Results by Package

🔍 Package Details

Package Tests ✅ Passed ❌ Failed ⏭️ Skipped Pass Rate
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht 193 191 0 2 99.0%
github.com/docToolchain/Bausteinsicht/internal/changelog 14 14 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/constraints 16 16 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/diagram 40 40 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/diff 12 12 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/drawio 99 99 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/export 17 16 0 1 94.1%
github.com/docToolchain/Bausteinsicht/internal/exporter/structurizr 18 18 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/graph 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/health 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/importer/likec4 3 3 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/importer/structurizr 7 7 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/layout 7 7 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/lsp 17 17 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/model 143 143 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/overlay 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/schema 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/search 13 13 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/sync 159 159 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/table 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/template 22 22 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/watcher 7 7 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/workspace 11 11 0 0 100.0%
github.com/docToolchain/Bausteinsicht/templates 4 4 0 0 100.0%

🔬 Line-Level Coverage

github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht-lsp/main.go 0.0%
1 package main
2
3 import (
4 "flag"
5 "log"
6 "os"
7
8 "github.com/docToolchain/Bausteinsicht/internal/lsp"
9 )
10
11 func main() {
12 var debug bool
13 var stdio bool // Accept -stdio flag (used by LSP clients, can be ignored)
14 flag.BoolVar(&debug, "debug", false, "Enable debug logging to stderr")
15 flag.BoolVar(&stdio, "stdio", false, "Use stdio for LSP communication (default behavior)")
16 flag.Parse()
17
18 // Set up logging
19 logFile := os.Stderr
20 if !debug {
21 logFile, _ = os.OpenFile(os.DevNull, os.O_WRONLY, 0)
22 }
23 log.SetOutput(logFile)
24
25 // Create and run LSP server
26 server := lsp.NewServer()
27 if err := server.Run(); err != nil {
28 log.Fatalf("LSP server error: %v", err)
29 }
30 }
31
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add.go 100.0%
1 package main
2
3 import "github.com/spf13/cobra"
4
5 func newAddCmd() *cobra.Command {
6 cmd := &cobra.Command{
7 Use: "add",
8 Short: "Add elements, relationships, views, or specification types to the model",
9 }
10
11 cmd.AddCommand(newAddElementCmd())
12 cmd.AddCommand(newAddRelationshipCmd())
13 cmd.AddCommand(newAddFromPatternCmd())
14
15 // Create a pattern sub-group
16 patternCmd := &cobra.Command{
17 Use: "pattern",
18 Short: "Manage patterns",
19 }
20 patternCmd.AddCommand(newListPatternsCmd())
21
22 cmd.AddCommand(patternCmd)
23 cmd.AddCommand(newAddViewCmd())
24 cmd.AddCommand(newAddSpecificationCmd())
25
26 return cmd
27 }
28
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_element.go 77.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "regexp"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 // validIDPattern matches element IDs: letters, digits, hyphens, underscores.
14 // Dots are NOT allowed since they serve as hierarchy separators.
15 var validIDPattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
16
17 // isValidID checks if the given ID is a valid element identifier.
18 func isValidID(id string) bool {
19 return validIDPattern.MatchString(id)
20 }
21
22 func newAddElementCmd() *cobra.Command {
23 cmd := &cobra.Command{
24 Use: "element",
25 Short: "Add an element to the model",
26 RunE: runAddElement,
27 }
28
29 cmd.Flags().String("id", "", "Unique identifier for the element (required)")
30 cmd.Flags().String("kind", "", "Element kind as defined in specification (required)")
31 cmd.Flags().String("title", "", "Display title (required)")
32 cmd.Flags().String("parent", "", "Parent element ID (dot notation)")
33 cmd.Flags().String("technology", "", "Technology description")
34 cmd.Flags().String("description", "", "Element description")
35
36 _ = cmd.MarkFlagRequired("id")
37 _ = cmd.MarkFlagRequired("kind")
38 _ = cmd.MarkFlagRequired("title")
39
40 return cmd
41 }
42
43 func runAddElement(cmd *cobra.Command, args []string) error {
44 id, _ := cmd.Flags().GetString("id")
45 kind, _ := cmd.Flags().GetString("kind")
46 title, _ := cmd.Flags().GetString("title")
47 parent, _ := cmd.Flags().GetString("parent")
48 technology, _ := cmd.Flags().GetString("technology")
49 description, _ := cmd.Flags().GetString("description")
50
51 modelPath, _ := cmd.Flags().GetString("model")
52 format, _ := cmd.Flags().GetString("format")
53
54 // Validate ID format. (#123)
55 if !isValidID(id) {
56 return exitWithCode(
57 fmt.Errorf("invalid element ID %q: must contain only letters, digits, hyphens, or underscores", id),
58 1,
59 )
60 }
61
62 // Validate title is not empty. (#124)
63 if title == "" {
64 return exitWithCode(fmt.Errorf("title must not be empty"), 1)
65 }
66
67 // Load model
68 if modelPath == "" {
69 detected, err := model.AutoDetect(".")
70 if err != nil {
71 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
72 }
73 modelPath = detected
74 }
75
76 m, err := model.Load(modelPath)
77 if err != nil {
78 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
79 }
80
81 // Validate kind
82 if _, ok := m.Specification.Elements[kind]; !ok {
83 return exitWithCode(
84 fmt.Errorf("unknown element kind %q; valid kinds: %s", kind, validKinds(m)),
85 1,
86 )
87 }
88
89 // Build element
90 elem := model.Element{
91 Kind: kind,
92 Title: title,
93 Technology: technology,
94 Description: description,
95 }
96
97 fullID := id
98
99 if parent != "" {
100 // Validate parent exists
101 parentElem, err := model.Resolve(m, parent)
102 if err != nil {
103 return exitWithCode(fmt.Errorf("parent %q not found: %w", parent, err), 1)
104 }
105
106 // Validate parent's kind allows children.
107 if spec, ok := m.Specification.Elements[parentElem.Kind]; !ok || !spec.Container {
108 return exitWithCode(
109 fmt.Errorf("element %q (kind: %s) is not a container and cannot have children", parent, parentElem.Kind),
110 1,
111 )
112 }
113
114 // Check duplicate within parent's children
115 if parentElem.Children != nil {
116 if _, exists := parentElem.Children[id]; exists {
117 return exitWithCode(fmt.Errorf("element %q already exists under %q", id, parent), 1)
118 }
119 }
120
121 // Add to parent's children — need to update in-place through the model
122 if err := addChildToParent(m, parent, id, elem); err != nil {
123 return exitWithCode(err, 1)
124 }
125
126 fullID = parent + "." + id
127 } else {
128 // Check duplicate at top level
129 if _, exists := m.Model[id]; exists {
130 return exitWithCode(fmt.Errorf("element %q already exists at top level", id), 1)
131 }
132
133 if m.Model == nil {
134 m.Model = make(map[string]model.Element)
135 }
136 m.Model[id] = elem
137 }
138
139 // Save model — use comment-preserving insertion. (#122)
140 if err := saveAddedElement(modelPath, m, fullID, parent, id, elem); err != nil {
141 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
142 }
143
144 // Output
145 if format == "json" {
146 out := map[string]string{
147 "id": fullID,
148 "kind": kind,
149 "title": title,
150 }
151 if technology != "" {
152 out["technology"] = technology
153 }
154 if description != "" {
155 out["description"] = description
156 }
157 data, _ := json.Marshal(out)
158 fmt.Println(string(data))
159 } else {
160 fmt.Printf("Added element '%s' (kind: %s) to model.\n", fullID, kind)
161 }
162
163 return nil
164 }
165
166 // addChildToParent traverses the model to the parent and adds the child element.
167 func addChildToParent(m *model.BausteinsichtModel, parentPath, childID string, child model.Element) error {
168 parts := splitDotPath(parentPath)
169
170 // Get root element
171 root, ok := m.Model[parts[0]]
172 if !ok {
173 return fmt.Errorf("element %q not found", parts[0])
174 }
175
176 if len(parts) == 1 {
177 if root.Children == nil {
178 root.Children = make(map[string]model.Element)
179 }
180 root.Children[childID] = child
181 m.Model[parts[0]] = root
182 return nil
183 }
184
185 // Traverse to parent, building a stack of elements to update
186 stack := []model.Element{root}
187 current := root
188 for _, part := range parts[1:] {
189 if current.Children == nil {
190 return fmt.Errorf("no children at %q", part)
191 }
192 next, ok := current.Children[part]
193 if !ok {
194 return fmt.Errorf("element %q not found", part)
195 }
196 stack = append(stack, next)
197 current = next
198 }
199
200 // Add child to the deepest parent
201 if current.Children == nil {
202 current.Children = make(map[string]model.Element)
203 }
204 current.Children[childID] = child
205 stack[len(stack)-1] = current
206
207 // Walk back up the stack updating parents
208 for i := len(stack) - 1; i > 0; i-- {
209 parentElem := stack[i-1]
210 if parentElem.Children == nil {
211 parentElem.Children = make(map[string]model.Element)
212 }
213 parentElem.Children[parts[i]] = stack[i]
214 stack[i-1] = parentElem
215 }
216
217 m.Model[parts[0]] = stack[0]
218 return nil
219 }
220
221 func splitDotPath(path string) []string {
222 result := []string{}
223 current := ""
224 for _, c := range path {
225 if c == '.' {
226 if current != "" {
227 result = append(result, current)
228 }
229 current = ""
230 } else {
231 current += string(c)
232 }
233 }
234 if current != "" {
235 result = append(result, current)
236 }
237 return result
238 }
239
240 // saveAddedElement saves a newly added element using comment-preserving
241 // insertion. Falls back to model.Save if patching fails. (#122)
242 func saveAddedElement(modelPath string, m *model.BausteinsichtModel, fullID, parent, id string, elem model.Element) error {
243 // Compute indent depth: "model" is depth 1, each dot-path segment adds 2
244 // (one for the parent element, one for "children").
245 depth := 2 // "model" → "X" is at depth 2
246 if parent != "" {
247 depth += len(splitDotPath(parent)) * 2 // each parent level adds element + children
248 }
249 elemJSON := marshalElementJSON(elem, depth)
250
251 // Build the object path for insertion.
252 var objectPath []string
253 if parent != "" {
254 // Insert into the parent's "children" object.
255 parts := splitDotPath(parent)
256 objectPath = append([]string{"model"}, parts...)
257 objectPath = append(objectPath, "children")
258 } else {
259 objectPath = []string{"model"}
260 }
261
262 err := model.PatchInsert(modelPath, func(data []byte) ([]byte, error) {
263 return model.InsertObjectEntry(data, objectPath, id, elemJSON)
264 })
265 if err != nil {
266 // Fall back to full save if patching fails.
267 return model.Save(modelPath, m)
268 }
269 return nil
270 }
271
272 // marshalElementJSON builds a formatted JSON object for an element.
273 // depth is the nesting depth of the entry key (e.g., 2 for "model.X",
274 // 4 for "model.X.children.Y"). Each level adds 2 spaces.
275 func marshalElementJSON(elem model.Element, depth int) string {
276 fieldIndent := strings.Repeat(" ", (depth+1)*2)
277 closeIndent := strings.Repeat(" ", depth*2)
278
279 parts := []string{fmt.Sprintf(`"kind": %q`, elem.Kind)}
280 parts = append(parts, fmt.Sprintf(`"title": %q`, elem.Title))
281 if elem.Technology != "" {
282 parts = append(parts, fmt.Sprintf(`"technology": %q`, elem.Technology))
283 }
284 if elem.Description != "" {
285 parts = append(parts, fmt.Sprintf(`"description": %q`, elem.Description))
286 }
287
288 result := "{\n"
289 for i, p := range parts {
290 result += fieldIndent + p
291 if i < len(parts)-1 {
292 result += ","
293 }
294 result += "\n"
295 }
296 result += closeIndent + "}"
297 return result
298 }
299
300 func validKinds(m *model.BausteinsichtModel) string {
301 kinds := ""
302 for k := range m.Specification.Elements {
303 if kinds != "" {
304 kinds += ", "
305 }
306 kinds += k
307 }
308 return kinds
309 }
310
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_from_pattern.go 8.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 func newAddFromPatternCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "add-from-pattern <pattern-id>",
15 Short: "Add elements and relationships from a pattern",
16 Long: "Expand a pattern from the specification into concrete elements and relationships in the model.",
17 Args: cobra.ExactArgs(1),
18 RunE: func(cmd *cobra.Command, args []string) error {
19 return runAddFromPattern(cmd, args)
20 },
21 }
22
23 cmd.Flags().String("id", "", "Base ID for generated elements (required)")
24 cmd.Flags().String("title", "", "Base title for generated elements (default: --id)")
25 cmd.Flags().String("prefix", "", "Namespace prefix (modifies {base} to prefix-base)")
26 _ = cmd.MarkFlagRequired("id")
27
28 return cmd
29 }
30
31 func newListPatternsCmd() *cobra.Command {
32 return &cobra.Command{
33 Use: "list",
34 Short: "List all available patterns",
35 Long: "List all patterns defined in specification with their element and relationship counts.",
36 RunE: func(cmd *cobra.Command, args []string) error {
37 return runListPatterns(cmd)
38 },
39 }
40 }
41
42 func runAddFromPattern(cmd *cobra.Command, args []string) error {
43 patternID := args[0]
44 modelPath, _ := cmd.Flags().GetString("model")
45 baseID, _ := cmd.Flags().GetString("id")
46 title, _ := cmd.Flags().GetString("title")
47 prefix, _ := cmd.Flags().GetString("prefix")
48
49 // Apply prefix if provided
50 if prefix != "" {
51 baseID = prefix + "-" + baseID
52 }
53
54 if modelPath == "" {
55 detected, err := model.AutoDetect(".")
56 if err != nil {
57 return exitWithCode(err, 2)
58 }
59 modelPath = detected
60 }
61
62 m, err := model.Load(modelPath)
63 if err != nil {
64 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
65 }
66
67 // Check if pattern exists
68 pattern, exists := m.Specification.Patterns[patternID]
69 if !exists {
70 return exitWithCode(fmt.Errorf("pattern %q not found in specification", patternID), 2)
71 }
72
73 // Check for conflicts
74 conflicts, err := model.CheckPatternConflicts(m, pattern, baseID)
75 if err != nil {
76 return exitWithCode(err, 2)
77 }
78
79 if len(conflicts) > 0 {
80 return exitWithCode(fmt.Errorf("conflict: elements already exist: %v (use a different --id)", conflicts), 2)
81 }
82
83 // Expand the pattern
84 elements, relationships, err := model.ExpandPattern(pattern, baseID, title)
85 if err != nil {
86 return exitWithCode(err, 2)
87 }
88
89 // Get expanded IDs
90 elemIDs, relIDs, err := model.ExpandPatternIDs(pattern, baseID)
91 if err != nil {
92 return exitWithCode(err, 2)
93 }
94
95 // Add elements to model (at top level for now)
96 if m.Model == nil {
97 m.Model = make(map[string]model.Element)
98 }
99 for i, elem := range elements {
100 m.Model[elemIDs[i]] = elem
101 }
102
103 // Add relationships (From and To are already expanded by ExpandPattern)
104 m.Relationships = append(m.Relationships, relationships...)
105
106 // Save the updated model
107 if err := model.Save(modelPath, m); err != nil {
108 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
109 }
110
111 // Output summary
112 fmt.Printf("✅ Pattern '%s' applied with base ID '%s':\n", patternID, baseID)
113 for i, id := range elemIDs {
114 fmt.Printf(" + %-20s [%-10s] \"%s\"\n", id, elements[i].Kind, elements[i].Title)
115 }
116 for i, id := range relIDs {
117 fmt.Printf(" + %-20s %s → %s \"%s\"\n", id, relationships[i].From, relationships[i].To, relationships[i].Label)
118 }
119
120 return nil
121 }
122
123 func runListPatterns(cmd *cobra.Command) error {
124 modelPath, _ := cmd.Flags().GetString("model")
125
126 if modelPath == "" {
127 detected, err := model.AutoDetect(".")
128 if err != nil {
129 return exitWithCode(err, 2)
130 }
131 modelPath = detected
132 }
133
134 m, err := model.Load(modelPath)
135 if err != nil {
136 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
137 }
138
139 if len(m.Specification.Patterns) == 0 {
140 fmt.Println("No patterns defined in specification")
141 return nil
142 }
143
144 // Sort pattern IDs
145 var patternIDs []string
146 for id := range m.Specification.Patterns {
147 patternIDs = append(patternIDs, id)
148 }
149 sort.Strings(patternIDs)
150
151 format, _ := cmd.Flags().GetString("format")
152 if format == "json" {
153 // Output as JSON
154 type patternInfo struct {
155 ID string `json:"id"`
156 Description string `json:"description"`
157 ElementCount int `json:"elementCount"`
158 RelationshipCount int `json:"relationshipCount"`
159 }
160 var patterns []patternInfo
161 for _, id := range patternIDs {
162 p := m.Specification.Patterns[id]
163 patterns = append(patterns, patternInfo{
164 ID: id,
165 Description: p.Description,
166 ElementCount: len(p.Elements),
167 RelationshipCount: len(p.Relationships),
168 })
169 }
170 b, err := json.MarshalIndent(patterns, "", " ")
171 if err != nil {
172 return err
173 }
174 fmt.Println(string(b))
175 return nil
176 }
177
178 // Text output
179 fmt.Println("Available patterns:")
180 fmt.Println("──────────────────────────────────────────────────────────────")
181 for _, id := range patternIDs {
182 p := m.Specification.Patterns[id]
183 elemCount := len(p.Elements)
184 relCount := len(p.Relationships)
185 fmt.Printf(" %-25s %s (%d elements, %d relationships)\n",
186 id, p.Description, elemCount, relCount)
187 }
188
189 return nil
190 }
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_relationship.go 87.5%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/spf13/cobra"
9 )
10
11 func newAddRelationshipCmd() *cobra.Command {
12 cmd := &cobra.Command{
13 Use: "relationship",
14 Short: "Add a relationship between two elements",
15 Long: "Adds a new relationship to the architecture model. Both --from and --to must reference existing elements.",
16 RunE: runAddRelationship,
17 }
18
19 cmd.Flags().String("from", "", "Source element (dot-notation path, e.g. webshop.api)")
20 cmd.Flags().String("to", "", "Target element (dot-notation path, e.g. webshop.db)")
21 cmd.Flags().String("label", "", "Relationship label")
22 cmd.Flags().String("kind", "", "Relationship kind (must be defined in specification)")
23 cmd.Flags().String("description", "", "Relationship description")
24
25 _ = cmd.MarkFlagRequired("from")
26 _ = cmd.MarkFlagRequired("to")
27
28 return cmd
29 }
30
31 func runAddRelationship(cmd *cobra.Command, args []string) error {
32 format, _ := cmd.Flags().GetString("format")
33 modelPath, _ := cmd.Flags().GetString("model")
34 from, _ := cmd.Flags().GetString("from")
35 to, _ := cmd.Flags().GetString("to")
36 label, _ := cmd.Flags().GetString("label")
37 kind, _ := cmd.Flags().GetString("kind")
38 description, _ := cmd.Flags().GetString("description")
39
40 if modelPath == "" {
41 detected, err := model.AutoDetect(".")
42 if err != nil {
43 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
44 }
45 modelPath = detected
46 }
47
48 m, err := model.Load(modelPath)
49 if err != nil {
50 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
51 }
52
53 if _, err := model.Resolve(m, from); err != nil {
54 return exitWithCode(fmt.Errorf("--from: element %q not found", from), 1)
55 }
56
57 if _, err := model.Resolve(m, to); err != nil {
58 return exitWithCode(fmt.Errorf("--to: element %q not found", to), 1)
59 }
60
61 if kind != "" {
62 if m.Specification.Relationships == nil {
63 return exitWithCode(fmt.Errorf("--kind: %q not defined (no relationship kinds in specification)", kind), 1)
64 }
65 if _, ok := m.Specification.Relationships[kind]; !ok {
66 return exitWithCode(fmt.Errorf("--kind: %q not defined in specification", kind), 1)
67 }
68 }
69
70 for _, r := range m.Relationships {
71 if r.From == from && r.To == to && r.Kind == kind {
72 return exitWithCode(fmt.Errorf("relationship %s -> %s (kind %q) already exists", from, to, kind), 1)
73 }
74 }
75
76 rel := model.Relationship{
77 From: from,
78 To: to,
79 Label: label,
80 Kind: kind,
81 Description: description,
82 }
83 m.Relationships = append(m.Relationships, rel)
84
85 // Save using comment-preserving array append. (#122)
86 relJSON := marshalRelationshipJSON(rel)
87 err = model.PatchInsert(modelPath, func(data []byte) ([]byte, error) {
88 return model.AppendArrayEntry(data, []string{"relationships"}, relJSON)
89 })
90 if err != nil {
91 // Fall back to full save if patching fails.
92 if err := model.Save(modelPath, m); err != nil {
93 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
94 }
95 }
96
97 if format == "json" {
98 return printRelationshipJSON(rel)
99 }
100 printRelationshipText(rel)
101 return nil
102 }
103
104 // marshalRelationshipJSON builds a compact JSON object for a relationship.
105 func marshalRelationshipJSON(rel model.Relationship) string {
106 parts := []string{
107 fmt.Sprintf(`"from": %q`, rel.From),
108 fmt.Sprintf(`"to": %q`, rel.To),
109 }
110 if rel.Label != "" {
111 parts = append(parts, fmt.Sprintf(`"label": %q`, rel.Label))
112 }
113 if rel.Kind != "" {
114 parts = append(parts, fmt.Sprintf(`"kind": %q`, rel.Kind))
115 }
116 if rel.Description != "" {
117 parts = append(parts, fmt.Sprintf(`"description": %q`, rel.Description))
118 }
119
120 result := "{\n"
121 for i, p := range parts {
122 result += " " + p
123 if i < len(parts)-1 {
124 result += ","
125 }
126 result += "\n"
127 }
128 result += " }"
129 return result
130 }
131
132 func printRelationshipText(r model.Relationship) {
133 if r.Label != "" {
134 fmt.Printf("Added relationship: %s -> %s (%s)\n", r.From, r.To, r.Label)
135 } else {
136 fmt.Printf("Added relationship: %s -> %s\n", r.From, r.To)
137 }
138 }
139
140 func printRelationshipJSON(r model.Relationship) error {
141 out := struct {
142 From string `json:"from"`
143 To string `json:"to"`
144 Label string `json:"label,omitempty"`
145 Kind string `json:"kind,omitempty"`
146 Description string `json:"description,omitempty"`
147 }{
148 From: r.From,
149 To: r.To,
150 Label: r.Label,
151 Kind: r.Kind,
152 Description: r.Description,
153 }
154 data, err := json.MarshalIndent(out, "", " ")
155 if err != nil {
156 return fmt.Errorf("marshaling JSON: %w", err)
157 }
158 fmt.Println(string(data))
159 return nil
160 }
161
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_specification.go 65.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "regexp"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 // validSpecKeyPattern matches specification keys: lowercase letters, digits, underscores, hyphens.
13 var validSpecKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
14
15 // isValidSpecKey checks if the given spec key is valid.
16 func isValidSpecKey(key string) bool {
17 return validSpecKeyPattern.MatchString(key)
18 }
19
20 func newAddSpecificationCmd() *cobra.Command {
21 cmd := &cobra.Command{
22 Use: "specification",
23 Short: "Add element or relationship types to the specification",
24 }
25
26 cmd.AddCommand(newAddSpecificationElementCmd())
27 cmd.AddCommand(newAddSpecificationRelationshipCmd())
28
29 return cmd
30 }
31
32 func newAddSpecificationElementCmd() *cobra.Command {
33 cmd := &cobra.Command{
34 Use: "element <key>",
35 Short: "Add an element type to the specification",
36 Args: cobra.ExactArgs(1),
37 RunE: runAddSpecificationElement,
38 }
39
40 cmd.Flags().String("notation", "", "Notation/display text for this element type (required)")
41 cmd.Flags().String("description", "", "Description of this element type")
42 cmd.Flags().Bool("container", false, "Whether this element can contain children")
43
44 _ = cmd.MarkFlagRequired("notation")
45
46 return cmd
47 }
48
49 func runAddSpecificationElement(cmd *cobra.Command, args []string) error {
50 key := args[0]
51 notation, _ := cmd.Flags().GetString("notation")
52 description, _ := cmd.Flags().GetString("description")
53 container, _ := cmd.Flags().GetBool("container")
54
55 modelPath, _ := cmd.Flags().GetString("model")
56 format, _ := cmd.Flags().GetString("format")
57
58 // Validate key format
59 if !isValidSpecKey(key) {
60 return exitWithCode(
61 fmt.Errorf("invalid specification key %q: must contain only lowercase letters, digits, hyphens, or underscores", key),
62 1,
63 )
64 }
65
66 // Load model
67 if modelPath == "" {
68 detected, err := model.AutoDetect(".")
69 if err != nil {
70 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
71 }
72 modelPath = detected
73 }
74
75 m, err := model.Load(modelPath)
76 if err != nil {
77 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
78 }
79
80 // Add element kind
81 err = m.AddSpecificationElement(key, model.ElementKind{
82 Notation: notation,
83 Description: description,
84 Container: container,
85 })
86 if err != nil {
87 return exitWithCode(fmt.Errorf("adding element: %w", err), 1)
88 }
89
90 // Save model
91 if err := model.Save(modelPath, m); err != nil {
92 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
93 }
94
95 // Output result
96 if format == "json" {
97 result := map[string]interface{}{
98 "key": key,
99 "notation": notation,
100 "container": container,
101 }
102 if description != "" {
103 result["description"] = description
104 }
105 jsonBytes, err := json.MarshalIndent(result, "", " ")
106 if err != nil {
107 return fmt.Errorf("encoding result: %w", err)
108 }
109 fmt.Println(string(jsonBytes))
110 } else {
111 fmt.Printf("Element type '%s' added to specification\n", key)
112 fmt.Printf(" Notation: %s\n", notation)
113 if description != "" {
114 fmt.Printf(" Description: %s\n", description)
115 }
116 if container {
117 fmt.Printf(" Container: yes\n")
118 }
119 }
120
121 return nil
122 }
123
124 func newAddSpecificationRelationshipCmd() *cobra.Command {
125 cmd := &cobra.Command{
126 Use: "relationship <key>",
127 Short: "Add a relationship type to the specification",
128 Args: cobra.ExactArgs(1),
129 RunE: runAddSpecificationRelationship,
130 }
131
132 cmd.Flags().String("notation", "", "Notation/display text for this relationship type (required)")
133 cmd.Flags().String("description", "", "Description of this relationship type")
134 cmd.Flags().Bool("dashed", false, "Whether this relationship is displayed as a dashed line")
135
136 _ = cmd.MarkFlagRequired("notation")
137
138 return cmd
139 }
140
141 func runAddSpecificationRelationship(cmd *cobra.Command, args []string) error {
142 key := args[0]
143 notation, _ := cmd.Flags().GetString("notation")
144 description, _ := cmd.Flags().GetString("description")
145 dashed, _ := cmd.Flags().GetBool("dashed")
146
147 modelPath, _ := cmd.Flags().GetString("model")
148 format, _ := cmd.Flags().GetString("format")
149
150 // Validate key format
151 if !isValidSpecKey(key) {
152 return exitWithCode(
153 fmt.Errorf("invalid specification key %q: must contain only lowercase letters, digits, hyphens, or underscores", key),
154 1,
155 )
156 }
157
158 // Load model
159 if modelPath == "" {
160 detected, err := model.AutoDetect(".")
161 if err != nil {
162 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
163 }
164 modelPath = detected
165 }
166
167 m, err := model.Load(modelPath)
168 if err != nil {
169 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
170 }
171
172 // Add relationship kind
173 err = m.AddSpecificationRelationship(key, model.RelationshipKind{
174 Notation: notation,
175 Dashed: dashed,
176 })
177 if err != nil {
178 return exitWithCode(fmt.Errorf("adding relationship: %w", err), 1)
179 }
180
181 // Save model
182 if err := model.Save(modelPath, m); err != nil {
183 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
184 }
185
186 // Output result
187 if format == "json" {
188 result := map[string]interface{}{
189 "key": key,
190 "notation": notation,
191 "dashed": dashed,
192 }
193 if description != "" {
194 result["description"] = description
195 }
196 jsonBytes, err := json.MarshalIndent(result, "", " ")
197 if err != nil {
198 return fmt.Errorf("encoding result: %w", err)
199 }
200 fmt.Println(string(jsonBytes))
201 } else {
202 fmt.Printf("Relationship type '%s' added to specification\n", key)
203 fmt.Printf(" Notation: %s\n", notation)
204 if description != "" {
205 fmt.Printf(" Description: %s\n", description)
206 }
207 if dashed {
208 fmt.Printf(" Style: dashed\n")
209 }
210 }
211
212 return nil
213 }
214
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_view.go 70.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "regexp"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 // validViewKeyPattern matches view keys: lowercase letters, digits, hyphens.
13 var validViewKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
14
15 // isValidViewKey checks if the given view key is valid.
16 func isValidViewKey(key string) bool {
17 return validViewKeyPattern.MatchString(key)
18 }
19
20 func newAddViewCmd() *cobra.Command {
21 cmd := &cobra.Command{
22 Use: "view <view-key>",
23 Short: "Create a new view or modify a view's include list",
24 Args: cobra.ExactArgs(1),
25 RunE: runAddView,
26 }
27
28 cmd.Flags().String("scope", "", "Scope element ID (parent element to show)")
29 cmd.Flags().StringSlice("include", []string{}, "Elements to include in view (repeatable)")
30 cmd.Flags().String("title", "", "View title (display name) (required for new views)")
31 cmd.Flags().String("description", "", "View description")
32
33 return cmd
34 }
35
36 func runAddView(cmd *cobra.Command, args []string) error {
37 viewKey := args[0]
38 scope, _ := cmd.Flags().GetString("scope")
39 includes, _ := cmd.Flags().GetStringSlice("include")
40 title, _ := cmd.Flags().GetString("title")
41 description, _ := cmd.Flags().GetString("description")
42
43 modelPath, _ := cmd.Flags().GetString("model")
44 format, _ := cmd.Flags().GetString("format")
45
46 // Validate view key format
47 if !isValidViewKey(viewKey) {
48 return exitWithCode(
49 fmt.Errorf("invalid view key %q: must contain only lowercase letters, digits, hyphens, or underscores", viewKey),
50 1,
51 )
52 }
53
54 // Load model
55 if modelPath == "" {
56 detected, err := model.AutoDetect(".")
57 if err != nil {
58 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
59 }
60 modelPath = detected
61 }
62
63 m, err := model.Load(modelPath)
64 if err != nil {
65 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
66 }
67
68 // Check if this is a new view or update
69 _, viewExists := m.Views[viewKey]
70 if !viewExists && title == "" {
71 return exitWithCode(
72 fmt.Errorf("title is required for new views"),
73 1,
74 )
75 }
76
77 // Create view struct
78 view := model.View{
79 Title: title,
80 Scope: scope,
81 Include: includes,
82 Description: description,
83 }
84
85 // Add or update view
86 err = m.AddView(viewKey, view)
87 if err != nil {
88 return exitWithCode(fmt.Errorf("adding view: %w", err), 1)
89 }
90
91 // Save model
92 if err := model.Save(modelPath, m); err != nil {
93 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
94 }
95
96 // Output result
97 if format == "json" {
98 result := map[string]interface{}{
99 "view_key": viewKey,
100 "title": title,
101 "scope": scope,
102 "include": includes,
103 }
104 jsonBytes, err := json.MarshalIndent(result, "", " ")
105 if err != nil {
106 return fmt.Errorf("encoding result: %w", err)
107 }
108 fmt.Println(string(jsonBytes))
109 } else {
110 fmt.Printf("View '%s' added to model\n", viewKey)
111 if title != "" {
112 fmt.Printf(" Title: %s\n", title)
113 }
114 if scope != "" {
115 fmt.Printf(" Scope: %s\n", scope)
116 }
117 if len(includes) > 0 {
118 fmt.Printf(" Includes: %v\n", includes)
119 }
120 }
121
122 return nil
123 }
124
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/adr.go 10.4%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 func newADRCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "adr",
16 Short: "Manage Architecture Decision Records (ADRs)",
17 Long: "List, show, and manage architecture decision records linked to model elements.",
18 RunE: func(cmd *cobra.Command, args []string) error {
19 return cmd.Help()
20 },
21 }
22
23 cmd.AddCommand(newADRListCmd())
24 cmd.AddCommand(newADRShowCmd())
25
26 return cmd
27 }
28
29 func newADRListCmd() *cobra.Command {
30 cmd := &cobra.Command{
31 Use: "list",
32 Short: "List all ADRs or ADRs linked to an element",
33 Long: "List architecture decision records, optionally filtered by element.",
34 RunE: func(cmd *cobra.Command, args []string) error {
35 modelPath := cmd.Flag("model").Value.String()
36 elementID := cmd.Flag("element").Value.String()
37 format := cmd.Flag("format").Value.String()
38
39 if modelPath == "" {
40 modelPath = "architecture.jsonc"
41 }
42
43 m, err := model.Load(modelPath)
44 if err != nil {
45 return fmt.Errorf("loading model: %w", err)
46 }
47
48 // Collect decisions to display
49 var decisions []model.DecisionRecord
50 if elementID != "" {
51 // Filter decisions for a specific element
52 elem, ok := findElementByID(m, elementID)
53 if !ok || elem == nil {
54 return fmt.Errorf("element not found: %s", elementID)
55 }
56
57 for _, decisionID := range elem.Decisions {
58 for _, d := range m.Specification.Decisions {
59 if d.ID == decisionID {
60 decisions = append(decisions, d)
61 break
62 }
63 }
64 }
65 } else {
66 // All decisions
67 decisions = m.Specification.Decisions
68 }
69
70 // Sort by ID
71 sort.Slice(decisions, func(i, j int) bool {
72 return decisions[i].ID < decisions[j].ID
73 })
74
75 // Format output
76 if format == "json" {
77 b, err := json.MarshalIndent(decisions, "", " ")
78 if err != nil {
79 return fmt.Errorf("marshaling JSON: %w", err)
80 }
81 fmt.Println(string(b))
82 return nil
83 }
84
85 // Default text format
86 if len(decisions) == 0 {
87 if elementID != "" {
88 fmt.Printf("No decisions linked to element %q\n", elementID)
89 } else {
90 fmt.Println("No decisions defined")
91 }
92 return nil
93 }
94
95 fmt.Printf("Decisions (%d):\n", len(decisions))
96 fmt.Println("──────────────────────────────────────────")
97 for _, d := range decisions {
98 statusIcon := getStatusIcon(d.Status)
99 fmt.Printf("%-20s %s %s\n", d.ID, statusIcon, d.Title)
100 if d.Date != "" {
101 fmt.Printf(" Date: %s\n", d.Date)
102 }
103 if d.FilePath != "" {
104 fmt.Printf(" File: %s\n", d.FilePath)
105 }
106 }
107
108 return nil
109 },
110 }
111
112 cmd.Flags().StringP("model", "m", "", "Path to architecture model (default: architecture.jsonc)")
113 cmd.Flags().String("element", "", "Filter decisions linked to this element")
114 cmd.Flags().String("format", "text", "Output format: text or json")
115
116 return cmd
117 }
118
119 func newADRShowCmd() *cobra.Command {
120 cmd := &cobra.Command{
121 Use: "show <adr-id>",
122 Short: "Show details of a specific ADR",
123 Long: "Display detailed information about an architecture decision record.",
124 Args: cobra.ExactArgs(1),
125 RunE: func(cmd *cobra.Command, args []string) error {
126 modelPath := cmd.Flag("model").Value.String()
127 decisionID := args[0]
128
129 if modelPath == "" {
130 modelPath = "architecture.jsonc"
131 }
132
133 m, err := model.Load(modelPath)
134 if err != nil {
135 return fmt.Errorf("loading model: %w", err)
136 }
137
138 // Find the decision
139 var decision *model.DecisionRecord
140 for i, d := range m.Specification.Decisions {
141 if d.ID == decisionID {
142 decision = &m.Specification.Decisions[i]
143 break
144 }
145 }
146
147 if decision == nil {
148 return fmt.Errorf("decision not found: %s", decisionID)
149 }
150
151 // Collect elements and relationships that reference this decision
152 var references []string
153 flat, _ := model.FlattenElements(m)
154 for elemID, elem := range flat {
155 for _, dID := range elem.Decisions {
156 if dID == decisionID {
157 references = append(references, "element: "+elemID)
158 break
159 }
160 }
161 }
162 for _, rel := range m.Relationships {
163 for _, dID := range rel.Decisions {
164 if dID == decisionID {
165 references = append(references, fmt.Sprintf("relationship: %s → %s", rel.From, rel.To))
166 break
167 }
168 }
169 }
170
171 // Display information
172 statusIcon := getStatusIcon(decision.Status)
173 fmt.Printf("ADR: %s %s\n", decision.ID, statusIcon)
174 fmt.Println("──────────────────────────────────────────")
175 fmt.Printf("Title: %s\n", decision.Title)
176 fmt.Printf("Status: %s\n", decision.Status)
177 if decision.Date != "" {
178 fmt.Printf("Date: %s\n", decision.Date)
179 }
180 if decision.FilePath != "" {
181 fmt.Printf("File: %s\n", decision.FilePath)
182 }
183
184 if len(references) > 0 {
185 sort.Strings(references)
186 fmt.Println("\nReferenced by:")
187 for _, ref := range references {
188 fmt.Printf(" - %s\n", ref)
189 }
190 }
191
192 return nil
193 },
194 }
195
196 cmd.Flags().StringP("model", "m", "", "Path to architecture model (default: architecture.jsonc)")
197
198 return cmd
199 }
200
201 func getStatusIcon(status model.ADRStatus) string {
202 switch status {
203 case model.ADRActive:
204 return "✓"
205 case model.ADRProposed:
206 return "◯"
207 case model.ADRDeprecated:
208 return "⚠"
209 case model.ADRSuperseded:
210 return "✗"
211 default:
212 return "?"
213 }
214 }
215
216 func findElementByID(m *model.BausteinsichtModel, id string) (*model.Element, bool) {
217 parts := strings.Split(id, ".")
218 if len(parts) == 0 {
219 return nil, false
220 }
221
222 elem, ok := m.Model[parts[0]]
223 if !ok {
224 return nil, false
225 }
226
227 // Navigate through child elements
228 current := &elem
229 for _, part := range parts[1:] {
230 child, ok := current.Children[part]
231 if !ok {
232 return nil, false
233 }
234 current = &child
235 }
236
237 return current, true
238 }
239
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/changelog_cmd.go 16.3%
1 package main
2
3 import (
4 "fmt"
5 "os"
6
7 "github.com/docToolchain/Bausteinsicht/internal/changelog"
8 "github.com/spf13/cobra"
9 )
10
11 func newChangelogCmd() *cobra.Command {
12 cmd := &cobra.Command{
13 Use: "changelog",
14 Short: "Generate architecture changelog between two points in time",
15 Long: "Compare two versions of the architecture model and generate a human-readable changelog showing what changed.",
16 RunE: runChangelog,
17 }
18
19 cmd.Flags().String("model", "architecture.jsonc", "Model file path")
20 cmd.Flags().String("since", "", "Starting git ref or snapshot ID (default: previous tag)")
21 cmd.Flags().String("until", "HEAD", "Ending git ref or snapshot ID")
22 cmd.Flags().String("format", "markdown", "Output format: markdown, asciidoc, or json")
23 cmd.Flags().String("output", "", "Output file path (default: stdout)")
24
25 return cmd
26 }
27
28 func runChangelog(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 since, _ := cmd.Flags().GetString("since")
31 until, _ := cmd.Flags().GetString("until")
32 format, _ := cmd.Flags().GetString("format")
33 output, _ := cmd.Flags().GetString("output")
34
35 if err := validatePathContainment(modelPath); err != nil {
36 return exitWithCode(err, 2)
37 }
38
39 // Validate format
40 if format != "markdown" && format != "asciidoc" && format != "json" {
41 return exitWithCode(fmt.Errorf("invalid format: %s (expected markdown, asciidoc, or json)", format), 2)
42 }
43
44 // Load models at the two refs
45 fromModel, err := changelog.LoadModelAtGitRef(modelPath, since)
46 if err != nil {
47 return exitWithCode(fmt.Errorf("loading model at %q: %w", since, err), 2)
48 }
49
50 toModel, err := changelog.LoadModelAtGitRef(modelPath, until)
51 if err != nil {
52 return exitWithCode(fmt.Errorf("loading model at %q: %w", until, err), 2)
53 }
54
55 // Get reference info for display
56 fromRef := changelog.Reference{Ref: since}
57 toRef := changelog.Reference{Ref: until}
58
59 if fromInfo, err := changelog.GetCommitInfo(since); err == nil {
60 fromRef.Date = fromInfo.Date
61 }
62 if toInfo, err := changelog.GetCommitInfo(until); err == nil {
63 toRef.Date = toInfo.Date
64 }
65
66 // Generate changelog
67 cl := changelog.Generate(fromModel, toModel, fromRef, toRef)
68
69 // Render output
70 var result string
71 switch format {
72 case "markdown":
73 result = changelog.RenderMarkdown(cl)
74 case "asciidoc":
75 result = changelog.RenderAsciiDoc(cl)
76 case "json":
77 var err error
78 result, err = changelog.RenderJSON(cl)
79 if err != nil {
80 return exitWithCode(fmt.Errorf("rendering JSON: %w", err), 2)
81 }
82 }
83
84 // Write output
85 if output == "" {
86 // Write to stdout
87 if _, err := fmt.Fprint(cmd.OutOrStdout(), result); err != nil {
88 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
89 }
90 } else {
91 // Write to file
92 if err := os.WriteFile(output, []byte(result), 0o644); err != nil {
93 return exitWithCode(fmt.Errorf("writing to %q: %w", output, err), 2)
94 }
95 }
96
97 return nil
98 }
99
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/cmd_schema.go 30.0%
1 package main
2
3 import (
4 "fmt"
5 "os"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/docToolchain/Bausteinsicht/internal/schema"
9 "github.com/spf13/cobra"
10 )
11
12 func newSchemaCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "schema",
15 Short: "Manage JSON Schema for architecture models",
16 Long: "Generate and manage JSON Schema definitions for Bausteinsicht models.",
17 }
18
19 cmd.AddCommand(newSchemaGenerateCmd())
20
21 return cmd
22 }
23
24 func newSchemaGenerateCmd() *cobra.Command {
25 cmd := &cobra.Command{
26 Use: "generate",
27 Short: "Generate JSON Schema from Go types",
28 Long: "Generate the JSON Schema from model type definitions and save to schemas/bausteinsicht.schema.json.",
29 RunE: runSchemaGenerate,
30 }
31
32 cmd.Flags().String("output", "schemas/bausteinsicht.schema.json", "Output file for the schema")
33
34 return cmd
35 }
36
37 func runSchemaGenerate(cmd *cobra.Command, _ []string) error {
38 outputFile, _ := cmd.Flags().GetString("output")
39
40 // Validate output path to prevent directory traversal (SEC-001)
41 if err := validatePathContainment(outputFile); err != nil {
42 return exitWithCode(fmt.Errorf("--output: %w", err), 1)
43 }
44
45 // Create schema generator
46 gen := schema.NewGenerator()
47
48 // Generate schema for BausteinsichtModel
49 schemaObj := gen.Generate(model.BausteinsichtModel{})
50
51 // Convert to JSON
52 jsonBytes, err := schemaObj.ToJSON()
53 if err != nil {
54 return fmt.Errorf("failed to convert schema to JSON: %w", err)
55 }
56
57 // Write to file
58 if err := os.WriteFile(outputFile, jsonBytes, 0600); err != nil {
59 return fmt.Errorf("failed to write schema file: %w", err)
60 }
61
62 // Print success message
63 fmt.Printf("✅ Schema generated: %s\n", outputFile)
64 fmt.Printf("📊 Properties: %d\n", len(schemaObj.Properties))
65 fmt.Printf("📌 Required fields: %d\n", len(schemaObj.Required))
66
67 return nil
68 }
69
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/diff.go 56.5%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/diff"
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 func newDiffCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "diff",
15 Short: "Show differences between as-is and to-be architecture",
16 Long: "Compare as-is and to-be sections of the model and report changes.",
17 RunE: runDiff,
18 }
19
20 cmd.Flags().String("model", "architecture.jsonc", "Model file path")
21 cmd.Flags().String("view", "", "Show diff for one view only (optional)")
22 cmd.Flags().String("format", "text", "Output format: text or json")
23
24 return cmd
25 }
26
27 func runDiff(cmd *cobra.Command, _ []string) error {
28 modelPath, _ := cmd.Flags().GetString("model")
29 format, _ := cmd.Flags().GetString("format")
30
31 if err := validatePathContainment(modelPath); err != nil {
32 return exitWithCode(err, 2)
33 }
34
35 m, err := model.Load(modelPath)
36 if err != nil {
37 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
38 }
39
40 if m.AsIs == nil || m.ToBe == nil {
41 return exitWithCode(fmt.Errorf("model does not contain asIs and toBe sections"), 1)
42 }
43
44 result := diff.Compare(m.AsIs, m.ToBe)
45
46 switch format {
47 case "json":
48 data, err := json.MarshalIndent(result, "", " ")
49 if err != nil {
50 return exitWithCode(fmt.Errorf("marshaling JSON: %w", err), 2)
51 }
52 if _, err := fmt.Fprint(cmd.OutOrStdout(), string(data)); err != nil {
53 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
54 }
55 case "text":
56 output := formatDiffAsText(result)
57 if _, err := fmt.Fprint(cmd.OutOrStdout(), output); err != nil {
58 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
59 }
60 default:
61 return exitWithCode(fmt.Errorf("invalid format: %s (expected text or json)", format), 2)
62 }
63
64 return nil
65 }
66
67 func formatDiffAsText(result *diff.DiffResult) string {
68 output := "Architecture Diff\n"
69 output += "=================\n\n"
70
71 // Added elements
72 if result.Summary.AddedElements > 0 {
73 output += fmt.Sprintf("Added (%d):\n", result.Summary.AddedElements)
74 for _, change := range result.Elements {
75 if change.Type == diff.ChangeAdded && change.ToBe != nil {
76 output += fmt.Sprintf(" + %-20s [%s] \"%s\"\n",
77 change.ID, change.ToBe.Kind, change.ToBe.Title)
78 }
79 }
80 output += "\n"
81 }
82
83 // Removed elements
84 if result.Summary.RemovedElements > 0 {
85 output += fmt.Sprintf("Removed (%d):\n", result.Summary.RemovedElements)
86 for _, change := range result.Elements {
87 if change.Type == diff.ChangeRemoved && change.AsIs != nil {
88 output += fmt.Sprintf(" - %-20s [%s] \"%s\"\n",
89 change.ID, change.AsIs.Kind, change.AsIs.Title)
90 }
91 }
92 output += "\n"
93 }
94
95 // Changed elements
96 if result.Summary.ChangedElements > 0 {
97 output += fmt.Sprintf("Changed (%d):\n", result.Summary.ChangedElements)
98 for _, change := range result.Elements {
99 if change.Type == diff.ChangeChanged && change.AsIs != nil && change.ToBe != nil {
100 output += fmt.Sprintf(" ~ %-20s [%s]\n", change.ID, change.AsIs.Kind)
101
102 // Show what changed
103 if change.AsIs.Title != change.ToBe.Title {
104 output += fmt.Sprintf(" title: \"%s\" → \"%s\"\n",
105 change.AsIs.Title, change.ToBe.Title)
106 }
107 if change.AsIs.Technology != change.ToBe.Technology {
108 output += fmt.Sprintf(" technology: \"%s\" → \"%s\"\n",
109 change.AsIs.Technology, change.ToBe.Technology)
110 }
111 if change.AsIs.Description != change.ToBe.Description {
112 output += " description: changed\n"
113 }
114 if change.AsIs.Status != change.ToBe.Status {
115 output += fmt.Sprintf(" status: \"%s\" → \"%s\"\n",
116 change.AsIs.Status, change.ToBe.Status)
117 }
118 }
119 }
120 output += "\n"
121 }
122
123 // Relationship changes
124 if result.Summary.AddedRelationships > 0 {
125 output += fmt.Sprintf("Added Relationships (%d):\n", result.Summary.AddedRelationships)
126 for _, change := range result.Relationships {
127 if change.Type == diff.ChangeAdded && change.ToBe != nil {
128 output += fmt.Sprintf(" + %s → %s (%s)\n",
129 change.From, change.To, change.ToBe.Label)
130 }
131 }
132 output += "\n"
133 }
134
135 if result.Summary.RemovedRelationships > 0 {
136 output += fmt.Sprintf("Removed Relationships (%d):\n", result.Summary.RemovedRelationships)
137 for _, change := range result.Relationships {
138 if change.Type == diff.ChangeRemoved && change.AsIs != nil {
139 output += fmt.Sprintf(" - %s → %s (%s)\n",
140 change.From, change.To, change.AsIs.Label)
141 }
142 }
143 output += "\n"
144 }
145
146 if result.Summary.AddedElements == 0 && result.Summary.RemovedElements == 0 &&
147 result.Summary.ChangedElements == 0 && result.Summary.AddedRelationships == 0 &&
148 result.Summary.RemovedRelationships == 0 {
149 output += "No changes found between as-is and to-be architecture.\n"
150 }
151
152 return output
153 }
154
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export.go 8.8%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/drawio"
10 "github.com/docToolchain/Bausteinsicht/internal/export"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 "github.com/spf13/cobra"
13 )
14
15 func newExportCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "export",
18 Short: "Export diagram views to PNG or SVG",
19 Long: "Exports draw.io diagram pages to image files using the draw.io CLI.",
20 RunE: runExport,
21 }
22 cmd.Flags().String("image-format", "png", "Image format: png or svg")
23 cmd.Flags().String("view", "", "Export only this view (by key)")
24 cmd.Flags().String("output", ".", "Output directory")
25 cmd.Flags().Bool("embed-diagram", false, "Embed draw.io XML source in output")
26 cmd.Flags().Float64("scale", 1.0, "Export scale factor (e.g. 2.0 for retina, 3.0 for print); scale > 1 requires hardware GPU")
27 return cmd
28 }
29
30 type exportResultJSON struct {
31 Files []string `json:"files"`
32 Errors []string `json:"errors,omitempty"`
33 Success bool `json:"success"`
34 }
35
36 func runExport(cmd *cobra.Command, _ []string) error {
37 format, _ := cmd.Flags().GetString("format")
38 modelPath, _ := cmd.Flags().GetString("model")
39 verbose, _ := cmd.Flags().GetBool("verbose")
40 imageFormat, _ := cmd.Flags().GetString("image-format")
41 viewFilter, _ := cmd.Flags().GetString("view")
42 outputDir, _ := cmd.Flags().GetString("output")
43 embedDiagram, _ := cmd.Flags().GetBool("embed-diagram")
44 scale, _ := cmd.Flags().GetFloat64("scale")
45
46 if outputDir != "" {
47 if err := validatePathContainment(outputDir); err != nil {
48 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
49 }
50 }
51
52 // Validate image format.
53 if imageFormat != "png" && imageFormat != "svg" {
54 return exitWithCode(fmt.Errorf("unsupported image format %q; use png or svg", imageFormat), 2)
55 }
56
57 // Auto-detect model file.
58 if modelPath == "" {
59 detected, err := model.AutoDetect(".")
60 if err != nil {
61 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
62 }
63 modelPath = detected
64 }
65
66 // Derive drawio path from model path.
67 dir := filepath.Dir(modelPath)
68 drawioPath := filepath.Join(dir, "architecture.drawio")
69
70 // Load model to get view keys.
71 m, err := model.Load(modelPath)
72 if err != nil {
73 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
74 }
75
76 if len(m.Views) == 0 {
77 return exitWithCode(fmt.Errorf("no views to export"), 2)
78 }
79
80 // If a specific view was requested, check it exists.
81 if viewFilter != "" {
82 if _, ok := m.Views[viewFilter]; !ok {
83 return exitWithCode(fmt.Errorf("view %q not found in model", viewFilter), 2)
84 }
85 }
86
87 // Load draw.io document to get page ordering.
88 doc, err := drawio.LoadDocument(drawioPath)
89 if err != nil {
90 return exitWithCode(fmt.Errorf("loading draw.io file: %w", err), 2)
91 }
92
93 // Detect draw.io CLI binary.
94 binary, err := export.DetectDrawioBinary()
95 if err != nil {
96 return exitWithCode(err, 2)
97 }
98
99 if verbose && format != "json" {
100 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Using draw.io CLI: %s\n", binary)
101 }
102
103 // Ensure output directory exists.
104 if err := os.MkdirAll(outputDir, 0750); err != nil {
105 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
106 }
107
108 // Build the list of pages to export.
109 pages := doc.Pages()
110 type viewExport struct {
111 key string
112 pageIndex int // 1-based
113 }
114 var exports []viewExport
115
116 for viewKey := range m.Views {
117 if viewFilter != "" && viewKey != viewFilter {
118 continue
119 }
120 pageID := "view-" + viewKey
121 for i, p := range pages {
122 if p.ID() == pageID {
123 exports = append(exports, viewExport{key: viewKey, pageIndex: i + 1})
124 break
125 }
126 }
127 }
128
129 if len(exports) == 0 {
130 return exitWithCode(fmt.Errorf("no matching pages found in draw.io file"), 2)
131 }
132
133 // Export each page.
134 var files []string
135 var exportErrors []string
136
137 for _, ex := range exports {
138 outFile := filepath.Join(outputDir, export.OutputFileName(ex.key, imageFormat))
139
140 if verbose && format != "json" {
141 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exporting view %q to %s\n", ex.key, outFile)
142 }
143
144 err := export.ExportPage(binary, export.ExportOptions{
145 Format: imageFormat,
146 PageIndex: ex.pageIndex,
147 OutputPath: outFile,
148 EmbedDiagram: embedDiagram,
149 InputFile: drawioPath,
150 Scale: scale,
151 })
152 if err != nil {
153 exportErrors = append(exportErrors, fmt.Sprintf("view %q: %v", ex.key, err))
154 continue
155 }
156 files = append(files, outFile)
157 }
158
159 // Output results.
160 if format == "json" {
161 result := exportResultJSON{
162 Files: files,
163 Errors: exportErrors,
164 Success: len(exportErrors) == 0,
165 }
166 out, _ := json.MarshalIndent(result, "", " ")
167 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
168 } else {
169 for _, f := range files {
170 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Exported: %s\n", f)
171 }
172 for _, e := range exportErrors {
173 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ERROR: %s\n", e)
174 }
175 }
176
177 if len(exportErrors) > 0 {
178 return exitWithCode(fmt.Errorf("%d export(s) failed", len(exportErrors)), 1)
179 }
180 return nil
181 }
182
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export_diagram.go 56.7%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9
10 "github.com/docToolchain/Bausteinsicht/internal/diagram"
11 "github.com/docToolchain/Bausteinsicht/internal/export"
12 dslexport "github.com/docToolchain/Bausteinsicht/internal/exporter/structurizr"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 "github.com/spf13/cobra"
15 )
16
17 func newExportDiagramCmd() *cobra.Command {
18 cmd := &cobra.Command{
19 Use: "export-diagram",
20 Short: "Export views as C4 diagrams (PlantUML, Mermaid, DOT, D2, HTML5, Structurizr DSL)",
21 Long: "Exports architecture views as text-based C4 diagrams (PlantUML, Mermaid, DOT, D2), interactive HTML5 viewer, or Structurizr DSL workspace.",
22 RunE: runExportDiagram,
23 }
24
25 cmd.Flags().String("view", "", "Export only this view (by key)")
26 cmd.Flags().String("diagram-format", "plantuml", "Diagram format: plantuml, mermaid, dot, d2, html, or structurizr")
27 cmd.Flags().String("output", "", "Output directory (default: stdout)")
28
29 return cmd
30 }
31
32 func runExportDiagram(cmd *cobra.Command, _ []string) error {
33 modelPath, _ := cmd.Flags().GetString("model")
34 viewKey, _ := cmd.Flags().GetString("view")
35 diagramFormat, _ := cmd.Flags().GetString("diagram-format")
36 outputDir, _ := cmd.Flags().GetString("output")
37
38 if outputDir != "" {
39 if err := validatePathContainment(outputDir); err != nil {
40 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
41 }
42 }
43
44 if modelPath == "" {
45 detected, err := model.AutoDetect(".")
46 if err != nil {
47 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
48 }
49 modelPath = detected
50 }
51
52 m, err := model.Load(modelPath)
53 if err != nil {
54 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
55 }
56
57 // Structurizr DSL export: outputs the whole workspace in one file.
58 if diagramFormat == "structurizr" {
59 // Structurizr exports the entire workspace, not individual views
60 if viewKey != "" {
61 return exitWithCode(fmt.Errorf("--view is not supported with structurizr format (exports entire workspace)"), 1)
62 }
63 dsl := dslexport.Export(m)
64 if outputDir == "" {
65 _, _ = fmt.Fprint(cmd.OutOrStdout(), dsl)
66 return nil
67 }
68 if err := os.MkdirAll(outputDir, 0750); err != nil {
69 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
70 }
71 outPath := filepath.Join(outputDir, "workspace.dsl")
72 if err := os.WriteFile(outPath, []byte(dsl), 0600); err != nil { //nolint:gosec
73 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
74 }
75 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
76 return nil
77 }
78
79 // Determine which views to export.
80 views := make(map[string]model.View)
81 if viewKey != "" {
82 v, ok := m.Views[viewKey]
83 if !ok {
84 return exitWithCode(fmt.Errorf("view %q not found", viewKey), 1)
85 }
86 views[viewKey] = v
87 } else {
88 views = m.Views
89 }
90
91 outputFormat, _ := cmd.Flags().GetString("format")
92
93 // Handle new export formats (DOT, D2, HTML) — with JSON envelope support
94 switch diagramFormat {
95 case "dot", "d2", "html":
96 return handleNewFormats(cmd, m, views, diagramFormat, outputFormat, outputDir, viewKey)
97 }
98
99 var f diagram.Format
100 var ext string
101 switch diagramFormat {
102 case "plantuml":
103 f = diagram.PlantUML
104 ext = "puml"
105 case "mermaid":
106 f = diagram.Mermaid
107 ext = "mmd"
108 default:
109 return exitWithCode(fmt.Errorf("unknown diagram format %q: valid values are \"plantuml\", \"mermaid\", \"dot\", \"d2\", \"html\", or \"structurizr\"", diagramFormat), 2)
110 }
111
112 // When --format json, output structured JSON with diagram source. (#241)
113 if outputFormat == "json" {
114 type diagramEntry struct {
115 View string `json:"view"`
116 Format string `json:"format"`
117 Source string `json:"source"`
118 }
119 var entries []diagramEntry
120 keys := sortedKeys(views)
121 for _, key := range keys {
122 result, fmtErr := diagram.FormatView(m, key, f)
123 if fmtErr != nil {
124 return exitWithCode(fmtErr, 1)
125 }
126 entries = append(entries, diagramEntry{
127 View: key,
128 Format: diagramFormat,
129 Source: result,
130 })
131 }
132 data, _ := json.MarshalIndent(entries, "", " ")
133 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
134 return nil
135 }
136
137 for key := range views {
138 result, fmtErr := diagram.FormatView(m, key, f)
139 if fmtErr != nil {
140 return exitWithCode(fmtErr, 1)
141 }
142
143 if outputDir == "" {
144 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
145 continue
146 }
147
148 if err := os.MkdirAll(outputDir, 0750); err != nil {
149 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
150 }
151 outPath := filepath.Join(outputDir, export.SafeViewKey(key)+"."+ext)
152 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil { //nolint:gosec // output files are non-sensitive documentation
153 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
154 }
155 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
156 }
157
158 return nil
159 }
160
161 func handleNewFormats(cmd *cobra.Command, m *model.BausteinsichtModel, views map[string]model.View, diagramFormat, outputFormat, outputDir, viewKey string) error {
162 var renderFunc func(*model.BausteinsichtModel, string) (string, error)
163 var ext string
164
165 switch diagramFormat {
166 case "dot":
167 renderFunc = diagram.RenderDOT
168 ext = "dot"
169 case "d2":
170 renderFunc = diagram.RenderD2
171 ext = "d2"
172 case "html":
173 renderFunc = diagram.RenderHTML
174 ext = "html"
175 default:
176 return exitWithCode(fmt.Errorf("unsupported format: %s", diagramFormat), 2)
177 }
178
179 // When --format json, output structured JSON with diagram source
180 if outputFormat == "json" {
181 type diagramEntry struct {
182 View string `json:"view"`
183 Format string `json:"format"`
184 Source string `json:"source"`
185 }
186 var entries []diagramEntry
187 keys := sortedKeys(views)
188 for _, key := range keys {
189 result, fmtErr := renderFunc(m, key)
190 if fmtErr != nil {
191 return exitWithCode(fmtErr, 1)
192 }
193 entries = append(entries, diagramEntry{
194 View: key,
195 Format: diagramFormat,
196 Source: result,
197 })
198 }
199 data, _ := json.MarshalIndent(entries, "", " ")
200 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
201 return nil
202 }
203
204 // For HTML, create a single file containing all views
205 if diagramFormat == "html" {
206 // When exporting to HTML, we need to handle multiple views in a single file
207 if viewKey != "" {
208 // Single view HTML export
209 result, err := renderFunc(m, viewKey)
210 if err != nil {
211 return exitWithCode(err, 1)
212 }
213
214 if outputDir == "" {
215 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
216 return nil
217 }
218
219 if err := os.MkdirAll(outputDir, 0750); err != nil {
220 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
221 }
222
223 outPath := filepath.Join(outputDir, export.SafeViewKey(viewKey)+".html")
224 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil {
225 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
226 }
227 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
228 return nil
229 }
230
231 // Multiple views: export each as separate HTML file
232 keys := sortedKeys(views)
233 for _, key := range keys {
234 result, err := renderFunc(m, key)
235 if err != nil {
236 return exitWithCode(err, 1)
237 }
238
239 if outputDir == "" {
240 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
241 continue
242 }
243
244 if err := os.MkdirAll(outputDir, 0750); err != nil {
245 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
246 }
247
248 outPath := filepath.Join(outputDir, export.SafeViewKey(key)+".html")
249 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil {
250 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
251 }
252 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
253 }
254 return nil
255 }
256
257 // For DOT and D2: export each view separately
258 keys := sortedKeys(views)
259 for _, key := range keys {
260 result, err := renderFunc(m, key)
261 if err != nil {
262 return exitWithCode(err, 1)
263 }
264
265 if outputDir == "" {
266 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
267 continue
268 }
269
270 if err := os.MkdirAll(outputDir, 0750); err != nil {
271 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
272 }
273
274 outPath := filepath.Join(outputDir, "architecture-"+export.SafeViewKey(key)+"."+ext)
275 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil {
276 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
277 }
278 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
279 }
280
281 return nil
282 }
283
284 func sortedKeys(views map[string]model.View) []string {
285 keys := make([]string, 0, len(views))
286 for k := range views {
287 keys = append(keys, k)
288 }
289 sort.Strings(keys)
290 return keys
291 }
292
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export_sequence.go 86.6%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/diagram"
10 "github.com/docToolchain/Bausteinsicht/internal/export"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 "github.com/spf13/cobra"
13 )
14
15 func newExportSequenceCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "export-sequence",
18 Short: "Export dynamic views as PlantUML or Mermaid sequence diagrams",
19 Long: "Exports dynamic views (sequence diagrams) as PlantUML (.puml) or Mermaid (.md) text files.",
20 RunE: runExportSequence,
21 }
22 cmd.Flags().String("view", "", "Export only this dynamic view (by key)")
23 cmd.Flags().String("diagram-format", "plantuml", "Diagram format: plantuml or mermaid")
24 cmd.Flags().String("output", "", "Output directory (default: stdout)")
25 return cmd
26 }
27
28 func runExportSequence(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 viewKey, _ := cmd.Flags().GetString("view")
31 diagramFormat, _ := cmd.Flags().GetString("diagram-format")
32 outputDir, _ := cmd.Flags().GetString("output")
33 format, _ := cmd.Flags().GetString("format")
34
35 if outputDir != "" {
36 if err := validatePathContainment(outputDir); err != nil {
37 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
38 }
39 }
40
41 if modelPath == "" {
42 detected, err := model.AutoDetect(".")
43 if err != nil {
44 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
45 }
46 modelPath = detected
47 }
48
49 m, err := model.Load(modelPath)
50 if err != nil {
51 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
52 }
53
54 var ext string
55 switch diagramFormat {
56 case "plantuml":
57 ext = "puml"
58 case "mermaid":
59 ext = "md"
60 default:
61 return exitWithCode(fmt.Errorf("unknown diagram format %q: valid values are \"plantuml\" and \"mermaid\"", diagramFormat), 2)
62 }
63
64 // Select views to export.
65 views := m.DynamicViews
66 if viewKey != "" {
67 var found *model.DynamicView
68 for i := range m.DynamicViews {
69 if m.DynamicViews[i].Key == viewKey {
70 found = &m.DynamicViews[i]
71 break
72 }
73 }
74 if found == nil {
75 return exitWithCode(fmt.Errorf("dynamic view %q not found", viewKey), 1)
76 }
77 views = []model.DynamicView{*found}
78 }
79
80 if len(views) == 0 {
81 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "No dynamic views defined in model.")
82 return nil
83 }
84
85 flat, err := model.FlattenElements(m)
86 if err != nil {
87 return exitWithCode(fmt.Errorf("flattening elements: %w", err), 2)
88 }
89
90 render := func(v model.DynamicView) string {
91 if diagramFormat == "mermaid" {
92 return diagram.RenderSequenceMermaid(v, flat)
93 }
94 return diagram.RenderSequencePlantUML(v, flat)
95 }
96
97 // JSON output.
98 if format == "json" {
99 type entry struct {
100 View string `json:"view"`
101 Format string `json:"format"`
102 Source string `json:"source"`
103 }
104 var entries []entry
105 for _, v := range views {
106 entries = append(entries, entry{View: v.Key, Format: diagramFormat, Source: render(v)})
107 }
108 data, _ := json.MarshalIndent(entries, "", " ")
109 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
110 return nil
111 }
112
113 // Text / file output.
114 for _, v := range views {
115 source := render(v)
116
117 if outputDir == "" {
118 _, _ = fmt.Fprint(cmd.OutOrStdout(), source)
119 continue
120 }
121
122 if err := os.MkdirAll(outputDir, 0750); err != nil {
123 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
124 }
125 filename := "sequence-" + export.SafeViewKey(v.Key) + "." + ext
126 outPath := filepath.Join(outputDir, filename)
127 if err := os.WriteFile(outPath, []byte(source), 0600); err != nil { //nolint:gosec // output files are non-sensitive documentation
128 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
129 }
130 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
131 }
132
133 return nil
134 }
135
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export_table.go 71.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/export"
10 "github.com/docToolchain/Bausteinsicht/internal/model"
11 "github.com/docToolchain/Bausteinsicht/internal/table"
12 "github.com/spf13/cobra"
13 )
14
15 func newExportTableCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "export-table",
18 Short: "Export element attributes as AsciiDoc or Markdown table",
19 Long: "Exports view elements as a table with columns: Element, Kind, Technology, Description.",
20 RunE: runExportTable,
21 }
22
23 cmd.Flags().String("view", "", "Export only this view (by key)")
24 cmd.Flags().String("table-format", "adoc", "Table format: adoc or md")
25 cmd.Flags().String("output", "", "Output directory (default: stdout)")
26 cmd.Flags().Bool("combined", false, "Export all elements across all views (deduplicated)")
27
28 return cmd
29 }
30
31 func runExportTable(cmd *cobra.Command, _ []string) error {
32 modelPath, _ := cmd.Flags().GetString("model")
33 format, _ := cmd.Flags().GetString("format")
34 viewKey, _ := cmd.Flags().GetString("view")
35 tableFormat, _ := cmd.Flags().GetString("table-format")
36 outputDir, _ := cmd.Flags().GetString("output")
37 combined, _ := cmd.Flags().GetBool("combined")
38
39 if outputDir != "" {
40 if err := validatePathContainment(outputDir); err != nil {
41 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
42 }
43 }
44
45 if modelPath == "" {
46 detected, err := model.AutoDetect(".")
47 if err != nil {
48 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
49 }
50 modelPath = detected
51 }
52
53 m, err := model.Load(modelPath)
54 if err != nil {
55 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
56 }
57
58 // When --format json is set, output structured JSON instead of a table. (#239)
59 if format == "json" {
60 return exportTableJSON(cmd, m, viewKey, combined)
61 }
62
63 var f table.Format
64 switch tableFormat {
65 case "adoc":
66 f = table.AsciiDoc
67 case "md":
68 f = table.Markdown
69 default:
70 return exitWithCode(fmt.Errorf("unknown table format %q: valid values are \"adoc\" and \"md\"", tableFormat), 2)
71 }
72
73 var result string
74 var filename string
75
76 switch {
77 case combined:
78 result, err = table.FormatCombined(m, f)
79 filename = "elements." + tableFormat
80 case viewKey != "":
81 result, err = table.FormatView(m, viewKey, f)
82 filename = export.SafeViewKey(viewKey) + "-elements." + tableFormat
83 default:
84 result, err = table.FormatAllViews(m, f)
85 filename = "all-views-elements." + tableFormat
86 }
87 if err != nil {
88 return exitWithCode(err, 1)
89 }
90
91 if outputDir == "" {
92 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
93 return nil
94 }
95
96 outPath := filepath.Join(outputDir, filename)
97 if err := os.MkdirAll(outputDir, 0750); err != nil {
98 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
99 }
100 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil { //nolint:gosec // output files are non-sensitive documentation
101 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
102 }
103
104 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
105 return nil
106 }
107
108 // exportTableJSON outputs the table data as JSON. (#239)
109 func exportTableJSON(cmd *cobra.Command, m *model.BausteinsichtModel, viewKey string, combined bool) error {
110 rows, err := table.CollectRows(m, viewKey, combined)
111 if err != nil {
112 return exitWithCode(err, 1)
113 }
114 data, err := json.MarshalIndent(rows, "", " ")
115 if err != nil {
116 return exitWithCode(fmt.Errorf("marshaling JSON: %w", err), 2)
117 }
118 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
119 return nil
120 }
121
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/find.go 75.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/search"
10 "github.com/spf13/cobra"
11 )
12
13 func newFindCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "find <query>",
16 Short: "Search elements, relationships, and views by free-text query",
17 Long: `Search all model objects (elements, relationships, views) for the given query.
18
19 All words in a multi-word query must match (AND semantics). Matching is
20 case-insensitive and partial (e.g. "pay" matches "payment-service").
21
22 Results are ranked by relevance score. Use --format json for LLM workflows.`,
23 Args: cobra.MinimumNArgs(1),
24 SilenceUsage: true,
25 SilenceErrors: true,
26 RunE: runFind,
27 }
28 cmd.Flags().String("type", "all", "Limit results to: element, relationship, view, all")
29 return cmd
30 }
31
32 func runFind(cmd *cobra.Command, args []string) error {
33 query := strings.Join(args, " ")
34 format, _ := cmd.Flags().GetString("format")
35 modelPath, _ := cmd.Flags().GetString("model")
36 typeFlag, _ := cmd.Flags().GetString("type")
37
38 if modelPath == "" {
39 detected, err := model.AutoDetect(".")
40 if err != nil {
41 return exitWithCode(err, 2)
42 }
43 modelPath = detected
44 }
45
46 m, err := model.Load(modelPath)
47 if err != nil {
48 return exitWithCode(err, 2)
49 }
50
51 opts := search.Options{}
52 switch typeFlag {
53 case "element":
54 opts.Type = search.ResultElement
55 case "relationship":
56 opts.Type = search.ResultRelationship
57 case "view":
58 opts.Type = search.ResultView
59 case "all", "":
60 // no filter
61 default:
62 return exitWithCode(fmt.Errorf("unknown --type %q: use element, relationship, view, or all", typeFlag), 2)
63 }
64
65 resp := search.Run(query, m, opts)
66
67 if format == "json" {
68 data, err := json.MarshalIndent(resp, "", " ")
69 if err != nil {
70 return err
71 }
72 _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
73 return err
74 }
75
76 return printFindText(cmd, resp)
77 }
78
79 func printFindText(cmd *cobra.Command, resp search.Response) error {
80 out := cmd.OutOrStdout()
81 if resp.Total == 0 {
82 _, err := fmt.Fprintf(out, "No results for %q.\n", resp.Query)
83 return err
84 }
85
86 header := fmt.Sprintf("Search results for %q (%d match", resp.Query, resp.Total)
87 if resp.Total != 1 {
88 header += "es"
89 }
90 header += ")"
91 if _, err := fmt.Fprintln(out, header); err != nil {
92 return err
93 }
94 if _, err := fmt.Fprintln(out, strings.Repeat("=", len(header))); err != nil {
95 return err
96 }
97 if _, err := fmt.Fprintln(out); err != nil {
98 return err
99 }
100
101 // Group by type for display.
102 var elements, relationships, views []search.Result
103 for _, r := range resp.Results {
104 switch r.Type {
105 case search.ResultElement:
106 elements = append(elements, r)
107 case search.ResultRelationship:
108 relationships = append(relationships, r)
109 case search.ResultView:
110 views = append(views, r)
111 }
112 }
113
114 if len(elements) > 0 {
115 if _, err := fmt.Fprintf(out, "Elements (%d):\n", len(elements)); err != nil {
116 return err
117 }
118 for _, r := range elements {
119 extra := ""
120 if r.Technology != "" {
121 extra = " technology: " + r.Technology
122 }
123 if _, err := fmt.Fprintf(out, " %-28s [%-10s] %-35s%s score: %d\n",
124 r.ID, r.Kind, fmt.Sprintf("%q", r.Title), extra, r.Score); err != nil {
125 return err
126 }
127 }
128 if _, err := fmt.Fprintln(out); err != nil {
129 return err
130 }
131 }
132
133 if len(relationships) > 0 {
134 if _, err := fmt.Fprintf(out, "Relationships (%d):\n", len(relationships)); err != nil {
135 return err
136 }
137 for _, r := range relationships {
138 label := ""
139 if r.Title != "" {
140 label = fmt.Sprintf("%q", r.Title)
141 }
142 if _, err := fmt.Fprintf(out, " %-28s %s → %s %s score: %d\n",
143 r.ID, r.From, r.To, label, r.Score); err != nil {
144 return err
145 }
146 }
147 if _, err := fmt.Fprintln(out); err != nil {
148 return err
149 }
150 }
151
152 if len(views) > 0 {
153 if _, err := fmt.Fprintf(out, "Views (%d):\n", len(views)); err != nil {
154 return err
155 }
156 for _, r := range views {
157 if _, err := fmt.Fprintf(out, " %-28s %-35s score: %d\n",
158 r.ID, fmt.Sprintf("%q", r.Title), r.Score); err != nil {
159 return err
160 }
161 }
162 if _, err := fmt.Fprintln(out); err != nil {
163 return err
164 }
165 }
166
167 return nil
168 }
169
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/generate_template.go 83.9%
1 package main
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/template"
10 "github.com/spf13/cobra"
11 )
12
13 func newGenerateTemplateCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "generate-template",
16 Short: "Generate a draw.io template from element specification",
17 Long: "Creates a draw.io template file with visual styles for all element kinds defined in the spec.",
18 RunE: runGenerateTemplate,
19 }
20
21 cmd.Flags().String("model", "", "Model file (default: auto-detect)")
22 cmd.Flags().String("output", "architecture-template.drawio", "Output template file")
23 cmd.Flags().String("style", "default", "Visual preset: default, c4, minimal, or dark")
24
25 return cmd
26 }
27
28 func runGenerateTemplate(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 outputPath, _ := cmd.Flags().GetString("output")
31 style, _ := cmd.Flags().GetString("style")
32
33 // Validate output path containment
34 if err := validatePathContainment(outputPath); err != nil {
35 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
36 }
37
38 // Auto-detect model if not provided
39 if modelPath == "" {
40 detected, err := model.AutoDetect(".")
41 if err != nil {
42 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
43 }
44 modelPath = detected
45 }
46
47 // Load model
48 m, err := model.Load(modelPath)
49 if err != nil {
50 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
51 }
52
53 // Validate style
54 validStyles := map[string]bool{
55 "default": true,
56 "c4": true,
57 "minimal": true,
58 "dark": true,
59 }
60 if !validStyles[style] {
61 return exitWithCode(fmt.Errorf("unknown style %q: valid values are default, c4, minimal, dark", style), 2)
62 }
63
64 // Generate template
65 gen := template.NewGenerator(m.Specification, style)
66 templateXML := gen.Generate()
67
68 // Write output
69 outputDir := filepath.Dir(outputPath)
70 if outputDir != "." && outputDir != "" {
71 if err := os.MkdirAll(outputDir, 0750); err != nil {
72 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
73 }
74 }
75
76 if err := os.WriteFile(outputPath, []byte(templateXML), 0600); err != nil { //nolint:gosec
77 return exitWithCode(fmt.Errorf("writing template: %w", err), 2)
78 }
79
80 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Generated template: %s\n", outputPath)
81 return nil
82 }
83
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/graph.go 6.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "sort"
8 "strings"
9
10 "github.com/docToolchain/Bausteinsicht/internal/graph"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 "github.com/spf13/cobra"
13 )
14
15 func newGraphCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "graph",
18 Short: "Analyze relationship graph for cycles and dependencies",
19 Long: "Analyzes the relationship graph to detect cycles, calculate centrality metrics, and identify dependency patterns.",
20 RunE: runGraph,
21 }
22
23 cmd.Flags().String("output", "", "Output file for analysis report (default: stdout)")
24 cmd.Flags().Bool("cycles-only", false, "Show only detected cycles")
25 cmd.Flags().Bool("centrality", false, "Show centrality metrics for each element")
26
27 return cmd
28 }
29
30 func runGraph(cmd *cobra.Command, _ []string) error {
31 modelPath, _ := cmd.Flags().GetString("model")
32 format, _ := cmd.Flags().GetString("format")
33 outputPath, _ := cmd.Flags().GetString("output")
34 cyclesOnly, _ := cmd.Flags().GetBool("cycles-only")
35 showCentrality, _ := cmd.Flags().GetBool("centrality")
36
37 if modelPath == "" {
38 return exitWithCode(fmt.Errorf("--model flag is required"), 2)
39 }
40
41 m, err := model.Load(modelPath)
42 if err != nil {
43 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
44 }
45
46 // Analyze graph
47 analyzer := graph.NewAnalyzer(m)
48 result := analyzer.Analyze()
49
50 // Format output
51 var output string
52
53 if format == "json" {
54 data, _ := json.MarshalIndent(result, "", " ")
55 output = string(data)
56 } else {
57 output = formatGraphReport(result, cyclesOnly, showCentrality)
58 }
59
60 // Write output
61 if outputPath != "" {
62 if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil {
63 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
64 }
65 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Graph analysis written to %s\n", outputPath)
66 } else {
67 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
68 }
69
70 return nil
71 }
72
73 func formatGraphReport(result *graph.GraphAnalysis, cyclesOnly, showCentrality bool) string {
74 var sb strings.Builder
75
76 sb.WriteString("Relationship Graph Analysis\n")
77 sb.WriteString("===========================\n\n")
78
79 // Summary
80 sb.WriteString("Summary\n")
81 sb.WriteString("-------\n")
82 fmt.Fprintf(&sb, "Elements: %d\n", result.ElementCount)
83 fmt.Fprintf(&sb, "Relationships: %d\n", result.RelationshipCount)
84 fmt.Fprintf(&sb, "Max Dependency Depth: %d\n", result.MaxDepth)
85 sb.WriteString("Graph Type: ")
86 if result.IDAGValid {
87 sb.WriteString("DAG (acyclic)")
88 } else {
89 sb.WriteString("Cyclic (contains cycles)")
90 }
91 sb.WriteString("\n\n")
92
93 // Cycles
94 if len(result.Cycles) > 0 {
95 fmt.Fprintf(&sb, "Cycles Found: %d\n", len(result.Cycles))
96 sb.WriteString("--------\n")
97 for idx, cycle := range result.Cycles {
98 fmt.Fprintf(&sb, "Cycle %d (length %d): %s\n", idx+1, cycle.Length, strings.Join(cycle.Elements, " → "))
99 }
100 sb.WriteString("\n")
101 } else {
102 sb.WriteString("No cycles detected (valid DAG)\n\n")
103 }
104
105 if cyclesOnly {
106 return sb.String()
107 }
108
109 // Strongly connected components
110 if len(result.Components) > 0 {
111 cycleCount := 0
112 for _, comp := range result.Components {
113 if comp.IsCycle {
114 cycleCount++
115 }
116 }
117 fmt.Fprintf(&sb, "Strongly Connected Components: %d\n", len(result.Components))
118 if cycleCount > 0 {
119 fmt.Fprintf(&sb, " (includes %d cycle(s))\n", cycleCount)
120 }
121 sb.WriteString("--------\n")
122 for _, comp := range result.Components {
123 if comp.IsCycle {
124 fmt.Fprintf(&sb, "Component %d (CYCLE): %v\n", comp.ID+1, comp.Elements)
125 }
126 }
127 sb.WriteString("\n")
128 }
129
130 // Centrality metrics
131 if showCentrality && len(result.Centrality) > 0 {
132 sb.WriteString("Centrality Metrics\n")
133 sb.WriteString("------------------\n")
134 sb.WriteString("Element | In-Degree | Out-Degree | Betweenness | Closeness\n")
135 sb.WriteString("---------------------- | --------- | ---------- | ----------- | ---------\n")
136
137 // Sort by out-degree descending
138 sorted := make([]graph.Centrality, len(result.Centrality))
139 copy(sorted, result.Centrality)
140 sort.Slice(sorted, func(i, j int) bool {
141 if sorted[i].OutDegree != sorted[j].OutDegree {
142 return sorted[i].OutDegree > sorted[j].OutDegree
143 }
144 return sorted[i].ID < sorted[j].ID
145 })
146
147 for _, c := range sorted {
148 elemName := c.ID
149 if len(elemName) > 22 {
150 elemName = elemName[:19] + "..."
151 }
152 fmt.Fprintf(&sb, "%-22s | %9d | %10d | %11.2f | %9.2f\n",
153 elemName, c.InDegree, c.OutDegree, c.Betweenness, c.Closeness)
154 }
155 sb.WriteString("\n")
156 }
157
158 return sb.String()
159 }
160
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/health.go 6.8%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/health"
10 "github.com/docToolchain/Bausteinsicht/internal/model"
11 "github.com/spf13/cobra"
12 )
13
14 func newHealthCmd() *cobra.Command {
15 cmd := &cobra.Command{
16 Use: "health",
17 Short: "Assess architecture health score",
18 Long: "Computes a comprehensive architecture health score across multiple dimensions including completeness, conformance, and complexity.",
19 RunE: runHealth,
20 }
21
22 cmd.Flags().String("output", "", "Output file for health report (default: stdout)")
23 cmd.Flags().Bool("summary", false, "Show only the overall score and grade")
24
25 return cmd
26 }
27
28 func runHealth(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 format, _ := cmd.Flags().GetString("format")
31 outputPath, _ := cmd.Flags().GetString("output")
32 summaryOnly, _ := cmd.Flags().GetBool("summary")
33
34 if modelPath == "" {
35 return exitWithCode(fmt.Errorf("--model flag is required"), 2)
36 }
37
38 m, err := model.Load(modelPath)
39 if err != nil {
40 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
41 }
42
43 // Compute health score
44 analyzer := health.NewAnalyzer(m)
45 score := analyzer.Analyze()
46
47 // Format output
48 var output string
49
50 if format == "json" {
51 if summaryOnly {
52 summary := map[string]interface{}{
53 "overall": score.Overall,
54 "grade": score.Grade,
55 "summary": score.Summary,
56 "timestamp": score.Timestamp,
57 }
58 data, _ := json.MarshalIndent(summary, "", " ")
59 output = string(data)
60 } else {
61 data, _ := json.MarshalIndent(score, "", " ")
62 output = string(data)
63 }
64 } else {
65 output = formatHealthReport(score, summaryOnly)
66 }
67
68 // Write output
69 if outputPath != "" {
70 if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil {
71 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
72 }
73 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Health report written to %s\n", outputPath)
74 } else {
75 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
76 }
77
78 return nil
79 }
80
81 func formatHealthReport(score *health.HealthScore, summaryOnly bool) string {
82 var sb strings.Builder
83
84 // Header
85 sb.WriteString("Architecture Health Report\n")
86 sb.WriteString("==========================\n\n")
87
88 // Overall score
89 fmt.Fprintf(&sb, "Overall Score: %.1f/100 [%s]\n", score.Overall, score.Grade)
90 fmt.Fprintf(&sb, "Summary: %s\n", score.Summary)
91 fmt.Fprintf(&sb, "Timestamp: %s\n\n", score.Timestamp)
92
93 if summaryOnly {
94 return sb.String()
95 }
96
97 // Model stats
98 sb.WriteString("Model Statistics\n")
99 sb.WriteString("----------------\n")
100 fmt.Fprintf(&sb, "Elements: %d\n", score.ElementCnt)
101 fmt.Fprintf(&sb, "Relationships: %d\n", score.RelCnt)
102 fmt.Fprintf(&sb, "Views: %d\n\n", score.ViewCnt)
103
104 // Category scores
105 sb.WriteString("Category Scores\n")
106 sb.WriteString("---------------\n")
107 for _, cat := range score.Categories {
108 fmt.Fprintf(&sb, "%s: %.1f/100 (weight: %.0f%%)\n", cat.Category, cat.Score, cat.Weight*100)
109 if cat.Details != "" {
110 fmt.Fprintf(&sb, " Details: %s\n", cat.Details)
111 }
112 }
113
114 // Findings
115 if len(score.Categories) > 0 {
116 sb.WriteString("\nFindings\n")
117 sb.WriteString("--------\n")
118
119 for _, cat := range score.Categories {
120 if len(cat.Findings) > 0 {
121 fmt.Fprintf(&sb, "\n%s (%d findings):\n", cat.Category, len(cat.Findings))
122 for _, f := range cat.Findings {
123 fmt.Fprintf(&sb, " [%s] %s\n", f.Severity, f.Title)
124 fmt.Fprintf(&sb, " %s\n", f.Message)
125 }
126 }
127 }
128 }
129
130 return sb.String()
131 }
132
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/import.go 79.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "github.com/docToolchain/Bausteinsicht/internal/importer/likec4"
11 "github.com/docToolchain/Bausteinsicht/internal/importer/structurizr"
12 "github.com/spf13/cobra"
13 )
14
15 func newImportCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "import <input-file>",
18 Short: "Import an architecture model from Structurizr DSL or LikeC4",
19 Long: `Imports an architecture model from an external DSL format and writes a
20 Bausteinsicht-compatible architecture.jsonc file.
21
22 Supported formats:
23 structurizr Structurizr DSL (.dsl)
24 likec4 LikeC4 DSL (.c4)
25
26 Exit codes:
27 0 import successful
28 1 parse error
29 2 output file already exists (use --force to overwrite)`,
30 Args: cobra.ExactArgs(1),
31 SilenceUsage: true,
32 SilenceErrors: true,
33 RunE: runImport,
34 }
35 cmd.Flags().String("from", "", "Source format: structurizr or likec4 (required)")
36 cmd.Flags().String("output", "architecture.jsonc", "Output model file path")
37 cmd.Flags().Bool("dry-run", false, "Print generated model to stdout instead of writing file")
38 cmd.Flags().Bool("force", false, "Overwrite output file if it already exists")
39 _ = cmd.MarkFlagRequired("from")
40 return cmd
41 }
42
43 func runImport(cmd *cobra.Command, args []string) error {
44 inputPath := args[0]
45 from, _ := cmd.Flags().GetString("from")
46 outputPath, _ := cmd.Flags().GetString("output")
47 dryRun, _ := cmd.Flags().GetBool("dry-run")
48 force, _ := cmd.Flags().GetBool("force")
49
50 from = strings.ToLower(strings.TrimSpace(from))
51 if from != "structurizr" && from != "likec4" {
52 return exitWithCode(fmt.Errorf("unknown format %q: valid values are \"structurizr\" and \"likec4\"", from), 1)
53 }
54
55 if err := validatePathContainment(inputPath); err != nil {
56 return exitWithCode(fmt.Errorf("input: %w", err), 1)
57 }
58 if err := validatePathContainment(outputPath); err != nil {
59 return exitWithCode(fmt.Errorf("--output: %w", err), 1)
60 }
61
62 if !dryRun && !force {
63 if _, err := os.Stat(outputPath); err == nil {
64 return exitWithCode(
65 fmt.Errorf("output file %q already exists — use --force to overwrite", outputPath),
66 2,
67 )
68 }
69 }
70
71 var (
72 importedModel any
73 warnings []string
74 )
75
76 switch from {
77 case "structurizr":
78 r, err := structurizr.Import(inputPath)
79 if err != nil {
80 return exitWithCode(fmt.Errorf("import failed: %w", err), 1)
81 }
82 importedModel, warnings = r.Model, r.Warnings
83 case "likec4":
84 r, err := likec4.Import(inputPath)
85 if err != nil {
86 return exitWithCode(fmt.Errorf("import failed: %w", err), 1)
87 }
88 importedModel, warnings = r.Model, r.Warnings
89 }
90
91 data, err := json.MarshalIndent(importedModel, "", " ")
92 if err != nil {
93 return exitWithCode(fmt.Errorf("encoding model: %w", err), 1)
94 }
95
96 if dryRun {
97 if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
98 return err
99 }
100 } else {
101 if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
102 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 1)
103 }
104 if err := os.WriteFile(outputPath, append(data, '\n'), 0o644); err != nil {
105 return exitWithCode(fmt.Errorf("writing %s: %w", outputPath, err), 1)
106 }
107 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Imported model written to %s\n", outputPath); err != nil {
108 return err
109 }
110 }
111
112 for _, w := range warnings {
113 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "WARNING: %s\n", w); err != nil {
114 return err
115 }
116 }
117
118 return nil
119 }
120
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/init.go 86.8%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "time"
9
10 "github.com/docToolchain/Bausteinsicht/internal/drawio"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 bsync "github.com/docToolchain/Bausteinsicht/internal/sync"
13 "github.com/docToolchain/Bausteinsicht/internal/template"
14 "github.com/docToolchain/Bausteinsicht/templates"
15 "github.com/spf13/cobra"
16 )
17
18 const (
19 defaultModelFile = "architecture.jsonc"
20 defaultDrawioFile = "architecture.drawio"
21 defaultTemplFile = "template.drawio"
22 defaultSyncState = ".bausteinsicht-sync"
23 )
24
25 func newInitCmd() *cobra.Command {
26 cmd := &cobra.Command{
27 Use: "init",
28 Short: "Initialize a new architecture project",
29 Long: "Creates a sample model, template, and initial draw.io diagram in the current directory.",
30 RunE: runInit,
31 }
32 cmd.Flags().Bool("generate-template", false, "Generate template from spec instead of using default")
33 return cmd
34 }
35
36 func runInit(cmd *cobra.Command, _ []string) error {
37 format, _ := cmd.Flags().GetString("format")
38 generateTemplate, _ := cmd.Flags().GetBool("generate-template")
39
40 // Check if files already exist.
41 for _, name := range []string{defaultModelFile, defaultDrawioFile, defaultTemplFile} {
42 if _, err := os.Stat(name); err == nil {
43 return exitWithCode(
44 fmt.Errorf("file %q already exists; remove it or use a different directory", name),
45 2,
46 )
47 }
48 }
49
50 // Write sample model.
51 if err := os.WriteFile(defaultModelFile, templates.SampleModel, 0600); err != nil {
52 return exitWithCode(fmt.Errorf("writing %s: %w", defaultModelFile, err), 2)
53 }
54
55 // Load model for sync.
56 m, err := model.Load(defaultModelFile)
57 if err != nil {
58 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
59 }
60
61 // Generate or use default template.
62 var templateBytes []byte
63 if generateTemplate {
64 gen := template.NewGenerator(m.Specification, "default")
65 templateXML := gen.Generate()
66 templateBytes = []byte(templateXML)
67 } else {
68 templateBytes = templates.DefaultTemplate
69 }
70
71 // Write template.
72 if err := os.WriteFile(defaultTemplFile, templateBytes, 0600); err != nil {
73 return exitWithCode(fmt.Errorf("writing %s: %w", defaultTemplFile, err), 2)
74 }
75
76 // Load template.
77 tmpl, err := drawio.LoadTemplateFromBytes(templateBytes)
78 if err != nil {
79 return exitWithCode(fmt.Errorf("loading template: %w", err), 2)
80 }
81
82 // Create empty document and run initial forward sync.
83 doc := drawio.NewDocument()
84 emptyState := &bsync.SyncState{
85 Elements: make(map[string]bsync.ElementState),
86 Relationships: []bsync.RelationshipState{},
87 }
88
89 // Add pages for each view before sync.
90 for viewID, view := range m.Views {
91 doc.AddPage("view-"+viewID, view.Title)
92 }
93
94 // Pass ForwardOptions so metadata/legend boxes are created during init,
95 // preventing the first sync from reporting metadata changes (#265).
96 // Use the same relative model path that sync would use.
97 fwdOpts := bsync.ForwardOptions{
98 ModelPath: defaultModelFile,
99 SyncTime: time.Now().Format("2006-01-02 15:04"),
100 }
101 _ = bsync.Run(m, doc, emptyState, tmpl, nil, fwdOpts)
102
103 // Save generated draw.io file.
104 if err := drawio.SaveDocument(defaultDrawioFile, doc); err != nil {
105 return exitWithCode(fmt.Errorf("writing %s: %w", defaultDrawioFile, err), 2)
106 }
107
108 // Build and save sync state.
109 absModel, _ := filepath.Abs(defaultModelFile)
110 absDrawio, _ := filepath.Abs(defaultDrawioFile)
111 state, err := bsync.BuildState(m, doc, absModel, absDrawio)
112 if err != nil {
113 return exitWithCode(fmt.Errorf("building sync state: %w", err), 2)
114 }
115 if err := bsync.SaveState(defaultSyncState, state); err != nil {
116 return exitWithCode(fmt.Errorf("writing %s: %w", defaultSyncState, err), 2)
117 }
118
119 // Output result.
120 createdFiles := []string{defaultModelFile, defaultTemplFile, defaultDrawioFile, defaultSyncState}
121
122 if format == "json" {
123 out := map[string]interface{}{
124 "success": true,
125 "files": createdFiles,
126 }
127 data, _ := json.Marshal(out)
128 fmt.Println(string(data))
129 } else {
130 fmt.Println("Initialized Bausteinsicht project:")
131 for _, f := range createdFiles {
132 fmt.Printf(" - %s\n", f)
133 }
134 fmt.Println()
135 fmt.Println("Next steps:")
136 fmt.Println(" 1. Edit architecture.jsonc to define your architecture")
137 fmt.Println(" 2. Run 'bausteinsicht sync' to update the draw.io diagram")
138 fmt.Println(" 3. Open architecture.drawio in draw.io to arrange elements")
139 }
140
141 return nil
142 }
143
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/layout.go 16.1%
1 package main
2
3 import (
4 "fmt"
5 "path/filepath"
6
7 "github.com/docToolchain/Bausteinsicht/internal/drawio"
8 "github.com/docToolchain/Bausteinsicht/internal/layout"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 func newLayoutCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "layout",
16 Short: "Auto-layout elements in draw.io diagram",
17 Long: `Computes hierarchical layout for diagram elements and writes positions back to draw.io.
18 Pinned elements (with bausteinsicht-pinned=true) are preserved by default.`,
19 RunE: runLayout,
20 }
21
22 cmd.Flags().String("algorithm", "hierarchical", "Layout algorithm: hierarchical (currently only option)")
23 cmd.Flags().String("rank-dir", "TB", "Ranking direction: TB (top-to-bottom) or LR (left-to-right)")
24 cmd.Flags().Bool("preserve-pinned", true, "Don't move pinned elements (bausteinsicht-pinned=true)")
25
26 return cmd
27 }
28
29 func runLayout(cmd *cobra.Command, _ []string) error {
30 modelPath, _ := cmd.Flags().GetString("model")
31 if modelPath == "" {
32 detected, err := model.AutoDetect(".")
33 if err != nil {
34 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
35 }
36 modelPath = detected
37 }
38
39 // Load model
40 m, err := model.Load(modelPath)
41 if err != nil {
42 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
43 }
44
45 // Validate model
46 if errs := model.Validate(m); len(errs) > 0 {
47 return exitWithCode(fmt.Errorf("model validation failed: %v", errs), 2)
48 }
49
50 // Derive draw.io path from model path
51 dir := filepath.Dir(modelPath)
52 drawioPath := filepath.Join(dir, "architecture.drawio")
53
54 doc, err := drawio.LoadDocument(drawioPath)
55 if err != nil {
56 return exitWithCode(fmt.Errorf("loading diagram: %w", err), 2)
57 }
58
59 rankDir, _ := cmd.Flags().GetString("rank-dir")
60 preservePinned, _ := cmd.Flags().GetBool("preserve-pinned")
61
62 // Compute hierarchical layout
63 h := layout.NewHierarchicalLayout(m, rankDir)
64 result := h.Compute()
65
66 // Apply layout to diagram
67 if err := layout.Apply(doc, result, preservePinned); err != nil {
68 return exitWithCode(fmt.Errorf("applying layout: %w", err), 2)
69 }
70
71 // Save diagram
72 if err := drawio.SaveDocument(drawioPath, doc); err != nil {
73 return exitWithCode(fmt.Errorf("saving diagram: %w", err), 2)
74 }
75
76 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Layout applied (hierarchical): %s\n", drawioPath)
77 return nil
78 }
79
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/lint.go 78.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/constraints"
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 func newLintCmd() *cobra.Command {
13 return &cobra.Command{
14 Use: "lint",
15 Short: "Check architecture constraints",
16 Long: "Evaluates all constraints defined in the model and reports violations.",
17 SilenceUsage: true,
18 SilenceErrors: true,
19 RunE: runLint,
20 }
21 }
22
23 func runLint(cmd *cobra.Command, _ []string) error {
24 format, _ := cmd.Flags().GetString("format")
25 modelPath, _ := cmd.Flags().GetString("model")
26
27 if modelPath == "" {
28 detected, err := model.AutoDetect(".")
29 if err != nil {
30 return exitWithCode(err, 2)
31 }
32 modelPath = detected
33 }
34
35 m, err := model.Load(modelPath)
36 if err != nil {
37 return exitWithCode(err, 2)
38 }
39
40 if len(m.Constraints) == 0 {
41 if _, err := fmt.Fprintln(cmd.OutOrStdout(), "No constraints defined."); err != nil {
42 return err
43 }
44 return nil
45 }
46
47 result := constraints.Evaluate(m)
48
49 if format == "json" {
50 return lintOutputJSON(cmd, result)
51 }
52 return lintOutputText(cmd, result)
53 }
54
55 func lintOutputJSON(cmd *cobra.Command, r constraints.Result) error {
56 type jsonResult struct {
57 Passed bool `json:"passed"`
58 Total int `json:"total"`
59 Violations []constraints.Violation `json:"violations"`
60 }
61
62 out := jsonResult{
63 Passed: r.Total == 0,
64 Total: r.Total,
65 Violations: r.Violations,
66 }
67 if out.Violations == nil {
68 out.Violations = []constraints.Violation{}
69 }
70
71 data, err := json.MarshalIndent(out, "", " ")
72 if err != nil {
73 return err
74 }
75 if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
76 return err
77 }
78 if r.Total > 0 {
79 return exitWithCode(fmt.Errorf("lint: %d violation(s) found", r.Total), 1)
80 }
81 return nil
82 }
83
84 func lintOutputText(cmd *cobra.Command, r constraints.Result) error {
85 if r.Total == 0 {
86 _, err := fmt.Fprintln(cmd.OutOrStdout(), "All constraints passed.")
87 return err
88 }
89
90 for _, v := range r.Violations {
91 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "VIOLATION [%s]: %s\n", v.ConstraintID, v.Message); err != nil {
92 return err
93 }
94 for _, el := range v.Elements {
95 if _, err := fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", el); err != nil {
96 return err
97 }
98 }
99 }
100
101 return exitWithCode(fmt.Errorf("lint: %d violation(s) found", r.Total), 1)
102 }
103
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/main.go 0.0%
1 package main
2
3 import "os"
4
5 var version = "dev"
6
7 func main() {
8 rootCmd := NewRootCmd()
9
10 if err := ExecuteRoot(rootCmd); err != nil {
11 if e, ok := err.(*exitError); ok {
12 os.Exit(e.code)
13 }
14 os.Exit(1)
15 }
16 }
17
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/overlay.go 18.6%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/overlay"
10 "github.com/spf13/cobra"
11 )
12
13 func newOverlayCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "overlay",
16 Short: "Apply or remove metric heatmap overlays on architecture diagrams",
17 Long: "Load external metrics (error rate, coverage, etc.) from JSON and overlay them as a heatmap on draw.io elements. Original styles are preserved.",
18 }
19
20 cmd.AddCommand(newOverlayApplyCmd())
21 cmd.AddCommand(newOverlayRemoveCmd())
22 cmd.AddCommand(newOverlayListCmd())
23
24 return cmd
25 }
26
27 func newOverlayApplyCmd() *cobra.Command {
28 cmd := &cobra.Command{
29 Use: "apply <metrics-file>",
30 Short: "Apply metric heatmap to draw.io diagram",
31 Long: "Load metrics from JSON file and apply heatmap colors to elements. Original colors are saved in metadata.",
32 Args: cobra.ExactArgs(1),
33 RunE: func(cmd *cobra.Command, args []string) error {
34 metricsPath := args[0]
35 modelPath, _ := cmd.Flags().GetString("model")
36 metricKey, _ := cmd.Flags().GetString("metric")
37 outputPath, _ := cmd.Flags().GetString("output")
38
39 m, err := model.Load(modelPath)
40 if err != nil {
41 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
42 }
43
44 drawioPath := outputPath
45 if drawioPath == "" {
46 drawioPath = filepath.Join(filepath.Dir(modelPath), "architecture.drawio")
47 }
48
49 mf, err := overlay.LoadMetricsFile(metricsPath)
50 if err != nil {
51 return exitWithCode(fmt.Errorf("loading metrics: %w", err), 2)
52 }
53
54 if metricKey == "" {
55 if len(mf.Metrics) > 0 && len(mf.Metrics[0].Values) > 0 {
56 for k := range mf.Metrics[0].Values {
57 metricKey = k
58 break
59 }
60 }
61 if metricKey == "" {
62 return exitWithCode(fmt.Errorf("no metrics found in file"), 2)
63 }
64 }
65
66 if err := overlay.Apply(drawioPath, mf, metricKey, overlay.DefaultColorScheme); err != nil {
67 return exitWithCode(fmt.Errorf("applying overlay: %w", err), 2)
68 }
69
70 format, _ := cmd.Flags().GetString("format")
71 if format == "json" {
72 out, _ := json.Marshal(map[string]interface{}{
73 "status": "applied",
74 "metric": metricKey,
75 "file": drawioPath,
76 "model": m.Specification,
77 })
78 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
79 } else {
80 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "✅ Overlay applied: %s (metric: %s)\n", drawioPath, metricKey)
81 }
82 return nil
83 },
84 }
85 cmd.Flags().String("metric", "", "Metric key to visualize (default: first available)")
86 cmd.Flags().String("output", "", "Output draw.io file (default: architecture.drawio)")
87 return cmd
88 }
89
90 func newOverlayRemoveCmd() *cobra.Command {
91 cmd := &cobra.Command{
92 Use: "remove",
93 Short: "Remove metric overlay from diagram (restore original colors)",
94 RunE: func(cmd *cobra.Command, args []string) error {
95 modelPath, _ := cmd.Flags().GetString("model")
96 outputPath, _ := cmd.Flags().GetString("output")
97
98 _, err := model.Load(modelPath)
99 if err != nil {
100 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
101 }
102
103 drawioPath := outputPath
104 if drawioPath == "" {
105 drawioPath = filepath.Join(filepath.Dir(modelPath), "architecture.drawio")
106 }
107
108 if err := overlay.Remove(drawioPath); err != nil {
109 return exitWithCode(fmt.Errorf("removing overlay: %w", err), 2)
110 }
111
112 format, _ := cmd.Flags().GetString("format")
113 if format == "json" {
114 out, _ := json.Marshal(map[string]interface{}{
115 "status": "removed",
116 "file": drawioPath,
117 })
118 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
119 } else {
120 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "✅ Overlay removed: %s (original colors restored)\n", drawioPath)
121 }
122 return nil
123 },
124 }
125 cmd.Flags().String("output", "", "Output draw.io file (default: architecture.drawio)")
126 return cmd
127 }
128
129 func newOverlayListCmd() *cobra.Command {
130 return &cobra.Command{
131 Use: "list <metrics-file>",
132 Short: "List available metrics in file",
133 Args: cobra.ExactArgs(1),
134 RunE: func(cmd *cobra.Command, args []string) error {
135 metricsPath := args[0]
136
137 mf, err := overlay.LoadMetricsFile(metricsPath)
138 if err != nil {
139 return exitWithCode(fmt.Errorf("loading metrics: %w", err), 2)
140 }
141
142 format, _ := cmd.Flags().GetString("format")
143 if format == "json" {
144 out, _ := json.Marshal(map[string]interface{}{
145 "source": mf.Meta.Source,
146 "generated": mf.Meta.Generated,
147 "metric_descriptions": mf.Meta.MetricDescriptions,
148 "element_count": len(mf.Metrics),
149 })
150 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
151 } else {
152 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "📊 Metrics from: %s (%s)\n\n", mf.Meta.Source, mf.Meta.Generated)
153 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Available metrics (%d elements):\n", len(mf.Metrics))
154 for metric, desc := range mf.Meta.MetricDescriptions {
155 _, _ = fmt.Fprintf(cmd.OutOrStdout(), " • %s: %s\n", metric, desc)
156 }
157 }
158 return nil
159 },
160 }
161 }
162
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/root.go 98.5%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7 "strings"
8
9 "github.com/spf13/cobra"
10 )
11
12 // NewRootCmd creates and returns the root cobra command with global flags.
13 func NewRootCmd() *cobra.Command {
14 rootCmd := &cobra.Command{
15 Use: "bausteinsicht",
16 Short: "Architecture-as-code with draw.io synchronization",
17 Version: version,
18 PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
19 format, _ := cmd.Flags().GetString("format")
20 format = strings.ToLower(format)
21 if format != "" && format != "text" && format != "json" {
22 return fmt.Errorf("unknown format %q: valid values are \"text\" and \"json\"", format)
23 }
24 // Normalize to lowercase for all subcommands.
25 _ = cmd.Flags().Set("format", format)
26
27 // Validate --template extension when provided.
28 templatePath, _ := cmd.Flags().GetString("template")
29 if templatePath != "" && filepath.Ext(templatePath) != ".drawio" {
30 return fmt.Errorf("template file %q must have a .drawio extension", templatePath)
31 }
32
33 // Validate --model path is under working directory (SEC-001).
34 modelPath, _ := cmd.Flags().GetString("model")
35 if modelPath != "" {
36 if err := validatePathContainment(modelPath); err != nil {
37 return fmt.Errorf("--model: %w", err)
38 }
39 }
40 if templatePath != "" {
41 if err := validatePathContainment(templatePath); err != nil {
42 return fmt.Errorf("--template: %w", err)
43 }
44 }
45
46 return nil
47 },
48 }
49
50 rootCmd.PersistentFlags().String("format", "text", "Output format: text or json")
51 rootCmd.PersistentFlags().String("model", "", "Path to model file (.jsonc)")
52 rootCmd.PersistentFlags().String("template", "", "Path to draw.io template file")
53 rootCmd.PersistentFlags().Bool("verbose", false, "Enable verbose output")
54
55 rootCmd.AddCommand(newInitCmd())
56 rootCmd.AddCommand(newSyncCmd())
57 rootCmd.AddCommand(newValidateCmd())
58 rootCmd.AddCommand(newAddCmd())
59 rootCmd.AddCommand(newWatchCmd())
60 rootCmd.AddCommand(newLayoutCmd())
61 rootCmd.AddCommand(newExportCmd())
62 rootCmd.AddCommand(newExportTableCmd())
63 rootCmd.AddCommand(newExportDiagramCmd())
64 rootCmd.AddCommand(newSchemaCmd())
65 rootCmd.AddCommand(newImportCmd())
66 rootCmd.AddCommand(newExportSequenceCmd())
67 rootCmd.AddCommand(newFindCmd())
68 rootCmd.AddCommand(newShowCmd())
69 rootCmd.AddCommand(newDiffCmd())
70 rootCmd.AddCommand(newChangelogCmd())
71 rootCmd.AddCommand(newLintCmd())
72 rootCmd.AddCommand(newStatusCmd())
73 rootCmd.AddCommand(newGenerateTemplateCmd())
74 rootCmd.AddCommand(newSnapshotCmd())
75 rootCmd.AddCommand(newADRCmd())
76 rootCmd.AddCommand(newWorkspaceCmd())
77 rootCmd.AddCommand(newHealthCmd())
78 rootCmd.AddCommand(newGraphCmd())
79 rootCmd.AddCommand(newOverlayCmd())
80
81 return rootCmd
82 }
83
84 type exitError struct {
85 err error
86 code int
87 }
88
89 func (e *exitError) Error() string { return e.err.Error() }
90
91 func exitWithCode(err error, code int) *exitError {
92 return &exitError{err: err, code: code}
93 }
94
95 // validatePathContainment normalizes a path and rejects directory traversal
96 // sequences that could be used to write files at unexpected locations
97 // (SEC-001, SEC-016).
98 func validatePathContainment(path string) error {
99 cleaned := filepath.Clean(path)
100 for _, component := range strings.Split(cleaned, string(filepath.Separator)) {
101 if component == ".." {
102 return fmt.Errorf("path %q contains directory traversal", path)
103 }
104 }
105 return nil
106 }
107
108 // ExecuteRoot runs the root command and writes errors in the appropriate format
109 // (JSON or plain text) to the command's error writer.
110 func ExecuteRoot(cmd *cobra.Command) error {
111 cmd.SilenceErrors = true
112 cmd.SilenceUsage = true
113 err := cmd.Execute()
114 if err == nil {
115 return nil
116 }
117 format, _ := cmd.PersistentFlags().GetString("format")
118 code := 1
119 if e, ok := err.(*exitError); ok {
120 code = e.code
121 }
122 if format == "json" {
123 out, _ := json.Marshal(map[string]interface{}{
124 "error": err.Error(),
125 "code": code,
126 })
127 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), string(out))
128 } else {
129 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), err.Error())
130 }
131 return err
132 }
133
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/show.go 75.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 func newShowCmd() *cobra.Command {
14 return &cobra.Command{
15 Use: "show <element-id>",
16 Short: "Show full details of a model element",
17 Long: `Display all fields, relationships, and views for a single element.
18
19 The element ID uses dot-notation for nested elements (e.g. "system.backend.api").
20 Use 'bausteinsicht find <query>' to discover element IDs.`,
21 Args: cobra.ExactArgs(1),
22 SilenceUsage: true,
23 SilenceErrors: true,
24 RunE: runShow,
25 }
26 }
27
28 type showRelEntry struct {
29 Direction string
30 Other string
31 Label string
32 Kind string
33 }
34
35 type showRelJSON struct {
36 Direction string `json:"direction"`
37 Other string `json:"other"`
38 Label string `json:"label,omitempty"`
39 Kind string `json:"kind,omitempty"`
40 }
41
42 type showJSONOutput struct {
43 ID string `json:"id"`
44 Kind string `json:"kind"`
45 Title string `json:"title,omitempty"`
46 Description string `json:"description,omitempty"`
47 Technology string `json:"technology,omitempty"`
48 Tags []string `json:"tags,omitempty"`
49 Metadata map[string]string `json:"metadata,omitempty"`
50 Rels []showRelJSON `json:"relationships"`
51 Views []string `json:"views"`
52 }
53
54 func runShow(cmd *cobra.Command, args []string) error {
55 elementID := args[0]
56 format, _ := cmd.Flags().GetString("format")
57 modelPath, _ := cmd.Flags().GetString("model")
58
59 if modelPath == "" {
60 detected, err := model.AutoDetect(".")
61 if err != nil {
62 return exitWithCode(err, 2)
63 }
64 modelPath = detected
65 }
66
67 m, err := model.Load(modelPath)
68 if err != nil {
69 return exitWithCode(err, 2)
70 }
71
72 flat, err := model.FlattenElements(m)
73 if err != nil {
74 return exitWithCode(err, 2)
75 }
76
77 elem, ok := flat[elementID]
78 if !ok {
79 return exitWithCode(fmt.Errorf("element %q not found", elementID), 1)
80 }
81
82 var rels []showRelEntry
83 for _, rel := range m.Relationships {
84 if rel.From == elementID {
85 rels = append(rels, showRelEntry{"→", rel.To, rel.Label, rel.Kind})
86 } else if rel.To == elementID {
87 rels = append(rels, showRelEntry{"←", rel.From, rel.Label, rel.Kind})
88 }
89 }
90 sort.Slice(rels, func(i, j int) bool {
91 return rels[i].Direction+rels[i].Other < rels[j].Direction+rels[j].Other
92 })
93
94 var viewKeys []string
95 for key, view := range m.Views {
96 for _, inc := range view.Include {
97 prefix := strings.TrimSuffix(inc, ".*")
98 if inc == elementID || (strings.HasSuffix(inc, ".*") && strings.HasPrefix(elementID, prefix+".")) {
99 viewKeys = append(viewKeys, key)
100 break
101 }
102 }
103 }
104 sort.Strings(viewKeys)
105
106 if format == "json" {
107 return printShowJSON(cmd, elementID, elem, rels, viewKeys)
108 }
109 return printShowText(cmd, elementID, elem, rels, viewKeys)
110 }
111
112 func printShowJSON(cmd *cobra.Command, id string, elem *model.Element, rels []showRelEntry, viewKeys []string) error {
113 out := showJSONOutput{
114 ID: id,
115 Kind: elem.Kind,
116 Title: elem.Title,
117 Description: elem.Description,
118 Technology: elem.Technology,
119 Tags: elem.Tags,
120 Metadata: elem.Metadata,
121 Views: viewKeys,
122 Rels: []showRelJSON{},
123 }
124 if out.Views == nil {
125 out.Views = []string{}
126 }
127 for _, r := range rels {
128 out.Rels = append(out.Rels, showRelJSON(r))
129 }
130 data, err := json.MarshalIndent(out, "", " ")
131 if err != nil {
132 return err
133 }
134 _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
135 return err
136 }
137
138 func printShowText(cmd *cobra.Command, id string, elem *model.Element, rels []showRelEntry, viewKeys []string) error {
139 o := cmd.OutOrStdout()
140 header := "Element: " + id
141 if _, err := fmt.Fprintln(o, header); err != nil {
142 return err
143 }
144 if _, err := fmt.Fprintln(o, strings.Repeat("=", len(header))); err != nil {
145 return err
146 }
147
148 printField := func(label, value string) error {
149 if value == "" {
150 return nil
151 }
152 _, err := fmt.Fprintf(o, "%-14s %s\n", label+":", value)
153 return err
154 }
155
156 if err := printField("Kind", elem.Kind); err != nil {
157 return err
158 }
159 if err := printField("Title", elem.Title); err != nil {
160 return err
161 }
162 if err := printField("Description", elem.Description); err != nil {
163 return err
164 }
165 if err := printField("Technology", elem.Technology); err != nil {
166 return err
167 }
168 if len(elem.Tags) > 0 {
169 if err := printField("Tags", "["+strings.Join(elem.Tags, ", ")+"]"); err != nil {
170 return err
171 }
172 }
173 for k, v := range elem.Metadata {
174 if err := printField(k, v); err != nil {
175 return err
176 }
177 }
178
179 if len(rels) > 0 {
180 if _, err := fmt.Fprintln(o, "\nRelationships:"); err != nil {
181 return err
182 }
183 for _, r := range rels {
184 label := ""
185 if r.Label != "" {
186 label = fmt.Sprintf(" %q", r.Label)
187 }
188 kind := ""
189 if r.Kind != "" {
190 kind = " [" + r.Kind + "]"
191 }
192 if _, err := fmt.Fprintf(o, " %s %-30s%s%s\n", r.Direction, r.Other, label, kind); err != nil {
193 return err
194 }
195 }
196 }
197
198 if len(viewKeys) > 0 {
199 if _, err := fmt.Fprintf(o, "\nViews: %s\n", strings.Join(viewKeys, ", ")); err != nil {
200 return err
201 }
202 }
203
204 return nil
205 }
206
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot.go 100.0%
1 package main
2
3 import (
4 "github.com/spf13/cobra"
5 )
6
7 func newSnapshotCmd() *cobra.Command {
8 cmd := &cobra.Command{
9 Use: "snapshot",
10 Short: "Manage versioned architecture snapshots",
11 Long: "Save, list, delete, diff, and restore architecture snapshots stored in .bausteinsicht-snapshots/",
12 }
13
14 cmd.AddCommand(newSnapshotSaveCmd())
15 cmd.AddCommand(newSnapshotListCmd())
16 cmd.AddCommand(newSnapshotDeleteCmd())
17 cmd.AddCommand(newSnapshotDiffCmd())
18 cmd.AddCommand(newSnapshotRestoreCmd())
19
20 return cmd
21 }
22
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_delete.go 90.9%
1 package main
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
7 "github.com/spf13/cobra"
8 )
9
10 func newSnapshotDeleteCmd() *cobra.Command {
11 cmd := &cobra.Command{
12 Use: "delete <snapshot-id>",
13 Short: "Delete a saved snapshot",
14 Long: "Remove a snapshot from .bausteinsicht-snapshots/",
15 Args: cobra.ExactArgs(1),
16 RunE: runSnapshotDelete,
17 }
18
19 return cmd
20 }
21
22 func runSnapshotDelete(cmd *cobra.Command, args []string) error {
23 snapshotID := args[0]
24
25 manager := snapshot.NewManager(".")
26 if !manager.Exists(snapshotID) {
27 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID), 2)
28 }
29
30 if err := manager.Delete(snapshotID); err != nil {
31 return exitWithCode(fmt.Errorf("deleting snapshot: %w", err), 2)
32 }
33
34 output := fmt.Sprintf("Snapshot deleted: %s\n", snapshotID)
35 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
36
37 return nil
38 }
39
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_diff.go 65.3%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
10 "github.com/spf13/cobra"
11 )
12
13 func newSnapshotDiffCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "diff <snapshot-id-1> [snapshot-id-2]",
16 Short: "Diff two snapshots or a snapshot vs current state",
17 Long: "Compare two snapshots or compare a snapshot against the current model state.",
18 Args: cobra.RangeArgs(1, 2),
19 RunE: runSnapshotDiff,
20 }
21
22 cmd.Flags().String("format", "text", "Output format: text or json")
23
24 return cmd
25 }
26
27 func runSnapshotDiff(cmd *cobra.Command, args []string) error {
28 snapshotID1 := args[0]
29 format, _ := cmd.Flags().GetString("format")
30 modelPath, _ := cmd.Flags().GetString("model")
31
32 manager := snapshot.NewManager(".")
33
34 // Load first snapshot
35 if !manager.Exists(snapshotID1) {
36 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID1), 2)
37 }
38
39 snap1, err := manager.Load(snapshotID1)
40 if err != nil {
41 return exitWithCode(fmt.Errorf("loading snapshot %s: %w", snapshotID1, err), 2)
42 }
43
44 var model2 *model.BausteinsichtModel
45
46 // Load second snapshot or current state
47 if len(args) == 2 {
48 snapshotID2 := args[1]
49 if !manager.Exists(snapshotID2) {
50 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID2), 2)
51 }
52 snap2, err := manager.Load(snapshotID2)
53 if err != nil {
54 return exitWithCode(fmt.Errorf("loading snapshot %s: %w", snapshotID2, err), 2)
55 }
56 model2 = snap2.Model
57 } else {
58 // Load current model
59 if modelPath == "" {
60 modelPath = "architecture.jsonc"
61 }
62 m, err := model.Load(modelPath)
63 if err != nil {
64 return exitWithCode(fmt.Errorf("loading current model: %w", err), 2)
65 }
66 model2 = m
67 }
68
69 // Compare models
70 diffs := diffModels(snap1.Model, model2)
71
72 // Output results
73 switch format {
74 case "json":
75 data, err := json.MarshalIndent(diffs, "", " ")
76 if err != nil {
77 return exitWithCode(fmt.Errorf("marshaling diff: %w", err), 2)
78 }
79 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
80 case "text":
81 _, _ = fmt.Fprint(cmd.OutOrStdout(), formatDiffText(diffs))
82 default:
83 return exitWithCode(fmt.Errorf("unknown format: %s", format), 2)
84 }
85
86 return nil
87 }
88
89 type ModelDiff struct {
90 AddedElements []string `json:"addedElements,omitempty"`
91 RemovedElements []string `json:"removedElements,omitempty"`
92 ChangedElements map[string][]string `json:"changedElements,omitempty"`
93 AddedRelationships []RelDiff `json:"addedRelationships,omitempty"`
94 RemovedRelationships []RelDiff `json:"removedRelationships,omitempty"`
95 }
96
97 type RelDiff struct {
98 From string `json:"from"`
99 To string `json:"to"`
100 Label string `json:"label,omitempty"`
101 }
102
103 func diffModels(m1, m2 *model.BausteinsichtModel) *ModelDiff {
104 result := &ModelDiff{
105 ChangedElements: make(map[string][]string),
106 }
107
108 // Flatten elements for easier comparison
109 flat1 := flattenAll(m1.Model)
110 flat2 := flattenAll(m2.Model)
111
112 // Find added and removed elements
113 for id := range flat2 {
114 if _, exists := flat1[id]; !exists {
115 result.AddedElements = append(result.AddedElements, id)
116 }
117 }
118
119 for id := range flat1 {
120 if _, exists := flat2[id]; !exists {
121 result.RemovedElements = append(result.RemovedElements, id)
122 }
123 }
124
125 // Find changed elements
126 for id, elem1 := range flat1 {
127 if elem2, exists := flat2[id]; exists {
128 changes := compareElements(elem1, elem2)
129 if len(changes) > 0 {
130 result.ChangedElements[id] = changes
131 }
132 }
133 }
134
135 // Compare relationships
136 relMap1 := relationshipMapString(m1.Relationships)
137 relMap2 := relationshipMapString(m2.Relationships)
138
139 for key, rel2 := range relMap2 {
140 if _, exists := relMap1[key]; !exists {
141 result.AddedRelationships = append(result.AddedRelationships, rel2)
142 }
143 }
144
145 for key, rel1 := range relMap1 {
146 if _, exists := relMap2[key]; !exists {
147 result.RemovedRelationships = append(result.RemovedRelationships, rel1)
148 }
149 }
150
151 return result
152 }
153
154 func flattenAll(elems map[string]model.Element) map[string]model.Element {
155 result := make(map[string]model.Element)
156 for key, elem := range elems {
157 result[key] = elem
158 if len(elem.Children) > 0 {
159 children := flattenAll(elem.Children)
160 for k, v := range children {
161 result[key+"."+k] = v
162 }
163 }
164 }
165 return result
166 }
167
168 func compareElements(e1, e2 model.Element) []string {
169 var changes []string
170 if e1.Title != e2.Title {
171 changes = append(changes, fmt.Sprintf("title: %q → %q", e1.Title, e2.Title))
172 }
173 if e1.Kind != e2.Kind {
174 changes = append(changes, fmt.Sprintf("kind: %q → %q", e1.Kind, e2.Kind))
175 }
176 if e1.Description != e2.Description {
177 changes = append(changes, fmt.Sprintf("description: %q → %q", e1.Description, e2.Description))
178 }
179 if e1.Technology != e2.Technology {
180 changes = append(changes, fmt.Sprintf("technology: %q → %q", e1.Technology, e2.Technology))
181 }
182 if e1.Status != e2.Status {
183 changes = append(changes, fmt.Sprintf("status: %q → %q", e1.Status, e2.Status))
184 }
185 return changes
186 }
187
188 func relationshipMapString(rels []model.Relationship) map[string]RelDiff {
189 m := make(map[string]RelDiff)
190 for _, rel := range rels {
191 key := fmt.Sprintf("%s:%s", rel.From, rel.To)
192 m[key] = RelDiff{From: rel.From, To: rel.To, Label: rel.Label}
193 }
194 return m
195 }
196
197 func formatDiffText(diffs *ModelDiff) string {
198 var sb strings.Builder
199
200 totalChanges := len(diffs.AddedElements) + len(diffs.RemovedElements) +
201 len(diffs.ChangedElements) + len(diffs.AddedRelationships) +
202 len(diffs.RemovedRelationships)
203
204 if totalChanges == 0 {
205 return "No differences found.\n"
206 }
207
208 fmt.Fprintf(&sb, "Architecture Differences (Total Changes: %d)\n", totalChanges)
209 sb.WriteString(strings.Repeat("=", 50))
210 sb.WriteString("\n\n")
211
212 if len(diffs.AddedElements) > 0 {
213 fmt.Fprintf(&sb, "Added Elements (%d):\n", len(diffs.AddedElements))
214 for _, id := range diffs.AddedElements {
215 fmt.Fprintf(&sb, " + %s\n", id)
216 }
217 sb.WriteString("\n")
218 }
219
220 if len(diffs.RemovedElements) > 0 {
221 fmt.Fprintf(&sb, "Removed Elements (%d):\n", len(diffs.RemovedElements))
222 for _, id := range diffs.RemovedElements {
223 fmt.Fprintf(&sb, " - %s\n", id)
224 }
225 sb.WriteString("\n")
226 }
227
228 if len(diffs.ChangedElements) > 0 {
229 fmt.Fprintf(&sb, "Changed Elements (%d):\n", len(diffs.ChangedElements))
230 for id, changes := range diffs.ChangedElements {
231 fmt.Fprintf(&sb, " ~ %s\n", id)
232 for _, change := range changes {
233 fmt.Fprintf(&sb, " %s\n", change)
234 }
235 }
236 sb.WriteString("\n")
237 }
238
239 if len(diffs.AddedRelationships) > 0 {
240 fmt.Fprintf(&sb, "Added Relationships (%d):\n", len(diffs.AddedRelationships))
241 for _, rel := range diffs.AddedRelationships {
242 fmt.Fprintf(&sb, " + %s → %s", rel.From, rel.To)
243 if rel.Label != "" {
244 fmt.Fprintf(&sb, " (%s)", rel.Label)
245 }
246 sb.WriteString("\n")
247 }
248 sb.WriteString("\n")
249 }
250
251 if len(diffs.RemovedRelationships) > 0 {
252 fmt.Fprintf(&sb, "Removed Relationships (%d):\n", len(diffs.RemovedRelationships))
253 for _, rel := range diffs.RemovedRelationships {
254 fmt.Fprintf(&sb, " - %s → %s", rel.From, rel.To)
255 if rel.Label != "" {
256 fmt.Fprintf(&sb, " (%s)", rel.Label)
257 }
258 sb.WriteString("\n")
259 }
260 }
261
262 return sb.String()
263 }
264
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_list.go 84.6%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "text/tabwriter"
7
8 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
9 "github.com/spf13/cobra"
10 )
11
12 func newSnapshotListCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "list",
15 Short: "List all saved snapshots",
16 Long: "Display all snapshots with timestamps, messages, and element/relationship counts.",
17 RunE: runSnapshotList,
18 }
19
20 cmd.Flags().String("format", "table", "Output format: table or json")
21
22 return cmd
23 }
24
25 func runSnapshotList(cmd *cobra.Command, _ []string) error {
26 format, _ := cmd.Flags().GetString("format")
27
28 manager := snapshot.NewManager(".")
29 snapshots, err := manager.List()
30 if err != nil {
31 return exitWithCode(fmt.Errorf("listing snapshots: %w", err), 2)
32 }
33
34 if len(snapshots) == 0 {
35 _, _ = fmt.Fprint(cmd.OutOrStdout(), "No snapshots found.\n")
36 return nil
37 }
38
39 switch format {
40 case "json":
41 data, err := json.MarshalIndent(snapshots, "", " ")
42 if err != nil {
43 return exitWithCode(fmt.Errorf("marshaling snapshots: %w", err), 2)
44 }
45 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
46 case "table":
47 w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
48 _, _ = fmt.Fprintln(w, "ID\tTIMESTAMP\tELEMENTS\tRELATIONSHIPS\tMESSAGE")
49 for _, s := range snapshots {
50 message := s.Message
51 if len(message) > 30 {
52 message = message[:27] + "..."
53 }
54 _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
55 s.ID,
56 s.Timestamp.Format("2006-01-02 15:04:05"),
57 s.ElementCount,
58 s.RelCount,
59 message,
60 )
61 }
62 _ = w.Flush()
63 default:
64 return exitWithCode(fmt.Errorf("unknown format: %s", format), 2)
65 }
66
67 return nil
68 }
69
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_restore.go 85.7%
1 package main
2
3 import (
4 "fmt"
5 "os"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
9 "github.com/spf13/cobra"
10 )
11
12 func newSnapshotRestoreCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "restore <snapshot-id> <output-path>",
15 Short: "Restore a snapshot to a file",
16 Long: "Export a snapshot's model to a JSONC file.",
17 Args: cobra.ExactArgs(2),
18 RunE: runSnapshotRestore,
19 }
20
21 cmd.Flags().Bool("force", false, "Overwrite output file if it exists")
22
23 return cmd
24 }
25
26 func runSnapshotRestore(cmd *cobra.Command, args []string) error {
27 snapshotID := args[0]
28 outputPath := args[1]
29 force, _ := cmd.Flags().GetBool("force")
30
31 if err := validatePathContainment(outputPath); err != nil {
32 return exitWithCode(err, 2)
33 }
34
35 manager := snapshot.NewManager(".")
36 if !manager.Exists(snapshotID) {
37 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID), 2)
38 }
39
40 snap, err := manager.Load(snapshotID)
41 if err != nil {
42 return exitWithCode(fmt.Errorf("loading snapshot: %w", err), 2)
43 }
44
45 // Check if output file already exists
46 if _, err := os.Stat(outputPath); err == nil && !force {
47 return exitWithCode(fmt.Errorf("output file already exists: %s (use --force to overwrite)", outputPath), 2)
48 }
49
50 // Save the model to the output file
51 if err := model.Save(outputPath, snap.Model); err != nil {
52 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
53 }
54
55 output := fmt.Sprintf("Snapshot restored: %s → %s\n", snapshotID, outputPath)
56 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
57
58 return nil
59 }
60
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_save.go 11.8%
1 package main
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
8 "github.com/spf13/cobra"
9 )
10
11 func newSnapshotSaveCmd() *cobra.Command {
12 cmd := &cobra.Command{
13 Use: "save",
14 Short: "Save current architecture model as a snapshot",
15 Long: "Capture the current architecture model state with an optional message and store it in .bausteinsicht-snapshots/",
16 RunE: runSnapshotSave,
17 }
18
19 cmd.Flags().String("model", "architecture.jsonc", "Model file path")
20 cmd.Flags().String("message", "", "Optional message describing the snapshot")
21
22 return cmd
23 }
24
25 func runSnapshotSave(cmd *cobra.Command, _ []string) error {
26 modelPath, _ := cmd.Flags().GetString("model")
27 message, _ := cmd.Flags().GetString("message")
28
29 if err := validatePathContainment(modelPath); err != nil {
30 return exitWithCode(err, 2)
31 }
32
33 // Load current model
34 m, err := model.Load(modelPath)
35 if err != nil {
36 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
37 }
38
39 // Create snapshot
40 snap := snapshot.NewSnapshot(message, m)
41
42 // Save snapshot
43 manager := snapshot.NewManager(".")
44 if err := manager.Save(snap); err != nil {
45 return exitWithCode(fmt.Errorf("saving snapshot: %w", err), 2)
46 }
47
48 // Report success
49 elementCount := len(flattenElements(m.Model))
50 relCount := len(m.Relationships)
51
52 output := fmt.Sprintf("Snapshot saved: %s\n", snap.ID)
53 output += fmt.Sprintf(" Timestamp: %s\n", snap.Timestamp.Format("2006-01-02T15:04:05Z"))
54 output += fmt.Sprintf(" Elements: %d\n", elementCount)
55 output += fmt.Sprintf(" Relationships: %d\n", relCount)
56 if message != "" {
57 output += fmt.Sprintf(" Message: %s\n", message)
58 }
59
60 if _, err := fmt.Fprint(cmd.OutOrStdout(), output); err != nil {
61 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
62 }
63
64 return nil
65 }
66
67 // flattenElements counts total elements including nested ones
68 func flattenElements(elems map[string]model.Element) map[string]model.Element {
69 result := make(map[string]model.Element)
70 for key, elem := range elems {
71 result[key] = elem
72 if len(elem.Children) > 0 {
73 children := flattenElements(elem.Children)
74 for k, v := range children {
75 result[key+"."+k] = v
76 }
77 }
78 }
79 return result
80 }
81
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/status.go 85.7%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 type statusResult struct {
13 Summary map[string]int `json:"summary"`
14 Elements []statusElement `json:"elements"`
15 }
16
17 type statusElement struct {
18 ID string `json:"id"`
19 Kind string `json:"kind"`
20 Title string `json:"title"`
21 Status string `json:"status"`
22 }
23
24 func newStatusCmd() *cobra.Command {
25 cmd := &cobra.Command{
26 Use: "status",
27 Short: "Show element lifecycle status",
28 Long: "Lists all elements and their lifecycle status (proposed, design, implementation, deployed, deprecated, archived).",
29 RunE: runStatus,
30 }
31
32 cmd.Flags().StringP("filter", "f", "", "Filter elements by status (proposed, design, implementation, deployed, deprecated, archived)")
33 // Note: --model is registered as PersistentFlag on root command, not locally
34
35 return cmd
36 }
37
38 func runStatus(cmd *cobra.Command, _ []string) error {
39 modelPath, _ := cmd.Flags().GetString("model")
40 filter, _ := cmd.Flags().GetString("filter")
41 format, _ := cmd.Flags().GetString("format")
42
43 if err := validatePathContainment(modelPath); err != nil {
44 return exitWithCode(fmt.Errorf("model path: %w", err), 1)
45 }
46
47 m, err := model.Load(modelPath)
48 if err != nil {
49 return exitWithCode(fmt.Errorf("loading model: %w", err), 1)
50 }
51
52 // Validate filter if provided
53 if filter != "" {
54 valid := false
55 for _, status := range model.ValidStatuses {
56 if filter == status {
57 valid = true
58 break
59 }
60 }
61 if !valid {
62 return exitWithCode(
63 fmt.Errorf("invalid status filter %q; valid values: %v", filter, model.ValidStatuses), 1)
64 }
65 }
66
67 // Collect all elements with their status
68 flatElements, err := model.FlattenElements(m)
69 if err != nil {
70 return exitWithCode(fmt.Errorf("flattening model: %w", err), 1)
71 }
72
73 result := statusResult{
74 Summary: make(map[string]int),
75 Elements: []statusElement{},
76 }
77
78 // Initialize summary counts
79 for _, status := range model.ValidStatuses {
80 result.Summary[status] = 0
81 }
82 result.Summary["unset"] = 0
83
84 // Process elements
85 for id, elem := range flatElements {
86 status := elem.Status
87 if status == "" {
88 status = "unset"
89 }
90
91 // Apply filter
92 if filter != "" && status != filter {
93 continue
94 }
95
96 // Count
97 if status != "unset" {
98 result.Summary[status]++
99 } else {
100 result.Summary["unset"]++
101 }
102
103 // Collect element
104 result.Elements = append(result.Elements, statusElement{
105 ID: id,
106 Kind: elem.Kind,
107 Title: elem.Title,
108 Status: status,
109 })
110 }
111
112 // Sort by status, then by ID
113 sort.Slice(result.Elements, func(i, j int) bool {
114 if result.Elements[i].Status != result.Elements[j].Status {
115 return result.Elements[i].Status < result.Elements[j].Status
116 }
117 return result.Elements[i].ID < result.Elements[j].ID
118 })
119
120 if format == "json" {
121 return outputStatusJSON(cmd, result)
122 }
123 return outputStatusText(cmd, result)
124 }
125
126 func outputStatusJSON(cmd *cobra.Command, result statusResult) error {
127 data, err := json.MarshalIndent(result, "", " ")
128 if err != nil {
129 return err
130 }
131 _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
132 return err
133 }
134
135 func outputStatusText(cmd *cobra.Command, result statusResult) error {
136 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Element Lifecycle Status\n"); err != nil {
137 return err
138 }
139 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "==================================================\n\n"); err != nil {
140 return err
141 }
142
143 // Print summary
144 for _, status := range append(model.ValidStatuses, "unset") {
145 count := result.Summary[status]
146 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s (%d):\n", status, count); err != nil {
147 return err
148 }
149
150 // Print elements for this status
151 for _, elem := range result.Elements {
152 if elem.Status == status {
153 if _, err := fmt.Fprintf(cmd.OutOrStdout(), " %-20s [%-12s] %q\n", elem.ID, elem.Kind, elem.Title); err != nil {
154 return err
155 }
156 }
157 }
158 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "\n"); err != nil {
159 return err
160 }
161 }
162
163 return nil
164 }
165
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/sync.go 88.1%
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io/fs"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "github.com/docToolchain/Bausteinsicht/internal/diagram"
14 "github.com/docToolchain/Bausteinsicht/internal/drawio"
15 "github.com/docToolchain/Bausteinsicht/internal/model"
16 bsync "github.com/docToolchain/Bausteinsicht/internal/sync"
17 "github.com/docToolchain/Bausteinsicht/templates"
18 "github.com/spf13/cobra"
19 )
20
21 func newSyncCmd() *cobra.Command {
22 cmd := &cobra.Command{
23 Use: "sync",
24 Short: "Synchronize model and draw.io diagram",
25 Long: "Runs one bidirectional sync cycle between the architecture model and the draw.io diagram.",
26 RunE: runSync,
27 }
28 cmd.Flags().Bool("relayout", false, "Re-apply auto-layout to all view pages (resets element positions)")
29 cmd.Flags().Bool("mermaid", false, "Export Mermaid diagrams to Markdown file")
30 cmd.Flags().String("mermaid-output", "architecture.md", "Output file for Mermaid diagrams")
31 return cmd
32 }
33
34 func runSync(cmd *cobra.Command, _ []string) error {
35 format, _ := cmd.Flags().GetString("format")
36 modelPath, _ := cmd.Flags().GetString("model")
37 templatePath, _ := cmd.Flags().GetString("template")
38 verbose, _ := cmd.Flags().GetBool("verbose")
39 relayout, _ := cmd.Flags().GetBool("relayout")
40
41 // Auto-detect model file.
42 if modelPath == "" {
43 detected, err := model.AutoDetect(".")
44 if err != nil {
45 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
46 }
47 modelPath = detected
48 }
49
50 // Derive drawio and state paths from model path.
51 dir := filepath.Dir(modelPath)
52 drawioPath := filepath.Join(dir, "architecture.drawio")
53 statePath := filepath.Join(dir, ".bausteinsicht-sync")
54
55 // Load model.
56 m, err := model.Load(modelPath)
57 if err != nil {
58 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
59 }
60
61 // Validate model before syncing to catch invalid view include/exclude
62 // patterns and other consistency errors. Without this, typos like
63 // "customer." (trailing dot) silently remove elements from draw.io. (#176)
64 if validationErrs := model.Validate(m); len(validationErrs) > 0 {
65 for _, ve := range validationErrs {
66 fmt.Fprintln(os.Stderr, "ERROR:", ve)
67 }
68 return exitWithCode(fmt.Errorf("model validation failed with %d error(s); fix the model before syncing", len(validationErrs)), 1)
69 }
70
71 // Load draw.io document. If the file was deleted or is an empty mxfile
72 // (no diagram pages — e.g., after all views were removed), recreate it
73 // from the template and reset sync state so forward sync repopulates it
74 // (#149, #175).
75 var recreated bool
76 doc, err := drawio.LoadDocument(drawioPath)
77 if err != nil {
78 if !errors.Is(err, fs.ErrNotExist) && !isEmptyMxfileError(err) {
79 return exitWithCode(fmt.Errorf("loading draw.io file: %w", err), 2)
80 }
81 if errors.Is(err, fs.ErrNotExist) {
82 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "WARNING: Draw.io file not found, recreating from template")
83 } else {
84 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "WARNING: Draw.io file has no diagram pages, recreating structure")
85 }
86 doc = drawio.NewDocument()
87 recreated = true
88 }
89
90 // Load sync state (empty on first sync).
91 // When the draw.io file was recreated, discard any stale state so the
92 // sync engine treats all model elements as new.
93 var state *bsync.SyncState
94 if recreated {
95 // Remove stale state file if it exists.
96 _ = os.Remove(statePath)
97 state = &bsync.SyncState{
98 Elements: make(map[string]bsync.ElementState),
99 Relationships: []bsync.RelationshipState{},
100 }
101 } else {
102 state, err = bsync.LoadState(statePath)
103 if err != nil {
104 return exitWithCode(fmt.Errorf("loading sync state: %w", err), 2)
105 }
106 }
107
108 // Load template.
109 var tmpl *drawio.TemplateSet
110 if templatePath != "" {
111 tmpl, err = drawio.LoadTemplate(templatePath)
112 } else {
113 tmpl, err = drawio.LoadTemplateFromBytes(templates.DefaultTemplate)
114 }
115 if err != nil {
116 return exitWithCode(fmt.Errorf("loading template: %w", err), 2)
117 }
118
119 // Verbose output goes to stderr so it doesn't interfere with JSON on stdout.
120 if verbose && format != "json" {
121 flat, _ := model.FlattenElements(m)
122 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Syncing model: %s\n", modelPath)
123 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %d elements, %d relationships, %d views\n",
124 len(flat), len(m.Relationships), len(m.Views))
125 }
126
127 // Ensure pages exist for all views; track which pages are newly created
128 // so the sync engine can avoid treating their missing elements as deletions
129 // (#184, #188, #189).
130 newPageIDs := make(map[string]bool)
131 for viewID, view := range m.Views {
132 pageID := "view-" + viewID
133 if doc.GetPage(pageID) == nil {
134 doc.AddPage(pageID, view.Title)
135 newPageIDs[pageID] = true
136 }
137 }
138
139 // Remove orphaned view pages (views deleted or renamed in model). (#143)
140 bsync.RemoveOrphanedViewPages(doc, m)
141
142 // Run sync.
143 fwdOpts := bsync.ForwardOptions{
144 ModelPath: modelPath,
145 SyncTime: time.Now().Format("2006-01-02 15:04"),
146 Relayout: relayout,
147 }
148 result := bsync.Run(m, doc, state, tmpl, newPageIDs, fwdOpts)
149
150 // Save updated model: use PatchSave to preserve JSONC comments and key
151 // ordering when possible, fall back to full Save for structural changes.
152 if err := saveModel(modelPath, m, result); err != nil {
153 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
154 }
155 if err := drawio.SaveDocument(drawioPath, doc); err != nil {
156 return exitWithCode(fmt.Errorf("saving draw.io file: %w", err), 2)
157 }
158
159 absModel, _ := filepath.Abs(modelPath)
160 absDrawio, _ := filepath.Abs(drawioPath)
161 newState, err := bsync.BuildState(m, doc, absModel, absDrawio)
162 if err != nil {
163 return exitWithCode(fmt.Errorf("building sync state: %w", err), 2)
164 }
165 if err := bsync.SaveState(statePath, newState); err != nil {
166 return exitWithCode(fmt.Errorf("saving sync state: %w", err), 2)
167 }
168
169 // Export Mermaid diagrams if requested
170 enableMermaid, _ := cmd.Flags().GetBool("mermaid")
171 if enableMermaid {
172 mermaidOutput, _ := cmd.Flags().GetString("mermaid-output")
173 viewKeys, diagrams, err := diagram.ExportAllViewsToMermaid(m)
174 if err != nil {
175 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "WARNING: Mermaid export failed: %v\n", err)
176 } else {
177 viewTitles := make(map[string]string)
178 for _, viewKey := range viewKeys {
179 if view, ok := m.Views[viewKey]; ok {
180 viewTitles[viewKey] = view.Title
181 }
182 }
183 markdownContent := diagram.WrapDiagramsInMarkdown(viewKeys, diagrams, viewTitles)
184 if err := os.WriteFile(mermaidOutput, []byte(markdownContent), 0o600); err != nil {
185 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "WARNING: Failed to write Mermaid file: %v\n", err)
186 } else {
187 if verbose && format != "json" {
188 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exporting Mermaid... ✅ %s (%d views)\n", mermaidOutput, len(viewKeys))
189 }
190 }
191 }
192 }
193
194 // Verbose post-sync details to stderr.
195 if verbose && format != "json" {
196 if result.Forward != nil {
197 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Forward sync: %d elements created, %d updated, %d deleted; %d connectors created, %d updated, %d deleted\n",
198 result.Forward.ElementsCreated, result.Forward.ElementsUpdated, result.Forward.ElementsDeleted,
199 result.Forward.ConnectorsCreated, result.Forward.ConnectorsUpdated, result.Forward.ConnectorsDeleted)
200 }
201 if result.Reverse != nil {
202 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Reverse sync: %d elements created, %d updated, %d deleted; %d relationships created, %d updated, %d deleted\n",
203 result.Reverse.ElementsCreated, result.Reverse.ElementsUpdated, result.Reverse.ElementsDeleted,
204 result.Reverse.RelationshipsCreated, result.Reverse.RelationshipsUpdated, result.Reverse.RelationshipsDeleted)
205 }
206 if len(result.Conflicts) > 0 {
207 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Conflicts resolved: %d (model wins)\n", len(result.Conflicts))
208 }
209 }
210
211 // Print warnings to stderr.
212 for _, w := range result.Warnings {
213 fmt.Fprintln(os.Stderr, "WARNING:", w)
214 }
215
216 // Output summary.
217 summary := buildSyncSummary(result)
218 if format == "json" {
219 data, _ := json.MarshalIndent(summary, "", " ")
220 fmt.Println(string(data))
221 } else {
222 printSyncSummary(summary)
223 }
224
225 // Exit code 1 if conflicts detected.
226 if len(result.Conflicts) > 0 {
227 return exitWithCode(fmt.Errorf("%d conflict(s) resolved (model wins)", len(result.Conflicts)), 1)
228 }
229
230 return nil
231 }
232
233 type syncSummary struct {
234 ForwardAdded int `json:"forward_added"`
235 ForwardUpdated int `json:"forward_updated"`
236 ForwardDeleted int `json:"forward_deleted"`
237 ReverseAdded int `json:"reverse_added"`
238 ReverseUpdated int `json:"reverse_updated"`
239 ReverseDeleted int `json:"reverse_deleted"`
240 MetadataUpdated int `json:"metadata_updated"`
241 Conflicts int `json:"conflicts"`
242 }
243
244 func buildSyncSummary(result *bsync.SyncResult) syncSummary {
245 s := syncSummary{
246 Conflicts: len(result.Conflicts),
247 }
248 if result.Forward != nil {
249 s.ForwardAdded = result.Forward.ElementsCreated
250 s.ForwardUpdated = result.Forward.ElementsUpdated
251 s.ForwardDeleted = result.Forward.ElementsDeleted
252 s.ForwardAdded += result.Forward.ConnectorsCreated
253 s.ForwardUpdated += result.Forward.ConnectorsUpdated
254 s.ForwardDeleted += result.Forward.ConnectorsDeleted
255 s.MetadataUpdated = result.Forward.MetadataUpdated
256 }
257 if result.Reverse != nil {
258 s.ReverseAdded = result.Reverse.ElementsCreated
259 s.ReverseUpdated = result.Reverse.ElementsUpdated
260 s.ReverseDeleted = result.Reverse.ElementsDeleted
261 s.ReverseAdded += result.Reverse.RelationshipsCreated
262 s.ReverseUpdated += result.Reverse.RelationshipsUpdated
263 s.ReverseDeleted += result.Reverse.RelationshipsDeleted
264 }
265 return s
266 }
267
268 // saveModel saves the model to path, preserving JSONC comments and key ordering
269 // when the reverse changes are simple field modifications. Falls back to full
270 // Save for structural changes or when patching fails.
271 func saveModel(path string, m *model.BausteinsichtModel, result *bsync.SyncResult) error {
272 hasReverse := result.Reverse != nil &&
273 (result.Reverse.ElementsUpdated+result.Reverse.ElementsCreated+
274 result.Reverse.ElementsDeleted+result.Reverse.RelationshipsCreated+
275 result.Reverse.RelationshipsUpdated+result.Reverse.RelationshipsDeleted) > 0
276
277 if !hasReverse {
278 // No reverse changes — model file doesn't need updating.
279 return nil
280 }
281
282 if result.Changes != nil {
283 ops, patchable := bsync.ReversePatchOps(result.Changes)
284 if patchable && len(ops) > 0 {
285 if err := model.PatchSave(path, ops); err == nil {
286 return nil
287 }
288 // PatchSave failed — fall through to full Save.
289 }
290 }
291
292 return model.Save(path, m)
293 }
294
295 // isEmptyMxfileError returns true if the error indicates that the draw.io file
296 // is a valid XML mxfile but contains no <diagram> elements. This happens when
297 // all views are removed from the model and sync removes all diagram pages (#175).
298 func isEmptyMxfileError(err error) bool {
299 return err != nil && strings.Contains(err.Error(), "no <diagram> elements")
300 }
301
302 func printSyncSummary(s syncSummary) {
303 total := s.ForwardAdded + s.ForwardUpdated + s.ForwardDeleted +
304 s.ReverseAdded + s.ReverseUpdated + s.ReverseDeleted
305 if total == 0 && s.Conflicts == 0 && s.MetadataUpdated == 0 {
306 fmt.Println("Already in sync. No changes.")
307 return
308 }
309 if s.ForwardAdded+s.ForwardUpdated+s.ForwardDeleted > 0 {
310 fmt.Printf("Forward (model → draw.io): %d added, %d updated, %d deleted\n",
311 s.ForwardAdded, s.ForwardUpdated, s.ForwardDeleted)
312 }
313 if s.MetadataUpdated > 0 && total == 0 {
314 fmt.Printf("Metadata/legend updated on %d view page(s).\n", s.MetadataUpdated/2)
315 }
316 if s.ReverseAdded+s.ReverseUpdated+s.ReverseDeleted > 0 {
317 fmt.Printf("Reverse (draw.io → model): %d added, %d updated, %d deleted\n",
318 s.ReverseAdded, s.ReverseUpdated, s.ReverseDeleted)
319 }
320 if s.Conflicts > 0 {
321 fmt.Printf("Conflicts: %d (resolved: model wins)\n", s.Conflicts)
322 }
323 }
324
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/validate.go 91.1%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/spf13/cobra"
9 )
10
11 type validateResult struct {
12 Valid bool `json:"valid"`
13 Errors []validateErrJSON `json:"errors"`
14 Warnings []validateErrJSON `json:"warnings,omitempty"`
15 }
16
17 type validateErrJSON struct {
18 Path string `json:"path"`
19 Message string `json:"message"`
20 }
21
22 func newValidateCmd() *cobra.Command {
23 return &cobra.Command{
24 Use: "validate",
25 Short: "Validate the architecture model",
26 Long: "Validates the architecture model file for consistency and reports any errors.",
27 SilenceUsage: true,
28 SilenceErrors: true,
29 RunE: runValidate,
30 }
31 }
32
33 func runValidate(cmd *cobra.Command, args []string) error {
34 format, _ := cmd.Flags().GetString("format")
35 modelPath, _ := cmd.Flags().GetString("model")
36 verbose, _ := cmd.Flags().GetBool("verbose")
37
38 if modelPath == "" {
39 detected, err := model.AutoDetect(".")
40 if err != nil {
41 return exitWithCode(err, 2)
42 }
43 modelPath = detected
44 }
45
46 m, err := model.Load(modelPath)
47 if err != nil {
48 return exitWithCode(err, 2)
49 }
50
51 // Verbose output goes to stderr so it doesn't interfere with JSON on stdout.
52 if verbose && format != "json" {
53 flat, _ := model.FlattenElements(m)
54 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Validating model: %s\n", modelPath)
55 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %d elements, %d relationships, %d views\n",
56 len(flat), len(m.Relationships), len(m.Views))
57 }
58
59 result := model.ValidateWithWarnings(m)
60
61 if format == "json" {
62 return outputJSON(cmd, result)
63 }
64 return outputText(cmd, result)
65 }
66
67 func outputJSON(cmd *cobra.Command, vr model.ValidationResult) error {
68 result := validateResult{
69 Valid: len(vr.Errors) == 0,
70 Errors: make([]validateErrJSON, len(vr.Errors)),
71 }
72 for i, e := range vr.Errors {
73 result.Errors[i] = validateErrJSON{Path: e.Path, Message: e.Message}
74 }
75 if len(vr.Warnings) > 0 {
76 result.Warnings = make([]validateErrJSON, len(vr.Warnings))
77 for i, w := range vr.Warnings {
78 result.Warnings[i] = validateErrJSON{Path: w.Path, Message: w.Message}
79 }
80 }
81 data, err := json.MarshalIndent(result, "", " ")
82 if err != nil {
83 return err
84 }
85 if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
86 return err
87 }
88 if !result.Valid {
89 return exitWithCode(fmt.Errorf("validation failed"), 1)
90 }
91 return nil
92 }
93
94 func outputText(cmd *cobra.Command, vr model.ValidationResult) error {
95 for _, w := range vr.Warnings {
96 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "WARNING: [%s] %s\n", w.Path, w.Message); err != nil {
97 return err
98 }
99 }
100 if len(vr.Errors) == 0 {
101 _, err := fmt.Fprintln(cmd.OutOrStdout(), "Model is valid.")
102 return err
103 }
104 for _, e := range vr.Errors {
105 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "ERROR: [%s] %s\n", e.Path, e.Message); err != nil {
106 return err
107 }
108 }
109 return exitWithCode(fmt.Errorf("validation failed"), 1)
110 }
111
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/watch.go 16.8%
1 package main
2
3 import (
4 "fmt"
5 "os"
6 "os/signal"
7 "path/filepath"
8 "syscall"
9 "time"
10
11 "github.com/docToolchain/Bausteinsicht/internal/diagram"
12 "github.com/docToolchain/Bausteinsicht/internal/drawio"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 bsync "github.com/docToolchain/Bausteinsicht/internal/sync"
15 "github.com/docToolchain/Bausteinsicht/internal/watcher"
16 "github.com/docToolchain/Bausteinsicht/templates"
17 "github.com/spf13/cobra"
18 )
19
20 func newWatchCmd() *cobra.Command {
21 cmd := &cobra.Command{
22 Use: "watch",
23 Short: "Watch model and diagram for changes and auto-sync",
24 Long: "Watches the model and draw.io files for changes and automatically runs a sync cycle on each change.",
25 RunE: runWatch,
26 }
27 cmd.Flags().Bool("mermaid", false, "Export Mermaid diagrams to Markdown file")
28 cmd.Flags().String("mermaid-output", "architecture.md", "Output file for Mermaid diagrams")
29 return cmd
30 }
31
32 func runWatch(cmd *cobra.Command, _ []string) error {
33 modelPath, _ := cmd.Flags().GetString("model")
34 templatePath, _ := cmd.Flags().GetString("template")
35 enableMermaid, _ := cmd.Flags().GetBool("mermaid")
36 mermaidOutput, _ := cmd.Flags().GetString("mermaid-output")
37
38 // Auto-detect model file.
39 if modelPath == "" {
40 detected, err := model.AutoDetect(".")
41 if err != nil {
42 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
43 }
44 modelPath = detected
45 }
46
47 // Derive drawio path from model path.
48 dir := filepath.Dir(modelPath)
49 drawioPath := filepath.Join(dir, "architecture.drawio")
50
51 // Verify both files exist before starting the watcher.
52 if _, err := os.Stat(modelPath); err != nil {
53 return exitWithCode(fmt.Errorf("model file not found: %w", err), 2)
54 }
55 if _, err := os.Stat(drawioPath); err != nil {
56 return exitWithCode(fmt.Errorf("draw.io file not found: %w", err), 2)
57 }
58
59 absModel, _ := filepath.Abs(modelPath)
60 absDrawio, _ := filepath.Abs(drawioPath)
61
62 var err error
63
64 fmt.Printf("Watching %s and %s...\n", modelPath, drawioPath)
65
66 // Create the file watcher. Use a variable so the callback can access the watcher.
67 var w *watcher.Watcher
68 w, err = watcher.New(
69 []string{absModel, absDrawio},
70 watcher.DefaultDebounce,
71 func(changedFile string) {
72 w.SetSyncing(true)
73 defer w.SetSyncing(false)
74 doSync(changedFile, modelPath, drawioPath, templatePath, enableMermaid, mermaidOutput)
75 },
76 )
77 if err != nil {
78 return exitWithCode(fmt.Errorf("creating watcher: %w", err), 2)
79 }
80
81 if err := w.Start(); err != nil {
82 return exitWithCode(fmt.Errorf("starting watcher: %w", err), 2)
83 }
84
85 // Block until SIGINT/SIGTERM.
86 sigCh := make(chan os.Signal, 1)
87 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
88 <-sigCh
89
90 w.Stop()
91 fmt.Println("Stopped watching.")
92 return nil
93 }
94
95 func doSync(changedFile, modelPath, drawioPath, templatePath string, enableMermaid bool, mermaidOutput string) {
96 fmt.Printf("[%s] Sync triggered by %s\n", time.Now().Format("15:04:05"), changedFile)
97
98 dir := filepath.Dir(modelPath)
99 statePath := filepath.Join(dir, ".bausteinsicht-sync")
100
101 m, err := model.Load(modelPath)
102 if err != nil {
103 fmt.Fprintf(os.Stderr, "ERROR loading model: %v\n", err)
104 return
105 }
106
107 // Load draw.io document. If the file is an empty mxfile (no diagram
108 // pages — e.g., after all views were removed), recreate it and reset
109 // sync state (#175).
110 var watchRecreated bool
111 doc, err := drawio.LoadDocument(drawioPath)
112 if err != nil {
113 if isEmptyMxfileError(err) {
114 fmt.Fprintln(os.Stderr, "WARNING: Draw.io file has no diagram pages, recreating structure")
115 doc = drawio.NewDocument()
116 watchRecreated = true
117 } else {
118 fmt.Fprintf(os.Stderr, "ERROR loading draw.io file: %v\n", err)
119 return
120 }
121 }
122
123 var state *bsync.SyncState
124 if watchRecreated {
125 _ = os.Remove(statePath)
126 state = &bsync.SyncState{
127 Elements: make(map[string]bsync.ElementState),
128 Relationships: []bsync.RelationshipState{},
129 }
130 } else {
131 state, err = bsync.LoadState(statePath)
132 if err != nil {
133 fmt.Fprintf(os.Stderr, "ERROR loading sync state: %v\n", err)
134 return
135 }
136 }
137
138 var tmpl *drawio.TemplateSet
139 if templatePath != "" {
140 tmpl, err = drawio.LoadTemplate(templatePath)
141 } else {
142 tmpl, err = drawio.LoadTemplateFromBytes(templates.DefaultTemplate)
143 }
144 if err != nil {
145 fmt.Fprintf(os.Stderr, "ERROR loading template: %v\n", err)
146 return
147 }
148
149 // Ensure pages exist for all views; track new pages (#184, #188, #189).
150 newPageIDs := make(map[string]bool)
151 for viewID, view := range m.Views {
152 pageID := "view-" + viewID
153 if doc.GetPage(pageID) == nil {
154 doc.AddPage(pageID, view.Title)
155 newPageIDs[pageID] = true
156 }
157 }
158
159 // Remove orphaned view pages (views deleted or renamed in model). (#143)
160 bsync.RemoveOrphanedViewPages(doc, m)
161
162 result := bsync.Run(m, doc, state, tmpl, newPageIDs)
163
164 if err := saveModel(modelPath, m, result); err != nil {
165 fmt.Fprintf(os.Stderr, "ERROR saving model: %v\n", err)
166 return
167 }
168 if err := drawio.SaveDocument(drawioPath, doc); err != nil {
169 fmt.Fprintf(os.Stderr, "ERROR saving draw.io file: %v\n", err)
170 return
171 }
172
173 absModel, _ := filepath.Abs(modelPath)
174 absDrawio, _ := filepath.Abs(drawioPath)
175 newState, err := bsync.BuildState(m, doc, absModel, absDrawio)
176 if err != nil {
177 fmt.Fprintf(os.Stderr, "ERROR building sync state: %v\n", err)
178 return
179 }
180 if err := bsync.SaveState(statePath, newState); err != nil {
181 fmt.Fprintf(os.Stderr, "ERROR saving sync state: %v\n", err)
182 return
183 }
184
185 // Export Mermaid diagrams if requested and sync succeeded
186 if enableMermaid {
187 viewKeys, diagrams, err := diagram.ExportAllViewsToMermaid(m)
188 if err != nil {
189 fmt.Fprintf(os.Stderr, "WARNING: Mermaid export failed: %v\n", err)
190 } else {
191 viewTitles := make(map[string]string)
192 for _, viewKey := range viewKeys {
193 if view, ok := m.Views[viewKey]; ok {
194 viewTitles[viewKey] = view.Title
195 }
196 }
197 markdownContent := diagram.WrapDiagramsInMarkdown(viewKeys, diagrams, viewTitles)
198 if err := os.WriteFile(mermaidOutput, []byte(markdownContent), 0o600); err != nil {
199 fmt.Fprintf(os.Stderr, "WARNING: Failed to write Mermaid file: %v\n", err)
200 } else {
201 fmt.Printf("[%s] Exporting Mermaid... ✅ %s (%d views)\n", time.Now().Format("15:04:05"), mermaidOutput, len(viewKeys))
202 }
203 }
204 }
205
206 for _, w := range result.Warnings {
207 fmt.Fprintln(os.Stderr, "WARNING:", w)
208 }
209
210 printSyncSummary(buildSyncSummary(result))
211 }
212
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/workspace.go 9.3%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/docToolchain/Bausteinsicht/internal/workspace"
11 "github.com/spf13/cobra"
12 )
13
14 func newWorkspaceCmd() *cobra.Command {
15 cmd := &cobra.Command{
16 Use: "workspace",
17 Short: "Manage multi-model workspaces",
18 Long: "Work with multi-model workspaces combining multiple architecture models.",
19 }
20
21 cmd.AddCommand(newWorkspaceMergeCmd())
22 cmd.AddCommand(newWorkspaceValidateCmd())
23 cmd.AddCommand(newWorkspaceListCmd())
24
25 return cmd
26 }
27
28 func newWorkspaceMergeCmd() *cobra.Command {
29 return &cobra.Command{
30 Use: "merge <config-file> <output-file>",
31 Short: "Merge multiple models into a single unified model",
32 Long: "Reads a workspace configuration file and merges all referenced models into a single output file. Element IDs are prefixed to avoid collisions.",
33 Args: cobra.ExactArgs(2),
34 RunE: func(cmd *cobra.Command, args []string) error {
35 return runWorkspaceMerge(cmd, args[0], args[1])
36 },
37 }
38 }
39
40 func runWorkspaceMerge(cmd *cobra.Command, configPath, outputPath string) error {
41 cfg, err := workspace.LoadConfig(configPath)
42 if err != nil {
43 return exitWithCode(fmt.Errorf("loading workspace config: %w", err), 2)
44 }
45
46 baseDir := filepath.Dir(configPath)
47 loaded, err := workspace.LoadModels(cfg, baseDir)
48 if err != nil {
49 return exitWithCode(fmt.Errorf("loading models: %w", err), 2)
50 }
51
52 merged, err := workspace.MergeModels(loaded)
53 if err != nil {
54 return exitWithCode(fmt.Errorf("merging models: %w", err), 2)
55 }
56
57 // Validate merged model
58 if errs := model.Validate(merged); len(errs) > 0 {
59 return exitWithCode(fmt.Errorf("validation failed: %v", errs), 2)
60 }
61
62 // Save merged model
63 data, err := json.MarshalIndent(merged, "", " ")
64 if err != nil {
65 return exitWithCode(fmt.Errorf("marshaling merged model: %w", err), 2)
66 }
67
68 if err := os.WriteFile(outputPath, data, 0644); err != nil {
69 return exitWithCode(fmt.Errorf("writing output file: %w", err), 2)
70 }
71
72 format, _ := cmd.Flags().GetString("format")
73 if format == "json" {
74 out, _ := json.Marshal(map[string]interface{}{
75 "message": "Models merged successfully",
76 "output": outputPath,
77 "models": len(loaded),
78 })
79 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
80 } else {
81 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Merged %d models into %s\n", len(loaded), outputPath)
82 }
83
84 return nil
85 }
86
87 func newWorkspaceValidateCmd() *cobra.Command {
88 return &cobra.Command{
89 Use: "validate <config-file>",
90 Short: "Validate a workspace configuration",
91 Long: "Validates that a workspace configuration is well-formed and all referenced models are valid.",
92 Args: cobra.ExactArgs(1),
93 RunE: func(cmd *cobra.Command, args []string) error {
94 return runWorkspaceValidate(cmd, args[0])
95 },
96 }
97 }
98
99 func runWorkspaceValidate(cmd *cobra.Command, configPath string) error {
100 cfg, err := workspace.LoadConfig(configPath)
101 if err != nil {
102 return exitWithCode(fmt.Errorf("loading workspace config: %w", err), 2)
103 }
104
105 baseDir := filepath.Dir(configPath)
106 loaded, err := workspace.LoadModels(cfg, baseDir)
107 if err != nil {
108 return exitWithCode(fmt.Errorf("loading models: %w", err), 2)
109 }
110
111 // Validate each model individually
112 var validationErrs []error
113 for _, lm := range loaded {
114 if errs := model.Validate(lm.Model); len(errs) > 0 {
115 for _, e := range errs {
116 validationErrs = append(validationErrs, fmt.Errorf("%s: %v", lm.Ref.ID, e))
117 }
118 }
119 }
120
121 if len(validationErrs) > 0 {
122 format, _ := cmd.Flags().GetString("format")
123 if format == "json" {
124 var errMsgs []string
125 for _, e := range validationErrs {
126 errMsgs = append(errMsgs, e.Error())
127 }
128 out, _ := json.Marshal(map[string]interface{}{
129 "valid": false,
130 "errors": errMsgs,
131 })
132 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
133 } else {
134 for _, e := range validationErrs {
135 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), e.Error())
136 }
137 }
138 return exitWithCode(fmt.Errorf("validation failed"), 2)
139 }
140
141 format, _ := cmd.Flags().GetString("format")
142 if format == "json" {
143 out, _ := json.Marshal(map[string]interface{}{
144 "valid": true,
145 "models": len(loaded),
146 })
147 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
148 } else {
149 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "✓ Workspace configuration is valid (%d models)\n", len(loaded))
150 }
151
152 return nil
153 }
154
155 func newWorkspaceListCmd() *cobra.Command {
156 return &cobra.Command{
157 Use: "list <config-file>",
158 Short: "List models in a workspace configuration",
159 Long: "Shows all models referenced in a workspace configuration with their IDs, paths, and prefixes.",
160 Args: cobra.ExactArgs(1),
161 RunE: func(cmd *cobra.Command, args []string) error {
162 return runWorkspaceList(cmd, args[0])
163 },
164 }
165 }
166
167 func runWorkspaceList(cmd *cobra.Command, configPath string) error {
168 cfg, err := workspace.LoadConfig(configPath)
169 if err != nil {
170 return exitWithCode(fmt.Errorf("loading workspace config: %w", err), 2)
171 }
172
173 format, _ := cmd.Flags().GetString("format")
174 if format == "json" {
175 out, _ := json.MarshalIndent(cfg, "", " ")
176 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
177 return nil
178 }
179
180 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Workspace: %s\n", cfg.Workspace.Name)
181 if cfg.Workspace.Description != "" {
182 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Description: %s\n\n", cfg.Workspace.Description)
183 } else {
184 _, _ = fmt.Fprint(cmd.OutOrStdout(), "\n")
185 }
186
187 _, _ = fmt.Fprint(cmd.OutOrStdout(), "Models:\n")
188 for i, ref := range cfg.Models {
189 _, _ = fmt.Fprintf(cmd.OutOrStdout(), " %d. ID: %s, Path: %s", i+1, ref.ID, ref.Path)
190 if ref.Prefix != "" {
191 _, _ = fmt.Fprintf(cmd.OutOrStdout(), ", Prefix: %s", ref.Prefix)
192 }
193 _, _ = fmt.Fprint(cmd.OutOrStdout(), "\n")
194 }
195
196 if len(cfg.CrossRels) > 0 {
197 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nCross-Model Relationships: %d\n", len(cfg.CrossRels))
198 }
199
200 if len(cfg.Views) > 0 {
201 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Workspace Views: %d\n", len(cfg.Views))
202 }
203
204 return nil
205 }
206
github.com/docToolchain/Bausteinsicht/internal/changelog/changelog.go 89.7%
1 package changelog
2
3 import (
4 "github.com/docToolchain/Bausteinsicht/internal/diff"
5 "github.com/docToolchain/Bausteinsicht/internal/model"
6 )
7
8 // Generate creates a changelog by comparing two model versions
9 func Generate(from, to *model.BausteinsichtModel, fromRef, toRef Reference) *Changelog {
10 // Convert models to snapshots for diff comparison
11 fromSnap := modelToSnapshot(from)
12 toSnap := modelToSnapshot(to)
13
14 // Use diff package to compute changes
15 result := diff.Compare(fromSnap, toSnap)
16
17 // Organize changes by type
18 return &Changelog{
19 From: fromRef,
20 To: toRef,
21 Elements: ElementChanges{
22 Added: filterElementsByType(result.Elements, diff.ChangeAdded),
23 Removed: filterElementsByType(result.Elements, diff.ChangeRemoved),
24 Changed: filterElementsByType(result.Elements, diff.ChangeChanged),
25 },
26 Relationships: RelationshipChanges{
27 Added: filterRelationshipsByType(result.Relationships, diff.ChangeAdded),
28 Removed: filterRelationshipsByType(result.Relationships, diff.ChangeRemoved),
29 },
30 }
31 }
32
33 // modelToSnapshot converts a BausteinsichtModel to a ModelSnapshot for diff computation
34 func modelToSnapshot(m *model.BausteinsichtModel) *model.ModelSnapshot {
35 if m == nil {
36 return &model.ModelSnapshot{
37 Elements: make(map[string]model.Element),
38 Relationships: []model.Relationship{},
39 }
40 }
41
42 // Flatten nested elements into a single-level map using dot notation
43 elements := flattenElements(m.Model, "")
44
45 return &model.ModelSnapshot{
46 Elements: elements,
47 Relationships: m.Relationships,
48 }
49 }
50
51 // flattenElements recursively flattens nested element maps into a single-level map
52 // with dot-separated keys (e.g., "system.backend.api")
53 func flattenElements(elems map[string]model.Element, prefix string) map[string]model.Element {
54 result := make(map[string]model.Element)
55
56 for key, elem := range elems {
57 fullKey := key
58 if prefix != "" {
59 fullKey = prefix + "." + key
60 }
61
62 // Add this element
63 result[fullKey] = elem
64
65 // Recursively flatten child elements if they exist
66 if len(elem.Children) > 0 {
67 children := flattenElements(elem.Children, fullKey)
68 for k, v := range children {
69 result[k] = v
70 }
71 }
72 }
73
74 return result
75 }
76
77 // filterElementsByType returns only elements with the specified change type
78 func filterElementsByType(changes []diff.ElementChange, changeType diff.ChangeType) []diff.ElementChange {
79 var result []diff.ElementChange
80 for _, c := range changes {
81 if c.Type == changeType {
82 result = append(result, c)
83 }
84 }
85 return result
86 }
87
88 // filterRelationshipsByType returns only relationships with the specified change type
89 func filterRelationshipsByType(changes []diff.RelationshipChange, changeType diff.ChangeType) []diff.RelationshipChange {
90 var result []diff.RelationshipChange
91 for _, c := range changes {
92 if c.Type == changeType {
93 result = append(result, c)
94 }
95 }
96 return result
97 }
98
github.com/docToolchain/Bausteinsicht/internal/changelog/git.go 0.0%
1 package changelog
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os/exec"
7 "strconv"
8 "strings"
9 "time"
10
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 )
13
14 // LoadModelAtGitRef loads a model from a specific git reference
15 func LoadModelAtGitRef(modelPath, gitRef string) (*model.BausteinsichtModel, error) {
16 // Resolve the git ref to its full commit hash
17 hash, _, err := resolveGitRef(gitRef)
18 if err != nil {
19 return nil, fmt.Errorf("resolving git ref %q: %w", gitRef, err)
20 }
21
22 // Load model from git
23 m, err := loadModelFromGit(modelPath, hash)
24 if err != nil {
25 return nil, fmt.Errorf("loading model from git ref %q: %w", gitRef, err)
26 }
27
28 // Store metadata for changelog generation
29 if m == nil {
30 m = &model.BausteinsichtModel{}
31 }
32
33 return m, nil
34 }
35
36 // resolveGitRef converts a git ref (tag, branch, commit) to its hash and timestamp
37 func resolveGitRef(gitRef string) (hash string, date time.Time, err error) {
38 // Get commit hash
39 hashCmd := exec.Command("git", "rev-parse", gitRef)
40 hashOut, err := hashCmd.Output()
41 if err != nil {
42 return "", time.Time{}, fmt.Errorf("failed to resolve git ref: %w", err)
43 }
44 hash = strings.TrimSpace(string(hashOut))
45
46 // Get commit date
47 dateCmd := exec.Command("git", "log", "-1", "--format=%ct", hash)
48 dateOut, err := dateCmd.Output()
49 if err != nil {
50 return "", time.Time{}, fmt.Errorf("failed to get commit date: %w", err)
51 }
52
53 timestamp, err := strconv.ParseInt(strings.TrimSpace(string(dateOut)), 10, 64)
54 if err != nil {
55 return "", time.Time{}, fmt.Errorf("failed to parse commit timestamp: %w", err)
56 }
57
58 date = time.Unix(timestamp, 0).UTC()
59 return hash, date, nil
60 }
61
62 // loadModelFromGit retrieves the model file from git at a specific commit
63 func loadModelFromGit(modelPath, commitHash string) (*model.BausteinsichtModel, error) {
64 cmd := exec.Command("git", "show", commitHash+":"+modelPath)
65 out, err := cmd.Output()
66 if err != nil {
67 return nil, fmt.Errorf("git show failed: %w", err)
68 }
69
70 // Strip JSONC comments and parse
71 clean := model.StripJSONC(out)
72 trimmed := strings.TrimSpace(string(clean))
73 if trimmed == "null" || trimmed == "" {
74 return nil, fmt.Errorf("model file is empty or null at %s in %s", modelPath, commitHash)
75 }
76
77 var m model.BausteinsichtModel
78 if err := json.Unmarshal(clean, &m); err != nil {
79 return nil, fmt.Errorf("parsing model: %w", err)
80 }
81
82 m.ElementOrder = extractElementOrder(clean)
83 return &m, nil
84 }
85
86 // extractElementOrder extracts the definition order of element kinds from specification
87 func extractElementOrder(data []byte) []string {
88 var raw map[string]json.RawMessage
89 if err := json.Unmarshal(data, &raw); err != nil {
90 return nil
91 }
92 specRaw, ok := raw["specification"]
93 if !ok {
94 return nil
95 }
96 var spec map[string]json.RawMessage
97 if err := json.Unmarshal(specRaw, &spec); err != nil {
98 return nil
99 }
100 elemsRaw, ok := spec["elements"]
101 if !ok {
102 return nil
103 }
104 var elems map[string]interface{}
105 d := json.NewDecoder(strings.NewReader(string(elemsRaw)))
106 d.UseNumber()
107 if err := d.Decode(&elems); err != nil {
108 return nil
109 }
110 var order []string
111 for k := range elems {
112 order = append(order, k)
113 }
114 return order
115 }
116
117 // GetCommitInfo retrieves metadata about a git commit
118 func GetCommitInfo(gitRef string) (*CommitInfo, error) {
119 hash, date, err := resolveGitRef(gitRef)
120 if err != nil {
121 return nil, err
122 }
123
124 // Get author
125 authorCmd := exec.Command("git", "log", "-1", "--format=%an", hash)
126 authorOut, err := authorCmd.Output()
127 if err != nil {
128 return nil, fmt.Errorf("failed to get commit author: %w", err)
129 }
130
131 // Get message
132 msgCmd := exec.Command("git", "log", "-1", "--format=%s", hash)
133 msgOut, err := msgCmd.Output()
134 if err != nil {
135 return nil, fmt.Errorf("failed to get commit message: %w", err)
136 }
137
138 return &CommitInfo{
139 Hash: hash,
140 Author: strings.TrimSpace(string(authorOut)),
141 Date: date,
142 Message: strings.TrimSpace(string(msgOut)),
143 Timestamp: date.Unix(),
144 }, nil
145 }
146
github.com/docToolchain/Bausteinsicht/internal/changelog/render.go 41.3%
1 package changelog
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderMarkdown renders the changelog as Markdown
12 func RenderMarkdown(cl *Changelog) string {
13 var sb strings.Builder
14
15 sb.WriteString("# Architecture Changelog\n\n")
16
17 // Title with date range
18 dateRange := fmt.Sprintf("%s → %s", cl.From.Ref, cl.To.Ref)
19 if !cl.From.Date.IsZero() && !cl.To.Date.IsZero() {
20 dateRange = fmt.Sprintf("%s → %s (%s → %s)",
21 cl.From.Ref, cl.To.Ref,
22 cl.From.Date.Format("2006-01-02"),
23 cl.To.Date.Format("2006-01-02"))
24 }
25 fmt.Fprintf(&sb, "## %s\n\n", dateRange)
26
27 // Added elements
28 if cl.Elements.CountAdded() > 0 {
29 fmt.Fprintf(&sb, "### Added (%d elements)\n", cl.Elements.CountAdded())
30 for _, change := range cl.Elements.Added {
31 if change.ToBe != nil {
32 desc := ""
33 if change.ToBe.Description != "" {
34 desc = fmt.Sprintf(" _{%s}_", change.ToBe.Description)
35 }
36 fmt.Fprintf(&sb, "- **%s** `[%s]` — %s%s\n",
37 change.ID, change.ToBe.Kind, change.ToBe.Title, desc)
38 }
39 }
40 sb.WriteString("\n")
41 }
42
43 // Removed elements
44 if cl.Elements.CountRemoved() > 0 {
45 fmt.Fprintf(&sb, "### Removed (%d elements)\n", cl.Elements.CountRemoved())
46 for _, change := range cl.Elements.Removed {
47 if change.AsIs != nil {
48 desc := ""
49 if change.AsIs.Description != "" {
50 desc = fmt.Sprintf(" _{%s}_", change.AsIs.Description)
51 }
52 fmt.Fprintf(&sb, "- ~~**%s**~~ `[%s]` — %s%s\n",
53 change.ID, change.AsIs.Kind, change.AsIs.Title, desc)
54 }
55 }
56 sb.WriteString("\n")
57 }
58
59 // Changed elements
60 if cl.Elements.CountChanged() > 0 {
61 fmt.Fprintf(&sb, "### Changed (%d elements)\n", cl.Elements.CountChanged())
62 for _, change := range cl.Elements.Changed {
63 if change.AsIs != nil && change.ToBe != nil {
64 fmt.Fprintf(&sb, "- **%s** — ", change.ID)
65 changes := renderElementChanges(*change.AsIs, *change.ToBe)
66 sb.WriteString(changes)
67 sb.WriteString("\n")
68 }
69 }
70 sb.WriteString("\n")
71 }
72
73 // Added relationships
74 if cl.Relationships.CountAddedRelationships() > 0 {
75 fmt.Fprintf(&sb, "### New Relationships (%d)\n", cl.Relationships.CountAddedRelationships())
76 for _, change := range cl.Relationships.Added {
77 label := ""
78 if change.ToBe != nil && change.ToBe.Label != "" {
79 label = fmt.Sprintf(" (%s)", change.ToBe.Label)
80 }
81 fmt.Fprintf(&sb, "- %s → %s%s\n", change.From, change.To, label)
82 }
83 sb.WriteString("\n")
84 }
85
86 // Removed relationships
87 if cl.Relationships.CountRemovedRelationships() > 0 {
88 fmt.Fprintf(&sb, "### Removed Relationships (%d)\n", cl.Relationships.CountRemovedRelationships())
89 for _, change := range cl.Relationships.Removed {
90 label := ""
91 if change.AsIs != nil && change.AsIs.Label != "" {
92 label = fmt.Sprintf(" (%s)", change.AsIs.Label)
93 }
94 fmt.Fprintf(&sb, "- ~~%s → %s~~%s\n", change.From, change.To, label)
95 }
96 sb.WriteString("\n")
97 }
98
99 if cl.Elements.CountAdded() == 0 && cl.Elements.CountRemoved() == 0 &&
100 cl.Elements.CountChanged() == 0 && cl.Relationships.CountAddedRelationships() == 0 &&
101 cl.Relationships.CountRemovedRelationships() == 0 {
102 sb.WriteString("No architectural changes detected.\n")
103 }
104
105 return sb.String()
106 }
107
108 // RenderAsciiDoc renders the changelog as AsciiDoc
109 func RenderAsciiDoc(cl *Changelog) string {
110 var sb strings.Builder
111
112 sb.WriteString("= Architecture Changelog\n\n")
113
114 // Title with date range
115 dateRange := fmt.Sprintf("%s → %s", cl.From.Ref, cl.To.Ref)
116 if !cl.From.Date.IsZero() && !cl.To.Date.IsZero() {
117 dateRange = fmt.Sprintf("%s → %s (%s → %s)",
118 cl.From.Ref, cl.To.Ref,
119 cl.From.Date.Format("2006-01-02"),
120 cl.To.Date.Format("2006-01-02"))
121 }
122 fmt.Fprintf(&sb, "== %s\n\n", dateRange)
123
124 // Added elements
125 if cl.Elements.CountAdded() > 0 {
126 fmt.Fprintf(&sb, "=== Added (%d elements)\n", cl.Elements.CountAdded())
127 for _, change := range cl.Elements.Added {
128 if change.ToBe != nil {
129 desc := ""
130 if change.ToBe.Description != "" {
131 desc = fmt.Sprintf(": %s", change.ToBe.Description)
132 }
133 fmt.Fprintf(&sb, "* *%s* `[%s]` – %s%s\n",
134 change.ID, change.ToBe.Kind, change.ToBe.Title, desc)
135 }
136 }
137 sb.WriteString("\n")
138 }
139
140 // Removed elements
141 if cl.Elements.CountRemoved() > 0 {
142 fmt.Fprintf(&sb, "=== Removed (%d elements)\n", cl.Elements.CountRemoved())
143 for _, change := range cl.Elements.Removed {
144 if change.AsIs != nil {
145 desc := ""
146 if change.AsIs.Description != "" {
147 desc = fmt.Sprintf(": %s", change.AsIs.Description)
148 }
149 fmt.Fprintf(&sb, "* [line-through]#*%s* `[%s]` – %s#%s\n",
150 change.ID, change.AsIs.Kind, change.AsIs.Title, desc)
151 }
152 }
153 sb.WriteString("\n")
154 }
155
156 // Changed elements
157 if cl.Elements.CountChanged() > 0 {
158 fmt.Fprintf(&sb, "=== Changed (%d elements)\n", cl.Elements.CountChanged())
159 for _, change := range cl.Elements.Changed {
160 if change.AsIs != nil && change.ToBe != nil {
161 fmt.Fprintf(&sb, "* *%s* – ", change.ID)
162 changes := renderElementChanges(*change.AsIs, *change.ToBe)
163 sb.WriteString(changes)
164 sb.WriteString("\n")
165 }
166 }
167 sb.WriteString("\n")
168 }
169
170 // Added relationships
171 if cl.Relationships.CountAddedRelationships() > 0 {
172 fmt.Fprintf(&sb, "=== New Relationships (%d)\n", cl.Relationships.CountAddedRelationships())
173 for _, change := range cl.Relationships.Added {
174 label := ""
175 if change.ToBe != nil && change.ToBe.Label != "" {
176 label = fmt.Sprintf(" (%s)", change.ToBe.Label)
177 }
178 fmt.Fprintf(&sb, "* %s → %s%s\n", change.From, change.To, label)
179 }
180 sb.WriteString("\n")
181 }
182
183 // Removed relationships
184 if cl.Relationships.CountRemovedRelationships() > 0 {
185 fmt.Fprintf(&sb, "=== Removed Relationships (%d)\n", cl.Relationships.CountRemovedRelationships())
186 for _, change := range cl.Relationships.Removed {
187 label := ""
188 if change.AsIs != nil && change.AsIs.Label != "" {
189 label = fmt.Sprintf(" (%s)", change.AsIs.Label)
190 }
191 fmt.Fprintf(&sb, "* [line-through]#%s → %s#%s\n", change.From, change.To, label)
192 }
193 sb.WriteString("\n")
194 }
195
196 if cl.Elements.CountAdded() == 0 && cl.Elements.CountRemoved() == 0 &&
197 cl.Elements.CountChanged() == 0 && cl.Relationships.CountAddedRelationships() == 0 &&
198 cl.Relationships.CountRemovedRelationships() == 0 {
199 sb.WriteString("No architectural changes detected.\n")
200 }
201
202 return sb.String()
203 }
204
205 // RenderJSON renders the changelog as JSON
206 func RenderJSON(cl *Changelog) (string, error) {
207 data, err := json.MarshalIndent(cl, "", " ")
208 if err != nil {
209 return "", err
210 }
211 return string(data), nil
212 }
213
214 // renderElementChanges formats what changed in an element
215 func renderElementChanges(asIs, toBe model.Element) string {
216 var sb strings.Builder
217
218 if asIs.Title != toBe.Title {
219 fmt.Fprintf(&sb, "title: \"%s\" → \"%s\"; ", asIs.Title, toBe.Title)
220 }
221 if asIs.Kind != toBe.Kind {
222 fmt.Fprintf(&sb, "kind: \"%s\" → \"%s\"; ", asIs.Kind, toBe.Kind)
223 }
224 if asIs.Technology != toBe.Technology {
225 fmt.Fprintf(&sb, "technology: \"%s\" → \"%s\"; ", asIs.Technology, toBe.Technology)
226 }
227 if asIs.Description != toBe.Description {
228 sb.WriteString("description: changed; ")
229 }
230 if asIs.Status != toBe.Status {
231 fmt.Fprintf(&sb, "status: \"%s\" → \"%s\"; ", asIs.Status, toBe.Status)
232 }
233
234 result := sb.String()
235 return strings.TrimSuffix(result, "; ")
236 }
237
github.com/docToolchain/Bausteinsicht/internal/changelog/types.go 87.5%
1 package changelog
2
3 import (
4 "time"
5
6 "github.com/docToolchain/Bausteinsicht/internal/diff"
7 )
8
9 // Reference represents a git ref or snapshot identifier
10 type Reference struct {
11 Ref string `json:"ref"` // git tag/commit SHA or snapshot ID
12 Date time.Time `json:"date"` // date of the reference
13 }
14
15 // Changelog describes changes between two architecture snapshots
16 type Changelog struct {
17 From Reference `json:"from"`
18 To Reference `json:"to"`
19 Elements ElementChanges `json:"elements"`
20 Relationships RelationshipChanges `json:"relationships"`
21 }
22
23 // ElementChanges groups element changes by type
24 type ElementChanges struct {
25 Added []diff.ElementChange `json:"added"`
26 Removed []diff.ElementChange `json:"removed"`
27 Changed []diff.ElementChange `json:"changed"`
28 }
29
30 // RelationshipChanges groups relationship changes by type
31 type RelationshipChanges struct {
32 Added []diff.RelationshipChange `json:"added"`
33 Removed []diff.RelationshipChange `json:"removed"`
34 }
35
36 // CommitInfo retrieves commit metadata for a git ref
37 type CommitInfo struct {
38 Hash string `json:"hash"`
39 Author string `json:"author"`
40 Date time.Time `json:"date"`
41 Message string `json:"message"`
42 Timestamp int64 `json:"timestamp"`
43 }
44
45 // FilterChangesByKind returns only changes of a specific element kind
46 func (ec ElementChanges) FilterByKind(kind string) ElementChanges {
47 return ElementChanges{
48 Added: filterElementsByKind(ec.Added, kind),
49 Removed: filterElementsByKind(ec.Removed, kind),
50 Changed: filterElementsByKind(ec.Changed, kind),
51 }
52 }
53
54 func filterElementsByKind(changes []diff.ElementChange, kind string) []diff.ElementChange {
55 var result []diff.ElementChange
56 for _, c := range changes {
57 var k string
58 if c.ToBe != nil {
59 k = c.ToBe.Kind
60 } else if c.AsIs != nil {
61 k = c.AsIs.Kind
62 }
63 if k == kind {
64 result = append(result, c)
65 }
66 }
67 return result
68 }
69
70 // CountAdded returns the number of added elements
71 func (ec ElementChanges) CountAdded() int {
72 return len(ec.Added)
73 }
74
75 // CountRemoved returns the number of removed elements
76 func (ec ElementChanges) CountRemoved() int {
77 return len(ec.Removed)
78 }
79
80 // CountChanged returns the number of changed elements
81 func (ec ElementChanges) CountChanged() int {
82 return len(ec.Changed)
83 }
84
85 // CountAddedRelationships returns the number of added relationships
86 func (rc RelationshipChanges) CountAddedRelationships() int {
87 return len(rc.Added)
88 }
89
90 // CountRemovedRelationships returns the number of removed relationships
91 func (rc RelationshipChanges) CountRemovedRelationships() int {
92 return len(rc.Removed)
93 }
94
github.com/docToolchain/Bausteinsicht/internal/chaos/chaos.go 0.0%
1 package chaos
2
3 import (
4 "os"
5 "path/filepath"
6 "testing"
7 )
8
9 type TestChaos struct {
10 t *testing.T
11 tmpDir string
12 }
13
14 // NewTestChaos creates a chaos injection helper for tests.
15 func NewTestChaos(t *testing.T) *TestChaos {
16 tmpDir := t.TempDir()
17 return &TestChaos{
18 t: t,
19 tmpDir: tmpDir,
20 }
21 }
22
23 // TmpDir returns the temporary directory for this test.
24 func (tc *TestChaos) TmpDir() string {
25 return tc.tmpDir
26 }
27
28 // CorruptFile truncates a file (simulating partial write).
29 func (tc *TestChaos) CorruptFile(path string) {
30 f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
31 if err != nil {
32 tc.t.Fatalf("CorruptFile: %v", err)
33 }
34 defer f.Close() //nolint:errcheck
35 if _, err := f.WriteString(""); err != nil {
36 tc.t.Fatalf("CorruptFile truncate: %v", err)
37 }
38 }
39
40 // CorruptFilePartial truncates file to partial content.
41 func (tc *TestChaos) CorruptFilePartial(path string, content string) {
42 f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
43 if err != nil {
44 tc.t.Fatalf("CorruptFilePartial: %v", err)
45 }
46 defer f.Close() //nolint:errcheck
47 if _, err := f.WriteString(content); err != nil {
48 tc.t.Fatalf("CorruptFilePartial write: %v", err)
49 }
50 }
51
52 // MakeReadOnly sets file permissions to read-only.
53 func (tc *TestChaos) MakeReadOnly(path string) {
54 if err := os.Chmod(path, 0444); err != nil {
55 tc.t.Fatalf("MakeReadOnly: %v", err)
56 }
57 }
58
59 // MakeWritable sets file permissions to writable.
60 func (tc *TestChaos) MakeWritable(path string) {
61 if err := os.Chmod(path, 0644); err != nil {
62 tc.t.Fatalf("MakeWritable: %v", err)
63 }
64 }
65
66 // DeleteFile removes a file.
67 func (tc *TestChaos) DeleteFile(path string) {
68 if err := os.Remove(path); err != nil {
69 tc.t.Fatalf("DeleteFile: %v", err)
70 }
71 }
72
73 // CreateEmptyFile creates an empty file at path.
74 func (tc *TestChaos) CreateEmptyFile(path string) string {
75 absPath := filepath.Join(tc.tmpDir, path)
76 if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
77 tc.t.Fatalf("CreateEmptyFile mkdir: %v", err)
78 }
79 f, err := os.Create(absPath)
80 if err != nil {
81 tc.t.Fatalf("CreateEmptyFile: %v", err)
82 }
83 defer f.Close() //nolint:errcheck
84 return absPath
85 }
86
87 // CreateFileWithContent creates a file with specific content.
88 func (tc *TestChaos) CreateFileWithContent(path string, content string) string {
89 absPath := filepath.Join(tc.tmpDir, path)
90 if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
91 tc.t.Fatalf("CreateFileWithContent mkdir: %v", err)
92 }
93 if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
94 tc.t.Fatalf("CreateFileWithContent: %v", err)
95 }
96 return absPath
97 }
98
99 // FileExists checks if a file exists.
100 func (tc *TestChaos) FileExists(path string) bool {
101 _, err := os.Stat(path)
102 return err == nil
103 }
104
105 // ReadFile reads a file's content.
106 func (tc *TestChaos) ReadFile(path string) string {
107 content, err := os.ReadFile(path)
108 if err != nil {
109 tc.t.Fatalf("ReadFile: %v", err)
110 }
111 return string(content)
112 }
113
github.com/docToolchain/Bausteinsicht/internal/constraints/engine.go 100.0%
1 package constraints
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // ErrUnknownRule is returned when a constraint specifies an unsupported rule type.
10 type ErrUnknownRule struct {
11 Rule string
12 }
13
14 func (e ErrUnknownRule) Error() string {
15 return fmt.Sprintf("unknown constraint rule %q", e.Rule)
16 }
17
18 // Evaluate runs all constraints against the model and returns the aggregated result.
19 // Unknown rule types are reported as violations so they don't silently pass.
20 func Evaluate(m *model.BausteinsichtModel) Result {
21 var all []Violation
22 for _, c := range m.Constraints {
23 vs, err := evaluate(c, m)
24 if err != nil {
25 all = append(all, Violation{
26 ConstraintID: c.ID,
27 Message: err.Error(),
28 })
29 continue
30 }
31 all = append(all, vs...)
32 }
33 return Result{Violations: all, Total: len(all)}
34 }
35
36 func evaluate(c model.Constraint, m *model.BausteinsichtModel) ([]Violation, error) {
37 switch c.Rule {
38 case "no-relationship":
39 return noRelationship(c, m), nil
40 case "allowed-relationship":
41 return allowedRelationship(c, m), nil
42 case "required-field":
43 return requiredField(c, m), nil
44 case "max-depth":
45 return maxDepth(c, m), nil
46 case "no-circular-dependency":
47 return noCircularDependency(c, m), nil
48 case "technology-allowed":
49 return technologyAllowed(c, m), nil
50 default:
51 return nil, ErrUnknownRule{Rule: c.Rule}
52 }
53 }
54
github.com/docToolchain/Bausteinsicht/internal/constraints/rules.go 92.5%
1 package constraints
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // noRelationship enforces that no relationship exists from any element of
11 // fromKind to any element of toKind.
12 func noRelationship(c model.Constraint, m *model.BausteinsichtModel) []Violation {
13 flat, err := model.FlattenElements(m)
14 if err != nil {
15 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
16 }
17 kindOf := buildKindMap(flat)
18
19 var bad []string
20 for _, rel := range m.Relationships {
21 if kindOf[rel.From] == c.FromKind && kindOf[rel.To] == c.ToKind {
22 bad = append(bad, fmt.Sprintf("%s → %s", rel.From, rel.To))
23 }
24 }
25 if len(bad) == 0 {
26 return nil
27 }
28 return []Violation{{
29 ConstraintID: c.ID,
30 Message: fmt.Sprintf("%s: %s kind must not relate to %s kind", c.Description, c.FromKind, c.ToKind),
31 Elements: bad,
32 }}
33 }
34
35 // allowedRelationship enforces that only elements whose kind is in fromKinds
36 // may have relationships pointing to elements of toKind.
37 func allowedRelationship(c model.Constraint, m *model.BausteinsichtModel) []Violation {
38 flat, err := model.FlattenElements(m)
39 if err != nil {
40 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
41 }
42 kindOf := buildKindMap(flat)
43
44 allowed := make(map[string]bool, len(c.FromKinds))
45 for _, k := range c.FromKinds {
46 allowed[k] = true
47 }
48
49 var bad []string
50 for _, rel := range m.Relationships {
51 if kindOf[rel.To] == c.ToKind && !allowed[kindOf[rel.From]] {
52 bad = append(bad, fmt.Sprintf("%s (%s) → %s", rel.From, kindOf[rel.From], rel.To))
53 }
54 }
55 if len(bad) == 0 {
56 return nil
57 }
58 return []Violation{{
59 ConstraintID: c.ID,
60 Message: fmt.Sprintf("%s: only [%s] may relate to %s kind", c.Description, strings.Join(c.FromKinds, ", "), c.ToKind),
61 Elements: bad,
62 }}
63 }
64
65 // requiredField enforces that all elements of elementKind have the given field
66 // set to a non-empty value. Supported fields: "description", "technology", "title".
67 func requiredField(c model.Constraint, m *model.BausteinsichtModel) []Violation {
68 flat, err := model.FlattenElements(m)
69 if err != nil {
70 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
71 }
72
73 var bad []string
74 for id, el := range flat {
75 if el.Kind != c.ElementKind {
76 continue
77 }
78 var missing bool
79 switch c.Field {
80 case "description":
81 missing = el.Description == ""
82 case "technology":
83 missing = el.Technology == ""
84 case "title":
85 missing = el.Title == ""
86 default:
87 // Unsupported field name — return error violation immediately
88 return []Violation{{
89 ConstraintID: c.ID,
90 Message: fmt.Sprintf("%s: unsupported field %q (valid: description, technology, title)", c.Description, c.Field),
91 }}
92 }
93 if missing {
94 bad = append(bad, fmt.Sprintf("%s: missing %s", id, c.Field))
95 }
96 }
97 if len(bad) == 0 {
98 return nil
99 }
100 return []Violation{{
101 ConstraintID: c.ID,
102 Message: fmt.Sprintf("%s: all %s elements must have %q set", c.Description, c.ElementKind, c.Field),
103 Elements: bad,
104 }}
105 }
106
107 // maxDepth enforces that no element is nested deeper than max levels.
108 // Root-level elements have depth 1.
109 func maxDepth(c model.Constraint, m *model.BausteinsichtModel) []Violation {
110 var bad []string
111 walkDepth(m.Model, 1, c.Max, &bad)
112 if len(bad) == 0 {
113 return nil
114 }
115 return []Violation{{
116 ConstraintID: c.ID,
117 Message: fmt.Sprintf("%s: maximum nesting depth is %d", c.Description, c.Max),
118 Elements: bad,
119 }}
120 }
121
122 func walkDepth(elements map[string]model.Element, depth, max int, bad *[]string) {
123 for id, el := range elements {
124 if depth > max {
125 *bad = append(*bad, fmt.Sprintf("%s (depth %d)", id, depth))
126 }
127 if len(el.Children) > 0 {
128 walkDepth(el.Children, depth+1, max, bad)
129 }
130 }
131 }
132
133 // noCircularDependency detects cycles in the relationship graph using DFS.
134 func noCircularDependency(c model.Constraint, m *model.BausteinsichtModel) []Violation {
135 // Build adjacency list.
136 adj := make(map[string][]string)
137 flat, err := model.FlattenElements(m)
138 if err != nil {
139 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
140 }
141 for id := range flat {
142 adj[id] = nil
143 }
144 for _, rel := range m.Relationships {
145 adj[rel.From] = append(adj[rel.From], rel.To)
146 }
147
148 visited := make(map[string]bool)
149 inStack := make(map[string]bool)
150 var cycles []string
151
152 var dfs func(node string, path []string)
153 dfs = func(node string, path []string) {
154 visited[node] = true
155 inStack[node] = true
156 path = append(path, node)
157
158 for _, neighbour := range adj[node] {
159 if !visited[neighbour] {
160 dfs(neighbour, path)
161 } else if inStack[neighbour] {
162 // Found a cycle — record the loop segment.
163 for i, n := range path {
164 if n == neighbour {
165 cycle := strings.Join(append(path[i:], neighbour), " → ")
166 cycles = append(cycles, cycle)
167 break
168 }
169 }
170 }
171 }
172 inStack[node] = false
173 }
174
175 for node := range adj {
176 if !visited[node] {
177 dfs(node, nil)
178 }
179 }
180
181 if len(cycles) == 0 {
182 return nil
183 }
184 return []Violation{{
185 ConstraintID: c.ID,
186 Message: c.Description + ": circular dependencies detected",
187 Elements: cycles,
188 }}
189 }
190
191 // technologyAllowed enforces that elements of elementKind only use technologies
192 // from the given allowed list.
193 func technologyAllowed(c model.Constraint, m *model.BausteinsichtModel) []Violation {
194 flat, err := model.FlattenElements(m)
195 if err != nil {
196 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
197 }
198 allowed := make(map[string]bool, len(c.Technologies))
199 for _, t := range c.Technologies {
200 allowed[strings.ToLower(t)] = true
201 }
202
203 var bad []string
204 for id, el := range flat {
205 if el.Kind != c.ElementKind {
206 continue
207 }
208 if el.Technology == "" {
209 continue // technology not set — use required-field rule to enforce that separately
210 }
211 if !allowed[strings.ToLower(el.Technology)] {
212 bad = append(bad, fmt.Sprintf("%s: technology %q not in allowed list [%s]",
213 id, el.Technology, strings.Join(c.Technologies, ", ")))
214 }
215 }
216 if len(bad) == 0 {
217 return nil
218 }
219 return []Violation{{
220 ConstraintID: c.ID,
221 Message: fmt.Sprintf("%s: %s elements must use one of [%s]", c.Description, c.ElementKind, strings.Join(c.Technologies, ", ")),
222 Elements: bad,
223 }}
224 }
225
226 // buildKindMap returns a map from element ID to its kind for all flattened elements.
227 func buildKindMap(flat map[string]*model.Element) map[string]string {
228 m := make(map[string]string, len(flat))
229 for id, el := range flat {
230 m[id] = el.Kind
231 }
232 return m
233 }
234
github.com/docToolchain/Bausteinsicht/internal/diagram/colors.go 100.0%
1 package diagram
2
3 // KindStyle defines fill and stroke colors for element kinds.
4 type KindStyle struct {
5 Fill string
6 Stroke string
7 }
8
9 // DefaultKindColors maps element kinds to consistent visual styles.
10 // Used by all diagram renderers (PlantUML, Mermaid, DOT, D2, HTML5).
11 var DefaultKindColors = map[string]KindStyle{
12 "actor": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
13 "person": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
14 "system": {Fill: "#f5f5f5", Stroke: "#666666"},
15 "external_system": {Fill: "#e1d5e7", Stroke: "#9673a6"},
16 "container": {Fill: "#d5e8d4", Stroke: "#82b366"},
17 "ui": {Fill: "#d5e8d4", Stroke: "#82b366"},
18 "mobile": {Fill: "#d5e8d4", Stroke: "#82b366"},
19 "datastore": {Fill: "#fff2cc", Stroke: "#d6b656"},
20 "database": {Fill: "#fff2cc", Stroke: "#d6b656"},
21 "queue": {Fill: "#ffe6cc", Stroke: "#d5a74e"},
22 "filestore": {Fill: "#fff2cc", Stroke: "#d6b656"},
23 "component": {Fill: "#d5e8d4", Stroke: "#82b366"},
24 }
25
26 // ColorForKind returns the style for a given element kind.
27 // Falls back to a default gray color if the kind is not defined.
28 func ColorForKind(kind string) KindStyle {
29 if style, ok := DefaultKindColors[kind]; ok {
30 return style
31 }
32 return KindStyle{Fill: "#f5f5f5", Stroke: "#666666"}
33 }
34
github.com/docToolchain/Bausteinsicht/internal/diagram/d2.go 89.1%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderD2 renders a view as a D2 diagram.
12 func RenderD2(m *model.BausteinsichtModel, viewKey string) (string, error) {
13 view, ok := m.Views[viewKey]
14 if !ok {
15 return "", fmt.Errorf("view %q not found", viewKey)
16 }
17
18 resolved, err := model.ResolveView(m, &view)
19 if err != nil {
20 return "", err
21 }
22
23 flat, _ := model.FlattenElements(m)
24 sort.Strings(resolved)
25
26 // Filter elements visible in this view
27 elemSet := make(map[string]bool, len(resolved))
28 for _, id := range resolved {
29 elemSet[id] = true
30 }
31 if view.Scope != "" {
32 elemSet[view.Scope] = true
33 }
34
35 // Filter relationships
36 rels := filterRelationships(m.Relationships, elemSet)
37
38 var b strings.Builder
39 b.WriteString("direction: right\n\n")
40
41 // Write nodes
42 for _, id := range resolved {
43 elem := flat[id]
44 if elem == nil {
45 continue
46 }
47
48 style := ColorForKind(elem.Kind)
49 nodeID := sanitizeD2ID(id)
50
51 title := elem.Title
52 if title == "" {
53 title = id
54 }
55
56 // Node with styling
57 nodeLabel := escapeD2String(title)
58 if elem.Kind != "" {
59 nodeLabel = fmt.Sprintf("%s [%s]", escapeD2String(title), elem.Kind)
60 }
61 fmt.Fprintf(&b, "%s: %s {\n", nodeID, nodeLabel)
62 fmt.Fprintf(&b, " shape: rectangle\n")
63 fmt.Fprintf(&b, " style.fill: \"%s\"\n", style.Fill)
64 fmt.Fprintf(&b, " style.stroke: \"%s\"\n", style.Stroke)
65 if elem.Description != "" {
66 fmt.Fprintf(&b, " note: %s\n", escapeD2String(elem.Description))
67 }
68 b.WriteString("}\n\n")
69 }
70
71 // Write relationships
72 for _, r := range rels {
73 fromID := sanitizeD2ID(r.From)
74 toID := sanitizeD2ID(r.To)
75 if r.Label != "" {
76 fmt.Fprintf(&b, "%s -> %s: %s\n", fromID, toID, escapeD2String(r.Label))
77 } else {
78 fmt.Fprintf(&b, "%s -> %s\n", fromID, toID)
79 }
80 }
81
82 return b.String(), nil
83 }
84
85 // sanitizeD2ID converts a dot-notation ID to a valid D2 identifier.
86 func sanitizeD2ID(id string) string {
87 s := strings.ReplaceAll(id, ".", "_")
88 s = strings.ReplaceAll(s, "-", "_")
89 return s
90 }
91
92 // escapeD2String escapes a string for use in D2 string literals.
93 func escapeD2String(s string) string {
94 return "\"" + strings.ReplaceAll(s, "\"", "\\\"") + "\""
95 }
96
github.com/docToolchain/Bausteinsicht/internal/diagram/diagram.go 90.7%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // Format represents the output diagram format.
12 type Format int
13
14 const (
15 PlantUML Format = iota
16 Mermaid
17 )
18
19 // C4-PlantUML is part of the PlantUML stdlib since v2.x, so we use
20 // the <C4/...> include syntax which resolves locally without network access.
21
22 // applyTagFiltering filters resolved element IDs based on tag criteria.
23 // Elements must have ALL filterTags (intersection) and must not have ANY excludeTags (union).
24 func applyTagFiltering(resolved []string, flat map[string]*model.Element, filterTags, excludeTags []string) []string {
25 if len(filterTags) == 0 && len(excludeTags) == 0 {
26 return resolved
27 }
28
29 var result []string
30 for _, id := range resolved {
31 elem := flat[id]
32 if elem == nil {
33 // Element not found in flat map, skip it (shouldn't happen)
34 continue
35 }
36
37 // Check exclude tags: if ANY exclude-tag matches, skip
38 excluded := false
39 for _, excludeTag := range excludeTags {
40 for _, elemTag := range elem.Tags {
41 if elemTag == excludeTag {
42 excluded = true
43 break
44 }
45 }
46 if excluded {
47 break
48 }
49 }
50 if excluded {
51 continue
52 }
53
54 // Check filter tags: if ANY filter-tags are specified, element must have ALL of them
55 if len(filterTags) > 0 {
56 hasAllFilterTags := true
57 for _, filterTag := range filterTags {
58 found := false
59 for _, elemTag := range elem.Tags {
60 if elemTag == filterTag {
61 found = true
62 break
63 }
64 }
65 if !found {
66 hasAllFilterTags = false
67 break
68 }
69 }
70 if !hasAllFilterTags {
71 continue
72 }
73 }
74
75 result = append(result, id)
76 }
77
78 return result
79 }
80
81 // FormatView renders a view as a C4 diagram in the given format.
82 func FormatView(m *model.BausteinsichtModel, viewKey string, f Format) (string, error) {
83 view, ok := m.Views[viewKey]
84 if !ok {
85 return "", fmt.Errorf("view %q not found", viewKey)
86 }
87
88 resolved, err := model.ResolveView(m, &view)
89 if err != nil {
90 return "", err
91 }
92
93 flat, _ := model.FlattenElements(m)
94
95 // Apply tag-based filtering if specified in the view.
96 resolved = applyTagFiltering(resolved, flat, view.FilterTags, view.ExcludeTags)
97 sort.Strings(resolved)
98
99 // Determine C4 level from view content.
100 level := detectLevel(resolved, flat, view.Scope)
101
102 // Separate scope-internal elements from external ones.
103 scopeElems, externalElems := partitionElements(resolved, flat, view.Scope)
104
105 // Filter relationships to those visible in this view.
106 elemSet := make(map[string]bool, len(resolved))
107 for _, id := range resolved {
108 elemSet[id] = true
109 }
110 if view.Scope != "" {
111 elemSet[view.Scope] = true
112 }
113 rels := filterRelationships(m.Relationships, elemSet)
114
115 var b strings.Builder
116 switch f {
117 case PlantUML:
118 writePlantUML(&b, view, level, scopeElems, externalElems, rels, flat)
119 case Mermaid:
120 writeMermaid(&b, view, level, scopeElems, externalElems, rels, flat)
121 }
122 return b.String(), nil
123 }
124
125 type elemEntry struct {
126 ID string
127 Elem *model.Element
128 }
129
130 func detectLevel(resolved []string, flat map[string]*model.Element, scope string) string {
131 hasContainer := false
132 for _, id := range resolved {
133 elem := flat[id]
134 if elem == nil {
135 continue
136 }
137 if elem.Kind == "component" {
138 return "Component"
139 }
140 if elem.Kind == "container" {
141 hasContainer = true
142 }
143 }
144 if hasContainer || scope != "" {
145 return "Container"
146 }
147 return "Context"
148 }
149
150 func partitionElements(resolved []string, flat map[string]*model.Element, scope string) (inside, outside []elemEntry) {
151 for _, id := range resolved {
152 elem := flat[id]
153 if elem == nil {
154 continue
155 }
156 if scope != "" && strings.HasPrefix(id, scope+".") {
157 inside = append(inside, elemEntry{id, elem})
158 } else {
159 outside = append(outside, elemEntry{id, elem})
160 }
161 }
162 return
163 }
164
165 type relEntry struct {
166 From string `json:"from"`
167 To string `json:"to"`
168 Label string `json:"label,omitempty"`
169 }
170
171 func filterRelationships(rels []model.Relationship, elemSet map[string]bool) []relEntry {
172 var result []relEntry
173 seen := make(map[string]bool)
174 for _, r := range rels {
175 from := liftToVisible(r.From, elemSet)
176 to := liftToVisible(r.To, elemSet)
177 if from == "" || to == "" || from == to {
178 continue
179 }
180 key := from + ":" + to
181 if seen[key] {
182 continue
183 }
184 seen[key] = true
185 result = append(result, relEntry{from, to, r.Label})
186 }
187 return result
188 }
189
190 func liftToVisible(id string, elemSet map[string]bool) string {
191 if elemSet[id] {
192 return id
193 }
194 for {
195 dot := strings.LastIndex(id, ".")
196 if dot < 0 {
197 return ""
198 }
199 id = id[:dot]
200 if elemSet[id] {
201 return id
202 }
203 }
204 }
205
206 func c4Macro(kind string) string {
207 switch kind {
208 case "actor":
209 return "Person"
210 case "system":
211 return "System"
212 case "external_system":
213 return "System_Ext"
214 case "container", "ui", "mobile":
215 return "Container"
216 case "datastore":
217 return "ContainerDb"
218 case "queue":
219 return "ContainerQueue"
220 case "filestore":
221 return "Container"
222 case "component":
223 return "Component"
224 default:
225 return "System"
226 }
227 }
228
229 func sanitizeID(id string) string {
230 return strings.ReplaceAll(strings.ReplaceAll(id, ".", "_"), "-", "_")
231 }
232
233 func escapeQuotes(s string) string {
234 return strings.ReplaceAll(s, "\"", "'")
235 }
236
237 // --- PlantUML ---
238
239 func writePlantUML(b *strings.Builder, view model.View, level string, inside, outside []elemEntry, rels []relEntry, flat map[string]*model.Element) {
240 b.WriteString("@startuml\n")
241 fmt.Fprintf(b, "!include <C4/C4_%s>\n\n", level)
242
243 // External elements (outside scope boundary).
244 for _, e := range outside {
245 writePlantUMLElement(b, e, "")
246 }
247
248 // Scope boundary with internal elements.
249 if view.Scope != "" {
250 scopeElem := flat[view.Scope]
251 scopeTitle := view.Scope
252 if scopeElem != nil {
253 scopeTitle = scopeElem.Title
254 }
255 boundaryMacro := "System_Boundary"
256 if scopeElem != nil && scopeElem.Kind == "container" {
257 boundaryMacro = "Container_Boundary"
258 }
259 fmt.Fprintf(b, "%s(%s, \"%s\") {\n", boundaryMacro, sanitizeID(view.Scope), escapeQuotes(scopeTitle))
260 for _, e := range inside {
261 writePlantUMLElement(b, e, " ")
262 }
263 b.WriteString("}\n")
264 } else {
265 for _, e := range inside {
266 writePlantUMLElement(b, e, "")
267 }
268 }
269
270 // Relationships.
271 if len(rels) > 0 {
272 b.WriteString("\n")
273 }
274 for _, r := range rels {
275 fmt.Fprintf(b, "Rel(%s, %s, \"%s\")\n", sanitizeID(r.From), sanitizeID(r.To), escapeQuotes(r.Label))
276 }
277
278 b.WriteString("@enduml\n")
279 }
280
281 func writePlantUMLElement(b *strings.Builder, e elemEntry, indent string) {
282 macro := c4Macro(e.Elem.Kind)
283 if e.Elem.Technology != "" {
284 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\", \"%s\")\n",
285 indent, macro, sanitizeID(e.ID),
286 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Technology), escapeQuotes(e.Elem.Description))
287 } else {
288 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\")\n",
289 indent, macro, sanitizeID(e.ID),
290 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Description))
291 }
292 }
293
294 // --- Mermaid ---
295
296 func writeMermaid(b *strings.Builder, view model.View, level string, inside, outside []elemEntry, rels []relEntry, flat map[string]*model.Element) {
297 fmt.Fprintf(b, "C4%s\n", level)
298 fmt.Fprintf(b, " title %s\n\n", view.Title)
299
300 for _, e := range outside {
301 writeMermaidElement(b, e, " ")
302 }
303
304 if view.Scope != "" {
305 scopeElem := flat[view.Scope]
306 scopeTitle := view.Scope
307 if scopeElem != nil {
308 scopeTitle = scopeElem.Title
309 }
310 boundaryMacro := "System_Boundary"
311 if scopeElem != nil && scopeElem.Kind == "container" {
312 boundaryMacro = "Container_Boundary"
313 }
314 fmt.Fprintf(b, " %s(%s, \"%s\") {\n", boundaryMacro, sanitizeID(view.Scope), escapeQuotes(scopeTitle))
315 for _, e := range inside {
316 writeMermaidElement(b, e, " ")
317 }
318 b.WriteString(" }\n")
319 } else {
320 for _, e := range inside {
321 writeMermaidElement(b, e, " ")
322 }
323 }
324
325 if len(rels) > 0 {
326 b.WriteString("\n")
327 }
328 for _, r := range rels {
329 fmt.Fprintf(b, " Rel(%s, %s, \"%s\")\n", sanitizeID(r.From), sanitizeID(r.To), escapeQuotes(r.Label))
330 }
331 }
332
333 func writeMermaidElement(b *strings.Builder, e elemEntry, indent string) {
334 macro := c4Macro(e.Elem.Kind)
335 if e.Elem.Technology != "" {
336 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\", \"%s\")\n",
337 indent, macro, sanitizeID(e.ID),
338 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Technology), escapeQuotes(e.Elem.Description))
339 } else {
340 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\")\n",
341 indent, macro, sanitizeID(e.ID),
342 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Description))
343 }
344 }
345
346 // ExportAllViewsToMermaid exports all views from the model as Mermaid diagrams.
347 // Returns a slice of view keys in order and a map of view key → Mermaid diagram code.
348 func ExportAllViewsToMermaid(m *model.BausteinsichtModel) ([]string, map[string]string, error) {
349 diagrams := make(map[string]string)
350 var viewKeys []string
351
352 for viewKey := range m.Views {
353 viewKeys = append(viewKeys, viewKey)
354 }
355 sort.Strings(viewKeys)
356
357 for _, viewKey := range viewKeys {
358 diagramCode, err := FormatView(m, viewKey, Mermaid)
359 if err != nil {
360 return nil, nil, err
361 }
362 diagrams[viewKey] = diagramCode
363 }
364
365 return viewKeys, diagrams, nil
366 }
367
github.com/docToolchain/Bausteinsicht/internal/diagram/dot.go 87.8%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderDOT renders a view as a GraphViz DOT graph.
12 func RenderDOT(m *model.BausteinsichtModel, viewKey string) (string, error) {
13 view, ok := m.Views[viewKey]
14 if !ok {
15 return "", fmt.Errorf("view %q not found", viewKey)
16 }
17
18 resolved, err := model.ResolveView(m, &view)
19 if err != nil {
20 return "", err
21 }
22
23 flat, _ := model.FlattenElements(m)
24 sort.Strings(resolved)
25
26 // Filter elements visible in this view
27 elemSet := make(map[string]bool, len(resolved))
28 for _, id := range resolved {
29 elemSet[id] = true
30 }
31 if view.Scope != "" {
32 elemSet[view.Scope] = true
33 }
34
35 // Filter relationships
36 rels := filterRelationships(m.Relationships, elemSet)
37
38 var b strings.Builder
39 b.WriteString("digraph \"" + escapeQuotes(view.Title) + "\" {\n")
40 b.WriteString(" rankdir=LR\n")
41 b.WriteString(" node [shape=box style=filled fontname=\"Arial\" fontsize=9]\n")
42 b.WriteString(" edge [fontsize=8]\n\n")
43
44 // Write nodes
45 for _, id := range resolved {
46 elem := flat[id]
47 if elem == nil {
48 continue
49 }
50
51 style := ColorForKind(elem.Kind)
52 nodeID := sanitizeID(id)
53
54 label := elem.Title
55 if elem.Title == "" {
56 label = id
57 }
58 if elem.Kind != "" {
59 label = label + "\n[" + elem.Kind + "]"
60 }
61
62 fmt.Fprintf(&b, " %s [label=\"%s\" fillcolor=\"%s\" color=\"%s\"]\n",
63 nodeID, escapeQuotes(label), style.Fill, style.Stroke)
64 }
65
66 // Write relationships
67 if len(rels) > 0 {
68 b.WriteString("\n")
69 for _, r := range rels {
70 fromID := sanitizeID(r.From)
71 toID := sanitizeID(r.To)
72 if r.Label != "" {
73 fmt.Fprintf(&b, " %s -> %s [label=\"%s\"]\n", fromID, toID, escapeQuotes(r.Label))
74 } else {
75 fmt.Fprintf(&b, " %s -> %s\n", fromID, toID)
76 }
77 }
78 }
79
80 b.WriteString("}\n")
81 return b.String(), nil
82 }
83
github.com/docToolchain/Bausteinsicht/internal/diagram/html.go 83.8%
1 package diagram
2
3 import (
4 "encoding/json"
5 "fmt"
6 "html"
7 "sort"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // HTMLNode represents a node in the interactive diagram.
13 type HTMLNode struct {
14 ID string `json:"id"`
15 Title string `json:"title"`
16 Kind string `json:"kind"`
17 Description string `json:"description,omitempty"`
18 Technology string `json:"technology,omitempty"`
19 X float64 `json:"x"`
20 Y float64 `json:"y"`
21 Fill string `json:"fill"`
22 Stroke string `json:"stroke"`
23 }
24
25 // HTMLEdge is a type alias for relEntry used in HTML diagram output.
26 type HTMLEdge = relEntry
27
28 // HTMLDiagramData is the data structure embedded in the HTML output.
29 type HTMLDiagramData struct {
30 Title string `json:"title"`
31 Nodes []HTMLNode `json:"nodes"`
32 Edges []HTMLEdge `json:"edges"`
33 }
34
35 // RenderHTML renders a view as an interactive HTML5 diagram.
36 func RenderHTML(m *model.BausteinsichtModel, viewKey string) (string, error) {
37 view, ok := m.Views[viewKey]
38 if !ok {
39 return "", fmt.Errorf("view %q not found", viewKey)
40 }
41
42 resolved, err := model.ResolveView(m, &view)
43 if err != nil {
44 return "", err
45 }
46
47 flat, _ := model.FlattenElements(m)
48 sort.Strings(resolved)
49
50 // Filter elements visible in this view
51 elemSet := make(map[string]bool, len(resolved))
52 for _, id := range resolved {
53 elemSet[id] = true
54 }
55 if view.Scope != "" {
56 elemSet[view.Scope] = true
57 }
58
59 // Filter relationships
60 rels := filterRelationships(m.Relationships, elemSet)
61
62 // Build node list with simple grid layout
63 nodes := []HTMLNode{}
64 x, y := 50.0, 50.0
65 for _, id := range resolved {
66 elem := flat[id]
67 if elem == nil {
68 continue
69 }
70
71 style := ColorForKind(elem.Kind)
72 title := elem.Title
73 if title == "" {
74 title = id
75 }
76
77 nodes = append(nodes, HTMLNode{
78 ID: id,
79 Title: title,
80 Kind: elem.Kind,
81 Description: elem.Description,
82 Technology: elem.Technology,
83 X: x,
84 Y: y,
85 Fill: style.Fill,
86 Stroke: style.Stroke,
87 })
88
89 x += 200
90 if x > 800 {
91 x = 50
92 y += 150
93 }
94 }
95
96 // Build edge list from relationships
97 edges := make([]HTMLEdge, len(rels))
98 for i, r := range rels {
99 edges[i] = HTMLEdge(r)
100 }
101
102 // Create diagram data
103 data := HTMLDiagramData{
104 Title: view.Title,
105 Nodes: nodes,
106 Edges: edges,
107 }
108
109 dataJSON, _ := json.Marshal(data)
110
111 // Generate HTML with embedded JavaScript renderer (escape title for HTML safety)
112 htmlContent := generateHTMLTemplate(html.EscapeString(view.Title), string(dataJSON))
113 return htmlContent, nil
114 }
115
116 func generateHTMLTemplate(title, dataJSON string) string {
117 return fmt.Sprintf(`<!DOCTYPE html>
118 <html lang="en">
119 <head>
120 <meta charset="UTF-8">
121 <meta name="viewport" content="width=device-width, initial-scale=1.0">
122 <title>Architecture — %s</title>
123 <style>
124 * { margin: 0; padding: 0; box-sizing: border-box; }
125 body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; }
126
127 #header {
128 background: white;
129 padding: 16px;
130 border-bottom: 1px solid #ddd;
131 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
132 }
133
134 #header h1 { font-size: 20px; font-weight: 600; color: #333; }
135
136 #controls {
137 display: flex;
138 gap: 12px;
139 margin-top: 12px;
140 align-items: center;
141 }
142
143 input[type="text"] {
144 flex: 1;
145 max-width: 300px;
146 padding: 8px 12px;
147 border: 1px solid #ddd;
148 border-radius: 4px;
149 font-size: 14px;
150 }
151
152 button {
153 padding: 8px 16px;
154 background: #0066cc;
155 color: white;
156 border: none;
157 border-radius: 4px;
158 font-size: 14px;
159 cursor: pointer;
160 }
161
162 button:hover { background: #0052a3; }
163
164 #canvas {
165 flex: 1;
166 background: white;
167 position: relative;
168 overflow: auto;
169 }
170
171 svg { display: block; }
172
173 #details {
174 width: 300px;
175 background: white;
176 border-left: 1px solid #ddd;
177 padding: 16px;
178 overflow-y: auto;
179 display: none;
180 }
181
182 #details.show { display: block; }
183
184 #details h3 { font-size: 16px; font-weight: 600; color: #333; margin-bottom: 12px; }
185 #details p { margin: 8px 0; font-size: 13px; color: #666; word-break: break-word; }
186 #details strong { color: #333; }
187
188 .grid { display: flex; }
189 #canvas { flex: 1; }
190
191 .node { cursor: pointer; transition: opacity 0.2s; }
192 .node:hover { opacity: 0.8; }
193 .node.highlighted { filter: drop-shadow(0 0 4px #0066cc); }
194 .node.faded { opacity: 0.3; }
195
196 .edge { stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
197 .edge.highlighted { stroke: #0066cc; stroke-width: 3; }
198 .edge.faded { opacity: 0.2; }
199
200 .edge-label { font-size: 12px; fill: #333; pointer-events: none; }
201 </style>
202 </head>
203 <body>
204 <div id="header">
205 <h1>Architecture Diagram</h1>
206 <div id="controls">
207 <input type="text" id="searchInput" placeholder="Search elements..." />
208 <button onclick="resetZoom()">Reset View</button>
209 </div>
210 </div>
211
212 <div class="grid">
213 <div id="canvas"></div>
214 <div id="details">
215 <h3 id="detailsTitle"></h3>
216 <p><strong>Kind:</strong> <span id="detailsKind"></span></p>
217 <p id="detailsTech" style="display:none;"><strong>Technology:</strong> <span id="detailsTechVal"></span></p>
218 <p id="detailsDesc" style="display:none;"><strong>Description:</strong> <span id="detailsDescVal"></span></p>
219 </div>
220 </div>
221
222 <script>
223 const DIAGRAM_DATA = %s;
224
225 const state = {
226 zoom: 1,
227 pan: { x: 0, y: 0 },
228 selected: null,
229 search: ""
230 };
231
232 function initDiagram() {
233 const canvas = document.getElementById('canvas');
234 const width = canvas.clientWidth;
235 const height = canvas.clientHeight;
236
237 const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
238 svg.setAttribute('width', width);
239 svg.setAttribute('height', height);
240
241 // Add arrowhead marker definition
242 const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
243 const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
244 marker.setAttribute('id', 'arrowhead');
245 marker.setAttribute('markerWidth', '10');
246 marker.setAttribute('markerHeight', '10');
247 marker.setAttribute('refX', '9');
248 marker.setAttribute('refY', '3');
249 marker.setAttribute('orient', 'auto');
250 const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
251 poly.setAttribute('points', '0 0, 10 3, 0 6');
252 poly.setAttribute('fill', '#333');
253 marker.appendChild(poly);
254 defs.appendChild(marker);
255 svg.appendChild(defs);
256
257 // Draw edges first (background)
258 for (const edge of DIAGRAM_DATA.edges) {
259 const fromNode = DIAGRAM_DATA.nodes.find(n => n.id === edge.from);
260 const toNode = DIAGRAM_DATA.nodes.find(n => n.id === edge.to);
261 if (fromNode && toNode) {
262 const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
263 line.setAttribute('x1', fromNode.x + 80);
264 line.setAttribute('y1', fromNode.y + 40);
265 line.setAttribute('x2', toNode.x + 80);
266 line.setAttribute('y2', toNode.y + 40);
267 line.setAttribute('stroke', '#999');
268 line.setAttribute('stroke-width', '2');
269 line.setAttribute('marker-end', 'url(#arrowhead)');
270 line.classList.add('edge');
271 line.dataset.from = edge.from;
272 line.dataset.to = edge.to;
273 svg.appendChild(line);
274
275 // Label
276 if (edge.label) {
277 const mid = {
278 x: (fromNode.x + toNode.x) / 2 + 80,
279 y: (fromNode.y + toNode.y) / 2 + 40
280 };
281 const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
282 text.setAttribute('x', mid.x);
283 text.setAttribute('y', mid.y - 5);
284 text.setAttribute('text-anchor', 'middle');
285 text.setAttribute('font-size', '12');
286 text.textContent = edge.label;
287 text.classList.add('edge-label');
288 svg.appendChild(text);
289 }
290 }
291 }
292
293 // Draw nodes
294 for (const node of DIAGRAM_DATA.nodes) {
295 const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
296 g.classList.add('node');
297 g.dataset.id = node.id;
298
299 const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
300 rect.setAttribute('x', node.x);
301 rect.setAttribute('y', node.y);
302 rect.setAttribute('width', '160');
303 rect.setAttribute('height', '80');
304 rect.setAttribute('fill', node.fill);
305 rect.setAttribute('stroke', node.stroke);
306 rect.setAttribute('stroke-width', '2');
307 rect.setAttribute('rx', '4');
308
309 const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
310 title.setAttribute('x', node.x + 80);
311 title.setAttribute('y', node.y + 25);
312 title.setAttribute('text-anchor', 'middle');
313 title.setAttribute('font-weight', 'bold');
314 title.setAttribute('font-size', '13');
315 title.textContent = node.title.substring(0, 20);
316
317 const kind = document.createElementNS('http://www.w3.org/2000/svg', 'text');
318 kind.setAttribute('x', node.x + 80);
319 kind.setAttribute('y', node.y + 50);
320 kind.setAttribute('text-anchor', 'middle');
321 kind.setAttribute('font-size', '11');
322 kind.setAttribute('fill', '#666');
323 kind.textContent = '[' + node.kind + ']';
324
325 g.appendChild(rect);
326 g.appendChild(title);
327 g.appendChild(kind);
328
329 g.onclick = () => selectNode(node);
330 svg.appendChild(g);
331 }
332
333 canvas.appendChild(svg);
334
335 // Search
336 document.getElementById('searchInput').addEventListener('input', (e) => {
337 state.search = e.target.value.toLowerCase();
338 highlightSearch();
339 });
340 }
341
342 function selectNode(node) {
343 state.selected = node.id;
344 updateDetails(node);
345 highlightNode(node.id);
346 }
347
348 function updateDetails(node) {
349 const details = document.getElementById('details');
350 document.getElementById('detailsTitle').textContent = node.title;
351 document.getElementById('detailsKind').textContent = node.kind;
352
353 const techEl = document.getElementById('detailsTech');
354 if (node.technology) {
355 techEl.style.display = 'block';
356 document.getElementById('detailsTechVal').textContent = node.technology;
357 } else {
358 techEl.style.display = 'none';
359 }
360
361 const descEl = document.getElementById('detailsDesc');
362 if (node.description) {
363 descEl.style.display = 'block';
364 document.getElementById('detailsDescVal').textContent = node.description;
365 } else {
366 descEl.style.display = 'none';
367 }
368
369 details.classList.add('show');
370 }
371
372 function highlightNode(nodeId) {
373 document.querySelectorAll('.node').forEach(el => {
374 if (el.dataset.id === nodeId) {
375 el.classList.add('highlighted');
376 } else {
377 el.classList.remove('highlighted');
378 }
379 });
380 }
381
382 function highlightSearch() {
383 if (!state.search) {
384 document.querySelectorAll('.node, .edge').forEach(el => el.classList.remove('faded'));
385 return;
386 }
387
388 document.querySelectorAll('.node').forEach(el => {
389 const nodeId = el.dataset.id;
390 const node = DIAGRAM_DATA.nodes.find(n => n.id === nodeId);
391 const matches = node && (node.id.toLowerCase().includes(state.search) || node.title.toLowerCase().includes(state.search));
392 el.classList.toggle('faded', !matches);
393 });
394
395 document.querySelectorAll('.edge').forEach(el => {
396 const from = el.dataset.from;
397 const to = el.dataset.to;
398 const matches = from.toLowerCase().includes(state.search) || to.toLowerCase().includes(state.search);
399 el.classList.toggle('faded', !matches);
400 });
401 }
402
403 function resetZoom() {
404 state.zoom = 1;
405 state.pan = { x: 0, y: 0 };
406 state.selected = null;
407 document.getElementById('details').classList.remove('show');
408 document.querySelectorAll('.node').forEach(el => el.classList.remove('highlighted', 'faded'));
409 document.getElementById('searchInput').value = '';
410 }
411
412 window.addEventListener('load', initDiagram);
413 </script>
414 </body>
415 </html>`, title, dataJSON)
416 }
417
github.com/docToolchain/Bausteinsicht/internal/diagram/markdown.go 93.8%
1 package diagram
2
3 import (
4 "fmt"
5 "strings"
6 "time"
7 )
8
9 // WrapDiagramsInMarkdown wraps multiple Mermaid diagrams in a Markdown document.
10 // viewKeys: ordered list of view keys
11 // diagrams: map of view key → Mermaid diagram code (without outer backticks)
12 func WrapDiagramsInMarkdown(viewKeys []string, diagrams map[string]string, viewTitles map[string]string) string {
13 var b strings.Builder
14
15 b.WriteString("# Architecture Diagrams\n\n")
16 b.WriteString("> Auto-generated by bausteinsicht — do not edit manually.\n")
17 fmt.Fprintf(&b, "> Last updated: %s\n\n", time.Now().UTC().Format(time.RFC3339))
18
19 for _, viewKey := range viewKeys {
20 diagramCode, exists := diagrams[viewKey]
21 if !exists || diagramCode == "" {
22 continue
23 }
24
25 title := viewKey
26 if customTitle, ok := viewTitles[viewKey]; ok {
27 title = customTitle
28 }
29
30 fmt.Fprintf(&b, "## %s\n\n", title)
31 b.WriteString("```mermaid\n")
32 b.WriteString(diagramCode)
33 b.WriteString("\n```\n\n")
34 }
35
36 return b.String()
37 }
38
github.com/docToolchain/Bausteinsicht/internal/diagram/sequence.go 100.0%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderSequencePlantUML renders a DynamicView as a PlantUML sequence diagram.
12 func RenderSequencePlantUML(view model.DynamicView, flat map[string]*model.Element) string {
13 steps := sortedSteps(view.Steps)
14 participants := collectParticipants(steps)
15
16 var b strings.Builder
17 fmt.Fprintf(&b, "@startuml %s\n", sanitizeID(view.Key))
18 fmt.Fprintf(&b, "title %s\n\n", escapeQuotes(view.Title))
19
20 for _, id := range participants {
21 title := participantTitle(id, flat)
22 fmt.Fprintf(&b, "participant \"%s\" as %s\n", escapeQuotes(title), sanitizeID(id))
23 }
24 b.WriteString("\n")
25
26 for _, step := range steps {
27 arrow := plantUMLArrow(step.Type)
28 label := fmt.Sprintf("%d. %s", step.Index, escapeQuotes(step.Label))
29 fmt.Fprintf(&b, "%s %s %s : %s\n", sanitizeID(step.From), arrow, sanitizeID(step.To), label)
30 }
31
32 b.WriteString("\n@enduml\n")
33 return b.String()
34 }
35
36 // RenderSequenceMermaid renders a DynamicView as a Mermaid sequence diagram.
37 func RenderSequenceMermaid(view model.DynamicView, flat map[string]*model.Element) string {
38 steps := sortedSteps(view.Steps)
39 participants := collectParticipants(steps)
40
41 var b strings.Builder
42 b.WriteString("sequenceDiagram\n")
43 fmt.Fprintf(&b, " title %s\n\n", escapeQuotes(view.Title))
44
45 for _, id := range participants {
46 title := participantTitle(id, flat)
47 fmt.Fprintf(&b, " participant %s as %s\n", sanitizeID(id), escapeQuotes(title))
48 }
49 b.WriteString("\n")
50
51 for _, step := range steps {
52 arrow := mermaidArrow(step.Type)
53 label := fmt.Sprintf("%d. %s", step.Index, escapeQuotes(step.Label))
54 fmt.Fprintf(&b, " %s%s%s: %s\n", sanitizeID(step.From), arrow, sanitizeID(step.To), label)
55 }
56
57 return b.String()
58 }
59
60 // sortedSteps returns steps sorted by index.
61 func sortedSteps(steps []model.SequenceStep) []model.SequenceStep {
62 sorted := make([]model.SequenceStep, len(steps))
63 copy(sorted, steps)
64 sort.Slice(sorted, func(i, j int) bool {
65 return sorted[i].Index < sorted[j].Index
66 })
67 return sorted
68 }
69
70 // collectParticipants returns participant IDs in first-appearance order.
71 func collectParticipants(steps []model.SequenceStep) []string {
72 seen := make(map[string]bool)
73 var order []string
74 for _, s := range steps {
75 if !seen[s.From] {
76 seen[s.From] = true
77 order = append(order, s.From)
78 }
79 if !seen[s.To] {
80 seen[s.To] = true
81 order = append(order, s.To)
82 }
83 }
84 return order
85 }
86
87 func participantTitle(id string, flat map[string]*model.Element) string {
88 if flat != nil {
89 if e, ok := flat[id]; ok && e.Title != "" {
90 return e.Title
91 }
92 }
93 return id
94 }
95
96 func plantUMLArrow(t model.StepType) string {
97 switch t {
98 case model.StepAsync:
99 return "->>"
100 case model.StepReturn:
101 return "-->"
102 default: // sync or empty
103 return "->"
104 }
105 }
106
107 func mermaidArrow(t model.StepType) string {
108 switch t {
109 case model.StepAsync:
110 return "-)"
111 case model.StepReturn:
112 return "-->>"
113 default: // sync or empty
114 return "->>"
115 }
116 }
117
github.com/docToolchain/Bausteinsicht/internal/diff/diff.go 94.0%
1 package diff
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // Compare generates a diff between two architecture snapshots (asIs vs toBe)
10 func Compare(asIs, toBe *model.ModelSnapshot) *DiffResult {
11 result := &DiffResult{
12 Elements: []ElementChange{},
13 Relationships: []RelationshipChange{},
14 Summary: Summary{},
15 }
16
17 if asIs == nil || toBe == nil {
18 return result
19 }
20
21 compareElements(asIs.Elements, toBe.Elements, result)
22 compareRelationships(asIs.Relationships, toBe.Relationships, result)
23
24 calculateSummary(result)
25
26 return result
27 }
28
29 func compareElements(asIsElems, toBeElems map[string]model.Element, result *DiffResult) {
30 // Mark as-is elements
31 seenAsIs := make(map[string]bool)
32 for id, asIsElem := range asIsElems {
33 seenAsIs[id] = true
34
35 toBeElem, exists := toBeElems[id]
36 if !exists {
37 // Element removed
38 result.Elements = append(result.Elements, ElementChange{
39 ID: id,
40 Type: ChangeRemoved,
41 AsIs: &asIsElem,
42 Reason: "removed from to-be state",
43 })
44 continue
45 }
46
47 // Check if element changed
48 if hasElementChanged(&asIsElem, &toBeElem) {
49 result.Elements = append(result.Elements, ElementChange{
50 ID: id,
51 Type: ChangeChanged,
52 AsIs: &asIsElem,
53 ToBe: &toBeElem,
54 Reason: "element properties changed",
55 })
56 }
57 }
58
59 // Find added elements
60 for id, toBeElem := range toBeElems {
61 if !seenAsIs[id] {
62 result.Elements = append(result.Elements, ElementChange{
63 ID: id,
64 Type: ChangeAdded,
65 ToBe: &toBeElem,
66 Reason: "new element in to-be state",
67 })
68 }
69 }
70 }
71
72 func compareRelationships(asIsRels, toBeRels []model.Relationship, result *DiffResult) {
73 // Build maps for easier comparison
74 asIsMap := relationshipMap(asIsRels)
75 toBeMap := relationshipMap(toBeRels)
76
77 // Find removed and changed relationships
78 for key, asIsRel := range asIsMap {
79 toBeRel, exists := toBeMap[key]
80 if !exists {
81 // Relationship removed
82 result.Relationships = append(result.Relationships, RelationshipChange{
83 From: asIsRel.From,
84 To: asIsRel.To,
85 Type: ChangeRemoved,
86 AsIs: &asIsRel,
87 })
88 continue
89 }
90
91 // Check if changed (e.g., label changed)
92 if asIsRel.Label != toBeRel.Label {
93 result.Relationships = append(result.Relationships, RelationshipChange{
94 From: asIsRel.From,
95 To: asIsRel.To,
96 Type: ChangeChanged,
97 AsIs: &asIsRel,
98 ToBe: &toBeRel,
99 })
100 }
101 }
102
103 // Find added relationships
104 for key, toBeRel := range toBeMap {
105 if _, exists := asIsMap[key]; !exists {
106 result.Relationships = append(result.Relationships, RelationshipChange{
107 From: toBeRel.From,
108 To: toBeRel.To,
109 Type: ChangeAdded,
110 ToBe: &toBeRel,
111 })
112 }
113 }
114 }
115
116 func relationshipMap(rels []model.Relationship) map[string]model.Relationship {
117 m := make(map[string]model.Relationship)
118 for _, rel := range rels {
119 key := fmt.Sprintf("%s->%s", rel.From, rel.To)
120 m[key] = rel
121 }
122 return m
123 }
124
125 func hasElementChanged(asIs, toBe *model.Element) bool {
126 if asIs == nil || toBe == nil {
127 return true
128 }
129
130 // Compare relevant fields (excluding layout properties)
131 return asIs.Title != toBe.Title ||
132 asIs.Kind != toBe.Kind ||
133 asIs.Technology != toBe.Technology ||
134 asIs.Description != toBe.Description ||
135 asIs.Status != toBe.Status
136 }
137
138 func calculateSummary(result *DiffResult) {
139 for _, change := range result.Elements {
140 switch change.Type {
141 case ChangeAdded:
142 result.Summary.AddedElements++
143 result.Summary.TotalAddedElements++
144 case ChangeRemoved:
145 result.Summary.RemovedElements++
146 result.Summary.TotalRemovedElements++
147 case ChangeChanged:
148 result.Summary.ChangedElements++
149 }
150 }
151
152 for _, change := range result.Relationships {
153 switch change.Type {
154 case ChangeAdded:
155 result.Summary.AddedRelationships++
156 case ChangeRemoved:
157 result.Summary.RemovedRelationships++
158 }
159 }
160 }
161
github.com/docToolchain/Bausteinsicht/internal/diff/drawio.go 90.0%
1 package diff
2
3 // DrawIO color definitions for diff visualization
4 const (
5 ColorAdded = "#d5e8d4" // green
6 ColorRemoved = "#f8cecc" // red
7 ColorChanged = "#ffe6cc" // orange
8 ColorUnchanged = "#ffffff" // white (default)
9
10 StrokeAdded = "#82b366" // dark green
11 StrokeRemoved = "#b85450" // dark red
12 StrokeChanged = "#d6b656" // dark orange
13 )
14
15 // AppliedChangeStyle returns the fill and stroke colors for a changed element
16 func GetChangeColors(changeType ChangeType) (fillColor, strokeColor string) {
17 switch changeType {
18 case ChangeAdded:
19 return ColorAdded, StrokeAdded
20 case ChangeRemoved:
21 return ColorRemoved, StrokeRemoved
22 case ChangeChanged:
23 return ColorChanged, StrokeChanged
24 default:
25 return ColorUnchanged, "#999999"
26 }
27 }
28
29 // ElementStyle describes visual styling for a draw.io element
30 type ElementStyle struct {
31 FillColor string
32 StrokeColor string
33 StrokeWidth float64
34 Opacity float64
35 Label string // For removed elements, add strikethrough indicator
36 }
37
38 // GetElementStyle returns the draw.io styling for a given element change
39 func GetElementStyle(change ElementChange) ElementStyle {
40 fillColor, strokeColor := GetChangeColors(change.Type)
41
42 style := ElementStyle{
43 FillColor: fillColor,
44 StrokeColor: strokeColor,
45 StrokeWidth: 2,
46 Opacity: 1.0,
47 }
48
49 // For removed elements, add visual indication
50 if change.Type == ChangeRemoved && change.AsIs != nil {
51 style.Label = "~" + change.AsIs.Title // strikethrough indicator
52 }
53
54 return style
55 }
56
github.com/docToolchain/Bausteinsicht/internal/drawio/connector.go 89.2%
1 package drawio
2
3 import (
4 "fmt"
5
6 "github.com/beevik/etree"
7 )
8
9 // ConnectorData holds data for creating/updating connectors.
10 type ConnectorData struct {
11 From string // source bausteinsicht_id
12 To string // target bausteinsicht_id
13 Label string // display label on the connector
14 SourceRef string // source cell ID (defaults to From if empty)
15 TargetRef string // target cell ID (defaults to To if empty)
16 Index int // relationship index for disambiguation (0-based)
17 }
18
19 // connectorID returns the canonical ID for a connector between two elements.
20 // The index disambiguates multiple relationships between the same pair.
21 func connectorID(from, to string, index int) string {
22 return fmt.Sprintf("rel-%s-%s-%d", from, to, index)
23 }
24
25 // CreateConnector creates an edge mxCell connecting From to To.
26 // Connectors always use parent="1" regardless of container nesting.
27 func (p *Page) CreateConnector(data ConnectorData, style string) {
28 root := p.Root()
29 if root == nil {
30 return
31 }
32
33 srcRef := data.SourceRef
34 if srcRef == "" {
35 srcRef = data.From
36 }
37 tgtRef := data.TargetRef
38 if tgtRef == "" {
39 tgtRef = data.To
40 }
41
42 cell := root.CreateElement("mxCell")
43 cell.CreateAttr("id", connectorID(srcRef, tgtRef, data.Index))
44 cell.CreateAttr("value", data.Label)
45 cell.CreateAttr("style", style)
46 cell.CreateAttr("edge", "1")
47 cell.CreateAttr("source", srcRef)
48 cell.CreateAttr("target", tgtRef)
49 cell.CreateAttr("parent", "1")
50
51 geom := cell.CreateElement("mxGeometry")
52 geom.CreateAttr("relative", "1")
53 geom.CreateAttr("as", "geometry")
54 }
55
56 // FindConnector returns the mxCell edge with id="rel-<from>-<to>-<index>", or nil.
57 func (p *Page) FindConnector(from, to string, index int) *etree.Element {
58 root := p.Root()
59 if root == nil {
60 return nil
61 }
62 id := connectorID(from, to, index)
63 for _, cell := range root.SelectElements("mxCell") {
64 if cell.SelectAttrValue("id", "") == id {
65 return cell
66 }
67 }
68 return nil
69 }
70
71 // FindAllConnectors returns all mxCell elements with edge="1".
72 func (p *Page) FindAllConnectors() []*etree.Element {
73 root := p.Root()
74 if root == nil {
75 return nil
76 }
77 var result []*etree.Element
78 for _, cell := range root.SelectElements("mxCell") {
79 if cell.SelectAttrValue("edge", "") == "1" {
80 result = append(result, cell)
81 }
82 }
83 return result
84 }
85
86 // UpdateConnectorLabel sets the value attribute on the connector between from and to.
87 func (p *Page) UpdateConnectorLabel(from, to string, index int, label string) {
88 conn := p.FindConnector(from, to, index)
89 if conn == nil {
90 return
91 }
92 attr := conn.SelectAttr("value")
93 if attr != nil {
94 attr.Value = label
95 } else {
96 conn.CreateAttr("value", label)
97 }
98 }
99
100 // DeleteConnector removes the connector between from and to at the given index.
101 func (p *Page) DeleteConnector(from, to string, index int) {
102 root := p.Root()
103 if root == nil {
104 return
105 }
106 id := connectorID(from, to, index)
107 for _, cell := range root.SelectElements("mxCell") {
108 if cell.SelectAttrValue("id", "") == id {
109 root.RemoveChild(cell)
110 return
111 }
112 }
113 }
114
115 // DeleteConnectorsFor removes all connectors where source or target matches elementID.
116 func (p *Page) DeleteConnectorsFor(elementID string) {
117 root := p.Root()
118 if root == nil {
119 return
120 }
121 var toRemove []*etree.Element
122 for _, cell := range root.SelectElements("mxCell") {
123 if cell.SelectAttrValue("edge", "") != "1" {
124 continue
125 }
126 src := cell.SelectAttrValue("source", "")
127 tgt := cell.SelectAttrValue("target", "")
128 if src == elementID || tgt == elementID {
129 toRemove = append(toRemove, cell)
130 }
131 }
132 for _, cell := range toRemove {
133 root.RemoveChild(cell)
134 }
135 }
136
github.com/docToolchain/Bausteinsicht/internal/drawio/document.go 84.1%
1 // Package drawio handles reading and writing draw.io XML files.
2 package drawio
3
4 import (
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/beevik/etree"
10 )
11
12 // Document represents a draw.io file (mxfile).
13 type Document struct {
14 tree *etree.Document
15 }
16
17 // Page represents a single page (diagram element) within a Document.
18 type Page struct {
19 diagram *etree.Element
20 }
21
22 // LoadDocument parses a draw.io XML file from disk.
23 // It validates the document structure to prevent data loss from corrupt files.
24 func LoadDocument(path string) (*Document, error) {
25 tree := etree.NewDocument()
26 if err := tree.ReadFromFile(path); err != nil {
27 return nil, fmt.Errorf("LoadDocument %q: %w", path, err)
28 }
29
30 // Validate document structure to prevent data loss from corrupt files.
31 root := tree.Root()
32 if root == nil || root.Tag != "mxfile" {
33 return nil, fmt.Errorf("LoadDocument %q: not a valid draw.io file (missing <mxfile> root)", path)
34 }
35 diagrams := root.SelectElements("diagram")
36 if len(diagrams) == 0 {
37 return nil, fmt.Errorf("LoadDocument %q: not a valid draw.io file (no <diagram> elements)", path)
38 }
39 for _, d := range diagrams {
40 model := d.FindElement("mxGraphModel")
41 if model == nil {
42 return nil, fmt.Errorf("LoadDocument %q: diagram %q missing <mxGraphModel>",
43 path, d.SelectAttrValue("id", "?"))
44 }
45 if model.FindElement("root") == nil {
46 return nil, fmt.Errorf("LoadDocument %q: diagram %q missing <root> element",
47 path, d.SelectAttrValue("id", "?"))
48 }
49 }
50
51 return &Document{tree: tree}, nil
52 }
53
54 // SaveDocument writes a Document to disk using an atomic temp-file + rename.
55 func SaveDocument(path string, doc *Document) error {
56 doc.tree.Indent(2)
57
58 dir := filepath.Dir(path)
59 tmp, err := os.CreateTemp(dir, ".drawio-tmp-*")
60 if err != nil {
61 return fmt.Errorf("SaveDocument create temp: %w", err)
62 }
63 tmpName := tmp.Name()
64
65 if _, err := doc.tree.WriteTo(tmp); err != nil {
66 _ = tmp.Close()
67 _ = os.Remove(tmpName)
68 return fmt.Errorf("SaveDocument write: %w", err)
69 }
70 if err := tmp.Close(); err != nil {
71 _ = os.Remove(tmpName)
72 return fmt.Errorf("SaveDocument close: %w", err)
73 }
74 if err := os.Rename(tmpName, path); err != nil {
75 _ = os.Remove(tmpName)
76 return fmt.Errorf("SaveDocument rename: %w", err)
77 }
78 return nil
79 }
80
81 // NewDocument creates an empty mxfile document.
82 func NewDocument() *Document {
83 tree := etree.NewDocument()
84 tree.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
85 mxfile := tree.CreateElement("mxfile")
86 mxfile.CreateAttr("host", "bausteinsicht")
87 mxfile.CreateAttr("compressed", "false")
88 return &Document{tree: tree}
89 }
90
91 // Pages returns all pages in the document.
92 func (d *Document) Pages() []*Page {
93 root := d.tree.Root()
94 if root == nil {
95 return nil
96 }
97 diagrams := root.SelectElements("diagram")
98 pages := make([]*Page, len(diagrams))
99 for i, el := range diagrams {
100 pages[i] = &Page{diagram: el}
101 }
102 return pages
103 }
104
105 // GetPage returns the page with the given id, or nil if not found.
106 func (d *Document) GetPage(id string) *Page {
107 root := d.tree.Root()
108 if root == nil {
109 return nil
110 }
111 for _, el := range root.SelectElements("diagram") {
112 if el.SelectAttrValue("id", "") == id {
113 return &Page{diagram: el}
114 }
115 }
116 return nil
117 }
118
119 // AddPage adds a new page with the given id and name, initialised with base cells.
120 func (d *Document) AddPage(id, name string) *Page {
121 root := d.tree.Root()
122 if root == nil {
123 root = d.tree.CreateElement("mxfile")
124 }
125
126 diagram := root.CreateElement("diagram")
127 diagram.CreateAttr("id", id)
128 diagram.CreateAttr("name", name)
129
130 model := diagram.CreateElement("mxGraphModel")
131 model.CreateAttr("dx", "1422")
132 model.CreateAttr("dy", "794")
133 model.CreateAttr("grid", "1")
134 model.CreateAttr("gridSize", "10")
135 model.CreateAttr("page", "1")
136 model.CreateAttr("pageWidth", "1169")
137 model.CreateAttr("pageHeight", "827")
138 model.CreateAttr("background", "#ffffff")
139
140 rootEl := model.CreateElement("root")
141 cell0 := rootEl.CreateElement("mxCell")
142 cell0.CreateAttr("id", "0")
143 cell1 := rootEl.CreateElement("mxCell")
144 cell1.CreateAttr("id", "1")
145 cell1.CreateAttr("parent", "0")
146
147 return &Page{diagram: diagram}
148 }
149
150 // RemovePage removes the page (diagram element) with the given id from the document.
151 // If no page with the given id exists, RemovePage is a no-op.
152 func (d *Document) RemovePage(id string) {
153 root := d.tree.Root()
154 if root == nil {
155 return
156 }
157 for _, el := range root.SelectElements("diagram") {
158 if el.SelectAttrValue("id", "") == id {
159 root.RemoveChild(el)
160 return
161 }
162 }
163 }
164
165 // ID returns the id attribute of the page's diagram element.
166 func (p *Page) ID() string {
167 return p.diagram.SelectAttrValue("id", "")
168 }
169
170 // Root returns the <root> element of the page for direct manipulation.
171 func (p *Page) Root() *etree.Element {
172 model := p.diagram.FindElement("mxGraphModel")
173 if model == nil {
174 return nil
175 }
176 return model.FindElement("root")
177 }
178
github.com/docToolchain/Bausteinsicht/internal/drawio/element.go 88.8%
1 package drawio
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/beevik/etree"
8 )
9
10 // ElementData holds the data needed to create or update an element.
11 type ElementData struct {
12 ID string // bausteinsicht_id (e.g., "webshop.api")
13 CellID string // draw.io cell ID (file-wide unique); defaults to ID if empty
14 Kind string // bausteinsicht_kind (e.g., "container")
15 Title string // display title
16 Technology string // technology string
17 Description string // tooltip text
18 Link string // drill-down link (e.g., "data:page/id,view-containers")
19 ParentID string // parent cell ID ("1" for top-level, container ID for children)
20 X, Y float64 // position
21 Width float64 // element width
22 Height float64 // element height
23 SubCells *SubCellTemplates // sub-cell templates; nil for legacy HTML labels
24 }
25
26 // CreateElement creates an <object> wrapping an <mxCell vertex="1"> with <mxGeometry>.
27 // ParentID defaults to "1" if empty.
28 // When SubCells is non-nil, the element uses grouped sub-cells for title/tech/desc
29 // instead of an HTML label.
30 func (p *Page) CreateElement(data ElementData, style string) error {
31 root := p.Root()
32 if root == nil {
33 return fmt.Errorf("CreateElement: page has no root element")
34 }
35
36 parentID := data.ParentID
37 if parentID == "" {
38 parentID = "1"
39 }
40
41 cellID := data.CellID
42 if cellID == "" {
43 cellID = data.ID
44 }
45
46 obj := root.CreateElement("object")
47 if data.SubCells != nil {
48 obj.CreateAttr("label", "")
49 } else {
50 obj.CreateAttr("label", GenerateLabel(data.Title, data.Technology, data.Description))
51 }
52 obj.CreateAttr("id", cellID)
53 obj.CreateAttr("bausteinsicht_id", data.ID)
54 obj.CreateAttr("bausteinsicht_kind", data.Kind)
55 if data.Technology != "" {
56 obj.CreateAttr("technology", data.Technology)
57 }
58 if data.Description != "" {
59 obj.CreateAttr("tooltip", data.Description)
60 }
61 if data.Link != "" {
62 obj.CreateAttr("link", data.Link)
63 }
64
65 // Ensure container=1 is set when using sub-cells (required for child grouping).
66 if data.SubCells != nil {
67 // Replace container=0 with container=1, or append if missing.
68 if strings.Contains(style, "container=0") {
69 style = strings.Replace(style, "container=0", "container=1", 1)
70 } else if !strings.Contains(style, "container=1") {
71 style = strings.TrimRight(style, ";") + ";container=1;"
72 }
73 }
74
75 // HTML labels require html=1 in the cell style; without it draw.io renders
76 // the raw markup as plain text. This guard covers elements whose kind has no
77 // template entry and therefore receives an empty style fallback.
78 if data.SubCells == nil && !strings.Contains(style, "html=1") {
79 if style != "" && !strings.HasSuffix(style, ";") {
80 style += ";"
81 }
82 style += "html=1;"
83 }
84
85 cell := obj.CreateElement("mxCell")
86 cell.CreateAttr("style", style)
87 cell.CreateAttr("vertex", "1")
88 cell.CreateAttr("parent", parentID)
89
90 geom := cell.CreateElement("mxGeometry")
91 geom.CreateAttr("x", formatFloat(data.X))
92 geom.CreateAttr("y", formatFloat(data.Y))
93 geom.CreateAttr("width", formatFloat(data.Width))
94 geom.CreateAttr("height", formatFloat(data.Height))
95 geom.CreateAttr("as", "geometry")
96
97 // Create grouped sub-cells for title, technology, and description.
98 if data.SubCells != nil {
99 createSubCells(root, cellID, data, data.SubCells)
100 }
101
102 return nil
103 }
104
105 // SubCellTemplates holds the template styles for creating text sub-cells.
106 type SubCellTemplates struct {
107 Title *SubCellStyle
108 Tech *SubCellStyle
109 Desc *SubCellStyle
110 }
111
112 // createSubCells creates child mxCell text elements inside the parent element.
113 func createSubCells(root *etree.Element, parentCellID string, data ElementData, sc *SubCellTemplates) {
114 // Title sub-cell (always created).
115 if sc.Title != nil {
116 createTextSubCell(root, parentCellID+"-title", parentCellID, data.Title,
117 sc.Title, data.Width, data.Height)
118 }
119
120 // Technology sub-cell (only when technology is non-empty).
121 if sc.Tech != nil && data.Technology != "" {
122 createTextSubCell(root, parentCellID+"-tech", parentCellID, "["+data.Technology+"]",
123 sc.Tech, data.Width, data.Height)
124 }
125
126 // Description sub-cell (only when description is non-empty).
127 // The display value is truncated to avoid visual overflow; the full text
128 // is preserved in the element's tooltip attribute.
129 if sc.Desc != nil && data.Description != "" {
130 createTextSubCell(root, parentCellID+"-desc", parentCellID, truncateText(data.Description, 120),
131 sc.Desc, data.Width, data.Height)
132 }
133 }
134
135 // truncateText shortens s to maxLen runes, appending "…" if truncated.
136 func truncateText(s string, maxLen int) string {
137 runes := []rune(s)
138 if len(runes) <= maxLen {
139 return s
140 }
141 return string(runes[:maxLen-1]) + "…"
142 }
143
144 // createTextSubCell creates a single text mxCell child element.
145 // Sub-cells are locked (non-movable, non-resizable, non-deletable, non-connectable)
146 // so that clicking the shape always selects the parent element.
147 func createTextSubCell(root *etree.Element, id, parentID, value string, sub *SubCellStyle, parentW, parentH float64) {
148 cell := root.CreateElement("mxCell")
149 cell.CreateAttr("id", id)
150 cell.CreateAttr("value", value)
151 // Make sub-cells transparent to mouse events so clicks pass through
152 // to the parent element. This lets users grab the whole shape at once.
153 // overflow=hidden clips text at the fixed sub-cell boundary; the full
154 // content remains accessible via the element's tooltip attribute.
155 style := setStyleFlags(sub.Style, "pointerEvents=0", "overflow=hidden")
156 cell.CreateAttr("style", style)
157 cell.CreateAttr("vertex", "1")
158 cell.CreateAttr("connectable", "0")
159 cell.CreateAttr("parent", parentID)
160
161 geom := cell.CreateElement("mxGeometry")
162 // Scale sub-cell width to parent width, keep x/y/height from template.
163 w := parentW
164 if w == 0 {
165 w = sub.Width
166 }
167 geom.CreateAttr("x", formatFloat(sub.X))
168 geom.CreateAttr("y", formatFloat(sub.Y))
169 geom.CreateAttr("width", formatFloat(w))
170 geom.CreateAttr("height", formatFloat(sub.Height))
171 geom.CreateAttr("as", "geometry")
172 }
173
174 // FindElement returns the <object> element with the given bausteinsicht_id, or nil if not found.
175 func (p *Page) FindElement(bausteinsichtID string) *etree.Element {
176 root := p.Root()
177 if root == nil {
178 return nil
179 }
180 for _, obj := range root.SelectElements("object") {
181 if obj.SelectAttrValue("bausteinsicht_id", "") == bausteinsichtID {
182 return obj
183 }
184 }
185 return nil
186 }
187
188 // FindAllElements returns all <object> elements that have a bausteinsicht_id attribute.
189 func (p *Page) FindAllElements() []*etree.Element {
190 root := p.Root()
191 if root == nil {
192 return nil
193 }
194 var result []*etree.Element
195 for _, obj := range root.SelectElements("object") {
196 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
197 result = append(result, obj)
198 }
199 }
200 return result
201 }
202
203 // UpdateElement updates label, tooltip, technology, and link on an existing element.
204 // If the element uses sub-cells, child text cells are updated instead of the HTML label.
205 func (p *Page) UpdateElement(id string, data ElementData) {
206 obj := p.FindElement(id)
207 if obj == nil {
208 return
209 }
210
211 cellID := obj.SelectAttrValue("id", "")
212
213 // Check if element uses sub-cells (label is empty and has child text cells).
214 root := p.Root()
215 childCells := findChildTextCells(root, cellID)
216 if len(childCells) > 0 {
217 // Update existing sub-cells and manage tech/desc cells.
218 updateSubCells(root, cellID, childCells, data)
219 setAttr(obj, "label", "")
220 } else {
221 setAttr(obj, "label", GenerateLabel(data.Title, data.Technology, data.Description))
222 }
223
224 setAttr(obj, "tooltip", data.Description)
225 setAttr(obj, "link", data.Link)
226 if data.Technology != "" {
227 setAttr(obj, "technology", data.Technology)
228 } else {
229 setAttr(obj, "technology", "")
230 }
231 }
232
233 // findChildTextCells finds all text sub-cells that are children of the given parent cell ID.
234 // Returns a map of role ("title", "tech", "desc") to the mxCell element.
235 func findChildTextCells(root *etree.Element, parentCellID string) map[string]*etree.Element {
236 if root == nil || parentCellID == "" {
237 return nil
238 }
239 result := make(map[string]*etree.Element)
240 for _, cell := range root.SelectElements("mxCell") {
241 if cell.SelectAttrValue("parent", "") != parentCellID {
242 continue
243 }
244 cellID := cell.SelectAttrValue("id", "")
245 style := cell.SelectAttrValue("style", "")
246 if !isTextSubCell(style) {
247 continue
248 }
249 switch {
250 case hasSuffix(cellID, "-title"):
251 result["title"] = cell
252 case hasSuffix(cellID, "-tech"):
253 result["tech"] = cell
254 case hasSuffix(cellID, "-desc"):
255 result["desc"] = cell
256 }
257 }
258 return result
259 }
260
261 // isTextSubCell returns true if the style indicates a text sub-cell.
262 func isTextSubCell(style string) bool {
263 return containsStyleKey(style, "text")
264 }
265
266 // containsStyleKey checks if a draw.io style string starts with or contains the given key.
267 func containsStyleKey(style, key string) bool {
268 // Style format: "key1;key2=val;key3=val;..."
269 // "text" appears as a flag (no =) at the beginning.
270 if style == key || style == key+";" {
271 return true
272 }
273 if len(style) > len(key) && style[:len(key)+1] == key+";" {
274 return true
275 }
276 return false
277 }
278
279 // hasSuffix is a simple string suffix check.
280 func hasSuffix(s, suffix string) bool {
281 return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
282 }
283
284 // updateSubCells updates existing sub-cell values and adds/removes tech/desc cells.
285 func updateSubCells(root *etree.Element, parentCellID string, cells map[string]*etree.Element, data ElementData) {
286 // Update title.
287 if tc, ok := cells["title"]; ok {
288 setAttr(tc, "value", data.Title)
289 ensureSubCellStyle(tc)
290 }
291
292 // Update or add/remove technology cell.
293 if tc, ok := cells["tech"]; ok {
294 if data.Technology != "" {
295 setAttr(tc, "value", "["+data.Technology+"]")
296 ensureSubCellStyle(tc)
297 } else {
298 root.RemoveChild(tc)
299 }
300 }
301
302 // Update or add/remove description cell.
303 if dc, ok := cells["desc"]; ok {
304 if data.Description != "" {
305 setAttr(dc, "value", truncateText(data.Description, 120))
306 ensureSubCellStyle(dc)
307 } else {
308 root.RemoveChild(dc)
309 }
310 }
311 }
312
313 // ensureSubCellStyle applies required style flags to an existing sub-cell.
314 // This migrates older sub-cells that were created before these flags existed.
315 func ensureSubCellStyle(cell *etree.Element) {
316 style := cell.SelectAttrValue("style", "")
317 updated := setStyleFlags(style, "pointerEvents=0", "overflow=hidden")
318 if updated != style {
319 setAttr(cell, "style", updated)
320 }
321 }
322
323 // UpdateElementKind updates the bausteinsicht_kind attribute and the mxCell style
324 // of an existing element. If style is empty, the mxCell style is not changed.
325 func (p *Page) UpdateElementKind(id, kind, style string) {
326 obj := p.FindElement(id)
327 if obj == nil {
328 return
329 }
330 setAttr(obj, "bausteinsicht_kind", kind)
331 cell := obj.FindElement("mxCell")
332 if cell != nil && style != "" {
333 setAttr(cell, "style", style)
334 }
335 }
336
337 // DeleteElement removes the <object> element with the given bausteinsicht_id.
338 // It also removes any child text sub-cells and mxCell connectors that reference
339 // this element as source or target.
340 func (p *Page) DeleteElement(id string) {
341 root := p.Root()
342 if root == nil {
343 return
344 }
345
346 obj := p.FindElement(id)
347 if obj != nil {
348 cellID := obj.SelectAttrValue("id", "")
349 root.RemoveChild(obj)
350
351 // Remove child text sub-cells (their parent references the cell ID).
352 if cellID != "" {
353 removeChildCells(root, cellID)
354 }
355 }
356
357 for _, cell := range root.SelectElements("mxCell") {
358 src := cell.SelectAttrValue("source", "")
359 dst := cell.SelectAttrValue("target", "")
360 if src == id || dst == id {
361 root.RemoveChild(cell)
362 }
363 }
364 }
365
366 // removeChildCells removes all mxCell elements whose parent attribute matches parentID.
367 func removeChildCells(root *etree.Element, parentID string) {
368 var toRemove []*etree.Element
369 for _, cell := range root.SelectElements("mxCell") {
370 if cell.SelectAttrValue("parent", "") == parentID {
371 toRemove = append(toRemove, cell)
372 }
373 }
374 for _, cell := range toRemove {
375 root.RemoveChild(cell)
376 }
377 }
378
379 // ReadElementFields extracts title, technology, and description from an element.
380 // It first looks for child text sub-cells; if none are found, falls back to
381 // parsing the HTML label (backward compatibility).
382 func (p *Page) ReadElementFields(obj *etree.Element) (title, technology, description string) {
383 cellID := obj.SelectAttrValue("id", "")
384 root := p.Root()
385 childCells := findChildTextCells(root, cellID)
386
387 if len(childCells) > 0 {
388 if tc, ok := childCells["title"]; ok {
389 title = tc.SelectAttrValue("value", "")
390 }
391 if tc, ok := childCells["tech"]; ok {
392 technology = trimBrackets(tc.SelectAttrValue("value", ""))
393 }
394 if dc, ok := childCells["desc"]; ok {
395 description = dc.SelectAttrValue("value", "")
396 }
397 return title, technology, description
398 }
399
400 // Fallback: parse HTML label (backward compat).
401 label := obj.SelectAttrValue("label", "")
402 return ParseLabel(label)
403 }
404
405 // setAttr sets an attribute on an element, creating it if it doesn't exist.
406 // If value is empty, any existing attribute is removed.
407 func setAttr(el *etree.Element, key, value string) {
408 if value == "" {
409 el.RemoveAttr(key)
410 return
411 }
412 attr := el.SelectAttr(key)
413 if attr != nil {
414 attr.Value = value
415 } else {
416 el.CreateAttr(key, value)
417 }
418 }
419
420 // formatFloat formats a float64 as a string without trailing zeros where possible.
421 func formatFloat(f float64) string {
422 if f == float64(int(f)) {
423 return fmt.Sprintf("%d", int(f))
424 }
425 return fmt.Sprintf("%g", f)
426 }
427
428 // setStyleFlags sets key=value flags in a draw.io style string,
429 // replacing any existing value for each key.
430 func setStyleFlags(style string, flags ...string) string {
431 for _, flag := range flags {
432 parts := strings.SplitN(flag, "=", 2)
433 if len(parts) != 2 {
434 continue
435 }
436 key := parts[0]
437 // Remove existing key=value pair.
438 segments := strings.Split(style, ";")
439 var filtered []string
440 for _, seg := range segments {
441 if seg == "" {
442 continue
443 }
444 if strings.HasPrefix(seg, key+"=") {
445 continue
446 }
447 filtered = append(filtered, seg)
448 }
449 filtered = append(filtered, flag)
450 style = strings.Join(filtered, ";") + ";"
451 }
452 return style
453 }
454
github.com/docToolchain/Bausteinsicht/internal/drawio/label.go 91.0%
1 package drawio
2
3 import (
4 "strings"
5 )
6
7 // Label color constants for technology and description lines.
8 // These are light enough to be readable on dark C4 backgrounds (#08427B,
9 // #1168BD, #438DD5) while still providing contrast on the lighter
10 // component background (#85BBF0).
11 const (
12 techColor = "#CCCCCC"
13 descColor = "#BBBBBB"
14 )
15
16 // maxLabelDescLen is the maximum rune length of the description portion of an
17 // HTML label. HTML labels are rendered inside a fixed-size element box
18 // (typically 120×60 px) that has no sub-cell clipping, so descriptions must
19 // be kept short. The full text is always preserved in the tooltip attribute.
20 const maxLabelDescLen = 60
21
22 // GenerateLabel creates an HTML label for draw.io elements.
23 // Format: <b>Title</b><br><font color="..."><i>[Technology]</i></font><br><font color="..." style="font-size:11px">Description</font>
24 // Technology is wrapped in square brackets per C4 convention and rendered in italic.
25 // Empty technology or description lines are omitted.
26 // The returned string is unescaped HTML; etree handles XML attribute escaping.
27 func GenerateLabel(title, technology, description string) string {
28 var b strings.Builder
29 b.WriteString("<b>" + escapeHTML(title) + "</b>")
30 if technology != "" {
31 b.WriteString("<br><font color=\"" + techColor + "\"><i>[" + escapeHTML(technology) + "]</i></font>")
32 }
33 if description != "" {
34 b.WriteString("<br><font color=\"" + descColor + "\" style=\"font-size:11px\">" + escapeHTML(truncateText(description, maxLabelDescLen)) + "</font>")
35 }
36 return b.String()
37 }
38
39 // GenerateActorLabel creates a label for actor elements (just the title, no technology line).
40 func GenerateActorLabel(title string) string {
41 return "<b>" + escapeHTML(title) + "</b>"
42 }
43
44 // ParseLabel extracts title, technology and description from an HTML label.
45 // Expected format: <b>Title</b><br><font color="#666666">[Technology]</font><br><font color="#999999">Description</font>
46 // Also handles legacy format without brackets around technology.
47 // If the label doesn't match, return the full text as title.
48 func ParseLabel(html string) (title, technology, description string) {
49 if !strings.HasPrefix(html, "<b>") {
50 return stripTags(html), "", ""
51 }
52
53 rest := html[len("<b>"):]
54 closeB := strings.Index(rest, "</b>")
55 if closeB < 0 {
56 return stripTags(html), "", ""
57 }
58
59 titlePart := rest[:closeB]
60 after := rest[closeB+len("</b>"):]
61
62 cleanTitle := stripTags(titlePart)
63
64 if after == "" {
65 return cleanTitle, "", ""
66 }
67
68 // Parse remaining <br><font ...>...</font> segments
69 segments := parseFontSegments(after)
70
71 switch len(segments) {
72 case 1:
73 seg := segments[0]
74 if seg.color == descColor || seg.color == "#999999" {
75 // Description only (no technology)
76 return cleanTitle, "", unescapeHTML(stripTags(seg.text))
77 }
78 // Technology (with or without brackets)
79 return cleanTitle, unescapeHTML(trimBrackets(stripTags(seg.text))), ""
80 case 2:
81 tech := unescapeHTML(trimBrackets(stripTags(segments[0].text)))
82 desc := unescapeHTML(stripTags(segments[1].text))
83 return cleanTitle, tech, desc
84 default:
85 return cleanTitle, "", ""
86 }
87 }
88
89 type fontSegment struct {
90 color string
91 text string
92 }
93
94 // parseFontSegments extracts consecutive <br><font color="...">...</font> segments.
95 func parseFontSegments(s string) []fontSegment {
96 var segments []fontSegment
97 for strings.HasPrefix(s, "<br>") {
98 s = s[len("<br>"):]
99 if !strings.HasPrefix(s, "<font") {
100 break
101 }
102 // Extract color attribute
103 colorStart := strings.Index(s, `color="`)
104 if colorStart < 0 {
105 break
106 }
107 colorStart += len(`color="`)
108 colorEnd := strings.Index(s[colorStart:], `"`)
109 if colorEnd < 0 {
110 break
111 }
112 color := s[colorStart : colorStart+colorEnd]
113
114 // Extract text content
115 tagClose := strings.Index(s, ">")
116 if tagClose < 0 {
117 break
118 }
119 textStart := tagClose + 1
120 endFont := strings.Index(s[textStart:], "</font>")
121 if endFont < 0 {
122 break
123 }
124 text := s[textStart : textStart+endFont]
125 segments = append(segments, fontSegment{color: color, text: text})
126 s = s[textStart+endFont+len("</font>"):]
127 }
128 return segments
129 }
130
131 // trimBrackets removes surrounding square brackets if present.
132 func trimBrackets(s string) string {
133 if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") {
134 return s[1 : len(s)-1]
135 }
136 return s
137 }
138
139 // escapeHTML escapes special HTML characters in text content.
140 func escapeHTML(s string) string {
141 s = strings.ReplaceAll(s, "&", "&amp;")
142 s = strings.ReplaceAll(s, "<", "&lt;")
143 s = strings.ReplaceAll(s, ">", "&gt;")
144 s = strings.ReplaceAll(s, "\"", "&quot;")
145 return s
146 }
147
148 // unescapeHTML reverses HTML entity escaping.
149 func unescapeHTML(s string) string {
150 s = strings.ReplaceAll(s, "&quot;", "\"")
151 s = strings.ReplaceAll(s, "&gt;", ">")
152 s = strings.ReplaceAll(s, "&lt;", "<")
153 s = strings.ReplaceAll(s, "&amp;", "&")
154 return s
155 }
156
157 // stripTags removes all HTML tags from a string.
158 // Handles '>' inside quoted attribute values correctly (SEC-008).
159 func stripTags(s string) string {
160 var b strings.Builder
161 inTag := false
162 var quote rune
163 for _, r := range s {
164 switch {
165 case inTag && quote != 0:
166 if r == quote {
167 quote = 0
168 }
169 case inTag && (r == '"' || r == '\''):
170 quote = r
171 case r == '<':
172 inTag = true
173 case r == '>' && inTag:
174 inTag = false
175 case !inTag:
176 b.WriteRune(r)
177 }
178 }
179 return unescapeHTML(b.String())
180 }
181
github.com/docToolchain/Bausteinsicht/internal/drawio/template.go 83.7%
1 package drawio
2
3 import (
4 "fmt"
5 "os"
6 "strconv"
7 "strings"
8
9 "github.com/beevik/etree"
10 )
11
12 // CurrentTemplateVersion is the latest template format version supported.
13 const CurrentTemplateVersion = 1
14
15 // SubCellStyle holds style and geometry for a text sub-cell within an element.
16 type SubCellStyle struct {
17 Style string // mxCell style string
18 X, Y float64 // position relative to parent
19 Width, Height float64 // dimensions
20 }
21
22 // TemplateStyle holds the visual style and default dimensions for a draw.io element.
23 type TemplateStyle struct {
24 Style string // mxCell style string
25 Width float64 // default width from mxGeometry
26 Height float64 // default height from mxGeometry
27
28 // Sub-cell styles for grouped text labels (title, technology, description).
29 // Nil means no sub-cells defined (legacy template).
30 TitleStyle *SubCellStyle
31 TechStyle *SubCellStyle
32 DescStyle *SubCellStyle
33 }
34
35 // TemplateSet holds all styles parsed from a draw.io template file.
36 type TemplateSet struct {
37 Version int // template format version (0 means unset/v1)
38 elements map[string]TemplateStyle // keyed by kind (actor, system, container, component)
39 boundaries map[string]TemplateStyle // keyed by kind (system_boundary, container_boundary)
40 connector string // default connector style
41 }
42
43 // LoadTemplate parses a draw.io template file from disk.
44 func LoadTemplate(path string) (*TemplateSet, error) {
45 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
46 if err != nil {
47 return nil, fmt.Errorf("LoadTemplate %q: %w", path, err)
48 }
49 return LoadTemplateFromBytes(data)
50 }
51
52 // LoadTemplateFromBytes parses a draw.io template from raw XML bytes.
53 func LoadTemplateFromBytes(data []byte) (*TemplateSet, error) {
54 tree := etree.NewDocument()
55 if err := tree.ReadFromBytes(data); err != nil {
56 return nil, fmt.Errorf("LoadTemplateFromBytes: %w", err)
57 }
58
59 // Validate that the document is a valid draw.io file with an <mxfile> root.
60 root := tree.Root()
61 if root == nil || root.Tag != "mxfile" {
62 return nil, fmt.Errorf("LoadTemplateFromBytes: not a valid draw.io template (missing <mxfile> root element)")
63 }
64
65 // Read template version from <mxfile> root. Missing → version 1 (backward compat).
66 version := 1
67 if vStr := root.SelectAttrValue("bausteinsicht_template_version", ""); vStr != "" {
68 v, err := strconv.Atoi(vStr)
69 if err != nil {
70 return nil, fmt.Errorf("LoadTemplateFromBytes: invalid template version %q: %w", vStr, err)
71 }
72 version = v
73 }
74 if version > CurrentTemplateVersion {
75 return nil, fmt.Errorf("LoadTemplateFromBytes: template version %d not supported (max: %d)", version, CurrentTemplateVersion)
76 }
77
78 ts := &TemplateSet{
79 Version: version,
80 elements: make(map[string]TemplateStyle),
81 boundaries: make(map[string]TemplateStyle),
82 }
83
84 // Build maps for looking up child cells.
85 templateIDs := make(map[string]string) // template object id → kind
86 groupToKind := make(map[string]string) // group cell id → kind (when template is inside a group)
87
88 // Find all <object> elements with bausteinsicht_template attribute.
89 for _, obj := range tree.FindElements("//object[@bausteinsicht_template]") {
90 kind := obj.SelectAttrValue("bausteinsicht_template", "")
91 if kind == "" {
92 continue
93 }
94
95 cell := obj.FindElement("mxCell")
96 if cell == nil {
97 continue
98 }
99
100 style := cell.SelectAttrValue("style", "")
101 width, height := parseGeometry(cell)
102
103 objID := obj.SelectAttrValue("id", "")
104 if objID != "" {
105 templateIDs[objID] = kind
106 }
107
108 // If the template mxCell's parent is not "1", it's inside a group.
109 // Map the group ID so sub-cells with that group parent are found too.
110 cellParent := cell.SelectAttrValue("parent", "1")
111 if cellParent != "1" && cellParent != "0" && cellParent != "" {
112 groupToKind[cellParent] = kind
113 }
114
115 ts.categorize(kind, TemplateStyle{Style: style, Width: width, Height: height})
116 }
117
118 // Parse child mxCells that are sub-cells of template elements.
119 // Sub-cells may be children of the template object ID directly,
120 // or children of a group cell that contains the template object.
121 for _, cell := range tree.FindElements("//mxCell[@parent]") {
122 parentID := cell.SelectAttrValue("parent", "")
123 kind, ok := templateIDs[parentID]
124 if !ok {
125 kind, ok = groupToKind[parentID]
126 }
127 if !ok {
128 continue
129 }
130 cellID := cell.SelectAttrValue("id", "")
131 if cellID == "" {
132 continue
133 }
134 sub := parseSubCellStyle(cell)
135 if sub == nil {
136 continue
137 }
138
139 // Determine role from cell ID suffix or value heuristic.
140 role := ""
141 switch {
142 case strings.HasSuffix(cellID, "-title"):
143 role = "title"
144 case strings.HasSuffix(cellID, "-tech"):
145 role = "tech"
146 case strings.HasSuffix(cellID, "-desc"):
147 role = "desc"
148 default:
149 // Fallback: detect role by value attribute when ID doesn't follow convention
150 // (e.g., draw.io-generated IDs from manual template editing).
151 val := strings.TrimSpace(cell.SelectAttrValue("value", ""))
152 switch {
153 case strings.EqualFold(val, "title") || strings.HasSuffix(val, " Name"):
154 role = "title"
155 case strings.EqualFold(val, "[technology]"):
156 role = "tech"
157 case strings.EqualFold(val, "description"):
158 role = "desc"
159 }
160 }
161 if role != "" {
162 ts.setSubCell(kind, role, sub)
163 }
164 }
165
166 // Find relationship connector: bare <mxCell bausteinsicht_template="relationship">.
167 for _, cell := range tree.FindElements("//mxCell[@bausteinsicht_template='relationship']") {
168 ts.connector = cell.SelectAttrValue("style", "")
169 }
170
171 return ts, nil
172 }
173
174 // GetStyle returns the TemplateStyle for a given element kind.
175 func (t *TemplateSet) GetStyle(kind string) (TemplateStyle, bool) {
176 s, ok := t.elements[kind]
177 return s, ok
178 }
179
180 // GetBoundaryStyle returns the TemplateStyle for a given boundary kind.
181 func (t *TemplateSet) GetBoundaryStyle(kind string) (TemplateStyle, bool) {
182 s, ok := t.boundaries[kind]
183 return s, ok
184 }
185
186 // GetConnectorStyle returns the default connector style string.
187 func (t *TemplateSet) GetConnectorStyle() string {
188 return t.connector
189 }
190
191 // GetAllStyles returns a copy of all element styles keyed by kind.
192 func (t *TemplateSet) GetAllStyles() map[string]TemplateStyle {
193 out := make(map[string]TemplateStyle, len(t.elements))
194 for k, v := range t.elements {
195 out[k] = v
196 }
197 return out
198 }
199
200 // categorize places the style into the appropriate map based on its kind.
201 func (t *TemplateSet) categorize(kind string, style TemplateStyle) {
202 if strings.HasSuffix(kind, "_boundary") {
203 t.boundaries[kind] = style
204 } else {
205 t.elements[kind] = style
206 }
207 }
208
209 // setSubCell assigns a parsed sub-cell style to the appropriate field on a TemplateStyle.
210 func (t *TemplateSet) setSubCell(kind, role string, sub *SubCellStyle) {
211 // Look up in both maps.
212 if ts, ok := t.elements[kind]; ok {
213 setSubCellOnStyle(&ts, role, sub)
214 t.elements[kind] = ts
215 }
216 if ts, ok := t.boundaries[kind]; ok {
217 setSubCellOnStyle(&ts, role, sub)
218 t.boundaries[kind] = ts
219 }
220 }
221
222 func setSubCellOnStyle(ts *TemplateStyle, role string, sub *SubCellStyle) {
223 switch role {
224 case "title":
225 ts.TitleStyle = sub
226 case "tech":
227 ts.TechStyle = sub
228 case "desc":
229 ts.DescStyle = sub
230 }
231 }
232
233 // parseSubCellStyle parses style and geometry from a child mxCell element.
234 func parseSubCellStyle(cell *etree.Element) *SubCellStyle {
235 style := cell.SelectAttrValue("style", "")
236 if style == "" {
237 return nil
238 }
239 geo := cell.FindElement("mxGeometry")
240 if geo == nil {
241 return nil
242 }
243 x, _ := strconv.ParseFloat(geo.SelectAttrValue("x", "0"), 64)
244 y, _ := strconv.ParseFloat(geo.SelectAttrValue("y", "0"), 64)
245 w, _ := strconv.ParseFloat(geo.SelectAttrValue("width", "0"), 64)
246 h, _ := strconv.ParseFloat(geo.SelectAttrValue("height", "0"), 64)
247 return &SubCellStyle{Style: style, X: x, Y: y, Width: w, Height: h}
248 }
249
250 // parseGeometry extracts width and height from an mxCell's nested mxGeometry element.
251 func parseGeometry(cell *etree.Element) (float64, float64) {
252 geo := cell.FindElement("mxGeometry")
253 if geo == nil {
254 return 0, 0
255 }
256 w, _ := strconv.ParseFloat(geo.SelectAttrValue("width", "0"), 64)
257 h, _ := strconv.ParseFloat(geo.SelectAttrValue("height", "0"), 64)
258 return w, h
259 }
260
github.com/docToolchain/Bausteinsicht/internal/export/export.go 93.3%
1 // Package export handles exporting draw.io diagrams to PNG/SVG using the
2 // draw.io CLI.
3 package export
4
5 import (
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strconv"
12 "strings"
13 )
14
15 // ExportOptions configures a single page export operation.
16 type ExportOptions struct {
17 Format string // "png" or "svg"
18 PageIndex int // 1-based page index
19 OutputPath string // full path to output file
20 EmbedDiagram bool // embed draw.io XML source in output
21 InputFile string // path to the .drawio file
22 Scale float64 // export scale factor (0 = default, e.g. 2.0 for retina)
23 }
24
25 // platformPaths is a function variable so tests can override it.
26 var platformPaths = platformDrawioPaths
27
28 // DetectDrawioBinary finds the draw.io CLI binary.
29 // Search order:
30 // 1. "drawio-export" — devcontainer wrapper (Linux, adds xvfb + --no-sandbox)
31 // 2. "drawio" — on PATH (Linux package install)
32 // 3. Platform-native install paths (Windows, macOS) via platformPaths()
33 func DetectDrawioBinary() (string, error) {
34 var searched strings.Builder
35
36 // Try PATH first
37 for _, name := range []string{"drawio-export", "drawio"} {
38 path, err := exec.LookPath(name)
39 if err == nil {
40 return path, nil
41 }
42 fmt.Fprintf(&searched, " PATH: %s not found\n", name)
43 }
44
45 // Try platform-specific paths
46 for _, candidate := range platformPaths() {
47 if _, err := os.Stat(candidate); err == nil {
48 return candidate, nil
49 }
50 fmt.Fprintf(&searched, " %s not found\n", candidate)
51 }
52 return "", buildDrawioNotFoundError(searched.String())
53 }
54
55 // buildDrawioNotFoundError returns a detailed error message with troubleshooting steps and searched paths.
56 func buildDrawioNotFoundError(searchedPaths string) error {
57 msg := strings.Builder{}
58 msg.WriteString("draw.io CLI not found\n")
59 if searchedPaths != "" {
60 msg.WriteString("\nSearched locations:\n")
61 msg.WriteString(searchedPaths)
62 }
63 msg.WriteString("\nInstallation options:\n")
64 msg.WriteString(" Windows (Scoop): scoop install drawio\n")
65 msg.WriteString(" Windows (Choco): choco install drawio\n")
66 msg.WriteString(" macOS (Homebrew): brew install draw.io\n")
67 msg.WriteString(" Linux: See https://www.drawio.com\n\n")
68 msg.WriteString("If already installed, try these troubleshooting steps:\n")
69 msg.WriteString(" 1. Add draw.io to PATH (Scoop): scoop reset drawio\n")
70 msg.WriteString(" 2. Set env var: export BAUSTEINSICHT_DRAWIO_PATH=/path/to/draw.io\n")
71 msg.WriteString(" 3. Use CLI flag: bausteinsicht export --drawio-path /path/to/draw.io\n\n")
72 msg.WriteString("More info: https://github.com/docToolchain/Bausteinsicht/issues/385\n")
73 return errors.New(msg.String())
74 }
75
76 // BuildExportArgs constructs the command-line arguments for a draw.io export.
77 func BuildExportArgs(opts ExportOptions) []string {
78 args := []string{
79 "--export",
80 "--format", opts.Format,
81 "--page-index", strconv.Itoa(opts.PageIndex),
82 "--output", opts.OutputPath,
83 }
84 if opts.EmbedDiagram {
85 args = append(args, "--embed-diagram")
86 }
87 // Only pass --scale for values > 1. Scale=1 is draw.io's native resolution
88 // and does not need an explicit flag. Scale > 1 (e.g. 2.0 for retina) uses
89 // the GPU rendering pipeline and requires hardware GPU acceleration.
90 // Passing --scale 2 in headless containers (where the GPU process is
91 // disabled via ELECTRON_DISABLE_GPU) causes the GPU process to crash with
92 // exit code 9, resulting in a silent export failure (exit 0, no output file).
93 if opts.Scale > 1 {
94 args = append(args, "--scale", fmt.Sprintf("%g", opts.Scale))
95 }
96 args = append(args, "--", opts.InputFile)
97 return args
98 }
99
100 // SafeViewKey strips directory components from a view key to prevent
101 // path traversal when used in filenames (SEC-015).
102 func SafeViewKey(key string) string {
103 key = filepath.Base(strings.ReplaceAll(key, "\\", "/"))
104 return key
105 }
106
107 // OutputFileName returns the canonical output file name for a view export.
108 func OutputFileName(viewKey, format string) string {
109 return fmt.Sprintf("architecture-%s.%s", SafeViewKey(viewKey), format)
110 }
111
112 // ExportPage runs the draw.io CLI to export a single page.
113 func ExportPage(binary string, opts ExportOptions) error {
114 args := BuildExportArgs(opts)
115 cmd := exec.Command(binary, args...) // #nosec G204 -- binary is auto-detected draw.io CLI path
116 output, err := cmd.CombinedOutput()
117 if err != nil {
118 return fmt.Errorf("draw.io export failed: %w\nOutput: %s", err, string(output))
119 }
120 // Verify the output file was actually created (#195).
121 if _, err := os.Stat(opts.OutputPath); err != nil {
122 return fmt.Errorf("draw.io CLI exited successfully but output file not created: %s", opts.OutputPath)
123 }
124 return nil
125 }
126
github.com/docToolchain/Bausteinsicht/internal/export/platform_linux.go 0.0%
1 //go:build linux
2
3 package export
4
5 // platformDrawioPaths returns platform-native draw.io install locations for Linux.
6 // On Linux, draw.io is typically on PATH already; this is a last-resort fallback.
7 func platformDrawioPaths() []string {
8 return []string{
9 "/opt/drawio/drawio",
10 "/usr/lib/drawio/drawio",
11 }
12 }
13
github.com/docToolchain/Bausteinsicht/internal/exporter/structurizr/structurizr.go 93.9%
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 }
248
github.com/docToolchain/Bausteinsicht/internal/graph/analyzer.go 97.2%
1 package graph
2
3 import (
4 "sort"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // Analyzer performs graph analysis on model relationships.
10 type Analyzer struct {
11 model *model.BausteinsichtModel
12 graph map[string][]string // element ID → list of outgoing relationship targets
13 reverse map[string][]string // reverse graph: target → list of incoming sources
14 }
15
16 // NewAnalyzer creates a new graph analyzer.
17 func NewAnalyzer(m *model.BausteinsichtModel) *Analyzer {
18 a := &Analyzer{
19 model: m,
20 graph: make(map[string][]string),
21 reverse: make(map[string][]string),
22 }
23
24 // Build adjacency lists from relationships
25 flatElems, _ := model.FlattenElements(m)
26 for id := range flatElems {
27 a.graph[id] = []string{}
28 a.reverse[id] = []string{}
29 }
30
31 for _, rel := range m.Relationships {
32 if _, ok := flatElems[rel.From]; ok {
33 if _, ok := flatElems[rel.To]; ok {
34 a.graph[rel.From] = append(a.graph[rel.From], rel.To)
35 a.reverse[rel.To] = append(a.reverse[rel.To], rel.From)
36 }
37 }
38 }
39
40 return a
41 }
42
43 // Analyze performs comprehensive graph analysis.
44 func (a *Analyzer) Analyze() *GraphAnalysis {
45 flatElems, _ := model.FlattenElements(a.model)
46
47 result := &GraphAnalysis{
48 ElementCount: len(flatElems),
49 RelationshipCount: len(a.model.Relationships),
50 }
51
52 // Find all cycles
53 result.Cycles = a.findCycles()
54 result.IDAGValid = len(result.Cycles) == 0
55
56 // Calculate centrality metrics
57 result.Centrality = a.calculateCentrality()
58
59 // Find strongly connected components
60 result.Components = a.findStronglyConnectedComponents()
61
62 // Calculate maximum depth (longest path)
63 result.MaxDepth = a.calculateMaxDepth()
64
65 return result
66 }
67
68 // findCycles detects all cycles using Tarjan's algorithm.
69 func (a *Analyzer) findCycles() []Cycle {
70 var cycles []Cycle
71 index := 0
72 stack := []string{}
73 nodeInfo := make(map[string]*NodeInfo)
74
75 var strongconnect func(string)
76 strongconnect = func(v string) {
77 nodeInfo[v] = &NodeInfo{
78 index: index,
79 lowlink: index,
80 onStack: true,
81 }
82 index++
83 stack = append(stack, v)
84
85 for _, w := range a.graph[v] {
86 if _, ok := nodeInfo[w]; !ok {
87 strongconnect(w)
88 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].lowlink)
89 } else if nodeInfo[w].onStack {
90 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].index)
91 }
92 }
93
94 if nodeInfo[v].lowlink == nodeInfo[v].index {
95 var component []string
96 for {
97 w := stack[len(stack)-1]
98 stack = stack[:len(stack)-1]
99 nodeInfo[w].onStack = false
100 component = append(component, w)
101 if w == v {
102 break
103 }
104 }
105
106 // A cycle is an SCC with more than one element
107 if len(component) > 1 {
108 cycles = append(cycles, Cycle{Elements: component, Length: len(component)})
109 }
110 }
111 }
112
113 for v := range a.graph {
114 if _, ok := nodeInfo[v]; !ok {
115 strongconnect(v)
116 }
117 }
118
119 return cycles
120 }
121
122 // calculateCentrality computes centrality metrics for all elements.
123 func (a *Analyzer) calculateCentrality() []Centrality {
124 flatElems, _ := model.FlattenElements(a.model)
125 var results []Centrality
126
127 for id := range flatElems {
128 c := Centrality{
129 ID: id,
130 InDegree: len(a.reverse[id]),
131 OutDegree: len(a.graph[id]),
132 }
133
134 // Betweenness (simplified): count elements that depend on this element
135 betweenness := 0
136 for target := range flatElems {
137 if target != id && a.hasPath(id, target) {
138 betweenness++
139 }
140 }
141 c.Betweenness = float64(betweenness) / float64(len(flatElems)-1)
142
143 // Closeness (simplified): inverse of average distance
144 totalDist := 0
145 reachable := 0
146 for target := range flatElems {
147 if target != id {
148 if dist := a.shortestPath(id, target); dist > 0 {
149 totalDist += dist
150 reachable++
151 }
152 }
153 }
154 if reachable > 0 {
155 c.Closeness = 1.0 / (1.0 + float64(totalDist)/float64(reachable))
156 }
157
158 results = append(results, c)
159 }
160
161 // Sort by ID for consistent output
162 sort.Slice(results, func(i, j int) bool {
163 return results[i].ID < results[j].ID
164 })
165
166 return results
167 }
168
169 // findStronglyConnectedComponents finds all SCCs in the graph.
170 func (a *Analyzer) findStronglyConnectedComponents() []Component {
171 var components []Component
172 index := 0
173 stack := []string{}
174 nodeInfo := make(map[string]*NodeInfo)
175 componentID := 0
176
177 var strongconnect func(string)
178 strongconnect = func(v string) {
179 nodeInfo[v] = &NodeInfo{
180 index: index,
181 lowlink: index,
182 onStack: true,
183 }
184 index++
185 stack = append(stack, v)
186
187 for _, w := range a.graph[v] {
188 if _, ok := nodeInfo[w]; !ok {
189 strongconnect(w)
190 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].lowlink)
191 } else if nodeInfo[w].onStack {
192 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].index)
193 }
194 }
195
196 if nodeInfo[v].lowlink == nodeInfo[v].index {
197 var component []string
198 for {
199 w := stack[len(stack)-1]
200 stack = stack[:len(stack)-1]
201 nodeInfo[w].onStack = false
202 component = append(component, w)
203 if w == v {
204 break
205 }
206 }
207
208 sort.Strings(component)
209 components = append(components, Component{
210 ID: componentID,
211 Elements: component,
212 IsCycle: len(component) > 1,
213 })
214 componentID++
215 }
216 }
217
218 flatElems, _ := model.FlattenElements(a.model)
219 for v := range flatElems {
220 if _, ok := nodeInfo[v]; !ok {
221 strongconnect(v)
222 }
223 }
224
225 return components
226 }
227
228 // calculateMaxDepth finds the longest dependency path in the graph.
229 // In cyclic graphs, returns 0 since there's no defined maximum.
230 func (a *Analyzer) calculateMaxDepth() int {
231 if !a.isDAG() {
232 return 0 // Cyclic graph has undefined max depth
233 }
234
235 flatElems, _ := model.FlattenElements(a.model)
236 maxDepth := 0
237
238 for start := range flatElems {
239 depth := a.longestPathDAG(start)
240 if depth > maxDepth {
241 maxDepth = depth
242 }
243 }
244
245 return maxDepth
246 }
247
248 // isDAG checks if the graph is acyclic.
249 func (a *Analyzer) isDAG() bool {
250 visited := make(map[string]int) // 0=unvisited, 1=visiting, 2=visited
251 var hasCycle bool
252
253 var visit func(string)
254 visit = func(node string) {
255 if visited[node] == 1 {
256 hasCycle = true
257 return
258 }
259 if visited[node] == 2 {
260 return
261 }
262
263 visited[node] = 1
264 for _, neighbor := range a.graph[node] {
265 visit(neighbor)
266 }
267 visited[node] = 2
268 }
269
270 flatElems, _ := model.FlattenElements(a.model)
271 for node := range flatElems {
272 if visited[node] == 0 {
273 visit(node)
274 }
275 }
276
277 return !hasCycle
278 }
279
280 // hasPath checks if there is a path from src to dst (BFS with limit).
281 func (a *Analyzer) hasPath(src, dst string) bool {
282 if src == dst {
283 return true
284 }
285
286 visited := make(map[string]bool)
287 queue := []string{src}
288
289 for len(queue) > 0 {
290 current := queue[0]
291 queue = queue[1:]
292
293 if visited[current] {
294 continue
295 }
296 visited[current] = true
297
298 if current == dst {
299 return true
300 }
301
302 for _, neighbor := range a.graph[current] {
303 if !visited[neighbor] {
304 queue = append(queue, neighbor)
305 }
306 }
307 }
308
309 return false
310 }
311
312 // shortestPath finds the shortest path from src to dst (BFS distance).
313 func (a *Analyzer) shortestPath(src, dst string) int {
314 if src == dst {
315 return 0
316 }
317
318 visited := make(map[string]bool)
319 queue := []string{src}
320 distances := map[string]int{src: 0}
321
322 for len(queue) > 0 {
323 current := queue[0]
324 queue = queue[1:]
325
326 if visited[current] {
327 continue
328 }
329 visited[current] = true
330
331 if current == dst {
332 return distances[current]
333 }
334
335 for _, neighbor := range a.graph[current] {
336 if !visited[neighbor] {
337 if _, ok := distances[neighbor]; !ok {
338 distances[neighbor] = distances[current] + 1
339 queue = append(queue, neighbor)
340 }
341 }
342 }
343 }
344
345 return -1 // no path found
346 }
347
348 // longestPathDAG finds the longest path starting from a node (DFS with memoization).
349 // Only valid for DAGs; cyclic graphs will have max depth 0.
350 func (a *Analyzer) longestPathDAG(start string) int {
351 memo := make(map[string]int)
352 return a.dfsLongestPath(start, memo)
353 }
354
355 func (a *Analyzer) dfsLongestPath(node string, memo map[string]int) int {
356 if depth, ok := memo[node]; ok {
357 return depth
358 }
359
360 maxDepth := 0
361 for _, neighbor := range a.graph[node] {
362 depth := 1 + a.dfsLongestPath(neighbor, memo)
363 if depth > maxDepth {
364 maxDepth = depth
365 }
366 }
367
368 memo[node] = maxDepth
369 return maxDepth
370 }
371
372 func min(a, b int) int {
373 if a < b {
374 return a
375 }
376 return b
377 }
378
github.com/docToolchain/Bausteinsicht/internal/health/analyzer.go 93.0%
1 package health
2
3 import (
4 "fmt"
5 "time"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // Analyzer computes health scores for a model.
11 type Analyzer struct {
12 model *model.BausteinsichtModel
13 }
14
15 // NewAnalyzer creates a new health analyzer.
16 func NewAnalyzer(m *model.BausteinsichtModel) *Analyzer {
17 return &Analyzer{model: m}
18 }
19
20 // Analyze computes a comprehensive health score.
21 func (a *Analyzer) Analyze() *HealthScore {
22 flatElems, _ := model.FlattenElements(a.model)
23
24 categories := []CategoryScore{
25 a.scoreCompleteness(flatElems),
26 a.scoreConformance(flatElems),
27 a.scoreComplexity(flatElems),
28 a.scoreDeprecation(flatElems),
29 a.scoreDocumentation(flatElems),
30 }
31
32 // Calculate weighted overall score
33 var totalScore float64
34 var totalWeight float64
35 for _, cat := range categories {
36 totalScore += cat.Score * cat.Weight
37 totalWeight += cat.Weight
38 }
39
40 overall := totalScore / totalWeight
41 if totalWeight == 0 {
42 overall = 0
43 }
44
45 return &HealthScore{
46 Overall: overall,
47 Categories: categories,
48 Grade: calculateGrade(overall),
49 Summary: summarizeHealth(overall),
50 Timestamp: time.Now().UTC().Format(time.RFC3339),
51 ElementCnt: len(flatElems),
52 RelCnt: len(a.model.Relationships),
53 ViewCnt: len(a.model.Views),
54 }
55 }
56
57 // scoreCompleteness measures how well-documented the model is.
58 func (a *Analyzer) scoreCompleteness(elems map[string]*model.Element) CategoryScore {
59 var findings []Finding
60 documented := 0
61 missing := 0
62
63 for id, elem := range elems {
64 if elem.Title == "" || (elem.Description == "" && elem.Technology == "") {
65 findings = append(findings, Finding{
66 Category: CategoryCompleteness,
67 Severity: "minor",
68 Title: "Missing element description or technology",
69 Message: fmt.Sprintf("element %q lacks description or technology", id),
70 Elements: []string{id},
71 })
72 missing++
73 } else {
74 documented++
75 }
76 }
77
78 score := float64(documented) / float64(documented+missing) * 100
79 if documented+missing == 0 {
80 score = 100
81 }
82
83 return CategoryScore{
84 Category: CategoryCompleteness,
85 Score: score,
86 Weight: 0.2,
87 Findings: findings,
88 Details: fmt.Sprintf("%d/%d elements documented", documented, documented+missing),
89 }
90 }
91
92 // scoreConformance checks for policy violations.
93 func (a *Analyzer) scoreConformance(elems map[string]*model.Element) CategoryScore {
94 var findings []Finding
95
96 // Check for undefined relationship kinds
97 for i, rel := range a.model.Relationships {
98 if rel.Kind != "" {
99 if _, ok := a.model.Specification.Relationships[rel.Kind]; !ok {
100 findings = append(findings, Finding{
101 Category: CategoryConformance,
102 Severity: "major",
103 Title: "Undefined relationship kind",
104 Message: fmt.Sprintf("relationships[%d] uses unknown kind %q", i, rel.Kind),
105 Elements: []string{rel.From, rel.To},
106 })
107 }
108 }
109 }
110
111 // Check for undefined element kinds
112 for id, elem := range elems {
113 if _, ok := a.model.Specification.Elements[elem.Kind]; !ok {
114 findings = append(findings, Finding{
115 Category: CategoryConformance,
116 Severity: "major",
117 Title: "Undefined element kind",
118 Message: fmt.Sprintf("element %q uses unknown kind %q", id, elem.Kind),
119 Elements: []string{id},
120 })
121 }
122 }
123
124 score := 100.0 - float64(len(findings)*5)
125 if score < 0 {
126 score = 0
127 }
128
129 return CategoryScore{
130 Category: CategoryConformance,
131 Score: score,
132 Weight: 0.3,
133 Findings: findings,
134 Details: fmt.Sprintf("%d violations found", len(findings)),
135 }
136 }
137
138 // scoreComplexity assesses architectural complexity.
139 func (a *Analyzer) scoreComplexity(elems map[string]*model.Element) CategoryScore {
140 var findings []Finding
141
142 // Measure relationship density
143 maxRels := len(elems) * (len(elems) - 1)
144 if maxRels == 0 {
145 maxRels = 1
146 }
147 density := float64(len(a.model.Relationships)) / float64(maxRels)
148
149 // Count high-degree nodes (elements with many relationships)
150 inDegree := make(map[string]int)
151 outDegree := make(map[string]int)
152 for _, rel := range a.model.Relationships {
153 inDegree[rel.To]++
154 outDegree[rel.From]++
155 }
156
157 for id, out := range outDegree {
158 if out > 5 {
159 findings = append(findings, Finding{
160 Category: CategoryComplexity,
161 Severity: "major",
162 Title: "High outgoing dependency count",
163 Message: fmt.Sprintf("element %q has %d outgoing relationships (threshold: 5)", id, out),
164 Elements: []string{id},
165 })
166 }
167 }
168
169 // Score based on density and high-degree nodes
170 score := 100.0
171 if density > 0.3 {
172 score -= 20
173 findings = append(findings, Finding{
174 Category: CategoryComplexity,
175 Severity: "minor",
176 Title: "High relationship density",
177 Message: fmt.Sprintf("Architecture has %d relationships across %d elements (density: %.2f)", len(a.model.Relationships), len(elems), density),
178 })
179 }
180 score -= float64(len(findings)) * 3
181
182 if score < 0 {
183 score = 0
184 }
185
186 return CategoryScore{
187 Category: CategoryComplexity,
188 Score: score,
189 Weight: 0.15,
190 Findings: findings,
191 Details: fmt.Sprintf("density: %.2f, high-degree nodes: %d", density, len(findings)),
192 }
193 }
194
195 // scoreDeprecation checks for deprecated elements still in use.
196 func (a *Analyzer) scoreDeprecation(elems map[string]*model.Element) CategoryScore {
197 var findings []Finding
198 deprecated := 0
199 active := 0
200
201 for id, elem := range elems {
202 switch elem.Status {
203 case model.StatusDeprecated:
204 deprecated++
205 findings = append(findings, Finding{
206 Category: CategoryDeprecation,
207 Severity: "major",
208 Title: "Deprecated element still present",
209 Message: fmt.Sprintf("element %q is marked as deprecated", id),
210 Elements: []string{id},
211 })
212 case model.StatusDeployed, "":
213 active++
214 }
215 }
216
217 score := 100.0 - float64(deprecated*10)
218 if score < 0 {
219 score = 0
220 }
221
222 return CategoryScore{
223 Category: CategoryDeprecation,
224 Score: score,
225 Weight: 0.15,
226 Findings: findings,
227 Details: fmt.Sprintf("%d active, %d deprecated", active, deprecated),
228 }
229 }
230
231 // scoreDocumentation measures the quality of documentation.
232 func (a *Analyzer) scoreDocumentation(elems map[string]*model.Element) CategoryScore {
233 var findings []Finding
234 withDocs := 0
235
236 for id, elem := range elems {
237 if elem.Description != "" && len(elem.Description) > 20 {
238 withDocs++
239 } else if elem.Description != "" {
240 findings = append(findings, Finding{
241 Category: CategoryDocumentation,
242 Severity: "minor",
243 Title: "Brief element description",
244 Message: fmt.Sprintf("element %q has short description (< 20 chars)", id),
245 Elements: []string{id},
246 })
247 }
248 }
249
250 score := (float64(withDocs) / float64(len(elems))) * 100
251 if len(elems) == 0 {
252 score = 100
253 }
254
255 return CategoryScore{
256 Category: CategoryDocumentation,
257 Score: score,
258 Weight: 0.2,
259 Findings: findings,
260 Details: fmt.Sprintf("%d/%d elements have substantial descriptions", withDocs, len(elems)),
261 }
262 }
263
264 // summarizeHealth creates a human-readable summary.
265 func summarizeHealth(score float64) string {
266 switch {
267 case score >= 90:
268 return "Excellent architecture. Well-structured, documented, and maintainable."
269 case score >= 80:
270 return "Good architecture. Minor improvements recommended."
271 case score >= 70:
272 return "Acceptable architecture. Several areas need attention."
273 case score >= 60:
274 return "Fair architecture. Multiple improvements needed."
275 default:
276 return "Poor architecture. Significant refactoring recommended."
277 }
278 }
279
github.com/docToolchain/Bausteinsicht/internal/health/types.go 100.0%
1 package health
2
3 // ScoreCategory represents a dimension of architectural health.
4 type ScoreCategory string
5
6 const (
7 CategoryCompleteness ScoreCategory = "completeness"
8 CategoryConformance ScoreCategory = "conformance"
9 CategoryComplexity ScoreCategory = "complexity"
10 CategoryDeprecation ScoreCategory = "deprecation"
11 CategoryDocumentation ScoreCategory = "documentation"
12 )
13
14 // Finding describes a single health issue or improvement area.
15 type Finding struct {
16 Category ScoreCategory `json:"category"`
17 Severity string `json:"severity"` // "critical", "major", "minor", "info"
18 Title string `json:"title"`
19 Message string `json:"message"`
20 Elements []string `json:"elements,omitempty"` // affected element IDs
21 }
22
23 // CategoryScore represents the score for a single dimension.
24 type CategoryScore struct {
25 Category ScoreCategory `json:"category"`
26 Score float64 `json:"score"` // 0-100
27 Weight float64 `json:"weight"` // 0-1
28 Findings []Finding `json:"findings"`
29 Details string `json:"details,omitempty"`
30 }
31
32 // HealthScore is the overall architecture health assessment.
33 type HealthScore struct {
34 Overall float64 `json:"overall"` // 0-100 weighted average
35 Categories []CategoryScore `json:"categories"`
36 Grade string `json:"grade"` // A+, A, B+, B, C+, C, D, F
37 Summary string `json:"summary"`
38 Timestamp string `json:"timestamp"` // ISO8601
39 ElementCnt int `json:"elementCnt"`
40 RelCnt int `json:"relCnt"`
41 ViewCnt int `json:"viewCnt"`
42 }
43
44 // calculateGrade converts a numeric score to a letter grade.
45 func calculateGrade(score float64) string {
46 switch {
47 case score >= 97:
48 return "A+"
49 case score >= 93:
50 return "A"
51 case score >= 90:
52 return "B+"
53 case score >= 87:
54 return "B"
55 case score >= 80:
56 return "C+"
57 case score >= 70:
58 return "C"
59 case score >= 60:
60 return "D"
61 default:
62 return "F"
63 }
64 }
65
github.com/docToolchain/Bausteinsicht/internal/importer/likec4/likec4.go 70.7%
1 // Package likec4 parses LikeC4 DSL files and converts them to the
2 // Bausteinsicht model format.
3 package likec4
4
5 import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "unicode"
11
12 "github.com/docToolchain/Bausteinsicht/internal/importer"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 )
15
16 // ─── Tokenizer (identical grammar to Structurizr) ────────────────────────────
17
18 type tokKind int
19
20 const (
21 tokEOF tokKind = iota
22 tokNewline
23 tokString
24 tokIdent
25 tokLBrace
26 tokRBrace
27 tokAssign
28 tokArrow
29 )
30
31 type token struct {
32 kind tokKind
33 val string
34 line int
35 }
36
37 type scanner struct {
38 src []rune
39 pos int
40 line int
41 }
42
43 func tokenize(src string) ([]token, error) {
44 s := &scanner{src: []rune(src), line: 1}
45 var toks []token
46 for {
47 tok, err := s.next()
48 if err != nil {
49 return nil, err
50 }
51 toks = append(toks, tok)
52 if tok.kind == tokEOF {
53 break
54 }
55 }
56 return toks, nil
57 }
58
59 func (s *scanner) at(offset int) (rune, bool) {
60 i := s.pos + offset
61 if i >= len(s.src) {
62 return 0, false
63 }
64 return s.src[i], true
65 }
66
67 func (s *scanner) consume() rune {
68 r := s.src[s.pos]
69 s.pos++
70 if r == '\n' {
71 s.line++
72 }
73 return r
74 }
75
76 func (s *scanner) next() (token, error) {
77 for {
78 c, ok := s.at(0)
79 if !ok {
80 return token{kind: tokEOF, line: s.line}, nil
81 }
82 if c == ' ' || c == '\t' || c == '\r' {
83 s.consume()
84 continue
85 }
86 if c == '/' {
87 n, _ := s.at(1)
88 if n == '/' {
89 for {
90 ch, ok := s.at(0)
91 if !ok || ch == '\n' {
92 break
93 }
94 s.consume()
95 }
96 continue
97 }
98 if n == '*' {
99 s.consume()
100 s.consume()
101 for {
102 ch, ok := s.at(0)
103 if !ok {
104 return token{}, fmt.Errorf("unterminated block comment")
105 }
106 s.consume()
107 if ch == '*' {
108 if nn, _ := s.at(0); nn == '/' {
109 s.consume()
110 break
111 }
112 }
113 }
114 continue
115 }
116 }
117 break
118 }
119
120 c, ok := s.at(0)
121 if !ok {
122 return token{kind: tokEOF, line: s.line}, nil
123 }
124 line := s.line
125
126 if c == '\n' {
127 for {
128 ch, ok := s.at(0)
129 if !ok || ch != '\n' {
130 break
131 }
132 s.consume()
133 }
134 return token{kind: tokNewline, line: line}, nil
135 }
136
137 switch {
138 case c == '{':
139 s.consume()
140 return token{kind: tokLBrace, val: "{", line: line}, nil
141 case c == '}':
142 s.consume()
143 return token{kind: tokRBrace, val: "}", line: line}, nil
144 case c == '=':
145 s.consume()
146 return token{kind: tokAssign, val: "=", line: line}, nil
147 case c == '-':
148 if n, _ := s.at(1); n == '>' {
149 s.consume()
150 s.consume()
151 return token{kind: tokArrow, val: "->", line: line}, nil
152 }
153 s.consume()
154 return s.next()
155 case c == '"':
156 return s.scanString(line)
157 case unicode.IsLetter(c) || c == '_':
158 return s.scanIdent(line)
159 default:
160 s.consume()
161 return s.next()
162 }
163 }
164
165 func (s *scanner) scanString(line int) (token, error) {
166 s.consume()
167 var sb strings.Builder
168 for {
169 c, ok := s.at(0)
170 if !ok {
171 return token{}, fmt.Errorf("line %d: unterminated string", line)
172 }
173 if c == '"' {
174 s.consume()
175 break
176 }
177 if c == '\\' {
178 s.consume()
179 esc, ok := s.at(0)
180 if !ok {
181 return token{}, fmt.Errorf("line %d: EOF in string escape", line)
182 }
183 s.consume()
184 switch esc {
185 case '"', '\\':
186 sb.WriteRune(esc)
187 case 'n':
188 sb.WriteRune('\n')
189 default:
190 sb.WriteRune('\\')
191 sb.WriteRune(esc)
192 }
193 continue
194 }
195 sb.WriteRune(s.consume())
196 }
197 return token{kind: tokString, val: sb.String(), line: line}, nil
198 }
199
200 func (s *scanner) scanIdent(line int) (token, error) {
201 var sb strings.Builder
202 for {
203 c, ok := s.at(0)
204 if !ok {
205 break
206 }
207 if c == '-' {
208 if n, _ := s.at(1); n == '>' {
209 break
210 }
211 sb.WriteRune(s.consume())
212 continue
213 }
214 if unicode.IsLetter(c) || unicode.IsDigit(c) || c == '_' || c == '.' || c == '/' || c == ':' {
215 sb.WriteRune(s.consume())
216 continue
217 }
218 break
219 }
220 return token{kind: tokIdent, val: sb.String(), line: line}, nil
221 }
222
223 // ─── Parser ──────────────────────────────────────────────────────────────────
224
225 type stmt struct {
226 line int
227 varName string
228 keyword string
229 args []string
230 isRel bool
231 relFrom string
232 relTo string
233 body []stmt
234 }
235
236 type dslParser struct {
237 toks []token
238 pos int
239 }
240
241 func (p *dslParser) peek() token {
242 if p.pos >= len(p.toks) {
243 return token{kind: tokEOF}
244 }
245 return p.toks[p.pos]
246 }
247
248 func (p *dslParser) advance() token {
249 t := p.peek()
250 if t.kind != tokEOF {
251 p.pos++
252 }
253 return t
254 }
255
256 func (p *dslParser) skipNewlines() {
257 for p.peek().kind == tokNewline {
258 p.advance()
259 }
260 }
261
262 func (p *dslParser) parseAll() ([]stmt, error) {
263 return p.parseStmts(false)
264 }
265
266 func (p *dslParser) parseStmts(inBlock bool) ([]stmt, error) {
267 var stmts []stmt
268 for {
269 p.skipNewlines()
270 tok := p.peek()
271 if tok.kind == tokEOF {
272 break
273 }
274 if inBlock && tok.kind == tokRBrace {
275 break
276 }
277 s, err := p.parseOneStmt()
278 if err != nil {
279 return nil, err
280 }
281 if s != nil {
282 stmts = append(stmts, *s)
283 }
284 }
285 return stmts, nil
286 }
287
288 func (p *dslParser) parseBlock() ([]stmt, error) {
289 if p.peek().kind != tokLBrace {
290 return nil, nil
291 }
292 p.advance()
293 p.skipNewlines()
294 stmts, err := p.parseStmts(true)
295 if err != nil {
296 return nil, err
297 }
298 if p.peek().kind == tokRBrace {
299 p.advance()
300 }
301 return stmts, nil
302 }
303
304 func (p *dslParser) optBlock(s *stmt) error {
305 p.skipNewlines()
306 if p.peek().kind == tokLBrace {
307 body, err := p.parseBlock()
308 if err != nil {
309 return err
310 }
311 s.body = body
312 }
313 return nil
314 }
315
316 func (p *dslParser) parseOneStmt() (*stmt, error) {
317 tok := p.peek()
318 if tok.kind == tokEOF || tok.kind == tokRBrace {
319 return nil, nil
320 }
321
322 line := tok.line
323
324 if tok.kind == tokArrow {
325 p.advance()
326 to := p.advance()
327 s := &stmt{line: line, isRel: true, relTo: to.val, args: p.collectArgs()}
328 if err := p.optBlock(s); err != nil {
329 return nil, err
330 }
331 return s, nil
332 }
333
334 if tok.kind == tokLBrace {
335 if _, err := p.parseBlock(); err != nil {
336 return nil, err
337 }
338 return nil, nil
339 }
340
341 if tok.kind != tokIdent && tok.kind != tokString {
342 p.advance()
343 return nil, nil
344 }
345
346 p.advance()
347
348 switch p.peek().kind {
349 case tokAssign:
350 p.advance()
351 kw := p.advance()
352 s := &stmt{line: line, varName: tok.val, keyword: kw.val, args: p.collectArgs()}
353 if err := p.optBlock(s); err != nil {
354 return nil, err
355 }
356 return s, nil
357
358 case tokArrow:
359 p.advance()
360 to := p.advance()
361 s := &stmt{line: line, isRel: true, relFrom: tok.val, relTo: to.val, args: p.collectArgs()}
362 if err := p.optBlock(s); err != nil {
363 return nil, err
364 }
365 return s, nil
366
367 default:
368 s := &stmt{line: line, keyword: tok.val, args: p.collectArgs()}
369 if err := p.optBlock(s); err != nil {
370 return nil, err
371 }
372 return s, nil
373 }
374 }
375
376 func (p *dslParser) collectArgs() []string {
377 var args []string
378 for {
379 k := p.peek().kind
380 if k == tokString || k == tokIdent {
381 args = append(args, p.advance().val)
382 } else {
383 break
384 }
385 }
386 return args
387 }
388
389 // ─── Mapper ──────────────────────────────────────────────────────────────────
390
391 type lc4State struct {
392 kinds map[string]bool // known element kind names from specification
393 kindsContainer map[string]bool // kinds that have children in the model
394 spec map[string]model.ElementKind
395 elements map[string]model.Element
396 varToPath map[string]string
397 pendingRels []pendingRel
398 views map[string]model.View
399 viewKeys map[string]int
400 warnings []string
401 }
402
403 type pendingRel struct {
404 from string
405 to string
406 label string
407 line int
408 }
409
410 func newLC4State() *lc4State {
411 return &lc4State{
412 kinds: make(map[string]bool),
413 kindsContainer: make(map[string]bool),
414 spec: make(map[string]model.ElementKind),
415 elements: make(map[string]model.Element),
416 varToPath: make(map[string]string),
417 views: make(map[string]model.View),
418 viewKeys: make(map[string]int),
419 }
420 }
421
422 func (ls *lc4State) processSpecification(stmts []stmt) {
423 for _, s := range stmts {
424 switch s.keyword {
425 case "element":
426 if len(s.args) == 0 {
427 continue
428 }
429 kindName := s.args[0]
430 ls.kinds[kindName] = true
431 notation := strings.ToUpper(kindName[:1]) + kindName[1:]
432 ls.spec[kindName] = model.ElementKind{Notation: notation}
433 case "relationship":
434 // ignore — Bausteinsicht relationships don't need pre-declared types
435 }
436 }
437 }
438
439 func (ls *lc4State) resolveVar(v string) string {
440 if p, ok := ls.varToPath[v]; ok {
441 return p
442 }
443 return v
444 }
445
446 func (ls *lc4State) processModelStmts(stmts []stmt, parentPath, parentVar string, dest map[string]model.Element) {
447 for _, s := range stmts {
448 ls.processModelStmt(s, parentPath, parentVar, dest)
449 }
450 }
451
452 func (ls *lc4State) processModelStmt(s stmt, parentPath, parentVar string, dest map[string]model.Element) {
453 if s.isRel {
454 from := s.relFrom
455 if from == "" {
456 from = parentVar
457 }
458 label := ""
459 if len(s.args) > 0 {
460 label = s.args[0]
461 }
462 ls.pendingRels = append(ls.pendingRels, pendingRel{from: from, to: s.relTo, label: label, line: s.line})
463 return
464 }
465
466 if !ls.kinds[s.keyword] {
467 // Not a known kind — treat as property inside element body
468 return
469 }
470
471 key := s.varName
472 if key == "" {
473 if len(s.args) > 0 {
474 key = slugify(s.args[0])
475 } else {
476 key = s.keyword
477 }
478 ls.warnings = append(ls.warnings, fmt.Sprintf("line %d: element has no variable name, using %q", s.line, key))
479 }
480
481 path := key
482 if parentPath != "" {
483 path = parentPath + "." + key
484 }
485 ls.varToPath[key] = path
486
487 el := model.Element{Kind: s.keyword}
488 if len(s.args) > 0 {
489 el.Title = s.args[0]
490 }
491
492 children := make(map[string]model.Element)
493 for _, child := range s.body {
494 switch {
495 case child.isRel:
496 from := child.relFrom
497 if from == "" {
498 from = key
499 }
500 label := ""
501 if len(child.args) > 0 {
502 label = child.args[0]
503 }
504 ls.pendingRels = append(ls.pendingRels, pendingRel{from: from, to: child.relTo, label: label, line: child.line})
505 case ls.kinds[child.keyword]:
506 ls.processModelStmt(child, path, key, children)
507 case child.keyword == "description" && len(child.args) > 0:
508 el.Description = child.args[0]
509 case child.keyword == "technology" && len(child.args) > 0:
510 el.Technology = child.args[0]
511 case child.keyword == "title" && len(child.args) > 0:
512 el.Title = child.args[0]
513 case child.keyword == "tags":
514 el.Tags = child.args
515 }
516 }
517
518 if len(children) > 0 {
519 el.Children = children
520 ls.kindsContainer[s.keyword] = true
521 }
522
523 dest[key] = el
524 }
525
526 func (ls *lc4State) processViews(stmts []stmt) {
527 for _, s := range stmts {
528 if s.keyword != "view" {
529 continue
530 }
531
532 // LikeC4: view <key> [of <element>] { ... }
533 // args can be: ["key"], ["key", "of", "element"], or ["key", "of", "element", "title"]
534 viewKey := ""
535 scope := ""
536 title := ""
537
538 args := s.args
539 if len(args) > 0 {
540 viewKey = args[0]
541 args = args[1:]
542 }
543
544 // Check for "of" keyword
545 if len(args) >= 2 && args[0] == "of" {
546 scope = ls.resolveVar(args[1])
547 args = args[2:]
548 }
549
550 title = strings.Join(args, " ")
551
552 if viewKey == "" {
553 baseKey := "view"
554 if scope != "" {
555 baseKey = scope
556 }
557 viewKey = baseKey
558 if ls.viewKeys[baseKey] > 0 {
559 viewKey = fmt.Sprintf("%s_%d", baseKey, ls.viewKeys[baseKey])
560 }
561 }
562 ls.viewKeys[viewKey]++
563
564 if title == "" {
565 title = viewKey
566 }
567
568 v := model.View{Title: title, Scope: scope, Include: []string{"*"}}
569
570 for _, bs := range s.body {
571 switch bs.keyword {
572 case "title":
573 if len(bs.args) > 0 {
574 v.Title = bs.args[0]
575 }
576 case "description":
577 if len(bs.args) > 0 {
578 v.Description = bs.args[0]
579 }
580 case "include":
581 if len(bs.args) == 1 && bs.args[0] == "*" {
582 v.Include = []string{"*"}
583 } else {
584 v.Include = nil
585 for _, arg := range bs.args {
586 if arg == "*" {
587 v.Include = []string{"*"}
588 break
589 }
590 v.Include = append(v.Include, ls.resolveVar(arg))
591 }
592 }
593 case "exclude":
594 for _, arg := range bs.args {
595 v.Exclude = append(v.Exclude, ls.resolveVar(arg))
596 }
597 }
598 }
599
600 ls.views[viewKey] = v
601 }
602 }
603
604 func (ls *lc4State) buildRelationships() []model.Relationship {
605 var rels []model.Relationship
606 for _, pr := range ls.pendingRels {
607 fromPath := ls.resolveVar(pr.from)
608 toPath := ls.resolveVar(pr.to)
609 if fromPath == "" || toPath == "" {
610 ls.warnings = append(ls.warnings, fmt.Sprintf("line %d: relationship skipped (unresolved variable)", pr.line))
611 continue
612 }
613 rels = append(rels, model.Relationship{From: fromPath, To: toPath, Label: pr.label})
614 }
615 return rels
616 }
617
618 func (ls *lc4State) updateSpecWithContainers() {
619 for kind, ek := range ls.spec {
620 if ls.kindsContainer[kind] {
621 ek.Container = true
622 ls.spec[kind] = ek
623 }
624 }
625 }
626
627 func slugify(s string) string {
628 s = strings.ToLower(s)
629 var sb strings.Builder
630 prevUnderscore := false
631 for _, r := range s {
632 if unicode.IsLetter(r) || unicode.IsDigit(r) {
633 sb.WriteRune(r)
634 prevUnderscore = false
635 } else if !prevUnderscore && sb.Len() > 0 {
636 sb.WriteRune('_')
637 prevUnderscore = true
638 }
639 }
640 result := strings.TrimRight(sb.String(), "_")
641 if result == "" {
642 return "element"
643 }
644 return result
645 }
646
647 // ─── Public API ──────────────────────────────────────────────────────────────
648
649 const schemaURL = "https://raw.githubusercontent.com/docToolchain/Bausteinsicht/main/schema/bausteinsicht.schema.json"
650
651 // Import reads the LikeC4 DSL file at path and returns an ImportResult.
652 func Import(path string) (*importer.ImportResult, error) {
653 data, err := os.ReadFile(path)
654 if err != nil {
655 return nil, fmt.Errorf("reading %s: %w", path, err)
656 }
657 _ = filepath.Dir(path) // reserved for future !include support
658 return importSource(string(data))
659 }
660
661 // ImportSource parses a LikeC4 DSL string directly (useful for testing).
662 func ImportSource(src string) (*importer.ImportResult, error) {
663 return importSource(src)
664 }
665
666 func importSource(src string) (*importer.ImportResult, error) {
667 toks, err := tokenize(src)
668 if err != nil {
669 return nil, fmt.Errorf("tokenize: %w", err)
670 }
671
672 p := &dslParser{toks: toks}
673 stmts, err := p.parseAll()
674 if err != nil {
675 return nil, fmt.Errorf("parse: %w", err)
676 }
677
678 ls := newLC4State()
679
680 for _, s := range stmts {
681 switch s.keyword {
682 case "specification":
683 ls.processSpecification(s.body)
684 case "model":
685 ls.processModelStmts(s.body, "", "", ls.elements)
686 case "views":
687 ls.processViews(s.body)
688 }
689 }
690
691 ls.updateSpecWithContainers()
692
693 rels := ls.buildRelationships()
694
695 spec := model.Specification{Elements: make(map[string]model.ElementKind)}
696 for k, v := range ls.spec {
697 spec.Elements[k] = v
698 }
699
700 m := &model.BausteinsichtModel{
701 Schema: schemaURL,
702 Specification: spec,
703 Model: ls.elements,
704 Relationships: rels,
705 Views: ls.views,
706 }
707 if m.Relationships == nil {
708 m.Relationships = []model.Relationship{}
709 }
710 if m.Views == nil {
711 m.Views = make(map[string]model.View)
712 }
713
714 return &importer.ImportResult{Model: m, Warnings: ls.warnings}, nil
715 }
716
github.com/docToolchain/Bausteinsicht/internal/importer/structurizr/structurizr.go 71.8%
1 // Package structurizr parses Structurizr DSL files and converts them to the
2 // Bausteinsicht model format.
3 package structurizr
4
5 import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "unicode"
11
12 "github.com/docToolchain/Bausteinsicht/internal/importer"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 )
15
16 // ─── Tokenizer ───────────────────────────────────────────────────────────────
17
18 type tokKind int
19
20 const (
21 tokEOF tokKind = iota
22 tokNewline // statement separator — emitted for each (group of) newline(s)
23 tokString
24 tokIdent
25 tokLBrace
26 tokRBrace
27 tokAssign
28 tokArrow
29 )
30
31 type token struct {
32 kind tokKind
33 val string
34 line int
35 }
36
37 type scanner struct {
38 src []rune
39 pos int
40 line int
41 }
42
43 func tokenize(src string) ([]token, error) {
44 s := &scanner{src: []rune(src), line: 1}
45 var toks []token
46 for {
47 tok, err := s.next()
48 if err != nil {
49 return nil, err
50 }
51 toks = append(toks, tok)
52 if tok.kind == tokEOF {
53 break
54 }
55 }
56 return toks, nil
57 }
58
59 func (s *scanner) at(offset int) (rune, bool) {
60 i := s.pos + offset
61 if i >= len(s.src) {
62 return 0, false
63 }
64 return s.src[i], true
65 }
66
67 func (s *scanner) consume() rune {
68 r := s.src[s.pos]
69 s.pos++
70 if r == '\n' {
71 s.line++
72 }
73 return r
74 }
75
76 func (s *scanner) next() (token, error) {
77 // Skip horizontal whitespace and handle comments.
78 // Newlines are NOT skipped here — they are emitted as tokNewline.
79 for {
80 c, ok := s.at(0)
81 if !ok {
82 return token{kind: tokEOF, line: s.line}, nil
83 }
84 if c == ' ' || c == '\t' || c == '\r' {
85 s.consume()
86 continue
87 }
88 if c == '/' {
89 n, _ := s.at(1)
90 if n == '/' {
91 // Line comment: consume to end of line (leave \n for next call)
92 for {
93 ch, ok := s.at(0)
94 if !ok || ch == '\n' {
95 break
96 }
97 s.consume()
98 }
99 continue
100 }
101 if n == '*' {
102 s.consume()
103 s.consume()
104 for {
105 ch, ok := s.at(0)
106 if !ok {
107 return token{}, fmt.Errorf("unterminated block comment")
108 }
109 s.consume()
110 if ch == '*' {
111 if nn, _ := s.at(0); nn == '/' {
112 s.consume()
113 break
114 }
115 }
116 }
117 continue
118 }
119 }
120 break
121 }
122
123 c, ok := s.at(0)
124 if !ok {
125 return token{kind: tokEOF, line: s.line}, nil
126 }
127 line := s.line
128
129 // Collapse consecutive newlines into a single tokNewline.
130 if c == '\n' {
131 for {
132 ch, ok := s.at(0)
133 if !ok || ch != '\n' {
134 break
135 }
136 s.consume()
137 }
138 return token{kind: tokNewline, line: line}, nil
139 }
140
141 switch {
142 case c == '{':
143 s.consume()
144 return token{kind: tokLBrace, val: "{", line: line}, nil
145 case c == '}':
146 s.consume()
147 return token{kind: tokRBrace, val: "}", line: line}, nil
148 case c == '=':
149 s.consume()
150 return token{kind: tokAssign, val: "=", line: line}, nil
151 case c == '-':
152 if n, _ := s.at(1); n == '>' {
153 s.consume()
154 s.consume()
155 return token{kind: tokArrow, val: "->", line: line}, nil
156 }
157 s.consume()
158 return s.next()
159 case c == '"':
160 return s.scanString(line)
161 case c == '!' || unicode.IsLetter(c) || c == '_':
162 return s.scanIdent(line)
163 default:
164 s.consume()
165 return s.next()
166 }
167 }
168
169 func (s *scanner) scanString(line int) (token, error) {
170 s.consume()
171 var sb strings.Builder
172 for {
173 c, ok := s.at(0)
174 if !ok {
175 return token{}, fmt.Errorf("line %d: unterminated string", line)
176 }
177 if c == '"' {
178 s.consume()
179 break
180 }
181 if c == '\\' {
182 s.consume()
183 esc, ok := s.at(0)
184 if !ok {
185 return token{}, fmt.Errorf("line %d: EOF in string escape", line)
186 }
187 s.consume()
188 switch esc {
189 case '"', '\\':
190 sb.WriteRune(esc)
191 case 'n':
192 sb.WriteRune('\n')
193 default:
194 sb.WriteRune('\\')
195 sb.WriteRune(esc)
196 }
197 continue
198 }
199 sb.WriteRune(s.consume())
200 }
201 return token{kind: tokString, val: sb.String(), line: line}, nil
202 }
203
204 func (s *scanner) scanIdent(line int) (token, error) {
205 var sb strings.Builder
206 if c, _ := s.at(0); c == '!' {
207 sb.WriteRune(s.consume())
208 }
209 for {
210 c, ok := s.at(0)
211 if !ok {
212 break
213 }
214 if c == '-' {
215 if n, _ := s.at(1); n == '>' {
216 break
217 }
218 sb.WriteRune(s.consume())
219 continue
220 }
221 if unicode.IsLetter(c) || unicode.IsDigit(c) || c == '_' || c == '.' || c == '/' || c == ':' {
222 sb.WriteRune(s.consume())
223 continue
224 }
225 break
226 }
227 return token{kind: tokIdent, val: sb.String(), line: line}, nil
228 }
229
230 // ─── Parser ──────────────────────────────────────────────────────────────────
231
232 // stmt represents one parsed statement in the DSL.
233 type stmt struct {
234 line int
235 varName string
236 keyword string
237 args []string
238 isRel bool
239 relFrom string
240 relTo string
241 body []stmt
242 }
243
244 type dslParser struct {
245 toks []token
246 pos int
247 }
248
249 func (p *dslParser) peek() token {
250 if p.pos >= len(p.toks) {
251 return token{kind: tokEOF}
252 }
253 return p.toks[p.pos]
254 }
255
256 func (p *dslParser) advance() token {
257 t := p.peek()
258 if t.kind != tokEOF {
259 p.pos++
260 }
261 return t
262 }
263
264 func (p *dslParser) skipNewlines() {
265 for p.peek().kind == tokNewline {
266 p.advance()
267 }
268 }
269
270 func (p *dslParser) parseAll() ([]stmt, error) {
271 return p.parseStmts(false)
272 }
273
274 func (p *dslParser) parseStmts(inBlock bool) ([]stmt, error) {
275 var stmts []stmt
276 for {
277 p.skipNewlines()
278 tok := p.peek()
279 if tok.kind == tokEOF {
280 break
281 }
282 if inBlock && tok.kind == tokRBrace {
283 break
284 }
285 s, err := p.parseOneStmt()
286 if err != nil {
287 return nil, err
288 }
289 if s != nil {
290 stmts = append(stmts, *s)
291 }
292 }
293 return stmts, nil
294 }
295
296 func (p *dslParser) parseBlock() ([]stmt, error) {
297 if p.peek().kind != tokLBrace {
298 return nil, nil
299 }
300 p.advance() // {
301 p.skipNewlines()
302 stmts, err := p.parseStmts(true)
303 if err != nil {
304 return nil, err
305 }
306 if p.peek().kind == tokRBrace {
307 p.advance()
308 }
309 return stmts, nil
310 }
311
312 // optBlock skips newlines then reads a block if the next token is {.
313 func (p *dslParser) optBlock(s *stmt) error {
314 p.skipNewlines()
315 if p.peek().kind == tokLBrace {
316 body, err := p.parseBlock()
317 if err != nil {
318 return err
319 }
320 s.body = body
321 }
322 return nil
323 }
324
325 func (p *dslParser) parseOneStmt() (*stmt, error) {
326 tok := p.peek()
327 if tok.kind == tokEOF || tok.kind == tokRBrace {
328 return nil, nil
329 }
330
331 line := tok.line
332
333 if tok.kind == tokArrow {
334 p.advance()
335 to := p.advance()
336 s := &stmt{line: line, isRel: true, relTo: to.val, args: p.collectArgs()}
337 if err := p.optBlock(s); err != nil {
338 return nil, err
339 }
340 return s, nil
341 }
342
343 if tok.kind == tokLBrace {
344 if _, err := p.parseBlock(); err != nil {
345 return nil, err
346 }
347 return nil, nil
348 }
349
350 if tok.kind != tokIdent && tok.kind != tokString {
351 p.advance()
352 return nil, nil
353 }
354
355 p.advance()
356
357 switch p.peek().kind {
358 case tokAssign:
359 p.advance()
360 kw := p.advance()
361 s := &stmt{line: line, varName: tok.val, keyword: kw.val, args: p.collectArgs()}
362 if err := p.optBlock(s); err != nil {
363 return nil, err
364 }
365 return s, nil
366
367 case tokArrow:
368 p.advance()
369 to := p.advance()
370 s := &stmt{line: line, isRel: true, relFrom: tok.val, relTo: to.val, args: p.collectArgs()}
371 if err := p.optBlock(s); err != nil {
372 return nil, err
373 }
374 return s, nil
375
376 default:
377 s := &stmt{line: line, keyword: tok.val, args: p.collectArgs()}
378 if err := p.optBlock(s); err != nil {
379 return nil, err
380 }
381 return s, nil
382 }
383 }
384
385 func (p *dslParser) collectArgs() []string {
386 var args []string
387 for {
388 k := p.peek().kind
389 if k == tokString || k == tokIdent {
390 args = append(args, p.advance().val)
391 } else {
392 break
393 }
394 }
395 return args
396 }
397
398 // ─── Mapper ──────────────────────────────────────────────────────────────────
399
400 type kindDef struct {
401 kind string
402 notation string
403 container bool
404 }
405
406 // elementKindOrder defines the canonical C4 layer order for specification.elements.
407 var elementKindOrder = []kindDef{
408 {"person", "Person", false},
409 {"system", "Software System", true},
410 {"container", "Container", true},
411 {"component", "Component", false},
412 }
413
414 var structurizrKindMap = map[string]kindDef{
415 "person": elementKindOrder[0],
416 "softwareSystem": elementKindOrder[1],
417 "container": elementKindOrder[2],
418 "component": elementKindOrder[3],
419 }
420
421 type pendingRel struct {
422 from string
423 to string
424 label string
425 line int
426 }
427
428 type importState struct {
429 specAdded map[string]bool
430 spec map[string]model.ElementKind
431 elements map[string]model.Element
432 varToPath map[string]string
433 pendingRels []pendingRel
434 views map[string]model.View
435 viewKeys map[string]int
436 warnings []string
437 }
438
439 func newImportState() *importState {
440 return &importState{
441 specAdded: make(map[string]bool),
442 spec: make(map[string]model.ElementKind),
443 elements: make(map[string]model.Element),
444 varToPath: make(map[string]string),
445 views: make(map[string]model.View),
446 viewKeys: make(map[string]int),
447 }
448 }
449
450 func (is *importState) registerKind(kw string) {
451 kd := structurizrKindMap[kw]
452 if !is.specAdded[kd.kind] {
453 is.spec[kd.kind] = model.ElementKind{
454 Notation: kd.notation,
455 Container: kd.container,
456 }
457 is.specAdded[kd.kind] = true
458 }
459 }
460
461 func (is *importState) resolveVar(v string) string {
462 if p, ok := is.varToPath[v]; ok {
463 return p
464 }
465 return v
466 }
467
468 func (is *importState) processModelStmts(stmts []stmt, parentPath, parentVar string, dest map[string]model.Element) {
469 for _, s := range stmts {
470 is.processModelStmt(s, parentPath, parentVar, dest)
471 }
472 }
473
474 func (is *importState) processModelStmt(s stmt, parentPath, parentVar string, dest map[string]model.Element) {
475 if s.isRel {
476 from := s.relFrom
477 if from == "" {
478 from = parentVar
479 }
480 label := ""
481 if len(s.args) > 0 {
482 label = s.args[0]
483 }
484 is.pendingRels = append(is.pendingRels, pendingRel{from: from, to: s.relTo, label: label, line: s.line})
485 return
486 }
487
488 kd, isElement := structurizrKindMap[s.keyword]
489 if !isElement {
490 switch s.keyword {
491 case "enterprise", "group":
492 is.processModelStmts(s.body, parentPath, parentVar, dest)
493 }
494 return
495 }
496
497 is.registerKind(s.keyword)
498
499 key := s.varName
500 if key == "" {
501 if len(s.args) > 0 {
502 key = slugify(s.args[0])
503 } else {
504 key = kd.kind
505 }
506 is.warnings = append(is.warnings, fmt.Sprintf("line %d: element has no variable name, using %q", s.line, key))
507 }
508
509 path := key
510 if parentPath != "" {
511 path = parentPath + "." + key
512 }
513 is.varToPath[key] = path
514
515 el := model.Element{Kind: kd.kind}
516 if len(s.args) > 0 {
517 el.Title = s.args[0]
518 }
519 if len(s.args) > 1 {
520 el.Description = s.args[1]
521 }
522 if (kd.kind == "container" || kd.kind == "component") && len(s.args) > 2 {
523 el.Technology = s.args[2]
524 }
525
526 children := make(map[string]model.Element)
527 for _, child := range s.body {
528 switch {
529 case child.isRel:
530 from := child.relFrom
531 if from == "" {
532 from = key
533 }
534 label := ""
535 if len(child.args) > 0 {
536 label = child.args[0]
537 }
538 is.pendingRels = append(is.pendingRels, pendingRel{from: from, to: child.relTo, label: label, line: child.line})
539 case structurizrKindMap[child.keyword].kind != "":
540 is.processModelStmt(child, path, key, children)
541 case child.keyword == "description" && len(child.args) > 0:
542 el.Description = child.args[0]
543 case child.keyword == "technology" && len(child.args) > 0:
544 el.Technology = child.args[0]
545 case child.keyword == "tags":
546 el.Tags = child.args
547 case child.keyword == "properties":
548 el.Metadata = parseProperties(child.body)
549 }
550 }
551
552 if len(children) > 0 {
553 el.Children = children
554 }
555 dest[key] = el
556 }
557
558 func (is *importState) processViewsStmts(stmts []stmt) {
559 for _, s := range stmts {
560 switch s.keyword {
561 case "systemContext", "container", "component", "systemLandscape":
562 case "filtered", "dynamic", "deployment":
563 is.warnings = append(is.warnings, fmt.Sprintf("line %d: %s view not supported, skipped", s.line, s.keyword))
564 continue
565 default:
566 continue
567 }
568
569 scope := ""
570 if s.keyword != "systemLandscape" && len(s.args) > 0 {
571 scope = is.resolveVar(s.args[0])
572 }
573
574 titleArgs := s.args
575 if scope != "" {
576 titleArgs = s.args[1:]
577 }
578 title := strings.Join(titleArgs, " ")
579
580 baseKey := s.keyword
581 if scope != "" {
582 baseKey = scope
583 }
584 viewKey := baseKey
585 if is.viewKeys[baseKey] > 0 {
586 viewKey = fmt.Sprintf("%s_%d", baseKey, is.viewKeys[baseKey])
587 }
588 is.viewKeys[baseKey]++
589
590 if title == "" {
591 title = viewKey
592 }
593
594 v := model.View{Title: title, Scope: scope, Include: []string{"*"}}
595
596 for _, bs := range s.body {
597 switch bs.keyword {
598 case "include":
599 if len(bs.args) == 1 && bs.args[0] == "*" {
600 v.Include = []string{"*"}
601 } else {
602 v.Include = nil
603 for _, arg := range bs.args {
604 if arg != "*" {
605 v.Include = append(v.Include, is.resolveVar(arg))
606 } else {
607 v.Include = []string{"*"}
608 break
609 }
610 }
611 }
612 case "exclude":
613 for _, arg := range bs.args {
614 v.Exclude = append(v.Exclude, is.resolveVar(arg))
615 }
616 case "title":
617 if len(bs.args) > 0 {
618 v.Title = bs.args[0]
619 }
620 case "description":
621 if len(bs.args) > 0 {
622 v.Description = bs.args[0]
623 }
624 case "autoLayout":
625 v.Layout = "auto"
626 }
627 }
628
629 is.views[viewKey] = v
630 }
631 }
632
633 func (is *importState) buildRelationships() []model.Relationship {
634 var rels []model.Relationship
635 for _, pr := range is.pendingRels {
636 fromPath := is.resolveVar(pr.from)
637 toPath := is.resolveVar(pr.to)
638 if fromPath == "" || toPath == "" {
639 is.warnings = append(is.warnings, fmt.Sprintf("line %d: relationship skipped (unresolved variable)", pr.line))
640 continue
641 }
642 rels = append(rels, model.Relationship{From: fromPath, To: toPath, Label: pr.label})
643 }
644 return rels
645 }
646
647 func parseProperties(body []stmt) map[string]string {
648 m := make(map[string]string)
649 for _, s := range body {
650 if s.keyword != "" && len(s.args) > 0 {
651 m[s.keyword] = s.args[0]
652 }
653 }
654 return m
655 }
656
657 func slugify(s string) string {
658 s = strings.ToLower(s)
659 var sb strings.Builder
660 prevUnderscore := false
661 for _, r := range s {
662 if unicode.IsLetter(r) || unicode.IsDigit(r) {
663 sb.WriteRune(r)
664 prevUnderscore = false
665 } else if !prevUnderscore && sb.Len() > 0 {
666 sb.WriteRune('_')
667 prevUnderscore = true
668 }
669 }
670 result := strings.TrimRight(sb.String(), "_")
671 if result == "" {
672 return "element"
673 }
674 return result
675 }
676
677 // ─── Public API ──────────────────────────────────────────────────────────────
678
679 const schemaURL = "https://raw.githubusercontent.com/docToolchain/Bausteinsicht/main/schema/bausteinsicht.schema.json"
680
681 // ImportSource parses a Structurizr DSL string directly (useful for testing).
682 func ImportSource(src string) (*importer.ImportResult, error) {
683 return importSource(src)
684 }
685
686 // Import reads the Structurizr DSL file at path and returns an ImportResult.
687 func Import(path string) (*importer.ImportResult, error) {
688 data, err := os.ReadFile(path)
689 if err != nil {
690 return nil, fmt.Errorf("reading %s: %w", path, err)
691 }
692 baseDir := filepath.Dir(path)
693 src, includeWarnings := resolveIncludes(string(data), baseDir, map[string]bool{})
694 result, err := importSource(src)
695 if err != nil {
696 return nil, err
697 }
698 result.Warnings = append(includeWarnings, result.Warnings...)
699 return result, nil
700 }
701
702 func resolveIncludes(src, baseDir string, visited map[string]bool) (string, []string) {
703 var warnings []string
704 var out strings.Builder
705 absDirBase, _ := filepath.Abs(baseDir)
706 for _, line := range strings.Split(src, "\n") {
707 trimmed := strings.TrimSpace(line)
708 if strings.HasPrefix(trimmed, "!include ") {
709 includePath := strings.TrimSpace(trimmed[len("!include "):])
710 if strings.HasPrefix(includePath, "http://") || strings.HasPrefix(includePath, "https://") {
711 warnings = append(warnings, "!include: HTTP includes not supported, skipped: "+includePath)
712 out.WriteByte('\n')
713 continue
714 }
715 cleanedPath := filepath.Clean(includePath)
716 fullPath := filepath.Join(baseDir, cleanedPath)
717 absFullPath, _ := filepath.Abs(fullPath)
718
719 // Verify that the resolved path is within baseDir (prevent path traversal).
720 // Use filepath.Rel to check if the path escapes the base directory via .. sequences.
721 relPath, err := filepath.Rel(absDirBase, absFullPath)
722 if err != nil || strings.HasPrefix(relPath, "..") {
723 warnings = append(warnings, "!include: path traversal rejected: "+includePath)
724 out.WriteByte('\n')
725 continue
726 }
727
728 if visited[absFullPath] {
729 warnings = append(warnings, "!include: circular include ignored: "+includePath)
730 out.WriteByte('\n')
731 continue
732 }
733 data, err := os.ReadFile(absFullPath)
734 if err != nil {
735 warnings = append(warnings, fmt.Sprintf("!include: cannot read %s: %v", includePath, err))
736 out.WriteByte('\n')
737 continue
738 }
739 newVisited := make(map[string]bool, len(visited)+1)
740 for k, v := range visited {
741 newVisited[k] = v
742 }
743 newVisited[absFullPath] = true
744 included, w := resolveIncludes(string(data), filepath.Dir(absFullPath), newVisited)
745 warnings = append(warnings, w...)
746 out.WriteString(included)
747 out.WriteByte('\n')
748 continue
749 }
750 out.WriteString(line)
751 out.WriteByte('\n')
752 }
753 return out.String(), warnings
754 }
755
756 func importSource(src string) (*importer.ImportResult, error) {
757 toks, err := tokenize(src)
758 if err != nil {
759 return nil, fmt.Errorf("tokenize: %w", err)
760 }
761
762 p := &dslParser{toks: toks}
763 stmts, err := p.parseAll()
764 if err != nil {
765 return nil, fmt.Errorf("parse: %w", err)
766 }
767
768 is := newImportState()
769
770 var modelStmts, viewsStmts []stmt
771 for _, s := range stmts {
772 switch s.keyword {
773 case "workspace":
774 for _, ws := range s.body {
775 switch ws.keyword {
776 case "model":
777 modelStmts = ws.body
778 case "views":
779 viewsStmts = ws.body
780 }
781 }
782 case "model":
783 modelStmts = s.body
784 case "views":
785 viewsStmts = s.body
786 }
787 }
788
789 is.processModelStmts(modelStmts, "", "", is.elements)
790 if len(viewsStmts) > 0 {
791 is.processViewsStmts(viewsStmts)
792 }
793
794 rels := is.buildRelationships()
795
796 spec := model.Specification{Elements: make(map[string]model.ElementKind)}
797 for _, kd := range elementKindOrder {
798 if ek, ok := is.spec[kd.kind]; ok {
799 spec.Elements[kd.kind] = ek
800 }
801 }
802
803 m := &model.BausteinsichtModel{
804 Schema: schemaURL,
805 Specification: spec,
806 Model: is.elements,
807 Relationships: rels,
808 Views: is.views,
809 }
810 if m.Relationships == nil {
811 m.Relationships = []model.Relationship{}
812 }
813 if m.Views == nil {
814 m.Views = make(map[string]model.View)
815 }
816
817 return &importer.ImportResult{Model: m, Warnings: is.warnings}, nil
818 }
819
github.com/docToolchain/Bausteinsicht/internal/layout/apply.go 0.0%
1 package layout
2
3 import (
4 "fmt"
5
6 "github.com/beevik/etree"
7 "github.com/docToolchain/Bausteinsicht/internal/drawio"
8 )
9
10 // Apply applies layout positions to draw.io diagram.
11 // Reads pinned status from existing draw.io elements and respects PreservePinned setting.
12 func Apply(doc *drawio.Document, result LayoutResult, preservePinned bool) error {
13 if len(result.Positions) == 0 {
14 return fmt.Errorf("layout result is empty")
15 }
16
17 // Build map of existing draw.io elements with their pinned status
18 pinnedMap := readPinnedStatus(doc)
19
20 // For each computed position, update the draw.io element
21 for elemID, pos := range result.Positions {
22 // Skip pinned elements if preservePinned is enabled
23 if preservePinned && pinnedMap[elemID] {
24 continue
25 }
26
27 // Find and update element in draw.io
28 if err := updateElementPosition(doc, elemID, pos); err != nil {
29 continue
30 }
31 }
32
33 return nil
34 }
35
36 // readPinnedStatus reads the bausteinsicht-pinned property from draw.io elements.
37 func readPinnedStatus(doc *drawio.Document) map[string]bool {
38 pinned := make(map[string]bool)
39
40 for _, page := range doc.Pages() {
41 if root := page.Root(); root != nil {
42 walkElements(root, func(elem *etree.Element) {
43 if id, ok := getAttr(elem, "bausteinsicht_id"); ok {
44 if pinValue, ok := getAttr(elem, "bausteinsicht-pinned"); ok && pinValue == "true" {
45 pinned[id] = true
46 }
47 }
48 })
49 }
50 }
51
52 return pinned
53 }
54
55 // updateElementPosition updates the x, y, width, height of a draw.io element.
56 func updateElementPosition(doc *drawio.Document, elemID string, pos ElementPosition) error {
57 for _, page := range doc.Pages() {
58 if root := page.Root(); root != nil {
59 found := false
60 walkElements(root, func(elem *etree.Element) {
61 if found {
62 return
63 }
64 if id, ok := getAttr(elem, "bausteinsicht_id"); ok && id == elemID {
65 // Find mxGeometry child and update coordinates
66 for _, child := range elem.ChildElements() {
67 if child.Tag == "mxGeometry" {
68 child.CreateAttr("x", fmt.Sprintf("%.0f", pos.X))
69 child.CreateAttr("y", fmt.Sprintf("%.0f", pos.Y))
70 child.CreateAttr("width", fmt.Sprintf("%.0f", pos.Width))
71 child.CreateAttr("height", fmt.Sprintf("%.0f", pos.Height))
72 found = true
73 break
74 }
75 }
76 }
77 })
78 if found {
79 return nil
80 }
81 }
82 }
83
84 return fmt.Errorf("element %s not found in diagram", elemID)
85 }
86
87 // walkElements recursively walks through all elements in the tree.
88 func walkElements(elem *etree.Element, fn func(*etree.Element)) {
89 fn(elem)
90 for _, child := range elem.ChildElements() {
91 walkElements(child, fn)
92 }
93 }
94
95 // getAttr extracts attribute value from element safely.
96 func getAttr(elem *etree.Element, name string) (string, bool) {
97 for _, attr := range elem.Attr {
98 if attr.Key == name {
99 return attr.Value, true
100 }
101 }
102 return "", false
103 }
104
github.com/docToolchain/Bausteinsicht/internal/layout/hierarchical.go 98.1%
1 package layout
2
3 import (
4 "github.com/docToolchain/Bausteinsicht/internal/model"
5 )
6
7 // HierarchicalLayout computes layer assignments via longest-path algorithm,
8 // then positions elements in layers with horizontal alignment.
9 type HierarchicalLayout struct {
10 model *model.BausteinsichtModel
11 rankDir string // TB or LR
12 spacing float64
13 layerGap float64
14 }
15
16 // NewHierarchicalLayout creates a hierarchical layout engine.
17 func NewHierarchicalLayout(m *model.BausteinsichtModel, rankDir string) *HierarchicalLayout {
18 if rankDir == "" {
19 rankDir = "TB"
20 }
21 return &HierarchicalLayout{
22 model: m,
23 rankDir: rankDir,
24 spacing: 20, // pixels between elements in same layer
25 layerGap: 100, // pixels between layers
26 }
27 }
28
29 // Compute calculates positions for all elements.
30 func (h *HierarchicalLayout) Compute() LayoutResult {
31 outgoing := h.buildOutgoingMap()
32
33 // Assign layers using longest-path algorithm
34 layers := h.assignLayers(outgoing)
35
36 // Position elements based on layers
37 positions := h.positionElements(layers)
38
39 return LayoutResult{
40 Positions: positions,
41 Algorithm: Hierarchical,
42 }
43 }
44
45 // buildOutgoingMap creates a map of outgoing relationships per element.
46 func (h *HierarchicalLayout) buildOutgoingMap() map[string][]string {
47 outgoing := make(map[string][]string)
48 for _, rel := range h.model.Relationships {
49 outgoing[rel.From] = append(outgoing[rel.From], rel.To)
50 }
51 return outgoing
52 }
53
54 // assignLayers assigns each element to a layer using longest-path algorithm.
55 func (h *HierarchicalLayout) assignLayers(outgoing map[string][]string) map[int][]string {
56 flat, _ := model.FlattenElements(h.model)
57
58 // Compute longest path from each node
59 depths := make(map[string]int)
60 for id := range flat {
61 depths[id] = h.longestPath(id, outgoing, make(map[string]bool))
62 }
63
64 // Group elements by layer
65 layers := make(map[int][]string)
66 maxLayer := 0
67 for id, depth := range depths {
68 layers[depth] = append(layers[depth], id)
69 if depth > maxLayer {
70 maxLayer = depth
71 }
72 }
73
74 return layers
75 }
76
77 // longestPath computes longest outgoing path from a node (memoized).
78 func (h *HierarchicalLayout) longestPath(id string, outgoing map[string][]string, visited map[string]bool) int {
79 if visited[id] {
80 return 0 // cycle detected, break here
81 }
82
83 targets := outgoing[id]
84 if len(targets) == 0 {
85 return 0
86 }
87
88 visited[id] = true
89 maxDepth := 0
90 for _, target := range targets {
91 depth := h.longestPath(target, outgoing, visited)
92 if depth > maxDepth {
93 maxDepth = depth
94 }
95 }
96 delete(visited, id)
97
98 return maxDepth + 1
99 }
100
101 // positionElements places elements horizontally within each layer.
102 func (h *HierarchicalLayout) positionElements(layers map[int][]string) map[string]ElementPosition {
103 positions := make(map[string]ElementPosition)
104
105 for layer, ids := range layers {
106 // Default sizes
107 elemWidth := 160.0
108 elemHeight := 60.0
109
110 // Calculate layer positions
111 var x, y float64
112 if h.rankDir == "TB" {
113 // Top-to-bottom: layer determines Y, elements spread horizontally
114 y = float64(layer) * h.layerGap
115 x = 50.0
116 } else {
117 // Left-to-right: layer determines X, elements spread vertically
118 x = float64(layer) * h.layerGap
119 y = 50.0
120 }
121
122 // Position elements in this layer
123 for i, id := range ids {
124 elemX, elemY := x, y
125 if h.rankDir == "TB" {
126 elemX = x + float64(i)*(elemWidth+h.spacing)
127 } else {
128 elemY = y + float64(i)*(elemHeight+h.spacing)
129 }
130
131 positions[id] = ElementPosition{
132 ID: id,
133 X: elemX,
134 Y: elemY,
135 Width: elemWidth,
136 Height: elemHeight,
137 Layer: layer,
138 }
139 }
140 }
141
142 return positions
143 }
144
github.com/docToolchain/Bausteinsicht/internal/lsp/codelens.go 97.3%
1 package lsp
2
3 import (
4 "fmt"
5 "path/filepath"
6 "regexp"
7 "strings"
8 )
9
10 type CodeLens struct {
11 Range Range `json:"range"`
12 Command *Command `json:"command,omitempty"`
13 Data interface{} `json:"data,omitempty"`
14 }
15
16 type Command struct {
17 Title string `json:"title"`
18 Command string `json:"command"`
19 Arguments []interface{} `json:"arguments,omitempty"`
20 }
21
22 type CodeLensData struct {
23 ElementID string
24 Kind string
25 Status string
26 ViewCount int
27 }
28
29 // GenerateCodeLens extracts element definitions from the document and generates CodeLens objects.
30 func GenerateCodeLens(doc *Document) []CodeLens {
31 // Check if filename matches *architecture*.jsonc pattern
32 base := filepath.Base(doc.Filename)
33 if !strings.Contains(base, "architecture") || !strings.HasSuffix(base, ".jsonc") {
34 return nil
35 }
36
37 var lenses []CodeLens
38 lines := strings.Split(doc.Content, "\n")
39
40 // Pattern: "elementName": { or "elementName": {, matching JSON object keys
41 elementPattern := regexp.MustCompile(`^\s*"([a-zA-Z_][a-zA-Z0-9_]*)"\s*:\s*{`)
42
43 for i, line := range lines {
44 matches := elementPattern.FindStringSubmatch(line)
45 if len(matches) < 2 {
46 continue
47 }
48
49 elementID := matches[1]
50
51 // Skip parent keys like "model", "views", etc.
52 if elementID == "model" || elementID == "views" || elementID == "relationships" {
53 continue
54 }
55
56 // Extract metadata (kind and status from nearby lines)
57 kind := extractKind(lines, i)
58 status := extractStatus(lines, i)
59 viewCount := estimateViewCount(doc.Content, elementID)
60
61 // Create CodeLens entry
62 lens := CodeLens{
63 Range: Range{
64 Start: Position{Line: i, Character: 0},
65 End: Position{Line: i, Character: len(line)},
66 },
67 Command: &Command{
68 Title: fmt.Sprintf("%s | status: %s | views: %d", kind, status, viewCount),
69 Command: "bausteinsicht.openInDrawio",
70 Arguments: []interface{}{
71 elementID,
72 map[string]interface{}{
73 "kind": kind,
74 "status": status,
75 "views": viewCount,
76 },
77 },
78 },
79 }
80
81 lenses = append(lenses, lens)
82 }
83
84 return lenses
85 }
86
87 // extractKind finds the "kind" field value in the element definition.
88 func extractKind(lines []string, startLine int) string {
89 // Search within the next 10 lines for a "kind" field
90 for i := startLine; i < startLine+10 && i < len(lines); i++ {
91 if strings.Contains(lines[i], `"kind"`) {
92 // Extract value: "kind": "service" → service
93 kindPattern := regexp.MustCompile(`"kind"\s*:\s*"([^"]*)"`)
94 matches := kindPattern.FindStringSubmatch(lines[i])
95 if len(matches) > 1 {
96 return matches[1]
97 }
98 }
99 }
100 return "unknown"
101 }
102
103 // extractStatus finds the "status" field value in the element definition.
104 func extractStatus(lines []string, startLine int) string {
105 // Search within the next 10 lines for a "status" field
106 for i := startLine; i < startLine+10 && i < len(lines); i++ {
107 if strings.Contains(lines[i], `"status"`) {
108 // Extract value: "status": "active" → active
109 statusPattern := regexp.MustCompile(`"status"\s*:\s*"([^"]*)"`)
110 matches := statusPattern.FindStringSubmatch(lines[i])
111 if len(matches) > 1 {
112 return matches[1]
113 }
114 }
115 }
116 return "active"
117 }
118
119 // estimateViewCount counts how many views reference this element.
120 func estimateViewCount(content string, elementID string) int {
121 // Count occurrences of the element ID in the document (rough estimate)
122 // A more precise implementation would parse the model and count actual view references
123 count := strings.Count(content, elementID) - 1 // Subtract 1 for the element definition itself
124 if count < 0 {
125 count = 0
126 }
127 return count
128 }
129
github.com/docToolchain/Bausteinsicht/internal/lsp/diagnostics.go 83.7%
1 package lsp
2
3 import (
4 "bytes"
5 "encoding/json"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 )
10
11 type Diagnostic struct {
12 Range Range `json:"range"`
13 Message string `json:"message"`
14 Severity int `json:"severity"`
15 Source string `json:"source"`
16 }
17
18 type Range struct {
19 Start Position `json:"start"`
20 End Position `json:"end"`
21 }
22
23 type Position struct {
24 Line int `json:"line"`
25 Character int `json:"character"`
26 }
27
28 const (
29 DiagnosticError = 1
30 DiagnosticWarning = 2
31 DiagnosticInfo = 3
32 DiagnosticHint = 4
33 )
34
35 type ValidateOutput struct {
36 Valid bool `json:"valid"`
37 Errors []ValidationError `json:"errors,omitempty"`
38 Warnings []ValidationWarning `json:"warnings,omitempty"`
39 }
40
41 type ValidationError struct {
42 Path string `json:"path"`
43 Message string `json:"message"`
44 Line int `json:"line,omitempty"`
45 }
46
47 type ValidationWarning struct {
48 Path string `json:"path"`
49 Message string `json:"message"`
50 Line int `json:"line,omitempty"`
51 }
52
53 func ValidateDocument(doc *Document, workDir string) []Diagnostic {
54 // Check if filename matches *architecture*.jsonc pattern
55 base := filepath.Base(doc.Filename)
56 if !strings.Contains(base, "architecture") || !strings.HasSuffix(base, ".jsonc") {
57 return nil
58 }
59
60 // Validate path to prevent directory traversal (SEC-001)
61 cleanPath := filepath.Clean(doc.Filename)
62 for _, component := range strings.Split(cleanPath, string(filepath.Separator)) {
63 if component == ".." {
64 return []Diagnostic{
65 {
66 Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 10}},
67 Message: "Invalid path: contains directory traversal",
68 Severity: DiagnosticError,
69 Source: "bausteinsicht",
70 },
71 }
72 }
73 }
74
75 // Call bausteinsicht validate --format json
76 cmd := exec.Command("bausteinsicht", "validate", "--format", "json", "--model", doc.Filename)
77 cmd.Dir = workDir
78
79 var stdout bytes.Buffer
80 var stderr bytes.Buffer
81 cmd.Stdout = &stdout
82 cmd.Stderr = &stderr
83
84 if err := cmd.Run(); err != nil {
85 // Command failed, but we might still get useful output from stdout/stderr
86 _ = err
87 }
88
89 // Parse JSON output
90 var output ValidateOutput
91 if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
92 // If parsing fails, try to extract errors from stderr
93 return parseValidationErrors(stderr.String())
94 }
95
96 return convertValidateOutput(&output, doc)
97 }
98
99 func convertValidateOutput(output *ValidateOutput, doc *Document) []Diagnostic {
100 var diags []Diagnostic
101
102 // Process errors
103 for _, err := range output.Errors {
104 line, char := findLineInDocument(doc, err.Path, err.Line)
105 diags = append(diags, Diagnostic{
106 Range: Range{
107 Start: Position{Line: line, Character: char},
108 End: Position{Line: line, Character: char + 10},
109 },
110 Message: err.Message,
111 Severity: DiagnosticError,
112 Source: "bausteinsicht",
113 })
114 }
115
116 // Process warnings
117 for _, warn := range output.Warnings {
118 line, char := findLineInDocument(doc, warn.Path, warn.Line)
119 diags = append(diags, Diagnostic{
120 Range: Range{
121 Start: Position{Line: line, Character: char},
122 End: Position{Line: line, Character: char + 10},
123 },
124 Message: warn.Message,
125 Severity: DiagnosticWarning,
126 Source: "bausteinsicht",
127 })
128 }
129
130 return diags
131 }
132
133 func findLineInDocument(doc *Document, path string, preferredLine int) (int, int) {
134 lines := strings.Split(doc.Content, "\n")
135
136 // If a preferred line is given, use it (adjusted for 0-indexing)
137 if preferredLine > 0 && preferredLine-1 < len(lines) {
138 return preferredLine - 1, 0
139 }
140
141 // Otherwise, search for the path in the document
142 // This is a simple search - real implementation would parse JSON
143 for i, line := range lines {
144 if strings.Contains(line, path) || strings.Contains(line, strings.TrimPrefix(path, "\"")) {
145 return i, 0
146 }
147 }
148
149 return 0, 0
150 }
151
152 func parseValidationErrors(stderr string) []Diagnostic {
153 var diags []Diagnostic
154
155 lines := strings.Split(stderr, "\n")
156 for _, line := range lines {
157 if strings.Contains(line, "Error:") || strings.Contains(line, "error:") {
158 // Extract error message
159 parts := strings.Split(line, ":")
160 if len(parts) > 1 {
161 msg := strings.TrimSpace(strings.Join(parts[1:], ":"))
162 diags = append(diags, Diagnostic{
163 Range: Range{
164 Start: Position{Line: 0, Character: 0},
165 End: Position{Line: 0, Character: 10},
166 },
167 Message: msg,
168 Severity: DiagnosticError,
169 Source: "bausteinsicht",
170 })
171 }
172 }
173 }
174
175 return diags
176 }
177
github.com/docToolchain/Bausteinsicht/internal/lsp/server.go 33.3%
1 package lsp
2
3 import (
4 "bufio"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log"
9 "net/url"
10 "os"
11 "path/filepath"
12 "runtime"
13 "strconv"
14 "strings"
15 )
16
17 type Server struct {
18 documents map[string]*Document
19 workDir string
20 modelPath string
21 }
22
23 type Document struct {
24 URI string
25 Content string
26 Version int
27 Text string
28 Filename string
29 }
30
31 type JSONRPCMessage struct {
32 JSONRPC string `json:"jsonrpc"`
33 ID interface{} `json:"id,omitempty"`
34 Method string `json:"method,omitempty"`
35 Params json.RawMessage `json:"params,omitempty"`
36 Result interface{} `json:"result,omitempty"`
37 Error interface{} `json:"error,omitempty"`
38 }
39
40 type InitializeParams struct {
41 RootPath string `json:"rootPath"`
42 RootURI string `json:"rootUri"`
43 Workspace struct {
44 WorkspaceFolders []struct {
45 URI string `json:"uri"`
46 Name string `json:"name"`
47 } `json:"workspaceFolders"`
48 } `json:"workspace"`
49 }
50
51 type InitializeResult struct {
52 Capabilities ServerCapabilities `json:"capabilities"`
53 }
54
55 type ServerCapabilities struct {
56 TextDocumentSync int `json:"textDocumentSync"`
57 DiagnosticProvider bool `json:"diagnosticProvider"`
58 CodeLensProvider struct {
59 CodeLensOptions
60 } `json:"codeLensProvider"`
61 }
62
63 type CodeLensOptions struct {
64 ResolveProvider bool `json:"resolveProvider"`
65 }
66
67 func NewServer() *Server {
68 return &Server{
69 documents: make(map[string]*Document),
70 workDir: ".",
71 }
72 }
73
74 func (s *Server) Run() error {
75 return s.readMessages()
76 }
77
78 func (s *Server) readMessages() error {
79 reader := bufio.NewReader(os.Stdin)
80
81 for {
82 // Read headers
83 headers := make(map[string]string)
84 for {
85 line, err := reader.ReadString('\n')
86 if err == io.EOF {
87 return nil // Client disconnected cleanly
88 }
89 if err != nil {
90 return err
91 }
92 line = strings.TrimSpace(line)
93 if line == "" {
94 break
95 }
96 parts := strings.Split(line, ": ")
97 if len(parts) == 2 {
98 headers[parts[0]] = parts[1]
99 }
100 }
101
102 // Read body
103 contentLength := headers["Content-Length"]
104 if contentLength == "" {
105 continue
106 }
107
108 length, err := strconv.Atoi(contentLength)
109 if err != nil {
110 continue
111 }
112
113 body := make([]byte, length)
114 _, err = io.ReadFull(reader, body)
115 if err != nil {
116 if err == io.EOF {
117 return fmt.Errorf("unexpected EOF while reading message body")
118 }
119 return err
120 }
121
122 // Parse and handle message
123 var msg JSONRPCMessage
124 if err := json.Unmarshal(body, &msg); err != nil {
125 log.Printf("Failed to parse message: %v", err)
126 continue
127 }
128
129 // Handle message
130 response := s.handleMessage(&msg)
131 if response != nil {
132 s.sendMessage(response)
133 }
134 }
135 }
136
137 func (s *Server) handleMessage(msg *JSONRPCMessage) interface{} {
138 switch msg.Method {
139 case "initialize":
140 var params InitializeParams
141 if err := json.Unmarshal(msg.Params, &params); err != nil {
142 return nil
143 }
144 return s.handleInitialize(msg.ID, &params)
145
146 case "initialized":
147 return nil
148
149 case "textDocument/didOpen":
150 var params struct {
151 TextDocument struct {
152 URI string `json:"uri"`
153 Text string `json:"text"`
154 } `json:"textDocument"`
155 }
156 if err := json.Unmarshal(msg.Params, &params); err != nil {
157 return nil
158 }
159 s.handleDidOpen(&params.TextDocument.URI, &params.TextDocument.Text)
160 return nil
161
162 case "textDocument/didChange":
163 var params struct {
164 TextDocument struct {
165 URI string `json:"uri"`
166 Version int `json:"version"`
167 } `json:"textDocument"`
168 ContentChanges []struct {
169 Text string `json:"text"`
170 } `json:"contentChanges"`
171 }
172 if err := json.Unmarshal(msg.Params, &params); err != nil {
173 return nil
174 }
175 if len(params.ContentChanges) > 0 {
176 s.handleDidChange(&params.TextDocument.URI, params.TextDocument.Version, params.ContentChanges[0].Text)
177 }
178 return nil
179
180 case "textDocument/didSave":
181 var params struct {
182 TextDocument struct {
183 URI string `json:"uri"`
184 } `json:"textDocument"`
185 }
186 if err := json.Unmarshal(msg.Params, &params); err != nil {
187 return nil
188 }
189 s.handleDidSave(&params.TextDocument.URI)
190 return nil
191
192 case "shutdown":
193 // Send response before exiting (LSP spec requirement)
194 response := &JSONRPCMessage{
195 JSONRPC: "2.0",
196 ID: msg.ID,
197 Result: map[string]interface{}{},
198 }
199 s.sendMessage(response)
200 os.Exit(0)
201
202 default:
203 return nil
204 }
205
206 return nil
207 }
208
209 func (s *Server) handleInitialize(id interface{}, params *InitializeParams) interface{} {
210 if params.RootPath != "" {
211 s.workDir = params.RootPath
212 }
213 // Auto-detect model file
214 s.detectModel()
215
216 return &JSONRPCMessage{
217 JSONRPC: "2.0",
218 ID: id,
219 Result: InitializeResult{
220 Capabilities: ServerCapabilities{
221 TextDocumentSync: 2, // Full document sync
222 DiagnosticProvider: true,
223 CodeLensProvider: struct {
224 CodeLensOptions
225 }{CodeLensOptions{ResolveProvider: false}},
226 },
227 },
228 }
229 }
230
231 func (s *Server) handleDidOpen(uri *string, text *string) {
232 if uri == nil || text == nil {
233 return
234 }
235
236 filename := URIToPath(*uri)
237 doc := &Document{
238 URI: *uri,
239 Content: *text,
240 Text: *text,
241 Version: 1,
242 Filename: filename,
243 }
244 s.documents[*uri] = doc
245
246 // Publish diagnostics if this is the model file
247 if s.isModelFile(filename) {
248 s.publishDiagnostics(uri)
249 }
250 }
251
252 func (s *Server) handleDidChange(uri *string, version int, text string) {
253 if uri == nil {
254 return
255 }
256
257 doc, ok := s.documents[*uri]
258 if !ok {
259 return
260 }
261
262 doc.Content = text
263 doc.Text = text
264 doc.Version = version
265
266 if s.isModelFile(doc.Filename) {
267 s.publishDiagnostics(uri)
268 }
269 }
270
271 func (s *Server) handleDidSave(uri *string) {
272 if uri == nil {
273 return
274 }
275
276 doc, ok := s.documents[*uri]
277 if !ok {
278 return
279 }
280
281 if s.isModelFile(doc.Filename) {
282 s.publishDiagnostics(uri)
283 }
284 }
285
286 func (s *Server) publishDiagnostics(uri *string) {
287 doc, ok := s.documents[*uri]
288 if !ok || doc == nil {
289 return
290 }
291
292 diags := ValidateDocument(doc, s.workDir)
293
294 params := map[string]interface{}{
295 "uri": *uri,
296 "diagnostics": diags,
297 }
298 paramsData, _ := json.Marshal(params)
299
300 msg := &JSONRPCMessage{
301 JSONRPC: "2.0",
302 Method: "textDocument/publishDiagnostics",
303 Params: paramsData,
304 }
305
306 s.sendMessage(msg)
307 }
308
309 func (s *Server) sendMessage(msg interface{}) {
310 data, err := json.Marshal(msg)
311 if err != nil {
312 log.Printf("Failed to marshal message: %v", err)
313 return
314 }
315
316 header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data))
317 _, _ = os.Stdout.WriteString(header)
318 _, _ = os.Stdout.Write(data)
319 }
320
321 func (s *Server) detectModel() {
322 // Look for architecture.jsonc in work directory
323 modelPath := filepath.Join(s.workDir, "architecture.jsonc")
324 if _, err := os.Stat(modelPath); err == nil {
325 s.modelPath = modelPath
326 }
327 }
328
329 func (s *Server) isModelFile(filename string) bool {
330 base := filepath.Base(filename)
331 return strings.Contains(base, "architecture") && strings.HasSuffix(base, ".jsonc")
332 }
333
334 func URIToPath(uri string) string {
335 // Parse URI to handle cross-platform paths and URL-encoded characters
336 u, err := url.Parse(uri)
337 if err != nil {
338 // Fall back to simple prefix removal on parse error
339 if strings.HasPrefix(uri, "file://") {
340 return uri[7:]
341 }
342 return uri
343 }
344
345 // Extract path from parsed URI (keep forward slashes per RFC 8089)
346 path := u.Path
347
348 // On Windows, remove leading slash from absolute paths (C:/path not /C:/path)
349 if runtime.GOOS == "windows" && len(path) > 0 && path[0] == '/' && len(path) > 2 && path[2] == ':' {
350 path = path[1:]
351 }
352
353 return path
354 }
355
github.com/docToolchain/Bausteinsicht/internal/model/add.go 83.9%
1 package model
2
3 import (
4 "fmt"
5 "sort"
6 )
7
8 // AddView creates a new view or merges fields into an existing view.
9 // For existing views:
10 // - --include elements are merged (deduplicated)
11 // - --title, --scope, --description are updated (if specified)
12 // Returns an error if:
13 // - View key is empty
14 // - Scope element doesn't exist (if specified)
15 // - Include elements don't exist (if specified)
16 func (m *BausteinsichtModel) AddView(key string, view View) error {
17 if key == "" {
18 return fmt.Errorf("view key must not be empty")
19 }
20
21 // Initialize views map if needed
22 if m.Views == nil {
23 m.Views = make(map[string]View)
24 }
25
26 // Validate scope exists (if specified)
27 if view.Scope != "" {
28 if _, err := Resolve(m, view.Scope); err != nil {
29 return fmt.Errorf("scope %q not found: %w", view.Scope, err)
30 }
31 }
32
33 // Validate all include elements exist
34 for _, include := range view.Include {
35 // Skip wildcard patterns — they're validated at render time
36 if include == "" || (len(include) > 0 && include[len(include)-1] == '*') {
37 continue
38 }
39 if _, err := Resolve(m, include); err != nil {
40 return fmt.Errorf("include %q not found: %w", include, err)
41 }
42 }
43
44 // Check if view already exists
45 existingView, exists := m.Views[key]
46 if exists {
47 // Merge: keep existing fields, override with new values, merge include lists
48 if view.Title != "" {
49 existingView.Title = view.Title
50 }
51 if view.Scope != "" {
52 existingView.Scope = view.Scope
53 }
54 if view.Description != "" {
55 existingView.Description = view.Description
56 }
57 if view.Layout != "" {
58 existingView.Layout = view.Layout
59 }
60 // Merge include lists (deduplicate, sort for deterministic output)
61 if len(view.Include) > 0 {
62 includedSet := make(map[string]bool)
63 for _, elem := range existingView.Include {
64 includedSet[elem] = true
65 }
66 for _, elem := range view.Include {
67 includedSet[elem] = true
68 }
69 merged := make([]string, 0, len(includedSet))
70 for elem := range includedSet {
71 merged = append(merged, elem)
72 }
73 sort.Strings(merged)
74 existingView.Include = merged
75 }
76 m.Views[key] = existingView
77 } else {
78 // New view: use as-is
79 m.Views[key] = view
80 }
81
82 return nil
83 }
84
85 // AddSpecificationElement adds an element kind to the specification.
86 // Returns an error if the element kind already exists.
87 func (m *BausteinsichtModel) AddSpecificationElement(key string, kind ElementKind) error {
88 if key == "" {
89 return fmt.Errorf("element key must not be empty")
90 }
91
92 if kind.Notation == "" {
93 return fmt.Errorf("notation must not be empty")
94 }
95
96 // Initialize elements map if needed
97 if m.Specification.Elements == nil {
98 m.Specification.Elements = make(map[string]ElementKind)
99 }
100
101 // Check for duplicate
102 if _, exists := m.Specification.Elements[key]; exists {
103 return fmt.Errorf("element kind %q already exists in specification", key)
104 }
105
106 m.Specification.Elements[key] = kind
107
108 return nil
109 }
110
111 // AddSpecificationRelationship adds a relationship kind to the specification.
112 // Returns an error if the relationship kind already exists.
113 func (m *BausteinsichtModel) AddSpecificationRelationship(key string, kind RelationshipKind) error {
114 if key == "" {
115 return fmt.Errorf("relationship key must not be empty")
116 }
117
118 if kind.Notation == "" {
119 return fmt.Errorf("notation must not be empty")
120 }
121
122 // Check for duplicate
123 if _, exists := m.Specification.Relationships[key]; exists {
124 return fmt.Errorf("relationship kind %q already exists in specification", key)
125 }
126
127 // Initialize relationships map if needed
128 if m.Specification.Relationships == nil {
129 m.Specification.Relationships = make(map[string]RelationshipKind)
130 }
131
132 // Add relationship kind
133 m.Specification.Relationships[key] = kind
134
135 return nil
136 }
137
github.com/docToolchain/Bausteinsicht/internal/model/loader.go 80.9%
1 package model
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strings"
11 )
12
13 // MaxModelFileSize is the maximum allowed model file size (10 MB).
14 const MaxModelFileSize = 10 * 1024 * 1024
15
16 // Load reads a JSONC file, strips comments and trailing commas, and parses it.
17 func Load(path string) (*BausteinsichtModel, error) {
18 info, err := os.Stat(path)
19 if err != nil {
20 return nil, fmt.Errorf("reading %s: %w", path, err)
21 }
22 if info.Size() > MaxModelFileSize {
23 return nil, fmt.Errorf("reading %s: file size %d exceeds limit of %d bytes", path, info.Size(), MaxModelFileSize)
24 }
25 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
26 if err != nil {
27 return nil, fmt.Errorf("reading %s: %w", path, err)
28 }
29 clean := StripJSONC(data)
30
31 // Reject null JSON root — json.Unmarshal silently accepts "null"
32 // and produces a zero-value struct, which passes validation vacuously.
33 trimmed := strings.TrimSpace(string(clean))
34 if trimmed == "null" || trimmed == "" {
35 return nil, fmt.Errorf("parsing %s: model file is empty or contains a null JSON root", path)
36 }
37
38 var m BausteinsichtModel
39 if err := json.Unmarshal(clean, &m); err != nil {
40 return nil, fmt.Errorf("parsing %s: %w", path, err)
41 }
42
43 m.ElementOrder = extractElementOrder(clean)
44
45 return &m, nil
46 }
47
48 // extractElementOrder walks the JSON with a streaming decoder to capture the
49 // definition order of keys in specification.elements. Go maps don't preserve
50 // insertion order, so we need this to determine layer assignment for layout.
51 func extractElementOrder(data []byte) []string {
52 // Parse into a raw structure to navigate to specification.elements,
53 // then re-decode that object with a streaming decoder to get key order.
54 var raw map[string]json.RawMessage
55 if err := json.Unmarshal(data, &raw); err != nil {
56 return nil
57 }
58 specRaw, ok := raw["specification"]
59 if !ok {
60 return nil
61 }
62 var spec map[string]json.RawMessage
63 if err := json.Unmarshal(specRaw, &spec); err != nil {
64 return nil
65 }
66 elemsRaw, ok := spec["elements"]
67 if !ok {
68 return nil
69 }
70
71 // Stream-decode the elements object to capture key order.
72 dec := json.NewDecoder(bytes.NewReader(elemsRaw))
73 tok, err := dec.Token() // consume opening '{'
74 if err != nil {
75 return nil
76 }
77 if d, ok := tok.(json.Delim); !ok || d != '{' {
78 return nil
79 }
80
81 var order []string
82 for dec.More() {
83 tok, err := dec.Token()
84 if err != nil {
85 break
86 }
87 key, ok := tok.(string)
88 if !ok {
89 continue
90 }
91 order = append(order, key)
92 // Skip the value (the element kind object).
93 var discard json.RawMessage
94 if err := dec.Decode(&discard); err != nil {
95 break
96 }
97 }
98 return order
99 }
100
101 // Save marshals the model and atomically writes it to path.
102 // Preserves any preamble (comments/whitespace before the root `{`) from the
103 // existing file so that users' header comments are not lost (#242).
104 // Uses os.CreateTemp for a randomized temp file name to prevent TOCTOU attacks.
105 func Save(path string, model *BausteinsichtModel) error {
106 data, err := json.MarshalIndent(model, "", " ")
107 if err != nil {
108 return fmt.Errorf("marshaling model: %w", err)
109 }
110
111 // Preserve preamble from the existing file (comments before root `{`).
112 if existing, readErr := os.ReadFile(path); readErr == nil { // #nosec G304
113 if preamble := extractPreamble(existing); len(preamble) > 0 {
114 data = append(preamble, data...)
115 }
116 }
117
118 dir := filepath.Dir(path)
119 tmp, err := os.CreateTemp(dir, ".model-tmp-*")
120 if err != nil {
121 return fmt.Errorf("creating temp file: %w", err)
122 }
123 tmpName := tmp.Name()
124 if _, err := tmp.Write(data); err != nil {
125 _ = tmp.Close()
126 _ = os.Remove(tmpName)
127 return fmt.Errorf("writing temp file: %w", err)
128 }
129 if err := tmp.Close(); err != nil {
130 _ = os.Remove(tmpName)
131 return fmt.Errorf("closing temp file: %w", err)
132 }
133 if err := os.Rename(tmpName, path); err != nil {
134 _ = os.Remove(tmpName)
135 return fmt.Errorf("renaming temp file: %w", err)
136 }
137 return nil
138 }
139
140 // AutoDetect finds the first *.jsonc file in dir.
141 func AutoDetect(dir string) (string, error) {
142 matches, err := filepath.Glob(filepath.Join(dir, "*.jsonc"))
143 if err != nil {
144 return "", fmt.Errorf("scanning %s: %w", dir, err)
145 }
146 if len(matches) == 0 {
147 return "", fmt.Errorf("no .jsonc file found in %s", dir)
148 }
149 if len(matches) > 1 {
150 return "", fmt.Errorf("multiple .jsonc files in %s — use --model to select one", dir)
151 }
152 return matches[0], nil
153 }
154
155 // StripJSONC removes single-line comments and trailing commas from JSONC data.
156 // Comments inside strings are preserved.
157 func StripJSONC(data []byte) []byte {
158 // Strip UTF-8 BOM if present.
159 if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
160 data = data[3:]
161 }
162
163 var sb strings.Builder
164 src := string(data)
165 i := 0
166 for i < len(src) {
167 // Handle string literals — skip their content intact
168 if src[i] == '"' {
169 sb.WriteByte(src[i])
170 i++
171 for i < len(src) {
172 if src[i] == '\\' && i+1 < len(src) {
173 sb.WriteByte(src[i])
174 sb.WriteByte(src[i+1])
175 i += 2
176 continue
177 }
178 sb.WriteByte(src[i])
179 if src[i] == '"' {
180 i++
181 break
182 }
183 i++
184 }
185 continue
186 }
187 // Handle block comments
188 if i+1 < len(src) && src[i] == '/' && src[i+1] == '*' {
189 // Trim trailing whitespace before comment if it's only
190 // whitespace since the last newline (i.e., comment on its own line).
191 s := sb.String()
192 lastNL := strings.LastIndex(s, "\n")
193 linePrefix := s[lastNL+1:]
194 if strings.TrimRight(linePrefix, " \t") == "" {
195 sb.Reset()
196 sb.WriteString(s[:lastNL+1])
197 }
198 i += 2
199 for i+1 < len(src) {
200 if src[i] == '*' && src[i+1] == '/' {
201 i += 2
202 break
203 }
204 i++
205 }
206 continue
207 }
208 // Handle single-line comments
209 if i+1 < len(src) && src[i] == '/' && src[i+1] == '/' {
210 // Trim trailing whitespace written before the comment
211 s := sb.String()
212 trimmed := strings.TrimRight(s, " \t")
213 sb.Reset()
214 sb.WriteString(trimmed)
215 for i < len(src) && src[i] != '\n' {
216 i++
217 }
218 continue
219 }
220 sb.WriteByte(src[i])
221 i++
222 }
223
224 // Remove trailing commas before } or ]
225 result := trailingCommaRe.ReplaceAllString(sb.String(), "$1")
226 return []byte(result)
227 }
228
229 // trailingCommaRe matches a comma optionally followed by whitespace before } or ]
230 var trailingCommaRe = regexp.MustCompile(`,(\s*[}\]])`)
231
232 // extractPreamble returns everything before the first `{` in the file.
233 // This captures comment lines and blank lines that precede the root object.
234 // Returns nil if there is no preamble or the file starts with `{`.
235 func extractPreamble(data []byte) []byte {
236 for i, b := range data {
237 if b == '{' {
238 if i == 0 {
239 return nil
240 }
241 return data[:i]
242 }
243 }
244 return nil
245 }
246
github.com/docToolchain/Bausteinsicht/internal/model/patch.go 64.7%
1 package model
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 )
8
9 // PatchOp describes a single value replacement in a JSONC file.
10 type PatchOp struct {
11 Path []string // JSON path segments, e.g., ["model", "api", "technology"]
12 Value string // New JSON-encoded value, e.g., `"Go 1.24"`
13 }
14
15 // PatchSave reads the JSONC file at path, applies each PatchOp, and writes
16 // the result back atomically. Comments, formatting, and key ordering are
17 // preserved because only the target values are replaced in the raw text.
18 func PatchSave(path string, ops []PatchOp) error {
19 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
20 if err != nil {
21 return fmt.Errorf("reading %s: %w", path, err)
22 }
23
24 for _, op := range ops {
25 data, err = PatchValue(data, op.Path, op.Value)
26 if err != nil {
27 return fmt.Errorf("patching %v: %w", op.Path, err)
28 }
29 }
30
31 dir := filepath.Dir(path)
32 tmp, err := os.CreateTemp(dir, ".model-tmp-*")
33 if err != nil {
34 return fmt.Errorf("creating temp file: %w", err)
35 }
36 tmpName := tmp.Name()
37 if _, err := tmp.Write(data); err != nil {
38 _ = tmp.Close()
39 _ = os.Remove(tmpName)
40 return fmt.Errorf("writing temp file: %w", err)
41 }
42 if err := tmp.Close(); err != nil {
43 _ = os.Remove(tmpName)
44 return fmt.Errorf("closing temp file: %w", err)
45 }
46 if err := os.Rename(tmpName, path); err != nil {
47 _ = os.Remove(tmpName)
48 return fmt.Errorf("renaming temp file: %w", err)
49 }
50 return nil
51 }
52
53 // PatchInsert reads the JSONC file at path, applies a raw data transformation,
54 // and writes the result back atomically. Used by InsertObjectEntry and
55 // AppendArrayEntry for comment-preserving insertions.
56 func PatchInsert(path string, transform func([]byte) ([]byte, error)) error {
57 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
58 if err != nil {
59 return fmt.Errorf("reading %s: %w", path, err)
60 }
61
62 data, err = transform(data)
63 if err != nil {
64 return err
65 }
66
67 dir := filepath.Dir(path)
68 tmp, err := os.CreateTemp(dir, ".model-tmp-*")
69 if err != nil {
70 return fmt.Errorf("creating temp file: %w", err)
71 }
72 tmpName := tmp.Name()
73 if _, err := tmp.Write(data); err != nil {
74 _ = tmp.Close()
75 _ = os.Remove(tmpName)
76 return fmt.Errorf("writing temp file: %w", err)
77 }
78 if err := tmp.Close(); err != nil {
79 _ = os.Remove(tmpName)
80 return fmt.Errorf("closing temp file: %w", err)
81 }
82 if err := os.Rename(tmpName, path); err != nil {
83 _ = os.Remove(tmpName)
84 return fmt.Errorf("renaming temp file: %w", err)
85 }
86 return nil
87 }
88
89 // PatchValue finds the JSON value at path in JSONC data and replaces it with
90 // newValue. The rest of the document (comments, whitespace, key ordering)
91 // is preserved. Returns the patched data or an error if the path is not found.
92 func PatchValue(data []byte, path []string, newValue string) ([]byte, error) {
93 if len(path) == 0 {
94 return nil, fmt.Errorf("empty path")
95 }
96
97 start, end, err := findValueRange(data, path)
98 if err != nil {
99 return nil, err
100 }
101
102 result := make([]byte, 0, len(data)+len(newValue))
103 result = append(result, data[:start]...)
104 result = append(result, []byte(newValue)...)
105 result = append(result, data[end:]...)
106 return result, nil
107 }
108
109 // findValueRange locates the byte range [start, end) of the JSON value at
110 // the given path within JSONC data. It handles single-line comments and
111 // string escaping.
112 func findValueRange(data []byte, path []string) (int, int, error) {
113 i := 0
114 n := len(data)
115
116 for depth := 0; depth < len(path); depth++ {
117 key := path[depth]
118 // Find the opening { of the current object.
119 i = skipToChar(data, i, n, '{')
120 if i >= n {
121 return 0, 0, fmt.Errorf("path %v: expected object, not found", path[:depth+1])
122 }
123 i++ // skip '{'
124
125 // Find the matching key within this object.
126 found := false
127 for i < n {
128 i = skipWhitespaceAndComments(data, i, n)
129 if i >= n {
130 break
131 }
132 if data[i] == '}' {
133 break
134 }
135
136 // Read the key.
137 if data[i] != '"' {
138 return 0, 0, fmt.Errorf("expected '\"' at offset %d", i)
139 }
140 keyStart := i
141 keyEnd := skipString(data, i, n)
142 currentKey := string(data[keyStart+1 : keyEnd-1]) // strip quotes
143 i = keyEnd
144
145 // Skip colon.
146 i = skipWhitespaceAndComments(data, i, n)
147 if i >= n || data[i] != ':' {
148 return 0, 0, fmt.Errorf("expected ':' after key %q at offset %d", currentKey, i)
149 }
150 i++ // skip ':'
151 i = skipWhitespaceAndComments(data, i, n)
152
153 if currentKey == key {
154 if depth == len(path)-1 {
155 // This is the target value — find its extent.
156 valStart := i
157 valEnd := skipValue(data, i, n)
158 return valStart, valEnd, nil
159 }
160 // Need to descend into this value (next iteration).
161 found = true
162 break
163 }
164
165 // Skip the value to move to the next key.
166 i = skipValue(data, i, n)
167
168 // Skip optional comma.
169 i = skipWhitespaceAndComments(data, i, n)
170 if i < n && data[i] == ',' {
171 i++
172 }
173 }
174 if !found && depth < len(path)-1 {
175 return 0, 0, fmt.Errorf("key %q not found in path %v", key, path[:depth+1])
176 }
177 }
178
179 return 0, 0, fmt.Errorf("path %v not found", path)
180 }
181
182 // skipWhitespaceAndComments advances past whitespace and // comments.
183 func skipWhitespaceAndComments(data []byte, i, n int) int {
184 for i < n {
185 if data[i] == ' ' || data[i] == '\t' || data[i] == '\n' || data[i] == '\r' {
186 i++
187 continue
188 }
189 if i+1 < n && data[i] == '/' && data[i+1] == '/' {
190 // Skip to end of line.
191 for i < n && data[i] != '\n' {
192 i++
193 }
194 continue
195 }
196 if i+1 < n && data[i] == '/' && data[i+1] == '*' {
197 i += 2
198 for i+1 < n {
199 if data[i] == '*' && data[i+1] == '/' {
200 i += 2
201 break
202 }
203 i++
204 }
205 continue
206 }
207 break
208 }
209 return i
210 }
211
212 // skipString skips a JSON string starting at data[i] (which must be '"')
213 // and returns the index after the closing quote.
214 func skipString(data []byte, i, n int) int {
215 i++ // skip opening '"'
216 for i < n {
217 if data[i] == '\\' && i+1 < n {
218 i += 2
219 continue
220 }
221 if data[i] == '"' {
222 return i + 1
223 }
224 i++
225 }
226 return i
227 }
228
229 // skipValue skips a complete JSON value (string, number, object, array, bool, null).
230 func skipValue(data []byte, i, n int) int {
231 if i >= n {
232 return i
233 }
234 switch data[i] {
235 case '"':
236 return skipString(data, i, n)
237 case '{':
238 return skipBraced(data, i, n, '{', '}')
239 case '[':
240 return skipBraced(data, i, n, '[', ']')
241 default:
242 // Number, bool, null — skip until delimiter.
243 for i < n {
244 c := data[i]
245 if c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || c == '\n' || c == '\r' {
246 break
247 }
248 // Also stop at // or /* comment.
249 if c == '/' && i+1 < n && (data[i+1] == '/' || data[i+1] == '*') {
250 break
251 }
252 i++
253 }
254 return i
255 }
256 }
257
258 // skipBraced skips a matched pair of braces/brackets, handling strings and
259 // comments within.
260 func skipBraced(data []byte, i, n int, open, close byte) int {
261 depth := 0
262 for i < n {
263 c := data[i]
264 if c == '"' {
265 i = skipString(data, i, n)
266 continue
267 }
268 if c == '/' && i+1 < n && data[i+1] == '/' {
269 for i < n && data[i] != '\n' {
270 i++
271 }
272 continue
273 }
274 if c == '/' && i+1 < n && data[i+1] == '*' {
275 i += 2
276 for i+1 < n {
277 if data[i] == '*' && data[i+1] == '/' {
278 i += 2
279 break
280 }
281 i++
282 }
283 continue
284 }
285 switch c {
286 case open:
287 depth++
288 case close:
289 depth--
290 if depth == 0 {
291 return i + 1
292 }
293 }
294 i++
295 }
296 return i
297 }
298
299 // InsertObjectEntry inserts a new key-value pair into the object at the given
300 // path. The value is inserted before the closing '}' of the target object.
301 // Comments and formatting are preserved.
302 func InsertObjectEntry(data []byte, objectPath []string, key, valueJSON string) ([]byte, error) {
303 // Find the object's value range.
304 start, end, err := findValueRange(data, objectPath)
305 if err != nil {
306 return nil, err
307 }
308 if data[start] != '{' {
309 return nil, fmt.Errorf("value at path %v is not an object", objectPath)
310 }
311
312 // Find the closing '}' of this object (end-1 since findValueRange returns after it).
313 closeBrace := end - 1
314 for closeBrace > start && data[closeBrace] != '}' {
315 closeBrace--
316 }
317
318 // Detect indentation from the closing brace line.
319 indent := detectIndent(data, closeBrace)
320
321 // Check if the object has existing entries by scanning for non-whitespace
322 // between '{' and '}'.
323 hasEntries := false
324 scan := start + 1
325 scan = skipWhitespaceAndComments(data, scan, len(data))
326 if scan < closeBrace {
327 hasEntries = true
328 }
329
330 // Build the insertion text.
331 var insertion string
332 if hasEntries {
333 // Find the last non-whitespace/comment byte before closeBrace to
334 // append a comma after the previous entry (not before the new one).
335 lastContent := closeBrace - 1
336 for lastContent > start && (data[lastContent] == ' ' || data[lastContent] == '\t' || data[lastContent] == '\n' || data[lastContent] == '\r') {
337 lastContent--
338 }
339 // Insert comma after last entry, then newline + new entry.
340 comma := ""
341 if lastContent > start && data[lastContent] != ',' {
342 comma = ","
343 }
344 insertion = fmt.Sprintf("%s\n%s %q: %s\n%s", comma, indent, key, valueJSON, indent)
345 // Replace from after last content to closing brace (inclusive) with:
346 // comma + \n + indent + new entry + \n + indent + }
347 result := make([]byte, 0, len(data)+len(insertion))
348 result = append(result, data[:lastContent+1]...)
349 result = append(result, []byte(insertion)...)
350 result = append(result, data[closeBrace:]...)
351 return result, nil
352 }
353
354 insertion = fmt.Sprintf("\n%s %q: %s\n%s", indent, key, valueJSON, indent)
355 result := make([]byte, 0, len(data)+len(insertion))
356 result = append(result, data[:closeBrace]...)
357 result = append(result, []byte(insertion)...)
358 result = append(result, data[closeBrace:]...)
359 return result, nil
360 }
361
362 // AppendArrayEntry appends a new value to the array at the given path.
363 // The value is inserted before the closing ']'. Comments and formatting
364 // are preserved.
365 func AppendArrayEntry(data []byte, arrayPath []string, valueJSON string) ([]byte, error) {
366 start, end, err := findValueRange(data, arrayPath)
367 if err != nil {
368 return nil, err
369 }
370 if data[start] != '[' {
371 return nil, fmt.Errorf("value at path %v is not an array", arrayPath)
372 }
373
374 // Find the closing ']'.
375 closeBracket := end - 1
376 for closeBracket > start && data[closeBracket] != ']' {
377 closeBracket--
378 }
379
380 indent := detectIndent(data, closeBracket)
381
382 // Check if the array has existing entries.
383 hasEntries := false
384 scan := start + 1
385 scan = skipWhitespaceAndComments(data, scan, len(data))
386 if scan < closeBracket {
387 hasEntries = true
388 }
389
390 var insertion string
391 if hasEntries {
392 // Find the last non-whitespace byte before closeBracket to
393 // append comma after the previous entry.
394 lastContent := closeBracket - 1
395 for lastContent > start && (data[lastContent] == ' ' || data[lastContent] == '\t' || data[lastContent] == '\n' || data[lastContent] == '\r') {
396 lastContent--
397 }
398 comma := ""
399 if lastContent > start && data[lastContent] != ',' {
400 comma = ","
401 }
402 insertion = fmt.Sprintf("%s\n%s %s\n%s", comma, indent, valueJSON, indent)
403 result := make([]byte, 0, len(data)+len(insertion))
404 result = append(result, data[:lastContent+1]...)
405 result = append(result, []byte(insertion)...)
406 result = append(result, data[closeBracket:]...)
407 return result, nil
408 }
409
410 insertion = fmt.Sprintf("\n%s %s\n%s", indent, valueJSON, indent)
411 result := make([]byte, 0, len(data)+len(insertion))
412 result = append(result, data[:closeBracket]...)
413 result = append(result, []byte(insertion)...)
414 result = append(result, data[closeBracket:]...)
415 return result, nil
416 }
417
418 // detectIndent returns the whitespace prefix of the line containing position pos.
419 func detectIndent(data []byte, pos int) string {
420 lineStart := pos
421 for lineStart > 0 && data[lineStart-1] != '\n' {
422 lineStart--
423 }
424 indent := ""
425 for i := lineStart; i < pos; i++ {
426 if data[i] == ' ' || data[i] == '\t' {
427 indent += string(data[i])
428 } else {
429 break
430 }
431 }
432 return indent
433 }
434
435 // skipToChar advances to the first occurrence of ch, skipping strings and comments.
436 func skipToChar(data []byte, i, n int, ch byte) int {
437 for i < n {
438 if data[i] == '"' {
439 i = skipString(data, i, n)
440 continue
441 }
442 if data[i] == '/' && i+1 < n && data[i+1] == '/' {
443 for i < n && data[i] != '\n' {
444 i++
445 }
446 continue
447 }
448 if data[i] == '/' && i+1 < n && data[i+1] == '*' {
449 i += 2
450 for i+1 < n {
451 if data[i] == '*' && data[i+1] == '/' {
452 i += 2
453 break
454 }
455 i++
456 }
457 continue
458 }
459 if data[i] == ch {
460 return i
461 }
462 i++
463 }
464 return i
465 }
466
github.com/docToolchain/Bausteinsicht/internal/model/patterns.go 0.0%
1 package model
2
3 import (
4 "strings"
5 "unicode"
6 )
7
8 // ExpandPattern takes a pattern definition and applies variable substitution
9 // to generate concrete elements and relationships.
10 // baseID is used for {base}, title is used for {Title} and {BASE}.
11 func ExpandPattern(pattern PatternDefinition, baseID, title string) ([]Element, []Relationship, error) {
12 if title == "" {
13 title = baseID
14 }
15
16 vars := map[string]string{
17 "{base}": baseID,
18 "{Title}": toTitleCase(title),
19 "{BASE}": strings.ToUpper(baseID),
20 }
21
22 // Expand elements (including nested children)
23 elements := make([]Element, len(pattern.Elements))
24 for i, tmpl := range pattern.Elements {
25 elements[i] = expandPatternElement(tmpl, vars)
26 }
27
28 // Expand relationships
29 relationships := make([]Relationship, len(pattern.Relationships))
30 for i, tmpl := range pattern.Relationships {
31 relationships[i] = Relationship{
32 From: replaceVars(tmpl.From, vars),
33 To: replaceVars(tmpl.To, vars),
34 Label: replaceVars(tmpl.Label, vars),
35 Kind: tmpl.Kind,
36 Description: replaceVars(tmpl.Description, vars),
37 }
38 }
39
40 return elements, relationships, nil
41 }
42
43 // expandPatternElement recursively expands an element template, including children
44 func expandPatternElement(tmpl PatternElement, vars map[string]string) Element {
45 elem := Element{
46 Kind: tmpl.Kind,
47 Title: replaceVars(tmpl.Title, vars),
48 Description: replaceVars(tmpl.Description, vars),
49 Technology: replaceVars(tmpl.Technology, vars),
50 Tags: tmpl.Tags,
51 }
52
53 // Recursively expand children if present
54 if len(tmpl.Children) > 0 {
55 elem.Children = make(map[string]Element, len(tmpl.Children))
56 for _, childTmpl := range tmpl.Children {
57 childID := replaceVars(childTmpl.ID, vars)
58 elem.Children[childID] = expandPatternElement(childTmpl, vars)
59 }
60 }
61
62 return elem
63 }
64
65 // ExpandPatternIDs applies variable substitution to element and relationship IDs
66 func ExpandPatternIDs(pattern PatternDefinition, baseID string) ([]string, []string, error) {
67 vars := map[string]string{
68 "{base}": baseID,
69 "{BASE}": strings.ToUpper(baseID),
70 }
71
72 var elemIDs []string
73 for _, tmpl := range pattern.Elements {
74 elemIDs = append(elemIDs, expandPatternElementIDs(tmpl, vars)...)
75 }
76
77 relIDs := make([]string, len(pattern.Relationships))
78 for i, tmpl := range pattern.Relationships {
79 relIDs[i] = replaceVars(tmpl.ID, vars)
80 }
81
82 return elemIDs, relIDs, nil
83 }
84
85 // expandPatternElementIDs recursively extracts all element IDs from a pattern element
86 func expandPatternElementIDs(tmpl PatternElement, vars map[string]string) []string {
87 ids := []string{replaceVars(tmpl.ID, vars)}
88 for _, childTmpl := range tmpl.Children {
89 ids = append(ids, expandPatternElementIDs(childTmpl, vars)...)
90 }
91 return ids
92 }
93
94 // replaceVars substitutes template variables in a string
95 func replaceVars(s string, vars map[string]string) string {
96 result := s
97 for k, v := range vars {
98 result = strings.ReplaceAll(result, k, v)
99 }
100 return result
101 }
102
103 // toTitleCase converts "order" to "Order"
104 func toTitleCase(s string) string {
105 if len(s) == 0 {
106 return s
107 }
108 runes := []rune(s)
109 runes[0] = unicode.ToUpper(runes[0])
110 return string(runes)
111 }
112
113 // CheckPatternConflicts checks if any generated IDs already exist in the model
114 func CheckPatternConflicts(m *BausteinsichtModel, pattern PatternDefinition, baseID string) ([]string, error) {
115 elemIDs, _, err := ExpandPatternIDs(pattern, baseID)
116 if err != nil {
117 return nil, err
118 }
119
120 flat, _ := FlattenElements(m)
121 var conflicts []string
122
123 for _, id := range elemIDs {
124 if _, exists := flat[id]; exists {
125 conflicts = append(conflicts, id)
126 }
127 }
128
129 return conflicts, nil
130 }
131
github.com/docToolchain/Bausteinsicht/internal/model/resolve.go 49.5%
1 package model
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7 )
8
9 // MaxElementDepth is the maximum nesting depth for elements.
10 // This prevents stack overflow from deeply nested or circular model definitions.
11 const MaxElementDepth = 50
12
13 // Resolve traverses the model hierarchy using dot notation (e.g., "webshop.api.auth").
14 func Resolve(m *BausteinsichtModel, id string) (*Element, error) {
15 parts := strings.Split(id, ".")
16 root := parts[0]
17
18 elem, ok := m.Model[root]
19 if !ok {
20 return nil, fmt.Errorf("element %q not found", id)
21 }
22
23 for _, part := range parts[1:] {
24 if elem.Children == nil {
25 return nil, fmt.Errorf("element %q not found: no children at this level", id)
26 }
27 child, ok := elem.Children[part]
28 if !ok {
29 return nil, fmt.Errorf("element %q not found", id)
30 }
31 elem = child
32 }
33
34 return &elem, nil
35 }
36
37 // flattenInto recursively adds elements to the map with their full dot-notation path.
38 func flattenInto(children map[string]Element, prefix string, depth int, result map[string]*Element) error {
39 if depth > MaxElementDepth {
40 return fmt.Errorf("element nesting exceeds maximum depth of %d at %q", MaxElementDepth, strings.TrimSuffix(prefix, "."))
41 }
42 for key, elem := range children {
43 fullID := prefix + key
44 e := elem
45 result[fullID] = &e
46 if elem.Children != nil {
47 if err := flattenInto(elem.Children, fullID+".", depth+1, result); err != nil {
48 return err
49 }
50 }
51 }
52 return nil
53 }
54
55 // FlattenElements returns all elements keyed by full dot-notation ID path.
56 // Returns an error if the element hierarchy exceeds MaxElementDepth.
57 func FlattenElements(m *BausteinsichtModel) (map[string]*Element, error) {
58 result := make(map[string]*Element)
59 if err := flattenInto(m.Model, "", 1, result); err != nil {
60 return nil, err
61 }
62 return result, nil
63 }
64
65 // MatchPattern matches elements in the flat map against a pattern.
66 // Supported patterns:
67 // - "id" — exact match
68 // - "prefix.*" — direct children of prefix (one level deep)
69 // - "prefix.**" — all descendants of prefix (recursive)
70 // - "*" — all top-level elements (no dots in ID)
71 // - "**" — all elements
72 func MatchPattern(flatMap map[string]*Element, pattern string) []string {
73 var matches []string
74
75 switch {
76 case pattern == "**":
77 // Match all elements.
78 for id := range flatMap {
79 matches = append(matches, id)
80 }
81
82 case pattern == "*":
83 // Match top-level elements only (no dots in ID).
84 for id := range flatMap {
85 if !strings.Contains(id, ".") {
86 matches = append(matches, id)
87 }
88 }
89
90 case strings.HasSuffix(pattern, ".**"):
91 // Match all descendants of prefix (recursive).
92 prefix := strings.TrimSuffix(pattern, "**")
93 for id := range flatMap {
94 if strings.HasPrefix(id, prefix) {
95 matches = append(matches, id)
96 }
97 }
98
99 case strings.HasSuffix(pattern, ".*"):
100 // Match direct children only (one level deep).
101 prefix := strings.TrimSuffix(pattern, "*")
102 depth := strings.Count(prefix, ".")
103 for id := range flatMap {
104 if !strings.HasPrefix(id, prefix) {
105 continue
106 }
107 rest := id[len(prefix):]
108 if !strings.Contains(rest, ".") && strings.Count(id, ".") == depth {
109 matches = append(matches, id)
110 }
111 }
112
113 default:
114 // Exact match.
115 if _, ok := flatMap[pattern]; ok {
116 matches = append(matches, pattern)
117 }
118 }
119
120 return matches
121 }
122
123 // ResolveView resolves view includes/excludes to a list of element IDs.
124 // Starts with include patterns, then removes exclude patterns.
125 func ResolveView(m *BausteinsichtModel, view *View) ([]string, error) {
126 if len(view.Include) == 0 {
127 return []string{}, nil
128 }
129
130 flatMap, err := FlattenElements(m)
131 if err != nil {
132 return nil, err
133 }
134
135 included := make(map[string]bool)
136 for _, pattern := range view.Include {
137 for _, id := range MatchPattern(flatMap, pattern) {
138 included[id] = true
139 }
140 }
141
142 for _, pattern := range view.Exclude {
143 for _, id := range MatchPattern(flatMap, pattern) {
144 delete(included, id)
145 }
146 }
147
148 result := make([]string, 0, len(included))
149 for id := range included {
150 result = append(result, id)
151 }
152 sort.Strings(result)
153 return result, nil
154 }
155
156 // FilterElementsByTags applies tag-based filtering to a flat element map.
157 // It includes elements that have ALL FilterTags and excludes elements with ANY ExcludeTags.
158 func FilterElementsByTags(elements map[string]*Element, filterTags, excludeTags []string) map[string]*Element {
159 // Quick exit: no filtering
160 if len(filterTags) == 0 && len(excludeTags) == 0 {
161 return elements
162 }
163
164 result := make(map[string]*Element)
165 for id, elem := range elements {
166 // Check exclude first: if ANY exclude-tag matches, skip
167 excluded := false
168 for _, excludeTag := range excludeTags {
169 for _, elemTag := range elem.Tags {
170 if elemTag == excludeTag {
171 excluded = true
172 break
173 }
174 }
175 if excluded {
176 break
177 }
178 }
179 if excluded {
180 continue
181 }
182
183 // Check include: if ANY filter-tags are specified, element must have ALL of them
184 if len(filterTags) > 0 {
185 hasAllFilterTags := true
186 for _, filterTag := range filterTags {
187 found := false
188 for _, elemTag := range elem.Tags {
189 if elemTag == filterTag {
190 found = true
191 break
192 }
193 }
194 if !found {
195 hasAllFilterTags = false
196 break
197 }
198 }
199 if !hasAllFilterTags {
200 continue
201 }
202 }
203
204 result[id] = elem
205 }
206
207 return result
208 }
209
github.com/docToolchain/Bausteinsicht/internal/model/types.go 61.5%
1 package model
2
3 // Element lifecycle status values
4 const (
5 StatusProposed = "proposed"
6 StatusDesign = "design"
7 StatusImplementing = "implementation"
8 StatusDeployed = "deployed"
9 StatusDeprecated = "deprecated"
10 StatusArchived = "archived"
11 )
12
13 var ValidStatuses = []string{
14 StatusProposed,
15 StatusDesign,
16 StatusImplementing,
17 StatusDeployed,
18 StatusDeprecated,
19 StatusArchived,
20 }
21
22 // StatusColor returns the draw.io badge color for a given status
23 func StatusColor(status string) string {
24 switch status {
25 case StatusProposed:
26 return "#fff2cc" // yellow
27 case StatusDesign:
28 return "#dae8fc" // blue
29 case StatusImplementing:
30 return "#ffe6cc" // orange
31 case StatusDeployed:
32 return "#d5e8d4" // green
33 case StatusDeprecated:
34 return "#f8cecc" // red
35 case StatusArchived:
36 return "#f5f5f5" // grey
37 default:
38 return "#ffffff" // white
39 }
40 }
41
42 // DecisionBadgeColor returns the draw.io badge color for a given ADR status
43 func DecisionBadgeColor(status ADRStatus) string {
44 switch status {
45 case ADRActive:
46 return "#0066cc" // blue
47 case ADRSuperseded, ADRDeprecated:
48 return "#999999" // grey
49 case ADRProposed:
50 return "#ffcc00" // yellow
51 default:
52 return "#ffffff" // white
53 }
54 }
55
56 // Relationship cardinality values
57 const (
58 CardinalityOneToOne = "1:1"
59 CardinalityOneToMany = "1:N"
60 CardinalityManyToMany = "N:N"
61 )
62
63 var ValidCardinalities = []string{
64 CardinalityOneToOne,
65 CardinalityOneToMany,
66 CardinalityManyToMany,
67 }
68
69 // Data flow annotation values
70 const (
71 DataFlowSync = "sync"
72 DataFlowAsync = "async"
73 DataFlowRequestResponse = "request/response"
74 DataFlowPublishSubscribe = "publish/subscribe"
75 )
76
77 var ValidDataFlows = []string{
78 DataFlowSync,
79 DataFlowAsync,
80 DataFlowRequestResponse,
81 DataFlowPublishSubscribe,
82 }
83
84 // Config holds top-level configuration for diagram generation.
85 type Config struct {
86 Metadata *bool `json:"metadata,omitempty"`
87 Legend *bool `json:"legend,omitempty"`
88 Author string `json:"author,omitempty"`
89 Repo string `json:"repo,omitempty"`
90 }
91
92 // ModelSnapshot represents a snapshot of architecture (as-is or to-be)
93 type ModelSnapshot struct {
94 Elements map[string]Element `json:"elements"`
95 Relationships []Relationship `json:"relationships"`
96 }
97
98 // BausteinsichtModel is the top-level model file
99 type BausteinsichtModel struct {
100 Schema string `json:"$schema,omitempty"`
101 Config Config `json:"config,omitempty"`
102 Specification Specification `json:"specification"`
103 Model map[string]Element `json:"model"`
104 Relationships []Relationship `json:"relationships"`
105 Views map[string]View `json:"views"`
106 DynamicViews []DynamicView `json:"dynamicViews,omitempty"`
107 Constraints []Constraint `json:"constraints,omitempty"`
108 AsIs *ModelSnapshot `json:"asIs,omitempty"`
109 ToBe *ModelSnapshot `json:"toBe,omitempty"`
110
111 // ElementOrder stores the definition order of element kinds from
112 // specification.elements. Used by the layout engine for layer assignment.
113 ElementOrder []string `json:"-"`
114 }
115
116 // StepType describes how a sequence step arrow is rendered.
117 type StepType string
118
119 const (
120 StepSync StepType = "sync"
121 StepAsync StepType = "async"
122 StepReturn StepType = "return"
123 )
124
125 // SequenceStep is one message/call in a dynamic view.
126 type SequenceStep struct {
127 Index int `json:"index"`
128 From string `json:"from"`
129 To string `json:"to"`
130 Label string `json:"label"`
131 Type StepType `json:"type,omitempty"`
132 }
133
134 // DynamicView describes a sequence of interactions between elements.
135 type DynamicView struct {
136 Key string `json:"key"`
137 Title string `json:"title"`
138 Description string `json:"description,omitempty"`
139 Steps []SequenceStep `json:"steps"`
140 }
141
142 // Constraint defines an architectural rule that can be enforced via `bausteinsicht lint`.
143 type Constraint struct {
144 ID string `json:"id"`
145 Description string `json:"description"`
146 Rule string `json:"rule"`
147
148 // no-relationship / allowed-relationship
149 FromKind string `json:"from-kind,omitempty"`
150 ToKind string `json:"to-kind,omitempty"`
151 FromKinds []string `json:"from-kinds,omitempty"`
152
153 // required-field
154 ElementKind string `json:"element-kind,omitempty"`
155 Field string `json:"field,omitempty"`
156
157 // max-depth
158 Max int `json:"max,omitempty"`
159
160 // technology-allowed
161 Technologies []string `json:"technologies,omitempty"`
162 }
163
164 // TagDefinition describes a tag with optional styling for draw.io rendering.
165 type TagDefinition struct {
166 ID string `json:"id"`
167 Description string `json:"description,omitempty"`
168 Style map[string]interface{} `json:"style,omitempty"`
169 }
170
171 // PatternElement describes an element template in a pattern
172 type PatternElement struct {
173 ID string `json:"id"`
174 Kind string `json:"kind"`
175 Title string `json:"title"`
176 Technology string `json:"technology,omitempty"`
177 Description string `json:"description,omitempty"`
178 Tags []string `json:"tags,omitempty"`
179 Children []PatternElement `json:"children,omitempty"`
180 }
181
182 // PatternRelationship describes a relationship template in a pattern
183 type PatternRelationship struct {
184 ID string `json:"id"`
185 From string `json:"from"`
186 To string `json:"to"`
187 Label string `json:"label,omitempty"`
188 Kind string `json:"kind,omitempty"`
189 Description string `json:"description,omitempty"`
190 }
191
192 // PatternDefinition describes a reusable topology pattern
193 type PatternDefinition struct {
194 Description string `json:"description,omitempty"`
195 Elements []PatternElement `json:"elements"`
196 Relationships []PatternRelationship `json:"relationships,omitempty"`
197 }
198
199 // ADRStatus describes the status of an architecture decision record
200 type ADRStatus string
201
202 const (
203 ADRProposed ADRStatus = "proposed"
204 ADRActive ADRStatus = "active"
205 ADRDeprecated ADRStatus = "deprecated"
206 ADRSuperseded ADRStatus = "superseded"
207 )
208
209 // DecisionRecord represents an architecture decision record (ADR)
210 type DecisionRecord struct {
211 ID string `json:"id"`
212 Title string `json:"title"`
213 Status ADRStatus `json:"status"`
214 Date string `json:"date,omitempty"`
215 FilePath string `json:"file,omitempty"`
216 }
217
218 type Specification struct {
219 Elements map[string]ElementKind `json:"elements"`
220 Relationships map[string]RelationshipKind `json:"relationships,omitempty"`
221 Tags []TagDefinition `json:"tags,omitempty"`
222 Patterns map[string]PatternDefinition `json:"patterns,omitempty"`
223 Decisions []DecisionRecord `json:"decisions,omitempty"`
224 }
225
226 type ElementKind struct {
227 Notation string `json:"notation"`
228 Description string `json:"description,omitempty"`
229 Container bool `json:"container,omitempty"`
230 }
231
232 type RelationshipKind struct {
233 Notation string `json:"notation"`
234 Dashed bool `json:"dashed,omitempty"`
235 }
236
237 type Element struct {
238 Kind string `json:"kind"`
239 Title string `json:"title"`
240 Description string `json:"description,omitempty"`
241 Technology string `json:"technology,omitempty"`
242 Tags []string `json:"tags,omitempty"`
243 Status string `json:"status,omitempty"`
244 Decisions []string `json:"decisions,omitempty"`
245 Children map[string]Element `json:"children,omitempty"`
246 Metadata map[string]string `json:"metadata,omitempty"`
247 }
248
249 type Relationship struct {
250 From string `json:"from"`
251 To string `json:"to"`
252 Label string `json:"label,omitempty"`
253 Kind string `json:"kind,omitempty"`
254 Description string `json:"description,omitempty"`
255 Decisions []string `json:"decisions,omitempty"`
256 Cardinality string `json:"cardinality,omitempty"`
257 DataFlow string `json:"dataFlow,omitempty"`
258 }
259
260 type View struct {
261 Title string `json:"title"`
262 Scope string `json:"scope,omitempty"`
263 Include []string `json:"include,omitempty"`
264 Exclude []string `json:"exclude,omitempty"`
265 FilterTags []string `json:"filter-tags,omitempty"` // Include only elements with ALL of these tags
266 ExcludeTags []string `json:"exclude-tags,omitempty"` // Exclude elements with ANY of these tags
267 Description string `json:"description,omitempty"`
268 Layout string `json:"layout,omitempty"`
269 }
270
github.com/docToolchain/Bausteinsicht/internal/model/validate.go 84.0%
1 package model
2
3 import (
4 "fmt"
5 "strings"
6 )
7
8 // ValidationError describes a single validation problem with its model path.
9 type ValidationError struct {
10 Path string
11 Message string
12 }
13
14 func (e ValidationError) Error() string {
15 return fmt.Sprintf("%s: %s", e.Path, e.Message)
16 }
17
18 // ValidationWarning describes a non-fatal issue with the model.
19 type ValidationWarning struct {
20 Path string
21 Message string
22 }
23
24 // ValidationResult holds both errors and warnings from validation.
25 type ValidationResult struct {
26 Errors []ValidationError
27 Warnings []ValidationWarning
28 }
29
30 // Validate checks the model for consistency and returns all found errors.
31 func Validate(m *BausteinsichtModel) []ValidationError {
32 result := ValidateWithWarnings(m)
33 return result.Errors
34 }
35
36 // ValidateWithWarnings checks the model for consistency and returns errors and warnings.
37 func ValidateWithWarnings(m *BausteinsichtModel) ValidationResult {
38 var result ValidationResult
39 result.Errors = append(result.Errors, validateElements(m)...)
40 result.Errors = append(result.Errors, validateRelationships(m)...)
41 result.Errors = append(result.Errors, validateViews(m)...)
42 result.Errors = append(result.Errors, validateDynamicViews(m)...)
43 result.Errors = append(result.Errors, validatePatterns(m)...)
44 result.Errors = append(result.Errors, validateDecisions(m)...)
45 result.Warnings = append(result.Warnings, validateEmptyModel(m)...)
46 result.Warnings = append(result.Warnings, validateLifecycleStatus(m)...)
47 result.Warnings = append(result.Warnings, validateOrphanDecisions(m)...)
48 result.Warnings = append(result.Warnings, validateSupersededDecisions(m)...)
49 return result
50 }
51
52 // validateEmptyModel checks for models with no specification or no elements.
53 func validateEmptyModel(m *BausteinsichtModel) []ValidationWarning {
54 var warnings []ValidationWarning
55 if len(m.Specification.Elements) == 0 {
56 warnings = append(warnings, ValidationWarning{
57 Path: "specification",
58 Message: "no element kinds defined in specification",
59 })
60 }
61 if len(m.Model) == 0 {
62 warnings = append(warnings, ValidationWarning{
63 Path: "model",
64 Message: "model is empty (no elements defined)",
65 })
66 }
67 return warnings
68 }
69
70 func validateElements(m *BausteinsichtModel) []ValidationError {
71 var errs []ValidationError
72 for id, elem := range m.Model {
73 if err := validateElementID(id); err != nil {
74 errs = append(errs, ValidationError{Path: "model." + id, Message: err.Error()})
75 }
76 errs = append(errs, validateElement(m, "model."+id, elem, 1)...)
77 }
78 return errs
79 }
80
81 func validateElement(m *BausteinsichtModel, path string, elem Element, depth int) []ValidationError {
82 var errs []ValidationError
83
84 if depth > MaxElementDepth {
85 errs = append(errs, ValidationError{
86 Path: path,
87 Message: fmt.Sprintf("element nesting exceeds maximum depth of %d", MaxElementDepth),
88 })
89 return errs
90 }
91
92 if elem.Kind == "" {
93 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"kind\""})
94 } else {
95 kindDef, known := m.Specification.Elements[elem.Kind]
96 if !known {
97 errs = append(errs, ValidationError{
98 Path: path,
99 Message: fmt.Sprintf("unknown kind %q", elem.Kind),
100 })
101 } else if len(elem.Children) > 0 && !kindDef.Container {
102 errs = append(errs, ValidationError{
103 Path: path,
104 Message: fmt.Sprintf("kind %q does not allow children (container: false)", elem.Kind),
105 })
106 }
107 }
108
109 if elem.Title == "" {
110 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"title\""})
111 }
112
113 for childID, child := range elem.Children {
114 if err := validateElementID(childID); err != nil {
115 errs = append(errs, ValidationError{Path: path + "." + childID, Message: err.Error()})
116 }
117 errs = append(errs, validateElement(m, path+"."+childID, child, depth+1)...)
118 }
119
120 return errs
121 }
122
123 func validateRelationships(m *BausteinsichtModel) []ValidationError {
124 var errs []ValidationError
125 // Track seen relationships keyed by "from->to->kind->label" to allow
126 // multiple relationships between the same pair with different kind or label. (#142)
127 type relSig struct {
128 from, to, kind, label string
129 }
130 seen := make(map[relSig]int) // signature → first index
131
132 for i, rel := range m.Relationships {
133 path := fmt.Sprintf("relationships[%d]", i)
134
135 if _, err := lookupElement(m, rel.From); err != nil {
136 errs = append(errs, ValidationError{
137 Path: path,
138 Message: fmt.Sprintf("from %q does not resolve to an existing element", rel.From),
139 })
140 }
141 if _, err := lookupElement(m, rel.To); err != nil {
142 errs = append(errs, ValidationError{
143 Path: path,
144 Message: fmt.Sprintf("to %q does not resolve to an existing element", rel.To),
145 })
146 }
147 if rel.Kind != "" {
148 if _, known := m.Specification.Relationships[rel.Kind]; !known {
149 errs = append(errs, ValidationError{
150 Path: path,
151 Message: fmt.Sprintf("unknown relationship kind %q", rel.Kind),
152 })
153 }
154 }
155
156 // Validate cardinality
157 if rel.Cardinality != "" {
158 valid := false
159 for _, c := range ValidCardinalities {
160 if rel.Cardinality == c {
161 valid = true
162 break
163 }
164 }
165 if !valid {
166 errs = append(errs, ValidationError{
167 Path: path,
168 Message: fmt.Sprintf("invalid cardinality %q (valid: %v)", rel.Cardinality, ValidCardinalities),
169 })
170 }
171 }
172
173 // Validate data flow
174 if rel.DataFlow != "" {
175 valid := false
176 for _, df := range ValidDataFlows {
177 if rel.DataFlow == df {
178 valid = true
179 break
180 }
181 }
182 if !valid {
183 errs = append(errs, ValidationError{
184 Path: path,
185 Message: fmt.Sprintf("invalid dataFlow %q (valid: %v)", rel.DataFlow, ValidDataFlows),
186 })
187 }
188 }
189
190 // Detect fully duplicate relationships (same from, to, kind, and label). (#117, #142)
191 // Multiple relationships between the same pair are allowed if they
192 // differ in kind or label.
193 sig := relSig{from: rel.From, to: rel.To, kind: rel.Kind, label: rel.Label}
194 if firstIdx, exists := seen[sig]; exists {
195 errs = append(errs, ValidationError{
196 Path: path,
197 Message: fmt.Sprintf("duplicate relationship %s → %s (first at relationships[%d])", rel.From, rel.To, firstIdx),
198 })
199 } else {
200 seen[sig] = i
201 }
202 }
203 return errs
204 }
205
206 // validLayouts is the set of allowed values for View.Layout.
207 var validLayouts = map[string]bool{
208 "": true,
209 "layered": true,
210 "grid": true,
211 "none": true,
212 }
213
214 func validateViews(m *BausteinsichtModel) []ValidationError {
215 var errs []ValidationError
216 for id, view := range m.Views {
217 path := "views." + id
218 if view.Title == "" {
219 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"title\""})
220 }
221 if !validLayouts[view.Layout] {
222 errs = append(errs, ValidationError{
223 Path: path,
224 Message: fmt.Sprintf("invalid layout %q (must be \"layered\", \"grid\", \"none\", or empty)", view.Layout),
225 })
226 }
227 if view.Scope != "" {
228 if _, err := lookupElement(m, view.Scope); err != nil {
229 errs = append(errs, ValidationError{
230 Path: path,
231 Message: fmt.Sprintf("scope %q does not resolve to an existing element", view.Scope),
232 })
233 }
234 }
235 for _, entry := range view.Include {
236 if strings.Contains(entry, "*") {
237 continue
238 }
239 if _, err := lookupElement(m, entry); err != nil {
240 errs = append(errs, ValidationError{
241 Path: path + ".include",
242 Message: fmt.Sprintf("element %q does not exist", entry),
243 })
244 }
245 }
246 for _, entry := range view.Exclude {
247 if strings.Contains(entry, "*") {
248 continue
249 }
250 if _, err := lookupElement(m, entry); err != nil {
251 errs = append(errs, ValidationError{
252 Path: path + ".exclude",
253 Message: fmt.Sprintf("element %q does not exist", entry),
254 })
255 }
256 }
257
258 // Validate filter-tags
259 validTagIDs := make(map[string]bool)
260 for _, tag := range m.Specification.Tags {
261 validTagIDs[tag.ID] = true
262 }
263 for _, tag := range view.FilterTags {
264 if !validTagIDs[tag] {
265 errs = append(errs, ValidationError{
266 Path: path + ".filter-tags",
267 Message: fmt.Sprintf("tag %q is not defined in specification.tags", tag),
268 })
269 }
270 }
271
272 // Validate exclude-tags
273 for _, tag := range view.ExcludeTags {
274 if !validTagIDs[tag] {
275 errs = append(errs, ValidationError{
276 Path: path + ".exclude-tags",
277 Message: fmt.Sprintf("tag %q is not defined in specification.tags", tag),
278 })
279 }
280 }
281 }
282 return errs
283 }
284
285 var validStepTypes = map[StepType]bool{
286 StepSync: true,
287 StepAsync: true,
288 StepReturn: true,
289 "": true, // omitted → default sync
290 }
291
292 func validateDynamicViews(m *BausteinsichtModel) []ValidationError {
293 var errs []ValidationError
294 for vi, dv := range m.DynamicViews {
295 path := fmt.Sprintf("dynamicViews[%d]", vi)
296 if dv.Key == "" {
297 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"key\""})
298 }
299 if dv.Title == "" {
300 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"title\""})
301 }
302 if len(dv.Steps) == 0 {
303 errs = append(errs, ValidationError{Path: path, Message: "dynamic view must have at least one step"})
304 continue
305 }
306 seenIndex := make(map[int]bool)
307 for si, step := range dv.Steps {
308 spath := fmt.Sprintf("%s.steps[%d]", path, si)
309 if _, err := lookupElement(m, step.From); err != nil {
310 errs = append(errs, ValidationError{
311 Path: spath,
312 Message: fmt.Sprintf("from %q does not resolve to an existing element", step.From),
313 })
314 }
315 if _, err := lookupElement(m, step.To); err != nil {
316 errs = append(errs, ValidationError{
317 Path: spath,
318 Message: fmt.Sprintf("to %q does not resolve to an existing element", step.To),
319 })
320 }
321 if !validStepTypes[step.Type] {
322 errs = append(errs, ValidationError{
323 Path: spath,
324 Message: fmt.Sprintf("invalid type %q (must be \"sync\", \"async\", or \"return\")", step.Type),
325 })
326 }
327 if seenIndex[step.Index] {
328 errs = append(errs, ValidationError{
329 Path: spath,
330 Message: fmt.Sprintf("duplicate step index %d", step.Index),
331 })
332 }
333 seenIndex[step.Index] = true
334 }
335 }
336 return errs
337 }
338
339 // validateElementID checks that an element ID is valid.
340 func validateElementID(id string) error {
341 if strings.TrimSpace(id) == "" {
342 return fmt.Errorf("invalid element ID %q: must not be empty or whitespace", id)
343 }
344 return nil
345 }
346
347 // lookupElement resolves a dot-notation path to an Element within the model.
348 func lookupElement(m *BausteinsichtModel, path string) (Element, error) {
349 head, rest, hasDot := strings.Cut(path, ".")
350 elem, ok := m.Model[head]
351 if !ok {
352 return Element{}, fmt.Errorf("element %q not found", head)
353 }
354 if !hasDot {
355 return elem, nil
356 }
357 return lookupChild(elem, rest)
358 }
359
360 func lookupChild(parent Element, path string) (Element, error) {
361 head, rest, hasDot := strings.Cut(path, ".")
362 child, ok := parent.Children[head]
363 if !ok {
364 return Element{}, fmt.Errorf("element %q not found", head)
365 }
366 if !hasDot {
367 return child, nil
368 }
369 return lookupChild(child, rest)
370 }
371
372 // validateLifecycleStatus checks element status fields for validity and lifecycle warnings.
373 func validateLifecycleStatus(m *BausteinsichtModel) []ValidationWarning {
374 var warnings []ValidationWarning
375 flatElements, _ := FlattenElements(m)
376
377 // Collect outgoing relationships by element
378 outgoing := make(map[string][]string)
379 for _, rel := range m.Relationships {
380 outgoing[rel.From] = append(outgoing[rel.From], rel.To)
381 }
382
383 for id, elem := range flatElements {
384 if elem.Status == "" {
385 continue // Status is optional
386 }
387
388 // Validate status value
389 validStatus := false
390 for _, valid := range ValidStatuses {
391 if elem.Status == valid {
392 validStatus = true
393 break
394 }
395 }
396 if !validStatus {
397 warnings = append(warnings, ValidationWarning{
398 Path: "model." + id,
399 Message: fmt.Sprintf("unknown status %q; valid values are: %v", elem.Status, ValidStatuses),
400 })
401 continue
402 }
403
404 // Rule: archived elements should not have outgoing relationships
405 if elem.Status == StatusArchived && len(outgoing[id]) > 0 {
406 warnings = append(warnings, ValidationWarning{
407 Path: "model." + id,
408 Message: fmt.Sprintf("archived element has %d outgoing relationships; archived elements should not have active relationships", len(outgoing[id])),
409 })
410 }
411
412 // Rule: deprecated elements should ideally have a successor
413 if elem.Status == StatusDeprecated {
414 hasSuccessor := false
415 for _, target := range outgoing[id] {
416 if targetElem, ok := flatElements[target]; ok && targetElem.Status == StatusDeployed && targetElem.Kind == elem.Kind {
417 hasSuccessor = true
418 break
419 }
420 }
421 if !hasSuccessor {
422 warnings = append(warnings, ValidationWarning{
423 Path: "model." + id,
424 Message: "deprecated element has no deployed successor of the same kind; consider linking to a replacement",
425 })
426 }
427 }
428 }
429
430 return warnings
431 }
432
433 // validatePatterns checks pattern definitions for consistency
434 func validatePatterns(m *BausteinsichtModel) []ValidationError {
435 var errs []ValidationError
436
437 for patternID, pattern := range m.Specification.Patterns {
438 path := "specification.patterns." + patternID
439
440 // Validate all element kinds referenced in the pattern exist
441 for i, elem := range pattern.Elements {
442 elemPath := fmt.Sprintf("%s.elements[%d]", path, i)
443 if elem.Kind == "" {
444 errs = append(errs, ValidationError{
445 Path: elemPath,
446 Message: "missing required field \"kind\"",
447 })
448 } else if _, exists := m.Specification.Elements[elem.Kind]; !exists {
449 errs = append(errs, ValidationError{
450 Path: elemPath,
451 Message: fmt.Sprintf("unknown kind %q", elem.Kind),
452 })
453 }
454 }
455
456 // Validate all relationship kinds referenced in the pattern exist
457 for i, rel := range pattern.Relationships {
458 relPath := fmt.Sprintf("%s.relationships[%d]", path, i)
459 if rel.Kind != "" {
460 if _, exists := m.Specification.Relationships[rel.Kind]; !exists {
461 errs = append(errs, ValidationError{
462 Path: relPath,
463 Message: fmt.Sprintf("unknown relationship kind %q", rel.Kind),
464 })
465 }
466 }
467 }
468 }
469
470 return errs
471 }
472
473 // validateDecisions checks for unknown ADR IDs referenced by elements and relationships.
474 func validateDecisions(m *BausteinsichtModel) []ValidationError {
475 var errs []ValidationError
476
477 // Build a map of known decision IDs
478 knownDecisions := make(map[string]bool)
479 for _, decision := range m.Specification.Decisions {
480 knownDecisions[decision.ID] = true
481 }
482
483 // Check elements
484 flatElements, _ := FlattenElements(m)
485 for id, elem := range flatElements {
486 for _, decisionID := range elem.Decisions {
487 if !knownDecisions[decisionID] {
488 errs = append(errs, ValidationError{
489 Path: "model." + id,
490 Message: fmt.Sprintf("references unknown decision %q", decisionID),
491 })
492 }
493 }
494 }
495
496 // Check relationships
497 for i, rel := range m.Relationships {
498 for _, decisionID := range rel.Decisions {
499 if !knownDecisions[decisionID] {
500 errs = append(errs, ValidationError{
501 Path: fmt.Sprintf("relationships[%d]", i),
502 Message: fmt.Sprintf("references unknown decision %q", decisionID),
503 })
504 }
505 }
506 }
507
508 return errs
509 }
510
511 // validateOrphanDecisions checks for decisions that are not referenced by any element or relationship.
512 func validateOrphanDecisions(m *BausteinsichtModel) []ValidationWarning {
513 var warnings []ValidationWarning
514
515 // Build a set of referenced decisions
516 referencedDecisions := make(map[string]bool)
517
518 flatElements, _ := FlattenElements(m)
519 for _, elem := range flatElements {
520 for _, decisionID := range elem.Decisions {
521 referencedDecisions[decisionID] = true
522 }
523 }
524
525 for _, rel := range m.Relationships {
526 for _, decisionID := range rel.Decisions {
527 referencedDecisions[decisionID] = true
528 }
529 }
530
531 // Check for orphans
532 for _, decision := range m.Specification.Decisions {
533 if !referencedDecisions[decision.ID] {
534 warnings = append(warnings, ValidationWarning{
535 Path: "specification.decisions",
536 Message: fmt.Sprintf("decision %q is not referenced by any element or relationship", decision.ID),
537 })
538 }
539 }
540
541 return warnings
542 }
543
544 // validateSupersededDecisions checks for superseded decisions that are still referenced.
545 func validateSupersededDecisions(m *BausteinsichtModel) []ValidationWarning {
546 var warnings []ValidationWarning
547
548 // Build a map of decision status
549 decisionStatus := make(map[string]ADRStatus)
550 for _, decision := range m.Specification.Decisions {
551 decisionStatus[decision.ID] = decision.Status
552 }
553
554 // Check elements
555 flatElements, _ := FlattenElements(m)
556 for id, elem := range flatElements {
557 for _, decisionID := range elem.Decisions {
558 if status, exists := decisionStatus[decisionID]; exists && status == ADRSuperseded {
559 warnings = append(warnings, ValidationWarning{
560 Path: "model." + id,
561 Message: fmt.Sprintf("references superseded decision %q", decisionID),
562 })
563 }
564 }
565 }
566
567 // Check relationships
568 for i, rel := range m.Relationships {
569 for _, decisionID := range rel.Decisions {
570 if status, exists := decisionStatus[decisionID]; exists && status == ADRSuperseded {
571 warnings = append(warnings, ValidationWarning{
572 Path: fmt.Sprintf("relationships[%d]", i),
573 Message: fmt.Sprintf("references superseded decision %q", decisionID),
574 })
575 }
576 }
577 }
578
579 return warnings
580 }
581
582
github.com/docToolchain/Bausteinsicht/internal/overlay/apply.go 0.0%
1 package overlay
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7
8 "github.com/beevik/etree"
9 )
10
11 const OriginalFillAttr = "data-original-fill"
12
13 func LoadMetricsFile(path string) (*MetricsFile, error) {
14 data, err := os.ReadFile(path)
15 if err != nil {
16 return nil, fmt.Errorf("reading metrics file: %w", err)
17 }
18 var mf MetricsFile
19 if err := json.Unmarshal(data, &mf); err != nil {
20 return nil, fmt.Errorf("parsing metrics file: %w", err)
21 }
22 return &mf, nil
23 }
24
25 func Apply(drawioPath string, metrics *MetricsFile, metricKey string, scheme ColorScheme) error {
26 doc := etree.NewDocument()
27 if err := doc.ReadFromFile(drawioPath); err != nil {
28 return fmt.Errorf("reading draw.io file: %w", err)
29 }
30
31 extracted, err := ExtractMetric(metrics.Metrics, metricKey)
32 if err != nil {
33 return fmt.Errorf("extracting metric %q: %w", metricKey, err)
34 }
35
36 if len(extracted) == 0 {
37 return fmt.Errorf("no elements found for metric %q", metricKey)
38 }
39
40 higherIsBetter := IsMetricBetter(metricKey)
41 normalized := Normalize(extracted, higherIsBetter)
42
43 root := doc.Root()
44 for _, page := range root.FindElements(".//mxGraphModel/root/mxCell") {
45 elementID := page.SelectAttrValue("id", "")
46 if elementID == "" || elementID == "0" || elementID == "1" {
47 continue
48 }
49
50 if normVal, ok := normalized[elementID]; ok {
51 applyColor(page, normVal, scheme)
52 }
53 }
54
55 if err := doc.WriteToFile(drawioPath); err != nil {
56 return fmt.Errorf("writing draw.io file: %w", err)
57 }
58 return nil
59 }
60
61 func Remove(drawioPath string) error {
62 doc := etree.NewDocument()
63 if err := doc.ReadFromFile(drawioPath); err != nil {
64 return fmt.Errorf("reading draw.io file: %w", err)
65 }
66
67 root := doc.Root()
68 for _, cell := range root.FindElements(".//mxGraphModel/root/mxCell") {
69 originalFill := cell.SelectAttrValue(OriginalFillAttr, "")
70 if originalFill != "" {
71 geometry := cell.FindElement("mxGeometry")
72 if geometry != nil {
73 style := geometry.SelectAttrValue("style", "")
74 if style != "" {
75 style = updateStyleFill(style, originalFill)
76 geometry.CreateAttr("style", style)
77 }
78 }
79 cell.RemoveAttr(OriginalFillAttr)
80 }
81 }
82
83 if err := doc.WriteToFile(drawioPath); err != nil {
84 return fmt.Errorf("writing draw.io file: %w", err)
85 }
86 return nil
87 }
88
89 func applyColor(cell *etree.Element, normalized float64, scheme ColorScheme) {
90 color := ColorForValue(normalized, scheme)
91
92 geometry := cell.FindElement("mxGeometry")
93 if geometry == nil {
94 return
95 }
96
97 style := geometry.SelectAttrValue("style", "")
98 originalFill := geometry.SelectAttrValue("fillColor", "")
99
100 if originalFill == "" {
101 originalFill = "#ffffff"
102 }
103 if cell.SelectAttrValue(OriginalFillAttr, "") == "" {
104 cell.CreateAttr(OriginalFillAttr, originalFill)
105 }
106
107 style = updateStyleFill(style, color)
108 geometry.CreateAttr("style", style)
109 }
110
111 func updateStyleFill(style, color string) string {
112 if style == "" {
113 return "fillColor=" + color
114 }
115
116 result := ""
117 hasKey := false
118 for _, part := range parseStyleParts(style) {
119 if len(part) > 0 && startsWithKey(part, "fillColor") {
120 result += "fillColor=" + color + ";"
121 hasKey = true
122 } else {
123 if part != "" {
124 result += part + ";"
125 }
126 }
127 }
128 if !hasKey {
129 result += "fillColor=" + color + ";"
130 }
131 return result
132 }
133
134 func parseStyleParts(style string) []string {
135 var result []string
136 var current string
137 for _, ch := range style {
138 if ch == ';' {
139 if current != "" {
140 result = append(result, current)
141 current = ""
142 }
143 } else {
144 current += string(ch)
145 }
146 }
147 if current != "" {
148 result = append(result, current)
149 }
150 return result
151 }
152
153 func startsWithKey(part, key string) bool {
154 return len(part) >= len(key) && part[:len(key)] == key
155 }
156
github.com/docToolchain/Bausteinsicht/internal/overlay/normalize.go 97.2%
1 package overlay
2
3 import (
4 "cmp"
5 "slices"
6 )
7
8 func IsMetricBetter(metricName string) bool {
9 goodMetrics := map[string]bool{
10 "coverage": true,
11 "uptime": true,
12 "deploy_freq": true,
13 "success_rate": true,
14 "availability": true,
15 }
16 badMetrics := map[string]bool{
17 "error_rate": true,
18 "latency": true,
19 "p99": true,
20 "p99_ms": true,
21 "response_time": true,
22 "cpu_usage": true,
23 "memory_usage": true,
24 "error_count": true,
25 "failures": true,
26 }
27
28 if goodMetrics[metricName] {
29 return true
30 }
31 if badMetrics[metricName] {
32 return false
33 }
34 return false
35 }
36
37 func Normalize(metrics []NormalizedMetric, higherIsBetter bool) map[string]float64 {
38 if len(metrics) == 0 {
39 return make(map[string]float64)
40 }
41
42 values := make([]float64, len(metrics))
43 for i, m := range metrics {
44 values[i] = m.Value
45 }
46
47 min := slices.Min(values)
48 max := slices.Max(values)
49 span := max - min
50
51 result := make(map[string]float64)
52 for _, m := range metrics {
53 normalized := 0.0
54 if span > 0 {
55 normalized = (m.Value - min) / span
56 }
57 if !higherIsBetter {
58 normalized = 1 - normalized
59 }
60 result[m.ElementID] = normalized
61 }
62 return result
63 }
64
65 func ColorForValue(normalized float64, scheme ColorScheme) string {
66 switch {
67 case normalized < 0.25:
68 return scheme.Green
69 case normalized < 0.50:
70 return scheme.Yellow
71 case normalized < 0.75:
72 return scheme.Orange
73 default:
74 return scheme.Red
75 }
76 }
77
78 func ExtractMetric(metrics []ElementMetric, metricKey string) ([]NormalizedMetric, error) {
79 result := make([]NormalizedMetric, 0, len(metrics))
80 for _, m := range metrics {
81 if val, ok := m.Values[metricKey]; ok {
82 result = append(result, NormalizedMetric{
83 ElementID: m.ElementID,
84 Value: val,
85 })
86 }
87 }
88 slices.SortFunc(result, func(a, b NormalizedMetric) int {
89 return cmp.Compare(a.ElementID, b.ElementID)
90 })
91 return result, nil
92 }
93
github.com/docToolchain/Bausteinsicht/internal/overlay/types.go 0.0%
1 package overlay
2
3 import (
4 "encoding/json"
5 )
6
7 type MetricsFile struct {
8 Meta MetaInfo `json:"meta"`
9 Metrics []ElementMetric `json:"metrics"`
10 }
11
12 type MetaInfo struct {
13 Generated string `json:"generated"`
14 Source string `json:"source"`
15 MetricDescriptions map[string]string `json:"metric_descriptions"`
16 }
17
18 type ElementMetric struct {
19 ElementID string `json:"elementId"`
20 Values map[string]float64
21 }
22
23 func (em *ElementMetric) UnmarshalJSON(data []byte) error {
24 var raw map[string]interface{}
25 if err := json.Unmarshal(data, &raw); err != nil {
26 return err
27 }
28
29 em.Values = make(map[string]float64)
30 for key, val := range raw {
31 if key == "elementId" {
32 if str, ok := val.(string); ok {
33 em.ElementID = str
34 }
35 } else if num, ok := val.(float64); ok {
36 em.Values[key] = num
37 }
38 }
39 return nil
40 }
41
42 type NormalizedMetric struct {
43 ElementID string
44 Value float64
45 }
46
47 type ColorScheme struct {
48 Green string
49 Yellow string
50 Orange string
51 Red string
52 }
53
54 var DefaultColorScheme = ColorScheme{
55 Green: "#d5e8d4",
56 Yellow: "#fff2cc",
57 Orange: "#ffe6cc",
58 Red: "#f8cecc",
59 }
60
github.com/docToolchain/Bausteinsicht/internal/schema/generator.go 85.9%
1 package schema
2
3 import (
4 "encoding/json"
5 "reflect"
6 )
7
8 // JSONSchema represents a JSON Schema Draft 7 schema
9 type JSONSchema struct {
10 Schema string `json:"$schema"`
11 Title string `json:"title"`
12 Description string `json:"description,omitempty"`
13 Type string `json:"type"`
14 Properties map[string]interface{} `json:"properties,omitempty"`
15 Required []string `json:"required,omitempty"`
16 Definitions map[string]interface{} `json:"definitions,omitempty"`
17 }
18
19 // Generator generates JSON Schema from Go types
20 type Generator struct {
21 definitions map[string]interface{}
22 }
23
24 // NewGenerator creates a new schema generator
25 func NewGenerator() *Generator {
26 return &Generator{
27 definitions: make(map[string]interface{}),
28 }
29 }
30
31 // Generate generates JSON Schema for a given type
32 func (g *Generator) Generate(v interface{}) *JSONSchema {
33 schema := &JSONSchema{
34 Schema: "http://json-schema.org/draft-07/schema#",
35 Title: "Bausteinsicht Model",
36 Description: "Architecture model in Bausteinsicht format",
37 Type: "object",
38 Properties: make(map[string]interface{}),
39 Definitions: g.definitions,
40 }
41
42 // Generate properties from struct fields
43 t := reflect.TypeOf(v)
44 if t.Kind() == reflect.Ptr { //nolint:govet
45 t = t.Elem()
46 }
47
48 for i := 0; i < t.NumField(); i++ {
49 field := t.Field(i)
50 jsonTag := field.Tag.Get("json")
51 if jsonTag == "" || jsonTag == "-" {
52 continue
53 }
54
55 fieldName := jsonTag
56 if idx := findComma(jsonTag); idx >= 0 {
57 fieldName = jsonTag[:idx]
58 }
59
60 schema.Properties[fieldName] = g.generateFieldSchema(field.Type)
61
62 // Add to required if no omitempty
63 if !hasOmitempty(jsonTag) {
64 schema.Required = append(schema.Required, fieldName)
65 }
66 }
67
68 return schema
69 }
70
71 // generateFieldSchema generates schema for a single field
72 func (g *Generator) generateFieldSchema(t reflect.Type) interface{} {
73 if t.Kind() == reflect.Ptr { //nolint:govet
74 return g.generateFieldSchema(t.Elem())
75 }
76
77 switch t.Kind() {
78 case reflect.String:
79 return map[string]interface{}{"type": "string"}
80 case reflect.Bool:
81 return map[string]interface{}{"type": "boolean"}
82 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
83 return map[string]interface{}{"type": "integer"}
84 case reflect.Float32, reflect.Float64:
85 return map[string]interface{}{"type": "number"}
86 case reflect.Slice, reflect.Array:
87 return map[string]interface{}{
88 "type": "array",
89 "items": g.generateFieldSchema(t.Elem()),
90 }
91 case reflect.Map:
92 return map[string]interface{}{
93 "type": "object",
94 }
95 case reflect.Struct:
96 return g.generateObjectSchema(t)
97 default:
98 return map[string]interface{}{"type": "object"}
99 }
100 }
101
102 // generateObjectSchema generates schema for a struct type
103 func (g *Generator) generateObjectSchema(t reflect.Type) interface{} {
104 typeName := t.Name()
105 if typeName == "" {
106 return map[string]interface{}{"type": "object"}
107 }
108
109 // Check if already defined
110 if _, exists := g.definitions[typeName]; exists {
111 return map[string]interface{}{"$ref": "#/definitions/" + typeName}
112 }
113
114 schema := map[string]interface{}{
115 "type": "object",
116 "properties": make(map[string]interface{}),
117 }
118
119 properties := schema["properties"].(map[string]interface{})
120 required := []string{}
121
122 for i := 0; i < t.NumField(); i++ {
123 field := t.Field(i)
124 jsonTag := field.Tag.Get("json")
125 if jsonTag == "" || jsonTag == "-" {
126 continue
127 }
128
129 fieldName := jsonTag
130 if idx := findComma(jsonTag); idx >= 0 {
131 fieldName = jsonTag[:idx]
132 }
133
134 properties[fieldName] = g.generateFieldSchema(field.Type)
135
136 if !hasOmitempty(jsonTag) {
137 required = append(required, fieldName)
138 }
139 }
140
141 if len(required) > 0 {
142 schema["required"] = required
143 }
144
145 g.definitions[typeName] = schema
146 return map[string]interface{}{"$ref": "#/definitions/" + typeName}
147 }
148
149 // ToJSON returns the schema as formatted JSON
150 func (s *JSONSchema) ToJSON() ([]byte, error) {
151 return json.MarshalIndent(s, "", " ")
152 }
153
154 // helper functions
155
156 func findComma(s string) int {
157 for i, c := range s {
158 if c == ',' {
159 return i
160 }
161 }
162 return -1
163 }
164
165 func hasOmitempty(jsonTag string) bool {
166 idx := findComma(jsonTag)
167 if idx < 0 {
168 return false
169 }
170 return json.Unmarshal([]byte(`"`+jsonTag[idx+1:]+`"`), new(string)) == nil &&
171 contains(jsonTag[idx+1:], "omitempty")
172 }
173
174 func contains(s, substr string) bool {
175 for i := 0; i <= len(s)-len(substr); i++ {
176 if s[i:i+len(substr)] == substr {
177 return true
178 }
179 }
180 return false
181 }
182
github.com/docToolchain/Bausteinsicht/internal/search/scorer.go 95.6%
1 package search
2
3 import (
4 "strings"
5 )
6
7 // fieldMatch checks whether the field value contains all query words (case-insensitive).
8 // Returns the weight if it matches, 0 otherwise. An exact full-string match
9 // (after lowercasing) returns 10× the weight to prioritise ID hits.
10 func fieldMatch(value string, words []string, weight int) (score int, matched bool) {
11 if value == "" || weight == 0 {
12 return 0, false
13 }
14 lower := strings.ToLower(value)
15 for _, w := range words {
16 if !strings.Contains(lower, w) {
17 return 0, false
18 }
19 }
20 // Exact match bonus: single-word query that equals the whole field value.
21 if len(words) == 1 && lower == words[0] {
22 return weight * 10, true
23 }
24 return weight, true
25 }
26
27 // scoreElement computes a relevance score for an element.
28 // Returns the total score and the list of field names that contributed.
29 func scoreElement(id, title, description, technology, kind string, tags []string, words []string) (int, []string) {
30 type field struct {
31 name string
32 value string
33 weight int
34 }
35 fields := []field{
36 {"id", id, 3},
37 {"title", title, 3},
38 {"technology", technology, 2},
39 {"kind", kind, 2},
40 {"description", description, 1},
41 }
42
43 total := 0
44 var matched []string
45 for _, f := range fields {
46 if s, ok := fieldMatch(f.value, words, f.weight); ok {
47 total += s
48 matched = append(matched, f.name)
49 }
50 }
51 for _, tag := range tags {
52 if s, ok := fieldMatch(tag, words, 2); ok {
53 total += s
54 if !contains(matched, "tags") {
55 matched = append(matched, "tags")
56 }
57 }
58 }
59 return total, matched
60 }
61
62 // scoreRelationship computes a relevance score for a relationship.
63 func scoreRelationship(id, label, kind, fromTitle, toTitle string, words []string) (int, []string) {
64 type field struct {
65 name string
66 value string
67 weight int
68 }
69 fields := []field{
70 {"id", id, 3},
71 {"label", label, 3},
72 {"kind", kind, 2},
73 {"from", fromTitle, 2},
74 {"to", toTitle, 2},
75 }
76
77 total := 0
78 var matched []string
79 for _, f := range fields {
80 if s, ok := fieldMatch(f.value, words, f.weight); ok {
81 total += s
82 matched = append(matched, f.name)
83 }
84 }
85 return total, matched
86 }
87
88 // scoreView computes a relevance score for a view.
89 func scoreView(key, title, description string, words []string) (int, []string) {
90 type field struct {
91 name string
92 value string
93 weight int
94 }
95 fields := []field{
96 {"key", key, 3},
97 {"title", title, 3},
98 {"description", description, 1},
99 }
100
101 total := 0
102 var matched []string
103 for _, f := range fields {
104 if s, ok := fieldMatch(f.value, words, f.weight); ok {
105 total += s
106 matched = append(matched, f.name)
107 }
108 }
109 return total, matched
110 }
111
112 func contains(slice []string, s string) bool {
113 for _, v := range slice {
114 if v == s {
115 return true
116 }
117 }
118 return false
119 }
120
github.com/docToolchain/Bausteinsicht/internal/search/search.go 94.9%
1 // Package search implements full-text search over Bausteinsicht model objects
2 // (elements, relationships, views) with field-weighted relevance scoring.
3 package search
4
5 import (
6 "sort"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // Run searches the model for the given query and returns ranked results.
13 // The query is split into words; all words must appear (AND semantics).
14 // Results are sorted by descending score, then alphabetically by ID.
15 func Run(query string, m *model.BausteinsichtModel, opts Options) Response {
16 words := tokenise(query)
17 if len(words) == 0 {
18 return Response{Query: query, Results: []Result{}, Total: 0}
19 }
20
21 flat, err := model.FlattenElements(m)
22 if err != nil {
23 return Response{Query: query, Results: []Result{}, Total: 0}
24 }
25
26 // Build a title lookup for relationships (from/to display).
27 titleOf := func(id string) string {
28 if e, ok := flat[id]; ok && e.Title != "" {
29 return e.Title
30 }
31 return id
32 }
33
34 var results []Result
35
36 if opts.Type == "" || opts.Type == ResultElement {
37 for id, elem := range flat {
38 score, matched := scoreElement(id, elem.Title, elem.Description, elem.Technology, elem.Kind, elem.Tags, words)
39 if score == 0 {
40 continue
41 }
42 results = append(results, Result{
43 Type: ResultElement,
44 ID: id,
45 Title: elem.Title,
46 Kind: elem.Kind,
47 Technology: elem.Technology,
48 Description: elem.Description,
49 Score: score,
50 MatchedFields: matched,
51 })
52 }
53 }
54
55 if opts.Type == "" || opts.Type == ResultRelationship {
56 for _, rel := range m.Relationships {
57 id := rel.From + "->" + rel.To
58 score, matched := scoreRelationship(id, rel.Label, rel.Kind, titleOf(rel.From), titleOf(rel.To), words)
59 if score == 0 {
60 continue
61 }
62 results = append(results, Result{
63 Type: ResultRelationship,
64 ID: id,
65 Title: rel.Label,
66 Kind: rel.Kind,
67 From: rel.From,
68 To: rel.To,
69 Description: rel.Description,
70 Score: score,
71 MatchedFields: matched,
72 })
73 }
74 }
75
76 if opts.Type == "" || opts.Type == ResultView {
77 for key, view := range m.Views {
78 score, matched := scoreView(key, view.Title, view.Description, words)
79 if score == 0 {
80 continue
81 }
82 results = append(results, Result{
83 Type: ResultView,
84 ID: key,
85 Title: view.Title,
86 Description: view.Description,
87 Score: score,
88 MatchedFields: matched,
89 })
90 }
91 }
92
93 sort.Slice(results, func(i, j int) bool {
94 if results[i].Score != results[j].Score {
95 return results[i].Score > results[j].Score
96 }
97 return results[i].ID < results[j].ID
98 })
99
100 return Response{
101 Query: query,
102 Results: results,
103 Total: len(results),
104 }
105 }
106
107 // tokenise lowercases the query and splits it into words.
108 func tokenise(query string) []string {
109 lower := strings.ToLower(strings.TrimSpace(query))
110 if lower == "" {
111 return nil
112 }
113 return strings.Fields(lower)
114 }
115
github.com/docToolchain/Bausteinsicht/internal/snapshot/storage.go 0.0%
1 package snapshot
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9 "time"
10 )
11
12 const (
13 snapshotDir = ".bausteinsicht-snapshots"
14 indexFile = "index.json"
15 snapshotDir0755 = 0o755
16 fileMode0644 = 0o644
17 )
18
19 // Manager handles snapshot storage and retrieval
20 type Manager struct {
21 baseDir string
22 }
23
24 // NewManager creates a new snapshot manager for a given directory
25 func NewManager(baseDir string) *Manager {
26 return &Manager{baseDir: baseDir}
27 }
28
29 // snapshotPath returns the path to the snapshot directory
30 func (m *Manager) snapshotPath() string {
31 return filepath.Join(m.baseDir, snapshotDir)
32 }
33
34 // indexPath returns the path to the snapshot index file
35 func (m *Manager) indexPath() string {
36 return filepath.Join(m.snapshotPath(), indexFile)
37 }
38
39 // snapshotFilePath returns the path to a specific snapshot file
40 func (m *Manager) snapshotFilePath(id string) string {
41 return filepath.Join(m.snapshotPath(), id+".json")
42 }
43
44 // Save stores a snapshot to disk
45 func (m *Manager) Save(snapshot *Snapshot) error {
46 // Create snapshot directory if it doesn't exist
47 snapPath := m.snapshotPath()
48 if err := os.MkdirAll(snapPath, snapshotDir0755); err != nil {
49 return fmt.Errorf("creating snapshot directory: %w", err)
50 }
51
52 // Write snapshot file
53 snapshotFile := m.snapshotFilePath(snapshot.ID)
54 data, err := json.MarshalIndent(snapshot, "", " ")
55 if err != nil {
56 return fmt.Errorf("marshaling snapshot: %w", err)
57 }
58 if err := os.WriteFile(snapshotFile, data, fileMode0644); err != nil {
59 return fmt.Errorf("writing snapshot file: %w", err)
60 }
61
62 // Update index
63 if err := m.updateIndex(snapshot); err != nil {
64 return fmt.Errorf("updating index: %w", err)
65 }
66
67 return nil
68 }
69
70 // Load retrieves a snapshot from disk
71 func (m *Manager) Load(id string) (*Snapshot, error) {
72 snapshotFile := m.snapshotFilePath(id)
73 data, err := os.ReadFile(snapshotFile)
74 if err != nil {
75 return nil, fmt.Errorf("reading snapshot file: %w", err)
76 }
77
78 var snapshot Snapshot
79 if err := json.Unmarshal(data, &snapshot); err != nil {
80 return nil, fmt.Errorf("parsing snapshot: %w", err)
81 }
82
83 return &snapshot, nil
84 }
85
86 // List returns all saved snapshots sorted by timestamp (newest first)
87 func (m *Manager) List() ([]SnapshotMetadata, error) {
88 indexPath := m.indexPath()
89 data, err := os.ReadFile(indexPath)
90 if err != nil {
91 if os.IsNotExist(err) {
92 return []SnapshotMetadata{}, nil
93 }
94 return nil, fmt.Errorf("reading index: %w", err)
95 }
96
97 var index SnapshotIndex
98 if err := json.Unmarshal(data, &index); err != nil {
99 return nil, fmt.Errorf("parsing index: %w", err)
100 }
101
102 return index.Snapshots, nil
103 }
104
105 // Delete removes a snapshot from disk
106 func (m *Manager) Delete(id string) error {
107 snapshotFile := m.snapshotFilePath(id)
108 if err := os.Remove(snapshotFile); err != nil {
109 return fmt.Errorf("deleting snapshot file: %w", err)
110 }
111
112 // Update index to remove the snapshot
113 if err := m.removeFromIndex(id); err != nil {
114 return fmt.Errorf("updating index: %w", err)
115 }
116
117 return nil
118 }
119
120 // updateIndex adds or updates a snapshot in the index
121 func (m *Manager) updateIndex(snapshot *Snapshot) error {
122 snapPath := m.snapshotPath()
123 if err := os.MkdirAll(snapPath, snapshotDir0755); err != nil {
124 return err
125 }
126
127 indexPath := m.indexPath()
128 var index SnapshotIndex
129
130 // Read existing index if it exists
131 if data, err := os.ReadFile(indexPath); err == nil {
132 if err := json.Unmarshal(data, &index); err != nil {
133 return err
134 }
135 }
136
137 // Remove old entry if it exists
138 index.Snapshots = removeMetadataByID(index.Snapshots, snapshot.ID)
139
140 // Add new entry
141 index.Snapshots = append(index.Snapshots, snapshot.ToMetadata())
142
143 // Sort by timestamp (newest first)
144 sort.Slice(index.Snapshots, func(i, j int) bool {
145 return index.Snapshots[i].Timestamp.After(index.Snapshots[j].Timestamp)
146 })
147
148 index.Version = 1
149 index.UpdatedAt = time.Now().UTC()
150
151 // Write updated index
152 data, err := json.MarshalIndent(index, "", " ")
153 if err != nil {
154 return err
155 }
156 return os.WriteFile(indexPath, data, fileMode0644)
157 }
158
159 // removeFromIndex removes a snapshot from the index
160 func (m *Manager) removeFromIndex(id string) error {
161 indexPath := m.indexPath()
162 data, err := os.ReadFile(indexPath)
163 if err != nil {
164 if os.IsNotExist(err) {
165 return nil
166 }
167 return err
168 }
169
170 var index SnapshotIndex
171 if err := json.Unmarshal(data, &index); err != nil {
172 return err
173 }
174
175 index.Snapshots = removeMetadataByID(index.Snapshots, id)
176 index.UpdatedAt = time.Now().UTC()
177
178 updatedData, err := json.MarshalIndent(index, "", " ")
179 if err != nil {
180 return err
181 }
182
183 return os.WriteFile(indexPath, updatedData, fileMode0644)
184 }
185
186 // removeMetadataByID removes a metadata entry by ID
187 func removeMetadataByID(metadata []SnapshotMetadata, id string) []SnapshotMetadata {
188 var result []SnapshotMetadata
189 for _, m := range metadata {
190 if m.ID != id {
191 result = append(result, m)
192 }
193 }
194 return result
195 }
196
197 // Exists checks if a snapshot exists
198 func (m *Manager) Exists(id string) bool {
199 snapshotFile := m.snapshotFilePath(id)
200 _, err := os.Stat(snapshotFile)
201 return err == nil
202 }
203
204 // ListFiles returns all snapshot files in the directory
205 func (m *Manager) ListFiles() ([]string, error) {
206 snapPath := m.snapshotPath()
207 entries, err := os.ReadDir(snapPath)
208 if err != nil {
209 if os.IsNotExist(err) {
210 return []string{}, nil
211 }
212 return nil, err
213 }
214
215 var files []string
216 for _, entry := range entries {
217 if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" && entry.Name() != indexFile {
218 files = append(files, entry.Name())
219 }
220 }
221
222 return files, nil
223 }
224
github.com/docToolchain/Bausteinsicht/internal/snapshot/types.go 0.0%
1 package snapshot
2
3 import (
4 "fmt"
5 "time"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // Snapshot represents a point-in-time capture of the architecture model
11 type Snapshot struct {
12 ID string `json:"id"`
13 Timestamp time.Time `json:"timestamp"`
14 Message string `json:"message,omitempty"`
15 Model *model.BausteinsichtModel `json:"model"`
16 }
17
18 // SnapshotMetadata is lightweight metadata for snapshots in the index
19 type SnapshotMetadata struct {
20 ID string `json:"id"`
21 Timestamp time.Time `json:"timestamp"`
22 Message string `json:"message,omitempty"`
23 ElementCount int `json:"elementCount"`
24 RelCount int `json:"relationshipCount"`
25 }
26
27 // SnapshotIndex holds the list of all snapshots
28 type SnapshotIndex struct {
29 Version int `json:"version"`
30 Snapshots []SnapshotMetadata `json:"snapshots"`
31 UpdatedAt time.Time `json:"updatedAt"`
32 }
33
34 // NewSnapshot creates a snapshot from a model
35 func NewSnapshot(message string, model *model.BausteinsichtModel) *Snapshot {
36 snapshot := &Snapshot{
37 ID: generateSnapshotID(),
38 Timestamp: time.Now().UTC(),
39 Message: message,
40 Model: model,
41 }
42 return snapshot
43 }
44
45 // generateSnapshotID creates a timestamp-based snapshot ID with nanosecond precision
46 func generateSnapshotID() string {
47 now := time.Now().UTC()
48 return now.Format("snapshot-2006-01-02T15-04-05") + fmt.Sprintf(".%09dZ", now.Nanosecond())
49 }
50
51 // ToMetadata converts a snapshot to its metadata representation
52 func (s *Snapshot) ToMetadata() SnapshotMetadata {
53 elementCount := 0
54 if s.Model != nil {
55 elementCount = len(flattenElements(s.Model.Model))
56 }
57
58 relationCount := 0
59 if s.Model != nil {
60 relationCount = len(s.Model.Relationships)
61 }
62
63 return SnapshotMetadata{
64 ID: s.ID,
65 Timestamp: s.Timestamp,
66 Message: s.Message,
67 ElementCount: elementCount,
68 RelCount: relationCount,
69 }
70 }
71
72 // flattenElements counts total elements including nested ones
73 func flattenElements(elems map[string]model.Element) map[string]model.Element {
74 result := make(map[string]model.Element)
75 for key, elem := range elems {
76 result[key] = elem
77 if len(elem.Children) > 0 {
78 children := flattenElements(elem.Children)
79 for k, v := range children {
80 result[key+"."+k] = v
81 }
82 }
83 }
84 return result
85 }
86
github.com/docToolchain/Bausteinsicht/internal/sync/badge.go 51.7%
1 package sync
2
3 import (
4 "fmt"
5
6 "github.com/beevik/etree"
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // AddStatusBadge adds a status badge as a child cell to an element shape.
11 // The badge is positioned in the top-right corner of the element.
12 func AddStatusBadge(elementCell *etree.Element, status string) {
13 if status == "" {
14 return // No badge for unset status
15 }
16
17 // Get element width (or use default if not set)
18 width := getAttrFloat(elementCell, "width", 100)
19
20 // Badge dimensions and positioning
21 badgeWidth := 60.0
22 badgeHeight := 20.0
23 badgeX := width - badgeWidth - 2
24 badgeY := 2.0
25
26 // Create badge cell
27 badge := etree.NewElement("mxCell")
28 badge.CreateAttr("id", fmt.Sprintf("%s_badge", getAttr(elementCell, "id")))
29 badge.CreateAttr("value", status)
30 badge.CreateAttr("style", fmt.Sprintf(
31 "rounded=1;fillColor=%s;strokeColor=%s;fontSize=11;fontColor=#000000;"+
32 "whiteSpace=wrap;overflow=hidden;connectable=0",
33 model.StatusColor(status), model.StatusColor(status)))
34 badge.CreateAttr("vertex", "1")
35 badge.CreateAttr("parent", getAttr(elementCell, "id"))
36
37 // Geometry for the badge
38 geom := etree.NewElement("mxGeometry")
39 geom.CreateAttr("x", fmt.Sprintf("%.0f", badgeX))
40 geom.CreateAttr("y", fmt.Sprintf("%.0f", badgeY))
41 geom.CreateAttr("width", fmt.Sprintf("%.0f", badgeWidth))
42 geom.CreateAttr("height", fmt.Sprintf("%.0f", badgeHeight))
43 geom.CreateAttr("as", "geometry")
44
45 badge.AddChild(geom)
46 elementCell.AddChild(badge)
47 }
48
49 // getAttr retrieves a string attribute value
50 func getAttr(el *etree.Element, name string) string {
51 attr := el.SelectAttr(name)
52 if attr == nil {
53 return ""
54 }
55 return attr.Value
56 }
57
58 // AddDecisionBadges adds decision badges as child cells to an element shape.
59 // One badge is created per decision, positioned in a row in the top-right corner.
60 func AddDecisionBadges(elementCell *etree.Element, decisions []string, decisionMap map[string]*model.DecisionRecord) {
61 if len(decisions) == 0 {
62 return // No badges if no decisions
63 }
64
65 // Get element width (or use default if not set)
66 width := getAttrFloat(elementCell, "width", 100)
67
68 // Badge dimensions and positioning
69 badgeWidth := 30.0
70 badgeHeight := 20.0
71 badgeStartX := width - (badgeWidth * float64(len(decisions))) - 4
72 badgeY := 2.0
73
74 for i, decisionID := range decisions {
75 badge := etree.NewElement("mxCell")
76 badge.CreateAttr("id", fmt.Sprintf("%s_decision_%d", getAttr(elementCell, "id"), i))
77 badge.CreateAttr("value", "⚖")
78
79 // Determine color based on decision status
80 color := model.DecisionBadgeColor("")
81 if decision, ok := decisionMap[decisionID]; ok {
82 color = model.DecisionBadgeColor(decision.Status)
83 }
84
85 badge.CreateAttr("style", fmt.Sprintf(
86 "rounded=0;fillColor=%s;strokeColor=%s;fontSize=14;fontColor=#ffffff;"+
87 "whiteSpace=wrap;overflow=hidden;connectable=0;align=center;verticalAlign=middle",
88 color, color))
89 badge.CreateAttr("vertex", "1")
90 badge.CreateAttr("parent", getAttr(elementCell, "id"))
91 badge.CreateAttr("bausteinsicht_decision_id", decisionID)
92
93 // Geometry for the badge
94 badgeX := badgeStartX + (badgeWidth * float64(i))
95 geom := etree.NewElement("mxGeometry")
96 geom.CreateAttr("x", fmt.Sprintf("%.0f", badgeX))
97 geom.CreateAttr("y", fmt.Sprintf("%.0f", badgeY))
98 geom.CreateAttr("width", fmt.Sprintf("%.0f", badgeWidth))
99 geom.CreateAttr("height", fmt.Sprintf("%.0f", badgeHeight))
100 geom.CreateAttr("as", "geometry")
101
102 badge.AddChild(geom)
103 elementCell.AddChild(badge)
104 }
105 }
106
107 // getAttrFloat retrieves a float attribute value with default
108 func getAttrFloat(el *etree.Element, name string, defaultVal float64) float64 {
109 attr := el.SelectAttr(name)
110 if attr == nil {
111 return defaultVal
112 }
113 var val float64
114 _, _ = fmt.Sscanf(attr.Value, "%f", &val)
115 return val
116 }
117
github.com/docToolchain/Bausteinsicht/internal/sync/conflict.go 100.0%
1 package sync
2
3 import "fmt"
4
5 // Conflict represents a field that was changed on both sides since the last sync.
6 type Conflict struct {
7 ElementID string
8 Field string // "title", "description", "technology"
9 ModelValue string
10 DrawioValue string
11 LastSyncValue string
12 }
13
14 // ResolvedConflict is a conflict with its resolution decision.
15 type ResolvedConflict struct {
16 Conflict
17 Winner string // "model" or "drawio"
18 Warning string // human-readable warning message
19 }
20
21 // ConflictResolver resolves conflicts between model and draw.io changes.
22 // Designed as an interface for future extension (interactive, merge strategies).
23 type ConflictResolver interface {
24 Resolve(conflicts []Conflict) []ResolvedConflict
25 }
26
27 // ModelWinsResolver always picks the model value (v1 default strategy).
28 type ModelWinsResolver struct{}
29
30 // NewModelWinsResolver creates a new ModelWinsResolver.
31 func NewModelWinsResolver() *ModelWinsResolver {
32 return &ModelWinsResolver{}
33 }
34
35 // Resolve resolves all conflicts by choosing the model value.
36 func (r *ModelWinsResolver) Resolve(conflicts []Conflict) []ResolvedConflict {
37 resolved := make([]ResolvedConflict, 0, len(conflicts))
38 for _, c := range conflicts {
39 warning := fmt.Sprintf(
40 "Conflict detected for element %q:\n"+
41 " Field: %s\n"+
42 " Model value: %q\n"+
43 " draw.io value: %q\n"+
44 " Last sync: %q\n"+
45 " → Keeping model value. Edit draw.io manually if needed.",
46 c.ElementID, c.Field, c.ModelValue, c.DrawioValue, c.LastSyncValue,
47 )
48 resolved = append(resolved, ResolvedConflict{
49 Conflict: c,
50 Winner: "model",
51 Warning: warning,
52 })
53 }
54 return resolved
55 }
56
github.com/docToolchain/Bausteinsicht/internal/sync/diff.go 91.3%
1 package sync
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/drawio"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // ChangeType classifies a change.
13 type ChangeType int
14
15 const (
16 Added ChangeType = iota
17 Modified // nolint:deadcode
18 Deleted
19 )
20
21 // ElementChange represents a change to a single element.
22 type ElementChange struct {
23 ID string
24 Type ChangeType
25 Field string // "title", "description", "technology", "" for add/delete
26 OldValue string
27 NewValue string
28 }
29
30 // RelationshipChange represents a change to a relationship.
31 type RelationshipChange struct {
32 From string
33 To string
34 Index int // relationship array index for disambiguation
35 Type ChangeType
36 Field string // "label", "" for add/delete
37 OldValue string
38 NewValue string
39 }
40
41 // ChangeSet contains all detected changes from both sides.
42 type ChangeSet struct {
43 ModelElementChanges []ElementChange
44 ModelRelationshipChanges []RelationshipChange
45 DrawioElementChanges []ElementChange
46 DrawioRelationshipChanges []RelationshipChange
47 Conflicts []Conflict
48 }
49
50 // drawioElemSnapshot holds extracted data from a draw.io element.
51 type drawioElemSnapshot struct {
52 title string
53 technology string
54 description string
55 kind string
56 }
57
58 // relKey returns a canonical key for a relationship.
59 // The index disambiguates multiple relationships between the same pair.
60 func relKey(from, to string, index int) string {
61 return fmt.Sprintf("%s:%s:%d", from, to, index)
62 }
63
64 // computeVisibleElements returns the set of element IDs that should be visible
65 // across all views. If the model has no views, returns nil (meaning ALL elements
66 // are visible on the single page).
67 func computeVisibleElements(m *model.BausteinsichtModel) map[string]bool {
68 if len(m.Views) == 0 {
69 return nil // all elements visible
70 }
71 visible := make(map[string]bool)
72 for _, view := range m.Views {
73 v := view
74 resolved, err := model.ResolveView(m, &v)
75 if err != nil {
76 continue
77 }
78 for _, id := range resolved {
79 visible[id] = true
80 }
81 // The scope element itself is also visible (rendered as boundary).
82 if view.Scope != "" {
83 visible[view.Scope] = true
84 }
85 }
86 return visible
87 }
88
89 // computeVisibleRelationships returns the set of relationship keys (from relKey)
90 // that should have a connector on at least one view page. A relationship is
91 // visible if both endpoints (or a lifted ancestor of each) are present on the
92 // same view's resolved element set.
93 // If the model has no views, returns nil (meaning ALL relationships are visible).
94 // This prevents reverse sync from treating connectors removed by view filter
95 // changes as user deletions (#167).
96 func computeVisibleRelationships(m *model.BausteinsichtModel) map[string]bool {
97 if len(m.Views) == 0 {
98 return nil // all relationships visible
99 }
100
101 // Resolve each view's element set.
102 type viewSet struct {
103 elems map[string]bool
104 }
105 var views []viewSet
106 for _, view := range m.Views {
107 v := view
108 resolved, err := model.ResolveView(m, &v)
109 if err != nil {
110 continue
111 }
112 elemSet := make(map[string]bool, len(resolved)+1)
113 for _, id := range resolved {
114 elemSet[id] = true
115 }
116 if view.Scope != "" {
117 elemSet[view.Scope] = true
118 }
119 views = append(views, viewSet{elems: elemSet})
120 }
121
122 visible := make(map[string]bool)
123 for i, r := range m.Relationships {
124 for _, vs := range views {
125 from := liftEndpoint(r.From, vs.elems)
126 to := liftEndpoint(r.To, vs.elems)
127 if from != "" && to != "" && from != to {
128 visible[relKey(r.From, r.To, i)] = true
129 break // found on at least one view
130 }
131 }
132 }
133 return visible
134 }
135
136 // computeNewPageOnlyElements returns the set of element IDs that are visible
137 // exclusively on newly created view pages (not on any pre-existing page).
138 // Elements on new pages should not be treated as "deleted from draw.io" because
139 // they simply haven't been forward-synced to those pages yet (#184, #188, #189).
140 func computeNewPageOnlyElements(m *model.BausteinsichtModel, newPageIDs map[string]bool) map[string]bool {
141 if len(newPageIDs) == 0 || len(m.Views) == 0 {
142 return nil
143 }
144
145 // Compute elements visible on existing (non-new) pages.
146 existingPageElems := make(map[string]bool)
147 for viewID, view := range m.Views {
148 pageID := "view-" + viewID
149 if newPageIDs[pageID] {
150 continue // Skip new pages.
151 }
152 v := view
153 resolved, _ := model.ResolveView(m, &v)
154 for _, id := range resolved {
155 existingPageElems[id] = true
156 }
157 if view.Scope != "" {
158 existingPageElems[view.Scope] = true
159 }
160 }
161
162 // Find elements that are visible but ONLY on new pages.
163 allVisible := computeVisibleElements(m)
164 if allVisible == nil {
165 return nil
166 }
167 newOnly := make(map[string]bool)
168 for id := range allVisible {
169 if !existingPageElems[id] {
170 newOnly[id] = true
171 }
172 }
173 return newOnly
174 }
175
176 // DetectChanges performs a three-way diff between the model, draw.io document,
177 // and the last known sync state.
178 // newPageIDs is the set of page IDs that were just created (not yet populated
179 // by forward sync). Elements expected only on new pages are excluded from
180 // draw.io-side deletion detection (#184, #188, #189).
181 func DetectChanges(m *model.BausteinsichtModel, doc *drawio.Document, lastState *SyncState, newPageIDs map[string]bool) *ChangeSet {
182 cs := &ChangeSet{}
183
184 flatModel, _ := model.FlattenElements(m)
185 drawioElems := extractDrawioElements(doc)
186 visibleElems := computeVisibleElements(m)
187 newPageOnly := computeNewPageOnlyElements(m, newPageIDs)
188 detectElementChanges(cs, flatModel, drawioElems, lastState, visibleElems, newPageOnly)
189 detectCrossViewInconsistencies(cs, doc, flatModel, lastState)
190 detectUnmanagedDrawioElements(cs, doc)
191
192 modelRels := buildModelRelMap(m)
193 drawioRels := extractDrawioRelationships(doc)
194 visibleRels := computeVisibleRelationships(m)
195 detectRelationshipChanges(cs, modelRels, drawioRels, lastState, visibleRels)
196
197 return cs
198 }
199
200 // extractDrawioElements gathers element data from all pages in the document.
201 // It reads from child text sub-cells first, falling back to HTML label parsing
202 // for backward compatibility with older draw.io files.
203 func extractDrawioElements(doc *drawio.Document) map[string]drawioElemSnapshot {
204 result := make(map[string]drawioElemSnapshot)
205 for _, page := range doc.Pages() {
206 for _, obj := range page.FindAllElements() {
207 id := obj.SelectAttrValue("bausteinsicht_id", "")
208 if id == "" {
209 continue
210 }
211 // ReadElementFields checks for child text sub-cells first,
212 // then falls back to ParseLabel for backward compat.
213 title, technology, labelDesc := page.ReadElementFields(obj)
214 // Fall back to XML attribute if label doesn't contain technology (#186).
215 if technology == "" {
216 technology = obj.SelectAttrValue("technology", "")
217 }
218 tooltipDesc := obj.SelectAttrValue("tooltip", "")
219 description := tooltipDesc
220 if description == "" {
221 description = labelDesc
222 }
223 // Skip duplicate bausteinsicht_id — keep first occurrence (#213).
224 if _, exists := result[id]; exists {
225 continue
226 }
227 result[id] = drawioElemSnapshot{
228 title: title,
229 technology: technology,
230 description: description,
231 kind: obj.SelectAttrValue("bausteinsicht_kind", ""),
232 }
233 }
234 }
235 return result
236 }
237
238 // detectUnmanagedDrawioElements finds shapes in draw.io that have no
239 // bausteinsicht_id attribute and emits Added element changes for them.
240 // This allows reverse sync to import new elements drawn by the user (#196).
241 func detectUnmanagedDrawioElements(cs *ChangeSet, doc *drawio.Document) {
242 for _, page := range doc.Pages() {
243 root := page.Root()
244 if root == nil {
245 continue
246 }
247 for _, obj := range root.SelectElements("object") {
248 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
249 continue // managed element, already handled
250 }
251 // Skip non-model elements created by forward sync.
252 objID := obj.SelectAttrValue("id", "")
253 if strings.HasPrefix(objID, "nav-back-") ||
254 strings.HasPrefix(objID, metadataPrefix) ||
255 strings.HasPrefix(objID, legendPrefix) {
256 continue
257 }
258 // Check that it wraps a vertex cell (not a connector).
259 cell := obj.SelectElement("mxCell")
260 if cell == nil || cell.SelectAttrValue("vertex", "") != "1" {
261 continue
262 }
263 // Try sub-cell reading first, then HTML label.
264 title, _, _ := page.ReadElementFields(obj)
265 if title == "" {
266 label := obj.SelectAttrValue("label", "")
267 if label == "" {
268 continue
269 }
270 title, _, _ = drawio.ParseLabel(label)
271 }
272 id := sanitizeID(title)
273 if id == "" {
274 continue
275 }
276 cs.DrawioElementChanges = append(cs.DrawioElementChanges, ElementChange{
277 ID: id,
278 Type: Added,
279 NewValue: title,
280 })
281 }
282 }
283 }
284
285 // sanitizeID converts a title to a lowercase, hyphen-separated ID suitable
286 // for use as a model element key. Strips dots, slashes, and backslashes to
287 // prevent IDs that interfere with dot-notation hierarchy (SEC-013).
288 func sanitizeID(title string) string {
289 title = strings.TrimSpace(title)
290 title = strings.ToLower(title)
291 title = strings.ReplaceAll(title, " ", "-")
292 title = strings.NewReplacer(".", "", "/", "", "\\", "").Replace(title)
293 return title
294 }
295
296 // stripScopedPrefix removes the view prefix from a scoped cell ID.
297 // Scoped cell IDs have the format "viewID--elemID" where "--" is the separator.
298 // If the ID does not contain "--", it is returned unchanged (legacy documents).
299 func stripScopedPrefix(cellID string) string {
300 if idx := strings.Index(cellID, "--"); idx >= 0 {
301 return cellID[idx+2:]
302 }
303 return cellID
304 }
305
306 // resolveCellID maps a draw.io cell ID to a canonical element ID using the
307 // cellToElem lookup table. If the cell ID is not in the table (e.g., because
308 // the element was deleted), it falls back to stripping the scoped view prefix.
309 func resolveCellID(cellID string, cellToElem map[string]string) string {
310 if elemID, ok := cellToElem[cellID]; ok {
311 return elemID
312 }
313 return stripScopedPrefix(cellID)
314 }
315
316 // buildCellIDToElemID builds a mapping from draw.io cell IDs to bausteinsicht
317 // element IDs. When views are used, cell IDs are scoped (e.g., "context--customer")
318 // while element IDs are un-scoped (e.g., "customer").
319 func buildCellIDToElemID(doc *drawio.Document) map[string]string {
320 m := make(map[string]string)
321 for _, page := range doc.Pages() {
322 for _, obj := range page.FindAllElements() {
323 elemID := obj.SelectAttrValue("bausteinsicht_id", "")
324 cellID := obj.SelectAttrValue("id", "")
325 if elemID != "" && cellID != "" {
326 m[cellID] = elemID
327 }
328 }
329 // Also map unmanaged elements (no bausteinsicht_id) to their
330 // sanitized label-based IDs so that connectors targeting them
331 // resolve correctly during reverse sync (#211).
332 root := page.Root()
333 if root == nil {
334 continue
335 }
336 for _, obj := range root.SelectElements("object") {
337 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
338 continue
339 }
340 cellID := obj.SelectAttrValue("id", "")
341 if cellID == "" {
342 continue
343 }
344 if strings.HasPrefix(cellID, "nav-back-") {
345 continue
346 }
347 cell := obj.SelectElement("mxCell")
348 if cell == nil || cell.SelectAttrValue("vertex", "") != "1" {
349 continue
350 }
351 label := obj.SelectAttrValue("label", "")
352 if label == "" {
353 continue
354 }
355 title, _, _ := drawio.ParseLabel(label)
356 id := sanitizeID(title)
357 if id != "" {
358 m[cellID] = id
359 }
360 }
361 }
362 return m
363 }
364
365 // extractDrawioRelationships gathers connector data from all pages.
366 // Connector source/target cell IDs are resolved to element IDs using the
367 // bausteinsicht_id attributes of referenced elements.
368 // Lifted connectors (where an endpoint was lifted to a parent because the
369 // original target is not visible on a view) are excluded to avoid phantom
370 // reverse changes.
371 func extractDrawioRelationships(doc *drawio.Document) map[string]RelationshipState {
372 cellToElem := buildCellIDToElemID(doc)
373 result := make(map[string]RelationshipState)
374 for _, page := range doc.Pages() {
375 for _, cell := range page.FindAllConnectors() {
376 fromCell := cell.SelectAttrValue("source", "")
377 toCell := cell.SelectAttrValue("target", "")
378 if fromCell == "" || toCell == "" {
379 continue
380 }
381 // Resolve scoped cell IDs to element IDs.
382 // Fall back to stripping the view prefix from scoped cell IDs
383 // (e.g., "components--onlineshop.db" → "onlineshop.db") when
384 // the element was deleted and is no longer in cellToElem (#166).
385 // For legacy (non-view) documents the raw cell ID is used as-is.
386 from := resolveCellID(fromCell, cellToElem)
387 to := resolveCellID(toCell, cellToElem)
388 // Skip connectors targeting navigation buttons (#205).
389 if strings.HasPrefix(from, "nav-back-") || strings.HasPrefix(to, "nav-back-") {
390 continue
391 }
392 // Extract the relationship index from the connector ID.
393 cellID := cell.SelectAttrValue("id", "")
394 index := parseConnectorIndex(cellID)
395 key := relKey(from, to, index)
396 if _, exists := result[key]; !exists {
397 result[key] = RelationshipState{
398 From: from,
399 To: to,
400 Index: index,
401 Label: cell.SelectAttrValue("value", ""),
402 }
403 }
404 }
405 }
406 return result
407 }
408
409 // parseConnectorIndex extracts the index from a connector ID of the form
410 // "rel-<from>-<to>-<index>". Returns 0 if the ID does not contain an index
411 // (backward compatibility with old connector IDs "rel-<from>-<to>").
412 func parseConnectorIndex(id string) int {
413 if !strings.HasPrefix(id, "rel-") {
414 return 0
415 }
416 // The index is the last segment after the last '-'.
417 lastDash := strings.LastIndex(id, "-")
418 if lastDash < 0 {
419 return 0
420 }
421 indexStr := id[lastDash+1:]
422 var index int
423 if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil {
424 return 0
425 }
426 return index
427 }
428
429 // buildModelRelMap converts model relationships to a map keyed by relKey.
430 func buildModelRelMap(m *model.BausteinsichtModel) map[string]RelationshipState {
431 modelRels := make(map[string]RelationshipState, len(m.Relationships))
432 for i, r := range m.Relationships {
433 modelRels[relKey(r.From, r.To, i)] = RelationshipState{
434 From: r.From,
435 To: r.To,
436 Index: i,
437 Label: r.Label,
438 Kind: r.Kind,
439 }
440 }
441 return modelRels
442 }
443
444 // detectElementChanges performs three-way comparison for elements.
445 // visibleElems is the set of element IDs visible across all views. If nil,
446 // all elements are considered visible (no views defined).
447 // newPageOnly is the set of elements visible ONLY on newly created pages
448 // (not yet populated by forward sync). These are excluded from draw.io-side
449 // deletion detection (#184, #188, #189).
450 func detectElementChanges(
451 cs *ChangeSet,
452 flatModel map[string]*model.Element,
453 drawioElems map[string]drawioElemSnapshot,
454 lastState *SyncState,
455 visibleElems map[string]bool,
456 newPageOnly map[string]bool,
457 ) {
458 allIDsMap := unionElementIDs(flatModel, drawioElems, lastState)
459 allIDs := make([]string, 0, len(allIDsMap))
460 for id := range allIDsMap {
461 allIDs = append(allIDs, id)
462 }
463 sort.Strings(allIDs)
464
465 for _, id := range allIDs {
466 me, inModel := flatModel[id]
467 de, inDrawio := drawioElems[id]
468 lastElem, inLast := lastState.Elements[id]
469
470 // Model side changes
471 switch {
472 case inModel && !inLast:
473 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{ID: id, Type: Added})
474 case !inModel && inLast:
475 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{ID: id, Type: Deleted})
476 case inModel && inLast:
477 appendIfChanged(id, "title", lastElem.Title, me.Title, &cs.ModelElementChanges)
478 appendIfChanged(id, "description", lastElem.Description, me.Description, &cs.ModelElementChanges)
479 appendIfChanged(id, "technology", lastElem.Technology, me.Technology, &cs.ModelElementChanges)
480 appendIfChanged(id, "kind", lastElem.Kind, me.Kind, &cs.ModelElementChanges)
481 }
482
483 // Draw.io side changes
484 switch {
485 case inDrawio && !inLast:
486 cs.DrawioElementChanges = append(cs.DrawioElementChanges, ElementChange{ID: id, Type: Added})
487 case !inDrawio && inLast:
488 // Only treat as deleted if the element should be visible on at least one
489 // view page. Elements not in any view's resolved set are simply filtered
490 // out and their absence from draw.io is expected, not a deletion. (#108, #118)
491 // Also skip elements that are only expected on newly created pages — those
492 // pages haven't been populated by forward sync yet (#184, #188, #189).
493 if visibleElems == nil || visibleElems[id] {
494 if newPageOnly != nil && newPageOnly[id] {
495 continue
496 }
497 // Skip elements that were never rendered to a draw.io page.
498 // When RenderedElements is available (state from v2+), an element
499 // absent from draw.io but not in RenderedElements was never on any
500 // page — it was filtered by views. Forward sync will create it now
501 // that views include it. (#240)
502 // When RenderedElements is nil (old state files), fall back to
503 // treating all elements as rendered (preserving old behavior).
504 if lastState.RenderedElements != nil && !lastState.RenderedElements[id] {
505 continue
506 }
507 cs.DrawioElementChanges = append(cs.DrawioElementChanges, ElementChange{ID: id, Type: Deleted})
508 }
509 case inDrawio && inLast:
510 appendIfChanged(id, "title", lastElem.Title, de.title, &cs.DrawioElementChanges)
511 appendIfChanged(id, "description", lastElem.Description, de.description, &cs.DrawioElementChanges)
512 appendIfChanged(id, "technology", lastElem.Technology, de.technology, &cs.DrawioElementChanges)
513 // Note: kind is not compared on the draw.io side because scope
514 // boundary elements have a derived kind (e.g. "system_boundary")
515 // that legitimately differs from the model kind ("system").
516 }
517
518 // Conflicts: both sides modified the same field
519 if inModel && inDrawio && inLast {
520 checkElemConflict(cs, id, "title", lastElem.Title, me.Title, de.title)
521 checkElemConflict(cs, id, "description", lastElem.Description, me.Description, de.description)
522 checkElemConflict(cs, id, "technology", lastElem.Technology, me.Technology, de.technology)
523 // Note: kind conflicts are not checked because kind is
524 // model-authoritative and draw.io boundary kinds are derived.
525 }
526 }
527 }
528
529 // unionElementIDs returns the union of IDs across all three sources.
530 func unionElementIDs(
531 flatModel map[string]*model.Element,
532 drawioElems map[string]drawioElemSnapshot,
533 lastState *SyncState,
534 ) map[string]struct{} {
535 all := make(map[string]struct{})
536 for id := range flatModel {
537 all[id] = struct{}{}
538 }
539 for id := range lastState.Elements {
540 all[id] = struct{}{}
541 }
542 for id := range drawioElems {
543 all[id] = struct{}{}
544 }
545 return all
546 }
547
548 // detectCrossViewInconsistencies scans ALL pages in the draw.io document
549 // and emits forward changes when an element on any page shows a stale value
550 // that doesn't match the model, even though model and state agree.
551 // This handles the case where reverse sync updated one view but other views
552 // still show the old value (#236).
553 func detectCrossViewInconsistencies(
554 cs *ChangeSet,
555 doc *drawio.Document,
556 flatModel map[string]*model.Element,
557 lastState *SyncState,
558 ) {
559 // Track which element+field combos we've already emitted to avoid duplicates.
560 type fieldKey struct {
561 id, field string
562 }
563 emitted := make(map[fieldKey]bool)
564 // Skip fields that detectElementChanges already emitted as model changes.
565 for _, ch := range cs.ModelElementChanges {
566 if ch.Field != "" {
567 emitted[fieldKey{ch.ID, ch.Field}] = true
568 }
569 }
570 // Skip fields that have a legitimate draw.io-side change — those are user
571 // edits that should be reverse-synced, not overwritten by forward sync.
572 for _, ch := range cs.DrawioElementChanges {
573 if ch.Field != "" {
574 emitted[fieldKey{ch.ID, ch.Field}] = true
575 }
576 }
577
578 for _, page := range doc.Pages() {
579 for _, obj := range page.FindAllElements() {
580 id := obj.SelectAttrValue("bausteinsicht_id", "")
581 if id == "" {
582 continue
583 }
584 me, inModel := flatModel[id]
585 if !inModel {
586 continue
587 }
588 lastElem, inLast := lastState.Elements[id]
589 if !inLast {
590 continue
591 }
592
593 title, technology, labelDesc := page.ReadElementFields(obj)
594 if technology == "" {
595 technology = obj.SelectAttrValue("technology", "")
596 }
597 tooltipDesc := obj.SelectAttrValue("tooltip", "")
598 description := tooltipDesc
599 if description == "" {
600 description = labelDesc
601 }
602
603 // If model and state agree but this page shows a stale value,
604 // emit a forward change so all views are brought up to date.
605 if me.Title == lastElem.Title && title != me.Title {
606 fk := fieldKey{id, "title"}
607 if !emitted[fk] {
608 emitted[fk] = true
609 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{
610 ID: id, Type: Modified, Field: "title",
611 OldValue: title, NewValue: me.Title,
612 })
613 }
614 }
615 if me.Description == lastElem.Description && description != me.Description {
616 fk := fieldKey{id, "description"}
617 if !emitted[fk] {
618 emitted[fk] = true
619 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{
620 ID: id, Type: Modified, Field: "description",
621 OldValue: description, NewValue: me.Description,
622 })
623 }
624 }
625 if me.Technology == lastElem.Technology && technology != me.Technology {
626 fk := fieldKey{id, "technology"}
627 if !emitted[fk] {
628 emitted[fk] = true
629 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{
630 ID: id, Type: Modified, Field: "technology",
631 OldValue: technology, NewValue: me.Technology,
632 })
633 }
634 }
635 }
636 }
637 }
638
639 // appendIfChanged adds a Modified ElementChange if newValue differs from lastValue.
640 func appendIfChanged(id, field, lastValue, newValue string, changes *[]ElementChange) {
641 if newValue != lastValue {
642 *changes = append(*changes, ElementChange{
643 ID: id,
644 Type: Modified,
645 Field: field,
646 OldValue: lastValue,
647 NewValue: newValue,
648 })
649 }
650 }
651
652 // checkElemConflict adds a Conflict when both model and draw.io changed the same field.
653 func checkElemConflict(cs *ChangeSet, id, field, last, modelVal, drawioVal string) {
654 if modelVal != last && drawioVal != last {
655 cs.Conflicts = append(cs.Conflicts, Conflict{
656 ElementID: id,
657 Field: field,
658 ModelValue: modelVal,
659 DrawioValue: drawioVal,
660 LastSyncValue: last,
661 })
662 }
663 }
664
665 // detectRelationshipChanges performs three-way comparison for relationships.
666 // visibleRels is the set of relationship keys that should have a connector on
667 // at least one view page. If nil, all relationships are considered visible
668 // (no views defined). Used to prevent treating filter-removed connectors as
669 // user deletions (#167).
670 func detectRelationshipChanges(
671 cs *ChangeSet,
672 modelRels map[string]RelationshipState,
673 drawioRels map[string]RelationshipState,
674 lastState *SyncState,
675 visibleRels map[string]bool,
676 ) {
677 lastRels := make(map[string]RelationshipState, len(lastState.Relationships))
678 for _, r := range lastState.Relationships {
679 lastRels[relKey(r.From, r.To, r.Index)] = r
680 }
681
682 allKeys := unionRelKeys(modelRels, drawioRels, lastRels)
683
684 for k := range allKeys {
685 mr, inModel := modelRels[k]
686 dr, inDrawio := drawioRels[k]
687 lr, inLast := lastRels[k]
688
689 from, to, index := resolveRelFromTo(mr, lr, dr)
690
691 // Model side
692 switch {
693 case inModel && !inLast:
694 cs.ModelRelationshipChanges = append(cs.ModelRelationshipChanges, RelationshipChange{
695 From: from, To: to, Index: index, Type: Added, NewValue: mr.Label,
696 })
697 case !inModel && inLast:
698 cs.ModelRelationshipChanges = append(cs.ModelRelationshipChanges, RelationshipChange{
699 From: from, To: to, Index: index, Type: Deleted,
700 })
701 case inModel && inLast && mr.Label != lr.Label:
702 cs.ModelRelationshipChanges = append(cs.ModelRelationshipChanges, RelationshipChange{
703 From: from, To: to, Index: index, Type: Modified, Field: "label",
704 OldValue: lr.Label, NewValue: mr.Label,
705 })
706 }
707
708 // Draw.io side
709 switch {
710 case inDrawio && !inLast:
711 // Skip lifted connectors: when a view lifts a relationship
712 // endpoint to a parent (e.g., A→B.child becomes A→B),
713 // the lifted connector should not be treated as a new relationship.
714 if isLiftedRelationship(from, to, modelRels) {
715 continue
716 }
717 cs.DrawioRelationshipChanges = append(cs.DrawioRelationshipChanges, RelationshipChange{
718 From: from, To: to, Index: index, Type: Added, NewValue: dr.Label,
719 })
720 case !inDrawio && inLast:
721 // Only treat as deleted if the relationship should have a connector
722 // on at least one view page. Relationships whose endpoints are not
723 // visible on any view (due to filter changes) are simply absent from
724 // draw.io — not user deletions. (#167)
725 if visibleRels != nil && !visibleRels[k] {
726 continue
727 }
728 // Skip if a lifted version of this relationship exists in draw.io.
729 // When a view lifts endpoints (e.g., cli→model.loader becomes
730 // cli→model), the connector has different keys but still represents
731 // this relationship. Without this check, the original relationship
732 // would be incorrectly deleted (#223).
733 if hasLiftedConnectorInDrawio(from, to, drawioRels) {
734 continue
735 }
736 cs.DrawioRelationshipChanges = append(cs.DrawioRelationshipChanges, RelationshipChange{
737 From: from, To: to, Index: index, Type: Deleted,
738 })
739 case inDrawio && inLast && dr.Label != lr.Label:
740 cs.DrawioRelationshipChanges = append(cs.DrawioRelationshipChanges, RelationshipChange{
741 From: from, To: to, Index: index, Type: Modified, Field: "label",
742 OldValue: lr.Label, NewValue: dr.Label,
743 })
744 }
745 }
746 }
747
748 // unionRelKeys returns the union of relationship keys from all three sources.
749 func unionRelKeys(
750 modelRels, drawioRels, lastRels map[string]RelationshipState,
751 ) map[string]struct{} {
752 all := make(map[string]struct{})
753 for k := range modelRels {
754 all[k] = struct{}{}
755 }
756 for k := range lastRels {
757 all[k] = struct{}{}
758 }
759 for k := range drawioRels {
760 all[k] = struct{}{}
761 }
762 return all
763 }
764
765 // isLiftedRelationship returns true if the relationship from→to is a "lifted"
766 // version of an existing model relationship. A relationship is lifted when a
767 // view shows a connector between parent elements because the original endpoint
768 // is not visible. For example, model has A→B.child but the view only shows A
769 // and B, so the connector is lifted to A→B.
770 func isLiftedRelationship(from, to string, modelRels map[string]RelationshipState) bool {
771 // A self-referencing relationship (from == to) can never be a lifted
772 // version of a child-to-child relationship, because lifting never
773 // collapses two distinct endpoints into the same element (#212).
774 if from == to {
775 return false
776 }
777 for _, mr := range modelRels {
778 // Same from, model to is more specific (to is ancestor of mr.To)
779 if mr.From == from && mr.To != to && strings.HasPrefix(mr.To, to+".") {
780 return true
781 }
782 // Same to, model from is more specific
783 if mr.To == to && mr.From != from && strings.HasPrefix(mr.From, from+".") {
784 return true
785 }
786 // Both endpoints lifted
787 if mr.From != from && mr.To != to &&
788 strings.HasPrefix(mr.From, from+".") && strings.HasPrefix(mr.To, to+".") {
789 return true
790 }
791 }
792 return false
793 }
794
795 // hasLiftedConnectorInDrawio returns true if a connector in drawioRels is a
796 // lifted version of the relationship from→to. This is the inverse of
797 // isLiftedRelationship: here we check if the drawio connector endpoints are
798 // ancestors of the model relationship endpoints.
799 // For example, model has cli→model.loader but drawio has cli→model (lifted).
800 func hasLiftedConnectorInDrawio(from, to string, drawioRels map[string]RelationshipState) bool {
801 for _, dr := range drawioRels {
802 fromMatch := dr.From == from || (dr.From != from && strings.HasPrefix(from, dr.From+"."))
803 toMatch := dr.To == to || (dr.To != to && strings.HasPrefix(to, dr.To+"."))
804 if fromMatch && toMatch && (dr.From != from || dr.To != to) {
805 return true
806 }
807 }
808 return false
809 }
810
811 // resolveRelFromTo returns the from/to/index from the first non-empty source.
812 func resolveRelFromTo(mr, lr, dr RelationshipState) (from, to string, index int) {
813 from, to, index = mr.From, mr.To, mr.Index
814 if from == "" {
815 from, to, index = lr.From, lr.To, lr.Index
816 }
817 if from == "" {
818 from, to, index = dr.From, dr.To, dr.Index
819 }
820 return from, to, index
821 }
822
github.com/docToolchain/Bausteinsicht/internal/sync/engine.go 95.9%
1 package sync
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/drawio"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // SyncResult contains the comprehensive result of a sync cycle.
13 type SyncResult struct {
14 Forward *ForwardResult
15 Reverse *ReverseResult
16 Changes *ChangeSet // The (post-conflict-resolution) changes used for sync.
17 Conflicts []ResolvedConflict
18 Warnings []string
19 }
20
21 // Run executes one full bidirectional sync cycle.
22 // It is a pure function — no file I/O. All data is passed as parameters.
23 //
24 // Sequence (Chapter 6 - Runtime View):
25 // 1. DetectChanges → ChangeSet
26 // 2. Resolve conflicts (model wins)
27 // 3. Remove conflicting fields from DrawioElementChanges
28 // 4. ApplyForward → ForwardResult
29 // 5. ApplyReverse → ReverseResult
30 // 6. Collect all warnings
31 func Run(
32 m *model.BausteinsichtModel,
33 doc *drawio.Document,
34 lastState *SyncState,
35 templates *drawio.TemplateSet,
36 newPageIDs map[string]bool,
37 opts ...ForwardOptions,
38 ) *SyncResult {
39 result := &SyncResult{}
40
41 // Step 1: Detect changes from both sides.
42 // Pass newPageIDs so that elements expected only on newly created pages
43 // are not mistakenly treated as "deleted from draw.io" (#184, #188, #189).
44 changes := DetectChanges(m, doc, lastState, newPageIDs)
45
46 // Step 2: Resolve conflicts.
47 if len(changes.Conflicts) > 0 {
48 resolved := NewModelWinsResolver().Resolve(changes.Conflicts)
49 result.Conflicts = resolved
50
51 // Step 3: For model-wins conflicts, drop the draw.io change so that
52 // ApplyReverse does not overwrite the model value.
53 changes.DrawioElementChanges = filterConflictingDrawioChanges(
54 changes.DrawioElementChanges, resolved,
55 )
56 }
57
58 result.Changes = changes
59
60 // Step 4: Forward sync (model → draw.io).
61 result.Forward = ApplyForward(changes, doc, templates, m, opts...)
62
63 // Step 5: Reverse sync (draw.io → model).
64 result.Reverse = ApplyReverse(changes, m)
65
66 // Step 6: Warn about model elements not visible in any view (#183).
67 if len(m.Views) > 0 {
68 visible := computeVisibleElements(m)
69 flat, _ := model.FlattenElements(m)
70 var invisible []string
71 for id := range flat {
72 if visible != nil && !visible[id] {
73 invisible = append(invisible, id)
74 }
75 }
76 if len(invisible) > 0 {
77 sort.Strings(invisible)
78 for _, id := range invisible {
79 result.Warnings = append(result.Warnings,
80 fmt.Sprintf("Element %q exists in the model but is not visible in any view — add it to a view's include list", id))
81 }
82 }
83 }
84
85 // Step 7: Collect all warnings.
86 result.Warnings = append(result.Warnings, result.Forward.Warnings...)
87 result.Warnings = append(result.Warnings, result.Reverse.Warnings...)
88 for _, rc := range result.Conflicts {
89 result.Warnings = append(result.Warnings, rc.Warning)
90 }
91
92 return result
93 }
94
95 // RemoveOrphanedViewPages removes pages from the draw.io document that were
96 // created for views that no longer exist in the model. Pages are identified as
97 // view-managed if their id starts with the "view-" prefix. Pages whose id does
98 // not start with "view-" are preserved (e.g., default template pages).
99 func RemoveOrphanedViewPages(doc *drawio.Document, m *model.BausteinsichtModel) {
100 // Build the set of expected view page IDs from the model.
101 expectedPages := make(map[string]bool, len(m.Views))
102 for viewID := range m.Views {
103 expectedPages["view-"+viewID] = true
104 }
105
106 // Iterate pages and collect orphaned view page IDs.
107 var orphans []string
108 for _, page := range doc.Pages() {
109 pageID := page.ID()
110 if !strings.HasPrefix(pageID, "view-") {
111 continue // Not a view-managed page; preserve it.
112 }
113 if !expectedPages[pageID] {
114 orphans = append(orphans, pageID)
115 }
116 }
117
118 // Remove orphaned pages.
119 for _, id := range orphans {
120 doc.RemovePage(id)
121 }
122 }
123
124 // filterConflictingDrawioChanges removes draw.io element changes for fields
125 // that were resolved in favour of the model (Winner == "model").
126 func filterConflictingDrawioChanges(
127 drawioChanges []ElementChange,
128 resolved []ResolvedConflict,
129 ) []ElementChange {
130 // Build a set of (elementID, field) pairs that model won.
131 type conflictKey struct {
132 id string
133 field string
134 }
135 modelWins := make(map[conflictKey]struct{}, len(resolved))
136 for _, rc := range resolved {
137 if rc.Winner == "model" {
138 modelWins[conflictKey{rc.ElementID, rc.Field}] = struct{}{}
139 }
140 }
141
142 if len(modelWins) == 0 {
143 return drawioChanges
144 }
145
146 filtered := make([]ElementChange, 0, len(drawioChanges))
147 for _, ch := range drawioChanges {
148 if _, skip := modelWins[conflictKey{ch.ID, ch.Field}]; !skip {
149 filtered = append(filtered, ch)
150 }
151 }
152 return filtered
153 }
154
github.com/docToolchain/Bausteinsicht/internal/sync/forward.go 80.8%
1 package sync
2
3 import (
4 "fmt"
5 "sort"
6 "strconv"
7 "strings"
8
9 "github.com/beevik/etree"
10 "github.com/docToolchain/Bausteinsicht/internal/drawio"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 )
13
14 const (
15 newElementMarker = "strokeColor=#FF0000;dashed=1;"
16 elementGap = 40.0
17 defaultWidth = 120.0
18 defaultHeight = 60.0
19 )
20
21 // applyTagStyles applies styles defined in tag definitions to an element's style.
22 // For each tag on the element, looks up the corresponding TagDefinition and merges
23 // any styles defined there into the element's style string.
24 func applyTagStyles(elem *model.Element, spec *model.Specification, baseStyle string) string {
25 if len(elem.Tags) == 0 {
26 return baseStyle
27 }
28
29 // Build a map of tag ID to TagDefinition for quick lookup
30 tagDefMap := make(map[string]model.TagDefinition)
31 for _, tagDef := range spec.Tags {
32 tagDefMap[tagDef.ID] = tagDef
33 }
34
35 // Collect all style properties from tags that apply to this element
36 styleProps := make(map[string]interface{})
37 for _, tagID := range elem.Tags {
38 if tagDef, ok := tagDefMap[tagID]; ok && len(tagDef.Style) > 0 {
39 for k, v := range tagDef.Style {
40 styleProps[k] = v
41 }
42 }
43 }
44
45 // If no tag styles found, return base style
46 if len(styleProps) == 0 {
47 return baseStyle
48 }
49
50 // Convert style properties to a string and merge with baseStyle
51 tagStyleStr := ""
52 for k, v := range styleProps {
53 tagStyleStr += k + "=" + toString(v) + ";"
54 }
55
56 return mergeStyles(baseStyle, tagStyleStr)
57 }
58
59 // toString converts a value to a string representation suitable for draw.io style attributes.
60 func toString(v interface{}) string {
61 switch val := v.(type) {
62 case string:
63 return val
64 case float64:
65 if val == float64(int64(val)) {
66 return strconv.FormatInt(int64(val), 10)
67 }
68 return strconv.FormatFloat(val, 'f', -1, 64)
69 case int:
70 return strconv.Itoa(val)
71 case bool:
72 return strconv.FormatBool(val)
73 default:
74 return fmt.Sprintf("%v", v)
75 }
76 }
77
78 // ForwardOptions holds optional parameters for forward sync.
79 type ForwardOptions struct {
80 ModelPath string // path to the model file, shown in metadata box
81 SyncTime string // timestamp string, shown in metadata box
82 Relayout bool // when true, clear and re-layout all view pages
83 }
84
85 // ForwardResult summarises the changes applied to a draw.io document.
86 type ForwardResult struct {
87 ElementsCreated int
88 ElementsUpdated int
89 ElementsDeleted int
90 ConnectorsCreated int
91 ConnectorsUpdated int
92 ConnectorsDeleted int
93 MetadataUpdated int // metadata and legend boxes created/updated
94 Warnings []string
95 }
96
97 // ApplyForward applies ModelElementChanges and ModelRelationshipChanges from cs
98 // to doc, using templates for styles and m for element data.
99 // When the model defines views, elements and relationships are placed on their
100 // corresponding view pages. Without views, falls back to the first page.
101 // opts is optional — pass nil to skip metadata/legend generation.
102 func ApplyForward(
103 cs *ChangeSet,
104 doc *drawio.Document,
105 templates *drawio.TemplateSet,
106 m *model.BausteinsichtModel,
107 opts ...ForwardOptions,
108 ) *ForwardResult {
109 result := &ForwardResult{}
110 flat, _ := model.FlattenElements(m)
111
112 var fwdOpts ForwardOptions
113 if len(opts) > 0 {
114 fwdOpts = opts[0]
115 }
116
117 if len(m.Views) == 0 {
118 applyForwardToPage(cs, doc, templates, flat, nil, m, result)
119 return result
120 }
121
122 applyForwardPerView(cs, doc, templates, flat, m, &fwdOpts, result)
123 return result
124 }
125
126 // applyForwardToPage applies all changes to a single page (legacy/no-views mode).
127 func applyForwardToPage(
128 cs *ChangeSet,
129 doc *drawio.Document,
130 templates *drawio.TemplateSet,
131 flat map[string]*model.Element,
132 elemFilter map[string]bool,
133 m *model.BausteinsichtModel,
134 result *ForwardResult,
135 ) {
136 page := firstPage(doc)
137 if page == nil {
138 result.Warnings = append(result.Warnings, "no page found in document")
139 return
140 }
141 applyChangesToPage(cs, page, templates, flat, elemFilter, "", "", &m.Specification, result)
142
143 // Reconcile orphaned elements: remove any element on the page whose
144 // bausteinsicht_id is not present in the current model. This handles
145 // cases where the sync state is missing or the model was emptied. (#110)
146 reconcileOrphanedElements(page, flat, result)
147
148 // Synchronize decision badges with the model
149 synchronizeDecisionBadges(page, m)
150 }
151
152 // applyForwardPerView iterates over model views and applies changes per page.
153 func applyForwardPerView(
154 cs *ChangeSet,
155 doc *drawio.Document,
156 templates *drawio.TemplateSet,
157 flat map[string]*model.Element,
158 m *model.BausteinsichtModel,
159 opts *ForwardOptions,
160 result *ForwardResult,
161 ) {
162 // Build drill-down link map: elementID → "data:page/id,view-<viewID>"
163 // An element gets a link when a view's scope matches that element.
164 drillDownLinks := make(map[string]string)
165 for vID, v := range m.Views {
166 if v.Scope != "" {
167 drillDownLinks[v.Scope] = "data:page/id,view-" + vID
168 }
169 }
170
171 for viewID, view := range m.Views {
172 pageID := "view-" + viewID
173 page := doc.GetPage(pageID)
174 if page == nil {
175 result.Warnings = append(result.Warnings,
176 "no page found for view: "+viewID)
177 continue
178 }
179
180 viewCopy := view
181 resolved, err := model.ResolveView(m, &viewCopy)
182 if err != nil {
183 result.Warnings = append(result.Warnings,
184 "resolving view "+viewID+": "+err.Error())
185 continue
186 }
187
188 elemSet := make(map[string]bool, len(resolved))
189 for _, id := range resolved {
190 elemSet[id] = true
191 }
192
193 scopeID := view.Scope
194
195 // --relayout: clear all managed elements from the page so
196 // populateNewPage treats it as a fresh page.
197 if opts != nil && opts.Relayout {
198 clearPageElements(page)
199 }
200
201 if scopeID != "" {
202 createScopeBoundary(scopeID, viewID, page, templates, flat, &m.Specification, result)
203 // Include scope element in the filter so connectors targeting the
204 // boundary element are rendered (#217).
205 elemSet[scopeID] = true
206 }
207
208 // Populate resolved elements that aren't already on the page.
209 // This runs BEFORE applyChangesToPage so the layout engine can
210 // position elements on fresh pages. applyChangesToPage will then
211 // skip Added elements that are already placed. For existing pages,
212 // this handles elements newly included via view changes (#231).
213 populateNewPage(page, viewID, scopeID, templates, flat, elemSet, m, result)
214
215 applyChangesToPage(cs, page, templates, flat, elemSet, viewID, scopeID, &m.Specification, result)
216
217 // Populate connectors for relationships whose endpoints are both
218 // on the page but whose connector doesn't exist yet. This handles
219 // relationships involving newly populated elements (#231).
220 populateConnectors(page, viewID, scopeID, m, elemSet, templates, result)
221
222 // Reconciliation: remove elements on the page that are no longer
223 // in the resolved view (e.g., after exclude list changes). #102
224 reconcileViewPage(page, elemSet, flat, scopeID, viewID, result)
225
226 // Set drill-down links on elements that have a detail view. (#198)
227 applyDrillDownLinks(page, drillDownLinks)
228
229 // Create back-navigation button on detail views (views with scope). (#198)
230 if scopeID != "" {
231 createBackNavButton(page, viewID, scopeID, m)
232 }
233
234 // Create metadata and legend boxes on each view page. (#233)
235 metadataEnabled := m.Config.Metadata == nil || *m.Config.Metadata
236 legendEnabled := m.Config.Legend == nil || *m.Config.Legend
237
238 if metadataEnabled && opts != nil {
239 if createMetadata(page, viewID, view, m.Config, opts.ModelPath, opts.SyncTime) {
240 result.MetadataUpdated++
241 }
242 }
243 if legendEnabled {
244 if createLegend(page, viewID, m.Specification, templates, elemSet, flat) {
245 result.MetadataUpdated++
246 }
247 }
248
249 // Synchronize decision badges with the model
250 synchronizeDecisionBadges(page, m)
251 }
252 }
253
254 // populateNewPage creates elements on a view page for all elements in
255 // the view's resolved set that aren't already present.
256 // For new pages, this populates elements already in sync state (#184, #188).
257 // For existing pages, this adds elements newly included via view changes (#231).
258 func populateNewPage(
259 page *drawio.Page,
260 viewID string,
261 scopeID string,
262 templates *drawio.TemplateSet,
263 flat map[string]*model.Element,
264 elemSet map[string]bool,
265 m *model.BausteinsichtModel,
266 result *ForwardResult,
267 ) {
268 // Collect elements that need placement.
269 var toPlace []string
270 for id := range elemSet {
271 if id == scopeID {
272 continue
273 }
274 if page.FindElement(id) != nil {
275 continue
276 }
277 toPlace = append(toPlace, id)
278 }
279 if len(toPlace) == 0 {
280 return
281 }
282
283 // Determine if this is a fresh page (no existing bausteinsicht elements
284 // besides the scope boundary which is created before this function runs).
285 existingElems := page.FindAllElements()
286 nonBoundaryCount := 0
287 for _, obj := range existingElems {
288 if obj.SelectAttrValue("bausteinsicht_id", "") != scopeID || scopeID == "" {
289 nonBoundaryCount++
290 }
291 }
292 isFreshPage := nonBoundaryCount == 0
293
294 // Look up the view's layout mode.
295 layoutMode := ""
296 for vID, v := range m.Views {
297 if "view-"+vID == page.ID() {
298 layoutMode = v.Layout
299 break
300 }
301 }
302
303 if isFreshPage {
304 // Use layout engine for fresh pages.
305 lr := computeLayout(toPlace, flat, templates, m.ElementOrder, scopeID, layoutMode, m.Relationships)
306
307 // Resize and reposition the scope boundary if the layout engine computed dimensions.
308 if scopeID != "" && lr.BoundaryWidth > 0 && lr.BoundaryHeight > 0 {
309 resizeScopeBoundary(page, scopeID, lr.BoundaryX, lr.BoundaryY, lr.BoundaryWidth, lr.BoundaryHeight)
310 }
311
312 sort.Strings(toPlace)
313 for _, id := range toPlace {
314 pos, ok := lr.Positions[id]
315 if !ok {
316 continue
317 }
318 placeSingleElement(id, viewID, scopeID, page, templates, flat, &m.Specification, pos.X, pos.Y, false, result)
319 }
320 } else {
321 // Incremental: fall back to cursor-based placement.
322 pl := computePlacement(page)
323 for _, id := range toPlace {
324 applyElementAdded(id, viewID, scopeID, page, templates, flat, &m.Specification, &pl, result)
325 }
326 }
327 }
328
329 // populateConnectors creates connectors for all model relationships whose
330 // (possibly lifted) endpoints are both on the page but no connector exists yet.
331 // This ensures relationships involving newly populated elements are rendered (#231).
332 func populateConnectors(
333 page *drawio.Page,
334 viewID string,
335 scopeID string,
336 m *model.BausteinsichtModel,
337 elemSet map[string]bool,
338 templates *drawio.TemplateSet,
339 result *ForwardResult,
340 ) {
341 liftedSeen := make(map[string]bool)
342 for i, rel := range m.Relationships {
343 from := liftEndpoint(rel.From, elemSet)
344 to := liftEndpoint(rel.To, elemSet)
345 if from == "" || to == "" {
346 continue
347 }
348 // Skip self-referencing lifted relationships.
349 if from == to && (from != rel.From || to != rel.To) {
350 continue
351 }
352 // Skip connectors between the scope boundary and external elements.
353 // The boundary is a visual container — scope↔external relationships
354 // belong in the parent view. Child↔scope connections are allowed.
355 if scopeID != "" && isScopeExternalConnector(from, to, scopeID) {
356 continue
357 }
358
359 isLifted := from != rel.From || to != rel.To
360 pairKey := from + "->" + to
361 if isLifted {
362 if liftedSeen[pairKey] {
363 continue
364 }
365 liftedSeen[pairKey] = true
366 } else {
367 liftedSeen[pairKey] = true
368 }
369
370 srcRef := scopedCellID(viewID, from)
371 tgtRef := scopedCellID(viewID, to)
372 if page.FindConnector(srcRef, tgtRef, i) != nil {
373 continue // Already exists.
374 }
375 style := templates.GetConnectorStyle()
376 data := drawio.ConnectorData{
377 From: from,
378 To: to,
379 Label: rel.Label,
380 SourceRef: srcRef,
381 TargetRef: tgtRef,
382 Index: i,
383 }
384 page.CreateConnector(data, style)
385 result.ConnectorsCreated++
386 }
387 }
388
389 // scopedCellID returns a page-scoped cell ID to ensure file-wide uniqueness.
390 // If viewID is empty, returns the raw element ID (legacy mode).
391 func scopedCellID(viewID, elemID string) string {
392 if viewID == "" {
393 return elemID
394 }
395 return viewID + "--" + elemID
396 }
397
398 // isChildOf returns true if id is a direct or nested child of parentID.
399 // Example: isChildOf("shop.api", "shop") → true
400 func isChildOf(id, parentID string) bool {
401 return strings.HasPrefix(id, parentID+".")
402 }
403
404 // isScopeExternalConnector returns true if one endpoint is the scope and
405 // the other is NOT a child of the scope. Child↔scope connections are allowed,
406 // but scope↔external connections should be shown in the parent view.
407 func isScopeExternalConnector(from, to, scopeID string) bool {
408 if from == scopeID && !isChildOf(to, scopeID) {
409 return true
410 }
411 if to == scopeID && !isChildOf(from, scopeID) {
412 return true
413 }
414 return false
415 }
416
417 // liftEndpoint returns id if it is in elemFilter. Otherwise it walks up the
418 // parent chain (by removing the last dot-segment) until a parent is found in
419 // the filter. Returns "" if no ancestor is present on the page.
420 func liftEndpoint(id string, elemFilter map[string]bool) string {
421 if elemFilter[id] {
422 return id
423 }
424 for {
425 dot := strings.LastIndex(id, ".")
426 if dot < 0 {
427 return ""
428 }
429 id = id[:dot]
430 if elemFilter[id] {
431 return id
432 }
433 }
434 }
435
436 // createScopeBoundary creates a boundary/swimlane element for the scope element
437 // of a view (e.g., the parent system in a container view).
438 // spec provides tag definitions for applying tag-based styles.
439 func createScopeBoundary(
440 scopeID string,
441 viewID string,
442 page *drawio.Page,
443 templates *drawio.TemplateSet,
444 flat map[string]*model.Element,
445 spec *model.Specification,
446 result *ForwardResult,
447 ) {
448 // Skip if already present on the page.
449 if page.FindElement(scopeID) != nil {
450 return
451 }
452
453 elem, ok := flat[scopeID]
454 if !ok {
455 result.Warnings = append(result.Warnings, "scope element not found in model: "+scopeID)
456 return
457 }
458
459 boundaryKind := elem.Kind + "_boundary"
460 ts, ok := templates.GetBoundaryStyle(boundaryKind)
461 if !ok {
462 ts = drawio.TemplateStyle{Width: 400, Height: 300}
463 result.Warnings = append(result.Warnings, "no boundary template for kind: "+boundaryKind)
464 }
465
466 baseStyle := ts.Style
467 // Apply tag-based styles if any tags are defined on the element
468 if spec != nil && len(elem.Tags) > 0 {
469 baseStyle = applyTagStyles(elem, spec, baseStyle)
470 }
471
472 style := baseStyle
473 width := ts.Width
474 if width == 0 {
475 width = 400
476 }
477 height := ts.Height
478 if height == 0 {
479 height = 300
480 }
481
482 data := drawio.ElementData{
483 ID: scopeID,
484 CellID: scopedCellID(viewID, scopeID),
485 Kind: boundaryKind,
486 Title: elem.Title,
487 Technology: elem.Technology,
488 Description: elem.Description,
489 X: elementGap,
490 Y: elementGap,
491 Width: width,
492 Height: height,
493 // Boundaries don't use sub-cells — they use the swimlane header for the label.
494 }
495
496 if err := page.CreateElement(data, style); err != nil {
497 result.Warnings = append(result.Warnings, "failed to create scope boundary "+scopeID+": "+err.Error())
498 }
499 }
500
501 // applyChangesToPage applies element and relationship changes to a single page.
502 // If elemFilter is nil, all changes are applied. Otherwise only elements in the
503 // filter set are processed, and relationships are only created when both
504 // endpoints are in the filter set.
505 // viewID is used to scope cell IDs for file-wide uniqueness (empty = legacy).
506 // scopeID identifies the boundary element for parenting children (empty = no scope).
507 // spec provides tag definitions for applying tag-based styles.
508 func applyChangesToPage(
509 cs *ChangeSet,
510 page *drawio.Page,
511 templates *drawio.TemplateSet,
512 flat map[string]*model.Element,
513 elemFilter map[string]bool,
514 viewID string,
515 scopeID string,
516 spec *model.Specification,
517 result *ForwardResult,
518 ) {
519 pl := computePlacement(page)
520
521 for _, ch := range cs.ModelElementChanges {
522 switch ch.Type {
523 case Added:
524 if elemFilter != nil && !elemFilter[ch.ID] {
525 continue
526 }
527 applyElementAdded(ch.ID, viewID, scopeID, page, templates, flat, spec, &pl, result)
528 case Modified:
529 if elemFilter != nil && !elemFilter[ch.ID] && ch.ID != scopeID {
530 continue
531 }
532 applyElementModified(ch, page, templates, flat, result)
533 case Deleted:
534 // Deleted elements are removed from all pages where they exist,
535 // regardless of the current view filter — a deleted element is
536 // no longer in the model, so it can't appear in any view's
537 // resolved set. We just check if it exists on this page.
538 cellID := scopedCellID(viewID, ch.ID)
539 if page.FindElement(ch.ID) != nil {
540 // Delete connectors referencing this element's cell ID before
541 // removing the element itself. (#101)
542 result.ConnectorsDeleted += countConnectorsFor(page, cellID)
543 page.DeleteConnectorsFor(cellID)
544 page.DeleteElement(ch.ID)
545 result.ElementsDeleted++
546 }
547 }
548 }
549
550 liftedSeen := make(map[string]bool)
551
552 // Process relationships in two passes: direct first, then lifted.
553 // This ensures that when a direct relationship (e.g., api→db) and a
554 // lifted relationship (e.g., api.catalog→db lifted to api→db) map to
555 // the same pair, the direct one's label is used for the connector.
556 for pass := 0; pass < 2; pass++ {
557 for _, ch := range cs.ModelRelationshipChanges {
558 switch ch.Type {
559 case Deleted:
560 if pass != 0 {
561 continue
562 }
563 // For deletions, use scoped cell IDs to find and remove the
564 // connector. The original endpoints may no longer be in the
565 // view's element filter, so we bypass lifting entirely.
566 fromRef := scopedCellID(viewID, ch.From)
567 toRef := scopedCellID(viewID, ch.To)
568 if page.FindConnector(fromRef, toRef, ch.Index) != nil {
569 page.DeleteConnector(fromRef, toRef, ch.Index)
570 result.ConnectorsDeleted++
571 }
572 default:
573 from := ch.From
574 to := ch.To
575 if elemFilter != nil {
576 from = liftEndpoint(from, elemFilter)
577 to = liftEndpoint(to, elemFilter)
578 if from == "" || to == "" || (from == to && (from != ch.From || to != ch.To)) {
579 continue
580 }
581 // Skip connectors between scope boundary and externals.
582 if scopeID != "" && isScopeExternalConnector(from, to, scopeID) {
583 continue
584 }
585 }
586 isLifted := from != ch.From || to != ch.To
587 if pass == 0 && isLifted {
588 continue // First pass: only direct relationships
589 }
590 if pass == 1 && !isLifted {
591 continue // Second pass: only lifted relationships
592 }
593 lifted := RelationshipChange{From: from, To: to, Index: ch.Index, Type: ch.Type, NewValue: ch.NewValue}
594 switch ch.Type {
595 case Added:
596 // Only deduplicate lifted relationships. When multiple
597 // child relationships (e.g., a.x→b.z and a.y→b.z) are
598 // lifted to the same parent pair (a→b), only one connector
599 // should be created. Direct (non-lifted) relationships
600 // with the same pair must not be deduplicated. (#142)
601 pairKey := from + "->" + to
602 if isLifted {
603 // Skip lifted relationships when a direct relationship
604 // or another lifted relationship already covers this
605 // pair. (#142, #197)
606 if liftedSeen[pairKey] {
607 continue
608 }
609 liftedSeen[pairKey] = true
610 } else {
611 // Record direct relationships so lifted ones targeting
612 // the same pair are suppressed in pass 1. (#197)
613 liftedSeen[pairKey] = true
614 }
615 applyRelAdded(lifted, viewID, page, templates, result)
616 case Modified:
617 page.UpdateConnectorLabel(from, to, ch.Index, ch.NewValue)
618 result.ConnectorsUpdated++
619 }
620 }
621 }
622 }
623 }
624
625 // firstPage returns the first page in doc, or nil if there are none.
626 func firstPage(doc *drawio.Document) *drawio.Page {
627 pages := doc.Pages()
628 if len(pages) == 0 {
629 return nil
630 }
631 return pages[0]
632 }
633
634 // placement tracks where the next new element should be placed.
635 type placement struct {
636 nextX float64
637 nextY float64
638 }
639
640 // computePlacement scans existing elements on a page and returns a placement
641 // state positioned one row below all existing content.
642 func computePlacement(page *drawio.Page) placement {
643 maxY := 0.0
644 for _, obj := range page.FindAllElements() {
645 cell := obj.FindElement("mxCell")
646 if cell == nil {
647 continue
648 }
649 geo := cell.FindElement("mxGeometry")
650 if geo == nil {
651 continue
652 }
653 y, _ := strconv.ParseFloat(geo.SelectAttrValue("y", "0"), 64)
654 h, _ := strconv.ParseFloat(geo.SelectAttrValue("height", "0"), 64)
655 if bottom := y + h; bottom > maxY {
656 maxY = bottom
657 }
658 }
659
660 startY := maxY
661 if maxY > 0 {
662 startY = maxY + elementGap
663 }
664 return placement{nextX: elementGap, nextY: startY}
665 }
666
667 // applyElementAdded creates a new element on page with a visual new-element marker.
668 // If scopeID is set and the element is a child of the scope, it is parented to the
669 // scope boundary cell.
670 // spec provides tag definitions for applying tag-based styles.
671 func applyElementAdded(
672 id string,
673 viewID string,
674 scopeID string,
675 page *drawio.Page,
676 templates *drawio.TemplateSet,
677 flat map[string]*model.Element,
678 spec *model.Specification,
679 pl *placement,
680 result *ForwardResult,
681 ) {
682 // Skip layout/creation if element already exists on the page (prevents duplicates on sync
683 // state reset, #141). Still update content so model values are applied (e.g. description
684 // truncation kicks in even on existing elements after a state reset).
685 if page.FindElement(id) != nil {
686 if elem, ok := flat[id]; ok {
687 page.UpdateElement(id, drawio.ElementData{
688 Title: elem.Title,
689 Technology: elem.Technology,
690 Description: elem.Description,
691 })
692 }
693 return
694 }
695
696 elem, ok := flat[id]
697 if !ok {
698 result.Warnings = append(result.Warnings, "element not found in model: "+id)
699 return
700 }
701
702 ts, ok := templates.GetStyle(elem.Kind)
703 if !ok {
704 ts = drawio.TemplateStyle{Width: defaultWidth, Height: defaultHeight}
705 result.Warnings = append(result.Warnings, "no template style for kind: "+elem.Kind)
706 }
707
708 // Apply tag-based styles if any tags are defined on the element
709 baseStyle := ts.Style
710 if spec != nil && len(elem.Tags) > 0 {
711 baseStyle = applyTagStyles(elem, spec, baseStyle)
712 }
713
714 style := mergeStyles(baseStyle, newElementMarker)
715
716 width := ts.Width
717 if width == 0 {
718 width = defaultWidth
719 }
720 height := ts.Height
721 if height == 0 {
722 height = defaultHeight
723 }
724
725 data := drawio.ElementData{
726 ID: id,
727 CellID: scopedCellID(viewID, id),
728 Kind: elem.Kind,
729 Title: elem.Title,
730 Technology: elem.Technology,
731 Description: elem.Description,
732 X: pl.nextX,
733 Y: pl.nextY,
734 Width: width,
735 Height: height,
736 SubCells: subCellsFromTemplate(ts),
737 }
738
739 // Parent children of the scope element to the boundary cell.
740 if scopeID != "" && isChildOf(id, scopeID) {
741 data.ParentID = scopedCellID(viewID, scopeID)
742 }
743
744 if err := page.CreateElement(data, style); err != nil {
745 result.Warnings = append(result.Warnings, "failed to create element "+id+": "+err.Error())
746 return
747 }
748
749 pl.nextX += width + elementGap
750 result.ElementsCreated++
751 }
752
753 // placeSingleElement creates a new element at specific coordinates (used by layout engine).
754 func placeSingleElement(
755 id string,
756 viewID string,
757 scopeID string,
758 page *drawio.Page,
759 templates *drawio.TemplateSet,
760 flat map[string]*model.Element,
761 spec *model.Specification,
762 x, y float64,
763 markNew bool,
764 result *ForwardResult,
765 ) {
766 if page.FindElement(id) != nil {
767 return
768 }
769
770 elem, ok := flat[id]
771 if !ok {
772 result.Warnings = append(result.Warnings, "element not found in model: "+id)
773 return
774 }
775
776 ts, ok := templates.GetStyle(elem.Kind)
777 if !ok {
778 ts = drawio.TemplateStyle{Width: defaultWidth, Height: defaultHeight}
779 result.Warnings = append(result.Warnings, "no template style for kind: "+elem.Kind)
780 }
781
782 baseStyle := ts.Style
783 // Apply tag-based styles if any tags are defined on the element
784 if spec != nil && len(elem.Tags) > 0 {
785 baseStyle = applyTagStyles(elem, spec, baseStyle)
786 }
787
788 style := baseStyle
789 if markNew {
790 style = mergeStyles(baseStyle, newElementMarker)
791 }
792
793 width := ts.Width
794 if width == 0 {
795 width = defaultWidth
796 }
797 height := ts.Height
798 if height == 0 {
799 height = defaultHeight
800 }
801
802 data := drawio.ElementData{
803 ID: id,
804 CellID: scopedCellID(viewID, id),
805 Kind: elem.Kind,
806 Title: elem.Title,
807 Technology: elem.Technology,
808 Description: elem.Description,
809 X: x,
810 Y: y,
811 Width: width,
812 Height: height,
813 SubCells: subCellsFromTemplate(ts),
814 }
815
816 if scopeID != "" && isChildOf(id, scopeID) {
817 data.ParentID = scopedCellID(viewID, scopeID)
818 }
819
820 if err := page.CreateElement(data, style); err != nil {
821 result.Warnings = append(result.Warnings, "failed to create element "+id+": "+err.Error())
822 return
823 }
824
825 result.ElementsCreated++
826 }
827
828 // resizeScopeBoundary updates the geometry and position of an existing scope boundary element.
829 func resizeScopeBoundary(page *drawio.Page, scopeID string, x, y, width, height float64) {
830 obj := page.FindElement(scopeID)
831 if obj == nil {
832 return
833 }
834 cell := obj.FindElement("mxCell")
835 if cell == nil {
836 return
837 }
838 geo := cell.FindElement("mxGeometry")
839 if geo == nil {
840 return
841 }
842 geo.CreateAttr("x", strconv.FormatFloat(x, 'f', -1, 64))
843 geo.CreateAttr("y", strconv.FormatFloat(y, 'f', -1, 64))
844 geo.CreateAttr("width", strconv.FormatFloat(width, 'f', -1, 64))
845 geo.CreateAttr("height", strconv.FormatFloat(height, 'f', -1, 64))
846 }
847
848 // clearPageElements removes all managed bausteinsicht elements and connectors
849 // from a page. Used by --relayout to allow the layout engine to reposition
850 // everything from scratch.
851 func clearPageElements(page *drawio.Page) {
852 root := page.Root()
853 if root == nil {
854 return
855 }
856 // Collect elements to remove (cannot modify tree while iterating).
857 var toRemove []*etree.Element
858 for _, obj := range root.SelectElements("object") {
859 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
860 toRemove = append(toRemove, obj)
861 }
862 }
863 for _, cell := range root.SelectElements("mxCell") {
864 if strings.HasPrefix(cell.SelectAttrValue("id", ""), "rel-") {
865 toRemove = append(toRemove, cell)
866 }
867 }
868 for _, el := range toRemove {
869 root.RemoveChild(el)
870 }
871 }
872
873 // subCellsFromTemplate creates SubCellTemplates from a TemplateStyle.
874 // Returns nil if the template has no sub-cell definitions.
875 func subCellsFromTemplate(ts drawio.TemplateStyle) *drawio.SubCellTemplates {
876 if ts.TitleStyle == nil {
877 return nil
878 }
879 return &drawio.SubCellTemplates{
880 Title: ts.TitleStyle,
881 Tech: ts.TechStyle,
882 Desc: ts.DescStyle,
883 }
884 }
885
886 // applyElementModified updates the changed field of an existing element.
887 func applyElementModified(
888 ch ElementChange,
889 page *drawio.Page,
890 templates *drawio.TemplateSet,
891 flat map[string]*model.Element,
892 result *ForwardResult,
893 ) {
894 elem, ok := flat[ch.ID]
895 if !ok {
896 result.Warnings = append(result.Warnings, "element not found in model for update: "+ch.ID)
897 return
898 }
899
900 // Handle kind changes separately (only updates attribute and style).
901 if ch.Field == "kind" {
902 ts, ok := templates.GetStyle(elem.Kind)
903 if ok {
904 page.UpdateElementKind(ch.ID, elem.Kind, ts.Style)
905 } else {
906 page.UpdateElementKind(ch.ID, elem.Kind, "")
907 }
908 result.ElementsUpdated++
909 return
910 }
911
912 // When a specific field is known, read the current draw.io values and only
913 // override the changed field. This prevents overwriting draw.io-side changes
914 // to other fields during concurrent modification. (#109)
915 title := elem.Title
916 technology := elem.Technology
917 description := elem.Description
918
919 if ch.Field != "" {
920 obj := page.FindElement(ch.ID)
921 if obj != nil {
922 // Use ReadElementFields which handles both sub-cells and HTML labels.
923 curTitle, curTech, curDesc := page.ReadElementFields(obj)
924 curTooltip := obj.SelectAttrValue("tooltip", "")
925 if curDesc == "" {
926 curDesc = curTooltip
927 }
928
929 // Start from current draw.io values, override only the changed field.
930 title = curTitle
931 technology = curTech
932 description = curDesc
933
934 switch ch.Field {
935 case "title":
936 title = elem.Title
937 case "technology":
938 technology = elem.Technology
939 case "description":
940 description = elem.Description
941 }
942 }
943 }
944
945 data := drawio.ElementData{
946 ID: ch.ID,
947 Title: title,
948 Technology: technology,
949 Description: description,
950 }
951 page.UpdateElement(ch.ID, data)
952 result.ElementsUpdated++
953 }
954
955 // countConnectorsFor counts the number of connectors on page where source or
956 // target matches cellID. This is used to increment ConnectorsDeleted before
957 // calling page.DeleteConnectorsFor.
958 func countConnectorsFor(page *drawio.Page, cellID string) int {
959 n := 0
960 for _, c := range page.FindAllConnectors() {
961 src := c.SelectAttrValue("source", "")
962 tgt := c.SelectAttrValue("target", "")
963 if src == cellID || tgt == cellID {
964 n++
965 }
966 }
967 return n
968 }
969
970 // applyRelAdded creates a new connector on page. If the connector already
971 // exists (e.g., sync state was deleted), it is skipped to avoid duplicates. (#119)
972 func applyRelAdded(
973 ch RelationshipChange,
974 viewID string,
975 page *drawio.Page,
976 templates *drawio.TemplateSet,
977 result *ForwardResult,
978 ) {
979 srcRef := scopedCellID(viewID, ch.From)
980 tgtRef := scopedCellID(viewID, ch.To)
981
982 // Skip if connector already exists to prevent duplicates. (#119)
983 if page.FindConnector(srcRef, tgtRef, ch.Index) != nil {
984 return
985 }
986
987 style := templates.GetConnectorStyle()
988 data := drawio.ConnectorData{
989 From: ch.From,
990 To: ch.To,
991 Label: ch.NewValue,
992 SourceRef: srcRef,
993 TargetRef: tgtRef,
994 Index: ch.Index,
995 }
996 page.CreateConnector(data, style)
997 result.ConnectorsCreated++
998 }
999
1000 // reconcileViewPage removes elements from the page that are not in the
1001 // resolved view filter. This handles cases where view include/exclude rules
1002 // change without corresponding model element changes (no ChangeSet entries).
1003 // Elements not present in the flat model map are preserved — they were
1004 // manually added by the user in draw.io and should not be deleted. (#115)
1005 func reconcileViewPage(
1006 page *drawio.Page,
1007 elemFilter map[string]bool,
1008 flat map[string]*model.Element,
1009 scopeID string,
1010 viewID string,
1011 result *ForwardResult,
1012 ) {
1013 if elemFilter == nil {
1014 return
1015 }
1016
1017 for _, obj := range page.FindAllElements() {
1018 id := obj.SelectAttrValue("bausteinsicht_id", "")
1019 if id == "" {
1020 continue
1021 }
1022
1023 // Skip the scope boundary element — it's rendered separately
1024 // and is not subject to the normal element filter.
1025 if id == scopeID {
1026 continue
1027 }
1028
1029 if elemFilter[id] {
1030 continue
1031 }
1032
1033 // Preserve elements not in the model — they were manually added
1034 // by the user in draw.io and should not be deleted. (#115)
1035 if _, inModel := flat[id]; !inModel {
1036 continue
1037 }
1038
1039 // Element is on the page but not in the view's resolved set.
1040 // Remove its connectors first (using scoped cell ID), then the element.
1041 // On view pages, connectors reference scoped cell IDs, not raw element IDs.
1042 cellID := scopedCellID(viewID, id)
1043 result.ConnectorsDeleted += countConnectorsFor(page, cellID)
1044 page.DeleteConnectorsFor(cellID)
1045
1046 page.DeleteElement(id)
1047 result.ElementsDeleted++
1048 }
1049 }
1050
1051 // reconcileOrphanedElements removes elements from the page whose
1052 // bausteinsicht_id does not exist in the current model. This is the
1053 // no-views equivalent of reconcileViewPage and handles cases where
1054 // sync state is missing or the model was emptied. (#110)
1055 func reconcileOrphanedElements(
1056 page *drawio.Page,
1057 flat map[string]*model.Element,
1058 result *ForwardResult,
1059 ) {
1060 for _, obj := range page.FindAllElements() {
1061 id := obj.SelectAttrValue("bausteinsicht_id", "")
1062 if id == "" {
1063 continue
1064 }
1065 if _, inModel := flat[id]; inModel {
1066 continue
1067 }
1068 // Element is on the page but not in the model — remove it.
1069 cellID := id // no view scoping in legacy mode
1070 result.ConnectorsDeleted += countConnectorsFor(page, cellID)
1071 page.DeleteConnectorsFor(cellID)
1072 page.DeleteElement(id)
1073 result.ElementsDeleted++
1074 }
1075 }
1076
1077 // mergeStyles merges overlay style properties into a base style string.
1078 // If both base and overlay define the same key (e.g., strokeColor), the
1079 // overlay value wins. This prevents duplicate keys in the style string (#187).
1080 func mergeStyles(base, overlay string) string {
1081 if overlay == "" {
1082 return base
1083 }
1084 if base == "" {
1085 return overlay
1086 }
1087
1088 // Parse overlay keys.
1089 overlayKeys := make(map[string]string)
1090 for _, part := range strings.Split(overlay, ";") {
1091 part = strings.TrimSpace(part)
1092 if part == "" {
1093 continue
1094 }
1095 if idx := strings.IndexByte(part, '='); idx > 0 {
1096 overlayKeys[part[:idx]] = part
1097 } else {
1098 overlayKeys[part] = part
1099 }
1100 }
1101
1102 // Build result: base properties (skipping those overridden) + overlay.
1103 var sb strings.Builder
1104 for _, part := range strings.Split(base, ";") {
1105 part = strings.TrimSpace(part)
1106 if part == "" {
1107 continue
1108 }
1109 key := part
1110 if idx := strings.IndexByte(part, '='); idx > 0 {
1111 key = part[:idx]
1112 }
1113 if _, overridden := overlayKeys[key]; overridden {
1114 continue
1115 }
1116 sb.WriteString(part)
1117 sb.WriteByte(';')
1118 }
1119 for _, v := range overlayKeys {
1120 sb.WriteString(v)
1121 sb.WriteByte(';')
1122 }
1123 return sb.String()
1124 }
1125
1126 // applyDrillDownLinks sets the link attribute on elements that have a detail
1127 // view (a view whose scope matches the element's bausteinsicht_id). (#198)
1128 func applyDrillDownLinks(page *drawio.Page, links map[string]string) {
1129 for _, obj := range page.FindAllElements() {
1130 bid := obj.SelectAttrValue("bausteinsicht_id", "")
1131 if link, ok := links[bid]; ok {
1132 setAttrOn(obj, "link", link)
1133 }
1134 }
1135 }
1136
1137 // setAttrOn sets or creates an attribute on an etree element.
1138 func setAttrOn(el *etree.Element, key, value string) {
1139 for i, a := range el.Attr {
1140 if a.Key == key {
1141 el.Attr[i].Value = value
1142 return
1143 }
1144 }
1145 el.CreateAttr(key, value)
1146 }
1147
1148 // createBackNavButton adds a small navigation button to a detail view page
1149 // that links back to the parent view. The parent view is the one that
1150 // contains the scope element. (#198)
1151 func createBackNavButton(
1152 page *drawio.Page,
1153 viewID string,
1154 scopeID string,
1155 m *model.BausteinsichtModel,
1156 ) {
1157 navCellID := "nav-back-" + viewID
1158
1159 // Don't create if already exists.
1160 root := page.Root()
1161 if root == nil {
1162 return
1163 }
1164 for _, obj := range root.SelectElements("object") {
1165 if obj.SelectAttrValue("id", "") == navCellID {
1166 return
1167 }
1168 }
1169
1170 // Find the parent view: a view that includes the scope element.
1171 var parentViewID string
1172 var parentTitle string
1173 for vID, v := range m.Views {
1174 if vID == viewID {
1175 continue
1176 }
1177 viewCopy := v
1178 resolved, err := model.ResolveView(m, &viewCopy)
1179 if err != nil {
1180 continue
1181 }
1182 for _, id := range resolved {
1183 if id == scopeID {
1184 parentViewID = vID
1185 parentTitle = v.Title
1186 break
1187 }
1188 }
1189 if parentViewID != "" {
1190 break
1191 }
1192 }
1193
1194 if parentViewID == "" {
1195 return // No parent view found.
1196 }
1197
1198 obj := root.CreateElement("object")
1199 obj.CreateAttr("label", "&larr; "+parentTitle)
1200 obj.CreateAttr("id", navCellID)
1201 obj.CreateAttr("link", "data:page/id,view-"+parentViewID)
1202
1203 cell := obj.CreateElement("mxCell")
1204 cell.CreateAttr("style", "rounded=1;fillColor=#f8cecc;strokeColor=#b85450;html=1;fontSize=10;")
1205 cell.CreateAttr("vertex", "1")
1206 cell.CreateAttr("parent", "1")
1207
1208 geo := cell.CreateElement("mxGeometry")
1209 geo.CreateAttr("x", "20")
1210 geo.CreateAttr("y", "20")
1211 geo.CreateAttr("width", "140")
1212 geo.CreateAttr("height", "30")
1213 geo.CreateAttr("as", "geometry")
1214 }
1215
1216 // synchronizeDecisionBadges updates decision badges on all elements in a page
1217 // to match the model. It removes old badges and creates new ones based on the
1218 // element's decision links in the model.
1219 func synchronizeDecisionBadges(page *drawio.Page, m *model.BausteinsichtModel) {
1220 if m == nil {
1221 return
1222 }
1223
1224 // Build decision map for quick lookup
1225 decisionMap := make(map[string]*model.DecisionRecord)
1226 for i := range m.Specification.Decisions {
1227 decisionMap[m.Specification.Decisions[i].ID] = &m.Specification.Decisions[i]
1228 }
1229
1230 // Find all elements on the page
1231 root := page.Root()
1232 if root == nil {
1233 return
1234 }
1235
1236 for _, obj := range root.SelectElements("object") {
1237 elemID := obj.SelectAttrValue("bausteinsicht_id", "")
1238 if elemID == "" {
1239 continue
1240 }
1241
1242 // Look up element in model
1243 elem, ok := findElementByID(m, elemID)
1244 if !ok || elem == nil {
1245 continue
1246 }
1247
1248 // Find the mxCell for this element
1249 cell := obj.SelectElement("mxCell")
1250 if cell == nil {
1251 continue
1252 }
1253
1254 // Remove old decision badges
1255 children := cell.SelectElements("mxCell")
1256 for i := len(children) - 1; i >= 0; i-- {
1257 badgeID := children[i].SelectAttrValue("bausteinsicht_decision_id", "")
1258 if badgeID != "" {
1259 cell.RemoveChild(children[i])
1260 }
1261 }
1262
1263 // Add new decision badges
1264 if len(elem.Decisions) > 0 {
1265 AddDecisionBadges(cell, elem.Decisions, decisionMap)
1266 }
1267 }
1268 }
1269
1270 // findElementByID finds an element in the model by its dot-path ID (e.g., "system.backend.api")
1271 func findElementByID(m *model.BausteinsichtModel, id string) (*model.Element, bool) {
1272 parts := strings.Split(id, ".")
1273 if len(parts) == 0 {
1274 return nil, false
1275 }
1276
1277 elem, ok := m.Model[parts[0]]
1278 if !ok {
1279 return nil, false
1280 }
1281
1282 // Navigate through child elements
1283 current := &elem
1284 for _, part := range parts[1:] {
1285 child, ok := current.Children[part]
1286 if !ok {
1287 return nil, false
1288 }
1289 current = &child
1290 }
1291
1292 return current, true
1293 }
1294
github.com/docToolchain/Bausteinsicht/internal/sync/layout.go 85.5%
1 package sync
2
3 import (
4 "math"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/drawio"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 type position struct {
13 X, Y float64
14 }
15
16 type layoutConfig struct {
17 pageWidth float64 // 1169 (A4 landscape)
18 elementGap float64 // 40
19 padding float64 // 60 (boundary inner padding)
20 startX float64 // 40
21 startY float64 // 40
22 }
23
24 var defaultLayoutConfig = layoutConfig{
25 pageWidth: 1169,
26 elementGap: 60,
27 padding: 60,
28 startX: 40,
29 startY: 40,
30 }
31
32 // layoutResult holds computed positions and optional boundary dimensions.
33 type layoutResult struct {
34 Positions map[string]position
35 BoundaryX float64
36 BoundaryY float64
37 BoundaryWidth float64
38 BoundaryHeight float64
39 }
40
41 // computeLayout returns positions for elements to place on a fresh page.
42 // It only computes positions; it does not modify the document.
43 func computeLayout(
44 ids []string,
45 flat map[string]*model.Element,
46 templates *drawio.TemplateSet,
47 elementOrder []string,
48 scopeID string,
49 layout string,
50 relationships []model.Relationship,
51 ) layoutResult {
52 switch layout {
53 case "grid":
54 return computeGridLayout(ids, flat, templates)
55 case "none":
56 return computeNoneLayout(ids, flat, templates)
57 default: // "layered" or ""
58 return computeLayeredLayout(ids, flat, templates, elementOrder, scopeID, relationships)
59 }
60 }
61
62 // computeLayeredLayout arranges elements in horizontal rows grouped by kind.
63 // Kinds are ordered according to elementOrder (from specification.elements).
64 // Elements within each layer are sorted alphabetically.
65 //
66 // For scoped views the layout is:
67 // 1. Actor-like externals (kinds whose notation contains "Actor") → top rows
68 // 2. Scope boundary with children → middle
69 // 3. Non-actor externals → bottom rows
70 func computeLayeredLayout(
71 ids []string,
72 flat map[string]*model.Element,
73 templates *drawio.TemplateSet,
74 elementOrder []string,
75 scopeID string,
76 relationships []model.Relationship,
77 ) layoutResult {
78 cfg := defaultLayoutConfig
79 result := layoutResult{Positions: make(map[string]position)}
80
81 // Build kind → tier mapping from elementOrder.
82 kindTier := make(map[string]int)
83 for i, k := range elementOrder {
84 kindTier[k] = i
85 }
86 maxTier := len(elementOrder) // unknown kinds go here
87
88 // Separate scoped children from external elements.
89 // For externals, further split into actors (top) and non-actors (bottom).
90 var scopeChildren []string
91 var actorExternals []string
92 var otherExternals []string
93 for _, id := range ids {
94 if id == scopeID {
95 continue
96 }
97 if scopeID != "" && isChildOf(id, scopeID) {
98 scopeChildren = append(scopeChildren, id)
99 } else {
100 if isActorKind(id, flat) {
101 actorExternals = append(actorExternals, id)
102 } else {
103 otherExternals = append(otherExternals, id)
104 }
105 }
106 }
107
108 // If no scope, treat everything as one group (actors first, then rest).
109 if scopeID == "" {
110 all := append(actorExternals, otherExternals...)
111 all = append(all, scopeChildren...)
112 placeLayered(all, flat, templates, kindTier, maxTier, cfg, cfg.startX, cfg.startY, result.Positions)
113 return result
114 }
115
116 // Compute boundary dimensions first (needed for centering actors/externals).
117 boundaryX := cfg.startX
118 var boundaryContentW, boundaryContentH float64
119 boundaryStartSize := 30.0
120
121 scopeChildPositions := make(map[string]position)
122 if len(scopeChildren) > 0 {
123 innerX := cfg.padding
124 innerY := boundaryStartSize + cfg.padding
125 minContentWidth := minBoundaryContentWidth(scopeChildren, flat, templates, cfg)
126
127 boundaryContentW, boundaryContentH = placeBFS(scopeChildren, flat, templates, cfg, innerX, innerY, minContentWidth, actorExternals, relationships, scopeChildPositions)
128
129 if boundaryContentW < minContentWidth {
130 boundaryContentW = minContentWidth
131 }
132
133 result.BoundaryWidth = boundaryContentW + 2*cfg.padding
134 result.BoundaryHeight = boundaryContentH + boundaryStartSize + 2*cfg.padding
135
136 if result.BoundaryWidth < 400 {
137 result.BoundaryWidth = 400
138 }
139 if result.BoundaryHeight < 300 {
140 result.BoundaryHeight = 300
141 }
142 }
143
144 // Reference width for centering: the wider of boundary or page content.
145 refWidth := result.BoundaryWidth
146 if refWidth < 400 {
147 refWidth = 400
148 }
149
150 curY := cfg.startY
151
152 // 1. Place actor externals above the boundary, centered to boundary width.
153 // Reserve the first row even when there are no actors so users can add them later.
154 if len(actorExternals) > 0 {
155 actorW, actorH := placeLayered(actorExternals, flat, templates, kindTier, maxTier, cfg, cfg.startX, curY, result.Positions)
156 // Center actor row relative to boundary width.
157 if actorW < refWidth {
158 offset := (refWidth - actorW) / 2
159 for _, id := range actorExternals {
160 if p, ok := result.Positions[id]; ok {
161 p.X += offset
162 result.Positions[id] = p
163 }
164 }
165 }
166 curY += actorH + cfg.elementGap
167 } else {
168 // No actors — still reserve space for a potential actor row.
169 curY += defaultHeight + cfg.elementGap
170 }
171
172 // 2. Place scope boundary and its children.
173 boundaryY := curY
174 if len(scopeChildren) > 0 {
175 result.BoundaryX = boundaryX
176 result.BoundaryY = boundaryY
177
178 // Copy pre-computed child positions into result.
179 for id, pos := range scopeChildPositions {
180 result.Positions[id] = pos
181 }
182
183 curY = boundaryY + result.BoundaryHeight + cfg.elementGap
184 }
185
186 // 3. Place non-actor externals below the boundary, centered.
187 if len(otherExternals) > 0 {
188 extW, _ := placeLayered(otherExternals, flat, templates, kindTier, maxTier, cfg, cfg.startX, curY, result.Positions)
189 if extW < refWidth {
190 offset := (refWidth - extW) / 2
191 for _, id := range otherExternals {
192 if p, ok := result.Positions[id]; ok {
193 p.X += offset
194 result.Positions[id] = p
195 }
196 }
197 }
198 }
199
200 return result
201 }
202
203 // isActorKind returns true if the element's kind contains "actor" (case-insensitive).
204 func isActorKind(id string, flat map[string]*model.Element) bool {
205 elem := flat[id]
206 if elem == nil {
207 return false
208 }
209 k := elem.Kind
210 return k == "actor" || k == "Actor" || k == "user" || k == "User" ||
211 k == "person" || k == "Person"
212 }
213
214 // minBoundaryContentWidth computes the minimum content width to fit at least
215 // 3 average-sized elements side by side.
216 func minBoundaryContentWidth(ids []string, flat map[string]*model.Element, templates *drawio.TemplateSet, cfg layoutConfig) float64 {
217 if len(ids) == 0 {
218 return 0
219 }
220 // Use the most common element width as reference.
221 totalW := 0.0
222 for _, id := range ids {
223 w, _ := elementSize(id, flat, templates)
224 totalW += w
225 }
226 avgW := totalW / float64(len(ids))
227
228 cols := 3
229 if len(ids) < cols {
230 cols = len(ids)
231 }
232 return float64(cols)*avgW + float64(cols-1)*cfg.elementGap
233 }
234
235 // placeLayered places elements in layered rows, centered horizontally,
236 // and returns the content width and height.
237 func placeLayered(
238 ids []string,
239 flat map[string]*model.Element,
240 templates *drawio.TemplateSet,
241 kindTier map[string]int,
242 maxTier int,
243 cfg layoutConfig,
244 originX, originY float64,
245 positions map[string]position,
246 ) (contentWidth, contentHeight float64) {
247 // Group by tier.
248 tiers := make(map[int][]string)
249 for _, id := range ids {
250 elem := flat[id]
251 if elem == nil {
252 continue
253 }
254 tier, ok := kindTier[elem.Kind]
255 if !ok {
256 tier = maxTier
257 }
258 tiers[tier] = append(tiers[tier], id)
259 }
260
261 // Sort tier keys.
262 tierKeys := make([]int, 0, len(tiers))
263 for k := range tiers {
264 tierKeys = append(tierKeys, k)
265 }
266 sort.Ints(tierKeys)
267
268 // First pass: place left-aligned and track row membership for centering.
269 type rowInfo struct {
270 ids []string
271 width float64
272 height float64
273 y float64
274 }
275 var rows []rowInfo
276 curY := originY
277 maxRowWidth := 0.0
278
279 for _, tier := range tierKeys {
280 elems := tiers[tier]
281 sort.Strings(elems)
282
283 curX := originX
284 rowHeight := 0.0
285 var currentRow []string
286
287 for _, id := range elems {
288 w, h := elementSize(id, flat, templates)
289
290 // Row wrapping.
291 if curX > originX && curX+w > cfg.pageWidth-cfg.startX {
292 rowWidth := curX - cfg.elementGap - originX
293 rows = append(rows, rowInfo{ids: currentRow, width: rowWidth, height: rowHeight, y: curY})
294 if rowWidth > maxRowWidth {
295 maxRowWidth = rowWidth
296 }
297 curY += rowHeight + cfg.elementGap
298 curX = originX
299 rowHeight = 0
300 currentRow = nil
301 }
302
303 positions[id] = position{X: curX, Y: curY}
304 currentRow = append(currentRow, id)
305 curX += w + cfg.elementGap
306 if h > rowHeight {
307 rowHeight = h
308 }
309 }
310
311 // Finish last row of this tier.
312 if len(currentRow) > 0 {
313 rowWidth := curX - cfg.elementGap - originX
314 rows = append(rows, rowInfo{ids: currentRow, width: rowWidth, height: rowHeight, y: curY})
315 if rowWidth > maxRowWidth {
316 maxRowWidth = rowWidth
317 }
318 }
319 curY += rowHeight + cfg.elementGap
320 }
321
322 // Second pass: center each row within the max row width.
323 for _, row := range rows {
324 if row.width >= maxRowWidth {
325 continue
326 }
327 offset := (maxRowWidth - row.width) / 2
328 for _, id := range row.ids {
329 p := positions[id]
330 p.X += offset
331 positions[id] = p
332 }
333 }
334
335 contentWidth = maxRowWidth
336 contentHeight = curY - originY - cfg.elementGap // subtract trailing gap
337 if contentHeight < 0 {
338 contentHeight = 0
339 }
340 return contentWidth, contentHeight
341 }
342
343 // placeBFS places scope children using relationship-based BFS ordering:
344 // 1. Row 1: elements connected to actors (external seeds)
345 // 2. Row 2: elements connected to row 1
346 // 3. Row N: elements connected to row N-1
347 // 4. Remaining: any unconnected elements at the end
348 //
349 // Within each row, the next element is chosen by adjacency to the last placed
350 // element (greedy neighbor selection), with alphabetical fallback.
351 func placeBFS(
352 ids []string,
353 flat map[string]*model.Element,
354 templates *drawio.TemplateSet,
355 cfg layoutConfig,
356 originX, originY float64,
357 minRowWidth float64,
358 actorIDs []string,
359 relationships []model.Relationship,
360 positions map[string]position,
361 ) (contentWidth, contentHeight float64) {
362 // Build an adjacency map among the scope children.
363 idSet := make(map[string]bool, len(ids))
364 for _, id := range ids {
365 idSet[id] = true
366 }
367
368 // adj maps each scope child to its neighbors (other scope children
369 // or external elements it is connected to).
370 adj := make(map[string]map[string]bool)
371 for _, id := range ids {
372 adj[id] = make(map[string]bool)
373 }
374
375 actorSet := make(map[string]bool, len(actorIDs))
376 for _, id := range actorIDs {
377 actorSet[id] = true
378 }
379
380 // connectedToActor tracks which scope children have a direct or
381 // indirect (via lifted) relationship to an actor.
382 connectedToActor := make(map[string]bool)
383
384 for _, rel := range relationships {
385 from, to := rel.From, rel.To
386
387 // Check if this relationship connects a scope child to an actor.
388 if idSet[from] && actorSet[to] {
389 connectedToActor[from] = true
390 }
391 if idSet[to] && actorSet[from] {
392 connectedToActor[to] = true
393 }
394
395 // Also check lifted relationships: if an actor connects to a
396 // parent of a scope child (e.g., actor→onlineshop.frontend where
397 // frontend is a scope child via onlineshop.frontend).
398 fromInScope := idSet[from] || hasChildInSet(from, idSet)
399 toInScope := idSet[to] || hasChildInSet(to, idSet)
400 _ = fromInScope
401 _ = toInScope
402
403 // Build adjacency between scope children.
404 fromResolved := resolveToScopeChild(from, idSet)
405 toResolved := resolveToScopeChild(to, idSet)
406 if fromResolved != "" && toResolved != "" && fromResolved != toResolved {
407 if adj[fromResolved] != nil && adj[toResolved] != nil {
408 adj[fromResolved][toResolved] = true
409 adj[toResolved][fromResolved] = true
410 }
411 }
412
413 // Track actor connections via resolved scope children.
414 if fromResolved != "" && actorSet[to] {
415 connectedToActor[fromResolved] = true
416 }
417 if toResolved != "" && actorSet[from] {
418 connectedToActor[toResolved] = true
419 }
420 }
421
422 // BFS: assign elements to rows.
423 placed := make(map[string]bool)
424 var rows [][]string
425
426 // Row 0: elements connected to actors.
427 var row0 []string
428 for _, id := range ids {
429 if connectedToActor[id] {
430 row0 = append(row0, id)
431 placed[id] = true
432 }
433 }
434 sort.Strings(row0)
435 if len(row0) > 0 {
436 rows = append(rows, orderByAdjacency(row0, adj))
437 }
438
439 // Subsequent rows: BFS from previous row.
440 for len(rows) > 0 {
441 if len(placed) >= len(ids) {
442 break
443 }
444 prevRow := rows[len(rows)-1]
445 var nextRow []string
446 for _, prev := range prevRow {
447 for neighbor := range adj[prev] {
448 if !placed[neighbor] {
449 nextRow = append(nextRow, neighbor)
450 placed[neighbor] = true
451 }
452 }
453 }
454 if len(nextRow) == 0 {
455 break
456 }
457 sort.Strings(nextRow)
458 rows = append(rows, orderByAdjacency(nextRow, adj))
459 }
460
461 // Remaining: elements not reached by BFS (no relationships).
462 var remaining []string
463 for _, id := range ids {
464 if !placed[id] {
465 remaining = append(remaining, id)
466 }
467 }
468 if len(remaining) > 0 {
469 sort.Strings(remaining)
470 rows = append(rows, remaining)
471 }
472
473 // Place rows.
474 curY := originY
475 maxWidth := 0.0
476
477 for _, row := range rows {
478 curX := originX
479 rowHeight := 0.0
480
481 for _, id := range row {
482 w, h := elementSize(id, flat, templates)
483
484 // Row wrapping.
485 if curX > originX && curX+w > originX+minRowWidth {
486 curY += rowHeight + cfg.elementGap
487 curX = originX
488 rowHeight = 0
489 }
490
491 positions[id] = position{X: curX, Y: curY}
492 curX += w + cfg.elementGap
493 usedWidth := curX - cfg.elementGap - originX
494 if usedWidth > maxWidth {
495 maxWidth = usedWidth
496 }
497 if h > rowHeight {
498 rowHeight = h
499 }
500 }
501
502 curY += rowHeight + cfg.elementGap
503 }
504
505 contentWidth = maxWidth
506 contentHeight = curY - originY - cfg.elementGap
507 if contentHeight < 0 {
508 contentHeight = 0
509 }
510 return contentWidth, contentHeight
511 }
512
513 // orderByAdjacency reorders elements so that each next element is a neighbor
514 // of the previously placed one (greedy). Falls back to original order.
515 func orderByAdjacency(ids []string, adj map[string]map[string]bool) []string {
516 if len(ids) <= 1 {
517 return ids
518 }
519
520 remaining := make(map[string]bool, len(ids))
521 for _, id := range ids {
522 remaining[id] = true
523 }
524
525 result := make([]string, 0, len(ids))
526 // Start with the first element (alphabetically).
527 current := ids[0]
528 result = append(result, current)
529 delete(remaining, current)
530
531 for len(remaining) > 0 {
532 // Find a neighbor of current that is in remaining.
533 var next string
534 var candidates []string
535 for neighbor := range adj[current] {
536 if remaining[neighbor] {
537 candidates = append(candidates, neighbor)
538 }
539 }
540 if len(candidates) > 0 {
541 sort.Strings(candidates)
542 next = candidates[0]
543 } else {
544 // No neighbor found — pick alphabetically first remaining.
545 var fallback []string
546 for id := range remaining {
547 fallback = append(fallback, id)
548 }
549 if len(fallback) == 0 {
550 break
551 }
552 sort.Strings(fallback)
553 next = fallback[0]
554 }
555 result = append(result, next)
556 delete(remaining, next)
557 current = next
558 }
559
560 return result
561 }
562
563 // resolveToScopeChild resolves an element ID to a scope child ID.
564 // If the ID is directly in the set, returns it. If a parent is in the set,
565 // returns the parent. Returns "" if no match.
566 func resolveToScopeChild(id string, scopeChildren map[string]bool) string {
567 if scopeChildren[id] {
568 return id
569 }
570 // Walk up the hierarchy to find a scope child ancestor.
571 for {
572 dot := strings.LastIndex(id, ".")
573 if dot < 0 {
574 return ""
575 }
576 id = id[:dot]
577 if scopeChildren[id] {
578 return id
579 }
580 }
581 }
582
583 // hasChildInSet returns true if any key in the set is a child of id.
584 func hasChildInSet(id string, set map[string]bool) bool {
585 prefix := id + "."
586 for k := range set {
587 if strings.HasPrefix(k, prefix) {
588 return true
589 }
590 }
591 return false
592 }
593
594 // computeGridLayout arranges all elements in a simple grid.
595 func computeGridLayout(
596 ids []string,
597 flat map[string]*model.Element,
598 templates *drawio.TemplateSet,
599 ) layoutResult {
600 cfg := defaultLayoutConfig
601 result := layoutResult{Positions: make(map[string]position)}
602
603 sorted := make([]string, len(ids))
604 copy(sorted, ids)
605 sort.Strings(sorted)
606
607 // Determine max element width for column calculation.
608 maxW := 0.0
609 for _, id := range sorted {
610 w, _ := elementSize(id, flat, templates)
611 if w > maxW {
612 maxW = w
613 }
614 }
615
616 columns := int(math.Floor((cfg.pageWidth - 2*cfg.startX) / (maxW + cfg.elementGap)))
617 if columns < 1 {
618 columns = 1
619 }
620
621 col, row := 0, 0
622 for _, id := range sorted {
623 _, h := elementSize(id, flat, templates)
624 _ = h
625 x := cfg.startX + float64(col)*(maxW+cfg.elementGap)
626 y := cfg.startY + float64(row)*(defaultHeight+cfg.elementGap)
627 result.Positions[id] = position{X: x, Y: y}
628 col++
629 if col >= columns {
630 col = 0
631 row++
632 }
633 }
634
635 return result
636 }
637
638 // computeNoneLayout uses the legacy horizontal row placement.
639 func computeNoneLayout(
640 ids []string,
641 flat map[string]*model.Element,
642 templates *drawio.TemplateSet,
643 ) layoutResult {
644 cfg := defaultLayoutConfig
645 result := layoutResult{Positions: make(map[string]position)}
646
647 sorted := make([]string, len(ids))
648 copy(sorted, ids)
649 sort.Strings(sorted)
650
651 curX := cfg.startX
652 for _, id := range sorted {
653 w, _ := elementSize(id, flat, templates)
654 result.Positions[id] = position{X: curX, Y: cfg.startY}
655 curX += w + cfg.elementGap
656 }
657
658 return result
659 }
660
661 // elementSize returns the width and height for an element, based on its
662 // template style or default dimensions.
663 func elementSize(id string, flat map[string]*model.Element, templates *drawio.TemplateSet) (float64, float64) {
664 elem := flat[id]
665 if elem == nil {
666 return defaultWidth, defaultHeight
667 }
668 ts, ok := templates.GetStyle(elem.Kind)
669 if !ok {
670 return defaultWidth, defaultHeight
671 }
672 w := ts.Width
673 if w == 0 {
674 w = defaultWidth
675 }
676 h := ts.Height
677 if h == 0 {
678 h = defaultHeight
679 }
680 return w, h
681 }
682
github.com/docToolchain/Bausteinsicht/internal/sync/metadata.go 81.8%
1 package sync
2
3 import (
4 "fmt"
5 "html"
6 "sort"
7 "strconv"
8 "strings"
9
10 "github.com/beevik/etree"
11 "github.com/docToolchain/Bausteinsicht/internal/drawio"
12 "github.com/docToolchain/Bausteinsicht/internal/model"
13 )
14
15 const (
16 metadataPrefix = "metadata-"
17 legendPrefix = "legend-"
18 infoBoxGap = 80.0
19 metadataX = 40.0
20 metadataWidth = 500.0
21 legendWidthRatio = 0.30
22 legendGap = 60.0
23 )
24
25 // createMetadata creates or updates a metadata info box on the view page.
26 // Returns true if the label was created or changed.
27 func createMetadata(
28 page *drawio.Page,
29 viewID string,
30 view model.View,
31 cfg model.Config,
32 modelPath string,
33 timestamp string,
34 ) bool {
35 cellID := metadataPrefix + viewID
36 label := buildMetadataLabel(view, cfg, modelPath, timestamp)
37
38 root := page.Root()
39 if root == nil {
40 return false
41 }
42
43 // Update existing metadata cell — only if label actually changed.
44 for _, obj := range root.SelectElements("object") {
45 if obj.SelectAttrValue("id", "") == cellID {
46 if obj.SelectAttrValue("label", "") == label {
47 return false
48 }
49 obj.CreateAttr("label", label)
50 return true
51 }
52 }
53
54 // Create new metadata cell — 60% of content width.
55 legendCellID := legendPrefix + viewID
56 y := computeMaxY(page, cellID, legendCellID) + infoBoxGap
57 contentWidth := computeContentWidth(page, cellID, legendCellID)
58 if contentWidth < 600 {
59 contentWidth = 600
60 }
61 width := contentWidth * 0.60
62 createInfoBox(root, cellID, label, metadataX, y, width)
63 return true
64 }
65
66 // createLegend creates or updates a legend box on the view page.
67 // Returns true if the label was created or changed.
68 func createLegend(
69 page *drawio.Page,
70 viewID string,
71 spec model.Specification,
72 templates *drawio.TemplateSet,
73 elemSet map[string]bool,
74 flat map[string]*model.Element,
75 ) bool {
76 cellID := legendPrefix + viewID
77 label := buildLegendLabel(spec, templates, elemSet, flat)
78
79 root := page.Root()
80 if root == nil {
81 return false
82 }
83
84 // Update existing legend cell — only if label actually changed.
85 for _, obj := range root.SelectElements("object") {
86 if obj.SelectAttrValue("id", "") == cellID {
87 if obj.SelectAttrValue("label", "") == label {
88 return false
89 }
90 obj.CreateAttr("label", label)
91 return true
92 }
93 }
94
95 // Create new legend cell — positioned right of the metadata box, same Y.
96 metaCellID := metadataPrefix + viewID
97 y := computeMaxY(page, cellID, metaCellID) + infoBoxGap
98
99 // Compute content width for legend positioning.
100 contentWidth := computeContentWidth(page, cellID, metaCellID)
101 if contentWidth < 600 {
102 contentWidth = 600
103 }
104 legendWidth := contentWidth * legendWidthRatio
105 legendX := contentWidth - legendWidth + metadataX
106 createInfoBox(root, cellID, label, legendX, y, legendWidth)
107 return true
108 }
109
110 // buildMetadataLabel returns an HTML label for the metadata box.
111 func buildMetadataLabel(view model.View, cfg model.Config, modelPath string, timestamp string) string {
112 var sb strings.Builder
113 sb.WriteString("<b>")
114 sb.WriteString(html.EscapeString(view.Title))
115 sb.WriteString("</b>")
116 if view.Description != "" {
117 sb.WriteString("<br>")
118 sb.WriteString(html.EscapeString(view.Description))
119 }
120 sb.WriteString("<br><br>")
121 sb.WriteString("<font point-size=\"9\">")
122 if cfg.Repo != "" {
123 sb.WriteString("Repo: ")
124 sb.WriteString(html.EscapeString(cfg.Repo))
125 sb.WriteString("<br>")
126 }
127 sb.WriteString("Source: ")
128 sb.WriteString(html.EscapeString(modelPath))
129 sb.WriteString("<br>Last synced: ")
130 sb.WriteString(html.EscapeString(timestamp))
131 if cfg.Author != "" {
132 sb.WriteString("<br>Author: ")
133 sb.WriteString(html.EscapeString(cfg.Author))
134 }
135 sb.WriteString("<br>Generated by Bausteinsicht</font>")
136 return sb.String()
137 }
138
139 // buildLegendLabel returns an HTML label for the legend box.
140 func buildLegendLabel(
141 spec model.Specification,
142 templates *drawio.TemplateSet,
143 elemSet map[string]bool,
144 flat map[string]*model.Element,
145 ) string {
146 // Collect kinds actually used in this view.
147 usedKinds := make(map[string]bool)
148 for id := range elemSet {
149 if elem, ok := flat[id]; ok {
150 usedKinds[elem.Kind] = true
151 }
152 }
153
154 // Sort kinds for deterministic output.
155 kinds := make([]string, 0, len(usedKinds))
156 for k := range usedKinds {
157 kinds = append(kinds, k)
158 }
159 sort.Strings(kinds)
160
161 allStyles := templates.GetAllStyles()
162
163 var sb strings.Builder
164 sb.WriteString("<b>Legend</b>")
165
166 for _, kind := range kinds {
167 ekind, ok := spec.Elements[kind]
168 if !ok {
169 continue
170 }
171
172 color := extractFillColor(allStyles, kind)
173 sb.WriteString("<br>")
174 fmt.Fprintf(&sb,
175 "<font color=\"%s\">\u25a0</font> %s",
176 html.EscapeString(color),
177 html.EscapeString(ekind.Notation),
178 )
179 }
180
181 return sb.String()
182 }
183
184 // extractFillColor extracts the fillColor from a template style string.
185 // Returns a default gray if not found.
186 func extractFillColor(allStyles map[string]drawio.TemplateStyle, kind string) string {
187 ts, ok := allStyles[kind]
188 if !ok {
189 return "#666666"
190 }
191 for _, part := range strings.Split(ts.Style, ";") {
192 if strings.HasPrefix(part, "fillColor=") {
193 return strings.TrimPrefix(part, "fillColor=")
194 }
195 }
196 return "#666666"
197 }
198
199 // createInfoBox creates a non-model mxCell info box at the given position.
200 func createInfoBox(root *etree.Element, id, label string, x, y, width float64) {
201 obj := root.CreateElement("object")
202 obj.CreateAttr("label", label)
203 obj.CreateAttr("id", id)
204
205 cell := obj.CreateElement("mxCell")
206 cell.CreateAttr("style", "rounded=0;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=none;fontColor=#333333;fontSize=10;align=left;verticalAlign=top;")
207 cell.CreateAttr("vertex", "1")
208 cell.CreateAttr("parent", "1")
209
210 geo := cell.CreateElement("mxGeometry")
211 geo.CreateAttr("x", fmt.Sprintf("%.0f", x))
212 geo.CreateAttr("y", fmt.Sprintf("%.0f", y))
213 geo.CreateAttr("width", fmt.Sprintf("%.0f", width))
214 geo.CreateAttr("height", "120")
215 geo.CreateAttr("as", "geometry")
216 }
217
218 // computeMaxY finds the bottom-most Y coordinate of elements on the page,
219 // excluding the specified cell IDs (used to ignore metadata/legend boxes).
220 func computeMaxY(page *drawio.Page, excludeIDs ...string) float64 {
221 exclude := make(map[string]bool, len(excludeIDs))
222 for _, id := range excludeIDs {
223 exclude[id] = true
224 }
225
226 maxY := 0.0
227 root := page.Root()
228 if root == nil {
229 return maxY
230 }
231 for _, obj := range root.ChildElements() {
232 // Skip excluded cells.
233 if id := obj.SelectAttrValue("id", ""); exclude[id] {
234 continue
235 }
236 cell := obj.FindElement("mxCell")
237 if cell == nil {
238 if obj.Tag == "mxCell" {
239 cell = obj
240 } else {
241 continue
242 }
243 }
244 geo := cell.FindElement("mxGeometry")
245 if geo == nil {
246 continue
247 }
248 y := parseFloat(geo.SelectAttrValue("y", "0"))
249 h := parseFloat(geo.SelectAttrValue("height", "0"))
250 if bottom := y + h; bottom > maxY {
251 maxY = bottom
252 }
253 }
254 return maxY
255 }
256
257 // computeContentWidth finds the rightmost X+Width of elements on the page,
258 // excluding the specified cell IDs.
259 func computeContentWidth(page *drawio.Page, excludeIDs ...string) float64 {
260 exclude := make(map[string]bool, len(excludeIDs))
261 for _, id := range excludeIDs {
262 exclude[id] = true
263 }
264
265 maxRight := 0.0
266 root := page.Root()
267 if root == nil {
268 return maxRight
269 }
270 for _, obj := range root.ChildElements() {
271 if id := obj.SelectAttrValue("id", ""); exclude[id] {
272 continue
273 }
274 cell := obj.FindElement("mxCell")
275 if cell == nil {
276 if obj.Tag == "mxCell" {
277 cell = obj
278 } else {
279 continue
280 }
281 }
282 geo := cell.FindElement("mxGeometry")
283 if geo == nil {
284 continue
285 }
286 x := parseFloat(geo.SelectAttrValue("x", "0"))
287 w := parseFloat(geo.SelectAttrValue("width", "0"))
288 if right := x + w; right > maxRight {
289 maxRight = right
290 }
291 }
292 return maxRight
293 }
294
295 // parseFloat parses a string to float64, returning 0 on failure.
296 func parseFloat(s string) float64 {
297 f, _ := strconv.ParseFloat(s, 64)
298 return f
299 }
300
github.com/docToolchain/Bausteinsicht/internal/sync/patchops.go 81.5%
1 package sync
2
3 import (
4 "strings"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // ReversePatchOps converts the reverse (draw.io → model) changes in cs into
10 // PatchOps that can be applied to the JSONC file directly. Returns the ops
11 // and true if all changes are patchable (only element field modifications).
12 // Returns nil, false if any structural change (add/delete) or relationship
13 // change is present — the caller should fall back to full Save.
14 func ReversePatchOps(cs *ChangeSet) ([]model.PatchOp, bool) {
15 // Any relationship change or structural element change means we can't patch.
16 if len(cs.DrawioRelationshipChanges) > 0 {
17 return nil, false
18 }
19
20 var ops []model.PatchOp
21 for _, ch := range cs.DrawioElementChanges {
22 if ch.Type != Modified || ch.Field == "" {
23 return nil, false
24 }
25 path := elementFieldPath(ch.ID, ch.Field)
26 ops = append(ops, model.PatchOp{
27 Path: path,
28 Value: `"` + jsonEscapeString(ch.NewValue) + `"`,
29 })
30 }
31 return ops, true
32 }
33
34 // elementFieldPath converts a dot-separated element ID and field name into
35 // a JSON path. E.g., ("webshop.api", "technology") →
36 // ["model", "webshop", "children", "api", "technology"]
37 func elementFieldPath(id, field string) []string {
38 parts := strings.Split(id, ".")
39 path := []string{"model"}
40 for i, part := range parts {
41 path = append(path, part)
42 if i < len(parts)-1 {
43 path = append(path, "children")
44 }
45 }
46 path = append(path, field)
47 return path
48 }
49
50 // jsonEscapeString escapes special characters for JSON string values.
51 func jsonEscapeString(s string) string {
52 var b strings.Builder
53 for _, r := range s {
54 switch r {
55 case '"':
56 b.WriteString(`\"`)
57 case '\\':
58 b.WriteString(`\\`)
59 case '\n':
60 b.WriteString(`\n`)
61 case '\r':
62 b.WriteString(`\r`)
63 case '\t':
64 b.WriteString(`\t`)
65 default:
66 b.WriteRune(r)
67 }
68 }
69 return b.String()
70 }
71
github.com/docToolchain/Bausteinsicht/internal/sync/reverse.go 83.3%
1 package sync
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // maxReverseDepth limits recursion in modifyInMap/deleteFromMap to prevent stack overflow.
11 const maxReverseDepth = model.MaxElementDepth
12
13 // ReverseResult summarizes the changes applied back to the model.
14 type ReverseResult struct {
15 ElementsCreated int
16 ElementsUpdated int
17 ElementsDeleted int
18 RelationshipsCreated int
19 RelationshipsUpdated int
20 RelationshipsDeleted int
21 Warnings []string
22 }
23
24 // ApplyReverse applies draw.io-side changes back to the model.
25 func ApplyReverse(changes *ChangeSet, m *model.BausteinsichtModel) *ReverseResult {
26 result := &ReverseResult{}
27
28 // Pre-compute the flat model for accurate existence checks.
29 // m.Model is a top-level map, so a key like "parent.child" won't be found
30 // there directly. We need the flattened view to properly detect nested elements.
31 flatModel, _ := model.FlattenElements(m)
32
33 for _, ch := range changes.DrawioElementChanges {
34 applyElementChange(ch, m, flatModel, result)
35 }
36
37 // Detect direction-swap pairs (Deleted a→b + Added b→a) so we can
38 // update the relationship in-place and preserve metadata (#185).
39 swaps := detectRelSwaps(changes.DrawioRelationshipChanges)
40
41 for _, ch := range changes.DrawioRelationshipChanges {
42 if swaps[relSwapKey{ch.From, ch.To, ch.Type}] {
43 continue // handled as part of a swap pair
44 }
45 applyRelationshipChange(ch, m, flatModel, result)
46 }
47
48 // Apply swaps: update direction in-place, preserving kind/label/description.
49 for _, sw := range collectSwapPairs(changes.DrawioRelationshipChanges) {
50 applyRelSwap(sw.from, sw.to, m, result)
51 }
52
53 return result
54 }
55
56 type relSwapKey struct {
57 from, to string
58 typ ChangeType
59 }
60
61 type swapPair struct {
62 from, to string // new direction (from the Added change)
63 }
64
65 // detectRelSwaps returns a set of (from, to, type) triples that are part of
66 // a direction-swap pair. A swap is a Deleted(a→b) paired with Added(b→a).
67 func detectRelSwaps(changes []RelationshipChange) map[relSwapKey]bool {
68 deleted := make(map[[2]string]bool)
69 added := make(map[[2]string]bool)
70 for _, ch := range changes {
71 switch ch.Type {
72 case Deleted:
73 deleted[[2]string{ch.From, ch.To}] = true
74 case Added:
75 added[[2]string{ch.From, ch.To}] = true
76 }
77 }
78 result := make(map[relSwapKey]bool)
79 for pair := range deleted {
80 reverse := [2]string{pair[1], pair[0]}
81 if added[reverse] {
82 result[relSwapKey{pair[0], pair[1], Deleted}] = true
83 result[relSwapKey{pair[1], pair[0], Added}] = true
84 }
85 }
86 return result
87 }
88
89 // collectSwapPairs returns the new-direction pairs for detected swaps.
90 func collectSwapPairs(changes []RelationshipChange) []swapPair {
91 swaps := detectRelSwaps(changes)
92 var pairs []swapPair
93 for key := range swaps {
94 if key.typ == Added {
95 pairs = append(pairs, swapPair{from: key.from, to: key.to})
96 }
97 }
98 return pairs
99 }
100
101 // applyRelSwap updates a relationship's direction in-place, preserving metadata.
102 func applyRelSwap(newFrom, newTo string, m *model.BausteinsichtModel, result *ReverseResult) {
103 for i, r := range m.Relationships {
104 if r.From == newTo && r.To == newFrom {
105 m.Relationships[i].From = newFrom
106 m.Relationships[i].To = newTo
107 result.RelationshipsUpdated++
108 return
109 }
110 }
111 // Fallback: original already deleted somehow; create new.
112 m.Relationships = append(m.Relationships, model.Relationship{
113 From: newFrom,
114 To: newTo,
115 })
116 result.RelationshipsCreated++
117 }
118
119 func applyElementChange(ch ElementChange, m *model.BausteinsichtModel, flatModel map[string]*model.Element, result *ReverseResult) {
120 switch ch.Type {
121 case Modified:
122 // Reject empty title updates from draw.io (#150).
123 if ch.Field == "title" && strings.TrimSpace(ch.NewValue) == "" {
124 result.Warnings = append(result.Warnings,
125 fmt.Sprintf("Element %q: ignoring empty title from draw.io", ch.ID))
126 return
127 }
128 err := modifyElement(m, ch.ID, func(e *model.Element) {
129 switch ch.Field {
130 case "title":
131 e.Title = ch.NewValue
132 case "description":
133 e.Description = ch.NewValue
134 case "technology":
135 e.Technology = ch.NewValue
136 }
137 })
138 if err != nil {
139 result.Warnings = append(result.Warnings,
140 fmt.Sprintf("Element %q not found in model: %v", ch.ID, err))
141 return
142 }
143 result.ElementsUpdated++
144
145 case Deleted:
146 err := deleteElement(m, ch.ID)
147 if err != nil {
148 result.Warnings = append(result.Warnings,
149 fmt.Sprintf("Element %q could not be deleted: %v", ch.ID, err))
150 return
151 }
152 result.ElementsDeleted++
153 // Clean orphaned relationships referencing the deleted element (#266).
154 prefix := ch.ID + "."
155 var kept []model.Relationship
156 for _, r := range m.Relationships {
157 if r.From == ch.ID || r.To == ch.ID ||
158 strings.HasPrefix(r.From, prefix) || strings.HasPrefix(r.To, prefix) {
159 result.RelationshipsDeleted++
160 continue
161 }
162 kept = append(kept, r)
163 }
164 m.Relationships = kept
165 // Clean stale references from view include/exclude lists.
166 for viewID, v := range m.Views {
167 v.Include = removeFromSlice(v.Include, ch.ID)
168 v.Exclude = removeFromSlice(v.Exclude, ch.ID)
169 m.Views[viewID] = v
170 }
171 result.Warnings = append(result.Warnings,
172 fmt.Sprintf("Element %q was deleted in draw.io and removed from model", ch.ID))
173
174 case Added:
175 if m.Model == nil {
176 m.Model = make(map[string]model.Element)
177 }
178 // Use the pre-computed flat model to check existence across the full
179 // hierarchy. A naive m.Model[ch.ID] check misses nested elements whose
180 // IDs are dot-paths (e.g. "parent.child"), causing them to be
181 // re-created as spurious top-level entries with empty titles (#307).
182 if _, exists := flatModel[ch.ID]; exists {
183 result.Warnings = append(result.Warnings,
184 fmt.Sprintf("New element %q from draw.io skipped: ID already exists in model.", ch.ID))
185 return
186 }
187 kind := firstSpecKind(m)
188 m.Model[ch.ID] = model.Element{
189 Kind: kind,
190 Title: ch.NewValue,
191 }
192 result.ElementsCreated++
193 result.Warnings = append(result.Warnings,
194 fmt.Sprintf("New element %q added from draw.io (kind=%q) — review and assign a meaningful ID and kind.", ch.ID, kind))
195 }
196 }
197
198 func applyRelationshipChange(ch RelationshipChange, m *model.BausteinsichtModel, flatModel map[string]*model.Element, result *ReverseResult) {
199 switch ch.Type {
200 case Modified:
201 updated := false
202 if ch.Index >= 0 && ch.Index < len(m.Relationships) {
203 r := m.Relationships[ch.Index]
204 if r.From == ch.From && r.To == ch.To {
205 if ch.Field == "label" {
206 m.Relationships[ch.Index].Label = ch.NewValue
207 }
208 updated = true
209 }
210 }
211 // Fallback: search by from/to if index does not match.
212 if !updated {
213 for i, r := range m.Relationships {
214 if r.From == ch.From && r.To == ch.To {
215 if ch.Field == "label" {
216 m.Relationships[i].Label = ch.NewValue
217 }
218 updated = true
219 break
220 }
221 }
222 }
223 if updated {
224 result.RelationshipsUpdated++
225 } else {
226 result.Warnings = append(result.Warnings,
227 fmt.Sprintf("Relationship %q->%q not found in model", ch.From, ch.To))
228 }
229
230 case Deleted:
231 before := len(m.Relationships)
232 if ch.Index >= 0 && ch.Index < len(m.Relationships) {
233 r := m.Relationships[ch.Index]
234 if r.From == ch.From && r.To == ch.To {
235 m.Relationships = append(m.Relationships[:ch.Index], m.Relationships[ch.Index+1:]...)
236 } else {
237 // Fallback: filter by from/to.
238 m.Relationships = filterRelationships(m.Relationships, ch.From, ch.To)
239 }
240 } else {
241 m.Relationships = filterRelationships(m.Relationships, ch.From, ch.To)
242 }
243 if len(m.Relationships) < before {
244 result.RelationshipsDeleted++
245 }
246
247 case Added:
248 // Validate that both endpoints exist in the model before adding (#329).
249 // Prevents stale relationships from old draw.io files being imported after model replacement.
250 if _, fromExists := flatModel[ch.From]; !fromExists {
251 result.Warnings = append(result.Warnings,
252 fmt.Sprintf("Relationship %q->%q rejected: From element %q does not exist in model", ch.From, ch.To, ch.From))
253 return
254 }
255 if _, toExists := flatModel[ch.To]; !toExists {
256 result.Warnings = append(result.Warnings,
257 fmt.Sprintf("Relationship %q->%q rejected: To element %q does not exist in model", ch.From, ch.To, ch.To))
258 return
259 }
260 m.Relationships = append(m.Relationships, model.Relationship{
261 From: ch.From,
262 To: ch.To,
263 Label: ch.NewValue,
264 })
265 result.RelationshipsCreated++
266 }
267 }
268
269 // filterRelationships returns all relationships except those matching from/to.
270 func filterRelationships(rels []model.Relationship, from, to string) []model.Relationship {
271 result := make([]model.Relationship, 0, len(rels))
272 for _, r := range rels {
273 if r.From != from || r.To != to {
274 result = append(result, r)
275 }
276 }
277 return result
278 }
279
280 // modifyElement finds an element by dot-notation ID and applies fn to it.
281 func modifyElement(m *model.BausteinsichtModel, id string, fn func(*model.Element)) error {
282 parts := strings.Split(id, ".")
283 return modifyInMap(m.Model, parts, id, fn)
284 }
285
286 // modifyInMap recursively walks the map to find and modify the target element.
287 func modifyInMap(elems map[string]model.Element, parts []string, fullID string, fn func(*model.Element)) error {
288 if len(parts) == 0 {
289 return fmt.Errorf("empty path")
290 }
291 if len(parts) > maxReverseDepth {
292 return fmt.Errorf("element path %q exceeds maximum depth of %d", fullID, maxReverseDepth)
293 }
294 key := parts[0]
295 elem, ok := elems[key]
296 if !ok {
297 return fmt.Errorf("element %q not found", fullID)
298 }
299 if len(parts) == 1 {
300 fn(&elem)
301 elems[key] = elem
302 return nil
303 }
304 if elem.Children == nil {
305 return fmt.Errorf("element %q not found: no children at this level", fullID)
306 }
307 if err := modifyInMap(elem.Children, parts[1:], fullID, fn); err != nil {
308 return err
309 }
310 elems[key] = elem
311 return nil
312 }
313
314 // deleteElement removes an element by dot-notation ID from the model hierarchy.
315 func deleteElement(m *model.BausteinsichtModel, id string) error {
316 parts := strings.Split(id, ".")
317 return deleteFromMap(m.Model, parts, id)
318 }
319
320 // deleteFromMap recursively walks to find the parent map and deletes the element.
321 func deleteFromMap(elems map[string]model.Element, parts []string, fullID string) error {
322 if len(parts) == 0 {
323 return fmt.Errorf("empty path")
324 }
325 if len(parts) > maxReverseDepth {
326 return fmt.Errorf("element path %q exceeds maximum depth of %d", fullID, maxReverseDepth)
327 }
328 key := parts[0]
329 if len(parts) == 1 {
330 if _, ok := elems[key]; !ok {
331 return fmt.Errorf("element %q not found", fullID)
332 }
333 delete(elems, key)
334 return nil
335 }
336 elem, ok := elems[key]
337 if !ok {
338 return fmt.Errorf("element %q not found", fullID)
339 }
340 if elem.Children == nil {
341 return fmt.Errorf("element %q not found: no children at this level", fullID)
342 }
343 if err := deleteFromMap(elem.Children, parts[1:], fullID); err != nil {
344 return err
345 }
346 elems[key] = elem
347 return nil
348 }
349
350 // firstSpecKind returns the first element kind defined in the specification,
351 // sorted alphabetically for determinism. Returns "" if no kinds are defined.
352 func firstSpecKind(m *model.BausteinsichtModel) string {
353 if len(m.Specification.Elements) == 0 {
354 return ""
355 }
356 var best string
357 for k := range m.Specification.Elements {
358 if best == "" || k < best {
359 best = k
360 }
361 }
362 return best
363 }
364
365 // removeFromSlice returns a new slice with all occurrences of val removed.
366 func removeFromSlice(s []string, val string) []string {
367 result := make([]string, 0, len(s))
368 for _, v := range s {
369 if v != val {
370 result = append(result, v)
371 }
372 }
373 if len(result) == 0 {
374 return nil
375 }
376 return result
377 }
378
github.com/docToolchain/Bausteinsicht/internal/sync/state.go 71.8%
1 // Package sync handles bidirectional synchronization between the model and draw.io files.
2 package sync
3
4 import (
5 "crypto/sha256"
6 "encoding/json"
7 "fmt"
8 "os"
9 "path/filepath"
10 "time"
11
12 "github.com/docToolchain/Bausteinsicht/internal/drawio"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 )
15
16 // SyncState stores the state after each successful sync.
17 type SyncState struct {
18 Checksum string `json:"checksum,omitempty"`
19 Timestamp string `json:"timestamp"`
20 ModelHash string `json:"model_hash"`
21 DrawioHash string `json:"drawio_hash"`
22 Elements map[string]ElementState `json:"elements"`
23 Relationships []RelationshipState `json:"relationships"`
24 RenderedElements map[string]bool `json:"rendered_elements,omitempty"`
25 }
26
27 // ElementState captures an element's synced values.
28 type ElementState struct {
29 Title string `json:"title"`
30 Description string `json:"description,omitempty"`
31 Technology string `json:"technology,omitempty"`
32 Kind string `json:"kind"`
33 }
34
35 // RelationshipState captures a relationship's synced values.
36 type RelationshipState struct {
37 From string `json:"from"`
38 To string `json:"to"`
39 Index int `json:"index"`
40 Label string `json:"label,omitempty"`
41 Kind string `json:"kind,omitempty"`
42 }
43
44 // LoadState reads a SyncState from the given path.
45 // If the file does not exist, an empty SyncState is returned (first-sync scenario).
46 func LoadState(path string) (*SyncState, error) {
47 data, err := os.ReadFile(path) // #nosec G304 -- path derived from model location
48 if err != nil {
49 if os.IsNotExist(err) {
50 return &SyncState{
51 Elements: make(map[string]ElementState),
52 Relationships: []RelationshipState{},
53 }, nil
54 }
55 return nil, fmt.Errorf("LoadState %q: %w", path, err)
56 }
57
58 // Treat a zero-byte file as empty/missing state (e.g. truncated write).
59 if len(data) == 0 {
60 return &SyncState{
61 Elements: make(map[string]ElementState),
62 Relationships: []RelationshipState{},
63 }, nil
64 }
65
66 var state SyncState
67 if err := json.Unmarshal(data, &state); err != nil {
68 return nil, fmt.Errorf("LoadState %q: %w", path, err)
69 }
70
71 // Verify integrity checksum if present (backward compat: old files without checksum skip validation).
72 if state.Checksum != "" {
73 savedChecksum := state.Checksum
74 state.Checksum = ""
75 canonical, err := json.Marshal(&state)
76 if err != nil {
77 return nil, fmt.Errorf("LoadState %q: checksum verification marshal: %w", path, err)
78 }
79 sum := sha256.Sum256(canonical)
80 computed := fmt.Sprintf("sha256:%x", sum)
81 if computed != savedChecksum {
82 return nil, fmt.Errorf("LoadState %q: sync state corrupted (checksum mismatch); delete .bausteinsicht-sync and re-run sync", path)
83 }
84 }
85
86 if state.Elements == nil {
87 state.Elements = make(map[string]ElementState)
88 }
89 if state.Relationships == nil {
90 state.Relationships = []RelationshipState{}
91 }
92 return &state, nil
93 }
94
95 // SaveState atomically writes state to path using a temp file + rename.
96 func SaveState(path string, state *SyncState) error {
97 // Compute integrity checksum: marshal without checksum → hash → set checksum → marshal with checksum.
98 state.Checksum = ""
99 canonical, err := json.Marshal(state)
100 if err != nil {
101 return fmt.Errorf("SaveState checksum marshal: %w", err)
102 }
103 sum := sha256.Sum256(canonical)
104 state.Checksum = fmt.Sprintf("sha256:%x", sum)
105
106 data, err := json.MarshalIndent(state, "", " ")
107 if err != nil {
108 return fmt.Errorf("SaveState marshal: %w", err)
109 }
110
111 dir := filepath.Dir(path)
112 tmp, err := os.CreateTemp(dir, ".bausteinsicht-sync-tmp-*")
113 if err != nil {
114 return fmt.Errorf("SaveState create temp: %w", err)
115 }
116 tmpName := tmp.Name()
117
118 if _, err := tmp.Write(data); err != nil {
119 _ = tmp.Close()
120 _ = os.Remove(tmpName)
121 return fmt.Errorf("SaveState write: %w", err)
122 }
123 if err := tmp.Close(); err != nil {
124 _ = os.Remove(tmpName)
125 return fmt.Errorf("SaveState close: %w", err)
126 }
127 if err := os.Rename(tmpName, path); err != nil {
128 _ = os.Remove(tmpName)
129 return fmt.Errorf("SaveState rename: %w", err)
130 }
131 return nil
132 }
133
134 // ComputeHash reads the file at path and returns a "sha256:<hex>" fingerprint.
135 func ComputeHash(path string) (string, error) {
136 data, err := os.ReadFile(path) // #nosec G304 -- path derived from model location
137 if err != nil {
138 return "", fmt.Errorf("ComputeHash %q: %w", path, err)
139 }
140 sum := sha256.Sum256(data)
141 return fmt.Sprintf("sha256:%x", sum), nil
142 }
143
144 // BuildState creates a SyncState snapshot from the current model and draw.io document.
145 func BuildState(m *model.BausteinsichtModel, doc *drawio.Document, modelPath, drawioPath string) (*SyncState, error) {
146 modelHash, err := ComputeHash(modelPath)
147 if err != nil {
148 return nil, fmt.Errorf("BuildState model hash: %w", err)
149 }
150
151 drawioHash, err := ComputeHash(drawioPath)
152 if err != nil {
153 return nil, fmt.Errorf("BuildState drawio hash: %w", err)
154 }
155
156 flat, err := model.FlattenElements(m)
157 if err != nil {
158 return nil, fmt.Errorf("BuildState flatten: %w", err)
159 }
160 elements := make(map[string]ElementState, len(flat))
161 for id, elem := range flat {
162 elements[id] = ElementState{
163 Title: elem.Title,
164 Description: elem.Description,
165 Technology: elem.Technology,
166 Kind: elem.Kind,
167 }
168 }
169
170 rels := make([]RelationshipState, 0, len(m.Relationships))
171 for i, r := range m.Relationships {
172 rels = append(rels, RelationshipState{
173 From: r.From,
174 To: r.To,
175 Index: i,
176 Label: r.Label,
177 Kind: r.Kind,
178 })
179 }
180
181 // Record which elements are actually present on draw.io pages.
182 // This allows deletion detection to distinguish "user deleted from draw.io"
183 // from "element was never rendered because views didn't include it" (#240).
184 rendered := make(map[string]bool)
185 if doc != nil {
186 for _, page := range doc.Pages() {
187 for _, obj := range page.FindAllElements() {
188 id := obj.SelectAttrValue("bausteinsicht_id", "")
189 if id != "" {
190 rendered[id] = true
191 }
192 }
193 }
194 }
195
196 return &SyncState{
197 Timestamp: time.Now().UTC().Format(time.RFC3339),
198 ModelHash: modelHash,
199 DrawioHash: drawioHash,
200 Elements: elements,
201 Relationships: rels,
202 RenderedElements: rendered,
203 }, nil
204 }
205
github.com/docToolchain/Bausteinsicht/internal/table/table.go 61.2%
1 package table
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // Format represents the output format for table export.
12 type Format int
13
14 const (
15 AsciiDoc Format = iota
16 Markdown
17 )
18
19 // Row represents a single element row in the table export.
20 type Row struct {
21 ID string `json:"id"`
22 Title string `json:"title"`
23 Kind string `json:"kind"`
24 Technology string `json:"technology,omitempty"`
25 Description string `json:"description,omitempty"`
26 }
27
28 // FormatView renders a single view's elements as a table.
29 func FormatView(m *model.BausteinsichtModel, viewKey string, f Format) (string, error) {
30 view, ok := m.Views[viewKey]
31 if !ok {
32 return "", fmt.Errorf("view %q not found", viewKey)
33 }
34
35 rows, err := resolveRows(m, &view)
36 if err != nil {
37 return "", err
38 }
39
40 var b strings.Builder
41 writeTitle(&b, view.Title, f)
42 writeTable(&b, rows, f)
43 return b.String(), nil
44 }
45
46 // FormatAllViews renders all views as tables in a single document.
47 func FormatAllViews(m *model.BausteinsichtModel, f Format) (string, error) {
48 keys := sortedViewKeys(m)
49 var b strings.Builder
50 for i, key := range keys {
51 if i > 0 {
52 b.WriteString("\n")
53 }
54 view, ok := m.Views[key]
55 if !ok {
56 continue
57 }
58 rows, err := resolveRows(m, &view)
59 if err != nil {
60 return "", err
61 }
62 writeTitle(&b, view.Title, f)
63 writeTable(&b, rows, f)
64 }
65 return b.String(), nil
66 }
67
68 // FormatCombined renders all elements across all views (deduplicated) as a single table.
69 func FormatCombined(m *model.BausteinsichtModel, f Format) (string, error) {
70 seen := make(map[string]bool)
71 var rows []Row
72
73 flat, _ := model.FlattenElements(m)
74 keys := sortedViewKeys(m)
75
76 for _, key := range keys {
77 view, ok := m.Views[key]
78 if !ok {
79 continue
80 }
81 v := view
82 resolved, err := model.ResolveView(m, &v)
83 if err != nil {
84 continue
85 }
86 for _, id := range resolved {
87 if seen[id] {
88 continue
89 }
90 seen[id] = true
91 elem := flat[id]
92 if elem == nil {
93 continue
94 }
95 rows = append(rows, Row{
96 ID: id,
97 Title: elem.Title,
98 Kind: elem.Kind,
99 Technology: elem.Technology,
100 Description: elem.Description,
101 })
102 }
103 }
104
105 sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
106
107 var b strings.Builder
108 writeTitle(&b, "All Elements", f)
109 writeTable(&b, rows, f)
110 return b.String(), nil
111 }
112
113 func resolveRows(m *model.BausteinsichtModel, view *model.View) ([]Row, error) {
114 resolved, err := model.ResolveView(m, view)
115 if err != nil {
116 return nil, err
117 }
118
119 flat, _ := model.FlattenElements(m)
120 sort.Strings(resolved)
121
122 var rows []Row
123 for _, id := range resolved {
124 elem := flat[id]
125 if elem == nil {
126 continue
127 }
128 rows = append(rows, Row{
129 ID: id,
130 Title: elem.Title,
131 Kind: elem.Kind,
132 Technology: elem.Technology,
133 Description: elem.Description,
134 })
135 }
136 return rows, nil
137 }
138
139 func writeTitle(b *strings.Builder, title string, f Format) {
140 switch f {
141 case AsciiDoc:
142 fmt.Fprintf(b, "=== %s\n\n", title)
143 case Markdown:
144 fmt.Fprintf(b, "### %s\n\n", title)
145 }
146 }
147
148 func writeTable(b *strings.Builder, rows []Row, f Format) {
149 switch f {
150 case AsciiDoc:
151 writeAsciiDocTable(b, rows)
152 case Markdown:
153 writeMarkdownTable(b, rows)
154 }
155 }
156
157 func writeAsciiDocTable(b *strings.Builder, rows []Row) {
158 b.WriteString("[cols=\"2,1,1,3\"]\n|===\n")
159 b.WriteString("| Element | Kind | Technology | Description\n\n")
160 for _, r := range rows {
161 fmt.Fprintf(b, "| %s\n| %s\n| %s\n| %s\n\n", r.Title, r.Kind, r.Technology, r.Description)
162 }
163 b.WriteString("|===\n")
164 }
165
166 func writeMarkdownTable(b *strings.Builder, rows []Row) {
167 b.WriteString("| Element | Kind | Technology | Description |\n")
168 b.WriteString("|---------|------|------------|-------------|\n")
169 for _, r := range rows {
170 fmt.Fprintf(b, "| %s | %s | %s | %s |\n", r.Title, r.Kind, r.Technology, r.Description)
171 }
172 }
173
174 // CollectRows returns the row data for JSON export.
175 // If viewKey is set, only that view's rows are returned.
176 // If combined is true, all views are merged (deduplicated).
177 // Otherwise, all views' rows are returned.
178 func CollectRows(m *model.BausteinsichtModel, viewKey string, combined bool) ([]Row, error) {
179 switch {
180 case combined:
181 return collectCombinedRows(m)
182 case viewKey != "":
183 view, ok := m.Views[viewKey]
184 if !ok {
185 return nil, fmt.Errorf("view %q not found", viewKey)
186 }
187 return resolveRows(m, &view)
188 default:
189 return collectAllRows(m)
190 }
191 }
192
193 func collectCombinedRows(m *model.BausteinsichtModel) ([]Row, error) {
194 seen := make(map[string]bool)
195 var rows []Row
196 flat, _ := model.FlattenElements(m)
197 for _, key := range sortedViewKeys(m) {
198 view, ok := m.Views[key]
199 if !ok {
200 continue
201 }
202 v := view
203 resolved, err := model.ResolveView(m, &v)
204 if err != nil {
205 continue
206 }
207 for _, id := range resolved {
208 if seen[id] {
209 continue
210 }
211 seen[id] = true
212 elem := flat[id]
213 if elem == nil {
214 continue
215 }
216 rows = append(rows, Row{
217 ID: id, Title: elem.Title, Kind: elem.Kind,
218 Technology: elem.Technology, Description: elem.Description,
219 })
220 }
221 }
222 sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
223 return rows, nil
224 }
225
226 func collectAllRows(m *model.BausteinsichtModel) ([]Row, error) {
227 var rows []Row
228 for _, key := range sortedViewKeys(m) {
229 view, ok := m.Views[key]
230 if !ok {
231 continue
232 }
233 viewRows, err := resolveRows(m, &view)
234 if err != nil {
235 return nil, err
236 }
237 rows = append(rows, viewRows...)
238 }
239 return rows, nil
240 }
241
242 func sortedViewKeys(m *model.BausteinsichtModel) []string {
243 keys := make([]string, 0, len(m.Views))
244 for k := range m.Views {
245 keys = append(keys, k)
246 }
247 sort.Strings(keys)
248 return keys
249 }
250
github.com/docToolchain/Bausteinsicht/internal/template/generator.go 96.1%
1 package template
2
3 import (
4 "bytes"
5 "fmt"
6 "sort"
7 "strings"
8
9 etree "github.com/beevik/etree"
10 "github.com/docToolchain/Bausteinsicht/internal/model"
11 )
12
13 // Generator creates a draw.io template from an element specification.
14 type Generator struct {
15 spec model.Specification
16 style string
17 nextID int
18 }
19
20 // NewGenerator creates a new template generator.
21 func NewGenerator(spec model.Specification, style string) *Generator {
22 if style == "" {
23 style = DefaultStyle
24 }
25 return &Generator{
26 spec: spec,
27 style: style,
28 nextID: 2,
29 }
30 }
31
32 // Generate produces the draw.io template XML as a complete mxfile.
33 func (g *Generator) Generate() string {
34 doc := etree.NewDocument()
35 doc.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
36
37 mxfile := doc.CreateElement("mxfile")
38 mxfile.CreateAttr("host", "app.diagrams.net")
39 mxfile.CreateAttr("modified", "2026-01-01T00:00:00.000Z")
40 mxfile.CreateAttr("agent", "Bausteinsicht")
41 mxfile.CreateAttr("version", "1.0")
42 mxfile.CreateAttr("type", "device")
43
44 diagram := mxfile.CreateElement("diagram")
45 diagram.CreateAttr("id", "1")
46 diagram.CreateAttr("name", "Template")
47
48 root := diagram.CreateElement("mxGraphModel")
49 root.CreateAttr("dx", "0")
50 root.CreateAttr("dy", "0")
51 root.CreateAttr("grid", "1")
52 root.CreateAttr("gridSize", "10")
53 root.CreateAttr("guides", "1")
54 root.CreateAttr("tooltips", "1")
55 root.CreateAttr("connect", "1")
56 root.CreateAttr("arrows", "1")
57 root.CreateAttr("fold", "1")
58 root.CreateAttr("page", "1")
59 root.CreateAttr("pageScale", "1")
60 root.CreateAttr("pageWidth", "827")
61 root.CreateAttr("pageHeight", "1169")
62 root.CreateAttr("background", "#ffffff")
63 root.CreateAttr("math", "0")
64 root.CreateAttr("shadow", "0")
65
66 rootElem := root.CreateElement("root")
67 cell0 := rootElem.CreateElement("mxCell")
68 cell0.CreateAttr("id", "0")
69 cell1 := rootElem.CreateElement("mxCell")
70 cell1.CreateAttr("id", "1")
71 cell1.CreateAttr("parent", "0")
72
73 g.nextID = 2
74
75 // Collect kinds in sorted order
76 var kinds []string
77 for kind := range g.spec.Elements {
78 kinds = append(kinds, kind)
79 }
80
81 // Sort for consistent output
82 sort.Strings(kinds)
83
84 // Layout elements in grid (4 columns)
85 layout := GridLayout(kinds, 4)
86
87 for _, elem := range layout {
88 g.addElement(rootElem, elem.Kind, elem.Position.X, elem.Position.Y)
89 }
90
91 doc.Indent(2)
92 var buf bytes.Buffer
93 if _, err := doc.WriteTo(&buf); err != nil {
94 return ""
95 }
96 return buf.String()
97 }
98
99 func (g *Generator) addElement(parent *etree.Element, kind string, x, y int) {
100 cfg := DefaultShapeConfig(kind)
101 colors := ColorForKind(g.style, kind)
102 elementSpec := g.spec.Elements[kind]
103
104 // Create label with kind name and type
105 kindTitle := strings.ToUpper(kind[:1]) + kind[1:]
106 label := fmt.Sprintf("<b>%s</b><br/>[%s]", kindTitle, kind)
107
108 // Build style string
109 style := g.buildStyle(cfg, colors, elementSpec)
110
111 cell := parent.CreateElement("mxCell")
112 cell.CreateAttr("id", fmt.Sprintf("%d", g.nextID))
113 g.nextID++
114 cell.CreateAttr("value", label) // Don't escape: draw.io uses html=1 and requires raw HTML markup for rich text
115 cell.CreateAttr("style", style+";html=1") // Enable HTML rendering in draw.io
116 cell.CreateAttr("vertex", "1")
117 cell.CreateAttr("parent", "1")
118
119 geometry := cell.CreateElement("mxGeometry")
120 geometry.CreateAttr("x", fmt.Sprintf("%d", x))
121 geometry.CreateAttr("y", fmt.Sprintf("%d", y))
122 geometry.CreateAttr("width", fmt.Sprintf("%d", cfg.Width))
123 geometry.CreateAttr("height", fmt.Sprintf("%d", cfg.Height))
124 geometry.CreateAttr("as", "geometry")
125 }
126
127 func (g *Generator) buildStyle(cfg ShapeConfig, colors ColorStyle, _ model.ElementKind) string {
128 parts := []string{
129 fmt.Sprintf("fillColor=%s", colors.Fill),
130 fmt.Sprintf("strokeColor=%s", colors.Stroke),
131 "fontColor=#000000",
132 "fontSize=12",
133 }
134
135 // Add shape if specified
136 if cfg.Shape != "" {
137 if strings.HasPrefix(cfg.Shape, "shape=") {
138 parts = append(parts, cfg.Shape)
139 } else if !strings.Contains(cfg.Shape, "=") {
140 parts = append(parts, fmt.Sprintf("shape=%s", cfg.Shape))
141 } else {
142 parts = append(parts, cfg.Shape)
143 }
144 }
145
146 return strings.Join(parts, ";")
147 }
148
github.com/docToolchain/Bausteinsicht/internal/template/layout.go 94.7%
1 package template
2
3 // Position represents the X, Y coordinates of an element.
4 type Position struct {
5 X int
6 Y int
7 }
8
9 // Element holds a kind and its position.
10 type Element struct {
11 Kind string
12 Position Position
13 }
14
15 // GridLayout arranges elements in a grid.
16 func GridLayout(kinds []string, cols int) []Element {
17 if cols <= 0 {
18 cols = 4
19 }
20
21 var elements []Element
22 x, y := 40, 40
23 colCount := 0
24 maxHeight := 0
25
26 for _, kind := range kinds {
27 cfg := DefaultShapeConfig(kind)
28 elements = append(elements, Element{
29 Kind: kind,
30 Position: Position{X: x, Y: y},
31 })
32
33 if cfg.Height > maxHeight {
34 maxHeight = cfg.Height
35 }
36
37 colCount++
38 if colCount >= cols {
39 x = 40
40 y += maxHeight + 40
41 colCount = 0
42 maxHeight = 0
43 } else {
44 x += cfg.Width + 40
45 }
46 }
47
48 return elements
49 }
50
github.com/docToolchain/Bausteinsicht/internal/template/shapes.go 100.0%
1 package template
2
3 // ShapeConfig defines the draw.io shape and dimensions for a kind.
4 type ShapeConfig struct {
5 Shape string
6 Width int
7 Height int
8 }
9
10 // KindShapes maps element kinds to their shape configurations.
11 var KindShapes = map[string]ShapeConfig{
12 "person": {Shape: "mxgraph.archimate3.actor", Width: 60, Height: 80},
13 "actor": {Shape: "mxgraph.archimate3.actor", Width: 60, Height: 80},
14 "system": {Shape: "rounded=1", Width: 160, Height: 60},
15 "service": {Shape: "rounded=1", Width: 120, Height: 60},
16 "container": {Shape: "rounded=1;container=1", Width: 200, Height: 120},
17 "database": {Shape: "mxgraph.flowchart.database", Width: 60, Height: 80},
18 "datastore": {Shape: "mxgraph.flowchart.database", Width: 60, Height: 80},
19 "cache": {Shape: "mxgraph.flowchart.stored_data", Width: 80, Height: 60},
20 "queue": {Shape: "mxgraph.flowchart.process", Width: 120, Height: 60},
21 "filestore": {Shape: "mxgraph.flowchart.stored_data", Width: 80, Height: 60},
22 "component": {Shape: "rounded=1", Width: 120, Height: 60},
23 "frontend": {Shape: "rounded=1", Width: 120, Height: 60},
24 "mobile": {Shape: "mxgraph.iphone.phone3", Width: 60, Height: 100},
25 "ui": {Shape: "rounded=1", Width: 120, Height: 60},
26 "external_system": {Shape: "dashed=1;dashPattern=5 5;rounded=1", Width: 160, Height: 60},
27 }
28
29 // ColorStyle defines fill and stroke colors for a style preset.
30 type ColorStyle struct {
31 Fill string
32 Stroke string
33 }
34
35 // StylePresets defines the visual presets for different kinds.
36 var StylePresets = map[string]map[string]ColorStyle{
37 "default": {
38 "person": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
39 "actor": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
40 "system": {Fill: "#d5e8d4", Stroke: "#82b366"},
41 "service": {Fill: "#d5e8d4", Stroke: "#82b366"},
42 "container": {Fill: "#d5e8d4", Stroke: "#82b366"},
43 "database": {Fill: "#fff2cc", Stroke: "#d6b656"},
44 "datastore": {Fill: "#fff2cc", Stroke: "#d6b656"},
45 "cache": {Fill: "#fff2cc", Stroke: "#d6b656"},
46 "queue": {Fill: "#f8cecc", Stroke: "#b85450"},
47 "filestore": {Fill: "#fff2cc", Stroke: "#d6b656"},
48 "component": {Fill: "#d5e8d4", Stroke: "#82b366"},
49 "frontend": {Fill: "#e1d5e7", Stroke: "#9673a6"},
50 "mobile": {Fill: "#e1d5e7", Stroke: "#9673a6"},
51 "ui": {Fill: "#e1d5e7", Stroke: "#9673a6"},
52 "external_system": {Fill: "#f5f5f5", Stroke: "#999999"},
53 },
54 "c4": {
55 "person": {Fill: "#08427b", Stroke: "#08427b"},
56 "actor": {Fill: "#08427b", Stroke: "#08427b"},
57 "system": {Fill: "#1168bd", Stroke: "#0b4884"},
58 "service": {Fill: "#1168bd", Stroke: "#0b4884"},
59 "container": {Fill: "#1168bd", Stroke: "#0b4884"},
60 "database": {Fill: "#438dd5", Stroke: "#3c7fc0"},
61 "datastore": {Fill: "#438dd5", Stroke: "#3c7fc0"},
62 "cache": {Fill: "#438dd5", Stroke: "#3c7fc0"},
63 "queue": {Fill: "#999999", Stroke: "#666666"},
64 "filestore": {Fill: "#438dd5", Stroke: "#3c7fc0"},
65 "component": {Fill: "#438dd5", Stroke: "#3c7fc0"},
66 "frontend": {Fill: "#1168bd", Stroke: "#0b4884"},
67 "mobile": {Fill: "#1168bd", Stroke: "#0b4884"},
68 "ui": {Fill: "#1168bd", Stroke: "#0b4884"},
69 "external_system": {Fill: "#999999", Stroke: "#666666"},
70 },
71 "minimal": {
72 "person": {Fill: "#ffffff", Stroke: "#999999"},
73 "actor": {Fill: "#ffffff", Stroke: "#999999"},
74 "system": {Fill: "#ffffff", Stroke: "#999999"},
75 "service": {Fill: "#ffffff", Stroke: "#999999"},
76 "container": {Fill: "#ffffff", Stroke: "#999999"},
77 "database": {Fill: "#ffffff", Stroke: "#999999"},
78 "datastore": {Fill: "#ffffff", Stroke: "#999999"},
79 "cache": {Fill: "#ffffff", Stroke: "#999999"},
80 "queue": {Fill: "#ffffff", Stroke: "#999999"},
81 "filestore": {Fill: "#ffffff", Stroke: "#999999"},
82 "component": {Fill: "#ffffff", Stroke: "#999999"},
83 "frontend": {Fill: "#ffffff", Stroke: "#999999"},
84 "mobile": {Fill: "#ffffff", Stroke: "#999999"},
85 "ui": {Fill: "#ffffff", Stroke: "#999999"},
86 "external_system": {Fill: "#ffffff", Stroke: "#999999"},
87 },
88 "dark": {
89 "person": {Fill: "#ffb74d", Stroke: "#ff8a00"},
90 "actor": {Fill: "#ffb74d", Stroke: "#ff8a00"},
91 "system": {Fill: "#4dd0e1", Stroke: "#00acc1"},
92 "service": {Fill: "#4dd0e1", Stroke: "#00acc1"},
93 "container": {Fill: "#4dd0e1", Stroke: "#00acc1"},
94 "database": {Fill: "#81c784", Stroke: "#66bb6a"},
95 "datastore": {Fill: "#81c784", Stroke: "#66bb6a"},
96 "cache": {Fill: "#81c784", Stroke: "#66bb6a"},
97 "queue": {Fill: "#e57373", Stroke: "#ef5350"},
98 "filestore": {Fill: "#81c784", Stroke: "#66bb6a"},
99 "component": {Fill: "#4dd0e1", Stroke: "#00acc1"},
100 "frontend": {Fill: "#ba68c8", Stroke: "#ab47bc"},
101 "mobile": {Fill: "#ba68c8", Stroke: "#ab47bc"},
102 "ui": {Fill: "#ba68c8", Stroke: "#ab47bc"},
103 "external_system": {Fill: "#bbdefb", Stroke: "#64b5f6"},
104 },
105 }
106
107 // DefaultStyle is the default visual preset.
108 const DefaultStyle = "default"
109
110 // ColorForKind returns the color style for a kind in a given preset.
111 // Falls back to default preset if not found.
112 func ColorForKind(preset, kind string) ColorStyle {
113 if colors, ok := StylePresets[preset]; ok {
114 if color, ok := colors[kind]; ok {
115 return color
116 }
117 }
118 // Fall back to default preset
119 if colors, ok := StylePresets[DefaultStyle]; ok {
120 if color, ok := colors[kind]; ok {
121 return color
122 }
123 }
124 // Ultimate fallback
125 return ColorStyle{Fill: "#d5e8d4", Stroke: "#82b366"}
126 }
127
128 // DefaultShapeConfig returns the shape config for a kind.
129 // Falls back to rounded rectangle if not found.
130 func DefaultShapeConfig(kind string) ShapeConfig {
131 if cfg, ok := KindShapes[kind]; ok {
132 return cfg
133 }
134 return ShapeConfig{Shape: "rounded=1", Width: 120, Height: 60}
135 }
136
github.com/docToolchain/Bausteinsicht/internal/watcher/watcher.go 87.9%
1 // Package watcher monitors file system changes for watch mode operation.
2 package watcher
3
4 import (
5 "os"
6 "sync"
7 "time"
8
9 "github.com/fsnotify/fsnotify"
10 )
11
12 // DefaultDebounce is the default debounce duration.
13 const DefaultDebounce = 300 * time.Millisecond
14
15 // OnChange is the callback type invoked when a watched file changes.
16 type OnChange func(changedFile string)
17
18 // Watcher monitors specific files for write changes with debounce support.
19 type Watcher struct {
20 fsWatcher *fsnotify.Watcher
21 debounce time.Duration
22 onChange OnChange
23 done chan struct{}
24 syncing bool
25 mu sync.Mutex
26 syncMu sync.Mutex // serializes sync execution to prevent concurrent onChange calls
27 }
28
29 // New creates a Watcher that monitors the given files. The onChange callback
30 // is invoked after the debounce duration elapses following a write event.
31 func New(files []string, debounce time.Duration, onChange OnChange) (*Watcher, error) {
32 fsw, err := fsnotify.NewWatcher()
33 if err != nil {
34 return nil, err
35 }
36
37 for _, f := range files {
38 if err := fsw.Add(f); err != nil {
39 _ = fsw.Close()
40 return nil, err
41 }
42 }
43
44 return &Watcher{
45 fsWatcher: fsw,
46 debounce: debounce,
47 onChange: onChange,
48 done: make(chan struct{}),
49 }, nil
50 }
51
52 // Start begins listening for file change events in a background goroutine.
53 func (w *Watcher) Start() error {
54 go w.loop()
55 return nil
56 }
57
58 // Stop signals the watcher to shut down and closes the underlying fsnotify watcher.
59 func (w *Watcher) Stop() {
60 close(w.done)
61 _ = w.fsWatcher.Close()
62 }
63
64 // SetSyncing sets the syncing flag. While true, file change events are ignored
65 // to prevent re-triggering from the watcher's own writes.
66 func (w *Watcher) SetSyncing(v bool) {
67 w.mu.Lock()
68 defer w.mu.Unlock()
69 w.syncing = v
70 }
71
72 func (w *Watcher) isSyncing() bool {
73 w.mu.Lock()
74 defer w.mu.Unlock()
75 return w.syncing
76 }
77
78 func (w *Watcher) loop() {
79 var timer *time.Timer
80 var lastFile string
81
82 for {
83 select {
84 case <-w.done:
85 if timer != nil {
86 timer.Stop()
87 }
88 return
89
90 case event, ok := <-w.fsWatcher.Events:
91 if !ok {
92 return
93 }
94
95 // When a file is removed or renamed, try to re-add it.
96 // Editors and git often use atomic writes (delete + create).
97 if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
98 go w.rewatch(event.Name)
99 continue
100 }
101
102 if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
103 continue
104 }
105 if w.isSyncing() {
106 continue
107 }
108
109 lastFile = event.Name
110
111 if timer != nil {
112 timer.Stop()
113 }
114 captured := lastFile // capture by value to avoid data race with AfterFunc goroutine
115 timer = time.AfterFunc(w.debounce, func() {
116 if !w.isSyncing() {
117 w.syncMu.Lock()
118 defer w.syncMu.Unlock()
119 if !w.isSyncing() {
120 w.onChange(captured)
121 }
122 }
123 })
124
125 case _, ok := <-w.fsWatcher.Errors:
126 if !ok {
127 return
128 }
129 }
130 }
131 }
132
133 // rewatch polls for a removed file to reappear, then re-adds it to the watcher.
134 // Uses exponential backoff (50ms → 100ms → ... → 2s max) and polls indefinitely
135 // until the file reappears or Stop() is called (#268).
136 func (w *Watcher) rewatch(path string) {
137 backoff := 50 * time.Millisecond
138 const maxBackoff = 2 * time.Second
139
140 for {
141 select {
142 case <-w.done:
143 return
144 default:
145 }
146
147 if _, err := os.Stat(path); err == nil {
148 // File exists again — re-add to watcher.
149 _ = w.fsWatcher.Add(path)
150 // File was replaced via atomic rename — trigger callback
151 // since the content has changed but no Write event will fire.
152 if !w.isSyncing() {
153 time.AfterFunc(w.debounce, func() {
154 if !w.isSyncing() {
155 w.syncMu.Lock()
156 defer w.syncMu.Unlock()
157 if !w.isSyncing() {
158 w.onChange(path)
159 }
160 }
161 })
162 }
163 return
164 }
165 time.Sleep(backoff)
166 if backoff < maxBackoff {
167 backoff *= 2
168 if backoff > maxBackoff {
169 backoff = maxBackoff
170 }
171 }
172 }
173 }
174
github.com/docToolchain/Bausteinsicht/internal/workspace/loader.go 0.0%
1 package workspace
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // LoadConfig reads and parses a workspace configuration file.
13 func LoadConfig(path string) (*Config, error) {
14 data, err := os.ReadFile(path)
15 if err != nil {
16 return nil, fmt.Errorf("reading config: %w", err)
17 }
18
19 var cfg Config
20 if err := json.Unmarshal(data, &cfg); err != nil {
21 return nil, fmt.Errorf("parsing config: %w", err)
22 }
23
24 return &cfg, nil
25 }
26
27 // LoadModels loads all models referenced in the configuration.
28 // The basePath is used to resolve relative model paths.
29 func LoadModels(cfg *Config, basePath string) ([]LoadedModel, error) {
30 var loaded []LoadedModel
31
32 for _, ref := range cfg.Models {
33 modelPath := ref.Path
34 // If path is relative, resolve it relative to basePath
35 if !filepath.IsAbs(modelPath) {
36 modelPath = filepath.Join(basePath, modelPath)
37 }
38
39 m, err := model.Load(modelPath)
40 if err != nil {
41 return nil, fmt.Errorf("loading model %s (%s): %w", ref.ID, ref.Path, err)
42 }
43
44 loaded = append(loaded, LoadedModel{
45 Ref: ref,
46 Model: m,
47 })
48 }
49
50 return loaded, nil
51 }
52
53 // SaveConfig writes a workspace configuration to a file.
54 func SaveConfig(cfg *Config, path string) error {
55 data, err := json.MarshalIndent(cfg, "", " ")
56 if err != nil {
57 return fmt.Errorf("marshaling config: %w", err)
58 }
59
60 if err := os.WriteFile(path, data, 0644); err != nil {
61 return fmt.Errorf("writing config: %w", err)
62 }
63
64 return nil
65 }
66
github.com/docToolchain/Bausteinsicht/internal/workspace/merge.go 53.8%
1 package workspace
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // MergeModels combines multiple models into a single unified model.
11 // Element IDs are prefixed to avoid collisions:
12 // - If ModelRef.Prefix is set, use it as prefix
13 // - Otherwise, use ModelRef.ID as prefix
14 // Cross-model relationships are resolved using prefixed IDs.
15 func MergeModels(loaded []LoadedModel) (*model.BausteinsichtModel, error) {
16 if len(loaded) == 0 {
17 return nil, fmt.Errorf("no models to merge")
18 }
19
20 merged := &model.BausteinsichtModel{
21 Specification: model.Specification{
22 Elements: make(map[string]model.ElementKind),
23 Relationships: make(map[string]model.RelationshipKind),
24 },
25 Model: make(map[string]model.Element),
26 Relationships: []model.Relationship{},
27 Views: make(map[string]model.View),
28 DynamicViews: []model.DynamicView{},
29 Constraints: []model.Constraint{},
30 }
31
32 // Merge specifications (element and relationship kinds)
33 for _, lm := range loaded {
34 for kind, def := range lm.Model.Specification.Elements {
35 if _, exists := merged.Specification.Elements[kind]; !exists {
36 merged.Specification.Elements[kind] = def
37 }
38 }
39 for kind, def := range lm.Model.Specification.Relationships {
40 if _, exists := merged.Specification.Relationships[kind]; !exists {
41 merged.Specification.Relationships[kind] = def
42 }
43 }
44 }
45
46 // Map to track ID transformations for relationship resolution
47 idMap := make(map[string]string) // original ID → prefixed ID
48
49 // Merge elements with prefixing
50 for _, lm := range loaded {
51 prefix := lm.Ref.Prefix
52 if prefix == "" {
53 prefix = lm.Ref.ID
54 }
55
56 flatElems, _ := model.FlattenElements(lm.Model)
57 for id, elemPtr := range flatElems {
58 prefixedID := prefixElementID(id, prefix)
59 idMap[id] = prefixedID
60
61 merged.Model[prefixedID] = *elemPtr
62 }
63 }
64
65 // Merge relationships with ID remapping
66 for _, lm := range loaded {
67 prefix := lm.Ref.Prefix
68 if prefix == "" {
69 prefix = lm.Ref.ID
70 }
71
72 for _, rel := range lm.Model.Relationships {
73 remappedRel := rel
74 remappedRel.From = prefixElementID(rel.From, prefix)
75 remappedRel.To = prefixElementID(rel.To, prefix)
76 merged.Relationships = append(merged.Relationships, remappedRel)
77 }
78 }
79
80 // Merge views (each model's views are prefixed with model ID)
81 for _, lm := range loaded {
82 prefix := lm.Ref.Prefix
83 if prefix == "" {
84 prefix = lm.Ref.ID
85 }
86
87 for viewID, view := range lm.Model.Views {
88 viewKey := prefix + "_" + viewID
89 remappedView := view
90 remappedView.Include = remapElementIDs(view.Include, prefix)
91 remappedView.Exclude = remapElementIDs(view.Exclude, prefix)
92 if view.Scope != "" {
93 remappedView.Scope = prefixElementID(view.Scope, prefix)
94 }
95 merged.Views[viewKey] = remappedView
96 }
97 }
98
99 // Merge dynamic views
100 for _, lm := range loaded {
101 prefix := lm.Ref.Prefix
102 if prefix == "" {
103 prefix = lm.Ref.ID
104 }
105
106 for _, dv := range lm.Model.DynamicViews {
107 remappedDV := dv
108 remappedDV.Key = prefix + "_" + dv.Key
109 for i := range remappedDV.Steps {
110 remappedDV.Steps[i].From = prefixElementID(remappedDV.Steps[i].From, prefix)
111 remappedDV.Steps[i].To = prefixElementID(remappedDV.Steps[i].To, prefix)
112 }
113 merged.DynamicViews = append(merged.DynamicViews, remappedDV)
114 }
115 }
116
117 // Merge constraints
118 for _, lm := range loaded {
119 prefix := lm.Ref.Prefix
120 if prefix == "" {
121 prefix = lm.Ref.ID
122 }
123 _ = prefix // TODO: use prefix for remapping element IDs
124
125 for _, constraint := range lm.Model.Constraints {
126 remappedConstraint := constraint
127 if constraint.FromKind != "" {
128 remappedConstraint.FromKind = constraint.FromKind
129 }
130 merged.Constraints = append(merged.Constraints, remappedConstraint)
131 }
132 }
133
134 return merged, nil
135 }
136
137 // prefixElementID adds a prefix to an element ID.
138 // Dot-notation paths like "a.b.c" become "prefix_a.b.c".
139 func prefixElementID(id, prefix string) string {
140 parts := strings.SplitN(id, ".", 2)
141 return prefix + "_" + parts[0] + func() string {
142 if len(parts) > 1 {
143 return "." + parts[1]
144 }
145 return ""
146 }()
147 }
148
149 // remapElementIDs applies prefixing to a list of element IDs.
150 func remapElementIDs(ids []string, prefix string) []string {
151 var result []string
152 for _, id := range ids {
153 result = append(result, prefixElementID(id, prefix))
154 }
155 return result
156 }
157
158 // ResolveWorkspaceView resolves a workspace view by expanding element filters
159 // across all loaded models and returning a unified element set.
160 func ResolveWorkspaceView(cfg *Config, loaded []LoadedModel, view *WorkspaceView) (map[string]*model.Element, error) {
161 merged, err := MergeModels(loaded)
162 if err != nil {
163 return nil, err
164 }
165
166 flatElems, _ := model.FlattenElements(merged)
167 result := make(map[string]*model.Element)
168
169 // Start with includes
170 if len(view.IncludeFrom) > 0 {
171 // Include from specific models
172 for _, modelID := range view.IncludeFrom {
173 for _, lm := range loaded {
174 if lm.Ref.ID == modelID {
175 prefix := lm.Ref.Prefix
176 if prefix == "" {
177 prefix = lm.Ref.ID
178 }
179 flatLM, _ := model.FlattenElements(lm.Model)
180 for id, elemPtr := range flatLM {
181 prefixedID := prefixElementID(id, prefix)
182 if len(view.IncludeKinds) == 0 || contains(view.IncludeKinds, elemPtr.Kind) {
183 result[prefixedID] = elemPtr
184 }
185 }
186 break
187 }
188 }
189 }
190 } else if len(view.IncludeKinds) > 0 {
191 // Include by kinds across all models
192 for id, elemPtr := range flatElems {
193 if contains(view.IncludeKinds, elemPtr.Kind) && !contains(view.ExcludeKinds, elemPtr.Kind) {
194 result[id] = elemPtr
195 }
196 }
197 } else {
198 // Include all
199 result = flatElems
200 }
201
202 // Apply excludes
203 for _, kind := range view.ExcludeKinds {
204 for id, elemPtr := range result {
205 if elemPtr.Kind == kind {
206 delete(result, id)
207 }
208 }
209 }
210
211 return result, nil
212 }
213
214 func contains(slice []string, s string) bool {
215 for _, item := range slice {
216 if item == s {
217 return true
218 }
219 }
220 return false
221 }
222
832
Total Tests
99.6%
Pass Rate
68.9%
Coverage
12.74s
Duration

📈 Changes from Previous Run

Metric Change

📦 Test Results by Package

🔍 Package Details

Package Tests ✅ Passed ❌ Failed ⏭️ Skipped Pass Rate
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht 193 191 0 2 99.0%
github.com/docToolchain/Bausteinsicht/internal/changelog 14 14 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/constraints 16 16 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/diagram 40 40 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/diff 12 12 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/drawio 99 99 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/export 17 16 0 1 94.1%
github.com/docToolchain/Bausteinsicht/internal/exporter/structurizr 18 18 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/graph 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/health 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/importer/likec4 3 3 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/importer/structurizr 7 7 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/layout 7 7 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/lsp 17 17 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/model 143 143 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/overlay 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/schema 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/search 13 13 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/sync 159 159 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/table 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/template 22 22 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/watcher 7 7 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/workspace 11 11 0 0 100.0%
github.com/docToolchain/Bausteinsicht/templates 4 4 0 0 100.0%

🔬 Line-Level Coverage

github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht-lsp/main.go 0.0%
1 package main
2
3 import (
4 "flag"
5 "log"
6 "os"
7
8 "github.com/docToolchain/Bausteinsicht/internal/lsp"
9 )
10
11 func main() {
12 var debug bool
13 var stdio bool // Accept -stdio flag (used by LSP clients, can be ignored)
14 flag.BoolVar(&debug, "debug", false, "Enable debug logging to stderr")
15 flag.BoolVar(&stdio, "stdio", false, "Use stdio for LSP communication (default behavior)")
16 flag.Parse()
17
18 // Set up logging
19 logFile := os.Stderr
20 if !debug {
21 logFile, _ = os.OpenFile(os.DevNull, os.O_WRONLY, 0)
22 }
23 log.SetOutput(logFile)
24
25 // Create and run LSP server
26 server := lsp.NewServer()
27 if err := server.Run(); err != nil {
28 log.Fatalf("LSP server error: %v", err)
29 }
30 }
31
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add.go 100.0%
1 package main
2
3 import "github.com/spf13/cobra"
4
5 func newAddCmd() *cobra.Command {
6 cmd := &cobra.Command{
7 Use: "add",
8 Short: "Add elements, relationships, views, or specification types to the model",
9 }
10
11 cmd.AddCommand(newAddElementCmd())
12 cmd.AddCommand(newAddRelationshipCmd())
13 cmd.AddCommand(newAddFromPatternCmd())
14
15 // Create a pattern sub-group
16 patternCmd := &cobra.Command{
17 Use: "pattern",
18 Short: "Manage patterns",
19 }
20 patternCmd.AddCommand(newListPatternsCmd())
21
22 cmd.AddCommand(patternCmd)
23 cmd.AddCommand(newAddViewCmd())
24 cmd.AddCommand(newAddSpecificationCmd())
25
26 return cmd
27 }
28
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_element.go 77.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "regexp"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 // validIDPattern matches element IDs: letters, digits, hyphens, underscores.
14 // Dots are NOT allowed since they serve as hierarchy separators.
15 var validIDPattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
16
17 // isValidID checks if the given ID is a valid element identifier.
18 func isValidID(id string) bool {
19 return validIDPattern.MatchString(id)
20 }
21
22 func newAddElementCmd() *cobra.Command {
23 cmd := &cobra.Command{
24 Use: "element",
25 Short: "Add an element to the model",
26 RunE: runAddElement,
27 }
28
29 cmd.Flags().String("id", "", "Unique identifier for the element (required)")
30 cmd.Flags().String("kind", "", "Element kind as defined in specification (required)")
31 cmd.Flags().String("title", "", "Display title (required)")
32 cmd.Flags().String("parent", "", "Parent element ID (dot notation)")
33 cmd.Flags().String("technology", "", "Technology description")
34 cmd.Flags().String("description", "", "Element description")
35
36 _ = cmd.MarkFlagRequired("id")
37 _ = cmd.MarkFlagRequired("kind")
38 _ = cmd.MarkFlagRequired("title")
39
40 return cmd
41 }
42
43 func runAddElement(cmd *cobra.Command, args []string) error {
44 id, _ := cmd.Flags().GetString("id")
45 kind, _ := cmd.Flags().GetString("kind")
46 title, _ := cmd.Flags().GetString("title")
47 parent, _ := cmd.Flags().GetString("parent")
48 technology, _ := cmd.Flags().GetString("technology")
49 description, _ := cmd.Flags().GetString("description")
50
51 modelPath, _ := cmd.Flags().GetString("model")
52 format, _ := cmd.Flags().GetString("format")
53
54 // Validate ID format. (#123)
55 if !isValidID(id) {
56 return exitWithCode(
57 fmt.Errorf("invalid element ID %q: must contain only letters, digits, hyphens, or underscores", id),
58 1,
59 )
60 }
61
62 // Validate title is not empty. (#124)
63 if title == "" {
64 return exitWithCode(fmt.Errorf("title must not be empty"), 1)
65 }
66
67 // Load model
68 if modelPath == "" {
69 detected, err := model.AutoDetect(".")
70 if err != nil {
71 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
72 }
73 modelPath = detected
74 }
75
76 m, err := model.Load(modelPath)
77 if err != nil {
78 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
79 }
80
81 // Validate kind
82 if _, ok := m.Specification.Elements[kind]; !ok {
83 return exitWithCode(
84 fmt.Errorf("unknown element kind %q; valid kinds: %s", kind, validKinds(m)),
85 1,
86 )
87 }
88
89 // Build element
90 elem := model.Element{
91 Kind: kind,
92 Title: title,
93 Technology: technology,
94 Description: description,
95 }
96
97 fullID := id
98
99 if parent != "" {
100 // Validate parent exists
101 parentElem, err := model.Resolve(m, parent)
102 if err != nil {
103 return exitWithCode(fmt.Errorf("parent %q not found: %w", parent, err), 1)
104 }
105
106 // Validate parent's kind allows children.
107 if spec, ok := m.Specification.Elements[parentElem.Kind]; !ok || !spec.Container {
108 return exitWithCode(
109 fmt.Errorf("element %q (kind: %s) is not a container and cannot have children", parent, parentElem.Kind),
110 1,
111 )
112 }
113
114 // Check duplicate within parent's children
115 if parentElem.Children != nil {
116 if _, exists := parentElem.Children[id]; exists {
117 return exitWithCode(fmt.Errorf("element %q already exists under %q", id, parent), 1)
118 }
119 }
120
121 // Add to parent's children — need to update in-place through the model
122 if err := addChildToParent(m, parent, id, elem); err != nil {
123 return exitWithCode(err, 1)
124 }
125
126 fullID = parent + "." + id
127 } else {
128 // Check duplicate at top level
129 if _, exists := m.Model[id]; exists {
130 return exitWithCode(fmt.Errorf("element %q already exists at top level", id), 1)
131 }
132
133 if m.Model == nil {
134 m.Model = make(map[string]model.Element)
135 }
136 m.Model[id] = elem
137 }
138
139 // Save model — use comment-preserving insertion. (#122)
140 if err := saveAddedElement(modelPath, m, fullID, parent, id, elem); err != nil {
141 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
142 }
143
144 // Output
145 if format == "json" {
146 out := map[string]string{
147 "id": fullID,
148 "kind": kind,
149 "title": title,
150 }
151 if technology != "" {
152 out["technology"] = technology
153 }
154 if description != "" {
155 out["description"] = description
156 }
157 data, _ := json.Marshal(out)
158 fmt.Println(string(data))
159 } else {
160 fmt.Printf("Added element '%s' (kind: %s) to model.\n", fullID, kind)
161 }
162
163 return nil
164 }
165
166 // addChildToParent traverses the model to the parent and adds the child element.
167 func addChildToParent(m *model.BausteinsichtModel, parentPath, childID string, child model.Element) error {
168 parts := splitDotPath(parentPath)
169
170 // Get root element
171 root, ok := m.Model[parts[0]]
172 if !ok {
173 return fmt.Errorf("element %q not found", parts[0])
174 }
175
176 if len(parts) == 1 {
177 if root.Children == nil {
178 root.Children = make(map[string]model.Element)
179 }
180 root.Children[childID] = child
181 m.Model[parts[0]] = root
182 return nil
183 }
184
185 // Traverse to parent, building a stack of elements to update
186 stack := []model.Element{root}
187 current := root
188 for _, part := range parts[1:] {
189 if current.Children == nil {
190 return fmt.Errorf("no children at %q", part)
191 }
192 next, ok := current.Children[part]
193 if !ok {
194 return fmt.Errorf("element %q not found", part)
195 }
196 stack = append(stack, next)
197 current = next
198 }
199
200 // Add child to the deepest parent
201 if current.Children == nil {
202 current.Children = make(map[string]model.Element)
203 }
204 current.Children[childID] = child
205 stack[len(stack)-1] = current
206
207 // Walk back up the stack updating parents
208 for i := len(stack) - 1; i > 0; i-- {
209 parentElem := stack[i-1]
210 if parentElem.Children == nil {
211 parentElem.Children = make(map[string]model.Element)
212 }
213 parentElem.Children[parts[i]] = stack[i]
214 stack[i-1] = parentElem
215 }
216
217 m.Model[parts[0]] = stack[0]
218 return nil
219 }
220
221 func splitDotPath(path string) []string {
222 result := []string{}
223 current := ""
224 for _, c := range path {
225 if c == '.' {
226 if current != "" {
227 result = append(result, current)
228 }
229 current = ""
230 } else {
231 current += string(c)
232 }
233 }
234 if current != "" {
235 result = append(result, current)
236 }
237 return result
238 }
239
240 // saveAddedElement saves a newly added element using comment-preserving
241 // insertion. Falls back to model.Save if patching fails. (#122)
242 func saveAddedElement(modelPath string, m *model.BausteinsichtModel, fullID, parent, id string, elem model.Element) error {
243 // Compute indent depth: "model" is depth 1, each dot-path segment adds 2
244 // (one for the parent element, one for "children").
245 depth := 2 // "model" → "X" is at depth 2
246 if parent != "" {
247 depth += len(splitDotPath(parent)) * 2 // each parent level adds element + children
248 }
249 elemJSON := marshalElementJSON(elem, depth)
250
251 // Build the object path for insertion.
252 var objectPath []string
253 if parent != "" {
254 // Insert into the parent's "children" object.
255 parts := splitDotPath(parent)
256 objectPath = append([]string{"model"}, parts...)
257 objectPath = append(objectPath, "children")
258 } else {
259 objectPath = []string{"model"}
260 }
261
262 err := model.PatchInsert(modelPath, func(data []byte) ([]byte, error) {
263 return model.InsertObjectEntry(data, objectPath, id, elemJSON)
264 })
265 if err != nil {
266 // Fall back to full save if patching fails.
267 return model.Save(modelPath, m)
268 }
269 return nil
270 }
271
272 // marshalElementJSON builds a formatted JSON object for an element.
273 // depth is the nesting depth of the entry key (e.g., 2 for "model.X",
274 // 4 for "model.X.children.Y"). Each level adds 2 spaces.
275 func marshalElementJSON(elem model.Element, depth int) string {
276 fieldIndent := strings.Repeat(" ", (depth+1)*2)
277 closeIndent := strings.Repeat(" ", depth*2)
278
279 parts := []string{fmt.Sprintf(`"kind": %q`, elem.Kind)}
280 parts = append(parts, fmt.Sprintf(`"title": %q`, elem.Title))
281 if elem.Technology != "" {
282 parts = append(parts, fmt.Sprintf(`"technology": %q`, elem.Technology))
283 }
284 if elem.Description != "" {
285 parts = append(parts, fmt.Sprintf(`"description": %q`, elem.Description))
286 }
287
288 result := "{\n"
289 for i, p := range parts {
290 result += fieldIndent + p
291 if i < len(parts)-1 {
292 result += ","
293 }
294 result += "\n"
295 }
296 result += closeIndent + "}"
297 return result
298 }
299
300 func validKinds(m *model.BausteinsichtModel) string {
301 kinds := ""
302 for k := range m.Specification.Elements {
303 if kinds != "" {
304 kinds += ", "
305 }
306 kinds += k
307 }
308 return kinds
309 }
310
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_from_pattern.go 8.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 func newAddFromPatternCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "add-from-pattern <pattern-id>",
15 Short: "Add elements and relationships from a pattern",
16 Long: "Expand a pattern from the specification into concrete elements and relationships in the model.",
17 Args: cobra.ExactArgs(1),
18 RunE: func(cmd *cobra.Command, args []string) error {
19 return runAddFromPattern(cmd, args)
20 },
21 }
22
23 cmd.Flags().String("id", "", "Base ID for generated elements (required)")
24 cmd.Flags().String("title", "", "Base title for generated elements (default: --id)")
25 cmd.Flags().String("prefix", "", "Namespace prefix (modifies {base} to prefix-base)")
26 _ = cmd.MarkFlagRequired("id")
27
28 return cmd
29 }
30
31 func newListPatternsCmd() *cobra.Command {
32 return &cobra.Command{
33 Use: "list",
34 Short: "List all available patterns",
35 Long: "List all patterns defined in specification with their element and relationship counts.",
36 RunE: func(cmd *cobra.Command, args []string) error {
37 return runListPatterns(cmd)
38 },
39 }
40 }
41
42 func runAddFromPattern(cmd *cobra.Command, args []string) error {
43 patternID := args[0]
44 modelPath, _ := cmd.Flags().GetString("model")
45 baseID, _ := cmd.Flags().GetString("id")
46 title, _ := cmd.Flags().GetString("title")
47 prefix, _ := cmd.Flags().GetString("prefix")
48
49 // Apply prefix if provided
50 if prefix != "" {
51 baseID = prefix + "-" + baseID
52 }
53
54 if modelPath == "" {
55 detected, err := model.AutoDetect(".")
56 if err != nil {
57 return exitWithCode(err, 2)
58 }
59 modelPath = detected
60 }
61
62 m, err := model.Load(modelPath)
63 if err != nil {
64 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
65 }
66
67 // Check if pattern exists
68 pattern, exists := m.Specification.Patterns[patternID]
69 if !exists {
70 return exitWithCode(fmt.Errorf("pattern %q not found in specification", patternID), 2)
71 }
72
73 // Check for conflicts
74 conflicts, err := model.CheckPatternConflicts(m, pattern, baseID)
75 if err != nil {
76 return exitWithCode(err, 2)
77 }
78
79 if len(conflicts) > 0 {
80 return exitWithCode(fmt.Errorf("conflict: elements already exist: %v (use a different --id)", conflicts), 2)
81 }
82
83 // Expand the pattern
84 elements, relationships, err := model.ExpandPattern(pattern, baseID, title)
85 if err != nil {
86 return exitWithCode(err, 2)
87 }
88
89 // Get expanded IDs
90 elemIDs, relIDs, err := model.ExpandPatternIDs(pattern, baseID)
91 if err != nil {
92 return exitWithCode(err, 2)
93 }
94
95 // Add elements to model (at top level for now)
96 if m.Model == nil {
97 m.Model = make(map[string]model.Element)
98 }
99 for i, elem := range elements {
100 m.Model[elemIDs[i]] = elem
101 }
102
103 // Add relationships (From and To are already expanded by ExpandPattern)
104 m.Relationships = append(m.Relationships, relationships...)
105
106 // Save the updated model
107 if err := model.Save(modelPath, m); err != nil {
108 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
109 }
110
111 // Output summary
112 fmt.Printf("✅ Pattern '%s' applied with base ID '%s':\n", patternID, baseID)
113 for i, id := range elemIDs {
114 fmt.Printf(" + %-20s [%-10s] \"%s\"\n", id, elements[i].Kind, elements[i].Title)
115 }
116 for i, id := range relIDs {
117 fmt.Printf(" + %-20s %s → %s \"%s\"\n", id, relationships[i].From, relationships[i].To, relationships[i].Label)
118 }
119
120 return nil
121 }
122
123 func runListPatterns(cmd *cobra.Command) error {
124 modelPath, _ := cmd.Flags().GetString("model")
125
126 if modelPath == "" {
127 detected, err := model.AutoDetect(".")
128 if err != nil {
129 return exitWithCode(err, 2)
130 }
131 modelPath = detected
132 }
133
134 m, err := model.Load(modelPath)
135 if err != nil {
136 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
137 }
138
139 if len(m.Specification.Patterns) == 0 {
140 fmt.Println("No patterns defined in specification")
141 return nil
142 }
143
144 // Sort pattern IDs
145 var patternIDs []string
146 for id := range m.Specification.Patterns {
147 patternIDs = append(patternIDs, id)
148 }
149 sort.Strings(patternIDs)
150
151 format, _ := cmd.Flags().GetString("format")
152 if format == "json" {
153 // Output as JSON
154 type patternInfo struct {
155 ID string `json:"id"`
156 Description string `json:"description"`
157 ElementCount int `json:"elementCount"`
158 RelationshipCount int `json:"relationshipCount"`
159 }
160 var patterns []patternInfo
161 for _, id := range patternIDs {
162 p := m.Specification.Patterns[id]
163 patterns = append(patterns, patternInfo{
164 ID: id,
165 Description: p.Description,
166 ElementCount: len(p.Elements),
167 RelationshipCount: len(p.Relationships),
168 })
169 }
170 b, err := json.MarshalIndent(patterns, "", " ")
171 if err != nil {
172 return err
173 }
174 fmt.Println(string(b))
175 return nil
176 }
177
178 // Text output
179 fmt.Println("Available patterns:")
180 fmt.Println("──────────────────────────────────────────────────────────────")
181 for _, id := range patternIDs {
182 p := m.Specification.Patterns[id]
183 elemCount := len(p.Elements)
184 relCount := len(p.Relationships)
185 fmt.Printf(" %-25s %s (%d elements, %d relationships)\n",
186 id, p.Description, elemCount, relCount)
187 }
188
189 return nil
190 }
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_relationship.go 87.5%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/spf13/cobra"
9 )
10
11 func newAddRelationshipCmd() *cobra.Command {
12 cmd := &cobra.Command{
13 Use: "relationship",
14 Short: "Add a relationship between two elements",
15 Long: "Adds a new relationship to the architecture model. Both --from and --to must reference existing elements.",
16 RunE: runAddRelationship,
17 }
18
19 cmd.Flags().String("from", "", "Source element (dot-notation path, e.g. webshop.api)")
20 cmd.Flags().String("to", "", "Target element (dot-notation path, e.g. webshop.db)")
21 cmd.Flags().String("label", "", "Relationship label")
22 cmd.Flags().String("kind", "", "Relationship kind (must be defined in specification)")
23 cmd.Flags().String("description", "", "Relationship description")
24
25 _ = cmd.MarkFlagRequired("from")
26 _ = cmd.MarkFlagRequired("to")
27
28 return cmd
29 }
30
31 func runAddRelationship(cmd *cobra.Command, args []string) error {
32 format, _ := cmd.Flags().GetString("format")
33 modelPath, _ := cmd.Flags().GetString("model")
34 from, _ := cmd.Flags().GetString("from")
35 to, _ := cmd.Flags().GetString("to")
36 label, _ := cmd.Flags().GetString("label")
37 kind, _ := cmd.Flags().GetString("kind")
38 description, _ := cmd.Flags().GetString("description")
39
40 if modelPath == "" {
41 detected, err := model.AutoDetect(".")
42 if err != nil {
43 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
44 }
45 modelPath = detected
46 }
47
48 m, err := model.Load(modelPath)
49 if err != nil {
50 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
51 }
52
53 if _, err := model.Resolve(m, from); err != nil {
54 return exitWithCode(fmt.Errorf("--from: element %q not found", from), 1)
55 }
56
57 if _, err := model.Resolve(m, to); err != nil {
58 return exitWithCode(fmt.Errorf("--to: element %q not found", to), 1)
59 }
60
61 if kind != "" {
62 if m.Specification.Relationships == nil {
63 return exitWithCode(fmt.Errorf("--kind: %q not defined (no relationship kinds in specification)", kind), 1)
64 }
65 if _, ok := m.Specification.Relationships[kind]; !ok {
66 return exitWithCode(fmt.Errorf("--kind: %q not defined in specification", kind), 1)
67 }
68 }
69
70 for _, r := range m.Relationships {
71 if r.From == from && r.To == to && r.Kind == kind {
72 return exitWithCode(fmt.Errorf("relationship %s -> %s (kind %q) already exists", from, to, kind), 1)
73 }
74 }
75
76 rel := model.Relationship{
77 From: from,
78 To: to,
79 Label: label,
80 Kind: kind,
81 Description: description,
82 }
83 m.Relationships = append(m.Relationships, rel)
84
85 // Save using comment-preserving array append. (#122)
86 relJSON := marshalRelationshipJSON(rel)
87 err = model.PatchInsert(modelPath, func(data []byte) ([]byte, error) {
88 return model.AppendArrayEntry(data, []string{"relationships"}, relJSON)
89 })
90 if err != nil {
91 // Fall back to full save if patching fails.
92 if err := model.Save(modelPath, m); err != nil {
93 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
94 }
95 }
96
97 if format == "json" {
98 return printRelationshipJSON(rel)
99 }
100 printRelationshipText(rel)
101 return nil
102 }
103
104 // marshalRelationshipJSON builds a compact JSON object for a relationship.
105 func marshalRelationshipJSON(rel model.Relationship) string {
106 parts := []string{
107 fmt.Sprintf(`"from": %q`, rel.From),
108 fmt.Sprintf(`"to": %q`, rel.To),
109 }
110 if rel.Label != "" {
111 parts = append(parts, fmt.Sprintf(`"label": %q`, rel.Label))
112 }
113 if rel.Kind != "" {
114 parts = append(parts, fmt.Sprintf(`"kind": %q`, rel.Kind))
115 }
116 if rel.Description != "" {
117 parts = append(parts, fmt.Sprintf(`"description": %q`, rel.Description))
118 }
119
120 result := "{\n"
121 for i, p := range parts {
122 result += " " + p
123 if i < len(parts)-1 {
124 result += ","
125 }
126 result += "\n"
127 }
128 result += " }"
129 return result
130 }
131
132 func printRelationshipText(r model.Relationship) {
133 if r.Label != "" {
134 fmt.Printf("Added relationship: %s -> %s (%s)\n", r.From, r.To, r.Label)
135 } else {
136 fmt.Printf("Added relationship: %s -> %s\n", r.From, r.To)
137 }
138 }
139
140 func printRelationshipJSON(r model.Relationship) error {
141 out := struct {
142 From string `json:"from"`
143 To string `json:"to"`
144 Label string `json:"label,omitempty"`
145 Kind string `json:"kind,omitempty"`
146 Description string `json:"description,omitempty"`
147 }{
148 From: r.From,
149 To: r.To,
150 Label: r.Label,
151 Kind: r.Kind,
152 Description: r.Description,
153 }
154 data, err := json.MarshalIndent(out, "", " ")
155 if err != nil {
156 return fmt.Errorf("marshaling JSON: %w", err)
157 }
158 fmt.Println(string(data))
159 return nil
160 }
161
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_specification.go 65.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "regexp"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 // validSpecKeyPattern matches specification keys: lowercase letters, digits, underscores, hyphens.
13 var validSpecKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
14
15 // isValidSpecKey checks if the given spec key is valid.
16 func isValidSpecKey(key string) bool {
17 return validSpecKeyPattern.MatchString(key)
18 }
19
20 func newAddSpecificationCmd() *cobra.Command {
21 cmd := &cobra.Command{
22 Use: "specification",
23 Short: "Add element or relationship types to the specification",
24 }
25
26 cmd.AddCommand(newAddSpecificationElementCmd())
27 cmd.AddCommand(newAddSpecificationRelationshipCmd())
28
29 return cmd
30 }
31
32 func newAddSpecificationElementCmd() *cobra.Command {
33 cmd := &cobra.Command{
34 Use: "element <key>",
35 Short: "Add an element type to the specification",
36 Args: cobra.ExactArgs(1),
37 RunE: runAddSpecificationElement,
38 }
39
40 cmd.Flags().String("notation", "", "Notation/display text for this element type (required)")
41 cmd.Flags().String("description", "", "Description of this element type")
42 cmd.Flags().Bool("container", false, "Whether this element can contain children")
43
44 _ = cmd.MarkFlagRequired("notation")
45
46 return cmd
47 }
48
49 func runAddSpecificationElement(cmd *cobra.Command, args []string) error {
50 key := args[0]
51 notation, _ := cmd.Flags().GetString("notation")
52 description, _ := cmd.Flags().GetString("description")
53 container, _ := cmd.Flags().GetBool("container")
54
55 modelPath, _ := cmd.Flags().GetString("model")
56 format, _ := cmd.Flags().GetString("format")
57
58 // Validate key format
59 if !isValidSpecKey(key) {
60 return exitWithCode(
61 fmt.Errorf("invalid specification key %q: must contain only lowercase letters, digits, hyphens, or underscores", key),
62 1,
63 )
64 }
65
66 // Load model
67 if modelPath == "" {
68 detected, err := model.AutoDetect(".")
69 if err != nil {
70 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
71 }
72 modelPath = detected
73 }
74
75 m, err := model.Load(modelPath)
76 if err != nil {
77 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
78 }
79
80 // Add element kind
81 err = m.AddSpecificationElement(key, model.ElementKind{
82 Notation: notation,
83 Description: description,
84 Container: container,
85 })
86 if err != nil {
87 return exitWithCode(fmt.Errorf("adding element: %w", err), 1)
88 }
89
90 // Save model
91 if err := model.Save(modelPath, m); err != nil {
92 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
93 }
94
95 // Output result
96 if format == "json" {
97 result := map[string]interface{}{
98 "key": key,
99 "notation": notation,
100 "container": container,
101 }
102 if description != "" {
103 result["description"] = description
104 }
105 jsonBytes, err := json.MarshalIndent(result, "", " ")
106 if err != nil {
107 return fmt.Errorf("encoding result: %w", err)
108 }
109 fmt.Println(string(jsonBytes))
110 } else {
111 fmt.Printf("Element type '%s' added to specification\n", key)
112 fmt.Printf(" Notation: %s\n", notation)
113 if description != "" {
114 fmt.Printf(" Description: %s\n", description)
115 }
116 if container {
117 fmt.Printf(" Container: yes\n")
118 }
119 }
120
121 return nil
122 }
123
124 func newAddSpecificationRelationshipCmd() *cobra.Command {
125 cmd := &cobra.Command{
126 Use: "relationship <key>",
127 Short: "Add a relationship type to the specification",
128 Args: cobra.ExactArgs(1),
129 RunE: runAddSpecificationRelationship,
130 }
131
132 cmd.Flags().String("notation", "", "Notation/display text for this relationship type (required)")
133 cmd.Flags().String("description", "", "Description of this relationship type")
134 cmd.Flags().Bool("dashed", false, "Whether this relationship is displayed as a dashed line")
135
136 _ = cmd.MarkFlagRequired("notation")
137
138 return cmd
139 }
140
141 func runAddSpecificationRelationship(cmd *cobra.Command, args []string) error {
142 key := args[0]
143 notation, _ := cmd.Flags().GetString("notation")
144 description, _ := cmd.Flags().GetString("description")
145 dashed, _ := cmd.Flags().GetBool("dashed")
146
147 modelPath, _ := cmd.Flags().GetString("model")
148 format, _ := cmd.Flags().GetString("format")
149
150 // Validate key format
151 if !isValidSpecKey(key) {
152 return exitWithCode(
153 fmt.Errorf("invalid specification key %q: must contain only lowercase letters, digits, hyphens, or underscores", key),
154 1,
155 )
156 }
157
158 // Load model
159 if modelPath == "" {
160 detected, err := model.AutoDetect(".")
161 if err != nil {
162 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
163 }
164 modelPath = detected
165 }
166
167 m, err := model.Load(modelPath)
168 if err != nil {
169 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
170 }
171
172 // Add relationship kind
173 err = m.AddSpecificationRelationship(key, model.RelationshipKind{
174 Notation: notation,
175 Dashed: dashed,
176 })
177 if err != nil {
178 return exitWithCode(fmt.Errorf("adding relationship: %w", err), 1)
179 }
180
181 // Save model
182 if err := model.Save(modelPath, m); err != nil {
183 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
184 }
185
186 // Output result
187 if format == "json" {
188 result := map[string]interface{}{
189 "key": key,
190 "notation": notation,
191 "dashed": dashed,
192 }
193 if description != "" {
194 result["description"] = description
195 }
196 jsonBytes, err := json.MarshalIndent(result, "", " ")
197 if err != nil {
198 return fmt.Errorf("encoding result: %w", err)
199 }
200 fmt.Println(string(jsonBytes))
201 } else {
202 fmt.Printf("Relationship type '%s' added to specification\n", key)
203 fmt.Printf(" Notation: %s\n", notation)
204 if description != "" {
205 fmt.Printf(" Description: %s\n", description)
206 }
207 if dashed {
208 fmt.Printf(" Style: dashed\n")
209 }
210 }
211
212 return nil
213 }
214
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_view.go 70.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "regexp"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 // validViewKeyPattern matches view keys: lowercase letters, digits, hyphens.
13 var validViewKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
14
15 // isValidViewKey checks if the given view key is valid.
16 func isValidViewKey(key string) bool {
17 return validViewKeyPattern.MatchString(key)
18 }
19
20 func newAddViewCmd() *cobra.Command {
21 cmd := &cobra.Command{
22 Use: "view <view-key>",
23 Short: "Create a new view or modify a view's include list",
24 Args: cobra.ExactArgs(1),
25 RunE: runAddView,
26 }
27
28 cmd.Flags().String("scope", "", "Scope element ID (parent element to show)")
29 cmd.Flags().StringSlice("include", []string{}, "Elements to include in view (repeatable)")
30 cmd.Flags().String("title", "", "View title (display name) (required for new views)")
31 cmd.Flags().String("description", "", "View description")
32
33 return cmd
34 }
35
36 func runAddView(cmd *cobra.Command, args []string) error {
37 viewKey := args[0]
38 scope, _ := cmd.Flags().GetString("scope")
39 includes, _ := cmd.Flags().GetStringSlice("include")
40 title, _ := cmd.Flags().GetString("title")
41 description, _ := cmd.Flags().GetString("description")
42
43 modelPath, _ := cmd.Flags().GetString("model")
44 format, _ := cmd.Flags().GetString("format")
45
46 // Validate view key format
47 if !isValidViewKey(viewKey) {
48 return exitWithCode(
49 fmt.Errorf("invalid view key %q: must contain only lowercase letters, digits, hyphens, or underscores", viewKey),
50 1,
51 )
52 }
53
54 // Load model
55 if modelPath == "" {
56 detected, err := model.AutoDetect(".")
57 if err != nil {
58 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
59 }
60 modelPath = detected
61 }
62
63 m, err := model.Load(modelPath)
64 if err != nil {
65 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
66 }
67
68 // Check if this is a new view or update
69 _, viewExists := m.Views[viewKey]
70 if !viewExists && title == "" {
71 return exitWithCode(
72 fmt.Errorf("title is required for new views"),
73 1,
74 )
75 }
76
77 // Create view struct
78 view := model.View{
79 Title: title,
80 Scope: scope,
81 Include: includes,
82 Description: description,
83 }
84
85 // Add or update view
86 err = m.AddView(viewKey, view)
87 if err != nil {
88 return exitWithCode(fmt.Errorf("adding view: %w", err), 1)
89 }
90
91 // Save model
92 if err := model.Save(modelPath, m); err != nil {
93 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
94 }
95
96 // Output result
97 if format == "json" {
98 result := map[string]interface{}{
99 "view_key": viewKey,
100 "title": title,
101 "scope": scope,
102 "include": includes,
103 }
104 jsonBytes, err := json.MarshalIndent(result, "", " ")
105 if err != nil {
106 return fmt.Errorf("encoding result: %w", err)
107 }
108 fmt.Println(string(jsonBytes))
109 } else {
110 fmt.Printf("View '%s' added to model\n", viewKey)
111 if title != "" {
112 fmt.Printf(" Title: %s\n", title)
113 }
114 if scope != "" {
115 fmt.Printf(" Scope: %s\n", scope)
116 }
117 if len(includes) > 0 {
118 fmt.Printf(" Includes: %v\n", includes)
119 }
120 }
121
122 return nil
123 }
124
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/adr.go 10.4%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 func newADRCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "adr",
16 Short: "Manage Architecture Decision Records (ADRs)",
17 Long: "List, show, and manage architecture decision records linked to model elements.",
18 RunE: func(cmd *cobra.Command, args []string) error {
19 return cmd.Help()
20 },
21 }
22
23 cmd.AddCommand(newADRListCmd())
24 cmd.AddCommand(newADRShowCmd())
25
26 return cmd
27 }
28
29 func newADRListCmd() *cobra.Command {
30 cmd := &cobra.Command{
31 Use: "list",
32 Short: "List all ADRs or ADRs linked to an element",
33 Long: "List architecture decision records, optionally filtered by element.",
34 RunE: func(cmd *cobra.Command, args []string) error {
35 modelPath := cmd.Flag("model").Value.String()
36 elementID := cmd.Flag("element").Value.String()
37 format := cmd.Flag("format").Value.String()
38
39 if modelPath == "" {
40 modelPath = "architecture.jsonc"
41 }
42
43 m, err := model.Load(modelPath)
44 if err != nil {
45 return fmt.Errorf("loading model: %w", err)
46 }
47
48 // Collect decisions to display
49 var decisions []model.DecisionRecord
50 if elementID != "" {
51 // Filter decisions for a specific element
52 elem, ok := findElementByID(m, elementID)
53 if !ok || elem == nil {
54 return fmt.Errorf("element not found: %s", elementID)
55 }
56
57 for _, decisionID := range elem.Decisions {
58 for _, d := range m.Specification.Decisions {
59 if d.ID == decisionID {
60 decisions = append(decisions, d)
61 break
62 }
63 }
64 }
65 } else {
66 // All decisions
67 decisions = m.Specification.Decisions
68 }
69
70 // Sort by ID
71 sort.Slice(decisions, func(i, j int) bool {
72 return decisions[i].ID < decisions[j].ID
73 })
74
75 // Format output
76 if format == "json" {
77 b, err := json.MarshalIndent(decisions, "", " ")
78 if err != nil {
79 return fmt.Errorf("marshaling JSON: %w", err)
80 }
81 fmt.Println(string(b))
82 return nil
83 }
84
85 // Default text format
86 if len(decisions) == 0 {
87 if elementID != "" {
88 fmt.Printf("No decisions linked to element %q\n", elementID)
89 } else {
90 fmt.Println("No decisions defined")
91 }
92 return nil
93 }
94
95 fmt.Printf("Decisions (%d):\n", len(decisions))
96 fmt.Println("──────────────────────────────────────────")
97 for _, d := range decisions {
98 statusIcon := getStatusIcon(d.Status)
99 fmt.Printf("%-20s %s %s\n", d.ID, statusIcon, d.Title)
100 if d.Date != "" {
101 fmt.Printf(" Date: %s\n", d.Date)
102 }
103 if d.FilePath != "" {
104 fmt.Printf(" File: %s\n", d.FilePath)
105 }
106 }
107
108 return nil
109 },
110 }
111
112 cmd.Flags().StringP("model", "m", "", "Path to architecture model (default: architecture.jsonc)")
113 cmd.Flags().String("element", "", "Filter decisions linked to this element")
114 cmd.Flags().String("format", "text", "Output format: text or json")
115
116 return cmd
117 }
118
119 func newADRShowCmd() *cobra.Command {
120 cmd := &cobra.Command{
121 Use: "show <adr-id>",
122 Short: "Show details of a specific ADR",
123 Long: "Display detailed information about an architecture decision record.",
124 Args: cobra.ExactArgs(1),
125 RunE: func(cmd *cobra.Command, args []string) error {
126 modelPath := cmd.Flag("model").Value.String()
127 decisionID := args[0]
128
129 if modelPath == "" {
130 modelPath = "architecture.jsonc"
131 }
132
133 m, err := model.Load(modelPath)
134 if err != nil {
135 return fmt.Errorf("loading model: %w", err)
136 }
137
138 // Find the decision
139 var decision *model.DecisionRecord
140 for i, d := range m.Specification.Decisions {
141 if d.ID == decisionID {
142 decision = &m.Specification.Decisions[i]
143 break
144 }
145 }
146
147 if decision == nil {
148 return fmt.Errorf("decision not found: %s", decisionID)
149 }
150
151 // Collect elements and relationships that reference this decision
152 var references []string
153 flat, _ := model.FlattenElements(m)
154 for elemID, elem := range flat {
155 for _, dID := range elem.Decisions {
156 if dID == decisionID {
157 references = append(references, "element: "+elemID)
158 break
159 }
160 }
161 }
162 for _, rel := range m.Relationships {
163 for _, dID := range rel.Decisions {
164 if dID == decisionID {
165 references = append(references, fmt.Sprintf("relationship: %s → %s", rel.From, rel.To))
166 break
167 }
168 }
169 }
170
171 // Display information
172 statusIcon := getStatusIcon(decision.Status)
173 fmt.Printf("ADR: %s %s\n", decision.ID, statusIcon)
174 fmt.Println("──────────────────────────────────────────")
175 fmt.Printf("Title: %s\n", decision.Title)
176 fmt.Printf("Status: %s\n", decision.Status)
177 if decision.Date != "" {
178 fmt.Printf("Date: %s\n", decision.Date)
179 }
180 if decision.FilePath != "" {
181 fmt.Printf("File: %s\n", decision.FilePath)
182 }
183
184 if len(references) > 0 {
185 sort.Strings(references)
186 fmt.Println("\nReferenced by:")
187 for _, ref := range references {
188 fmt.Printf(" - %s\n", ref)
189 }
190 }
191
192 return nil
193 },
194 }
195
196 cmd.Flags().StringP("model", "m", "", "Path to architecture model (default: architecture.jsonc)")
197
198 return cmd
199 }
200
201 func getStatusIcon(status model.ADRStatus) string {
202 switch status {
203 case model.ADRActive:
204 return "✓"
205 case model.ADRProposed:
206 return "◯"
207 case model.ADRDeprecated:
208 return "⚠"
209 case model.ADRSuperseded:
210 return "✗"
211 default:
212 return "?"
213 }
214 }
215
216 func findElementByID(m *model.BausteinsichtModel, id string) (*model.Element, bool) {
217 parts := strings.Split(id, ".")
218 if len(parts) == 0 {
219 return nil, false
220 }
221
222 elem, ok := m.Model[parts[0]]
223 if !ok {
224 return nil, false
225 }
226
227 // Navigate through child elements
228 current := &elem
229 for _, part := range parts[1:] {
230 child, ok := current.Children[part]
231 if !ok {
232 return nil, false
233 }
234 current = &child
235 }
236
237 return current, true
238 }
239
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/changelog_cmd.go 16.3%
1 package main
2
3 import (
4 "fmt"
5 "os"
6
7 "github.com/docToolchain/Bausteinsicht/internal/changelog"
8 "github.com/spf13/cobra"
9 )
10
11 func newChangelogCmd() *cobra.Command {
12 cmd := &cobra.Command{
13 Use: "changelog",
14 Short: "Generate architecture changelog between two points in time",
15 Long: "Compare two versions of the architecture model and generate a human-readable changelog showing what changed.",
16 RunE: runChangelog,
17 }
18
19 cmd.Flags().String("model", "architecture.jsonc", "Model file path")
20 cmd.Flags().String("since", "", "Starting git ref or snapshot ID (default: previous tag)")
21 cmd.Flags().String("until", "HEAD", "Ending git ref or snapshot ID")
22 cmd.Flags().String("format", "markdown", "Output format: markdown, asciidoc, or json")
23 cmd.Flags().String("output", "", "Output file path (default: stdout)")
24
25 return cmd
26 }
27
28 func runChangelog(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 since, _ := cmd.Flags().GetString("since")
31 until, _ := cmd.Flags().GetString("until")
32 format, _ := cmd.Flags().GetString("format")
33 output, _ := cmd.Flags().GetString("output")
34
35 if err := validatePathContainment(modelPath); err != nil {
36 return exitWithCode(err, 2)
37 }
38
39 // Validate format
40 if format != "markdown" && format != "asciidoc" && format != "json" {
41 return exitWithCode(fmt.Errorf("invalid format: %s (expected markdown, asciidoc, or json)", format), 2)
42 }
43
44 // Load models at the two refs
45 fromModel, err := changelog.LoadModelAtGitRef(modelPath, since)
46 if err != nil {
47 return exitWithCode(fmt.Errorf("loading model at %q: %w", since, err), 2)
48 }
49
50 toModel, err := changelog.LoadModelAtGitRef(modelPath, until)
51 if err != nil {
52 return exitWithCode(fmt.Errorf("loading model at %q: %w", until, err), 2)
53 }
54
55 // Get reference info for display
56 fromRef := changelog.Reference{Ref: since}
57 toRef := changelog.Reference{Ref: until}
58
59 if fromInfo, err := changelog.GetCommitInfo(since); err == nil {
60 fromRef.Date = fromInfo.Date
61 }
62 if toInfo, err := changelog.GetCommitInfo(until); err == nil {
63 toRef.Date = toInfo.Date
64 }
65
66 // Generate changelog
67 cl := changelog.Generate(fromModel, toModel, fromRef, toRef)
68
69 // Render output
70 var result string
71 switch format {
72 case "markdown":
73 result = changelog.RenderMarkdown(cl)
74 case "asciidoc":
75 result = changelog.RenderAsciiDoc(cl)
76 case "json":
77 var err error
78 result, err = changelog.RenderJSON(cl)
79 if err != nil {
80 return exitWithCode(fmt.Errorf("rendering JSON: %w", err), 2)
81 }
82 }
83
84 // Write output
85 if output == "" {
86 // Write to stdout
87 if _, err := fmt.Fprint(cmd.OutOrStdout(), result); err != nil {
88 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
89 }
90 } else {
91 // Write to file
92 if err := os.WriteFile(output, []byte(result), 0o644); err != nil {
93 return exitWithCode(fmt.Errorf("writing to %q: %w", output, err), 2)
94 }
95 }
96
97 return nil
98 }
99
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/cmd_schema.go 30.0%
1 package main
2
3 import (
4 "fmt"
5 "os"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/docToolchain/Bausteinsicht/internal/schema"
9 "github.com/spf13/cobra"
10 )
11
12 func newSchemaCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "schema",
15 Short: "Manage JSON Schema for architecture models",
16 Long: "Generate and manage JSON Schema definitions for Bausteinsicht models.",
17 }
18
19 cmd.AddCommand(newSchemaGenerateCmd())
20
21 return cmd
22 }
23
24 func newSchemaGenerateCmd() *cobra.Command {
25 cmd := &cobra.Command{
26 Use: "generate",
27 Short: "Generate JSON Schema from Go types",
28 Long: "Generate the JSON Schema from model type definitions and save to schemas/bausteinsicht.schema.json.",
29 RunE: runSchemaGenerate,
30 }
31
32 cmd.Flags().String("output", "schemas/bausteinsicht.schema.json", "Output file for the schema")
33
34 return cmd
35 }
36
37 func runSchemaGenerate(cmd *cobra.Command, _ []string) error {
38 outputFile, _ := cmd.Flags().GetString("output")
39
40 // Validate output path to prevent directory traversal (SEC-001)
41 if err := validatePathContainment(outputFile); err != nil {
42 return exitWithCode(fmt.Errorf("--output: %w", err), 1)
43 }
44
45 // Create schema generator
46 gen := schema.NewGenerator()
47
48 // Generate schema for BausteinsichtModel
49 schemaObj := gen.Generate(model.BausteinsichtModel{})
50
51 // Convert to JSON
52 jsonBytes, err := schemaObj.ToJSON()
53 if err != nil {
54 return fmt.Errorf("failed to convert schema to JSON: %w", err)
55 }
56
57 // Write to file
58 if err := os.WriteFile(outputFile, jsonBytes, 0600); err != nil {
59 return fmt.Errorf("failed to write schema file: %w", err)
60 }
61
62 // Print success message
63 fmt.Printf("✅ Schema generated: %s\n", outputFile)
64 fmt.Printf("📊 Properties: %d\n", len(schemaObj.Properties))
65 fmt.Printf("📌 Required fields: %d\n", len(schemaObj.Required))
66
67 return nil
68 }
69
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/diff.go 56.5%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/diff"
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 func newDiffCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "diff",
15 Short: "Show differences between as-is and to-be architecture",
16 Long: "Compare as-is and to-be sections of the model and report changes.",
17 RunE: runDiff,
18 }
19
20 cmd.Flags().String("model", "architecture.jsonc", "Model file path")
21 cmd.Flags().String("view", "", "Show diff for one view only (optional)")
22 cmd.Flags().String("format", "text", "Output format: text or json")
23
24 return cmd
25 }
26
27 func runDiff(cmd *cobra.Command, _ []string) error {
28 modelPath, _ := cmd.Flags().GetString("model")
29 format, _ := cmd.Flags().GetString("format")
30
31 if err := validatePathContainment(modelPath); err != nil {
32 return exitWithCode(err, 2)
33 }
34
35 m, err := model.Load(modelPath)
36 if err != nil {
37 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
38 }
39
40 if m.AsIs == nil || m.ToBe == nil {
41 return exitWithCode(fmt.Errorf("model does not contain asIs and toBe sections"), 1)
42 }
43
44 result := diff.Compare(m.AsIs, m.ToBe)
45
46 switch format {
47 case "json":
48 data, err := json.MarshalIndent(result, "", " ")
49 if err != nil {
50 return exitWithCode(fmt.Errorf("marshaling JSON: %w", err), 2)
51 }
52 if _, err := fmt.Fprint(cmd.OutOrStdout(), string(data)); err != nil {
53 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
54 }
55 case "text":
56 output := formatDiffAsText(result)
57 if _, err := fmt.Fprint(cmd.OutOrStdout(), output); err != nil {
58 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
59 }
60 default:
61 return exitWithCode(fmt.Errorf("invalid format: %s (expected text or json)", format), 2)
62 }
63
64 return nil
65 }
66
67 func formatDiffAsText(result *diff.DiffResult) string {
68 output := "Architecture Diff\n"
69 output += "=================\n\n"
70
71 // Added elements
72 if result.Summary.AddedElements > 0 {
73 output += fmt.Sprintf("Added (%d):\n", result.Summary.AddedElements)
74 for _, change := range result.Elements {
75 if change.Type == diff.ChangeAdded && change.ToBe != nil {
76 output += fmt.Sprintf(" + %-20s [%s] \"%s\"\n",
77 change.ID, change.ToBe.Kind, change.ToBe.Title)
78 }
79 }
80 output += "\n"
81 }
82
83 // Removed elements
84 if result.Summary.RemovedElements > 0 {
85 output += fmt.Sprintf("Removed (%d):\n", result.Summary.RemovedElements)
86 for _, change := range result.Elements {
87 if change.Type == diff.ChangeRemoved && change.AsIs != nil {
88 output += fmt.Sprintf(" - %-20s [%s] \"%s\"\n",
89 change.ID, change.AsIs.Kind, change.AsIs.Title)
90 }
91 }
92 output += "\n"
93 }
94
95 // Changed elements
96 if result.Summary.ChangedElements > 0 {
97 output += fmt.Sprintf("Changed (%d):\n", result.Summary.ChangedElements)
98 for _, change := range result.Elements {
99 if change.Type == diff.ChangeChanged && change.AsIs != nil && change.ToBe != nil {
100 output += fmt.Sprintf(" ~ %-20s [%s]\n", change.ID, change.AsIs.Kind)
101
102 // Show what changed
103 if change.AsIs.Title != change.ToBe.Title {
104 output += fmt.Sprintf(" title: \"%s\" → \"%s\"\n",
105 change.AsIs.Title, change.ToBe.Title)
106 }
107 if change.AsIs.Technology != change.ToBe.Technology {
108 output += fmt.Sprintf(" technology: \"%s\" → \"%s\"\n",
109 change.AsIs.Technology, change.ToBe.Technology)
110 }
111 if change.AsIs.Description != change.ToBe.Description {
112 output += " description: changed\n"
113 }
114 if change.AsIs.Status != change.ToBe.Status {
115 output += fmt.Sprintf(" status: \"%s\" → \"%s\"\n",
116 change.AsIs.Status, change.ToBe.Status)
117 }
118 }
119 }
120 output += "\n"
121 }
122
123 // Relationship changes
124 if result.Summary.AddedRelationships > 0 {
125 output += fmt.Sprintf("Added Relationships (%d):\n", result.Summary.AddedRelationships)
126 for _, change := range result.Relationships {
127 if change.Type == diff.ChangeAdded && change.ToBe != nil {
128 output += fmt.Sprintf(" + %s → %s (%s)\n",
129 change.From, change.To, change.ToBe.Label)
130 }
131 }
132 output += "\n"
133 }
134
135 if result.Summary.RemovedRelationships > 0 {
136 output += fmt.Sprintf("Removed Relationships (%d):\n", result.Summary.RemovedRelationships)
137 for _, change := range result.Relationships {
138 if change.Type == diff.ChangeRemoved && change.AsIs != nil {
139 output += fmt.Sprintf(" - %s → %s (%s)\n",
140 change.From, change.To, change.AsIs.Label)
141 }
142 }
143 output += "\n"
144 }
145
146 if result.Summary.AddedElements == 0 && result.Summary.RemovedElements == 0 &&
147 result.Summary.ChangedElements == 0 && result.Summary.AddedRelationships == 0 &&
148 result.Summary.RemovedRelationships == 0 {
149 output += "No changes found between as-is and to-be architecture.\n"
150 }
151
152 return output
153 }
154
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export.go 8.8%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/drawio"
10 "github.com/docToolchain/Bausteinsicht/internal/export"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 "github.com/spf13/cobra"
13 )
14
15 func newExportCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "export",
18 Short: "Export diagram views to PNG or SVG",
19 Long: "Exports draw.io diagram pages to image files using the draw.io CLI.",
20 RunE: runExport,
21 }
22 cmd.Flags().String("image-format", "png", "Image format: png or svg")
23 cmd.Flags().String("view", "", "Export only this view (by key)")
24 cmd.Flags().String("output", ".", "Output directory")
25 cmd.Flags().Bool("embed-diagram", false, "Embed draw.io XML source in output")
26 cmd.Flags().Float64("scale", 1.0, "Export scale factor (e.g. 2.0 for retina, 3.0 for print); scale > 1 requires hardware GPU")
27 return cmd
28 }
29
30 type exportResultJSON struct {
31 Files []string `json:"files"`
32 Errors []string `json:"errors,omitempty"`
33 Success bool `json:"success"`
34 }
35
36 func runExport(cmd *cobra.Command, _ []string) error {
37 format, _ := cmd.Flags().GetString("format")
38 modelPath, _ := cmd.Flags().GetString("model")
39 verbose, _ := cmd.Flags().GetBool("verbose")
40 imageFormat, _ := cmd.Flags().GetString("image-format")
41 viewFilter, _ := cmd.Flags().GetString("view")
42 outputDir, _ := cmd.Flags().GetString("output")
43 embedDiagram, _ := cmd.Flags().GetBool("embed-diagram")
44 scale, _ := cmd.Flags().GetFloat64("scale")
45
46 if outputDir != "" {
47 if err := validatePathContainment(outputDir); err != nil {
48 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
49 }
50 }
51
52 // Validate image format.
53 if imageFormat != "png" && imageFormat != "svg" {
54 return exitWithCode(fmt.Errorf("unsupported image format %q; use png or svg", imageFormat), 2)
55 }
56
57 // Auto-detect model file.
58 if modelPath == "" {
59 detected, err := model.AutoDetect(".")
60 if err != nil {
61 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
62 }
63 modelPath = detected
64 }
65
66 // Derive drawio path from model path.
67 dir := filepath.Dir(modelPath)
68 drawioPath := filepath.Join(dir, "architecture.drawio")
69
70 // Load model to get view keys.
71 m, err := model.Load(modelPath)
72 if err != nil {
73 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
74 }
75
76 if len(m.Views) == 0 {
77 return exitWithCode(fmt.Errorf("no views to export"), 2)
78 }
79
80 // If a specific view was requested, check it exists.
81 if viewFilter != "" {
82 if _, ok := m.Views[viewFilter]; !ok {
83 return exitWithCode(fmt.Errorf("view %q not found in model", viewFilter), 2)
84 }
85 }
86
87 // Load draw.io document to get page ordering.
88 doc, err := drawio.LoadDocument(drawioPath)
89 if err != nil {
90 return exitWithCode(fmt.Errorf("loading draw.io file: %w", err), 2)
91 }
92
93 // Detect draw.io CLI binary.
94 binary, err := export.DetectDrawioBinary()
95 if err != nil {
96 return exitWithCode(err, 2)
97 }
98
99 if verbose && format != "json" {
100 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Using draw.io CLI: %s\n", binary)
101 }
102
103 // Ensure output directory exists.
104 if err := os.MkdirAll(outputDir, 0750); err != nil {
105 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
106 }
107
108 // Build the list of pages to export.
109 pages := doc.Pages()
110 type viewExport struct {
111 key string
112 pageIndex int // 1-based
113 }
114 var exports []viewExport
115
116 for viewKey := range m.Views {
117 if viewFilter != "" && viewKey != viewFilter {
118 continue
119 }
120 pageID := "view-" + viewKey
121 for i, p := range pages {
122 if p.ID() == pageID {
123 exports = append(exports, viewExport{key: viewKey, pageIndex: i + 1})
124 break
125 }
126 }
127 }
128
129 if len(exports) == 0 {
130 return exitWithCode(fmt.Errorf("no matching pages found in draw.io file"), 2)
131 }
132
133 // Export each page.
134 var files []string
135 var exportErrors []string
136
137 for _, ex := range exports {
138 outFile := filepath.Join(outputDir, export.OutputFileName(ex.key, imageFormat))
139
140 if verbose && format != "json" {
141 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exporting view %q to %s\n", ex.key, outFile)
142 }
143
144 err := export.ExportPage(binary, export.ExportOptions{
145 Format: imageFormat,
146 PageIndex: ex.pageIndex,
147 OutputPath: outFile,
148 EmbedDiagram: embedDiagram,
149 InputFile: drawioPath,
150 Scale: scale,
151 })
152 if err != nil {
153 exportErrors = append(exportErrors, fmt.Sprintf("view %q: %v", ex.key, err))
154 continue
155 }
156 files = append(files, outFile)
157 }
158
159 // Output results.
160 if format == "json" {
161 result := exportResultJSON{
162 Files: files,
163 Errors: exportErrors,
164 Success: len(exportErrors) == 0,
165 }
166 out, _ := json.MarshalIndent(result, "", " ")
167 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
168 } else {
169 for _, f := range files {
170 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Exported: %s\n", f)
171 }
172 for _, e := range exportErrors {
173 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ERROR: %s\n", e)
174 }
175 }
176
177 if len(exportErrors) > 0 {
178 return exitWithCode(fmt.Errorf("%d export(s) failed", len(exportErrors)), 1)
179 }
180 return nil
181 }
182
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export_diagram.go 56.7%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9
10 "github.com/docToolchain/Bausteinsicht/internal/diagram"
11 "github.com/docToolchain/Bausteinsicht/internal/export"
12 dslexport "github.com/docToolchain/Bausteinsicht/internal/exporter/structurizr"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 "github.com/spf13/cobra"
15 )
16
17 func newExportDiagramCmd() *cobra.Command {
18 cmd := &cobra.Command{
19 Use: "export-diagram",
20 Short: "Export views as C4 diagrams (PlantUML, Mermaid, DOT, D2, HTML5, Structurizr DSL)",
21 Long: "Exports architecture views as text-based C4 diagrams (PlantUML, Mermaid, DOT, D2), interactive HTML5 viewer, or Structurizr DSL workspace.",
22 RunE: runExportDiagram,
23 }
24
25 cmd.Flags().String("view", "", "Export only this view (by key)")
26 cmd.Flags().String("diagram-format", "plantuml", "Diagram format: plantuml, mermaid, dot, d2, html, or structurizr")
27 cmd.Flags().String("output", "", "Output directory (default: stdout)")
28
29 return cmd
30 }
31
32 func runExportDiagram(cmd *cobra.Command, _ []string) error {
33 modelPath, _ := cmd.Flags().GetString("model")
34 viewKey, _ := cmd.Flags().GetString("view")
35 diagramFormat, _ := cmd.Flags().GetString("diagram-format")
36 outputDir, _ := cmd.Flags().GetString("output")
37
38 if outputDir != "" {
39 if err := validatePathContainment(outputDir); err != nil {
40 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
41 }
42 }
43
44 if modelPath == "" {
45 detected, err := model.AutoDetect(".")
46 if err != nil {
47 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
48 }
49 modelPath = detected
50 }
51
52 m, err := model.Load(modelPath)
53 if err != nil {
54 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
55 }
56
57 // Structurizr DSL export: outputs the whole workspace in one file.
58 if diagramFormat == "structurizr" {
59 // Structurizr exports the entire workspace, not individual views
60 if viewKey != "" {
61 return exitWithCode(fmt.Errorf("--view is not supported with structurizr format (exports entire workspace)"), 1)
62 }
63 dsl := dslexport.Export(m)
64 if outputDir == "" {
65 _, _ = fmt.Fprint(cmd.OutOrStdout(), dsl)
66 return nil
67 }
68 if err := os.MkdirAll(outputDir, 0750); err != nil {
69 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
70 }
71 outPath := filepath.Join(outputDir, "workspace.dsl")
72 if err := os.WriteFile(outPath, []byte(dsl), 0600); err != nil { //nolint:gosec
73 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
74 }
75 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
76 return nil
77 }
78
79 // Determine which views to export.
80 views := make(map[string]model.View)
81 if viewKey != "" {
82 v, ok := m.Views[viewKey]
83 if !ok {
84 return exitWithCode(fmt.Errorf("view %q not found", viewKey), 1)
85 }
86 views[viewKey] = v
87 } else {
88 views = m.Views
89 }
90
91 outputFormat, _ := cmd.Flags().GetString("format")
92
93 // Handle new export formats (DOT, D2, HTML) — with JSON envelope support
94 switch diagramFormat {
95 case "dot", "d2", "html":
96 return handleNewFormats(cmd, m, views, diagramFormat, outputFormat, outputDir, viewKey)
97 }
98
99 var f diagram.Format
100 var ext string
101 switch diagramFormat {
102 case "plantuml":
103 f = diagram.PlantUML
104 ext = "puml"
105 case "mermaid":
106 f = diagram.Mermaid
107 ext = "mmd"
108 default:
109 return exitWithCode(fmt.Errorf("unknown diagram format %q: valid values are \"plantuml\", \"mermaid\", \"dot\", \"d2\", \"html\", or \"structurizr\"", diagramFormat), 2)
110 }
111
112 // When --format json, output structured JSON with diagram source. (#241)
113 if outputFormat == "json" {
114 type diagramEntry struct {
115 View string `json:"view"`
116 Format string `json:"format"`
117 Source string `json:"source"`
118 }
119 var entries []diagramEntry
120 keys := sortedKeys(views)
121 for _, key := range keys {
122 result, fmtErr := diagram.FormatView(m, key, f)
123 if fmtErr != nil {
124 return exitWithCode(fmtErr, 1)
125 }
126 entries = append(entries, diagramEntry{
127 View: key,
128 Format: diagramFormat,
129 Source: result,
130 })
131 }
132 data, _ := json.MarshalIndent(entries, "", " ")
133 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
134 return nil
135 }
136
137 for key := range views {
138 result, fmtErr := diagram.FormatView(m, key, f)
139 if fmtErr != nil {
140 return exitWithCode(fmtErr, 1)
141 }
142
143 if outputDir == "" {
144 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
145 continue
146 }
147
148 if err := os.MkdirAll(outputDir, 0750); err != nil {
149 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
150 }
151 outPath := filepath.Join(outputDir, export.SafeViewKey(key)+"."+ext)
152 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil { //nolint:gosec // output files are non-sensitive documentation
153 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
154 }
155 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
156 }
157
158 return nil
159 }
160
161 func handleNewFormats(cmd *cobra.Command, m *model.BausteinsichtModel, views map[string]model.View, diagramFormat, outputFormat, outputDir, viewKey string) error {
162 var renderFunc func(*model.BausteinsichtModel, string) (string, error)
163 var ext string
164
165 switch diagramFormat {
166 case "dot":
167 renderFunc = diagram.RenderDOT
168 ext = "dot"
169 case "d2":
170 renderFunc = diagram.RenderD2
171 ext = "d2"
172 case "html":
173 renderFunc = diagram.RenderHTML
174 ext = "html"
175 default:
176 return exitWithCode(fmt.Errorf("unsupported format: %s", diagramFormat), 2)
177 }
178
179 // When --format json, output structured JSON with diagram source
180 if outputFormat == "json" {
181 type diagramEntry struct {
182 View string `json:"view"`
183 Format string `json:"format"`
184 Source string `json:"source"`
185 }
186 var entries []diagramEntry
187 keys := sortedKeys(views)
188 for _, key := range keys {
189 result, fmtErr := renderFunc(m, key)
190 if fmtErr != nil {
191 return exitWithCode(fmtErr, 1)
192 }
193 entries = append(entries, diagramEntry{
194 View: key,
195 Format: diagramFormat,
196 Source: result,
197 })
198 }
199 data, _ := json.MarshalIndent(entries, "", " ")
200 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
201 return nil
202 }
203
204 // For HTML, create a single file containing all views
205 if diagramFormat == "html" {
206 // When exporting to HTML, we need to handle multiple views in a single file
207 if viewKey != "" {
208 // Single view HTML export
209 result, err := renderFunc(m, viewKey)
210 if err != nil {
211 return exitWithCode(err, 1)
212 }
213
214 if outputDir == "" {
215 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
216 return nil
217 }
218
219 if err := os.MkdirAll(outputDir, 0750); err != nil {
220 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
221 }
222
223 outPath := filepath.Join(outputDir, export.SafeViewKey(viewKey)+".html")
224 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil {
225 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
226 }
227 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
228 return nil
229 }
230
231 // Multiple views: export each as separate HTML file
232 keys := sortedKeys(views)
233 for _, key := range keys {
234 result, err := renderFunc(m, key)
235 if err != nil {
236 return exitWithCode(err, 1)
237 }
238
239 if outputDir == "" {
240 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
241 continue
242 }
243
244 if err := os.MkdirAll(outputDir, 0750); err != nil {
245 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
246 }
247
248 outPath := filepath.Join(outputDir, export.SafeViewKey(key)+".html")
249 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil {
250 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
251 }
252 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
253 }
254 return nil
255 }
256
257 // For DOT and D2: export each view separately
258 keys := sortedKeys(views)
259 for _, key := range keys {
260 result, err := renderFunc(m, key)
261 if err != nil {
262 return exitWithCode(err, 1)
263 }
264
265 if outputDir == "" {
266 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
267 continue
268 }
269
270 if err := os.MkdirAll(outputDir, 0750); err != nil {
271 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
272 }
273
274 outPath := filepath.Join(outputDir, "architecture-"+export.SafeViewKey(key)+"."+ext)
275 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil {
276 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
277 }
278 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
279 }
280
281 return nil
282 }
283
284 func sortedKeys(views map[string]model.View) []string {
285 keys := make([]string, 0, len(views))
286 for k := range views {
287 keys = append(keys, k)
288 }
289 sort.Strings(keys)
290 return keys
291 }
292
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export_sequence.go 86.6%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/diagram"
10 "github.com/docToolchain/Bausteinsicht/internal/export"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 "github.com/spf13/cobra"
13 )
14
15 func newExportSequenceCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "export-sequence",
18 Short: "Export dynamic views as PlantUML or Mermaid sequence diagrams",
19 Long: "Exports dynamic views (sequence diagrams) as PlantUML (.puml) or Mermaid (.md) text files.",
20 RunE: runExportSequence,
21 }
22 cmd.Flags().String("view", "", "Export only this dynamic view (by key)")
23 cmd.Flags().String("diagram-format", "plantuml", "Diagram format: plantuml or mermaid")
24 cmd.Flags().String("output", "", "Output directory (default: stdout)")
25 return cmd
26 }
27
28 func runExportSequence(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 viewKey, _ := cmd.Flags().GetString("view")
31 diagramFormat, _ := cmd.Flags().GetString("diagram-format")
32 outputDir, _ := cmd.Flags().GetString("output")
33 format, _ := cmd.Flags().GetString("format")
34
35 if outputDir != "" {
36 if err := validatePathContainment(outputDir); err != nil {
37 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
38 }
39 }
40
41 if modelPath == "" {
42 detected, err := model.AutoDetect(".")
43 if err != nil {
44 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
45 }
46 modelPath = detected
47 }
48
49 m, err := model.Load(modelPath)
50 if err != nil {
51 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
52 }
53
54 var ext string
55 switch diagramFormat {
56 case "plantuml":
57 ext = "puml"
58 case "mermaid":
59 ext = "md"
60 default:
61 return exitWithCode(fmt.Errorf("unknown diagram format %q: valid values are \"plantuml\" and \"mermaid\"", diagramFormat), 2)
62 }
63
64 // Select views to export.
65 views := m.DynamicViews
66 if viewKey != "" {
67 var found *model.DynamicView
68 for i := range m.DynamicViews {
69 if m.DynamicViews[i].Key == viewKey {
70 found = &m.DynamicViews[i]
71 break
72 }
73 }
74 if found == nil {
75 return exitWithCode(fmt.Errorf("dynamic view %q not found", viewKey), 1)
76 }
77 views = []model.DynamicView{*found}
78 }
79
80 if len(views) == 0 {
81 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "No dynamic views defined in model.")
82 return nil
83 }
84
85 flat, err := model.FlattenElements(m)
86 if err != nil {
87 return exitWithCode(fmt.Errorf("flattening elements: %w", err), 2)
88 }
89
90 render := func(v model.DynamicView) string {
91 if diagramFormat == "mermaid" {
92 return diagram.RenderSequenceMermaid(v, flat)
93 }
94 return diagram.RenderSequencePlantUML(v, flat)
95 }
96
97 // JSON output.
98 if format == "json" {
99 type entry struct {
100 View string `json:"view"`
101 Format string `json:"format"`
102 Source string `json:"source"`
103 }
104 var entries []entry
105 for _, v := range views {
106 entries = append(entries, entry{View: v.Key, Format: diagramFormat, Source: render(v)})
107 }
108 data, _ := json.MarshalIndent(entries, "", " ")
109 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
110 return nil
111 }
112
113 // Text / file output.
114 for _, v := range views {
115 source := render(v)
116
117 if outputDir == "" {
118 _, _ = fmt.Fprint(cmd.OutOrStdout(), source)
119 continue
120 }
121
122 if err := os.MkdirAll(outputDir, 0750); err != nil {
123 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
124 }
125 filename := "sequence-" + export.SafeViewKey(v.Key) + "." + ext
126 outPath := filepath.Join(outputDir, filename)
127 if err := os.WriteFile(outPath, []byte(source), 0600); err != nil { //nolint:gosec // output files are non-sensitive documentation
128 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
129 }
130 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
131 }
132
133 return nil
134 }
135
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export_table.go 71.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/export"
10 "github.com/docToolchain/Bausteinsicht/internal/model"
11 "github.com/docToolchain/Bausteinsicht/internal/table"
12 "github.com/spf13/cobra"
13 )
14
15 func newExportTableCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "export-table",
18 Short: "Export element attributes as AsciiDoc or Markdown table",
19 Long: "Exports view elements as a table with columns: Element, Kind, Technology, Description.",
20 RunE: runExportTable,
21 }
22
23 cmd.Flags().String("view", "", "Export only this view (by key)")
24 cmd.Flags().String("table-format", "adoc", "Table format: adoc or md")
25 cmd.Flags().String("output", "", "Output directory (default: stdout)")
26 cmd.Flags().Bool("combined", false, "Export all elements across all views (deduplicated)")
27
28 return cmd
29 }
30
31 func runExportTable(cmd *cobra.Command, _ []string) error {
32 modelPath, _ := cmd.Flags().GetString("model")
33 format, _ := cmd.Flags().GetString("format")
34 viewKey, _ := cmd.Flags().GetString("view")
35 tableFormat, _ := cmd.Flags().GetString("table-format")
36 outputDir, _ := cmd.Flags().GetString("output")
37 combined, _ := cmd.Flags().GetBool("combined")
38
39 if outputDir != "" {
40 if err := validatePathContainment(outputDir); err != nil {
41 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
42 }
43 }
44
45 if modelPath == "" {
46 detected, err := model.AutoDetect(".")
47 if err != nil {
48 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
49 }
50 modelPath = detected
51 }
52
53 m, err := model.Load(modelPath)
54 if err != nil {
55 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
56 }
57
58 // When --format json is set, output structured JSON instead of a table. (#239)
59 if format == "json" {
60 return exportTableJSON(cmd, m, viewKey, combined)
61 }
62
63 var f table.Format
64 switch tableFormat {
65 case "adoc":
66 f = table.AsciiDoc
67 case "md":
68 f = table.Markdown
69 default:
70 return exitWithCode(fmt.Errorf("unknown table format %q: valid values are \"adoc\" and \"md\"", tableFormat), 2)
71 }
72
73 var result string
74 var filename string
75
76 switch {
77 case combined:
78 result, err = table.FormatCombined(m, f)
79 filename = "elements." + tableFormat
80 case viewKey != "":
81 result, err = table.FormatView(m, viewKey, f)
82 filename = export.SafeViewKey(viewKey) + "-elements." + tableFormat
83 default:
84 result, err = table.FormatAllViews(m, f)
85 filename = "all-views-elements." + tableFormat
86 }
87 if err != nil {
88 return exitWithCode(err, 1)
89 }
90
91 if outputDir == "" {
92 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
93 return nil
94 }
95
96 outPath := filepath.Join(outputDir, filename)
97 if err := os.MkdirAll(outputDir, 0750); err != nil {
98 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
99 }
100 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil { //nolint:gosec // output files are non-sensitive documentation
101 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
102 }
103
104 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
105 return nil
106 }
107
108 // exportTableJSON outputs the table data as JSON. (#239)
109 func exportTableJSON(cmd *cobra.Command, m *model.BausteinsichtModel, viewKey string, combined bool) error {
110 rows, err := table.CollectRows(m, viewKey, combined)
111 if err != nil {
112 return exitWithCode(err, 1)
113 }
114 data, err := json.MarshalIndent(rows, "", " ")
115 if err != nil {
116 return exitWithCode(fmt.Errorf("marshaling JSON: %w", err), 2)
117 }
118 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
119 return nil
120 }
121
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/find.go 75.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/search"
10 "github.com/spf13/cobra"
11 )
12
13 func newFindCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "find <query>",
16 Short: "Search elements, relationships, and views by free-text query",
17 Long: `Search all model objects (elements, relationships, views) for the given query.
18
19 All words in a multi-word query must match (AND semantics). Matching is
20 case-insensitive and partial (e.g. "pay" matches "payment-service").
21
22 Results are ranked by relevance score. Use --format json for LLM workflows.`,
23 Args: cobra.MinimumNArgs(1),
24 SilenceUsage: true,
25 SilenceErrors: true,
26 RunE: runFind,
27 }
28 cmd.Flags().String("type", "all", "Limit results to: element, relationship, view, all")
29 return cmd
30 }
31
32 func runFind(cmd *cobra.Command, args []string) error {
33 query := strings.Join(args, " ")
34 format, _ := cmd.Flags().GetString("format")
35 modelPath, _ := cmd.Flags().GetString("model")
36 typeFlag, _ := cmd.Flags().GetString("type")
37
38 if modelPath == "" {
39 detected, err := model.AutoDetect(".")
40 if err != nil {
41 return exitWithCode(err, 2)
42 }
43 modelPath = detected
44 }
45
46 m, err := model.Load(modelPath)
47 if err != nil {
48 return exitWithCode(err, 2)
49 }
50
51 opts := search.Options{}
52 switch typeFlag {
53 case "element":
54 opts.Type = search.ResultElement
55 case "relationship":
56 opts.Type = search.ResultRelationship
57 case "view":
58 opts.Type = search.ResultView
59 case "all", "":
60 // no filter
61 default:
62 return exitWithCode(fmt.Errorf("unknown --type %q: use element, relationship, view, or all", typeFlag), 2)
63 }
64
65 resp := search.Run(query, m, opts)
66
67 if format == "json" {
68 data, err := json.MarshalIndent(resp, "", " ")
69 if err != nil {
70 return err
71 }
72 _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
73 return err
74 }
75
76 return printFindText(cmd, resp)
77 }
78
79 func printFindText(cmd *cobra.Command, resp search.Response) error {
80 out := cmd.OutOrStdout()
81 if resp.Total == 0 {
82 _, err := fmt.Fprintf(out, "No results for %q.\n", resp.Query)
83 return err
84 }
85
86 header := fmt.Sprintf("Search results for %q (%d match", resp.Query, resp.Total)
87 if resp.Total != 1 {
88 header += "es"
89 }
90 header += ")"
91 if _, err := fmt.Fprintln(out, header); err != nil {
92 return err
93 }
94 if _, err := fmt.Fprintln(out, strings.Repeat("=", len(header))); err != nil {
95 return err
96 }
97 if _, err := fmt.Fprintln(out); err != nil {
98 return err
99 }
100
101 // Group by type for display.
102 var elements, relationships, views []search.Result
103 for _, r := range resp.Results {
104 switch r.Type {
105 case search.ResultElement:
106 elements = append(elements, r)
107 case search.ResultRelationship:
108 relationships = append(relationships, r)
109 case search.ResultView:
110 views = append(views, r)
111 }
112 }
113
114 if len(elements) > 0 {
115 if _, err := fmt.Fprintf(out, "Elements (%d):\n", len(elements)); err != nil {
116 return err
117 }
118 for _, r := range elements {
119 extra := ""
120 if r.Technology != "" {
121 extra = " technology: " + r.Technology
122 }
123 if _, err := fmt.Fprintf(out, " %-28s [%-10s] %-35s%s score: %d\n",
124 r.ID, r.Kind, fmt.Sprintf("%q", r.Title), extra, r.Score); err != nil {
125 return err
126 }
127 }
128 if _, err := fmt.Fprintln(out); err != nil {
129 return err
130 }
131 }
132
133 if len(relationships) > 0 {
134 if _, err := fmt.Fprintf(out, "Relationships (%d):\n", len(relationships)); err != nil {
135 return err
136 }
137 for _, r := range relationships {
138 label := ""
139 if r.Title != "" {
140 label = fmt.Sprintf("%q", r.Title)
141 }
142 if _, err := fmt.Fprintf(out, " %-28s %s → %s %s score: %d\n",
143 r.ID, r.From, r.To, label, r.Score); err != nil {
144 return err
145 }
146 }
147 if _, err := fmt.Fprintln(out); err != nil {
148 return err
149 }
150 }
151
152 if len(views) > 0 {
153 if _, err := fmt.Fprintf(out, "Views (%d):\n", len(views)); err != nil {
154 return err
155 }
156 for _, r := range views {
157 if _, err := fmt.Fprintf(out, " %-28s %-35s score: %d\n",
158 r.ID, fmt.Sprintf("%q", r.Title), r.Score); err != nil {
159 return err
160 }
161 }
162 if _, err := fmt.Fprintln(out); err != nil {
163 return err
164 }
165 }
166
167 return nil
168 }
169
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/generate_template.go 83.9%
1 package main
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/template"
10 "github.com/spf13/cobra"
11 )
12
13 func newGenerateTemplateCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "generate-template",
16 Short: "Generate a draw.io template from element specification",
17 Long: "Creates a draw.io template file with visual styles for all element kinds defined in the spec.",
18 RunE: runGenerateTemplate,
19 }
20
21 cmd.Flags().String("model", "", "Model file (default: auto-detect)")
22 cmd.Flags().String("output", "architecture-template.drawio", "Output template file")
23 cmd.Flags().String("style", "default", "Visual preset: default, c4, minimal, or dark")
24
25 return cmd
26 }
27
28 func runGenerateTemplate(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 outputPath, _ := cmd.Flags().GetString("output")
31 style, _ := cmd.Flags().GetString("style")
32
33 // Validate output path containment
34 if err := validatePathContainment(outputPath); err != nil {
35 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
36 }
37
38 // Auto-detect model if not provided
39 if modelPath == "" {
40 detected, err := model.AutoDetect(".")
41 if err != nil {
42 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
43 }
44 modelPath = detected
45 }
46
47 // Load model
48 m, err := model.Load(modelPath)
49 if err != nil {
50 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
51 }
52
53 // Validate style
54 validStyles := map[string]bool{
55 "default": true,
56 "c4": true,
57 "minimal": true,
58 "dark": true,
59 }
60 if !validStyles[style] {
61 return exitWithCode(fmt.Errorf("unknown style %q: valid values are default, c4, minimal, dark", style), 2)
62 }
63
64 // Generate template
65 gen := template.NewGenerator(m.Specification, style)
66 templateXML := gen.Generate()
67
68 // Write output
69 outputDir := filepath.Dir(outputPath)
70 if outputDir != "." && outputDir != "" {
71 if err := os.MkdirAll(outputDir, 0750); err != nil {
72 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
73 }
74 }
75
76 if err := os.WriteFile(outputPath, []byte(templateXML), 0600); err != nil { //nolint:gosec
77 return exitWithCode(fmt.Errorf("writing template: %w", err), 2)
78 }
79
80 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Generated template: %s\n", outputPath)
81 return nil
82 }
83
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/graph.go 6.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "sort"
8 "strings"
9
10 "github.com/docToolchain/Bausteinsicht/internal/graph"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 "github.com/spf13/cobra"
13 )
14
15 func newGraphCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "graph",
18 Short: "Analyze relationship graph for cycles and dependencies",
19 Long: "Analyzes the relationship graph to detect cycles, calculate centrality metrics, and identify dependency patterns.",
20 RunE: runGraph,
21 }
22
23 cmd.Flags().String("output", "", "Output file for analysis report (default: stdout)")
24 cmd.Flags().Bool("cycles-only", false, "Show only detected cycles")
25 cmd.Flags().Bool("centrality", false, "Show centrality metrics for each element")
26
27 return cmd
28 }
29
30 func runGraph(cmd *cobra.Command, _ []string) error {
31 modelPath, _ := cmd.Flags().GetString("model")
32 format, _ := cmd.Flags().GetString("format")
33 outputPath, _ := cmd.Flags().GetString("output")
34 cyclesOnly, _ := cmd.Flags().GetBool("cycles-only")
35 showCentrality, _ := cmd.Flags().GetBool("centrality")
36
37 if modelPath == "" {
38 return exitWithCode(fmt.Errorf("--model flag is required"), 2)
39 }
40
41 m, err := model.Load(modelPath)
42 if err != nil {
43 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
44 }
45
46 // Analyze graph
47 analyzer := graph.NewAnalyzer(m)
48 result := analyzer.Analyze()
49
50 // Format output
51 var output string
52
53 if format == "json" {
54 data, _ := json.MarshalIndent(result, "", " ")
55 output = string(data)
56 } else {
57 output = formatGraphReport(result, cyclesOnly, showCentrality)
58 }
59
60 // Write output
61 if outputPath != "" {
62 if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil {
63 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
64 }
65 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Graph analysis written to %s\n", outputPath)
66 } else {
67 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
68 }
69
70 return nil
71 }
72
73 func formatGraphReport(result *graph.GraphAnalysis, cyclesOnly, showCentrality bool) string {
74 var sb strings.Builder
75
76 sb.WriteString("Relationship Graph Analysis\n")
77 sb.WriteString("===========================\n\n")
78
79 // Summary
80 sb.WriteString("Summary\n")
81 sb.WriteString("-------\n")
82 fmt.Fprintf(&sb, "Elements: %d\n", result.ElementCount)
83 fmt.Fprintf(&sb, "Relationships: %d\n", result.RelationshipCount)
84 fmt.Fprintf(&sb, "Max Dependency Depth: %d\n", result.MaxDepth)
85 sb.WriteString("Graph Type: ")
86 if result.IDAGValid {
87 sb.WriteString("DAG (acyclic)")
88 } else {
89 sb.WriteString("Cyclic (contains cycles)")
90 }
91 sb.WriteString("\n\n")
92
93 // Cycles
94 if len(result.Cycles) > 0 {
95 fmt.Fprintf(&sb, "Cycles Found: %d\n", len(result.Cycles))
96 sb.WriteString("--------\n")
97 for idx, cycle := range result.Cycles {
98 fmt.Fprintf(&sb, "Cycle %d (length %d): %s\n", idx+1, cycle.Length, strings.Join(cycle.Elements, " → "))
99 }
100 sb.WriteString("\n")
101 } else {
102 sb.WriteString("No cycles detected (valid DAG)\n\n")
103 }
104
105 if cyclesOnly {
106 return sb.String()
107 }
108
109 // Strongly connected components
110 if len(result.Components) > 0 {
111 cycleCount := 0
112 for _, comp := range result.Components {
113 if comp.IsCycle {
114 cycleCount++
115 }
116 }
117 fmt.Fprintf(&sb, "Strongly Connected Components: %d\n", len(result.Components))
118 if cycleCount > 0 {
119 fmt.Fprintf(&sb, " (includes %d cycle(s))\n", cycleCount)
120 }
121 sb.WriteString("--------\n")
122 for _, comp := range result.Components {
123 if comp.IsCycle {
124 fmt.Fprintf(&sb, "Component %d (CYCLE): %v\n", comp.ID+1, comp.Elements)
125 }
126 }
127 sb.WriteString("\n")
128 }
129
130 // Centrality metrics
131 if showCentrality && len(result.Centrality) > 0 {
132 sb.WriteString("Centrality Metrics\n")
133 sb.WriteString("------------------\n")
134 sb.WriteString("Element | In-Degree | Out-Degree | Betweenness | Closeness\n")
135 sb.WriteString("---------------------- | --------- | ---------- | ----------- | ---------\n")
136
137 // Sort by out-degree descending
138 sorted := make([]graph.Centrality, len(result.Centrality))
139 copy(sorted, result.Centrality)
140 sort.Slice(sorted, func(i, j int) bool {
141 if sorted[i].OutDegree != sorted[j].OutDegree {
142 return sorted[i].OutDegree > sorted[j].OutDegree
143 }
144 return sorted[i].ID < sorted[j].ID
145 })
146
147 for _, c := range sorted {
148 elemName := c.ID
149 if len(elemName) > 22 {
150 elemName = elemName[:19] + "..."
151 }
152 fmt.Fprintf(&sb, "%-22s | %9d | %10d | %11.2f | %9.2f\n",
153 elemName, c.InDegree, c.OutDegree, c.Betweenness, c.Closeness)
154 }
155 sb.WriteString("\n")
156 }
157
158 return sb.String()
159 }
160
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/health.go 6.8%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/health"
10 "github.com/docToolchain/Bausteinsicht/internal/model"
11 "github.com/spf13/cobra"
12 )
13
14 func newHealthCmd() *cobra.Command {
15 cmd := &cobra.Command{
16 Use: "health",
17 Short: "Assess architecture health score",
18 Long: "Computes a comprehensive architecture health score across multiple dimensions including completeness, conformance, and complexity.",
19 RunE: runHealth,
20 }
21
22 cmd.Flags().String("output", "", "Output file for health report (default: stdout)")
23 cmd.Flags().Bool("summary", false, "Show only the overall score and grade")
24
25 return cmd
26 }
27
28 func runHealth(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 format, _ := cmd.Flags().GetString("format")
31 outputPath, _ := cmd.Flags().GetString("output")
32 summaryOnly, _ := cmd.Flags().GetBool("summary")
33
34 if modelPath == "" {
35 return exitWithCode(fmt.Errorf("--model flag is required"), 2)
36 }
37
38 m, err := model.Load(modelPath)
39 if err != nil {
40 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
41 }
42
43 // Compute health score
44 analyzer := health.NewAnalyzer(m)
45 score := analyzer.Analyze()
46
47 // Format output
48 var output string
49
50 if format == "json" {
51 if summaryOnly {
52 summary := map[string]interface{}{
53 "overall": score.Overall,
54 "grade": score.Grade,
55 "summary": score.Summary,
56 "timestamp": score.Timestamp,
57 }
58 data, _ := json.MarshalIndent(summary, "", " ")
59 output = string(data)
60 } else {
61 data, _ := json.MarshalIndent(score, "", " ")
62 output = string(data)
63 }
64 } else {
65 output = formatHealthReport(score, summaryOnly)
66 }
67
68 // Write output
69 if outputPath != "" {
70 if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil {
71 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
72 }
73 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Health report written to %s\n", outputPath)
74 } else {
75 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
76 }
77
78 return nil
79 }
80
81 func formatHealthReport(score *health.HealthScore, summaryOnly bool) string {
82 var sb strings.Builder
83
84 // Header
85 sb.WriteString("Architecture Health Report\n")
86 sb.WriteString("==========================\n\n")
87
88 // Overall score
89 fmt.Fprintf(&sb, "Overall Score: %.1f/100 [%s]\n", score.Overall, score.Grade)
90 fmt.Fprintf(&sb, "Summary: %s\n", score.Summary)
91 fmt.Fprintf(&sb, "Timestamp: %s\n\n", score.Timestamp)
92
93 if summaryOnly {
94 return sb.String()
95 }
96
97 // Model stats
98 sb.WriteString("Model Statistics\n")
99 sb.WriteString("----------------\n")
100 fmt.Fprintf(&sb, "Elements: %d\n", score.ElementCnt)
101 fmt.Fprintf(&sb, "Relationships: %d\n", score.RelCnt)
102 fmt.Fprintf(&sb, "Views: %d\n\n", score.ViewCnt)
103
104 // Category scores
105 sb.WriteString("Category Scores\n")
106 sb.WriteString("---------------\n")
107 for _, cat := range score.Categories {
108 fmt.Fprintf(&sb, "%s: %.1f/100 (weight: %.0f%%)\n", cat.Category, cat.Score, cat.Weight*100)
109 if cat.Details != "" {
110 fmt.Fprintf(&sb, " Details: %s\n", cat.Details)
111 }
112 }
113
114 // Findings
115 if len(score.Categories) > 0 {
116 sb.WriteString("\nFindings\n")
117 sb.WriteString("--------\n")
118
119 for _, cat := range score.Categories {
120 if len(cat.Findings) > 0 {
121 fmt.Fprintf(&sb, "\n%s (%d findings):\n", cat.Category, len(cat.Findings))
122 for _, f := range cat.Findings {
123 fmt.Fprintf(&sb, " [%s] %s\n", f.Severity, f.Title)
124 fmt.Fprintf(&sb, " %s\n", f.Message)
125 }
126 }
127 }
128 }
129
130 return sb.String()
131 }
132
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/import.go 79.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "github.com/docToolchain/Bausteinsicht/internal/importer/likec4"
11 "github.com/docToolchain/Bausteinsicht/internal/importer/structurizr"
12 "github.com/spf13/cobra"
13 )
14
15 func newImportCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "import <input-file>",
18 Short: "Import an architecture model from Structurizr DSL or LikeC4",
19 Long: `Imports an architecture model from an external DSL format and writes a
20 Bausteinsicht-compatible architecture.jsonc file.
21
22 Supported formats:
23 structurizr Structurizr DSL (.dsl)
24 likec4 LikeC4 DSL (.c4)
25
26 Exit codes:
27 0 import successful
28 1 parse error
29 2 output file already exists (use --force to overwrite)`,
30 Args: cobra.ExactArgs(1),
31 SilenceUsage: true,
32 SilenceErrors: true,
33 RunE: runImport,
34 }
35 cmd.Flags().String("from", "", "Source format: structurizr or likec4 (required)")
36 cmd.Flags().String("output", "architecture.jsonc", "Output model file path")
37 cmd.Flags().Bool("dry-run", false, "Print generated model to stdout instead of writing file")
38 cmd.Flags().Bool("force", false, "Overwrite output file if it already exists")
39 _ = cmd.MarkFlagRequired("from")
40 return cmd
41 }
42
43 func runImport(cmd *cobra.Command, args []string) error {
44 inputPath := args[0]
45 from, _ := cmd.Flags().GetString("from")
46 outputPath, _ := cmd.Flags().GetString("output")
47 dryRun, _ := cmd.Flags().GetBool("dry-run")
48 force, _ := cmd.Flags().GetBool("force")
49
50 from = strings.ToLower(strings.TrimSpace(from))
51 if from != "structurizr" && from != "likec4" {
52 return exitWithCode(fmt.Errorf("unknown format %q: valid values are \"structurizr\" and \"likec4\"", from), 1)
53 }
54
55 if err := validatePathContainment(inputPath); err != nil {
56 return exitWithCode(fmt.Errorf("input: %w", err), 1)
57 }
58 if err := validatePathContainment(outputPath); err != nil {
59 return exitWithCode(fmt.Errorf("--output: %w", err), 1)
60 }
61
62 if !dryRun && !force {
63 if _, err := os.Stat(outputPath); err == nil {
64 return exitWithCode(
65 fmt.Errorf("output file %q already exists — use --force to overwrite", outputPath),
66 2,
67 )
68 }
69 }
70
71 var (
72 importedModel any
73 warnings []string
74 )
75
76 switch from {
77 case "structurizr":
78 r, err := structurizr.Import(inputPath)
79 if err != nil {
80 return exitWithCode(fmt.Errorf("import failed: %w", err), 1)
81 }
82 importedModel, warnings = r.Model, r.Warnings
83 case "likec4":
84 r, err := likec4.Import(inputPath)
85 if err != nil {
86 return exitWithCode(fmt.Errorf("import failed: %w", err), 1)
87 }
88 importedModel, warnings = r.Model, r.Warnings
89 }
90
91 data, err := json.MarshalIndent(importedModel, "", " ")
92 if err != nil {
93 return exitWithCode(fmt.Errorf("encoding model: %w", err), 1)
94 }
95
96 if dryRun {
97 if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
98 return err
99 }
100 } else {
101 if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
102 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 1)
103 }
104 if err := os.WriteFile(outputPath, append(data, '\n'), 0o644); err != nil {
105 return exitWithCode(fmt.Errorf("writing %s: %w", outputPath, err), 1)
106 }
107 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Imported model written to %s\n", outputPath); err != nil {
108 return err
109 }
110 }
111
112 for _, w := range warnings {
113 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "WARNING: %s\n", w); err != nil {
114 return err
115 }
116 }
117
118 return nil
119 }
120
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/init.go 86.8%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "time"
9
10 "github.com/docToolchain/Bausteinsicht/internal/drawio"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 bsync "github.com/docToolchain/Bausteinsicht/internal/sync"
13 "github.com/docToolchain/Bausteinsicht/internal/template"
14 "github.com/docToolchain/Bausteinsicht/templates"
15 "github.com/spf13/cobra"
16 )
17
18 const (
19 defaultModelFile = "architecture.jsonc"
20 defaultDrawioFile = "architecture.drawio"
21 defaultTemplFile = "template.drawio"
22 defaultSyncState = ".bausteinsicht-sync"
23 )
24
25 func newInitCmd() *cobra.Command {
26 cmd := &cobra.Command{
27 Use: "init",
28 Short: "Initialize a new architecture project",
29 Long: "Creates a sample model, template, and initial draw.io diagram in the current directory.",
30 RunE: runInit,
31 }
32 cmd.Flags().Bool("generate-template", false, "Generate template from spec instead of using default")
33 return cmd
34 }
35
36 func runInit(cmd *cobra.Command, _ []string) error {
37 format, _ := cmd.Flags().GetString("format")
38 generateTemplate, _ := cmd.Flags().GetBool("generate-template")
39
40 // Check if files already exist.
41 for _, name := range []string{defaultModelFile, defaultDrawioFile, defaultTemplFile} {
42 if _, err := os.Stat(name); err == nil {
43 return exitWithCode(
44 fmt.Errorf("file %q already exists; remove it or use a different directory", name),
45 2,
46 )
47 }
48 }
49
50 // Write sample model.
51 if err := os.WriteFile(defaultModelFile, templates.SampleModel, 0600); err != nil {
52 return exitWithCode(fmt.Errorf("writing %s: %w", defaultModelFile, err), 2)
53 }
54
55 // Load model for sync.
56 m, err := model.Load(defaultModelFile)
57 if err != nil {
58 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
59 }
60
61 // Generate or use default template.
62 var templateBytes []byte
63 if generateTemplate {
64 gen := template.NewGenerator(m.Specification, "default")
65 templateXML := gen.Generate()
66 templateBytes = []byte(templateXML)
67 } else {
68 templateBytes = templates.DefaultTemplate
69 }
70
71 // Write template.
72 if err := os.WriteFile(defaultTemplFile, templateBytes, 0600); err != nil {
73 return exitWithCode(fmt.Errorf("writing %s: %w", defaultTemplFile, err), 2)
74 }
75
76 // Load template.
77 tmpl, err := drawio.LoadTemplateFromBytes(templateBytes)
78 if err != nil {
79 return exitWithCode(fmt.Errorf("loading template: %w", err), 2)
80 }
81
82 // Create empty document and run initial forward sync.
83 doc := drawio.NewDocument()
84 emptyState := &bsync.SyncState{
85 Elements: make(map[string]bsync.ElementState),
86 Relationships: []bsync.RelationshipState{},
87 }
88
89 // Add pages for each view before sync.
90 for viewID, view := range m.Views {
91 doc.AddPage("view-"+viewID, view.Title)
92 }
93
94 // Pass ForwardOptions so metadata/legend boxes are created during init,
95 // preventing the first sync from reporting metadata changes (#265).
96 // Use the same relative model path that sync would use.
97 fwdOpts := bsync.ForwardOptions{
98 ModelPath: defaultModelFile,
99 SyncTime: time.Now().Format("2006-01-02 15:04"),
100 }
101 _ = bsync.Run(m, doc, emptyState, tmpl, nil, fwdOpts)
102
103 // Save generated draw.io file.
104 if err := drawio.SaveDocument(defaultDrawioFile, doc); err != nil {
105 return exitWithCode(fmt.Errorf("writing %s: %w", defaultDrawioFile, err), 2)
106 }
107
108 // Build and save sync state.
109 absModel, _ := filepath.Abs(defaultModelFile)
110 absDrawio, _ := filepath.Abs(defaultDrawioFile)
111 state, err := bsync.BuildState(m, doc, absModel, absDrawio)
112 if err != nil {
113 return exitWithCode(fmt.Errorf("building sync state: %w", err), 2)
114 }
115 if err := bsync.SaveState(defaultSyncState, state); err != nil {
116 return exitWithCode(fmt.Errorf("writing %s: %w", defaultSyncState, err), 2)
117 }
118
119 // Output result.
120 createdFiles := []string{defaultModelFile, defaultTemplFile, defaultDrawioFile, defaultSyncState}
121
122 if format == "json" {
123 out := map[string]interface{}{
124 "success": true,
125 "files": createdFiles,
126 }
127 data, _ := json.Marshal(out)
128 fmt.Println(string(data))
129 } else {
130 fmt.Println("Initialized Bausteinsicht project:")
131 for _, f := range createdFiles {
132 fmt.Printf(" - %s\n", f)
133 }
134 fmt.Println()
135 fmt.Println("Next steps:")
136 fmt.Println(" 1. Edit architecture.jsonc to define your architecture")
137 fmt.Println(" 2. Run 'bausteinsicht sync' to update the draw.io diagram")
138 fmt.Println(" 3. Open architecture.drawio in draw.io to arrange elements")
139 }
140
141 return nil
142 }
143
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/layout.go 16.1%
1 package main
2
3 import (
4 "fmt"
5 "path/filepath"
6
7 "github.com/docToolchain/Bausteinsicht/internal/drawio"
8 "github.com/docToolchain/Bausteinsicht/internal/layout"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 func newLayoutCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "layout",
16 Short: "Auto-layout elements in draw.io diagram",
17 Long: `Computes hierarchical layout for diagram elements and writes positions back to draw.io.
18 Pinned elements (with bausteinsicht-pinned=true) are preserved by default.`,
19 RunE: runLayout,
20 }
21
22 cmd.Flags().String("algorithm", "hierarchical", "Layout algorithm: hierarchical (currently only option)")
23 cmd.Flags().String("rank-dir", "TB", "Ranking direction: TB (top-to-bottom) or LR (left-to-right)")
24 cmd.Flags().Bool("preserve-pinned", true, "Don't move pinned elements (bausteinsicht-pinned=true)")
25
26 return cmd
27 }
28
29 func runLayout(cmd *cobra.Command, _ []string) error {
30 modelPath, _ := cmd.Flags().GetString("model")
31 if modelPath == "" {
32 detected, err := model.AutoDetect(".")
33 if err != nil {
34 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
35 }
36 modelPath = detected
37 }
38
39 // Load model
40 m, err := model.Load(modelPath)
41 if err != nil {
42 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
43 }
44
45 // Validate model
46 if errs := model.Validate(m); len(errs) > 0 {
47 return exitWithCode(fmt.Errorf("model validation failed: %v", errs), 2)
48 }
49
50 // Derive draw.io path from model path
51 dir := filepath.Dir(modelPath)
52 drawioPath := filepath.Join(dir, "architecture.drawio")
53
54 doc, err := drawio.LoadDocument(drawioPath)
55 if err != nil {
56 return exitWithCode(fmt.Errorf("loading diagram: %w", err), 2)
57 }
58
59 rankDir, _ := cmd.Flags().GetString("rank-dir")
60 preservePinned, _ := cmd.Flags().GetBool("preserve-pinned")
61
62 // Compute hierarchical layout
63 h := layout.NewHierarchicalLayout(m, rankDir)
64 result := h.Compute()
65
66 // Apply layout to diagram
67 if err := layout.Apply(doc, result, preservePinned); err != nil {
68 return exitWithCode(fmt.Errorf("applying layout: %w", err), 2)
69 }
70
71 // Save diagram
72 if err := drawio.SaveDocument(drawioPath, doc); err != nil {
73 return exitWithCode(fmt.Errorf("saving diagram: %w", err), 2)
74 }
75
76 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Layout applied (hierarchical): %s\n", drawioPath)
77 return nil
78 }
79
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/lint.go 78.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/constraints"
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 func newLintCmd() *cobra.Command {
13 return &cobra.Command{
14 Use: "lint",
15 Short: "Check architecture constraints",
16 Long: "Evaluates all constraints defined in the model and reports violations.",
17 SilenceUsage: true,
18 SilenceErrors: true,
19 RunE: runLint,
20 }
21 }
22
23 func runLint(cmd *cobra.Command, _ []string) error {
24 format, _ := cmd.Flags().GetString("format")
25 modelPath, _ := cmd.Flags().GetString("model")
26
27 if modelPath == "" {
28 detected, err := model.AutoDetect(".")
29 if err != nil {
30 return exitWithCode(err, 2)
31 }
32 modelPath = detected
33 }
34
35 m, err := model.Load(modelPath)
36 if err != nil {
37 return exitWithCode(err, 2)
38 }
39
40 if len(m.Constraints) == 0 {
41 if _, err := fmt.Fprintln(cmd.OutOrStdout(), "No constraints defined."); err != nil {
42 return err
43 }
44 return nil
45 }
46
47 result := constraints.Evaluate(m)
48
49 if format == "json" {
50 return lintOutputJSON(cmd, result)
51 }
52 return lintOutputText(cmd, result)
53 }
54
55 func lintOutputJSON(cmd *cobra.Command, r constraints.Result) error {
56 type jsonResult struct {
57 Passed bool `json:"passed"`
58 Total int `json:"total"`
59 Violations []constraints.Violation `json:"violations"`
60 }
61
62 out := jsonResult{
63 Passed: r.Total == 0,
64 Total: r.Total,
65 Violations: r.Violations,
66 }
67 if out.Violations == nil {
68 out.Violations = []constraints.Violation{}
69 }
70
71 data, err := json.MarshalIndent(out, "", " ")
72 if err != nil {
73 return err
74 }
75 if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
76 return err
77 }
78 if r.Total > 0 {
79 return exitWithCode(fmt.Errorf("lint: %d violation(s) found", r.Total), 1)
80 }
81 return nil
82 }
83
84 func lintOutputText(cmd *cobra.Command, r constraints.Result) error {
85 if r.Total == 0 {
86 _, err := fmt.Fprintln(cmd.OutOrStdout(), "All constraints passed.")
87 return err
88 }
89
90 for _, v := range r.Violations {
91 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "VIOLATION [%s]: %s\n", v.ConstraintID, v.Message); err != nil {
92 return err
93 }
94 for _, el := range v.Elements {
95 if _, err := fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", el); err != nil {
96 return err
97 }
98 }
99 }
100
101 return exitWithCode(fmt.Errorf("lint: %d violation(s) found", r.Total), 1)
102 }
103
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/main.go 0.0%
1 package main
2
3 import "os"
4
5 var version = "dev"
6
7 func main() {
8 rootCmd := NewRootCmd()
9
10 if err := ExecuteRoot(rootCmd); err != nil {
11 if e, ok := err.(*exitError); ok {
12 os.Exit(e.code)
13 }
14 os.Exit(1)
15 }
16 }
17
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/overlay.go 18.6%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/overlay"
10 "github.com/spf13/cobra"
11 )
12
13 func newOverlayCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "overlay",
16 Short: "Apply or remove metric heatmap overlays on architecture diagrams",
17 Long: "Load external metrics (error rate, coverage, etc.) from JSON and overlay them as a heatmap on draw.io elements. Original styles are preserved.",
18 }
19
20 cmd.AddCommand(newOverlayApplyCmd())
21 cmd.AddCommand(newOverlayRemoveCmd())
22 cmd.AddCommand(newOverlayListCmd())
23
24 return cmd
25 }
26
27 func newOverlayApplyCmd() *cobra.Command {
28 cmd := &cobra.Command{
29 Use: "apply <metrics-file>",
30 Short: "Apply metric heatmap to draw.io diagram",
31 Long: "Load metrics from JSON file and apply heatmap colors to elements. Original colors are saved in metadata.",
32 Args: cobra.ExactArgs(1),
33 RunE: func(cmd *cobra.Command, args []string) error {
34 metricsPath := args[0]
35 modelPath, _ := cmd.Flags().GetString("model")
36 metricKey, _ := cmd.Flags().GetString("metric")
37 outputPath, _ := cmd.Flags().GetString("output")
38
39 m, err := model.Load(modelPath)
40 if err != nil {
41 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
42 }
43
44 drawioPath := outputPath
45 if drawioPath == "" {
46 drawioPath = filepath.Join(filepath.Dir(modelPath), "architecture.drawio")
47 }
48
49 mf, err := overlay.LoadMetricsFile(metricsPath)
50 if err != nil {
51 return exitWithCode(fmt.Errorf("loading metrics: %w", err), 2)
52 }
53
54 if metricKey == "" {
55 if len(mf.Metrics) > 0 && len(mf.Metrics[0].Values) > 0 {
56 for k := range mf.Metrics[0].Values {
57 metricKey = k
58 break
59 }
60 }
61 if metricKey == "" {
62 return exitWithCode(fmt.Errorf("no metrics found in file"), 2)
63 }
64 }
65
66 if err := overlay.Apply(drawioPath, mf, metricKey, overlay.DefaultColorScheme); err != nil {
67 return exitWithCode(fmt.Errorf("applying overlay: %w", err), 2)
68 }
69
70 format, _ := cmd.Flags().GetString("format")
71 if format == "json" {
72 out, _ := json.Marshal(map[string]interface{}{
73 "status": "applied",
74 "metric": metricKey,
75 "file": drawioPath,
76 "model": m.Specification,
77 })
78 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
79 } else {
80 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "✅ Overlay applied: %s (metric: %s)\n", drawioPath, metricKey)
81 }
82 return nil
83 },
84 }
85 cmd.Flags().String("metric", "", "Metric key to visualize (default: first available)")
86 cmd.Flags().String("output", "", "Output draw.io file (default: architecture.drawio)")
87 return cmd
88 }
89
90 func newOverlayRemoveCmd() *cobra.Command {
91 cmd := &cobra.Command{
92 Use: "remove",
93 Short: "Remove metric overlay from diagram (restore original colors)",
94 RunE: func(cmd *cobra.Command, args []string) error {
95 modelPath, _ := cmd.Flags().GetString("model")
96 outputPath, _ := cmd.Flags().GetString("output")
97
98 _, err := model.Load(modelPath)
99 if err != nil {
100 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
101 }
102
103 drawioPath := outputPath
104 if drawioPath == "" {
105 drawioPath = filepath.Join(filepath.Dir(modelPath), "architecture.drawio")
106 }
107
108 if err := overlay.Remove(drawioPath); err != nil {
109 return exitWithCode(fmt.Errorf("removing overlay: %w", err), 2)
110 }
111
112 format, _ := cmd.Flags().GetString("format")
113 if format == "json" {
114 out, _ := json.Marshal(map[string]interface{}{
115 "status": "removed",
116 "file": drawioPath,
117 })
118 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
119 } else {
120 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "✅ Overlay removed: %s (original colors restored)\n", drawioPath)
121 }
122 return nil
123 },
124 }
125 cmd.Flags().String("output", "", "Output draw.io file (default: architecture.drawio)")
126 return cmd
127 }
128
129 func newOverlayListCmd() *cobra.Command {
130 return &cobra.Command{
131 Use: "list <metrics-file>",
132 Short: "List available metrics in file",
133 Args: cobra.ExactArgs(1),
134 RunE: func(cmd *cobra.Command, args []string) error {
135 metricsPath := args[0]
136
137 mf, err := overlay.LoadMetricsFile(metricsPath)
138 if err != nil {
139 return exitWithCode(fmt.Errorf("loading metrics: %w", err), 2)
140 }
141
142 format, _ := cmd.Flags().GetString("format")
143 if format == "json" {
144 out, _ := json.Marshal(map[string]interface{}{
145 "source": mf.Meta.Source,
146 "generated": mf.Meta.Generated,
147 "metric_descriptions": mf.Meta.MetricDescriptions,
148 "element_count": len(mf.Metrics),
149 })
150 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
151 } else {
152 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "📊 Metrics from: %s (%s)\n\n", mf.Meta.Source, mf.Meta.Generated)
153 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Available metrics (%d elements):\n", len(mf.Metrics))
154 for metric, desc := range mf.Meta.MetricDescriptions {
155 _, _ = fmt.Fprintf(cmd.OutOrStdout(), " • %s: %s\n", metric, desc)
156 }
157 }
158 return nil
159 },
160 }
161 }
162
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/root.go 98.5%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7 "strings"
8
9 "github.com/spf13/cobra"
10 )
11
12 // NewRootCmd creates and returns the root cobra command with global flags.
13 func NewRootCmd() *cobra.Command {
14 rootCmd := &cobra.Command{
15 Use: "bausteinsicht",
16 Short: "Architecture-as-code with draw.io synchronization",
17 Version: version,
18 PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
19 format, _ := cmd.Flags().GetString("format")
20 format = strings.ToLower(format)
21 if format != "" && format != "text" && format != "json" {
22 return fmt.Errorf("unknown format %q: valid values are \"text\" and \"json\"", format)
23 }
24 // Normalize to lowercase for all subcommands.
25 _ = cmd.Flags().Set("format", format)
26
27 // Validate --template extension when provided.
28 templatePath, _ := cmd.Flags().GetString("template")
29 if templatePath != "" && filepath.Ext(templatePath) != ".drawio" {
30 return fmt.Errorf("template file %q must have a .drawio extension", templatePath)
31 }
32
33 // Validate --model path is under working directory (SEC-001).
34 modelPath, _ := cmd.Flags().GetString("model")
35 if modelPath != "" {
36 if err := validatePathContainment(modelPath); err != nil {
37 return fmt.Errorf("--model: %w", err)
38 }
39 }
40 if templatePath != "" {
41 if err := validatePathContainment(templatePath); err != nil {
42 return fmt.Errorf("--template: %w", err)
43 }
44 }
45
46 return nil
47 },
48 }
49
50 rootCmd.PersistentFlags().String("format", "text", "Output format: text or json")
51 rootCmd.PersistentFlags().String("model", "", "Path to model file (.jsonc)")
52 rootCmd.PersistentFlags().String("template", "", "Path to draw.io template file")
53 rootCmd.PersistentFlags().Bool("verbose", false, "Enable verbose output")
54
55 rootCmd.AddCommand(newInitCmd())
56 rootCmd.AddCommand(newSyncCmd())
57 rootCmd.AddCommand(newValidateCmd())
58 rootCmd.AddCommand(newAddCmd())
59 rootCmd.AddCommand(newWatchCmd())
60 rootCmd.AddCommand(newLayoutCmd())
61 rootCmd.AddCommand(newExportCmd())
62 rootCmd.AddCommand(newExportTableCmd())
63 rootCmd.AddCommand(newExportDiagramCmd())
64 rootCmd.AddCommand(newSchemaCmd())
65 rootCmd.AddCommand(newImportCmd())
66 rootCmd.AddCommand(newExportSequenceCmd())
67 rootCmd.AddCommand(newFindCmd())
68 rootCmd.AddCommand(newShowCmd())
69 rootCmd.AddCommand(newDiffCmd())
70 rootCmd.AddCommand(newChangelogCmd())
71 rootCmd.AddCommand(newLintCmd())
72 rootCmd.AddCommand(newStatusCmd())
73 rootCmd.AddCommand(newGenerateTemplateCmd())
74 rootCmd.AddCommand(newSnapshotCmd())
75 rootCmd.AddCommand(newADRCmd())
76 rootCmd.AddCommand(newWorkspaceCmd())
77 rootCmd.AddCommand(newHealthCmd())
78 rootCmd.AddCommand(newGraphCmd())
79 rootCmd.AddCommand(newOverlayCmd())
80
81 return rootCmd
82 }
83
84 type exitError struct {
85 err error
86 code int
87 }
88
89 func (e *exitError) Error() string { return e.err.Error() }
90
91 func exitWithCode(err error, code int) *exitError {
92 return &exitError{err: err, code: code}
93 }
94
95 // validatePathContainment normalizes a path and rejects directory traversal
96 // sequences that could be used to write files at unexpected locations
97 // (SEC-001, SEC-016).
98 func validatePathContainment(path string) error {
99 cleaned := filepath.Clean(path)
100 for _, component := range strings.Split(cleaned, string(filepath.Separator)) {
101 if component == ".." {
102 return fmt.Errorf("path %q contains directory traversal", path)
103 }
104 }
105 return nil
106 }
107
108 // ExecuteRoot runs the root command and writes errors in the appropriate format
109 // (JSON or plain text) to the command's error writer.
110 func ExecuteRoot(cmd *cobra.Command) error {
111 cmd.SilenceErrors = true
112 cmd.SilenceUsage = true
113 err := cmd.Execute()
114 if err == nil {
115 return nil
116 }
117 format, _ := cmd.PersistentFlags().GetString("format")
118 code := 1
119 if e, ok := err.(*exitError); ok {
120 code = e.code
121 }
122 if format == "json" {
123 out, _ := json.Marshal(map[string]interface{}{
124 "error": err.Error(),
125 "code": code,
126 })
127 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), string(out))
128 } else {
129 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), err.Error())
130 }
131 return err
132 }
133
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/show.go 75.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 func newShowCmd() *cobra.Command {
14 return &cobra.Command{
15 Use: "show <element-id>",
16 Short: "Show full details of a model element",
17 Long: `Display all fields, relationships, and views for a single element.
18
19 The element ID uses dot-notation for nested elements (e.g. "system.backend.api").
20 Use 'bausteinsicht find <query>' to discover element IDs.`,
21 Args: cobra.ExactArgs(1),
22 SilenceUsage: true,
23 SilenceErrors: true,
24 RunE: runShow,
25 }
26 }
27
28 type showRelEntry struct {
29 Direction string
30 Other string
31 Label string
32 Kind string
33 }
34
35 type showRelJSON struct {
36 Direction string `json:"direction"`
37 Other string `json:"other"`
38 Label string `json:"label,omitempty"`
39 Kind string `json:"kind,omitempty"`
40 }
41
42 type showJSONOutput struct {
43 ID string `json:"id"`
44 Kind string `json:"kind"`
45 Title string `json:"title,omitempty"`
46 Description string `json:"description,omitempty"`
47 Technology string `json:"technology,omitempty"`
48 Tags []string `json:"tags,omitempty"`
49 Metadata map[string]string `json:"metadata,omitempty"`
50 Rels []showRelJSON `json:"relationships"`
51 Views []string `json:"views"`
52 }
53
54 func runShow(cmd *cobra.Command, args []string) error {
55 elementID := args[0]
56 format, _ := cmd.Flags().GetString("format")
57 modelPath, _ := cmd.Flags().GetString("model")
58
59 if modelPath == "" {
60 detected, err := model.AutoDetect(".")
61 if err != nil {
62 return exitWithCode(err, 2)
63 }
64 modelPath = detected
65 }
66
67 m, err := model.Load(modelPath)
68 if err != nil {
69 return exitWithCode(err, 2)
70 }
71
72 flat, err := model.FlattenElements(m)
73 if err != nil {
74 return exitWithCode(err, 2)
75 }
76
77 elem, ok := flat[elementID]
78 if !ok {
79 return exitWithCode(fmt.Errorf("element %q not found", elementID), 1)
80 }
81
82 var rels []showRelEntry
83 for _, rel := range m.Relationships {
84 if rel.From == elementID {
85 rels = append(rels, showRelEntry{"→", rel.To, rel.Label, rel.Kind})
86 } else if rel.To == elementID {
87 rels = append(rels, showRelEntry{"←", rel.From, rel.Label, rel.Kind})
88 }
89 }
90 sort.Slice(rels, func(i, j int) bool {
91 return rels[i].Direction+rels[i].Other < rels[j].Direction+rels[j].Other
92 })
93
94 var viewKeys []string
95 for key, view := range m.Views {
96 for _, inc := range view.Include {
97 prefix := strings.TrimSuffix(inc, ".*")
98 if inc == elementID || (strings.HasSuffix(inc, ".*") && strings.HasPrefix(elementID, prefix+".")) {
99 viewKeys = append(viewKeys, key)
100 break
101 }
102 }
103 }
104 sort.Strings(viewKeys)
105
106 if format == "json" {
107 return printShowJSON(cmd, elementID, elem, rels, viewKeys)
108 }
109 return printShowText(cmd, elementID, elem, rels, viewKeys)
110 }
111
112 func printShowJSON(cmd *cobra.Command, id string, elem *model.Element, rels []showRelEntry, viewKeys []string) error {
113 out := showJSONOutput{
114 ID: id,
115 Kind: elem.Kind,
116 Title: elem.Title,
117 Description: elem.Description,
118 Technology: elem.Technology,
119 Tags: elem.Tags,
120 Metadata: elem.Metadata,
121 Views: viewKeys,
122 Rels: []showRelJSON{},
123 }
124 if out.Views == nil {
125 out.Views = []string{}
126 }
127 for _, r := range rels {
128 out.Rels = append(out.Rels, showRelJSON(r))
129 }
130 data, err := json.MarshalIndent(out, "", " ")
131 if err != nil {
132 return err
133 }
134 _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
135 return err
136 }
137
138 func printShowText(cmd *cobra.Command, id string, elem *model.Element, rels []showRelEntry, viewKeys []string) error {
139 o := cmd.OutOrStdout()
140 header := "Element: " + id
141 if _, err := fmt.Fprintln(o, header); err != nil {
142 return err
143 }
144 if _, err := fmt.Fprintln(o, strings.Repeat("=", len(header))); err != nil {
145 return err
146 }
147
148 printField := func(label, value string) error {
149 if value == "" {
150 return nil
151 }
152 _, err := fmt.Fprintf(o, "%-14s %s\n", label+":", value)
153 return err
154 }
155
156 if err := printField("Kind", elem.Kind); err != nil {
157 return err
158 }
159 if err := printField("Title", elem.Title); err != nil {
160 return err
161 }
162 if err := printField("Description", elem.Description); err != nil {
163 return err
164 }
165 if err := printField("Technology", elem.Technology); err != nil {
166 return err
167 }
168 if len(elem.Tags) > 0 {
169 if err := printField("Tags", "["+strings.Join(elem.Tags, ", ")+"]"); err != nil {
170 return err
171 }
172 }
173 for k, v := range elem.Metadata {
174 if err := printField(k, v); err != nil {
175 return err
176 }
177 }
178
179 if len(rels) > 0 {
180 if _, err := fmt.Fprintln(o, "\nRelationships:"); err != nil {
181 return err
182 }
183 for _, r := range rels {
184 label := ""
185 if r.Label != "" {
186 label = fmt.Sprintf(" %q", r.Label)
187 }
188 kind := ""
189 if r.Kind != "" {
190 kind = " [" + r.Kind + "]"
191 }
192 if _, err := fmt.Fprintf(o, " %s %-30s%s%s\n", r.Direction, r.Other, label, kind); err != nil {
193 return err
194 }
195 }
196 }
197
198 if len(viewKeys) > 0 {
199 if _, err := fmt.Fprintf(o, "\nViews: %s\n", strings.Join(viewKeys, ", ")); err != nil {
200 return err
201 }
202 }
203
204 return nil
205 }
206
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot.go 100.0%
1 package main
2
3 import (
4 "github.com/spf13/cobra"
5 )
6
7 func newSnapshotCmd() *cobra.Command {
8 cmd := &cobra.Command{
9 Use: "snapshot",
10 Short: "Manage versioned architecture snapshots",
11 Long: "Save, list, delete, diff, and restore architecture snapshots stored in .bausteinsicht-snapshots/",
12 }
13
14 cmd.AddCommand(newSnapshotSaveCmd())
15 cmd.AddCommand(newSnapshotListCmd())
16 cmd.AddCommand(newSnapshotDeleteCmd())
17 cmd.AddCommand(newSnapshotDiffCmd())
18 cmd.AddCommand(newSnapshotRestoreCmd())
19
20 return cmd
21 }
22
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_delete.go 90.9%
1 package main
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
7 "github.com/spf13/cobra"
8 )
9
10 func newSnapshotDeleteCmd() *cobra.Command {
11 cmd := &cobra.Command{
12 Use: "delete <snapshot-id>",
13 Short: "Delete a saved snapshot",
14 Long: "Remove a snapshot from .bausteinsicht-snapshots/",
15 Args: cobra.ExactArgs(1),
16 RunE: runSnapshotDelete,
17 }
18
19 return cmd
20 }
21
22 func runSnapshotDelete(cmd *cobra.Command, args []string) error {
23 snapshotID := args[0]
24
25 manager := snapshot.NewManager(".")
26 if !manager.Exists(snapshotID) {
27 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID), 2)
28 }
29
30 if err := manager.Delete(snapshotID); err != nil {
31 return exitWithCode(fmt.Errorf("deleting snapshot: %w", err), 2)
32 }
33
34 output := fmt.Sprintf("Snapshot deleted: %s\n", snapshotID)
35 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
36
37 return nil
38 }
39
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_diff.go 65.3%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
10 "github.com/spf13/cobra"
11 )
12
13 func newSnapshotDiffCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "diff <snapshot-id-1> [snapshot-id-2]",
16 Short: "Diff two snapshots or a snapshot vs current state",
17 Long: "Compare two snapshots or compare a snapshot against the current model state.",
18 Args: cobra.RangeArgs(1, 2),
19 RunE: runSnapshotDiff,
20 }
21
22 cmd.Flags().String("format", "text", "Output format: text or json")
23
24 return cmd
25 }
26
27 func runSnapshotDiff(cmd *cobra.Command, args []string) error {
28 snapshotID1 := args[0]
29 format, _ := cmd.Flags().GetString("format")
30 modelPath, _ := cmd.Flags().GetString("model")
31
32 manager := snapshot.NewManager(".")
33
34 // Load first snapshot
35 if !manager.Exists(snapshotID1) {
36 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID1), 2)
37 }
38
39 snap1, err := manager.Load(snapshotID1)
40 if err != nil {
41 return exitWithCode(fmt.Errorf("loading snapshot %s: %w", snapshotID1, err), 2)
42 }
43
44 var model2 *model.BausteinsichtModel
45
46 // Load second snapshot or current state
47 if len(args) == 2 {
48 snapshotID2 := args[1]
49 if !manager.Exists(snapshotID2) {
50 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID2), 2)
51 }
52 snap2, err := manager.Load(snapshotID2)
53 if err != nil {
54 return exitWithCode(fmt.Errorf("loading snapshot %s: %w", snapshotID2, err), 2)
55 }
56 model2 = snap2.Model
57 } else {
58 // Load current model
59 if modelPath == "" {
60 modelPath = "architecture.jsonc"
61 }
62 m, err := model.Load(modelPath)
63 if err != nil {
64 return exitWithCode(fmt.Errorf("loading current model: %w", err), 2)
65 }
66 model2 = m
67 }
68
69 // Compare models
70 diffs := diffModels(snap1.Model, model2)
71
72 // Output results
73 switch format {
74 case "json":
75 data, err := json.MarshalIndent(diffs, "", " ")
76 if err != nil {
77 return exitWithCode(fmt.Errorf("marshaling diff: %w", err), 2)
78 }
79 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
80 case "text":
81 _, _ = fmt.Fprint(cmd.OutOrStdout(), formatDiffText(diffs))
82 default:
83 return exitWithCode(fmt.Errorf("unknown format: %s", format), 2)
84 }
85
86 return nil
87 }
88
89 type ModelDiff struct {
90 AddedElements []string `json:"addedElements,omitempty"`
91 RemovedElements []string `json:"removedElements,omitempty"`
92 ChangedElements map[string][]string `json:"changedElements,omitempty"`
93 AddedRelationships []RelDiff `json:"addedRelationships,omitempty"`
94 RemovedRelationships []RelDiff `json:"removedRelationships,omitempty"`
95 }
96
97 type RelDiff struct {
98 From string `json:"from"`
99 To string `json:"to"`
100 Label string `json:"label,omitempty"`
101 }
102
103 func diffModels(m1, m2 *model.BausteinsichtModel) *ModelDiff {
104 result := &ModelDiff{
105 ChangedElements: make(map[string][]string),
106 }
107
108 // Flatten elements for easier comparison
109 flat1 := flattenAll(m1.Model)
110 flat2 := flattenAll(m2.Model)
111
112 // Find added and removed elements
113 for id := range flat2 {
114 if _, exists := flat1[id]; !exists {
115 result.AddedElements = append(result.AddedElements, id)
116 }
117 }
118
119 for id := range flat1 {
120 if _, exists := flat2[id]; !exists {
121 result.RemovedElements = append(result.RemovedElements, id)
122 }
123 }
124
125 // Find changed elements
126 for id, elem1 := range flat1 {
127 if elem2, exists := flat2[id]; exists {
128 changes := compareElements(elem1, elem2)
129 if len(changes) > 0 {
130 result.ChangedElements[id] = changes
131 }
132 }
133 }
134
135 // Compare relationships
136 relMap1 := relationshipMapString(m1.Relationships)
137 relMap2 := relationshipMapString(m2.Relationships)
138
139 for key, rel2 := range relMap2 {
140 if _, exists := relMap1[key]; !exists {
141 result.AddedRelationships = append(result.AddedRelationships, rel2)
142 }
143 }
144
145 for key, rel1 := range relMap1 {
146 if _, exists := relMap2[key]; !exists {
147 result.RemovedRelationships = append(result.RemovedRelationships, rel1)
148 }
149 }
150
151 return result
152 }
153
154 func flattenAll(elems map[string]model.Element) map[string]model.Element {
155 result := make(map[string]model.Element)
156 for key, elem := range elems {
157 result[key] = elem
158 if len(elem.Children) > 0 {
159 children := flattenAll(elem.Children)
160 for k, v := range children {
161 result[key+"."+k] = v
162 }
163 }
164 }
165 return result
166 }
167
168 func compareElements(e1, e2 model.Element) []string {
169 var changes []string
170 if e1.Title != e2.Title {
171 changes = append(changes, fmt.Sprintf("title: %q → %q", e1.Title, e2.Title))
172 }
173 if e1.Kind != e2.Kind {
174 changes = append(changes, fmt.Sprintf("kind: %q → %q", e1.Kind, e2.Kind))
175 }
176 if e1.Description != e2.Description {
177 changes = append(changes, fmt.Sprintf("description: %q → %q", e1.Description, e2.Description))
178 }
179 if e1.Technology != e2.Technology {
180 changes = append(changes, fmt.Sprintf("technology: %q → %q", e1.Technology, e2.Technology))
181 }
182 if e1.Status != e2.Status {
183 changes = append(changes, fmt.Sprintf("status: %q → %q", e1.Status, e2.Status))
184 }
185 return changes
186 }
187
188 func relationshipMapString(rels []model.Relationship) map[string]RelDiff {
189 m := make(map[string]RelDiff)
190 for _, rel := range rels {
191 key := fmt.Sprintf("%s:%s", rel.From, rel.To)
192 m[key] = RelDiff{From: rel.From, To: rel.To, Label: rel.Label}
193 }
194 return m
195 }
196
197 func formatDiffText(diffs *ModelDiff) string {
198 var sb strings.Builder
199
200 totalChanges := len(diffs.AddedElements) + len(diffs.RemovedElements) +
201 len(diffs.ChangedElements) + len(diffs.AddedRelationships) +
202 len(diffs.RemovedRelationships)
203
204 if totalChanges == 0 {
205 return "No differences found.\n"
206 }
207
208 fmt.Fprintf(&sb, "Architecture Differences (Total Changes: %d)\n", totalChanges)
209 sb.WriteString(strings.Repeat("=", 50))
210 sb.WriteString("\n\n")
211
212 if len(diffs.AddedElements) > 0 {
213 fmt.Fprintf(&sb, "Added Elements (%d):\n", len(diffs.AddedElements))
214 for _, id := range diffs.AddedElements {
215 fmt.Fprintf(&sb, " + %s\n", id)
216 }
217 sb.WriteString("\n")
218 }
219
220 if len(diffs.RemovedElements) > 0 {
221 fmt.Fprintf(&sb, "Removed Elements (%d):\n", len(diffs.RemovedElements))
222 for _, id := range diffs.RemovedElements {
223 fmt.Fprintf(&sb, " - %s\n", id)
224 }
225 sb.WriteString("\n")
226 }
227
228 if len(diffs.ChangedElements) > 0 {
229 fmt.Fprintf(&sb, "Changed Elements (%d):\n", len(diffs.ChangedElements))
230 for id, changes := range diffs.ChangedElements {
231 fmt.Fprintf(&sb, " ~ %s\n", id)
232 for _, change := range changes {
233 fmt.Fprintf(&sb, " %s\n", change)
234 }
235 }
236 sb.WriteString("\n")
237 }
238
239 if len(diffs.AddedRelationships) > 0 {
240 fmt.Fprintf(&sb, "Added Relationships (%d):\n", len(diffs.AddedRelationships))
241 for _, rel := range diffs.AddedRelationships {
242 fmt.Fprintf(&sb, " + %s → %s", rel.From, rel.To)
243 if rel.Label != "" {
244 fmt.Fprintf(&sb, " (%s)", rel.Label)
245 }
246 sb.WriteString("\n")
247 }
248 sb.WriteString("\n")
249 }
250
251 if len(diffs.RemovedRelationships) > 0 {
252 fmt.Fprintf(&sb, "Removed Relationships (%d):\n", len(diffs.RemovedRelationships))
253 for _, rel := range diffs.RemovedRelationships {
254 fmt.Fprintf(&sb, " - %s → %s", rel.From, rel.To)
255 if rel.Label != "" {
256 fmt.Fprintf(&sb, " (%s)", rel.Label)
257 }
258 sb.WriteString("\n")
259 }
260 }
261
262 return sb.String()
263 }
264
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_list.go 84.6%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "text/tabwriter"
7
8 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
9 "github.com/spf13/cobra"
10 )
11
12 func newSnapshotListCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "list",
15 Short: "List all saved snapshots",
16 Long: "Display all snapshots with timestamps, messages, and element/relationship counts.",
17 RunE: runSnapshotList,
18 }
19
20 cmd.Flags().String("format", "table", "Output format: table or json")
21
22 return cmd
23 }
24
25 func runSnapshotList(cmd *cobra.Command, _ []string) error {
26 format, _ := cmd.Flags().GetString("format")
27
28 manager := snapshot.NewManager(".")
29 snapshots, err := manager.List()
30 if err != nil {
31 return exitWithCode(fmt.Errorf("listing snapshots: %w", err), 2)
32 }
33
34 if len(snapshots) == 0 {
35 _, _ = fmt.Fprint(cmd.OutOrStdout(), "No snapshots found.\n")
36 return nil
37 }
38
39 switch format {
40 case "json":
41 data, err := json.MarshalIndent(snapshots, "", " ")
42 if err != nil {
43 return exitWithCode(fmt.Errorf("marshaling snapshots: %w", err), 2)
44 }
45 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
46 case "table":
47 w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
48 _, _ = fmt.Fprintln(w, "ID\tTIMESTAMP\tELEMENTS\tRELATIONSHIPS\tMESSAGE")
49 for _, s := range snapshots {
50 message := s.Message
51 if len(message) > 30 {
52 message = message[:27] + "..."
53 }
54 _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
55 s.ID,
56 s.Timestamp.Format("2006-01-02 15:04:05"),
57 s.ElementCount,
58 s.RelCount,
59 message,
60 )
61 }
62 _ = w.Flush()
63 default:
64 return exitWithCode(fmt.Errorf("unknown format: %s", format), 2)
65 }
66
67 return nil
68 }
69
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_restore.go 85.7%
1 package main
2
3 import (
4 "fmt"
5 "os"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
9 "github.com/spf13/cobra"
10 )
11
12 func newSnapshotRestoreCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "restore <snapshot-id> <output-path>",
15 Short: "Restore a snapshot to a file",
16 Long: "Export a snapshot's model to a JSONC file.",
17 Args: cobra.ExactArgs(2),
18 RunE: runSnapshotRestore,
19 }
20
21 cmd.Flags().Bool("force", false, "Overwrite output file if it exists")
22
23 return cmd
24 }
25
26 func runSnapshotRestore(cmd *cobra.Command, args []string) error {
27 snapshotID := args[0]
28 outputPath := args[1]
29 force, _ := cmd.Flags().GetBool("force")
30
31 if err := validatePathContainment(outputPath); err != nil {
32 return exitWithCode(err, 2)
33 }
34
35 manager := snapshot.NewManager(".")
36 if !manager.Exists(snapshotID) {
37 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID), 2)
38 }
39
40 snap, err := manager.Load(snapshotID)
41 if err != nil {
42 return exitWithCode(fmt.Errorf("loading snapshot: %w", err), 2)
43 }
44
45 // Check if output file already exists
46 if _, err := os.Stat(outputPath); err == nil && !force {
47 return exitWithCode(fmt.Errorf("output file already exists: %s (use --force to overwrite)", outputPath), 2)
48 }
49
50 // Save the model to the output file
51 if err := model.Save(outputPath, snap.Model); err != nil {
52 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
53 }
54
55 output := fmt.Sprintf("Snapshot restored: %s → %s\n", snapshotID, outputPath)
56 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
57
58 return nil
59 }
60
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_save.go 11.8%
1 package main
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
8 "github.com/spf13/cobra"
9 )
10
11 func newSnapshotSaveCmd() *cobra.Command {
12 cmd := &cobra.Command{
13 Use: "save",
14 Short: "Save current architecture model as a snapshot",
15 Long: "Capture the current architecture model state with an optional message and store it in .bausteinsicht-snapshots/",
16 RunE: runSnapshotSave,
17 }
18
19 cmd.Flags().String("model", "architecture.jsonc", "Model file path")
20 cmd.Flags().String("message", "", "Optional message describing the snapshot")
21
22 return cmd
23 }
24
25 func runSnapshotSave(cmd *cobra.Command, _ []string) error {
26 modelPath, _ := cmd.Flags().GetString("model")
27 message, _ := cmd.Flags().GetString("message")
28
29 if err := validatePathContainment(modelPath); err != nil {
30 return exitWithCode(err, 2)
31 }
32
33 // Load current model
34 m, err := model.Load(modelPath)
35 if err != nil {
36 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
37 }
38
39 // Create snapshot
40 snap := snapshot.NewSnapshot(message, m)
41
42 // Save snapshot
43 manager := snapshot.NewManager(".")
44 if err := manager.Save(snap); err != nil {
45 return exitWithCode(fmt.Errorf("saving snapshot: %w", err), 2)
46 }
47
48 // Report success
49 elementCount := len(flattenElements(m.Model))
50 relCount := len(m.Relationships)
51
52 output := fmt.Sprintf("Snapshot saved: %s\n", snap.ID)
53 output += fmt.Sprintf(" Timestamp: %s\n", snap.Timestamp.Format("2006-01-02T15:04:05Z"))
54 output += fmt.Sprintf(" Elements: %d\n", elementCount)
55 output += fmt.Sprintf(" Relationships: %d\n", relCount)
56 if message != "" {
57 output += fmt.Sprintf(" Message: %s\n", message)
58 }
59
60 if _, err := fmt.Fprint(cmd.OutOrStdout(), output); err != nil {
61 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
62 }
63
64 return nil
65 }
66
67 // flattenElements counts total elements including nested ones
68 func flattenElements(elems map[string]model.Element) map[string]model.Element {
69 result := make(map[string]model.Element)
70 for key, elem := range elems {
71 result[key] = elem
72 if len(elem.Children) > 0 {
73 children := flattenElements(elem.Children)
74 for k, v := range children {
75 result[key+"."+k] = v
76 }
77 }
78 }
79 return result
80 }
81
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/status.go 85.7%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 type statusResult struct {
13 Summary map[string]int `json:"summary"`
14 Elements []statusElement `json:"elements"`
15 }
16
17 type statusElement struct {
18 ID string `json:"id"`
19 Kind string `json:"kind"`
20 Title string `json:"title"`
21 Status string `json:"status"`
22 }
23
24 func newStatusCmd() *cobra.Command {
25 cmd := &cobra.Command{
26 Use: "status",
27 Short: "Show element lifecycle status",
28 Long: "Lists all elements and their lifecycle status (proposed, design, implementation, deployed, deprecated, archived).",
29 RunE: runStatus,
30 }
31
32 cmd.Flags().StringP("filter", "f", "", "Filter elements by status (proposed, design, implementation, deployed, deprecated, archived)")
33 // Note: --model is registered as PersistentFlag on root command, not locally
34
35 return cmd
36 }
37
38 func runStatus(cmd *cobra.Command, _ []string) error {
39 modelPath, _ := cmd.Flags().GetString("model")
40 filter, _ := cmd.Flags().GetString("filter")
41 format, _ := cmd.Flags().GetString("format")
42
43 if err := validatePathContainment(modelPath); err != nil {
44 return exitWithCode(fmt.Errorf("model path: %w", err), 1)
45 }
46
47 m, err := model.Load(modelPath)
48 if err != nil {
49 return exitWithCode(fmt.Errorf("loading model: %w", err), 1)
50 }
51
52 // Validate filter if provided
53 if filter != "" {
54 valid := false
55 for _, status := range model.ValidStatuses {
56 if filter == status {
57 valid = true
58 break
59 }
60 }
61 if !valid {
62 return exitWithCode(
63 fmt.Errorf("invalid status filter %q; valid values: %v", filter, model.ValidStatuses), 1)
64 }
65 }
66
67 // Collect all elements with their status
68 flatElements, err := model.FlattenElements(m)
69 if err != nil {
70 return exitWithCode(fmt.Errorf("flattening model: %w", err), 1)
71 }
72
73 result := statusResult{
74 Summary: make(map[string]int),
75 Elements: []statusElement{},
76 }
77
78 // Initialize summary counts
79 for _, status := range model.ValidStatuses {
80 result.Summary[status] = 0
81 }
82 result.Summary["unset"] = 0
83
84 // Process elements
85 for id, elem := range flatElements {
86 status := elem.Status
87 if status == "" {
88 status = "unset"
89 }
90
91 // Apply filter
92 if filter != "" && status != filter {
93 continue
94 }
95
96 // Count
97 if status != "unset" {
98 result.Summary[status]++
99 } else {
100 result.Summary["unset"]++
101 }
102
103 // Collect element
104 result.Elements = append(result.Elements, statusElement{
105 ID: id,
106 Kind: elem.Kind,
107 Title: elem.Title,
108 Status: status,
109 })
110 }
111
112 // Sort by status, then by ID
113 sort.Slice(result.Elements, func(i, j int) bool {
114 if result.Elements[i].Status != result.Elements[j].Status {
115 return result.Elements[i].Status < result.Elements[j].Status
116 }
117 return result.Elements[i].ID < result.Elements[j].ID
118 })
119
120 if format == "json" {
121 return outputStatusJSON(cmd, result)
122 }
123 return outputStatusText(cmd, result)
124 }
125
126 func outputStatusJSON(cmd *cobra.Command, result statusResult) error {
127 data, err := json.MarshalIndent(result, "", " ")
128 if err != nil {
129 return err
130 }
131 _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
132 return err
133 }
134
135 func outputStatusText(cmd *cobra.Command, result statusResult) error {
136 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Element Lifecycle Status\n"); err != nil {
137 return err
138 }
139 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "==================================================\n\n"); err != nil {
140 return err
141 }
142
143 // Print summary
144 for _, status := range append(model.ValidStatuses, "unset") {
145 count := result.Summary[status]
146 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s (%d):\n", status, count); err != nil {
147 return err
148 }
149
150 // Print elements for this status
151 for _, elem := range result.Elements {
152 if elem.Status == status {
153 if _, err := fmt.Fprintf(cmd.OutOrStdout(), " %-20s [%-12s] %q\n", elem.ID, elem.Kind, elem.Title); err != nil {
154 return err
155 }
156 }
157 }
158 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "\n"); err != nil {
159 return err
160 }
161 }
162
163 return nil
164 }
165
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/sync.go 88.1%
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io/fs"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "github.com/docToolchain/Bausteinsicht/internal/diagram"
14 "github.com/docToolchain/Bausteinsicht/internal/drawio"
15 "github.com/docToolchain/Bausteinsicht/internal/model"
16 bsync "github.com/docToolchain/Bausteinsicht/internal/sync"
17 "github.com/docToolchain/Bausteinsicht/templates"
18 "github.com/spf13/cobra"
19 )
20
21 func newSyncCmd() *cobra.Command {
22 cmd := &cobra.Command{
23 Use: "sync",
24 Short: "Synchronize model and draw.io diagram",
25 Long: "Runs one bidirectional sync cycle between the architecture model and the draw.io diagram.",
26 RunE: runSync,
27 }
28 cmd.Flags().Bool("relayout", false, "Re-apply auto-layout to all view pages (resets element positions)")
29 cmd.Flags().Bool("mermaid", false, "Export Mermaid diagrams to Markdown file")
30 cmd.Flags().String("mermaid-output", "architecture.md", "Output file for Mermaid diagrams")
31 return cmd
32 }
33
34 func runSync(cmd *cobra.Command, _ []string) error {
35 format, _ := cmd.Flags().GetString("format")
36 modelPath, _ := cmd.Flags().GetString("model")
37 templatePath, _ := cmd.Flags().GetString("template")
38 verbose, _ := cmd.Flags().GetBool("verbose")
39 relayout, _ := cmd.Flags().GetBool("relayout")
40
41 // Auto-detect model file.
42 if modelPath == "" {
43 detected, err := model.AutoDetect(".")
44 if err != nil {
45 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
46 }
47 modelPath = detected
48 }
49
50 // Derive drawio and state paths from model path.
51 dir := filepath.Dir(modelPath)
52 drawioPath := filepath.Join(dir, "architecture.drawio")
53 statePath := filepath.Join(dir, ".bausteinsicht-sync")
54
55 // Load model.
56 m, err := model.Load(modelPath)
57 if err != nil {
58 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
59 }
60
61 // Validate model before syncing to catch invalid view include/exclude
62 // patterns and other consistency errors. Without this, typos like
63 // "customer." (trailing dot) silently remove elements from draw.io. (#176)
64 if validationErrs := model.Validate(m); len(validationErrs) > 0 {
65 for _, ve := range validationErrs {
66 fmt.Fprintln(os.Stderr, "ERROR:", ve)
67 }
68 return exitWithCode(fmt.Errorf("model validation failed with %d error(s); fix the model before syncing", len(validationErrs)), 1)
69 }
70
71 // Load draw.io document. If the file was deleted or is an empty mxfile
72 // (no diagram pages — e.g., after all views were removed), recreate it
73 // from the template and reset sync state so forward sync repopulates it
74 // (#149, #175).
75 var recreated bool
76 doc, err := drawio.LoadDocument(drawioPath)
77 if err != nil {
78 if !errors.Is(err, fs.ErrNotExist) && !isEmptyMxfileError(err) {
79 return exitWithCode(fmt.Errorf("loading draw.io file: %w", err), 2)
80 }
81 if errors.Is(err, fs.ErrNotExist) {
82 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "WARNING: Draw.io file not found, recreating from template")
83 } else {
84 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "WARNING: Draw.io file has no diagram pages, recreating structure")
85 }
86 doc = drawio.NewDocument()
87 recreated = true
88 }
89
90 // Load sync state (empty on first sync).
91 // When the draw.io file was recreated, discard any stale state so the
92 // sync engine treats all model elements as new.
93 var state *bsync.SyncState
94 if recreated {
95 // Remove stale state file if it exists.
96 _ = os.Remove(statePath)
97 state = &bsync.SyncState{
98 Elements: make(map[string]bsync.ElementState),
99 Relationships: []bsync.RelationshipState{},
100 }
101 } else {
102 state, err = bsync.LoadState(statePath)
103 if err != nil {
104 return exitWithCode(fmt.Errorf("loading sync state: %w", err), 2)
105 }
106 }
107
108 // Load template.
109 var tmpl *drawio.TemplateSet
110 if templatePath != "" {
111 tmpl, err = drawio.LoadTemplate(templatePath)
112 } else {
113 tmpl, err = drawio.LoadTemplateFromBytes(templates.DefaultTemplate)
114 }
115 if err != nil {
116 return exitWithCode(fmt.Errorf("loading template: %w", err), 2)
117 }
118
119 // Verbose output goes to stderr so it doesn't interfere with JSON on stdout.
120 if verbose && format != "json" {
121 flat, _ := model.FlattenElements(m)
122 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Syncing model: %s\n", modelPath)
123 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %d elements, %d relationships, %d views\n",
124 len(flat), len(m.Relationships), len(m.Views))
125 }
126
127 // Ensure pages exist for all views; track which pages are newly created
128 // so the sync engine can avoid treating their missing elements as deletions
129 // (#184, #188, #189).
130 newPageIDs := make(map[string]bool)
131 for viewID, view := range m.Views {
132 pageID := "view-" + viewID
133 if doc.GetPage(pageID) == nil {
134 doc.AddPage(pageID, view.Title)
135 newPageIDs[pageID] = true
136 }
137 }
138
139 // Remove orphaned view pages (views deleted or renamed in model). (#143)
140 bsync.RemoveOrphanedViewPages(doc, m)
141
142 // Run sync.
143 fwdOpts := bsync.ForwardOptions{
144 ModelPath: modelPath,
145 SyncTime: time.Now().Format("2006-01-02 15:04"),
146 Relayout: relayout,
147 }
148 result := bsync.Run(m, doc, state, tmpl, newPageIDs, fwdOpts)
149
150 // Save updated model: use PatchSave to preserve JSONC comments and key
151 // ordering when possible, fall back to full Save for structural changes.
152 if err := saveModel(modelPath, m, result); err != nil {
153 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
154 }
155 if err := drawio.SaveDocument(drawioPath, doc); err != nil {
156 return exitWithCode(fmt.Errorf("saving draw.io file: %w", err), 2)
157 }
158
159 absModel, _ := filepath.Abs(modelPath)
160 absDrawio, _ := filepath.Abs(drawioPath)
161 newState, err := bsync.BuildState(m, doc, absModel, absDrawio)
162 if err != nil {
163 return exitWithCode(fmt.Errorf("building sync state: %w", err), 2)
164 }
165 if err := bsync.SaveState(statePath, newState); err != nil {
166 return exitWithCode(fmt.Errorf("saving sync state: %w", err), 2)
167 }
168
169 // Export Mermaid diagrams if requested
170 enableMermaid, _ := cmd.Flags().GetBool("mermaid")
171 if enableMermaid {
172 mermaidOutput, _ := cmd.Flags().GetString("mermaid-output")
173 viewKeys, diagrams, err := diagram.ExportAllViewsToMermaid(m)
174 if err != nil {
175 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "WARNING: Mermaid export failed: %v\n", err)
176 } else {
177 viewTitles := make(map[string]string)
178 for _, viewKey := range viewKeys {
179 if view, ok := m.Views[viewKey]; ok {
180 viewTitles[viewKey] = view.Title
181 }
182 }
183 markdownContent := diagram.WrapDiagramsInMarkdown(viewKeys, diagrams, viewTitles)
184 if err := os.WriteFile(mermaidOutput, []byte(markdownContent), 0o600); err != nil {
185 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "WARNING: Failed to write Mermaid file: %v\n", err)
186 } else {
187 if verbose && format != "json" {
188 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exporting Mermaid... ✅ %s (%d views)\n", mermaidOutput, len(viewKeys))
189 }
190 }
191 }
192 }
193
194 // Verbose post-sync details to stderr.
195 if verbose && format != "json" {
196 if result.Forward != nil {
197 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Forward sync: %d elements created, %d updated, %d deleted; %d connectors created, %d updated, %d deleted\n",
198 result.Forward.ElementsCreated, result.Forward.ElementsUpdated, result.Forward.ElementsDeleted,
199 result.Forward.ConnectorsCreated, result.Forward.ConnectorsUpdated, result.Forward.ConnectorsDeleted)
200 }
201 if result.Reverse != nil {
202 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Reverse sync: %d elements created, %d updated, %d deleted; %d relationships created, %d updated, %d deleted\n",
203 result.Reverse.ElementsCreated, result.Reverse.ElementsUpdated, result.Reverse.ElementsDeleted,
204 result.Reverse.RelationshipsCreated, result.Reverse.RelationshipsUpdated, result.Reverse.RelationshipsDeleted)
205 }
206 if len(result.Conflicts) > 0 {
207 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Conflicts resolved: %d (model wins)\n", len(result.Conflicts))
208 }
209 }
210
211 // Print warnings to stderr.
212 for _, w := range result.Warnings {
213 fmt.Fprintln(os.Stderr, "WARNING:", w)
214 }
215
216 // Output summary.
217 summary := buildSyncSummary(result)
218 if format == "json" {
219 data, _ := json.MarshalIndent(summary, "", " ")
220 fmt.Println(string(data))
221 } else {
222 printSyncSummary(summary)
223 }
224
225 // Exit code 1 if conflicts detected.
226 if len(result.Conflicts) > 0 {
227 return exitWithCode(fmt.Errorf("%d conflict(s) resolved (model wins)", len(result.Conflicts)), 1)
228 }
229
230 return nil
231 }
232
233 type syncSummary struct {
234 ForwardAdded int `json:"forward_added"`
235 ForwardUpdated int `json:"forward_updated"`
236 ForwardDeleted int `json:"forward_deleted"`
237 ReverseAdded int `json:"reverse_added"`
238 ReverseUpdated int `json:"reverse_updated"`
239 ReverseDeleted int `json:"reverse_deleted"`
240 MetadataUpdated int `json:"metadata_updated"`
241 Conflicts int `json:"conflicts"`
242 }
243
244 func buildSyncSummary(result *bsync.SyncResult) syncSummary {
245 s := syncSummary{
246 Conflicts: len(result.Conflicts),
247 }
248 if result.Forward != nil {
249 s.ForwardAdded = result.Forward.ElementsCreated
250 s.ForwardUpdated = result.Forward.ElementsUpdated
251 s.ForwardDeleted = result.Forward.ElementsDeleted
252 s.ForwardAdded += result.Forward.ConnectorsCreated
253 s.ForwardUpdated += result.Forward.ConnectorsUpdated
254 s.ForwardDeleted += result.Forward.ConnectorsDeleted
255 s.MetadataUpdated = result.Forward.MetadataUpdated
256 }
257 if result.Reverse != nil {
258 s.ReverseAdded = result.Reverse.ElementsCreated
259 s.ReverseUpdated = result.Reverse.ElementsUpdated
260 s.ReverseDeleted = result.Reverse.ElementsDeleted
261 s.ReverseAdded += result.Reverse.RelationshipsCreated
262 s.ReverseUpdated += result.Reverse.RelationshipsUpdated
263 s.ReverseDeleted += result.Reverse.RelationshipsDeleted
264 }
265 return s
266 }
267
268 // saveModel saves the model to path, preserving JSONC comments and key ordering
269 // when the reverse changes are simple field modifications. Falls back to full
270 // Save for structural changes or when patching fails.
271 func saveModel(path string, m *model.BausteinsichtModel, result *bsync.SyncResult) error {
272 hasReverse := result.Reverse != nil &&
273 (result.Reverse.ElementsUpdated+result.Reverse.ElementsCreated+
274 result.Reverse.ElementsDeleted+result.Reverse.RelationshipsCreated+
275 result.Reverse.RelationshipsUpdated+result.Reverse.RelationshipsDeleted) > 0
276
277 if !hasReverse {
278 // No reverse changes — model file doesn't need updating.
279 return nil
280 }
281
282 if result.Changes != nil {
283 ops, patchable := bsync.ReversePatchOps(result.Changes)
284 if patchable && len(ops) > 0 {
285 if err := model.PatchSave(path, ops); err == nil {
286 return nil
287 }
288 // PatchSave failed — fall through to full Save.
289 }
290 }
291
292 return model.Save(path, m)
293 }
294
295 // isEmptyMxfileError returns true if the error indicates that the draw.io file
296 // is a valid XML mxfile but contains no <diagram> elements. This happens when
297 // all views are removed from the model and sync removes all diagram pages (#175).
298 func isEmptyMxfileError(err error) bool {
299 return err != nil && strings.Contains(err.Error(), "no <diagram> elements")
300 }
301
302 func printSyncSummary(s syncSummary) {
303 total := s.ForwardAdded + s.ForwardUpdated + s.ForwardDeleted +
304 s.ReverseAdded + s.ReverseUpdated + s.ReverseDeleted
305 if total == 0 && s.Conflicts == 0 && s.MetadataUpdated == 0 {
306 fmt.Println("Already in sync. No changes.")
307 return
308 }
309 if s.ForwardAdded+s.ForwardUpdated+s.ForwardDeleted > 0 {
310 fmt.Printf("Forward (model → draw.io): %d added, %d updated, %d deleted\n",
311 s.ForwardAdded, s.ForwardUpdated, s.ForwardDeleted)
312 }
313 if s.MetadataUpdated > 0 && total == 0 {
314 fmt.Printf("Metadata/legend updated on %d view page(s).\n", s.MetadataUpdated/2)
315 }
316 if s.ReverseAdded+s.ReverseUpdated+s.ReverseDeleted > 0 {
317 fmt.Printf("Reverse (draw.io → model): %d added, %d updated, %d deleted\n",
318 s.ReverseAdded, s.ReverseUpdated, s.ReverseDeleted)
319 }
320 if s.Conflicts > 0 {
321 fmt.Printf("Conflicts: %d (resolved: model wins)\n", s.Conflicts)
322 }
323 }
324
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/validate.go 91.1%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/spf13/cobra"
9 )
10
11 type validateResult struct {
12 Valid bool `json:"valid"`
13 Errors []validateErrJSON `json:"errors"`
14 Warnings []validateErrJSON `json:"warnings,omitempty"`
15 }
16
17 type validateErrJSON struct {
18 Path string `json:"path"`
19 Message string `json:"message"`
20 }
21
22 func newValidateCmd() *cobra.Command {
23 return &cobra.Command{
24 Use: "validate",
25 Short: "Validate the architecture model",
26 Long: "Validates the architecture model file for consistency and reports any errors.",
27 SilenceUsage: true,
28 SilenceErrors: true,
29 RunE: runValidate,
30 }
31 }
32
33 func runValidate(cmd *cobra.Command, args []string) error {
34 format, _ := cmd.Flags().GetString("format")
35 modelPath, _ := cmd.Flags().GetString("model")
36 verbose, _ := cmd.Flags().GetBool("verbose")
37
38 if modelPath == "" {
39 detected, err := model.AutoDetect(".")
40 if err != nil {
41 return exitWithCode(err, 2)
42 }
43 modelPath = detected
44 }
45
46 m, err := model.Load(modelPath)
47 if err != nil {
48 return exitWithCode(err, 2)
49 }
50
51 // Verbose output goes to stderr so it doesn't interfere with JSON on stdout.
52 if verbose && format != "json" {
53 flat, _ := model.FlattenElements(m)
54 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Validating model: %s\n", modelPath)
55 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %d elements, %d relationships, %d views\n",
56 len(flat), len(m.Relationships), len(m.Views))
57 }
58
59 result := model.ValidateWithWarnings(m)
60
61 if format == "json" {
62 return outputJSON(cmd, result)
63 }
64 return outputText(cmd, result)
65 }
66
67 func outputJSON(cmd *cobra.Command, vr model.ValidationResult) error {
68 result := validateResult{
69 Valid: len(vr.Errors) == 0,
70 Errors: make([]validateErrJSON, len(vr.Errors)),
71 }
72 for i, e := range vr.Errors {
73 result.Errors[i] = validateErrJSON{Path: e.Path, Message: e.Message}
74 }
75 if len(vr.Warnings) > 0 {
76 result.Warnings = make([]validateErrJSON, len(vr.Warnings))
77 for i, w := range vr.Warnings {
78 result.Warnings[i] = validateErrJSON{Path: w.Path, Message: w.Message}
79 }
80 }
81 data, err := json.MarshalIndent(result, "", " ")
82 if err != nil {
83 return err
84 }
85 if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
86 return err
87 }
88 if !result.Valid {
89 return exitWithCode(fmt.Errorf("validation failed"), 1)
90 }
91 return nil
92 }
93
94 func outputText(cmd *cobra.Command, vr model.ValidationResult) error {
95 for _, w := range vr.Warnings {
96 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "WARNING: [%s] %s\n", w.Path, w.Message); err != nil {
97 return err
98 }
99 }
100 if len(vr.Errors) == 0 {
101 _, err := fmt.Fprintln(cmd.OutOrStdout(), "Model is valid.")
102 return err
103 }
104 for _, e := range vr.Errors {
105 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "ERROR: [%s] %s\n", e.Path, e.Message); err != nil {
106 return err
107 }
108 }
109 return exitWithCode(fmt.Errorf("validation failed"), 1)
110 }
111
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/watch.go 16.8%
1 package main
2
3 import (
4 "fmt"
5 "os"
6 "os/signal"
7 "path/filepath"
8 "syscall"
9 "time"
10
11 "github.com/docToolchain/Bausteinsicht/internal/diagram"
12 "github.com/docToolchain/Bausteinsicht/internal/drawio"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 bsync "github.com/docToolchain/Bausteinsicht/internal/sync"
15 "github.com/docToolchain/Bausteinsicht/internal/watcher"
16 "github.com/docToolchain/Bausteinsicht/templates"
17 "github.com/spf13/cobra"
18 )
19
20 func newWatchCmd() *cobra.Command {
21 cmd := &cobra.Command{
22 Use: "watch",
23 Short: "Watch model and diagram for changes and auto-sync",
24 Long: "Watches the model and draw.io files for changes and automatically runs a sync cycle on each change.",
25 RunE: runWatch,
26 }
27 cmd.Flags().Bool("mermaid", false, "Export Mermaid diagrams to Markdown file")
28 cmd.Flags().String("mermaid-output", "architecture.md", "Output file for Mermaid diagrams")
29 return cmd
30 }
31
32 func runWatch(cmd *cobra.Command, _ []string) error {
33 modelPath, _ := cmd.Flags().GetString("model")
34 templatePath, _ := cmd.Flags().GetString("template")
35 enableMermaid, _ := cmd.Flags().GetBool("mermaid")
36 mermaidOutput, _ := cmd.Flags().GetString("mermaid-output")
37
38 // Auto-detect model file.
39 if modelPath == "" {
40 detected, err := model.AutoDetect(".")
41 if err != nil {
42 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
43 }
44 modelPath = detected
45 }
46
47 // Derive drawio path from model path.
48 dir := filepath.Dir(modelPath)
49 drawioPath := filepath.Join(dir, "architecture.drawio")
50
51 // Verify both files exist before starting the watcher.
52 if _, err := os.Stat(modelPath); err != nil {
53 return exitWithCode(fmt.Errorf("model file not found: %w", err), 2)
54 }
55 if _, err := os.Stat(drawioPath); err != nil {
56 return exitWithCode(fmt.Errorf("draw.io file not found: %w", err), 2)
57 }
58
59 absModel, _ := filepath.Abs(modelPath)
60 absDrawio, _ := filepath.Abs(drawioPath)
61
62 var err error
63
64 fmt.Printf("Watching %s and %s...\n", modelPath, drawioPath)
65
66 // Create the file watcher. Use a variable so the callback can access the watcher.
67 var w *watcher.Watcher
68 w, err = watcher.New(
69 []string{absModel, absDrawio},
70 watcher.DefaultDebounce,
71 func(changedFile string) {
72 w.SetSyncing(true)
73 defer w.SetSyncing(false)
74 doSync(changedFile, modelPath, drawioPath, templatePath, enableMermaid, mermaidOutput)
75 },
76 )
77 if err != nil {
78 return exitWithCode(fmt.Errorf("creating watcher: %w", err), 2)
79 }
80
81 if err := w.Start(); err != nil {
82 return exitWithCode(fmt.Errorf("starting watcher: %w", err), 2)
83 }
84
85 // Block until SIGINT/SIGTERM.
86 sigCh := make(chan os.Signal, 1)
87 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
88 <-sigCh
89
90 w.Stop()
91 fmt.Println("Stopped watching.")
92 return nil
93 }
94
95 func doSync(changedFile, modelPath, drawioPath, templatePath string, enableMermaid bool, mermaidOutput string) {
96 fmt.Printf("[%s] Sync triggered by %s\n", time.Now().Format("15:04:05"), changedFile)
97
98 dir := filepath.Dir(modelPath)
99 statePath := filepath.Join(dir, ".bausteinsicht-sync")
100
101 m, err := model.Load(modelPath)
102 if err != nil {
103 fmt.Fprintf(os.Stderr, "ERROR loading model: %v\n", err)
104 return
105 }
106
107 // Load draw.io document. If the file is an empty mxfile (no diagram
108 // pages — e.g., after all views were removed), recreate it and reset
109 // sync state (#175).
110 var watchRecreated bool
111 doc, err := drawio.LoadDocument(drawioPath)
112 if err != nil {
113 if isEmptyMxfileError(err) {
114 fmt.Fprintln(os.Stderr, "WARNING: Draw.io file has no diagram pages, recreating structure")
115 doc = drawio.NewDocument()
116 watchRecreated = true
117 } else {
118 fmt.Fprintf(os.Stderr, "ERROR loading draw.io file: %v\n", err)
119 return
120 }
121 }
122
123 var state *bsync.SyncState
124 if watchRecreated {
125 _ = os.Remove(statePath)
126 state = &bsync.SyncState{
127 Elements: make(map[string]bsync.ElementState),
128 Relationships: []bsync.RelationshipState{},
129 }
130 } else {
131 state, err = bsync.LoadState(statePath)
132 if err != nil {
133 fmt.Fprintf(os.Stderr, "ERROR loading sync state: %v\n", err)
134 return
135 }
136 }
137
138 var tmpl *drawio.TemplateSet
139 if templatePath != "" {
140 tmpl, err = drawio.LoadTemplate(templatePath)
141 } else {
142 tmpl, err = drawio.LoadTemplateFromBytes(templates.DefaultTemplate)
143 }
144 if err != nil {
145 fmt.Fprintf(os.Stderr, "ERROR loading template: %v\n", err)
146 return
147 }
148
149 // Ensure pages exist for all views; track new pages (#184, #188, #189).
150 newPageIDs := make(map[string]bool)
151 for viewID, view := range m.Views {
152 pageID := "view-" + viewID
153 if doc.GetPage(pageID) == nil {
154 doc.AddPage(pageID, view.Title)
155 newPageIDs[pageID] = true
156 }
157 }
158
159 // Remove orphaned view pages (views deleted or renamed in model). (#143)
160 bsync.RemoveOrphanedViewPages(doc, m)
161
162 result := bsync.Run(m, doc, state, tmpl, newPageIDs)
163
164 if err := saveModel(modelPath, m, result); err != nil {
165 fmt.Fprintf(os.Stderr, "ERROR saving model: %v\n", err)
166 return
167 }
168 if err := drawio.SaveDocument(drawioPath, doc); err != nil {
169 fmt.Fprintf(os.Stderr, "ERROR saving draw.io file: %v\n", err)
170 return
171 }
172
173 absModel, _ := filepath.Abs(modelPath)
174 absDrawio, _ := filepath.Abs(drawioPath)
175 newState, err := bsync.BuildState(m, doc, absModel, absDrawio)
176 if err != nil {
177 fmt.Fprintf(os.Stderr, "ERROR building sync state: %v\n", err)
178 return
179 }
180 if err := bsync.SaveState(statePath, newState); err != nil {
181 fmt.Fprintf(os.Stderr, "ERROR saving sync state: %v\n", err)
182 return
183 }
184
185 // Export Mermaid diagrams if requested and sync succeeded
186 if enableMermaid {
187 viewKeys, diagrams, err := diagram.ExportAllViewsToMermaid(m)
188 if err != nil {
189 fmt.Fprintf(os.Stderr, "WARNING: Mermaid export failed: %v\n", err)
190 } else {
191 viewTitles := make(map[string]string)
192 for _, viewKey := range viewKeys {
193 if view, ok := m.Views[viewKey]; ok {
194 viewTitles[viewKey] = view.Title
195 }
196 }
197 markdownContent := diagram.WrapDiagramsInMarkdown(viewKeys, diagrams, viewTitles)
198 if err := os.WriteFile(mermaidOutput, []byte(markdownContent), 0o600); err != nil {
199 fmt.Fprintf(os.Stderr, "WARNING: Failed to write Mermaid file: %v\n", err)
200 } else {
201 fmt.Printf("[%s] Exporting Mermaid... ✅ %s (%d views)\n", time.Now().Format("15:04:05"), mermaidOutput, len(viewKeys))
202 }
203 }
204 }
205
206 for _, w := range result.Warnings {
207 fmt.Fprintln(os.Stderr, "WARNING:", w)
208 }
209
210 printSyncSummary(buildSyncSummary(result))
211 }
212
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/workspace.go 9.3%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/docToolchain/Bausteinsicht/internal/workspace"
11 "github.com/spf13/cobra"
12 )
13
14 func newWorkspaceCmd() *cobra.Command {
15 cmd := &cobra.Command{
16 Use: "workspace",
17 Short: "Manage multi-model workspaces",
18 Long: "Work with multi-model workspaces combining multiple architecture models.",
19 }
20
21 cmd.AddCommand(newWorkspaceMergeCmd())
22 cmd.AddCommand(newWorkspaceValidateCmd())
23 cmd.AddCommand(newWorkspaceListCmd())
24
25 return cmd
26 }
27
28 func newWorkspaceMergeCmd() *cobra.Command {
29 return &cobra.Command{
30 Use: "merge <config-file> <output-file>",
31 Short: "Merge multiple models into a single unified model",
32 Long: "Reads a workspace configuration file and merges all referenced models into a single output file. Element IDs are prefixed to avoid collisions.",
33 Args: cobra.ExactArgs(2),
34 RunE: func(cmd *cobra.Command, args []string) error {
35 return runWorkspaceMerge(cmd, args[0], args[1])
36 },
37 }
38 }
39
40 func runWorkspaceMerge(cmd *cobra.Command, configPath, outputPath string) error {
41 cfg, err := workspace.LoadConfig(configPath)
42 if err != nil {
43 return exitWithCode(fmt.Errorf("loading workspace config: %w", err), 2)
44 }
45
46 baseDir := filepath.Dir(configPath)
47 loaded, err := workspace.LoadModels(cfg, baseDir)
48 if err != nil {
49 return exitWithCode(fmt.Errorf("loading models: %w", err), 2)
50 }
51
52 merged, err := workspace.MergeModels(loaded)
53 if err != nil {
54 return exitWithCode(fmt.Errorf("merging models: %w", err), 2)
55 }
56
57 // Validate merged model
58 if errs := model.Validate(merged); len(errs) > 0 {
59 return exitWithCode(fmt.Errorf("validation failed: %v", errs), 2)
60 }
61
62 // Save merged model
63 data, err := json.MarshalIndent(merged, "", " ")
64 if err != nil {
65 return exitWithCode(fmt.Errorf("marshaling merged model: %w", err), 2)
66 }
67
68 if err := os.WriteFile(outputPath, data, 0644); err != nil {
69 return exitWithCode(fmt.Errorf("writing output file: %w", err), 2)
70 }
71
72 format, _ := cmd.Flags().GetString("format")
73 if format == "json" {
74 out, _ := json.Marshal(map[string]interface{}{
75 "message": "Models merged successfully",
76 "output": outputPath,
77 "models": len(loaded),
78 })
79 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
80 } else {
81 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Merged %d models into %s\n", len(loaded), outputPath)
82 }
83
84 return nil
85 }
86
87 func newWorkspaceValidateCmd() *cobra.Command {
88 return &cobra.Command{
89 Use: "validate <config-file>",
90 Short: "Validate a workspace configuration",
91 Long: "Validates that a workspace configuration is well-formed and all referenced models are valid.",
92 Args: cobra.ExactArgs(1),
93 RunE: func(cmd *cobra.Command, args []string) error {
94 return runWorkspaceValidate(cmd, args[0])
95 },
96 }
97 }
98
99 func runWorkspaceValidate(cmd *cobra.Command, configPath string) error {
100 cfg, err := workspace.LoadConfig(configPath)
101 if err != nil {
102 return exitWithCode(fmt.Errorf("loading workspace config: %w", err), 2)
103 }
104
105 baseDir := filepath.Dir(configPath)
106 loaded, err := workspace.LoadModels(cfg, baseDir)
107 if err != nil {
108 return exitWithCode(fmt.Errorf("loading models: %w", err), 2)
109 }
110
111 // Validate each model individually
112 var validationErrs []error
113 for _, lm := range loaded {
114 if errs := model.Validate(lm.Model); len(errs) > 0 {
115 for _, e := range errs {
116 validationErrs = append(validationErrs, fmt.Errorf("%s: %v", lm.Ref.ID, e))
117 }
118 }
119 }
120
121 if len(validationErrs) > 0 {
122 format, _ := cmd.Flags().GetString("format")
123 if format == "json" {
124 var errMsgs []string
125 for _, e := range validationErrs {
126 errMsgs = append(errMsgs, e.Error())
127 }
128 out, _ := json.Marshal(map[string]interface{}{
129 "valid": false,
130 "errors": errMsgs,
131 })
132 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
133 } else {
134 for _, e := range validationErrs {
135 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), e.Error())
136 }
137 }
138 return exitWithCode(fmt.Errorf("validation failed"), 2)
139 }
140
141 format, _ := cmd.Flags().GetString("format")
142 if format == "json" {
143 out, _ := json.Marshal(map[string]interface{}{
144 "valid": true,
145 "models": len(loaded),
146 })
147 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
148 } else {
149 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "✓ Workspace configuration is valid (%d models)\n", len(loaded))
150 }
151
152 return nil
153 }
154
155 func newWorkspaceListCmd() *cobra.Command {
156 return &cobra.Command{
157 Use: "list <config-file>",
158 Short: "List models in a workspace configuration",
159 Long: "Shows all models referenced in a workspace configuration with their IDs, paths, and prefixes.",
160 Args: cobra.ExactArgs(1),
161 RunE: func(cmd *cobra.Command, args []string) error {
162 return runWorkspaceList(cmd, args[0])
163 },
164 }
165 }
166
167 func runWorkspaceList(cmd *cobra.Command, configPath string) error {
168 cfg, err := workspace.LoadConfig(configPath)
169 if err != nil {
170 return exitWithCode(fmt.Errorf("loading workspace config: %w", err), 2)
171 }
172
173 format, _ := cmd.Flags().GetString("format")
174 if format == "json" {
175 out, _ := json.MarshalIndent(cfg, "", " ")
176 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
177 return nil
178 }
179
180 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Workspace: %s\n", cfg.Workspace.Name)
181 if cfg.Workspace.Description != "" {
182 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Description: %s\n\n", cfg.Workspace.Description)
183 } else {
184 _, _ = fmt.Fprint(cmd.OutOrStdout(), "\n")
185 }
186
187 _, _ = fmt.Fprint(cmd.OutOrStdout(), "Models:\n")
188 for i, ref := range cfg.Models {
189 _, _ = fmt.Fprintf(cmd.OutOrStdout(), " %d. ID: %s, Path: %s", i+1, ref.ID, ref.Path)
190 if ref.Prefix != "" {
191 _, _ = fmt.Fprintf(cmd.OutOrStdout(), ", Prefix: %s", ref.Prefix)
192 }
193 _, _ = fmt.Fprint(cmd.OutOrStdout(), "\n")
194 }
195
196 if len(cfg.CrossRels) > 0 {
197 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nCross-Model Relationships: %d\n", len(cfg.CrossRels))
198 }
199
200 if len(cfg.Views) > 0 {
201 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Workspace Views: %d\n", len(cfg.Views))
202 }
203
204 return nil
205 }
206
github.com/docToolchain/Bausteinsicht/internal/changelog/changelog.go 89.7%
1 package changelog
2
3 import (
4 "github.com/docToolchain/Bausteinsicht/internal/diff"
5 "github.com/docToolchain/Bausteinsicht/internal/model"
6 )
7
8 // Generate creates a changelog by comparing two model versions
9 func Generate(from, to *model.BausteinsichtModel, fromRef, toRef Reference) *Changelog {
10 // Convert models to snapshots for diff comparison
11 fromSnap := modelToSnapshot(from)
12 toSnap := modelToSnapshot(to)
13
14 // Use diff package to compute changes
15 result := diff.Compare(fromSnap, toSnap)
16
17 // Organize changes by type
18 return &Changelog{
19 From: fromRef,
20 To: toRef,
21 Elements: ElementChanges{
22 Added: filterElementsByType(result.Elements, diff.ChangeAdded),
23 Removed: filterElementsByType(result.Elements, diff.ChangeRemoved),
24 Changed: filterElementsByType(result.Elements, diff.ChangeChanged),
25 },
26 Relationships: RelationshipChanges{
27 Added: filterRelationshipsByType(result.Relationships, diff.ChangeAdded),
28 Removed: filterRelationshipsByType(result.Relationships, diff.ChangeRemoved),
29 },
30 }
31 }
32
33 // modelToSnapshot converts a BausteinsichtModel to a ModelSnapshot for diff computation
34 func modelToSnapshot(m *model.BausteinsichtModel) *model.ModelSnapshot {
35 if m == nil {
36 return &model.ModelSnapshot{
37 Elements: make(map[string]model.Element),
38 Relationships: []model.Relationship{},
39 }
40 }
41
42 // Flatten nested elements into a single-level map using dot notation
43 elements := flattenElements(m.Model, "")
44
45 return &model.ModelSnapshot{
46 Elements: elements,
47 Relationships: m.Relationships,
48 }
49 }
50
51 // flattenElements recursively flattens nested element maps into a single-level map
52 // with dot-separated keys (e.g., "system.backend.api")
53 func flattenElements(elems map[string]model.Element, prefix string) map[string]model.Element {
54 result := make(map[string]model.Element)
55
56 for key, elem := range elems {
57 fullKey := key
58 if prefix != "" {
59 fullKey = prefix + "." + key
60 }
61
62 // Add this element
63 result[fullKey] = elem
64
65 // Recursively flatten child elements if they exist
66 if len(elem.Children) > 0 {
67 children := flattenElements(elem.Children, fullKey)
68 for k, v := range children {
69 result[k] = v
70 }
71 }
72 }
73
74 return result
75 }
76
77 // filterElementsByType returns only elements with the specified change type
78 func filterElementsByType(changes []diff.ElementChange, changeType diff.ChangeType) []diff.ElementChange {
79 var result []diff.ElementChange
80 for _, c := range changes {
81 if c.Type == changeType {
82 result = append(result, c)
83 }
84 }
85 return result
86 }
87
88 // filterRelationshipsByType returns only relationships with the specified change type
89 func filterRelationshipsByType(changes []diff.RelationshipChange, changeType diff.ChangeType) []diff.RelationshipChange {
90 var result []diff.RelationshipChange
91 for _, c := range changes {
92 if c.Type == changeType {
93 result = append(result, c)
94 }
95 }
96 return result
97 }
98
github.com/docToolchain/Bausteinsicht/internal/changelog/git.go 0.0%
1 package changelog
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os/exec"
7 "strconv"
8 "strings"
9 "time"
10
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 )
13
14 // LoadModelAtGitRef loads a model from a specific git reference
15 func LoadModelAtGitRef(modelPath, gitRef string) (*model.BausteinsichtModel, error) {
16 // Resolve the git ref to its full commit hash
17 hash, _, err := resolveGitRef(gitRef)
18 if err != nil {
19 return nil, fmt.Errorf("resolving git ref %q: %w", gitRef, err)
20 }
21
22 // Load model from git
23 m, err := loadModelFromGit(modelPath, hash)
24 if err != nil {
25 return nil, fmt.Errorf("loading model from git ref %q: %w", gitRef, err)
26 }
27
28 // Store metadata for changelog generation
29 if m == nil {
30 m = &model.BausteinsichtModel{}
31 }
32
33 return m, nil
34 }
35
36 // resolveGitRef converts a git ref (tag, branch, commit) to its hash and timestamp
37 func resolveGitRef(gitRef string) (hash string, date time.Time, err error) {
38 // Get commit hash
39 hashCmd := exec.Command("git", "rev-parse", gitRef)
40 hashOut, err := hashCmd.Output()
41 if err != nil {
42 return "", time.Time{}, fmt.Errorf("failed to resolve git ref: %w", err)
43 }
44 hash = strings.TrimSpace(string(hashOut))
45
46 // Get commit date
47 dateCmd := exec.Command("git", "log", "-1", "--format=%ct", hash)
48 dateOut, err := dateCmd.Output()
49 if err != nil {
50 return "", time.Time{}, fmt.Errorf("failed to get commit date: %w", err)
51 }
52
53 timestamp, err := strconv.ParseInt(strings.TrimSpace(string(dateOut)), 10, 64)
54 if err != nil {
55 return "", time.Time{}, fmt.Errorf("failed to parse commit timestamp: %w", err)
56 }
57
58 date = time.Unix(timestamp, 0).UTC()
59 return hash, date, nil
60 }
61
62 // loadModelFromGit retrieves the model file from git at a specific commit
63 func loadModelFromGit(modelPath, commitHash string) (*model.BausteinsichtModel, error) {
64 cmd := exec.Command("git", "show", commitHash+":"+modelPath)
65 out, err := cmd.Output()
66 if err != nil {
67 return nil, fmt.Errorf("git show failed: %w", err)
68 }
69
70 // Strip JSONC comments and parse
71 clean := model.StripJSONC(out)
72 trimmed := strings.TrimSpace(string(clean))
73 if trimmed == "null" || trimmed == "" {
74 return nil, fmt.Errorf("model file is empty or null at %s in %s", modelPath, commitHash)
75 }
76
77 var m model.BausteinsichtModel
78 if err := json.Unmarshal(clean, &m); err != nil {
79 return nil, fmt.Errorf("parsing model: %w", err)
80 }
81
82 m.ElementOrder = extractElementOrder(clean)
83 return &m, nil
84 }
85
86 // extractElementOrder extracts the definition order of element kinds from specification
87 func extractElementOrder(data []byte) []string {
88 var raw map[string]json.RawMessage
89 if err := json.Unmarshal(data, &raw); err != nil {
90 return nil
91 }
92 specRaw, ok := raw["specification"]
93 if !ok {
94 return nil
95 }
96 var spec map[string]json.RawMessage
97 if err := json.Unmarshal(specRaw, &spec); err != nil {
98 return nil
99 }
100 elemsRaw, ok := spec["elements"]
101 if !ok {
102 return nil
103 }
104 var elems map[string]interface{}
105 d := json.NewDecoder(strings.NewReader(string(elemsRaw)))
106 d.UseNumber()
107 if err := d.Decode(&elems); err != nil {
108 return nil
109 }
110 var order []string
111 for k := range elems {
112 order = append(order, k)
113 }
114 return order
115 }
116
117 // GetCommitInfo retrieves metadata about a git commit
118 func GetCommitInfo(gitRef string) (*CommitInfo, error) {
119 hash, date, err := resolveGitRef(gitRef)
120 if err != nil {
121 return nil, err
122 }
123
124 // Get author
125 authorCmd := exec.Command("git", "log", "-1", "--format=%an", hash)
126 authorOut, err := authorCmd.Output()
127 if err != nil {
128 return nil, fmt.Errorf("failed to get commit author: %w", err)
129 }
130
131 // Get message
132 msgCmd := exec.Command("git", "log", "-1", "--format=%s", hash)
133 msgOut, err := msgCmd.Output()
134 if err != nil {
135 return nil, fmt.Errorf("failed to get commit message: %w", err)
136 }
137
138 return &CommitInfo{
139 Hash: hash,
140 Author: strings.TrimSpace(string(authorOut)),
141 Date: date,
142 Message: strings.TrimSpace(string(msgOut)),
143 Timestamp: date.Unix(),
144 }, nil
145 }
146
github.com/docToolchain/Bausteinsicht/internal/changelog/render.go 41.3%
1 package changelog
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderMarkdown renders the changelog as Markdown
12 func RenderMarkdown(cl *Changelog) string {
13 var sb strings.Builder
14
15 sb.WriteString("# Architecture Changelog\n\n")
16
17 // Title with date range
18 dateRange := fmt.Sprintf("%s → %s", cl.From.Ref, cl.To.Ref)
19 if !cl.From.Date.IsZero() && !cl.To.Date.IsZero() {
20 dateRange = fmt.Sprintf("%s → %s (%s → %s)",
21 cl.From.Ref, cl.To.Ref,
22 cl.From.Date.Format("2006-01-02"),
23 cl.To.Date.Format("2006-01-02"))
24 }
25 fmt.Fprintf(&sb, "## %s\n\n", dateRange)
26
27 // Added elements
28 if cl.Elements.CountAdded() > 0 {
29 fmt.Fprintf(&sb, "### Added (%d elements)\n", cl.Elements.CountAdded())
30 for _, change := range cl.Elements.Added {
31 if change.ToBe != nil {
32 desc := ""
33 if change.ToBe.Description != "" {
34 desc = fmt.Sprintf(" _{%s}_", change.ToBe.Description)
35 }
36 fmt.Fprintf(&sb, "- **%s** `[%s]` — %s%s\n",
37 change.ID, change.ToBe.Kind, change.ToBe.Title, desc)
38 }
39 }
40 sb.WriteString("\n")
41 }
42
43 // Removed elements
44 if cl.Elements.CountRemoved() > 0 {
45 fmt.Fprintf(&sb, "### Removed (%d elements)\n", cl.Elements.CountRemoved())
46 for _, change := range cl.Elements.Removed {
47 if change.AsIs != nil {
48 desc := ""
49 if change.AsIs.Description != "" {
50 desc = fmt.Sprintf(" _{%s}_", change.AsIs.Description)
51 }
52 fmt.Fprintf(&sb, "- ~~**%s**~~ `[%s]` — %s%s\n",
53 change.ID, change.AsIs.Kind, change.AsIs.Title, desc)
54 }
55 }
56 sb.WriteString("\n")
57 }
58
59 // Changed elements
60 if cl.Elements.CountChanged() > 0 {
61 fmt.Fprintf(&sb, "### Changed (%d elements)\n", cl.Elements.CountChanged())
62 for _, change := range cl.Elements.Changed {
63 if change.AsIs != nil && change.ToBe != nil {
64 fmt.Fprintf(&sb, "- **%s** — ", change.ID)
65 changes := renderElementChanges(*change.AsIs, *change.ToBe)
66 sb.WriteString(changes)
67 sb.WriteString("\n")
68 }
69 }
70 sb.WriteString("\n")
71 }
72
73 // Added relationships
74 if cl.Relationships.CountAddedRelationships() > 0 {
75 fmt.Fprintf(&sb, "### New Relationships (%d)\n", cl.Relationships.CountAddedRelationships())
76 for _, change := range cl.Relationships.Added {
77 label := ""
78 if change.ToBe != nil && change.ToBe.Label != "" {
79 label = fmt.Sprintf(" (%s)", change.ToBe.Label)
80 }
81 fmt.Fprintf(&sb, "- %s → %s%s\n", change.From, change.To, label)
82 }
83 sb.WriteString("\n")
84 }
85
86 // Removed relationships
87 if cl.Relationships.CountRemovedRelationships() > 0 {
88 fmt.Fprintf(&sb, "### Removed Relationships (%d)\n", cl.Relationships.CountRemovedRelationships())
89 for _, change := range cl.Relationships.Removed {
90 label := ""
91 if change.AsIs != nil && change.AsIs.Label != "" {
92 label = fmt.Sprintf(" (%s)", change.AsIs.Label)
93 }
94 fmt.Fprintf(&sb, "- ~~%s → %s~~%s\n", change.From, change.To, label)
95 }
96 sb.WriteString("\n")
97 }
98
99 if cl.Elements.CountAdded() == 0 && cl.Elements.CountRemoved() == 0 &&
100 cl.Elements.CountChanged() == 0 && cl.Relationships.CountAddedRelationships() == 0 &&
101 cl.Relationships.CountRemovedRelationships() == 0 {
102 sb.WriteString("No architectural changes detected.\n")
103 }
104
105 return sb.String()
106 }
107
108 // RenderAsciiDoc renders the changelog as AsciiDoc
109 func RenderAsciiDoc(cl *Changelog) string {
110 var sb strings.Builder
111
112 sb.WriteString("= Architecture Changelog\n\n")
113
114 // Title with date range
115 dateRange := fmt.Sprintf("%s → %s", cl.From.Ref, cl.To.Ref)
116 if !cl.From.Date.IsZero() && !cl.To.Date.IsZero() {
117 dateRange = fmt.Sprintf("%s → %s (%s → %s)",
118 cl.From.Ref, cl.To.Ref,
119 cl.From.Date.Format("2006-01-02"),
120 cl.To.Date.Format("2006-01-02"))
121 }
122 fmt.Fprintf(&sb, "== %s\n\n", dateRange)
123
124 // Added elements
125 if cl.Elements.CountAdded() > 0 {
126 fmt.Fprintf(&sb, "=== Added (%d elements)\n", cl.Elements.CountAdded())
127 for _, change := range cl.Elements.Added {
128 if change.ToBe != nil {
129 desc := ""
130 if change.ToBe.Description != "" {
131 desc = fmt.Sprintf(": %s", change.ToBe.Description)
132 }
133 fmt.Fprintf(&sb, "* *%s* `[%s]` – %s%s\n",
134 change.ID, change.ToBe.Kind, change.ToBe.Title, desc)
135 }
136 }
137 sb.WriteString("\n")
138 }
139
140 // Removed elements
141 if cl.Elements.CountRemoved() > 0 {
142 fmt.Fprintf(&sb, "=== Removed (%d elements)\n", cl.Elements.CountRemoved())
143 for _, change := range cl.Elements.Removed {
144 if change.AsIs != nil {
145 desc := ""
146 if change.AsIs.Description != "" {
147 desc = fmt.Sprintf(": %s", change.AsIs.Description)
148 }
149 fmt.Fprintf(&sb, "* [line-through]#*%s* `[%s]` – %s#%s\n",
150 change.ID, change.AsIs.Kind, change.AsIs.Title, desc)
151 }
152 }
153 sb.WriteString("\n")
154 }
155
156 // Changed elements
157 if cl.Elements.CountChanged() > 0 {
158 fmt.Fprintf(&sb, "=== Changed (%d elements)\n", cl.Elements.CountChanged())
159 for _, change := range cl.Elements.Changed {
160 if change.AsIs != nil && change.ToBe != nil {
161 fmt.Fprintf(&sb, "* *%s* – ", change.ID)
162 changes := renderElementChanges(*change.AsIs, *change.ToBe)
163 sb.WriteString(changes)
164 sb.WriteString("\n")
165 }
166 }
167 sb.WriteString("\n")
168 }
169
170 // Added relationships
171 if cl.Relationships.CountAddedRelationships() > 0 {
172 fmt.Fprintf(&sb, "=== New Relationships (%d)\n", cl.Relationships.CountAddedRelationships())
173 for _, change := range cl.Relationships.Added {
174 label := ""
175 if change.ToBe != nil && change.ToBe.Label != "" {
176 label = fmt.Sprintf(" (%s)", change.ToBe.Label)
177 }
178 fmt.Fprintf(&sb, "* %s → %s%s\n", change.From, change.To, label)
179 }
180 sb.WriteString("\n")
181 }
182
183 // Removed relationships
184 if cl.Relationships.CountRemovedRelationships() > 0 {
185 fmt.Fprintf(&sb, "=== Removed Relationships (%d)\n", cl.Relationships.CountRemovedRelationships())
186 for _, change := range cl.Relationships.Removed {
187 label := ""
188 if change.AsIs != nil && change.AsIs.Label != "" {
189 label = fmt.Sprintf(" (%s)", change.AsIs.Label)
190 }
191 fmt.Fprintf(&sb, "* [line-through]#%s → %s#%s\n", change.From, change.To, label)
192 }
193 sb.WriteString("\n")
194 }
195
196 if cl.Elements.CountAdded() == 0 && cl.Elements.CountRemoved() == 0 &&
197 cl.Elements.CountChanged() == 0 && cl.Relationships.CountAddedRelationships() == 0 &&
198 cl.Relationships.CountRemovedRelationships() == 0 {
199 sb.WriteString("No architectural changes detected.\n")
200 }
201
202 return sb.String()
203 }
204
205 // RenderJSON renders the changelog as JSON
206 func RenderJSON(cl *Changelog) (string, error) {
207 data, err := json.MarshalIndent(cl, "", " ")
208 if err != nil {
209 return "", err
210 }
211 return string(data), nil
212 }
213
214 // renderElementChanges formats what changed in an element
215 func renderElementChanges(asIs, toBe model.Element) string {
216 var sb strings.Builder
217
218 if asIs.Title != toBe.Title {
219 fmt.Fprintf(&sb, "title: \"%s\" → \"%s\"; ", asIs.Title, toBe.Title)
220 }
221 if asIs.Kind != toBe.Kind {
222 fmt.Fprintf(&sb, "kind: \"%s\" → \"%s\"; ", asIs.Kind, toBe.Kind)
223 }
224 if asIs.Technology != toBe.Technology {
225 fmt.Fprintf(&sb, "technology: \"%s\" → \"%s\"; ", asIs.Technology, toBe.Technology)
226 }
227 if asIs.Description != toBe.Description {
228 sb.WriteString("description: changed; ")
229 }
230 if asIs.Status != toBe.Status {
231 fmt.Fprintf(&sb, "status: \"%s\" → \"%s\"; ", asIs.Status, toBe.Status)
232 }
233
234 result := sb.String()
235 return strings.TrimSuffix(result, "; ")
236 }
237
github.com/docToolchain/Bausteinsicht/internal/changelog/types.go 87.5%
1 package changelog
2
3 import (
4 "time"
5
6 "github.com/docToolchain/Bausteinsicht/internal/diff"
7 )
8
9 // Reference represents a git ref or snapshot identifier
10 type Reference struct {
11 Ref string `json:"ref"` // git tag/commit SHA or snapshot ID
12 Date time.Time `json:"date"` // date of the reference
13 }
14
15 // Changelog describes changes between two architecture snapshots
16 type Changelog struct {
17 From Reference `json:"from"`
18 To Reference `json:"to"`
19 Elements ElementChanges `json:"elements"`
20 Relationships RelationshipChanges `json:"relationships"`
21 }
22
23 // ElementChanges groups element changes by type
24 type ElementChanges struct {
25 Added []diff.ElementChange `json:"added"`
26 Removed []diff.ElementChange `json:"removed"`
27 Changed []diff.ElementChange `json:"changed"`
28 }
29
30 // RelationshipChanges groups relationship changes by type
31 type RelationshipChanges struct {
32 Added []diff.RelationshipChange `json:"added"`
33 Removed []diff.RelationshipChange `json:"removed"`
34 }
35
36 // CommitInfo retrieves commit metadata for a git ref
37 type CommitInfo struct {
38 Hash string `json:"hash"`
39 Author string `json:"author"`
40 Date time.Time `json:"date"`
41 Message string `json:"message"`
42 Timestamp int64 `json:"timestamp"`
43 }
44
45 // FilterChangesByKind returns only changes of a specific element kind
46 func (ec ElementChanges) FilterByKind(kind string) ElementChanges {
47 return ElementChanges{
48 Added: filterElementsByKind(ec.Added, kind),
49 Removed: filterElementsByKind(ec.Removed, kind),
50 Changed: filterElementsByKind(ec.Changed, kind),
51 }
52 }
53
54 func filterElementsByKind(changes []diff.ElementChange, kind string) []diff.ElementChange {
55 var result []diff.ElementChange
56 for _, c := range changes {
57 var k string
58 if c.ToBe != nil {
59 k = c.ToBe.Kind
60 } else if c.AsIs != nil {
61 k = c.AsIs.Kind
62 }
63 if k == kind {
64 result = append(result, c)
65 }
66 }
67 return result
68 }
69
70 // CountAdded returns the number of added elements
71 func (ec ElementChanges) CountAdded() int {
72 return len(ec.Added)
73 }
74
75 // CountRemoved returns the number of removed elements
76 func (ec ElementChanges) CountRemoved() int {
77 return len(ec.Removed)
78 }
79
80 // CountChanged returns the number of changed elements
81 func (ec ElementChanges) CountChanged() int {
82 return len(ec.Changed)
83 }
84
85 // CountAddedRelationships returns the number of added relationships
86 func (rc RelationshipChanges) CountAddedRelationships() int {
87 return len(rc.Added)
88 }
89
90 // CountRemovedRelationships returns the number of removed relationships
91 func (rc RelationshipChanges) CountRemovedRelationships() int {
92 return len(rc.Removed)
93 }
94
github.com/docToolchain/Bausteinsicht/internal/chaos/chaos.go 0.0%
1 package chaos
2
3 import (
4 "os"
5 "path/filepath"
6 "testing"
7 )
8
9 type TestChaos struct {
10 t *testing.T
11 tmpDir string
12 }
13
14 // NewTestChaos creates a chaos injection helper for tests.
15 func NewTestChaos(t *testing.T) *TestChaos {
16 tmpDir := t.TempDir()
17 return &TestChaos{
18 t: t,
19 tmpDir: tmpDir,
20 }
21 }
22
23 // TmpDir returns the temporary directory for this test.
24 func (tc *TestChaos) TmpDir() string {
25 return tc.tmpDir
26 }
27
28 // CorruptFile truncates a file (simulating partial write).
29 func (tc *TestChaos) CorruptFile(path string) {
30 f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
31 if err != nil {
32 tc.t.Fatalf("CorruptFile: %v", err)
33 }
34 defer f.Close() //nolint:errcheck
35 if _, err := f.WriteString(""); err != nil {
36 tc.t.Fatalf("CorruptFile truncate: %v", err)
37 }
38 }
39
40 // CorruptFilePartial truncates file to partial content.
41 func (tc *TestChaos) CorruptFilePartial(path string, content string) {
42 f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
43 if err != nil {
44 tc.t.Fatalf("CorruptFilePartial: %v", err)
45 }
46 defer f.Close() //nolint:errcheck
47 if _, err := f.WriteString(content); err != nil {
48 tc.t.Fatalf("CorruptFilePartial write: %v", err)
49 }
50 }
51
52 // MakeReadOnly sets file permissions to read-only.
53 func (tc *TestChaos) MakeReadOnly(path string) {
54 if err := os.Chmod(path, 0444); err != nil {
55 tc.t.Fatalf("MakeReadOnly: %v", err)
56 }
57 }
58
59 // MakeWritable sets file permissions to writable.
60 func (tc *TestChaos) MakeWritable(path string) {
61 if err := os.Chmod(path, 0644); err != nil {
62 tc.t.Fatalf("MakeWritable: %v", err)
63 }
64 }
65
66 // DeleteFile removes a file.
67 func (tc *TestChaos) DeleteFile(path string) {
68 if err := os.Remove(path); err != nil {
69 tc.t.Fatalf("DeleteFile: %v", err)
70 }
71 }
72
73 // CreateEmptyFile creates an empty file at path.
74 func (tc *TestChaos) CreateEmptyFile(path string) string {
75 absPath := filepath.Join(tc.tmpDir, path)
76 if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
77 tc.t.Fatalf("CreateEmptyFile mkdir: %v", err)
78 }
79 f, err := os.Create(absPath)
80 if err != nil {
81 tc.t.Fatalf("CreateEmptyFile: %v", err)
82 }
83 defer f.Close() //nolint:errcheck
84 return absPath
85 }
86
87 // CreateFileWithContent creates a file with specific content.
88 func (tc *TestChaos) CreateFileWithContent(path string, content string) string {
89 absPath := filepath.Join(tc.tmpDir, path)
90 if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
91 tc.t.Fatalf("CreateFileWithContent mkdir: %v", err)
92 }
93 if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
94 tc.t.Fatalf("CreateFileWithContent: %v", err)
95 }
96 return absPath
97 }
98
99 // FileExists checks if a file exists.
100 func (tc *TestChaos) FileExists(path string) bool {
101 _, err := os.Stat(path)
102 return err == nil
103 }
104
105 // ReadFile reads a file's content.
106 func (tc *TestChaos) ReadFile(path string) string {
107 content, err := os.ReadFile(path)
108 if err != nil {
109 tc.t.Fatalf("ReadFile: %v", err)
110 }
111 return string(content)
112 }
113
github.com/docToolchain/Bausteinsicht/internal/constraints/engine.go 100.0%
1 package constraints
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // ErrUnknownRule is returned when a constraint specifies an unsupported rule type.
10 type ErrUnknownRule struct {
11 Rule string
12 }
13
14 func (e ErrUnknownRule) Error() string {
15 return fmt.Sprintf("unknown constraint rule %q", e.Rule)
16 }
17
18 // Evaluate runs all constraints against the model and returns the aggregated result.
19 // Unknown rule types are reported as violations so they don't silently pass.
20 func Evaluate(m *model.BausteinsichtModel) Result {
21 var all []Violation
22 for _, c := range m.Constraints {
23 vs, err := evaluate(c, m)
24 if err != nil {
25 all = append(all, Violation{
26 ConstraintID: c.ID,
27 Message: err.Error(),
28 })
29 continue
30 }
31 all = append(all, vs...)
32 }
33 return Result{Violations: all, Total: len(all)}
34 }
35
36 func evaluate(c model.Constraint, m *model.BausteinsichtModel) ([]Violation, error) {
37 switch c.Rule {
38 case "no-relationship":
39 return noRelationship(c, m), nil
40 case "allowed-relationship":
41 return allowedRelationship(c, m), nil
42 case "required-field":
43 return requiredField(c, m), nil
44 case "max-depth":
45 return maxDepth(c, m), nil
46 case "no-circular-dependency":
47 return noCircularDependency(c, m), nil
48 case "technology-allowed":
49 return technologyAllowed(c, m), nil
50 default:
51 return nil, ErrUnknownRule{Rule: c.Rule}
52 }
53 }
54
github.com/docToolchain/Bausteinsicht/internal/constraints/rules.go 92.5%
1 package constraints
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // noRelationship enforces that no relationship exists from any element of
11 // fromKind to any element of toKind.
12 func noRelationship(c model.Constraint, m *model.BausteinsichtModel) []Violation {
13 flat, err := model.FlattenElements(m)
14 if err != nil {
15 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
16 }
17 kindOf := buildKindMap(flat)
18
19 var bad []string
20 for _, rel := range m.Relationships {
21 if kindOf[rel.From] == c.FromKind && kindOf[rel.To] == c.ToKind {
22 bad = append(bad, fmt.Sprintf("%s → %s", rel.From, rel.To))
23 }
24 }
25 if len(bad) == 0 {
26 return nil
27 }
28 return []Violation{{
29 ConstraintID: c.ID,
30 Message: fmt.Sprintf("%s: %s kind must not relate to %s kind", c.Description, c.FromKind, c.ToKind),
31 Elements: bad,
32 }}
33 }
34
35 // allowedRelationship enforces that only elements whose kind is in fromKinds
36 // may have relationships pointing to elements of toKind.
37 func allowedRelationship(c model.Constraint, m *model.BausteinsichtModel) []Violation {
38 flat, err := model.FlattenElements(m)
39 if err != nil {
40 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
41 }
42 kindOf := buildKindMap(flat)
43
44 allowed := make(map[string]bool, len(c.FromKinds))
45 for _, k := range c.FromKinds {
46 allowed[k] = true
47 }
48
49 var bad []string
50 for _, rel := range m.Relationships {
51 if kindOf[rel.To] == c.ToKind && !allowed[kindOf[rel.From]] {
52 bad = append(bad, fmt.Sprintf("%s (%s) → %s", rel.From, kindOf[rel.From], rel.To))
53 }
54 }
55 if len(bad) == 0 {
56 return nil
57 }
58 return []Violation{{
59 ConstraintID: c.ID,
60 Message: fmt.Sprintf("%s: only [%s] may relate to %s kind", c.Description, strings.Join(c.FromKinds, ", "), c.ToKind),
61 Elements: bad,
62 }}
63 }
64
65 // requiredField enforces that all elements of elementKind have the given field
66 // set to a non-empty value. Supported fields: "description", "technology", "title".
67 func requiredField(c model.Constraint, m *model.BausteinsichtModel) []Violation {
68 flat, err := model.FlattenElements(m)
69 if err != nil {
70 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
71 }
72
73 var bad []string
74 for id, el := range flat {
75 if el.Kind != c.ElementKind {
76 continue
77 }
78 var missing bool
79 switch c.Field {
80 case "description":
81 missing = el.Description == ""
82 case "technology":
83 missing = el.Technology == ""
84 case "title":
85 missing = el.Title == ""
86 default:
87 // Unsupported field name — return error violation immediately
88 return []Violation{{
89 ConstraintID: c.ID,
90 Message: fmt.Sprintf("%s: unsupported field %q (valid: description, technology, title)", c.Description, c.Field),
91 }}
92 }
93 if missing {
94 bad = append(bad, fmt.Sprintf("%s: missing %s", id, c.Field))
95 }
96 }
97 if len(bad) == 0 {
98 return nil
99 }
100 return []Violation{{
101 ConstraintID: c.ID,
102 Message: fmt.Sprintf("%s: all %s elements must have %q set", c.Description, c.ElementKind, c.Field),
103 Elements: bad,
104 }}
105 }
106
107 // maxDepth enforces that no element is nested deeper than max levels.
108 // Root-level elements have depth 1.
109 func maxDepth(c model.Constraint, m *model.BausteinsichtModel) []Violation {
110 var bad []string
111 walkDepth(m.Model, 1, c.Max, &bad)
112 if len(bad) == 0 {
113 return nil
114 }
115 return []Violation{{
116 ConstraintID: c.ID,
117 Message: fmt.Sprintf("%s: maximum nesting depth is %d", c.Description, c.Max),
118 Elements: bad,
119 }}
120 }
121
122 func walkDepth(elements map[string]model.Element, depth, max int, bad *[]string) {
123 for id, el := range elements {
124 if depth > max {
125 *bad = append(*bad, fmt.Sprintf("%s (depth %d)", id, depth))
126 }
127 if len(el.Children) > 0 {
128 walkDepth(el.Children, depth+1, max, bad)
129 }
130 }
131 }
132
133 // noCircularDependency detects cycles in the relationship graph using DFS.
134 func noCircularDependency(c model.Constraint, m *model.BausteinsichtModel) []Violation {
135 // Build adjacency list.
136 adj := make(map[string][]string)
137 flat, err := model.FlattenElements(m)
138 if err != nil {
139 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
140 }
141 for id := range flat {
142 adj[id] = nil
143 }
144 for _, rel := range m.Relationships {
145 adj[rel.From] = append(adj[rel.From], rel.To)
146 }
147
148 visited := make(map[string]bool)
149 inStack := make(map[string]bool)
150 var cycles []string
151
152 var dfs func(node string, path []string)
153 dfs = func(node string, path []string) {
154 visited[node] = true
155 inStack[node] = true
156 path = append(path, node)
157
158 for _, neighbour := range adj[node] {
159 if !visited[neighbour] {
160 dfs(neighbour, path)
161 } else if inStack[neighbour] {
162 // Found a cycle — record the loop segment.
163 for i, n := range path {
164 if n == neighbour {
165 cycle := strings.Join(append(path[i:], neighbour), " → ")
166 cycles = append(cycles, cycle)
167 break
168 }
169 }
170 }
171 }
172 inStack[node] = false
173 }
174
175 for node := range adj {
176 if !visited[node] {
177 dfs(node, nil)
178 }
179 }
180
181 if len(cycles) == 0 {
182 return nil
183 }
184 return []Violation{{
185 ConstraintID: c.ID,
186 Message: c.Description + ": circular dependencies detected",
187 Elements: cycles,
188 }}
189 }
190
191 // technologyAllowed enforces that elements of elementKind only use technologies
192 // from the given allowed list.
193 func technologyAllowed(c model.Constraint, m *model.BausteinsichtModel) []Violation {
194 flat, err := model.FlattenElements(m)
195 if err != nil {
196 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
197 }
198 allowed := make(map[string]bool, len(c.Technologies))
199 for _, t := range c.Technologies {
200 allowed[strings.ToLower(t)] = true
201 }
202
203 var bad []string
204 for id, el := range flat {
205 if el.Kind != c.ElementKind {
206 continue
207 }
208 if el.Technology == "" {
209 continue // technology not set — use required-field rule to enforce that separately
210 }
211 if !allowed[strings.ToLower(el.Technology)] {
212 bad = append(bad, fmt.Sprintf("%s: technology %q not in allowed list [%s]",
213 id, el.Technology, strings.Join(c.Technologies, ", ")))
214 }
215 }
216 if len(bad) == 0 {
217 return nil
218 }
219 return []Violation{{
220 ConstraintID: c.ID,
221 Message: fmt.Sprintf("%s: %s elements must use one of [%s]", c.Description, c.ElementKind, strings.Join(c.Technologies, ", ")),
222 Elements: bad,
223 }}
224 }
225
226 // buildKindMap returns a map from element ID to its kind for all flattened elements.
227 func buildKindMap(flat map[string]*model.Element) map[string]string {
228 m := make(map[string]string, len(flat))
229 for id, el := range flat {
230 m[id] = el.Kind
231 }
232 return m
233 }
234
github.com/docToolchain/Bausteinsicht/internal/diagram/colors.go 100.0%
1 package diagram
2
3 // KindStyle defines fill and stroke colors for element kinds.
4 type KindStyle struct {
5 Fill string
6 Stroke string
7 }
8
9 // DefaultKindColors maps element kinds to consistent visual styles.
10 // Used by all diagram renderers (PlantUML, Mermaid, DOT, D2, HTML5).
11 var DefaultKindColors = map[string]KindStyle{
12 "actor": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
13 "person": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
14 "system": {Fill: "#f5f5f5", Stroke: "#666666"},
15 "external_system": {Fill: "#e1d5e7", Stroke: "#9673a6"},
16 "container": {Fill: "#d5e8d4", Stroke: "#82b366"},
17 "ui": {Fill: "#d5e8d4", Stroke: "#82b366"},
18 "mobile": {Fill: "#d5e8d4", Stroke: "#82b366"},
19 "datastore": {Fill: "#fff2cc", Stroke: "#d6b656"},
20 "database": {Fill: "#fff2cc", Stroke: "#d6b656"},
21 "queue": {Fill: "#ffe6cc", Stroke: "#d5a74e"},
22 "filestore": {Fill: "#fff2cc", Stroke: "#d6b656"},
23 "component": {Fill: "#d5e8d4", Stroke: "#82b366"},
24 }
25
26 // ColorForKind returns the style for a given element kind.
27 // Falls back to a default gray color if the kind is not defined.
28 func ColorForKind(kind string) KindStyle {
29 if style, ok := DefaultKindColors[kind]; ok {
30 return style
31 }
32 return KindStyle{Fill: "#f5f5f5", Stroke: "#666666"}
33 }
34
github.com/docToolchain/Bausteinsicht/internal/diagram/d2.go 89.1%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderD2 renders a view as a D2 diagram.
12 func RenderD2(m *model.BausteinsichtModel, viewKey string) (string, error) {
13 view, ok := m.Views[viewKey]
14 if !ok {
15 return "", fmt.Errorf("view %q not found", viewKey)
16 }
17
18 resolved, err := model.ResolveView(m, &view)
19 if err != nil {
20 return "", err
21 }
22
23 flat, _ := model.FlattenElements(m)
24 sort.Strings(resolved)
25
26 // Filter elements visible in this view
27 elemSet := make(map[string]bool, len(resolved))
28 for _, id := range resolved {
29 elemSet[id] = true
30 }
31 if view.Scope != "" {
32 elemSet[view.Scope] = true
33 }
34
35 // Filter relationships
36 rels := filterRelationships(m.Relationships, elemSet)
37
38 var b strings.Builder
39 b.WriteString("direction: right\n\n")
40
41 // Write nodes
42 for _, id := range resolved {
43 elem := flat[id]
44 if elem == nil {
45 continue
46 }
47
48 style := ColorForKind(elem.Kind)
49 nodeID := sanitizeD2ID(id)
50
51 title := elem.Title
52 if title == "" {
53 title = id
54 }
55
56 // Node with styling
57 nodeLabel := escapeD2String(title)
58 if elem.Kind != "" {
59 nodeLabel = fmt.Sprintf("%s [%s]", escapeD2String(title), elem.Kind)
60 }
61 fmt.Fprintf(&b, "%s: %s {\n", nodeID, nodeLabel)
62 fmt.Fprintf(&b, " shape: rectangle\n")
63 fmt.Fprintf(&b, " style.fill: \"%s\"\n", style.Fill)
64 fmt.Fprintf(&b, " style.stroke: \"%s\"\n", style.Stroke)
65 if elem.Description != "" {
66 fmt.Fprintf(&b, " note: %s\n", escapeD2String(elem.Description))
67 }
68 b.WriteString("}\n\n")
69 }
70
71 // Write relationships
72 for _, r := range rels {
73 fromID := sanitizeD2ID(r.From)
74 toID := sanitizeD2ID(r.To)
75 if r.Label != "" {
76 fmt.Fprintf(&b, "%s -> %s: %s\n", fromID, toID, escapeD2String(r.Label))
77 } else {
78 fmt.Fprintf(&b, "%s -> %s\n", fromID, toID)
79 }
80 }
81
82 return b.String(), nil
83 }
84
85 // sanitizeD2ID converts a dot-notation ID to a valid D2 identifier.
86 func sanitizeD2ID(id string) string {
87 s := strings.ReplaceAll(id, ".", "_")
88 s = strings.ReplaceAll(s, "-", "_")
89 return s
90 }
91
92 // escapeD2String escapes a string for use in D2 string literals.
93 func escapeD2String(s string) string {
94 return "\"" + strings.ReplaceAll(s, "\"", "\\\"") + "\""
95 }
96
github.com/docToolchain/Bausteinsicht/internal/diagram/diagram.go 90.7%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // Format represents the output diagram format.
12 type Format int
13
14 const (
15 PlantUML Format = iota
16 Mermaid
17 )
18
19 // C4-PlantUML is part of the PlantUML stdlib since v2.x, so we use
20 // the <C4/...> include syntax which resolves locally without network access.
21
22 // applyTagFiltering filters resolved element IDs based on tag criteria.
23 // Elements must have ALL filterTags (intersection) and must not have ANY excludeTags (union).
24 func applyTagFiltering(resolved []string, flat map[string]*model.Element, filterTags, excludeTags []string) []string {
25 if len(filterTags) == 0 && len(excludeTags) == 0 {
26 return resolved
27 }
28
29 var result []string
30 for _, id := range resolved {
31 elem := flat[id]
32 if elem == nil {
33 // Element not found in flat map, skip it (shouldn't happen)
34 continue
35 }
36
37 // Check exclude tags: if ANY exclude-tag matches, skip
38 excluded := false
39 for _, excludeTag := range excludeTags {
40 for _, elemTag := range elem.Tags {
41 if elemTag == excludeTag {
42 excluded = true
43 break
44 }
45 }
46 if excluded {
47 break
48 }
49 }
50 if excluded {
51 continue
52 }
53
54 // Check filter tags: if ANY filter-tags are specified, element must have ALL of them
55 if len(filterTags) > 0 {
56 hasAllFilterTags := true
57 for _, filterTag := range filterTags {
58 found := false
59 for _, elemTag := range elem.Tags {
60 if elemTag == filterTag {
61 found = true
62 break
63 }
64 }
65 if !found {
66 hasAllFilterTags = false
67 break
68 }
69 }
70 if !hasAllFilterTags {
71 continue
72 }
73 }
74
75 result = append(result, id)
76 }
77
78 return result
79 }
80
81 // FormatView renders a view as a C4 diagram in the given format.
82 func FormatView(m *model.BausteinsichtModel, viewKey string, f Format) (string, error) {
83 view, ok := m.Views[viewKey]
84 if !ok {
85 return "", fmt.Errorf("view %q not found", viewKey)
86 }
87
88 resolved, err := model.ResolveView(m, &view)
89 if err != nil {
90 return "", err
91 }
92
93 flat, _ := model.FlattenElements(m)
94
95 // Apply tag-based filtering if specified in the view.
96 resolved = applyTagFiltering(resolved, flat, view.FilterTags, view.ExcludeTags)
97 sort.Strings(resolved)
98
99 // Determine C4 level from view content.
100 level := detectLevel(resolved, flat, view.Scope)
101
102 // Separate scope-internal elements from external ones.
103 scopeElems, externalElems := partitionElements(resolved, flat, view.Scope)
104
105 // Filter relationships to those visible in this view.
106 elemSet := make(map[string]bool, len(resolved))
107 for _, id := range resolved {
108 elemSet[id] = true
109 }
110 if view.Scope != "" {
111 elemSet[view.Scope] = true
112 }
113 rels := filterRelationships(m.Relationships, elemSet)
114
115 var b strings.Builder
116 switch f {
117 case PlantUML:
118 writePlantUML(&b, view, level, scopeElems, externalElems, rels, flat)
119 case Mermaid:
120 writeMermaid(&b, view, level, scopeElems, externalElems, rels, flat)
121 }
122 return b.String(), nil
123 }
124
125 type elemEntry struct {
126 ID string
127 Elem *model.Element
128 }
129
130 func detectLevel(resolved []string, flat map[string]*model.Element, scope string) string {
131 hasContainer := false
132 for _, id := range resolved {
133 elem := flat[id]
134 if elem == nil {
135 continue
136 }
137 if elem.Kind == "component" {
138 return "Component"
139 }
140 if elem.Kind == "container" {
141 hasContainer = true
142 }
143 }
144 if hasContainer || scope != "" {
145 return "Container"
146 }
147 return "Context"
148 }
149
150 func partitionElements(resolved []string, flat map[string]*model.Element, scope string) (inside, outside []elemEntry) {
151 for _, id := range resolved {
152 elem := flat[id]
153 if elem == nil {
154 continue
155 }
156 if scope != "" && strings.HasPrefix(id, scope+".") {
157 inside = append(inside, elemEntry{id, elem})
158 } else {
159 outside = append(outside, elemEntry{id, elem})
160 }
161 }
162 return
163 }
164
165 type relEntry struct {
166 From string `json:"from"`
167 To string `json:"to"`
168 Label string `json:"label,omitempty"`
169 }
170
171 func filterRelationships(rels []model.Relationship, elemSet map[string]bool) []relEntry {
172 var result []relEntry
173 seen := make(map[string]bool)
174 for _, r := range rels {
175 from := liftToVisible(r.From, elemSet)
176 to := liftToVisible(r.To, elemSet)
177 if from == "" || to == "" || from == to {
178 continue
179 }
180 key := from + ":" + to
181 if seen[key] {
182 continue
183 }
184 seen[key] = true
185 result = append(result, relEntry{from, to, r.Label})
186 }
187 return result
188 }
189
190 func liftToVisible(id string, elemSet map[string]bool) string {
191 if elemSet[id] {
192 return id
193 }
194 for {
195 dot := strings.LastIndex(id, ".")
196 if dot < 0 {
197 return ""
198 }
199 id = id[:dot]
200 if elemSet[id] {
201 return id
202 }
203 }
204 }
205
206 func c4Macro(kind string) string {
207 switch kind {
208 case "actor":
209 return "Person"
210 case "system":
211 return "System"
212 case "external_system":
213 return "System_Ext"
214 case "container", "ui", "mobile":
215 return "Container"
216 case "datastore":
217 return "ContainerDb"
218 case "queue":
219 return "ContainerQueue"
220 case "filestore":
221 return "Container"
222 case "component":
223 return "Component"
224 default:
225 return "System"
226 }
227 }
228
229 func sanitizeID(id string) string {
230 return strings.ReplaceAll(strings.ReplaceAll(id, ".", "_"), "-", "_")
231 }
232
233 func escapeQuotes(s string) string {
234 return strings.ReplaceAll(s, "\"", "'")
235 }
236
237 // --- PlantUML ---
238
239 func writePlantUML(b *strings.Builder, view model.View, level string, inside, outside []elemEntry, rels []relEntry, flat map[string]*model.Element) {
240 b.WriteString("@startuml\n")
241 fmt.Fprintf(b, "!include <C4/C4_%s>\n\n", level)
242
243 // External elements (outside scope boundary).
244 for _, e := range outside {
245 writePlantUMLElement(b, e, "")
246 }
247
248 // Scope boundary with internal elements.
249 if view.Scope != "" {
250 scopeElem := flat[view.Scope]
251 scopeTitle := view.Scope
252 if scopeElem != nil {
253 scopeTitle = scopeElem.Title
254 }
255 boundaryMacro := "System_Boundary"
256 if scopeElem != nil && scopeElem.Kind == "container" {
257 boundaryMacro = "Container_Boundary"
258 }
259 fmt.Fprintf(b, "%s(%s, \"%s\") {\n", boundaryMacro, sanitizeID(view.Scope), escapeQuotes(scopeTitle))
260 for _, e := range inside {
261 writePlantUMLElement(b, e, " ")
262 }
263 b.WriteString("}\n")
264 } else {
265 for _, e := range inside {
266 writePlantUMLElement(b, e, "")
267 }
268 }
269
270 // Relationships.
271 if len(rels) > 0 {
272 b.WriteString("\n")
273 }
274 for _, r := range rels {
275 fmt.Fprintf(b, "Rel(%s, %s, \"%s\")\n", sanitizeID(r.From), sanitizeID(r.To), escapeQuotes(r.Label))
276 }
277
278 b.WriteString("@enduml\n")
279 }
280
281 func writePlantUMLElement(b *strings.Builder, e elemEntry, indent string) {
282 macro := c4Macro(e.Elem.Kind)
283 if e.Elem.Technology != "" {
284 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\", \"%s\")\n",
285 indent, macro, sanitizeID(e.ID),
286 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Technology), escapeQuotes(e.Elem.Description))
287 } else {
288 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\")\n",
289 indent, macro, sanitizeID(e.ID),
290 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Description))
291 }
292 }
293
294 // --- Mermaid ---
295
296 func writeMermaid(b *strings.Builder, view model.View, level string, inside, outside []elemEntry, rels []relEntry, flat map[string]*model.Element) {
297 fmt.Fprintf(b, "C4%s\n", level)
298 fmt.Fprintf(b, " title %s\n\n", view.Title)
299
300 for _, e := range outside {
301 writeMermaidElement(b, e, " ")
302 }
303
304 if view.Scope != "" {
305 scopeElem := flat[view.Scope]
306 scopeTitle := view.Scope
307 if scopeElem != nil {
308 scopeTitle = scopeElem.Title
309 }
310 boundaryMacro := "System_Boundary"
311 if scopeElem != nil && scopeElem.Kind == "container" {
312 boundaryMacro = "Container_Boundary"
313 }
314 fmt.Fprintf(b, " %s(%s, \"%s\") {\n", boundaryMacro, sanitizeID(view.Scope), escapeQuotes(scopeTitle))
315 for _, e := range inside {
316 writeMermaidElement(b, e, " ")
317 }
318 b.WriteString(" }\n")
319 } else {
320 for _, e := range inside {
321 writeMermaidElement(b, e, " ")
322 }
323 }
324
325 if len(rels) > 0 {
326 b.WriteString("\n")
327 }
328 for _, r := range rels {
329 fmt.Fprintf(b, " Rel(%s, %s, \"%s\")\n", sanitizeID(r.From), sanitizeID(r.To), escapeQuotes(r.Label))
330 }
331 }
332
333 func writeMermaidElement(b *strings.Builder, e elemEntry, indent string) {
334 macro := c4Macro(e.Elem.Kind)
335 if e.Elem.Technology != "" {
336 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\", \"%s\")\n",
337 indent, macro, sanitizeID(e.ID),
338 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Technology), escapeQuotes(e.Elem.Description))
339 } else {
340 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\")\n",
341 indent, macro, sanitizeID(e.ID),
342 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Description))
343 }
344 }
345
346 // ExportAllViewsToMermaid exports all views from the model as Mermaid diagrams.
347 // Returns a slice of view keys in order and a map of view key → Mermaid diagram code.
348 func ExportAllViewsToMermaid(m *model.BausteinsichtModel) ([]string, map[string]string, error) {
349 diagrams := make(map[string]string)
350 var viewKeys []string
351
352 for viewKey := range m.Views {
353 viewKeys = append(viewKeys, viewKey)
354 }
355 sort.Strings(viewKeys)
356
357 for _, viewKey := range viewKeys {
358 diagramCode, err := FormatView(m, viewKey, Mermaid)
359 if err != nil {
360 return nil, nil, err
361 }
362 diagrams[viewKey] = diagramCode
363 }
364
365 return viewKeys, diagrams, nil
366 }
367
github.com/docToolchain/Bausteinsicht/internal/diagram/dot.go 87.8%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderDOT renders a view as a GraphViz DOT graph.
12 func RenderDOT(m *model.BausteinsichtModel, viewKey string) (string, error) {
13 view, ok := m.Views[viewKey]
14 if !ok {
15 return "", fmt.Errorf("view %q not found", viewKey)
16 }
17
18 resolved, err := model.ResolveView(m, &view)
19 if err != nil {
20 return "", err
21 }
22
23 flat, _ := model.FlattenElements(m)
24 sort.Strings(resolved)
25
26 // Filter elements visible in this view
27 elemSet := make(map[string]bool, len(resolved))
28 for _, id := range resolved {
29 elemSet[id] = true
30 }
31 if view.Scope != "" {
32 elemSet[view.Scope] = true
33 }
34
35 // Filter relationships
36 rels := filterRelationships(m.Relationships, elemSet)
37
38 var b strings.Builder
39 b.WriteString("digraph \"" + escapeQuotes(view.Title) + "\" {\n")
40 b.WriteString(" rankdir=LR\n")
41 b.WriteString(" node [shape=box style=filled fontname=\"Arial\" fontsize=9]\n")
42 b.WriteString(" edge [fontsize=8]\n\n")
43
44 // Write nodes
45 for _, id := range resolved {
46 elem := flat[id]
47 if elem == nil {
48 continue
49 }
50
51 style := ColorForKind(elem.Kind)
52 nodeID := sanitizeID(id)
53
54 label := elem.Title
55 if elem.Title == "" {
56 label = id
57 }
58 if elem.Kind != "" {
59 label = label + "\n[" + elem.Kind + "]"
60 }
61
62 fmt.Fprintf(&b, " %s [label=\"%s\" fillcolor=\"%s\" color=\"%s\"]\n",
63 nodeID, escapeQuotes(label), style.Fill, style.Stroke)
64 }
65
66 // Write relationships
67 if len(rels) > 0 {
68 b.WriteString("\n")
69 for _, r := range rels {
70 fromID := sanitizeID(r.From)
71 toID := sanitizeID(r.To)
72 if r.Label != "" {
73 fmt.Fprintf(&b, " %s -> %s [label=\"%s\"]\n", fromID, toID, escapeQuotes(r.Label))
74 } else {
75 fmt.Fprintf(&b, " %s -> %s\n", fromID, toID)
76 }
77 }
78 }
79
80 b.WriteString("}\n")
81 return b.String(), nil
82 }
83
github.com/docToolchain/Bausteinsicht/internal/diagram/html.go 83.8%
1 package diagram
2
3 import (
4 "encoding/json"
5 "fmt"
6 "html"
7 "sort"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // HTMLNode represents a node in the interactive diagram.
13 type HTMLNode struct {
14 ID string `json:"id"`
15 Title string `json:"title"`
16 Kind string `json:"kind"`
17 Description string `json:"description,omitempty"`
18 Technology string `json:"technology,omitempty"`
19 X float64 `json:"x"`
20 Y float64 `json:"y"`
21 Fill string `json:"fill"`
22 Stroke string `json:"stroke"`
23 }
24
25 // HTMLEdge is a type alias for relEntry used in HTML diagram output.
26 type HTMLEdge = relEntry
27
28 // HTMLDiagramData is the data structure embedded in the HTML output.
29 type HTMLDiagramData struct {
30 Title string `json:"title"`
31 Nodes []HTMLNode `json:"nodes"`
32 Edges []HTMLEdge `json:"edges"`
33 }
34
35 // RenderHTML renders a view as an interactive HTML5 diagram.
36 func RenderHTML(m *model.BausteinsichtModel, viewKey string) (string, error) {
37 view, ok := m.Views[viewKey]
38 if !ok {
39 return "", fmt.Errorf("view %q not found", viewKey)
40 }
41
42 resolved, err := model.ResolveView(m, &view)
43 if err != nil {
44 return "", err
45 }
46
47 flat, _ := model.FlattenElements(m)
48 sort.Strings(resolved)
49
50 // Filter elements visible in this view
51 elemSet := make(map[string]bool, len(resolved))
52 for _, id := range resolved {
53 elemSet[id] = true
54 }
55 if view.Scope != "" {
56 elemSet[view.Scope] = true
57 }
58
59 // Filter relationships
60 rels := filterRelationships(m.Relationships, elemSet)
61
62 // Build node list with simple grid layout
63 nodes := []HTMLNode{}
64 x, y := 50.0, 50.0
65 for _, id := range resolved {
66 elem := flat[id]
67 if elem == nil {
68 continue
69 }
70
71 style := ColorForKind(elem.Kind)
72 title := elem.Title
73 if title == "" {
74 title = id
75 }
76
77 nodes = append(nodes, HTMLNode{
78 ID: id,
79 Title: title,
80 Kind: elem.Kind,
81 Description: elem.Description,
82 Technology: elem.Technology,
83 X: x,
84 Y: y,
85 Fill: style.Fill,
86 Stroke: style.Stroke,
87 })
88
89 x += 200
90 if x > 800 {
91 x = 50
92 y += 150
93 }
94 }
95
96 // Build edge list from relationships
97 edges := make([]HTMLEdge, len(rels))
98 for i, r := range rels {
99 edges[i] = HTMLEdge(r)
100 }
101
102 // Create diagram data
103 data := HTMLDiagramData{
104 Title: view.Title,
105 Nodes: nodes,
106 Edges: edges,
107 }
108
109 dataJSON, _ := json.Marshal(data)
110
111 // Generate HTML with embedded JavaScript renderer (escape title for HTML safety)
112 htmlContent := generateHTMLTemplate(html.EscapeString(view.Title), string(dataJSON))
113 return htmlContent, nil
114 }
115
116 func generateHTMLTemplate(title, dataJSON string) string {
117 return fmt.Sprintf(`<!DOCTYPE html>
118 <html lang="en">
119 <head>
120 <meta charset="UTF-8">
121 <meta name="viewport" content="width=device-width, initial-scale=1.0">
122 <title>Architecture — %s</title>
123 <style>
124 * { margin: 0; padding: 0; box-sizing: border-box; }
125 body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; }
126
127 #header {
128 background: white;
129 padding: 16px;
130 border-bottom: 1px solid #ddd;
131 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
132 }
133
134 #header h1 { font-size: 20px; font-weight: 600; color: #333; }
135
136 #controls {
137 display: flex;
138 gap: 12px;
139 margin-top: 12px;
140 align-items: center;
141 }
142
143 input[type="text"] {
144 flex: 1;
145 max-width: 300px;
146 padding: 8px 12px;
147 border: 1px solid #ddd;
148 border-radius: 4px;
149 font-size: 14px;
150 }
151
152 button {
153 padding: 8px 16px;
154 background: #0066cc;
155 color: white;
156 border: none;
157 border-radius: 4px;
158 font-size: 14px;
159 cursor: pointer;
160 }
161
162 button:hover { background: #0052a3; }
163
164 #canvas {
165 flex: 1;
166 background: white;
167 position: relative;
168 overflow: auto;
169 }
170
171 svg { display: block; }
172
173 #details {
174 width: 300px;
175 background: white;
176 border-left: 1px solid #ddd;
177 padding: 16px;
178 overflow-y: auto;
179 display: none;
180 }
181
182 #details.show { display: block; }
183
184 #details h3 { font-size: 16px; font-weight: 600; color: #333; margin-bottom: 12px; }
185 #details p { margin: 8px 0; font-size: 13px; color: #666; word-break: break-word; }
186 #details strong { color: #333; }
187
188 .grid { display: flex; }
189 #canvas { flex: 1; }
190
191 .node { cursor: pointer; transition: opacity 0.2s; }
192 .node:hover { opacity: 0.8; }
193 .node.highlighted { filter: drop-shadow(0 0 4px #0066cc); }
194 .node.faded { opacity: 0.3; }
195
196 .edge { stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
197 .edge.highlighted { stroke: #0066cc; stroke-width: 3; }
198 .edge.faded { opacity: 0.2; }
199
200 .edge-label { font-size: 12px; fill: #333; pointer-events: none; }
201 </style>
202 </head>
203 <body>
204 <div id="header">
205 <h1>Architecture Diagram</h1>
206 <div id="controls">
207 <input type="text" id="searchInput" placeholder="Search elements..." />
208 <button onclick="resetZoom()">Reset View</button>
209 </div>
210 </div>
211
212 <div class="grid">
213 <div id="canvas"></div>
214 <div id="details">
215 <h3 id="detailsTitle"></h3>
216 <p><strong>Kind:</strong> <span id="detailsKind"></span></p>
217 <p id="detailsTech" style="display:none;"><strong>Technology:</strong> <span id="detailsTechVal"></span></p>
218 <p id="detailsDesc" style="display:none;"><strong>Description:</strong> <span id="detailsDescVal"></span></p>
219 </div>
220 </div>
221
222 <script>
223 const DIAGRAM_DATA = %s;
224
225 const state = {
226 zoom: 1,
227 pan: { x: 0, y: 0 },
228 selected: null,
229 search: ""
230 };
231
232 function initDiagram() {
233 const canvas = document.getElementById('canvas');
234 const width = canvas.clientWidth;
235 const height = canvas.clientHeight;
236
237 const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
238 svg.setAttribute('width', width);
239 svg.setAttribute('height', height);
240
241 // Add arrowhead marker definition
242 const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
243 const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
244 marker.setAttribute('id', 'arrowhead');
245 marker.setAttribute('markerWidth', '10');
246 marker.setAttribute('markerHeight', '10');
247 marker.setAttribute('refX', '9');
248 marker.setAttribute('refY', '3');
249 marker.setAttribute('orient', 'auto');
250 const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
251 poly.setAttribute('points', '0 0, 10 3, 0 6');
252 poly.setAttribute('fill', '#333');
253 marker.appendChild(poly);
254 defs.appendChild(marker);
255 svg.appendChild(defs);
256
257 // Draw edges first (background)
258 for (const edge of DIAGRAM_DATA.edges) {
259 const fromNode = DIAGRAM_DATA.nodes.find(n => n.id === edge.from);
260 const toNode = DIAGRAM_DATA.nodes.find(n => n.id === edge.to);
261 if (fromNode && toNode) {
262 const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
263 line.setAttribute('x1', fromNode.x + 80);
264 line.setAttribute('y1', fromNode.y + 40);
265 line.setAttribute('x2', toNode.x + 80);
266 line.setAttribute('y2', toNode.y + 40);
267 line.setAttribute('stroke', '#999');
268 line.setAttribute('stroke-width', '2');
269 line.setAttribute('marker-end', 'url(#arrowhead)');
270 line.classList.add('edge');
271 line.dataset.from = edge.from;
272 line.dataset.to = edge.to;
273 svg.appendChild(line);
274
275 // Label
276 if (edge.label) {
277 const mid = {
278 x: (fromNode.x + toNode.x) / 2 + 80,
279 y: (fromNode.y + toNode.y) / 2 + 40
280 };
281 const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
282 text.setAttribute('x', mid.x);
283 text.setAttribute('y', mid.y - 5);
284 text.setAttribute('text-anchor', 'middle');
285 text.setAttribute('font-size', '12');
286 text.textContent = edge.label;
287 text.classList.add('edge-label');
288 svg.appendChild(text);
289 }
290 }
291 }
292
293 // Draw nodes
294 for (const node of DIAGRAM_DATA.nodes) {
295 const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
296 g.classList.add('node');
297 g.dataset.id = node.id;
298
299 const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
300 rect.setAttribute('x', node.x);
301 rect.setAttribute('y', node.y);
302 rect.setAttribute('width', '160');
303 rect.setAttribute('height', '80');
304 rect.setAttribute('fill', node.fill);
305 rect.setAttribute('stroke', node.stroke);
306 rect.setAttribute('stroke-width', '2');
307 rect.setAttribute('rx', '4');
308
309 const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
310 title.setAttribute('x', node.x + 80);
311 title.setAttribute('y', node.y + 25);
312 title.setAttribute('text-anchor', 'middle');
313 title.setAttribute('font-weight', 'bold');
314 title.setAttribute('font-size', '13');
315 title.textContent = node.title.substring(0, 20);
316
317 const kind = document.createElementNS('http://www.w3.org/2000/svg', 'text');
318 kind.setAttribute('x', node.x + 80);
319 kind.setAttribute('y', node.y + 50);
320 kind.setAttribute('text-anchor', 'middle');
321 kind.setAttribute('font-size', '11');
322 kind.setAttribute('fill', '#666');
323 kind.textContent = '[' + node.kind + ']';
324
325 g.appendChild(rect);
326 g.appendChild(title);
327 g.appendChild(kind);
328
329 g.onclick = () => selectNode(node);
330 svg.appendChild(g);
331 }
332
333 canvas.appendChild(svg);
334
335 // Search
336 document.getElementById('searchInput').addEventListener('input', (e) => {
337 state.search = e.target.value.toLowerCase();
338 highlightSearch();
339 });
340 }
341
342 function selectNode(node) {
343 state.selected = node.id;
344 updateDetails(node);
345 highlightNode(node.id);
346 }
347
348 function updateDetails(node) {
349 const details = document.getElementById('details');
350 document.getElementById('detailsTitle').textContent = node.title;
351 document.getElementById('detailsKind').textContent = node.kind;
352
353 const techEl = document.getElementById('detailsTech');
354 if (node.technology) {
355 techEl.style.display = 'block';
356 document.getElementById('detailsTechVal').textContent = node.technology;
357 } else {
358 techEl.style.display = 'none';
359 }
360
361 const descEl = document.getElementById('detailsDesc');
362 if (node.description) {
363 descEl.style.display = 'block';
364 document.getElementById('detailsDescVal').textContent = node.description;
365 } else {
366 descEl.style.display = 'none';
367 }
368
369 details.classList.add('show');
370 }
371
372 function highlightNode(nodeId) {
373 document.querySelectorAll('.node').forEach(el => {
374 if (el.dataset.id === nodeId) {
375 el.classList.add('highlighted');
376 } else {
377 el.classList.remove('highlighted');
378 }
379 });
380 }
381
382 function highlightSearch() {
383 if (!state.search) {
384 document.querySelectorAll('.node, .edge').forEach(el => el.classList.remove('faded'));
385 return;
386 }
387
388 document.querySelectorAll('.node').forEach(el => {
389 const nodeId = el.dataset.id;
390 const node = DIAGRAM_DATA.nodes.find(n => n.id === nodeId);
391 const matches = node && (node.id.toLowerCase().includes(state.search) || node.title.toLowerCase().includes(state.search));
392 el.classList.toggle('faded', !matches);
393 });
394
395 document.querySelectorAll('.edge').forEach(el => {
396 const from = el.dataset.from;
397 const to = el.dataset.to;
398 const matches = from.toLowerCase().includes(state.search) || to.toLowerCase().includes(state.search);
399 el.classList.toggle('faded', !matches);
400 });
401 }
402
403 function resetZoom() {
404 state.zoom = 1;
405 state.pan = { x: 0, y: 0 };
406 state.selected = null;
407 document.getElementById('details').classList.remove('show');
408 document.querySelectorAll('.node').forEach(el => el.classList.remove('highlighted', 'faded'));
409 document.getElementById('searchInput').value = '';
410 }
411
412 window.addEventListener('load', initDiagram);
413 </script>
414 </body>
415 </html>`, title, dataJSON)
416 }
417
github.com/docToolchain/Bausteinsicht/internal/diagram/markdown.go 93.8%
1 package diagram
2
3 import (
4 "fmt"
5 "strings"
6 "time"
7 )
8
9 // WrapDiagramsInMarkdown wraps multiple Mermaid diagrams in a Markdown document.
10 // viewKeys: ordered list of view keys
11 // diagrams: map of view key → Mermaid diagram code (without outer backticks)
12 func WrapDiagramsInMarkdown(viewKeys []string, diagrams map[string]string, viewTitles map[string]string) string {
13 var b strings.Builder
14
15 b.WriteString("# Architecture Diagrams\n\n")
16 b.WriteString("> Auto-generated by bausteinsicht — do not edit manually.\n")
17 fmt.Fprintf(&b, "> Last updated: %s\n\n", time.Now().UTC().Format(time.RFC3339))
18
19 for _, viewKey := range viewKeys {
20 diagramCode, exists := diagrams[viewKey]
21 if !exists || diagramCode == "" {
22 continue
23 }
24
25 title := viewKey
26 if customTitle, ok := viewTitles[viewKey]; ok {
27 title = customTitle
28 }
29
30 fmt.Fprintf(&b, "## %s\n\n", title)
31 b.WriteString("```mermaid\n")
32 b.WriteString(diagramCode)
33 b.WriteString("\n```\n\n")
34 }
35
36 return b.String()
37 }
38
github.com/docToolchain/Bausteinsicht/internal/diagram/sequence.go 100.0%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderSequencePlantUML renders a DynamicView as a PlantUML sequence diagram.
12 func RenderSequencePlantUML(view model.DynamicView, flat map[string]*model.Element) string {
13 steps := sortedSteps(view.Steps)
14 participants := collectParticipants(steps)
15
16 var b strings.Builder
17 fmt.Fprintf(&b, "@startuml %s\n", sanitizeID(view.Key))
18 fmt.Fprintf(&b, "title %s\n\n", escapeQuotes(view.Title))
19
20 for _, id := range participants {
21 title := participantTitle(id, flat)
22 fmt.Fprintf(&b, "participant \"%s\" as %s\n", escapeQuotes(title), sanitizeID(id))
23 }
24 b.WriteString("\n")
25
26 for _, step := range steps {
27 arrow := plantUMLArrow(step.Type)
28 label := fmt.Sprintf("%d. %s", step.Index, escapeQuotes(step.Label))
29 fmt.Fprintf(&b, "%s %s %s : %s\n", sanitizeID(step.From), arrow, sanitizeID(step.To), label)
30 }
31
32 b.WriteString("\n@enduml\n")
33 return b.String()
34 }
35
36 // RenderSequenceMermaid renders a DynamicView as a Mermaid sequence diagram.
37 func RenderSequenceMermaid(view model.DynamicView, flat map[string]*model.Element) string {
38 steps := sortedSteps(view.Steps)
39 participants := collectParticipants(steps)
40
41 var b strings.Builder
42 b.WriteString("sequenceDiagram\n")
43 fmt.Fprintf(&b, " title %s\n\n", escapeQuotes(view.Title))
44
45 for _, id := range participants {
46 title := participantTitle(id, flat)
47 fmt.Fprintf(&b, " participant %s as %s\n", sanitizeID(id), escapeQuotes(title))
48 }
49 b.WriteString("\n")
50
51 for _, step := range steps {
52 arrow := mermaidArrow(step.Type)
53 label := fmt.Sprintf("%d. %s", step.Index, escapeQuotes(step.Label))
54 fmt.Fprintf(&b, " %s%s%s: %s\n", sanitizeID(step.From), arrow, sanitizeID(step.To), label)
55 }
56
57 return b.String()
58 }
59
60 // sortedSteps returns steps sorted by index.
61 func sortedSteps(steps []model.SequenceStep) []model.SequenceStep {
62 sorted := make([]model.SequenceStep, len(steps))
63 copy(sorted, steps)
64 sort.Slice(sorted, func(i, j int) bool {
65 return sorted[i].Index < sorted[j].Index
66 })
67 return sorted
68 }
69
70 // collectParticipants returns participant IDs in first-appearance order.
71 func collectParticipants(steps []model.SequenceStep) []string {
72 seen := make(map[string]bool)
73 var order []string
74 for _, s := range steps {
75 if !seen[s.From] {
76 seen[s.From] = true
77 order = append(order, s.From)
78 }
79 if !seen[s.To] {
80 seen[s.To] = true
81 order = append(order, s.To)
82 }
83 }
84 return order
85 }
86
87 func participantTitle(id string, flat map[string]*model.Element) string {
88 if flat != nil {
89 if e, ok := flat[id]; ok && e.Title != "" {
90 return e.Title
91 }
92 }
93 return id
94 }
95
96 func plantUMLArrow(t model.StepType) string {
97 switch t {
98 case model.StepAsync:
99 return "->>"
100 case model.StepReturn:
101 return "-->"
102 default: // sync or empty
103 return "->"
104 }
105 }
106
107 func mermaidArrow(t model.StepType) string {
108 switch t {
109 case model.StepAsync:
110 return "-)"
111 case model.StepReturn:
112 return "-->>"
113 default: // sync or empty
114 return "->>"
115 }
116 }
117
github.com/docToolchain/Bausteinsicht/internal/diff/diff.go 94.0%
1 package diff
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // Compare generates a diff between two architecture snapshots (asIs vs toBe)
10 func Compare(asIs, toBe *model.ModelSnapshot) *DiffResult {
11 result := &DiffResult{
12 Elements: []ElementChange{},
13 Relationships: []RelationshipChange{},
14 Summary: Summary{},
15 }
16
17 if asIs == nil || toBe == nil {
18 return result
19 }
20
21 compareElements(asIs.Elements, toBe.Elements, result)
22 compareRelationships(asIs.Relationships, toBe.Relationships, result)
23
24 calculateSummary(result)
25
26 return result
27 }
28
29 func compareElements(asIsElems, toBeElems map[string]model.Element, result *DiffResult) {
30 // Mark as-is elements
31 seenAsIs := make(map[string]bool)
32 for id, asIsElem := range asIsElems {
33 seenAsIs[id] = true
34
35 toBeElem, exists := toBeElems[id]
36 if !exists {
37 // Element removed
38 result.Elements = append(result.Elements, ElementChange{
39 ID: id,
40 Type: ChangeRemoved,
41 AsIs: &asIsElem,
42 Reason: "removed from to-be state",
43 })
44 continue
45 }
46
47 // Check if element changed
48 if hasElementChanged(&asIsElem, &toBeElem) {
49 result.Elements = append(result.Elements, ElementChange{
50 ID: id,
51 Type: ChangeChanged,
52 AsIs: &asIsElem,
53 ToBe: &toBeElem,
54 Reason: "element properties changed",
55 })
56 }
57 }
58
59 // Find added elements
60 for id, toBeElem := range toBeElems {
61 if !seenAsIs[id] {
62 result.Elements = append(result.Elements, ElementChange{
63 ID: id,
64 Type: ChangeAdded,
65 ToBe: &toBeElem,
66 Reason: "new element in to-be state",
67 })
68 }
69 }
70 }
71
72 func compareRelationships(asIsRels, toBeRels []model.Relationship, result *DiffResult) {
73 // Build maps for easier comparison
74 asIsMap := relationshipMap(asIsRels)
75 toBeMap := relationshipMap(toBeRels)
76
77 // Find removed and changed relationships
78 for key, asIsRel := range asIsMap {
79 toBeRel, exists := toBeMap[key]
80 if !exists {
81 // Relationship removed
82 result.Relationships = append(result.Relationships, RelationshipChange{
83 From: asIsRel.From,
84 To: asIsRel.To,
85 Type: ChangeRemoved,
86 AsIs: &asIsRel,
87 })
88 continue
89 }
90
91 // Check if changed (e.g., label changed)
92 if asIsRel.Label != toBeRel.Label {
93 result.Relationships = append(result.Relationships, RelationshipChange{
94 From: asIsRel.From,
95 To: asIsRel.To,
96 Type: ChangeChanged,
97 AsIs: &asIsRel,
98 ToBe: &toBeRel,
99 })
100 }
101 }
102
103 // Find added relationships
104 for key, toBeRel := range toBeMap {
105 if _, exists := asIsMap[key]; !exists {
106 result.Relationships = append(result.Relationships, RelationshipChange{
107 From: toBeRel.From,
108 To: toBeRel.To,
109 Type: ChangeAdded,
110 ToBe: &toBeRel,
111 })
112 }
113 }
114 }
115
116 func relationshipMap(rels []model.Relationship) map[string]model.Relationship {
117 m := make(map[string]model.Relationship)
118 for _, rel := range rels {
119 key := fmt.Sprintf("%s->%s", rel.From, rel.To)
120 m[key] = rel
121 }
122 return m
123 }
124
125 func hasElementChanged(asIs, toBe *model.Element) bool {
126 if asIs == nil || toBe == nil {
127 return true
128 }
129
130 // Compare relevant fields (excluding layout properties)
131 return asIs.Title != toBe.Title ||
132 asIs.Kind != toBe.Kind ||
133 asIs.Technology != toBe.Technology ||
134 asIs.Description != toBe.Description ||
135 asIs.Status != toBe.Status
136 }
137
138 func calculateSummary(result *DiffResult) {
139 for _, change := range result.Elements {
140 switch change.Type {
141 case ChangeAdded:
142 result.Summary.AddedElements++
143 result.Summary.TotalAddedElements++
144 case ChangeRemoved:
145 result.Summary.RemovedElements++
146 result.Summary.TotalRemovedElements++
147 case ChangeChanged:
148 result.Summary.ChangedElements++
149 }
150 }
151
152 for _, change := range result.Relationships {
153 switch change.Type {
154 case ChangeAdded:
155 result.Summary.AddedRelationships++
156 case ChangeRemoved:
157 result.Summary.RemovedRelationships++
158 }
159 }
160 }
161
github.com/docToolchain/Bausteinsicht/internal/diff/drawio.go 90.0%
1 package diff
2
3 // DrawIO color definitions for diff visualization
4 const (
5 ColorAdded = "#d5e8d4" // green
6 ColorRemoved = "#f8cecc" // red
7 ColorChanged = "#ffe6cc" // orange
8 ColorUnchanged = "#ffffff" // white (default)
9
10 StrokeAdded = "#82b366" // dark green
11 StrokeRemoved = "#b85450" // dark red
12 StrokeChanged = "#d6b656" // dark orange
13 )
14
15 // AppliedChangeStyle returns the fill and stroke colors for a changed element
16 func GetChangeColors(changeType ChangeType) (fillColor, strokeColor string) {
17 switch changeType {
18 case ChangeAdded:
19 return ColorAdded, StrokeAdded
20 case ChangeRemoved:
21 return ColorRemoved, StrokeRemoved
22 case ChangeChanged:
23 return ColorChanged, StrokeChanged
24 default:
25 return ColorUnchanged, "#999999"
26 }
27 }
28
29 // ElementStyle describes visual styling for a draw.io element
30 type ElementStyle struct {
31 FillColor string
32 StrokeColor string
33 StrokeWidth float64
34 Opacity float64
35 Label string // For removed elements, add strikethrough indicator
36 }
37
38 // GetElementStyle returns the draw.io styling for a given element change
39 func GetElementStyle(change ElementChange) ElementStyle {
40 fillColor, strokeColor := GetChangeColors(change.Type)
41
42 style := ElementStyle{
43 FillColor: fillColor,
44 StrokeColor: strokeColor,
45 StrokeWidth: 2,
46 Opacity: 1.0,
47 }
48
49 // For removed elements, add visual indication
50 if change.Type == ChangeRemoved && change.AsIs != nil {
51 style.Label = "~" + change.AsIs.Title // strikethrough indicator
52 }
53
54 return style
55 }
56
github.com/docToolchain/Bausteinsicht/internal/drawio/connector.go 89.2%
1 package drawio
2
3 import (
4 "fmt"
5
6 "github.com/beevik/etree"
7 )
8
9 // ConnectorData holds data for creating/updating connectors.
10 type ConnectorData struct {
11 From string // source bausteinsicht_id
12 To string // target bausteinsicht_id
13 Label string // display label on the connector
14 SourceRef string // source cell ID (defaults to From if empty)
15 TargetRef string // target cell ID (defaults to To if empty)
16 Index int // relationship index for disambiguation (0-based)
17 }
18
19 // connectorID returns the canonical ID for a connector between two elements.
20 // The index disambiguates multiple relationships between the same pair.
21 func connectorID(from, to string, index int) string {
22 return fmt.Sprintf("rel-%s-%s-%d", from, to, index)
23 }
24
25 // CreateConnector creates an edge mxCell connecting From to To.
26 // Connectors always use parent="1" regardless of container nesting.
27 func (p *Page) CreateConnector(data ConnectorData, style string) {
28 root := p.Root()
29 if root == nil {
30 return
31 }
32
33 srcRef := data.SourceRef
34 if srcRef == "" {
35 srcRef = data.From
36 }
37 tgtRef := data.TargetRef
38 if tgtRef == "" {
39 tgtRef = data.To
40 }
41
42 cell := root.CreateElement("mxCell")
43 cell.CreateAttr("id", connectorID(srcRef, tgtRef, data.Index))
44 cell.CreateAttr("value", data.Label)
45 cell.CreateAttr("style", style)
46 cell.CreateAttr("edge", "1")
47 cell.CreateAttr("source", srcRef)
48 cell.CreateAttr("target", tgtRef)
49 cell.CreateAttr("parent", "1")
50
51 geom := cell.CreateElement("mxGeometry")
52 geom.CreateAttr("relative", "1")
53 geom.CreateAttr("as", "geometry")
54 }
55
56 // FindConnector returns the mxCell edge with id="rel-<from>-<to>-<index>", or nil.
57 func (p *Page) FindConnector(from, to string, index int) *etree.Element {
58 root := p.Root()
59 if root == nil {
60 return nil
61 }
62 id := connectorID(from, to, index)
63 for _, cell := range root.SelectElements("mxCell") {
64 if cell.SelectAttrValue("id", "") == id {
65 return cell
66 }
67 }
68 return nil
69 }
70
71 // FindAllConnectors returns all mxCell elements with edge="1".
72 func (p *Page) FindAllConnectors() []*etree.Element {
73 root := p.Root()
74 if root == nil {
75 return nil
76 }
77 var result []*etree.Element
78 for _, cell := range root.SelectElements("mxCell") {
79 if cell.SelectAttrValue("edge", "") == "1" {
80 result = append(result, cell)
81 }
82 }
83 return result
84 }
85
86 // UpdateConnectorLabel sets the value attribute on the connector between from and to.
87 func (p *Page) UpdateConnectorLabel(from, to string, index int, label string) {
88 conn := p.FindConnector(from, to, index)
89 if conn == nil {
90 return
91 }
92 attr := conn.SelectAttr("value")
93 if attr != nil {
94 attr.Value = label
95 } else {
96 conn.CreateAttr("value", label)
97 }
98 }
99
100 // DeleteConnector removes the connector between from and to at the given index.
101 func (p *Page) DeleteConnector(from, to string, index int) {
102 root := p.Root()
103 if root == nil {
104 return
105 }
106 id := connectorID(from, to, index)
107 for _, cell := range root.SelectElements("mxCell") {
108 if cell.SelectAttrValue("id", "") == id {
109 root.RemoveChild(cell)
110 return
111 }
112 }
113 }
114
115 // DeleteConnectorsFor removes all connectors where source or target matches elementID.
116 func (p *Page) DeleteConnectorsFor(elementID string) {
117 root := p.Root()
118 if root == nil {
119 return
120 }
121 var toRemove []*etree.Element
122 for _, cell := range root.SelectElements("mxCell") {
123 if cell.SelectAttrValue("edge", "") != "1" {
124 continue
125 }
126 src := cell.SelectAttrValue("source", "")
127 tgt := cell.SelectAttrValue("target", "")
128 if src == elementID || tgt == elementID {
129 toRemove = append(toRemove, cell)
130 }
131 }
132 for _, cell := range toRemove {
133 root.RemoveChild(cell)
134 }
135 }
136
github.com/docToolchain/Bausteinsicht/internal/drawio/document.go 84.1%
1 // Package drawio handles reading and writing draw.io XML files.
2 package drawio
3
4 import (
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/beevik/etree"
10 )
11
12 // Document represents a draw.io file (mxfile).
13 type Document struct {
14 tree *etree.Document
15 }
16
17 // Page represents a single page (diagram element) within a Document.
18 type Page struct {
19 diagram *etree.Element
20 }
21
22 // LoadDocument parses a draw.io XML file from disk.
23 // It validates the document structure to prevent data loss from corrupt files.
24 func LoadDocument(path string) (*Document, error) {
25 tree := etree.NewDocument()
26 if err := tree.ReadFromFile(path); err != nil {
27 return nil, fmt.Errorf("LoadDocument %q: %w", path, err)
28 }
29
30 // Validate document structure to prevent data loss from corrupt files.
31 root := tree.Root()
32 if root == nil || root.Tag != "mxfile" {
33 return nil, fmt.Errorf("LoadDocument %q: not a valid draw.io file (missing <mxfile> root)", path)
34 }
35 diagrams := root.SelectElements("diagram")
36 if len(diagrams) == 0 {
37 return nil, fmt.Errorf("LoadDocument %q: not a valid draw.io file (no <diagram> elements)", path)
38 }
39 for _, d := range diagrams {
40 model := d.FindElement("mxGraphModel")
41 if model == nil {
42 return nil, fmt.Errorf("LoadDocument %q: diagram %q missing <mxGraphModel>",
43 path, d.SelectAttrValue("id", "?"))
44 }
45 if model.FindElement("root") == nil {
46 return nil, fmt.Errorf("LoadDocument %q: diagram %q missing <root> element",
47 path, d.SelectAttrValue("id", "?"))
48 }
49 }
50
51 return &Document{tree: tree}, nil
52 }
53
54 // SaveDocument writes a Document to disk using an atomic temp-file + rename.
55 func SaveDocument(path string, doc *Document) error {
56 doc.tree.Indent(2)
57
58 dir := filepath.Dir(path)
59 tmp, err := os.CreateTemp(dir, ".drawio-tmp-*")
60 if err != nil {
61 return fmt.Errorf("SaveDocument create temp: %w", err)
62 }
63 tmpName := tmp.Name()
64
65 if _, err := doc.tree.WriteTo(tmp); err != nil {
66 _ = tmp.Close()
67 _ = os.Remove(tmpName)
68 return fmt.Errorf("SaveDocument write: %w", err)
69 }
70 if err := tmp.Close(); err != nil {
71 _ = os.Remove(tmpName)
72 return fmt.Errorf("SaveDocument close: %w", err)
73 }
74 if err := os.Rename(tmpName, path); err != nil {
75 _ = os.Remove(tmpName)
76 return fmt.Errorf("SaveDocument rename: %w", err)
77 }
78 return nil
79 }
80
81 // NewDocument creates an empty mxfile document.
82 func NewDocument() *Document {
83 tree := etree.NewDocument()
84 tree.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
85 mxfile := tree.CreateElement("mxfile")
86 mxfile.CreateAttr("host", "bausteinsicht")
87 mxfile.CreateAttr("compressed", "false")
88 return &Document{tree: tree}
89 }
90
91 // Pages returns all pages in the document.
92 func (d *Document) Pages() []*Page {
93 root := d.tree.Root()
94 if root == nil {
95 return nil
96 }
97 diagrams := root.SelectElements("diagram")
98 pages := make([]*Page, len(diagrams))
99 for i, el := range diagrams {
100 pages[i] = &Page{diagram: el}
101 }
102 return pages
103 }
104
105 // GetPage returns the page with the given id, or nil if not found.
106 func (d *Document) GetPage(id string) *Page {
107 root := d.tree.Root()
108 if root == nil {
109 return nil
110 }
111 for _, el := range root.SelectElements("diagram") {
112 if el.SelectAttrValue("id", "") == id {
113 return &Page{diagram: el}
114 }
115 }
116 return nil
117 }
118
119 // AddPage adds a new page with the given id and name, initialised with base cells.
120 func (d *Document) AddPage(id, name string) *Page {
121 root := d.tree.Root()
122 if root == nil {
123 root = d.tree.CreateElement("mxfile")
124 }
125
126 diagram := root.CreateElement("diagram")
127 diagram.CreateAttr("id", id)
128 diagram.CreateAttr("name", name)
129
130 model := diagram.CreateElement("mxGraphModel")
131 model.CreateAttr("dx", "1422")
132 model.CreateAttr("dy", "794")
133 model.CreateAttr("grid", "1")
134 model.CreateAttr("gridSize", "10")
135 model.CreateAttr("page", "1")
136 model.CreateAttr("pageWidth", "1169")
137 model.CreateAttr("pageHeight", "827")
138 model.CreateAttr("background", "#ffffff")
139
140 rootEl := model.CreateElement("root")
141 cell0 := rootEl.CreateElement("mxCell")
142 cell0.CreateAttr("id", "0")
143 cell1 := rootEl.CreateElement("mxCell")
144 cell1.CreateAttr("id", "1")
145 cell1.CreateAttr("parent", "0")
146
147 return &Page{diagram: diagram}
148 }
149
150 // RemovePage removes the page (diagram element) with the given id from the document.
151 // If no page with the given id exists, RemovePage is a no-op.
152 func (d *Document) RemovePage(id string) {
153 root := d.tree.Root()
154 if root == nil {
155 return
156 }
157 for _, el := range root.SelectElements("diagram") {
158 if el.SelectAttrValue("id", "") == id {
159 root.RemoveChild(el)
160 return
161 }
162 }
163 }
164
165 // ID returns the id attribute of the page's diagram element.
166 func (p *Page) ID() string {
167 return p.diagram.SelectAttrValue("id", "")
168 }
169
170 // Root returns the <root> element of the page for direct manipulation.
171 func (p *Page) Root() *etree.Element {
172 model := p.diagram.FindElement("mxGraphModel")
173 if model == nil {
174 return nil
175 }
176 return model.FindElement("root")
177 }
178
github.com/docToolchain/Bausteinsicht/internal/drawio/element.go 88.8%
1 package drawio
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/beevik/etree"
8 )
9
10 // ElementData holds the data needed to create or update an element.
11 type ElementData struct {
12 ID string // bausteinsicht_id (e.g., "webshop.api")
13 CellID string // draw.io cell ID (file-wide unique); defaults to ID if empty
14 Kind string // bausteinsicht_kind (e.g., "container")
15 Title string // display title
16 Technology string // technology string
17 Description string // tooltip text
18 Link string // drill-down link (e.g., "data:page/id,view-containers")
19 ParentID string // parent cell ID ("1" for top-level, container ID for children)
20 X, Y float64 // position
21 Width float64 // element width
22 Height float64 // element height
23 SubCells *SubCellTemplates // sub-cell templates; nil for legacy HTML labels
24 }
25
26 // CreateElement creates an <object> wrapping an <mxCell vertex="1"> with <mxGeometry>.
27 // ParentID defaults to "1" if empty.
28 // When SubCells is non-nil, the element uses grouped sub-cells for title/tech/desc
29 // instead of an HTML label.
30 func (p *Page) CreateElement(data ElementData, style string) error {
31 root := p.Root()
32 if root == nil {
33 return fmt.Errorf("CreateElement: page has no root element")
34 }
35
36 parentID := data.ParentID
37 if parentID == "" {
38 parentID = "1"
39 }
40
41 cellID := data.CellID
42 if cellID == "" {
43 cellID = data.ID
44 }
45
46 obj := root.CreateElement("object")
47 if data.SubCells != nil {
48 obj.CreateAttr("label", "")
49 } else {
50 obj.CreateAttr("label", GenerateLabel(data.Title, data.Technology, data.Description))
51 }
52 obj.CreateAttr("id", cellID)
53 obj.CreateAttr("bausteinsicht_id", data.ID)
54 obj.CreateAttr("bausteinsicht_kind", data.Kind)
55 if data.Technology != "" {
56 obj.CreateAttr("technology", data.Technology)
57 }
58 if data.Description != "" {
59 obj.CreateAttr("tooltip", data.Description)
60 }
61 if data.Link != "" {
62 obj.CreateAttr("link", data.Link)
63 }
64
65 // Ensure container=1 is set when using sub-cells (required for child grouping).
66 if data.SubCells != nil {
67 // Replace container=0 with container=1, or append if missing.
68 if strings.Contains(style, "container=0") {
69 style = strings.Replace(style, "container=0", "container=1", 1)
70 } else if !strings.Contains(style, "container=1") {
71 style = strings.TrimRight(style, ";") + ";container=1;"
72 }
73 }
74
75 // HTML labels require html=1 in the cell style; without it draw.io renders
76 // the raw markup as plain text. This guard covers elements whose kind has no
77 // template entry and therefore receives an empty style fallback.
78 if data.SubCells == nil && !strings.Contains(style, "html=1") {
79 if style != "" && !strings.HasSuffix(style, ";") {
80 style += ";"
81 }
82 style += "html=1;"
83 }
84
85 cell := obj.CreateElement("mxCell")
86 cell.CreateAttr("style", style)
87 cell.CreateAttr("vertex", "1")
88 cell.CreateAttr("parent", parentID)
89
90 geom := cell.CreateElement("mxGeometry")
91 geom.CreateAttr("x", formatFloat(data.X))
92 geom.CreateAttr("y", formatFloat(data.Y))
93 geom.CreateAttr("width", formatFloat(data.Width))
94 geom.CreateAttr("height", formatFloat(data.Height))
95 geom.CreateAttr("as", "geometry")
96
97 // Create grouped sub-cells for title, technology, and description.
98 if data.SubCells != nil {
99 createSubCells(root, cellID, data, data.SubCells)
100 }
101
102 return nil
103 }
104
105 // SubCellTemplates holds the template styles for creating text sub-cells.
106 type SubCellTemplates struct {
107 Title *SubCellStyle
108 Tech *SubCellStyle
109 Desc *SubCellStyle
110 }
111
112 // createSubCells creates child mxCell text elements inside the parent element.
113 func createSubCells(root *etree.Element, parentCellID string, data ElementData, sc *SubCellTemplates) {
114 // Title sub-cell (always created).
115 if sc.Title != nil {
116 createTextSubCell(root, parentCellID+"-title", parentCellID, data.Title,
117 sc.Title, data.Width, data.Height)
118 }
119
120 // Technology sub-cell (only when technology is non-empty).
121 if sc.Tech != nil && data.Technology != "" {
122 createTextSubCell(root, parentCellID+"-tech", parentCellID, "["+data.Technology+"]",
123 sc.Tech, data.Width, data.Height)
124 }
125
126 // Description sub-cell (only when description is non-empty).
127 // The display value is truncated to avoid visual overflow; the full text
128 // is preserved in the element's tooltip attribute.
129 if sc.Desc != nil && data.Description != "" {
130 createTextSubCell(root, parentCellID+"-desc", parentCellID, truncateText(data.Description, 120),
131 sc.Desc, data.Width, data.Height)
132 }
133 }
134
135 // truncateText shortens s to maxLen runes, appending "…" if truncated.
136 func truncateText(s string, maxLen int) string {
137 runes := []rune(s)
138 if len(runes) <= maxLen {
139 return s
140 }
141 return string(runes[:maxLen-1]) + "…"
142 }
143
144 // createTextSubCell creates a single text mxCell child element.
145 // Sub-cells are locked (non-movable, non-resizable, non-deletable, non-connectable)
146 // so that clicking the shape always selects the parent element.
147 func createTextSubCell(root *etree.Element, id, parentID, value string, sub *SubCellStyle, parentW, parentH float64) {
148 cell := root.CreateElement("mxCell")
149 cell.CreateAttr("id", id)
150 cell.CreateAttr("value", value)
151 // Make sub-cells transparent to mouse events so clicks pass through
152 // to the parent element. This lets users grab the whole shape at once.
153 // overflow=hidden clips text at the fixed sub-cell boundary; the full
154 // content remains accessible via the element's tooltip attribute.
155 style := setStyleFlags(sub.Style, "pointerEvents=0", "overflow=hidden")
156 cell.CreateAttr("style", style)
157 cell.CreateAttr("vertex", "1")
158 cell.CreateAttr("connectable", "0")
159 cell.CreateAttr("parent", parentID)
160
161 geom := cell.CreateElement("mxGeometry")
162 // Scale sub-cell width to parent width, keep x/y/height from template.
163 w := parentW
164 if w == 0 {
165 w = sub.Width
166 }
167 geom.CreateAttr("x", formatFloat(sub.X))
168 geom.CreateAttr("y", formatFloat(sub.Y))
169 geom.CreateAttr("width", formatFloat(w))
170 geom.CreateAttr("height", formatFloat(sub.Height))
171 geom.CreateAttr("as", "geometry")
172 }
173
174 // FindElement returns the <object> element with the given bausteinsicht_id, or nil if not found.
175 func (p *Page) FindElement(bausteinsichtID string) *etree.Element {
176 root := p.Root()
177 if root == nil {
178 return nil
179 }
180 for _, obj := range root.SelectElements("object") {
181 if obj.SelectAttrValue("bausteinsicht_id", "") == bausteinsichtID {
182 return obj
183 }
184 }
185 return nil
186 }
187
188 // FindAllElements returns all <object> elements that have a bausteinsicht_id attribute.
189 func (p *Page) FindAllElements() []*etree.Element {
190 root := p.Root()
191 if root == nil {
192 return nil
193 }
194 var result []*etree.Element
195 for _, obj := range root.SelectElements("object") {
196 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
197 result = append(result, obj)
198 }
199 }
200 return result
201 }
202
203 // UpdateElement updates label, tooltip, technology, and link on an existing element.
204 // If the element uses sub-cells, child text cells are updated instead of the HTML label.
205 func (p *Page) UpdateElement(id string, data ElementData) {
206 obj := p.FindElement(id)
207 if obj == nil {
208 return
209 }
210
211 cellID := obj.SelectAttrValue("id", "")
212
213 // Check if element uses sub-cells (label is empty and has child text cells).
214 root := p.Root()
215 childCells := findChildTextCells(root, cellID)
216 if len(childCells) > 0 {
217 // Update existing sub-cells and manage tech/desc cells.
218 updateSubCells(root, cellID, childCells, data)
219 setAttr(obj, "label", "")
220 } else {
221 setAttr(obj, "label", GenerateLabel(data.Title, data.Technology, data.Description))
222 }
223
224 setAttr(obj, "tooltip", data.Description)
225 setAttr(obj, "link", data.Link)
226 if data.Technology != "" {
227 setAttr(obj, "technology", data.Technology)
228 } else {
229 setAttr(obj, "technology", "")
230 }
231 }
232
233 // findChildTextCells finds all text sub-cells that are children of the given parent cell ID.
234 // Returns a map of role ("title", "tech", "desc") to the mxCell element.
235 func findChildTextCells(root *etree.Element, parentCellID string) map[string]*etree.Element {
236 if root == nil || parentCellID == "" {
237 return nil
238 }
239 result := make(map[string]*etree.Element)
240 for _, cell := range root.SelectElements("mxCell") {
241 if cell.SelectAttrValue("parent", "") != parentCellID {
242 continue
243 }
244 cellID := cell.SelectAttrValue("id", "")
245 style := cell.SelectAttrValue("style", "")
246 if !isTextSubCell(style) {
247 continue
248 }
249 switch {
250 case hasSuffix(cellID, "-title"):
251 result["title"] = cell
252 case hasSuffix(cellID, "-tech"):
253 result["tech"] = cell
254 case hasSuffix(cellID, "-desc"):
255 result["desc"] = cell
256 }
257 }
258 return result
259 }
260
261 // isTextSubCell returns true if the style indicates a text sub-cell.
262 func isTextSubCell(style string) bool {
263 return containsStyleKey(style, "text")
264 }
265
266 // containsStyleKey checks if a draw.io style string starts with or contains the given key.
267 func containsStyleKey(style, key string) bool {
268 // Style format: "key1;key2=val;key3=val;..."
269 // "text" appears as a flag (no =) at the beginning.
270 if style == key || style == key+";" {
271 return true
272 }
273 if len(style) > len(key) && style[:len(key)+1] == key+";" {
274 return true
275 }
276 return false
277 }
278
279 // hasSuffix is a simple string suffix check.
280 func hasSuffix(s, suffix string) bool {
281 return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
282 }
283
284 // updateSubCells updates existing sub-cell values and adds/removes tech/desc cells.
285 func updateSubCells(root *etree.Element, parentCellID string, cells map[string]*etree.Element, data ElementData) {
286 // Update title.
287 if tc, ok := cells["title"]; ok {
288 setAttr(tc, "value", data.Title)
289 ensureSubCellStyle(tc)
290 }
291
292 // Update or add/remove technology cell.
293 if tc, ok := cells["tech"]; ok {
294 if data.Technology != "" {
295 setAttr(tc, "value", "["+data.Technology+"]")
296 ensureSubCellStyle(tc)
297 } else {
298 root.RemoveChild(tc)
299 }
300 }
301
302 // Update or add/remove description cell.
303 if dc, ok := cells["desc"]; ok {
304 if data.Description != "" {
305 setAttr(dc, "value", truncateText(data.Description, 120))
306 ensureSubCellStyle(dc)
307 } else {
308 root.RemoveChild(dc)
309 }
310 }
311 }
312
313 // ensureSubCellStyle applies required style flags to an existing sub-cell.
314 // This migrates older sub-cells that were created before these flags existed.
315 func ensureSubCellStyle(cell *etree.Element) {
316 style := cell.SelectAttrValue("style", "")
317 updated := setStyleFlags(style, "pointerEvents=0", "overflow=hidden")
318 if updated != style {
319 setAttr(cell, "style", updated)
320 }
321 }
322
323 // UpdateElementKind updates the bausteinsicht_kind attribute and the mxCell style
324 // of an existing element. If style is empty, the mxCell style is not changed.
325 func (p *Page) UpdateElementKind(id, kind, style string) {
326 obj := p.FindElement(id)
327 if obj == nil {
328 return
329 }
330 setAttr(obj, "bausteinsicht_kind", kind)
331 cell := obj.FindElement("mxCell")
332 if cell != nil && style != "" {
333 setAttr(cell, "style", style)
334 }
335 }
336
337 // DeleteElement removes the <object> element with the given bausteinsicht_id.
338 // It also removes any child text sub-cells and mxCell connectors that reference
339 // this element as source or target.
340 func (p *Page) DeleteElement(id string) {
341 root := p.Root()
342 if root == nil {
343 return
344 }
345
346 obj := p.FindElement(id)
347 if obj != nil {
348 cellID := obj.SelectAttrValue("id", "")
349 root.RemoveChild(obj)
350
351 // Remove child text sub-cells (their parent references the cell ID).
352 if cellID != "" {
353 removeChildCells(root, cellID)
354 }
355 }
356
357 for _, cell := range root.SelectElements("mxCell") {
358 src := cell.SelectAttrValue("source", "")
359 dst := cell.SelectAttrValue("target", "")
360 if src == id || dst == id {
361 root.RemoveChild(cell)
362 }
363 }
364 }
365
366 // removeChildCells removes all mxCell elements whose parent attribute matches parentID.
367 func removeChildCells(root *etree.Element, parentID string) {
368 var toRemove []*etree.Element
369 for _, cell := range root.SelectElements("mxCell") {
370 if cell.SelectAttrValue("parent", "") == parentID {
371 toRemove = append(toRemove, cell)
372 }
373 }
374 for _, cell := range toRemove {
375 root.RemoveChild(cell)
376 }
377 }
378
379 // ReadElementFields extracts title, technology, and description from an element.
380 // It first looks for child text sub-cells; if none are found, falls back to
381 // parsing the HTML label (backward compatibility).
382 func (p *Page) ReadElementFields(obj *etree.Element) (title, technology, description string) {
383 cellID := obj.SelectAttrValue("id", "")
384 root := p.Root()
385 childCells := findChildTextCells(root, cellID)
386
387 if len(childCells) > 0 {
388 if tc, ok := childCells["title"]; ok {
389 title = tc.SelectAttrValue("value", "")
390 }
391 if tc, ok := childCells["tech"]; ok {
392 technology = trimBrackets(tc.SelectAttrValue("value", ""))
393 }
394 if dc, ok := childCells["desc"]; ok {
395 description = dc.SelectAttrValue("value", "")
396 }
397 return title, technology, description
398 }
399
400 // Fallback: parse HTML label (backward compat).
401 label := obj.SelectAttrValue("label", "")
402 return ParseLabel(label)
403 }
404
405 // setAttr sets an attribute on an element, creating it if it doesn't exist.
406 // If value is empty, any existing attribute is removed.
407 func setAttr(el *etree.Element, key, value string) {
408 if value == "" {
409 el.RemoveAttr(key)
410 return
411 }
412 attr := el.SelectAttr(key)
413 if attr != nil {
414 attr.Value = value
415 } else {
416 el.CreateAttr(key, value)
417 }
418 }
419
420 // formatFloat formats a float64 as a string without trailing zeros where possible.
421 func formatFloat(f float64) string {
422 if f == float64(int(f)) {
423 return fmt.Sprintf("%d", int(f))
424 }
425 return fmt.Sprintf("%g", f)
426 }
427
428 // setStyleFlags sets key=value flags in a draw.io style string,
429 // replacing any existing value for each key.
430 func setStyleFlags(style string, flags ...string) string {
431 for _, flag := range flags {
432 parts := strings.SplitN(flag, "=", 2)
433 if len(parts) != 2 {
434 continue
435 }
436 key := parts[0]
437 // Remove existing key=value pair.
438 segments := strings.Split(style, ";")
439 var filtered []string
440 for _, seg := range segments {
441 if seg == "" {
442 continue
443 }
444 if strings.HasPrefix(seg, key+"=") {
445 continue
446 }
447 filtered = append(filtered, seg)
448 }
449 filtered = append(filtered, flag)
450 style = strings.Join(filtered, ";") + ";"
451 }
452 return style
453 }
454
github.com/docToolchain/Bausteinsicht/internal/drawio/label.go 91.0%
1 package drawio
2
3 import (
4 "strings"
5 )
6
7 // Label color constants for technology and description lines.
8 // These are light enough to be readable on dark C4 backgrounds (#08427B,
9 // #1168BD, #438DD5) while still providing contrast on the lighter
10 // component background (#85BBF0).
11 const (
12 techColor = "#CCCCCC"
13 descColor = "#BBBBBB"
14 )
15
16 // maxLabelDescLen is the maximum rune length of the description portion of an
17 // HTML label. HTML labels are rendered inside a fixed-size element box
18 // (typically 120×60 px) that has no sub-cell clipping, so descriptions must
19 // be kept short. The full text is always preserved in the tooltip attribute.
20 const maxLabelDescLen = 60
21
22 // GenerateLabel creates an HTML label for draw.io elements.
23 // Format: <b>Title</b><br><font color="..."><i>[Technology]</i></font><br><font color="..." style="font-size:11px">Description</font>
24 // Technology is wrapped in square brackets per C4 convention and rendered in italic.
25 // Empty technology or description lines are omitted.
26 // The returned string is unescaped HTML; etree handles XML attribute escaping.
27 func GenerateLabel(title, technology, description string) string {
28 var b strings.Builder
29 b.WriteString("<b>" + escapeHTML(title) + "</b>")
30 if technology != "" {
31 b.WriteString("<br><font color=\"" + techColor + "\"><i>[" + escapeHTML(technology) + "]</i></font>")
32 }
33 if description != "" {
34 b.WriteString("<br><font color=\"" + descColor + "\" style=\"font-size:11px\">" + escapeHTML(truncateText(description, maxLabelDescLen)) + "</font>")
35 }
36 return b.String()
37 }
38
39 // GenerateActorLabel creates a label for actor elements (just the title, no technology line).
40 func GenerateActorLabel(title string) string {
41 return "<b>" + escapeHTML(title) + "</b>"
42 }
43
44 // ParseLabel extracts title, technology and description from an HTML label.
45 // Expected format: <b>Title</b><br><font color="#666666">[Technology]</font><br><font color="#999999">Description</font>
46 // Also handles legacy format without brackets around technology.
47 // If the label doesn't match, return the full text as title.
48 func ParseLabel(html string) (title, technology, description string) {
49 if !strings.HasPrefix(html, "<b>") {
50 return stripTags(html), "", ""
51 }
52
53 rest := html[len("<b>"):]
54 closeB := strings.Index(rest, "</b>")
55 if closeB < 0 {
56 return stripTags(html), "", ""
57 }
58
59 titlePart := rest[:closeB]
60 after := rest[closeB+len("</b>"):]
61
62 cleanTitle := stripTags(titlePart)
63
64 if after == "" {
65 return cleanTitle, "", ""
66 }
67
68 // Parse remaining <br><font ...>...</font> segments
69 segments := parseFontSegments(after)
70
71 switch len(segments) {
72 case 1:
73 seg := segments[0]
74 if seg.color == descColor || seg.color == "#999999" {
75 // Description only (no technology)
76 return cleanTitle, "", unescapeHTML(stripTags(seg.text))
77 }
78 // Technology (with or without brackets)
79 return cleanTitle, unescapeHTML(trimBrackets(stripTags(seg.text))), ""
80 case 2:
81 tech := unescapeHTML(trimBrackets(stripTags(segments[0].text)))
82 desc := unescapeHTML(stripTags(segments[1].text))
83 return cleanTitle, tech, desc
84 default:
85 return cleanTitle, "", ""
86 }
87 }
88
89 type fontSegment struct {
90 color string
91 text string
92 }
93
94 // parseFontSegments extracts consecutive <br><font color="...">...</font> segments.
95 func parseFontSegments(s string) []fontSegment {
96 var segments []fontSegment
97 for strings.HasPrefix(s, "<br>") {
98 s = s[len("<br>"):]
99 if !strings.HasPrefix(s, "<font") {
100 break
101 }
102 // Extract color attribute
103 colorStart := strings.Index(s, `color="`)
104 if colorStart < 0 {
105 break
106 }
107 colorStart += len(`color="`)
108 colorEnd := strings.Index(s[colorStart:], `"`)
109 if colorEnd < 0 {
110 break
111 }
112 color := s[colorStart : colorStart+colorEnd]
113
114 // Extract text content
115 tagClose := strings.Index(s, ">")
116 if tagClose < 0 {
117 break
118 }
119 textStart := tagClose + 1
120 endFont := strings.Index(s[textStart:], "</font>")
121 if endFont < 0 {
122 break
123 }
124 text := s[textStart : textStart+endFont]
125 segments = append(segments, fontSegment{color: color, text: text})
126 s = s[textStart+endFont+len("</font>"):]
127 }
128 return segments
129 }
130
131 // trimBrackets removes surrounding square brackets if present.
132 func trimBrackets(s string) string {
133 if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") {
134 return s[1 : len(s)-1]
135 }
136 return s
137 }
138
139 // escapeHTML escapes special HTML characters in text content.
140 func escapeHTML(s string) string {
141 s = strings.ReplaceAll(s, "&", "&amp;")
142 s = strings.ReplaceAll(s, "<", "&lt;")
143 s = strings.ReplaceAll(s, ">", "&gt;")
144 s = strings.ReplaceAll(s, "\"", "&quot;")
145 return s
146 }
147
148 // unescapeHTML reverses HTML entity escaping.
149 func unescapeHTML(s string) string {
150 s = strings.ReplaceAll(s, "&quot;", "\"")
151 s = strings.ReplaceAll(s, "&gt;", ">")
152 s = strings.ReplaceAll(s, "&lt;", "<")
153 s = strings.ReplaceAll(s, "&amp;", "&")
154 return s
155 }
156
157 // stripTags removes all HTML tags from a string.
158 // Handles '>' inside quoted attribute values correctly (SEC-008).
159 func stripTags(s string) string {
160 var b strings.Builder
161 inTag := false
162 var quote rune
163 for _, r := range s {
164 switch {
165 case inTag && quote != 0:
166 if r == quote {
167 quote = 0
168 }
169 case inTag && (r == '"' || r == '\''):
170 quote = r
171 case r == '<':
172 inTag = true
173 case r == '>' && inTag:
174 inTag = false
175 case !inTag:
176 b.WriteRune(r)
177 }
178 }
179 return unescapeHTML(b.String())
180 }
181
github.com/docToolchain/Bausteinsicht/internal/drawio/template.go 83.7%
1 package drawio
2
3 import (
4 "fmt"
5 "os"
6 "strconv"
7 "strings"
8
9 "github.com/beevik/etree"
10 )
11
12 // CurrentTemplateVersion is the latest template format version supported.
13 const CurrentTemplateVersion = 1
14
15 // SubCellStyle holds style and geometry for a text sub-cell within an element.
16 type SubCellStyle struct {
17 Style string // mxCell style string
18 X, Y float64 // position relative to parent
19 Width, Height float64 // dimensions
20 }
21
22 // TemplateStyle holds the visual style and default dimensions for a draw.io element.
23 type TemplateStyle struct {
24 Style string // mxCell style string
25 Width float64 // default width from mxGeometry
26 Height float64 // default height from mxGeometry
27
28 // Sub-cell styles for grouped text labels (title, technology, description).
29 // Nil means no sub-cells defined (legacy template).
30 TitleStyle *SubCellStyle
31 TechStyle *SubCellStyle
32 DescStyle *SubCellStyle
33 }
34
35 // TemplateSet holds all styles parsed from a draw.io template file.
36 type TemplateSet struct {
37 Version int // template format version (0 means unset/v1)
38 elements map[string]TemplateStyle // keyed by kind (actor, system, container, component)
39 boundaries map[string]TemplateStyle // keyed by kind (system_boundary, container_boundary)
40 connector string // default connector style
41 }
42
43 // LoadTemplate parses a draw.io template file from disk.
44 func LoadTemplate(path string) (*TemplateSet, error) {
45 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
46 if err != nil {
47 return nil, fmt.Errorf("LoadTemplate %q: %w", path, err)
48 }
49 return LoadTemplateFromBytes(data)
50 }
51
52 // LoadTemplateFromBytes parses a draw.io template from raw XML bytes.
53 func LoadTemplateFromBytes(data []byte) (*TemplateSet, error) {
54 tree := etree.NewDocument()
55 if err := tree.ReadFromBytes(data); err != nil {
56 return nil, fmt.Errorf("LoadTemplateFromBytes: %w", err)
57 }
58
59 // Validate that the document is a valid draw.io file with an <mxfile> root.
60 root := tree.Root()
61 if root == nil || root.Tag != "mxfile" {
62 return nil, fmt.Errorf("LoadTemplateFromBytes: not a valid draw.io template (missing <mxfile> root element)")
63 }
64
65 // Read template version from <mxfile> root. Missing → version 1 (backward compat).
66 version := 1
67 if vStr := root.SelectAttrValue("bausteinsicht_template_version", ""); vStr != "" {
68 v, err := strconv.Atoi(vStr)
69 if err != nil {
70 return nil, fmt.Errorf("LoadTemplateFromBytes: invalid template version %q: %w", vStr, err)
71 }
72 version = v
73 }
74 if version > CurrentTemplateVersion {
75 return nil, fmt.Errorf("LoadTemplateFromBytes: template version %d not supported (max: %d)", version, CurrentTemplateVersion)
76 }
77
78 ts := &TemplateSet{
79 Version: version,
80 elements: make(map[string]TemplateStyle),
81 boundaries: make(map[string]TemplateStyle),
82 }
83
84 // Build maps for looking up child cells.
85 templateIDs := make(map[string]string) // template object id → kind
86 groupToKind := make(map[string]string) // group cell id → kind (when template is inside a group)
87
88 // Find all <object> elements with bausteinsicht_template attribute.
89 for _, obj := range tree.FindElements("//object[@bausteinsicht_template]") {
90 kind := obj.SelectAttrValue("bausteinsicht_template", "")
91 if kind == "" {
92 continue
93 }
94
95 cell := obj.FindElement("mxCell")
96 if cell == nil {
97 continue
98 }
99
100 style := cell.SelectAttrValue("style", "")
101 width, height := parseGeometry(cell)
102
103 objID := obj.SelectAttrValue("id", "")
104 if objID != "" {
105 templateIDs[objID] = kind
106 }
107
108 // If the template mxCell's parent is not "1", it's inside a group.
109 // Map the group ID so sub-cells with that group parent are found too.
110 cellParent := cell.SelectAttrValue("parent", "1")
111 if cellParent != "1" && cellParent != "0" && cellParent != "" {
112 groupToKind[cellParent] = kind
113 }
114
115 ts.categorize(kind, TemplateStyle{Style: style, Width: width, Height: height})
116 }
117
118 // Parse child mxCells that are sub-cells of template elements.
119 // Sub-cells may be children of the template object ID directly,
120 // or children of a group cell that contains the template object.
121 for _, cell := range tree.FindElements("//mxCell[@parent]") {
122 parentID := cell.SelectAttrValue("parent", "")
123 kind, ok := templateIDs[parentID]
124 if !ok {
125 kind, ok = groupToKind[parentID]
126 }
127 if !ok {
128 continue
129 }
130 cellID := cell.SelectAttrValue("id", "")
131 if cellID == "" {
132 continue
133 }
134 sub := parseSubCellStyle(cell)
135 if sub == nil {
136 continue
137 }
138
139 // Determine role from cell ID suffix or value heuristic.
140 role := ""
141 switch {
142 case strings.HasSuffix(cellID, "-title"):
143 role = "title"
144 case strings.HasSuffix(cellID, "-tech"):
145 role = "tech"
146 case strings.HasSuffix(cellID, "-desc"):
147 role = "desc"
148 default:
149 // Fallback: detect role by value attribute when ID doesn't follow convention
150 // (e.g., draw.io-generated IDs from manual template editing).
151 val := strings.TrimSpace(cell.SelectAttrValue("value", ""))
152 switch {
153 case strings.EqualFold(val, "title") || strings.HasSuffix(val, " Name"):
154 role = "title"
155 case strings.EqualFold(val, "[technology]"):
156 role = "tech"
157 case strings.EqualFold(val, "description"):
158 role = "desc"
159 }
160 }
161 if role != "" {
162 ts.setSubCell(kind, role, sub)
163 }
164 }
165
166 // Find relationship connector: bare <mxCell bausteinsicht_template="relationship">.
167 for _, cell := range tree.FindElements("//mxCell[@bausteinsicht_template='relationship']") {
168 ts.connector = cell.SelectAttrValue("style", "")
169 }
170
171 return ts, nil
172 }
173
174 // GetStyle returns the TemplateStyle for a given element kind.
175 func (t *TemplateSet) GetStyle(kind string) (TemplateStyle, bool) {
176 s, ok := t.elements[kind]
177 return s, ok
178 }
179
180 // GetBoundaryStyle returns the TemplateStyle for a given boundary kind.
181 func (t *TemplateSet) GetBoundaryStyle(kind string) (TemplateStyle, bool) {
182 s, ok := t.boundaries[kind]
183 return s, ok
184 }
185
186 // GetConnectorStyle returns the default connector style string.
187 func (t *TemplateSet) GetConnectorStyle() string {
188 return t.connector
189 }
190
191 // GetAllStyles returns a copy of all element styles keyed by kind.
192 func (t *TemplateSet) GetAllStyles() map[string]TemplateStyle {
193 out := make(map[string]TemplateStyle, len(t.elements))
194 for k, v := range t.elements {
195 out[k] = v
196 }
197 return out
198 }
199
200 // categorize places the style into the appropriate map based on its kind.
201 func (t *TemplateSet) categorize(kind string, style TemplateStyle) {
202 if strings.HasSuffix(kind, "_boundary") {
203 t.boundaries[kind] = style
204 } else {
205 t.elements[kind] = style
206 }
207 }
208
209 // setSubCell assigns a parsed sub-cell style to the appropriate field on a TemplateStyle.
210 func (t *TemplateSet) setSubCell(kind, role string, sub *SubCellStyle) {
211 // Look up in both maps.
212 if ts, ok := t.elements[kind]; ok {
213 setSubCellOnStyle(&ts, role, sub)
214 t.elements[kind] = ts
215 }
216 if ts, ok := t.boundaries[kind]; ok {
217 setSubCellOnStyle(&ts, role, sub)
218 t.boundaries[kind] = ts
219 }
220 }
221
222 func setSubCellOnStyle(ts *TemplateStyle, role string, sub *SubCellStyle) {
223 switch role {
224 case "title":
225 ts.TitleStyle = sub
226 case "tech":
227 ts.TechStyle = sub
228 case "desc":
229 ts.DescStyle = sub
230 }
231 }
232
233 // parseSubCellStyle parses style and geometry from a child mxCell element.
234 func parseSubCellStyle(cell *etree.Element) *SubCellStyle {
235 style := cell.SelectAttrValue("style", "")
236 if style == "" {
237 return nil
238 }
239 geo := cell.FindElement("mxGeometry")
240 if geo == nil {
241 return nil
242 }
243 x, _ := strconv.ParseFloat(geo.SelectAttrValue("x", "0"), 64)
244 y, _ := strconv.ParseFloat(geo.SelectAttrValue("y", "0"), 64)
245 w, _ := strconv.ParseFloat(geo.SelectAttrValue("width", "0"), 64)
246 h, _ := strconv.ParseFloat(geo.SelectAttrValue("height", "0"), 64)
247 return &SubCellStyle{Style: style, X: x, Y: y, Width: w, Height: h}
248 }
249
250 // parseGeometry extracts width and height from an mxCell's nested mxGeometry element.
251 func parseGeometry(cell *etree.Element) (float64, float64) {
252 geo := cell.FindElement("mxGeometry")
253 if geo == nil {
254 return 0, 0
255 }
256 w, _ := strconv.ParseFloat(geo.SelectAttrValue("width", "0"), 64)
257 h, _ := strconv.ParseFloat(geo.SelectAttrValue("height", "0"), 64)
258 return w, h
259 }
260
github.com/docToolchain/Bausteinsicht/internal/export/export.go 91.1%
1 // Package export handles exporting draw.io diagrams to PNG/SVG using the
2 // draw.io CLI.
3 package export
4
5 import (
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strconv"
12 "strings"
13 )
14
15 // ExportOptions configures a single page export operation.
16 type ExportOptions struct {
17 Format string // "png" or "svg"
18 PageIndex int // 1-based page index
19 OutputPath string // full path to output file
20 EmbedDiagram bool // embed draw.io XML source in output
21 InputFile string // path to the .drawio file
22 Scale float64 // export scale factor (0 = default, e.g. 2.0 for retina)
23 }
24
25 // platformPaths is a function variable so tests can override it.
26 var platformPaths = platformDrawioPaths
27
28 // DetectDrawioBinary finds the draw.io CLI binary.
29 // Search order:
30 // 1. "drawio-export" — devcontainer wrapper (Linux, adds xvfb + --no-sandbox)
31 // 2. "drawio" — on PATH (Linux package install)
32 // 3. Platform-native install paths (Windows, macOS) via platformPaths()
33 func DetectDrawioBinary() (string, error) {
34 var searched strings.Builder
35
36 // Try PATH first
37 for _, name := range []string{"drawio-export", "drawio"} {
38 path, err := exec.LookPath(name)
39 if err == nil {
40 return path, nil
41 }
42 fmt.Fprintf(&searched, " PATH: %s not found\n", name)
43 }
44
45 // Try platform-specific paths
46 for _, candidate := range platformPaths() {
47 if _, err := os.Stat(candidate); err == nil {
48 return candidate, nil
49 }
50 fmt.Fprintf(&searched, " %s not found\n", candidate)
51 }
52 return "", buildDrawioNotFoundError(searched.String())
53 }
54
55 // buildDrawioNotFoundError returns a detailed error message with troubleshooting steps and searched paths.
56 func buildDrawioNotFoundError(searchedPaths string) error {
57 msg := strings.Builder{}
58 msg.WriteString("draw.io CLI not found\n")
59 if searchedPaths != "" {
60 msg.WriteString("\nSearched locations:\n")
61 msg.WriteString(searchedPaths)
62 }
63 msg.WriteString("\nInstallation options:\n")
64 msg.WriteString(" Windows (Scoop): scoop install drawio\n")
65 msg.WriteString(" Windows (Choco): choco install drawio\n")
66 msg.WriteString(" macOS (Homebrew): brew install draw.io\n")
67 msg.WriteString(" Linux: See https://www.drawio.com\n\n")
68 msg.WriteString("If already installed, try these troubleshooting steps:\n")
69 msg.WriteString(" 1. Add draw.io to PATH (Scoop): scoop reset drawio\n")
70 msg.WriteString(" 2. Set env var: export BAUSTEINSICHT_DRAWIO_PATH=/path/to/draw.io\n")
71 msg.WriteString(" 3. Use CLI flag: bausteinsicht export --drawio-path /path/to/draw.io\n\n")
72 msg.WriteString("More info: https://github.com/docToolchain/Bausteinsicht/issues/385\n")
73 return errors.New(msg.String())
74 }
75
76 // BuildExportArgs constructs the command-line arguments for a draw.io export.
77 func BuildExportArgs(opts ExportOptions) []string {
78 args := []string{
79 "--export",
80 "--format", opts.Format,
81 "--page-index", strconv.Itoa(opts.PageIndex),
82 "--output", opts.OutputPath,
83 }
84 if opts.EmbedDiagram {
85 args = append(args, "--embed-diagram")
86 }
87 // Only pass --scale for values > 1. Scale=1 is draw.io's native resolution
88 // and does not need an explicit flag. Scale > 1 (e.g. 2.0 for retina) uses
89 // the GPU rendering pipeline and requires hardware GPU acceleration.
90 // Passing --scale 2 in headless containers (where the GPU process is
91 // disabled via ELECTRON_DISABLE_GPU) causes the GPU process to crash with
92 // exit code 9, resulting in a silent export failure (exit 0, no output file).
93 if opts.Scale > 1 {
94 args = append(args, "--scale", fmt.Sprintf("%g", opts.Scale))
95 }
96 args = append(args, "--", opts.InputFile)
97 return args
98 }
99
100 // SafeViewKey strips directory components from a view key to prevent
101 // path traversal when used in filenames (SEC-015).
102 func SafeViewKey(key string) string {
103 key = filepath.Base(strings.ReplaceAll(key, "\\", "/"))
104 return key
105 }
106
107 // OutputFileName returns the canonical output file name for a view export.
108 func OutputFileName(viewKey, format string) string {
109 return fmt.Sprintf("architecture-%s.%s", SafeViewKey(viewKey), format)
110 }
111
112 // ExportPage runs the draw.io CLI to export a single page.
113 func ExportPage(binary string, opts ExportOptions) error {
114 args := BuildExportArgs(opts)
115 cmd := exec.Command(binary, args...) // #nosec G204 -- binary is auto-detected draw.io CLI path
116 output, err := cmd.CombinedOutput()
117 if err != nil {
118 return fmt.Errorf("draw.io export failed: %w\nOutput: %s", err, string(output))
119 }
120 // Verify the output file was actually created (#195).
121 if _, err := os.Stat(opts.OutputPath); err != nil {
122 return fmt.Errorf("draw.io CLI exited successfully but output file not created: %s", opts.OutputPath)
123 }
124 return nil
125 }
126
github.com/docToolchain/Bausteinsicht/internal/export/platform_windows.go 0.0%
1 //go:build windows
2
3 package export
4
5 import (
6 "os"
7 "os/user"
8 "path/filepath"
9 )
10
11 // platformDrawioPaths returns platform-native draw.io install locations for Windows.
12 // Search order: Scoop → Chocolatey → Official Installer → Program Files
13 func platformDrawioPaths() []string {
14 var paths []string
15
16 // Scoop package manager (most common on Windows dev machines).
17 // First, try SCOOP env var if set (allows override).
18 // If not set, try default: C:\Users\<username>\scoop\
19 if scoop := os.Getenv("SCOOP"); scoop != "" {
20 paths = append(paths, filepath.Join(scoop, "apps", "drawio", "current", "draw.io.exe"))
21 paths = append(paths, filepath.Join(scoop, "shims", "draw.io.exe"))
22 } else {
23 // Fallback: try default Scoop location if user home dir is available
24 if currentUser, err := user.Current(); err == nil {
25 scoopHome := filepath.Join(currentUser.HomeDir, "scoop")
26 paths = append(paths, filepath.Join(scoopHome, "apps", "drawio", "current", "draw.io.exe"))
27 paths = append(paths, filepath.Join(scoopHome, "shims", "draw.io.exe"))
28 }
29 }
30
31 // Chocolatey package manager (uses %PROGRAMDATA%, defaults to C:\ProgramData).
32 progData := os.Getenv("PROGRAMDATA")
33 if progData == "" {
34 progData = `C:\ProgramData`
35 }
36 paths = append(paths, filepath.Join(progData, "chocolatey", "bin", "draw.io.exe"))
37
38 // Official installer (per-user install - LOCALAPPDATA).
39 if localApp := os.Getenv("LOCALAPPDATA"); localApp != "" {
40 paths = append(paths, filepath.Join(localApp, "Programs", "draw.io", "draw.io.exe"))
41 }
42
43 // System-wide install (Program Files).
44 for _, prog := range []string{os.Getenv("PROGRAMFILES"), os.Getenv("PROGRAMFILES(X86)")} {
45 if prog != "" {
46 paths = append(paths, filepath.Join(prog, "draw.io", "draw.io.exe"))
47 }
48 }
49
50 return paths
51 }
52
github.com/docToolchain/Bausteinsicht/internal/exporter/structurizr/structurizr.go 93.9%
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 }
248
github.com/docToolchain/Bausteinsicht/internal/graph/analyzer.go 97.2%
1 package graph
2
3 import (
4 "sort"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // Analyzer performs graph analysis on model relationships.
10 type Analyzer struct {
11 model *model.BausteinsichtModel
12 graph map[string][]string // element ID → list of outgoing relationship targets
13 reverse map[string][]string // reverse graph: target → list of incoming sources
14 }
15
16 // NewAnalyzer creates a new graph analyzer.
17 func NewAnalyzer(m *model.BausteinsichtModel) *Analyzer {
18 a := &Analyzer{
19 model: m,
20 graph: make(map[string][]string),
21 reverse: make(map[string][]string),
22 }
23
24 // Build adjacency lists from relationships
25 flatElems, _ := model.FlattenElements(m)
26 for id := range flatElems {
27 a.graph[id] = []string{}
28 a.reverse[id] = []string{}
29 }
30
31 for _, rel := range m.Relationships {
32 if _, ok := flatElems[rel.From]; ok {
33 if _, ok := flatElems[rel.To]; ok {
34 a.graph[rel.From] = append(a.graph[rel.From], rel.To)
35 a.reverse[rel.To] = append(a.reverse[rel.To], rel.From)
36 }
37 }
38 }
39
40 return a
41 }
42
43 // Analyze performs comprehensive graph analysis.
44 func (a *Analyzer) Analyze() *GraphAnalysis {
45 flatElems, _ := model.FlattenElements(a.model)
46
47 result := &GraphAnalysis{
48 ElementCount: len(flatElems),
49 RelationshipCount: len(a.model.Relationships),
50 }
51
52 // Find all cycles
53 result.Cycles = a.findCycles()
54 result.IDAGValid = len(result.Cycles) == 0
55
56 // Calculate centrality metrics
57 result.Centrality = a.calculateCentrality()
58
59 // Find strongly connected components
60 result.Components = a.findStronglyConnectedComponents()
61
62 // Calculate maximum depth (longest path)
63 result.MaxDepth = a.calculateMaxDepth()
64
65 return result
66 }
67
68 // findCycles detects all cycles using Tarjan's algorithm.
69 func (a *Analyzer) findCycles() []Cycle {
70 var cycles []Cycle
71 index := 0
72 stack := []string{}
73 nodeInfo := make(map[string]*NodeInfo)
74
75 var strongconnect func(string)
76 strongconnect = func(v string) {
77 nodeInfo[v] = &NodeInfo{
78 index: index,
79 lowlink: index,
80 onStack: true,
81 }
82 index++
83 stack = append(stack, v)
84
85 for _, w := range a.graph[v] {
86 if _, ok := nodeInfo[w]; !ok {
87 strongconnect(w)
88 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].lowlink)
89 } else if nodeInfo[w].onStack {
90 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].index)
91 }
92 }
93
94 if nodeInfo[v].lowlink == nodeInfo[v].index {
95 var component []string
96 for {
97 w := stack[len(stack)-1]
98 stack = stack[:len(stack)-1]
99 nodeInfo[w].onStack = false
100 component = append(component, w)
101 if w == v {
102 break
103 }
104 }
105
106 // A cycle is an SCC with more than one element
107 if len(component) > 1 {
108 cycles = append(cycles, Cycle{Elements: component, Length: len(component)})
109 }
110 }
111 }
112
113 for v := range a.graph {
114 if _, ok := nodeInfo[v]; !ok {
115 strongconnect(v)
116 }
117 }
118
119 return cycles
120 }
121
122 // calculateCentrality computes centrality metrics for all elements.
123 func (a *Analyzer) calculateCentrality() []Centrality {
124 flatElems, _ := model.FlattenElements(a.model)
125 var results []Centrality
126
127 for id := range flatElems {
128 c := Centrality{
129 ID: id,
130 InDegree: len(a.reverse[id]),
131 OutDegree: len(a.graph[id]),
132 }
133
134 // Betweenness (simplified): count elements that depend on this element
135 betweenness := 0
136 for target := range flatElems {
137 if target != id && a.hasPath(id, target) {
138 betweenness++
139 }
140 }
141 c.Betweenness = float64(betweenness) / float64(len(flatElems)-1)
142
143 // Closeness (simplified): inverse of average distance
144 totalDist := 0
145 reachable := 0
146 for target := range flatElems {
147 if target != id {
148 if dist := a.shortestPath(id, target); dist > 0 {
149 totalDist += dist
150 reachable++
151 }
152 }
153 }
154 if reachable > 0 {
155 c.Closeness = 1.0 / (1.0 + float64(totalDist)/float64(reachable))
156 }
157
158 results = append(results, c)
159 }
160
161 // Sort by ID for consistent output
162 sort.Slice(results, func(i, j int) bool {
163 return results[i].ID < results[j].ID
164 })
165
166 return results
167 }
168
169 // findStronglyConnectedComponents finds all SCCs in the graph.
170 func (a *Analyzer) findStronglyConnectedComponents() []Component {
171 var components []Component
172 index := 0
173 stack := []string{}
174 nodeInfo := make(map[string]*NodeInfo)
175 componentID := 0
176
177 var strongconnect func(string)
178 strongconnect = func(v string) {
179 nodeInfo[v] = &NodeInfo{
180 index: index,
181 lowlink: index,
182 onStack: true,
183 }
184 index++
185 stack = append(stack, v)
186
187 for _, w := range a.graph[v] {
188 if _, ok := nodeInfo[w]; !ok {
189 strongconnect(w)
190 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].lowlink)
191 } else if nodeInfo[w].onStack {
192 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].index)
193 }
194 }
195
196 if nodeInfo[v].lowlink == nodeInfo[v].index {
197 var component []string
198 for {
199 w := stack[len(stack)-1]
200 stack = stack[:len(stack)-1]
201 nodeInfo[w].onStack = false
202 component = append(component, w)
203 if w == v {
204 break
205 }
206 }
207
208 sort.Strings(component)
209 components = append(components, Component{
210 ID: componentID,
211 Elements: component,
212 IsCycle: len(component) > 1,
213 })
214 componentID++
215 }
216 }
217
218 flatElems, _ := model.FlattenElements(a.model)
219 for v := range flatElems {
220 if _, ok := nodeInfo[v]; !ok {
221 strongconnect(v)
222 }
223 }
224
225 return components
226 }
227
228 // calculateMaxDepth finds the longest dependency path in the graph.
229 // In cyclic graphs, returns 0 since there's no defined maximum.
230 func (a *Analyzer) calculateMaxDepth() int {
231 if !a.isDAG() {
232 return 0 // Cyclic graph has undefined max depth
233 }
234
235 flatElems, _ := model.FlattenElements(a.model)
236 maxDepth := 0
237
238 for start := range flatElems {
239 depth := a.longestPathDAG(start)
240 if depth > maxDepth {
241 maxDepth = depth
242 }
243 }
244
245 return maxDepth
246 }
247
248 // isDAG checks if the graph is acyclic.
249 func (a *Analyzer) isDAG() bool {
250 visited := make(map[string]int) // 0=unvisited, 1=visiting, 2=visited
251 var hasCycle bool
252
253 var visit func(string)
254 visit = func(node string) {
255 if visited[node] == 1 {
256 hasCycle = true
257 return
258 }
259 if visited[node] == 2 {
260 return
261 }
262
263 visited[node] = 1
264 for _, neighbor := range a.graph[node] {
265 visit(neighbor)
266 }
267 visited[node] = 2
268 }
269
270 flatElems, _ := model.FlattenElements(a.model)
271 for node := range flatElems {
272 if visited[node] == 0 {
273 visit(node)
274 }
275 }
276
277 return !hasCycle
278 }
279
280 // hasPath checks if there is a path from src to dst (BFS with limit).
281 func (a *Analyzer) hasPath(src, dst string) bool {
282 if src == dst {
283 return true
284 }
285
286 visited := make(map[string]bool)
287 queue := []string{src}
288
289 for len(queue) > 0 {
290 current := queue[0]
291 queue = queue[1:]
292
293 if visited[current] {
294 continue
295 }
296 visited[current] = true
297
298 if current == dst {
299 return true
300 }
301
302 for _, neighbor := range a.graph[current] {
303 if !visited[neighbor] {
304 queue = append(queue, neighbor)
305 }
306 }
307 }
308
309 return false
310 }
311
312 // shortestPath finds the shortest path from src to dst (BFS distance).
313 func (a *Analyzer) shortestPath(src, dst string) int {
314 if src == dst {
315 return 0
316 }
317
318 visited := make(map[string]bool)
319 queue := []string{src}
320 distances := map[string]int{src: 0}
321
322 for len(queue) > 0 {
323 current := queue[0]
324 queue = queue[1:]
325
326 if visited[current] {
327 continue
328 }
329 visited[current] = true
330
331 if current == dst {
332 return distances[current]
333 }
334
335 for _, neighbor := range a.graph[current] {
336 if !visited[neighbor] {
337 if _, ok := distances[neighbor]; !ok {
338 distances[neighbor] = distances[current] + 1
339 queue = append(queue, neighbor)
340 }
341 }
342 }
343 }
344
345 return -1 // no path found
346 }
347
348 // longestPathDAG finds the longest path starting from a node (DFS with memoization).
349 // Only valid for DAGs; cyclic graphs will have max depth 0.
350 func (a *Analyzer) longestPathDAG(start string) int {
351 memo := make(map[string]int)
352 return a.dfsLongestPath(start, memo)
353 }
354
355 func (a *Analyzer) dfsLongestPath(node string, memo map[string]int) int {
356 if depth, ok := memo[node]; ok {
357 return depth
358 }
359
360 maxDepth := 0
361 for _, neighbor := range a.graph[node] {
362 depth := 1 + a.dfsLongestPath(neighbor, memo)
363 if depth > maxDepth {
364 maxDepth = depth
365 }
366 }
367
368 memo[node] = maxDepth
369 return maxDepth
370 }
371
372 func min(a, b int) int {
373 if a < b {
374 return a
375 }
376 return b
377 }
378
github.com/docToolchain/Bausteinsicht/internal/health/analyzer.go 93.0%
1 package health
2
3 import (
4 "fmt"
5 "time"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // Analyzer computes health scores for a model.
11 type Analyzer struct {
12 model *model.BausteinsichtModel
13 }
14
15 // NewAnalyzer creates a new health analyzer.
16 func NewAnalyzer(m *model.BausteinsichtModel) *Analyzer {
17 return &Analyzer{model: m}
18 }
19
20 // Analyze computes a comprehensive health score.
21 func (a *Analyzer) Analyze() *HealthScore {
22 flatElems, _ := model.FlattenElements(a.model)
23
24 categories := []CategoryScore{
25 a.scoreCompleteness(flatElems),
26 a.scoreConformance(flatElems),
27 a.scoreComplexity(flatElems),
28 a.scoreDeprecation(flatElems),
29 a.scoreDocumentation(flatElems),
30 }
31
32 // Calculate weighted overall score
33 var totalScore float64
34 var totalWeight float64
35 for _, cat := range categories {
36 totalScore += cat.Score * cat.Weight
37 totalWeight += cat.Weight
38 }
39
40 overall := totalScore / totalWeight
41 if totalWeight == 0 {
42 overall = 0
43 }
44
45 return &HealthScore{
46 Overall: overall,
47 Categories: categories,
48 Grade: calculateGrade(overall),
49 Summary: summarizeHealth(overall),
50 Timestamp: time.Now().UTC().Format(time.RFC3339),
51 ElementCnt: len(flatElems),
52 RelCnt: len(a.model.Relationships),
53 ViewCnt: len(a.model.Views),
54 }
55 }
56
57 // scoreCompleteness measures how well-documented the model is.
58 func (a *Analyzer) scoreCompleteness(elems map[string]*model.Element) CategoryScore {
59 var findings []Finding
60 documented := 0
61 missing := 0
62
63 for id, elem := range elems {
64 if elem.Title == "" || (elem.Description == "" && elem.Technology == "") {
65 findings = append(findings, Finding{
66 Category: CategoryCompleteness,
67 Severity: "minor",
68 Title: "Missing element description or technology",
69 Message: fmt.Sprintf("element %q lacks description or technology", id),
70 Elements: []string{id},
71 })
72 missing++
73 } else {
74 documented++
75 }
76 }
77
78 score := float64(documented) / float64(documented+missing) * 100
79 if documented+missing == 0 {
80 score = 100
81 }
82
83 return CategoryScore{
84 Category: CategoryCompleteness,
85 Score: score,
86 Weight: 0.2,
87 Findings: findings,
88 Details: fmt.Sprintf("%d/%d elements documented", documented, documented+missing),
89 }
90 }
91
92 // scoreConformance checks for policy violations.
93 func (a *Analyzer) scoreConformance(elems map[string]*model.Element) CategoryScore {
94 var findings []Finding
95
96 // Check for undefined relationship kinds
97 for i, rel := range a.model.Relationships {
98 if rel.Kind != "" {
99 if _, ok := a.model.Specification.Relationships[rel.Kind]; !ok {
100 findings = append(findings, Finding{
101 Category: CategoryConformance,
102 Severity: "major",
103 Title: "Undefined relationship kind",
104 Message: fmt.Sprintf("relationships[%d] uses unknown kind %q", i, rel.Kind),
105 Elements: []string{rel.From, rel.To},
106 })
107 }
108 }
109 }
110
111 // Check for undefined element kinds
112 for id, elem := range elems {
113 if _, ok := a.model.Specification.Elements[elem.Kind]; !ok {
114 findings = append(findings, Finding{
115 Category: CategoryConformance,
116 Severity: "major",
117 Title: "Undefined element kind",
118 Message: fmt.Sprintf("element %q uses unknown kind %q", id, elem.Kind),
119 Elements: []string{id},
120 })
121 }
122 }
123
124 score := 100.0 - float64(len(findings)*5)
125 if score < 0 {
126 score = 0
127 }
128
129 return CategoryScore{
130 Category: CategoryConformance,
131 Score: score,
132 Weight: 0.3,
133 Findings: findings,
134 Details: fmt.Sprintf("%d violations found", len(findings)),
135 }
136 }
137
138 // scoreComplexity assesses architectural complexity.
139 func (a *Analyzer) scoreComplexity(elems map[string]*model.Element) CategoryScore {
140 var findings []Finding
141
142 // Measure relationship density
143 maxRels := len(elems) * (len(elems) - 1)
144 if maxRels == 0 {
145 maxRels = 1
146 }
147 density := float64(len(a.model.Relationships)) / float64(maxRels)
148
149 // Count high-degree nodes (elements with many relationships)
150 inDegree := make(map[string]int)
151 outDegree := make(map[string]int)
152 for _, rel := range a.model.Relationships {
153 inDegree[rel.To]++
154 outDegree[rel.From]++
155 }
156
157 for id, out := range outDegree {
158 if out > 5 {
159 findings = append(findings, Finding{
160 Category: CategoryComplexity,
161 Severity: "major",
162 Title: "High outgoing dependency count",
163 Message: fmt.Sprintf("element %q has %d outgoing relationships (threshold: 5)", id, out),
164 Elements: []string{id},
165 })
166 }
167 }
168
169 // Score based on density and high-degree nodes
170 score := 100.0
171 if density > 0.3 {
172 score -= 20
173 findings = append(findings, Finding{
174 Category: CategoryComplexity,
175 Severity: "minor",
176 Title: "High relationship density",
177 Message: fmt.Sprintf("Architecture has %d relationships across %d elements (density: %.2f)", len(a.model.Relationships), len(elems), density),
178 })
179 }
180 score -= float64(len(findings)) * 3
181
182 if score < 0 {
183 score = 0
184 }
185
186 return CategoryScore{
187 Category: CategoryComplexity,
188 Score: score,
189 Weight: 0.15,
190 Findings: findings,
191 Details: fmt.Sprintf("density: %.2f, high-degree nodes: %d", density, len(findings)),
192 }
193 }
194
195 // scoreDeprecation checks for deprecated elements still in use.
196 func (a *Analyzer) scoreDeprecation(elems map[string]*model.Element) CategoryScore {
197 var findings []Finding
198 deprecated := 0
199 active := 0
200
201 for id, elem := range elems {
202 switch elem.Status {
203 case model.StatusDeprecated:
204 deprecated++
205 findings = append(findings, Finding{
206 Category: CategoryDeprecation,
207 Severity: "major",
208 Title: "Deprecated element still present",
209 Message: fmt.Sprintf("element %q is marked as deprecated", id),
210 Elements: []string{id},
211 })
212 case model.StatusDeployed, "":
213 active++
214 }
215 }
216
217 score := 100.0 - float64(deprecated*10)
218 if score < 0 {
219 score = 0
220 }
221
222 return CategoryScore{
223 Category: CategoryDeprecation,
224 Score: score,
225 Weight: 0.15,
226 Findings: findings,
227 Details: fmt.Sprintf("%d active, %d deprecated", active, deprecated),
228 }
229 }
230
231 // scoreDocumentation measures the quality of documentation.
232 func (a *Analyzer) scoreDocumentation(elems map[string]*model.Element) CategoryScore {
233 var findings []Finding
234 withDocs := 0
235
236 for id, elem := range elems {
237 if elem.Description != "" && len(elem.Description) > 20 {
238 withDocs++
239 } else if elem.Description != "" {
240 findings = append(findings, Finding{
241 Category: CategoryDocumentation,
242 Severity: "minor",
243 Title: "Brief element description",
244 Message: fmt.Sprintf("element %q has short description (< 20 chars)", id),
245 Elements: []string{id},
246 })
247 }
248 }
249
250 score := (float64(withDocs) / float64(len(elems))) * 100
251 if len(elems) == 0 {
252 score = 100
253 }
254
255 return CategoryScore{
256 Category: CategoryDocumentation,
257 Score: score,
258 Weight: 0.2,
259 Findings: findings,
260 Details: fmt.Sprintf("%d/%d elements have substantial descriptions", withDocs, len(elems)),
261 }
262 }
263
264 // summarizeHealth creates a human-readable summary.
265 func summarizeHealth(score float64) string {
266 switch {
267 case score >= 90:
268 return "Excellent architecture. Well-structured, documented, and maintainable."
269 case score >= 80:
270 return "Good architecture. Minor improvements recommended."
271 case score >= 70:
272 return "Acceptable architecture. Several areas need attention."
273 case score >= 60:
274 return "Fair architecture. Multiple improvements needed."
275 default:
276 return "Poor architecture. Significant refactoring recommended."
277 }
278 }
279
github.com/docToolchain/Bausteinsicht/internal/health/types.go 100.0%
1 package health
2
3 // ScoreCategory represents a dimension of architectural health.
4 type ScoreCategory string
5
6 const (
7 CategoryCompleteness ScoreCategory = "completeness"
8 CategoryConformance ScoreCategory = "conformance"
9 CategoryComplexity ScoreCategory = "complexity"
10 CategoryDeprecation ScoreCategory = "deprecation"
11 CategoryDocumentation ScoreCategory = "documentation"
12 )
13
14 // Finding describes a single health issue or improvement area.
15 type Finding struct {
16 Category ScoreCategory `json:"category"`
17 Severity string `json:"severity"` // "critical", "major", "minor", "info"
18 Title string `json:"title"`
19 Message string `json:"message"`
20 Elements []string `json:"elements,omitempty"` // affected element IDs
21 }
22
23 // CategoryScore represents the score for a single dimension.
24 type CategoryScore struct {
25 Category ScoreCategory `json:"category"`
26 Score float64 `json:"score"` // 0-100
27 Weight float64 `json:"weight"` // 0-1
28 Findings []Finding `json:"findings"`
29 Details string `json:"details,omitempty"`
30 }
31
32 // HealthScore is the overall architecture health assessment.
33 type HealthScore struct {
34 Overall float64 `json:"overall"` // 0-100 weighted average
35 Categories []CategoryScore `json:"categories"`
36 Grade string `json:"grade"` // A+, A, B+, B, C+, C, D, F
37 Summary string `json:"summary"`
38 Timestamp string `json:"timestamp"` // ISO8601
39 ElementCnt int `json:"elementCnt"`
40 RelCnt int `json:"relCnt"`
41 ViewCnt int `json:"viewCnt"`
42 }
43
44 // calculateGrade converts a numeric score to a letter grade.
45 func calculateGrade(score float64) string {
46 switch {
47 case score >= 97:
48 return "A+"
49 case score >= 93:
50 return "A"
51 case score >= 90:
52 return "B+"
53 case score >= 87:
54 return "B"
55 case score >= 80:
56 return "C+"
57 case score >= 70:
58 return "C"
59 case score >= 60:
60 return "D"
61 default:
62 return "F"
63 }
64 }
65
github.com/docToolchain/Bausteinsicht/internal/importer/likec4/likec4.go 70.7%
1 // Package likec4 parses LikeC4 DSL files and converts them to the
2 // Bausteinsicht model format.
3 package likec4
4
5 import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "unicode"
11
12 "github.com/docToolchain/Bausteinsicht/internal/importer"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 )
15
16 // ─── Tokenizer (identical grammar to Structurizr) ────────────────────────────
17
18 type tokKind int
19
20 const (
21 tokEOF tokKind = iota
22 tokNewline
23 tokString
24 tokIdent
25 tokLBrace
26 tokRBrace
27 tokAssign
28 tokArrow
29 )
30
31 type token struct {
32 kind tokKind
33 val string
34 line int
35 }
36
37 type scanner struct {
38 src []rune
39 pos int
40 line int
41 }
42
43 func tokenize(src string) ([]token, error) {
44 s := &scanner{src: []rune(src), line: 1}
45 var toks []token
46 for {
47 tok, err := s.next()
48 if err != nil {
49 return nil, err
50 }
51 toks = append(toks, tok)
52 if tok.kind == tokEOF {
53 break
54 }
55 }
56 return toks, nil
57 }
58
59 func (s *scanner) at(offset int) (rune, bool) {
60 i := s.pos + offset
61 if i >= len(s.src) {
62 return 0, false
63 }
64 return s.src[i], true
65 }
66
67 func (s *scanner) consume() rune {
68 r := s.src[s.pos]
69 s.pos++
70 if r == '\n' {
71 s.line++
72 }
73 return r
74 }
75
76 func (s *scanner) next() (token, error) {
77 for {
78 c, ok := s.at(0)
79 if !ok {
80 return token{kind: tokEOF, line: s.line}, nil
81 }
82 if c == ' ' || c == '\t' || c == '\r' {
83 s.consume()
84 continue
85 }
86 if c == '/' {
87 n, _ := s.at(1)
88 if n == '/' {
89 for {
90 ch, ok := s.at(0)
91 if !ok || ch == '\n' {
92 break
93 }
94 s.consume()
95 }
96 continue
97 }
98 if n == '*' {
99 s.consume()
100 s.consume()
101 for {
102 ch, ok := s.at(0)
103 if !ok {
104 return token{}, fmt.Errorf("unterminated block comment")
105 }
106 s.consume()
107 if ch == '*' {
108 if nn, _ := s.at(0); nn == '/' {
109 s.consume()
110 break
111 }
112 }
113 }
114 continue
115 }
116 }
117 break
118 }
119
120 c, ok := s.at(0)
121 if !ok {
122 return token{kind: tokEOF, line: s.line}, nil
123 }
124 line := s.line
125
126 if c == '\n' {
127 for {
128 ch, ok := s.at(0)
129 if !ok || ch != '\n' {
130 break
131 }
132 s.consume()
133 }
134 return token{kind: tokNewline, line: line}, nil
135 }
136
137 switch {
138 case c == '{':
139 s.consume()
140 return token{kind: tokLBrace, val: "{", line: line}, nil
141 case c == '}':
142 s.consume()
143 return token{kind: tokRBrace, val: "}", line: line}, nil
144 case c == '=':
145 s.consume()
146 return token{kind: tokAssign, val: "=", line: line}, nil
147 case c == '-':
148 if n, _ := s.at(1); n == '>' {
149 s.consume()
150 s.consume()
151 return token{kind: tokArrow, val: "->", line: line}, nil
152 }
153 s.consume()
154 return s.next()
155 case c == '"':
156 return s.scanString(line)
157 case unicode.IsLetter(c) || c == '_':
158 return s.scanIdent(line)
159 default:
160 s.consume()
161 return s.next()
162 }
163 }
164
165 func (s *scanner) scanString(line int) (token, error) {
166 s.consume()
167 var sb strings.Builder
168 for {
169 c, ok := s.at(0)
170 if !ok {
171 return token{}, fmt.Errorf("line %d: unterminated string", line)
172 }
173 if c == '"' {
174 s.consume()
175 break
176 }
177 if c == '\\' {
178 s.consume()
179 esc, ok := s.at(0)
180 if !ok {
181 return token{}, fmt.Errorf("line %d: EOF in string escape", line)
182 }
183 s.consume()
184 switch esc {
185 case '"', '\\':
186 sb.WriteRune(esc)
187 case 'n':
188 sb.WriteRune('\n')
189 default:
190 sb.WriteRune('\\')
191 sb.WriteRune(esc)
192 }
193 continue
194 }
195 sb.WriteRune(s.consume())
196 }
197 return token{kind: tokString, val: sb.String(), line: line}, nil
198 }
199
200 func (s *scanner) scanIdent(line int) (token, error) {
201 var sb strings.Builder
202 for {
203 c, ok := s.at(0)
204 if !ok {
205 break
206 }
207 if c == '-' {
208 if n, _ := s.at(1); n == '>' {
209 break
210 }
211 sb.WriteRune(s.consume())
212 continue
213 }
214 if unicode.IsLetter(c) || unicode.IsDigit(c) || c == '_' || c == '.' || c == '/' || c == ':' {
215 sb.WriteRune(s.consume())
216 continue
217 }
218 break
219 }
220 return token{kind: tokIdent, val: sb.String(), line: line}, nil
221 }
222
223 // ─── Parser ──────────────────────────────────────────────────────────────────
224
225 type stmt struct {
226 line int
227 varName string
228 keyword string
229 args []string
230 isRel bool
231 relFrom string
232 relTo string
233 body []stmt
234 }
235
236 type dslParser struct {
237 toks []token
238 pos int
239 }
240
241 func (p *dslParser) peek() token {
242 if p.pos >= len(p.toks) {
243 return token{kind: tokEOF}
244 }
245 return p.toks[p.pos]
246 }
247
248 func (p *dslParser) advance() token {
249 t := p.peek()
250 if t.kind != tokEOF {
251 p.pos++
252 }
253 return t
254 }
255
256 func (p *dslParser) skipNewlines() {
257 for p.peek().kind == tokNewline {
258 p.advance()
259 }
260 }
261
262 func (p *dslParser) parseAll() ([]stmt, error) {
263 return p.parseStmts(false)
264 }
265
266 func (p *dslParser) parseStmts(inBlock bool) ([]stmt, error) {
267 var stmts []stmt
268 for {
269 p.skipNewlines()
270 tok := p.peek()
271 if tok.kind == tokEOF {
272 break
273 }
274 if inBlock && tok.kind == tokRBrace {
275 break
276 }
277 s, err := p.parseOneStmt()
278 if err != nil {
279 return nil, err
280 }
281 if s != nil {
282 stmts = append(stmts, *s)
283 }
284 }
285 return stmts, nil
286 }
287
288 func (p *dslParser) parseBlock() ([]stmt, error) {
289 if p.peek().kind != tokLBrace {
290 return nil, nil
291 }
292 p.advance()
293 p.skipNewlines()
294 stmts, err := p.parseStmts(true)
295 if err != nil {
296 return nil, err
297 }
298 if p.peek().kind == tokRBrace {
299 p.advance()
300 }
301 return stmts, nil
302 }
303
304 func (p *dslParser) optBlock(s *stmt) error {
305 p.skipNewlines()
306 if p.peek().kind == tokLBrace {
307 body, err := p.parseBlock()
308 if err != nil {
309 return err
310 }
311 s.body = body
312 }
313 return nil
314 }
315
316 func (p *dslParser) parseOneStmt() (*stmt, error) {
317 tok := p.peek()
318 if tok.kind == tokEOF || tok.kind == tokRBrace {
319 return nil, nil
320 }
321
322 line := tok.line
323
324 if tok.kind == tokArrow {
325 p.advance()
326 to := p.advance()
327 s := &stmt{line: line, isRel: true, relTo: to.val, args: p.collectArgs()}
328 if err := p.optBlock(s); err != nil {
329 return nil, err
330 }
331 return s, nil
332 }
333
334 if tok.kind == tokLBrace {
335 if _, err := p.parseBlock(); err != nil {
336 return nil, err
337 }
338 return nil, nil
339 }
340
341 if tok.kind != tokIdent && tok.kind != tokString {
342 p.advance()
343 return nil, nil
344 }
345
346 p.advance()
347
348 switch p.peek().kind {
349 case tokAssign:
350 p.advance()
351 kw := p.advance()
352 s := &stmt{line: line, varName: tok.val, keyword: kw.val, args: p.collectArgs()}
353 if err := p.optBlock(s); err != nil {
354 return nil, err
355 }
356 return s, nil
357
358 case tokArrow:
359 p.advance()
360 to := p.advance()
361 s := &stmt{line: line, isRel: true, relFrom: tok.val, relTo: to.val, args: p.collectArgs()}
362 if err := p.optBlock(s); err != nil {
363 return nil, err
364 }
365 return s, nil
366
367 default:
368 s := &stmt{line: line, keyword: tok.val, args: p.collectArgs()}
369 if err := p.optBlock(s); err != nil {
370 return nil, err
371 }
372 return s, nil
373 }
374 }
375
376 func (p *dslParser) collectArgs() []string {
377 var args []string
378 for {
379 k := p.peek().kind
380 if k == tokString || k == tokIdent {
381 args = append(args, p.advance().val)
382 } else {
383 break
384 }
385 }
386 return args
387 }
388
389 // ─── Mapper ──────────────────────────────────────────────────────────────────
390
391 type lc4State struct {
392 kinds map[string]bool // known element kind names from specification
393 kindsContainer map[string]bool // kinds that have children in the model
394 spec map[string]model.ElementKind
395 elements map[string]model.Element
396 varToPath map[string]string
397 pendingRels []pendingRel
398 views map[string]model.View
399 viewKeys map[string]int
400 warnings []string
401 }
402
403 type pendingRel struct {
404 from string
405 to string
406 label string
407 line int
408 }
409
410 func newLC4State() *lc4State {
411 return &lc4State{
412 kinds: make(map[string]bool),
413 kindsContainer: make(map[string]bool),
414 spec: make(map[string]model.ElementKind),
415 elements: make(map[string]model.Element),
416 varToPath: make(map[string]string),
417 views: make(map[string]model.View),
418 viewKeys: make(map[string]int),
419 }
420 }
421
422 func (ls *lc4State) processSpecification(stmts []stmt) {
423 for _, s := range stmts {
424 switch s.keyword {
425 case "element":
426 if len(s.args) == 0 {
427 continue
428 }
429 kindName := s.args[0]
430 ls.kinds[kindName] = true
431 notation := strings.ToUpper(kindName[:1]) + kindName[1:]
432 ls.spec[kindName] = model.ElementKind{Notation: notation}
433 case "relationship":
434 // ignore — Bausteinsicht relationships don't need pre-declared types
435 }
436 }
437 }
438
439 func (ls *lc4State) resolveVar(v string) string {
440 if p, ok := ls.varToPath[v]; ok {
441 return p
442 }
443 return v
444 }
445
446 func (ls *lc4State) processModelStmts(stmts []stmt, parentPath, parentVar string, dest map[string]model.Element) {
447 for _, s := range stmts {
448 ls.processModelStmt(s, parentPath, parentVar, dest)
449 }
450 }
451
452 func (ls *lc4State) processModelStmt(s stmt, parentPath, parentVar string, dest map[string]model.Element) {
453 if s.isRel {
454 from := s.relFrom
455 if from == "" {
456 from = parentVar
457 }
458 label := ""
459 if len(s.args) > 0 {
460 label = s.args[0]
461 }
462 ls.pendingRels = append(ls.pendingRels, pendingRel{from: from, to: s.relTo, label: label, line: s.line})
463 return
464 }
465
466 if !ls.kinds[s.keyword] {
467 // Not a known kind — treat as property inside element body
468 return
469 }
470
471 key := s.varName
472 if key == "" {
473 if len(s.args) > 0 {
474 key = slugify(s.args[0])
475 } else {
476 key = s.keyword
477 }
478 ls.warnings = append(ls.warnings, fmt.Sprintf("line %d: element has no variable name, using %q", s.line, key))
479 }
480
481 path := key
482 if parentPath != "" {
483 path = parentPath + "." + key
484 }
485 ls.varToPath[key] = path
486
487 el := model.Element{Kind: s.keyword}
488 if len(s.args) > 0 {
489 el.Title = s.args[0]
490 }
491
492 children := make(map[string]model.Element)
493 for _, child := range s.body {
494 switch {
495 case child.isRel:
496 from := child.relFrom
497 if from == "" {
498 from = key
499 }
500 label := ""
501 if len(child.args) > 0 {
502 label = child.args[0]
503 }
504 ls.pendingRels = append(ls.pendingRels, pendingRel{from: from, to: child.relTo, label: label, line: child.line})
505 case ls.kinds[child.keyword]:
506 ls.processModelStmt(child, path, key, children)
507 case child.keyword == "description" && len(child.args) > 0:
508 el.Description = child.args[0]
509 case child.keyword == "technology" && len(child.args) > 0:
510 el.Technology = child.args[0]
511 case child.keyword == "title" && len(child.args) > 0:
512 el.Title = child.args[0]
513 case child.keyword == "tags":
514 el.Tags = child.args
515 }
516 }
517
518 if len(children) > 0 {
519 el.Children = children
520 ls.kindsContainer[s.keyword] = true
521 }
522
523 dest[key] = el
524 }
525
526 func (ls *lc4State) processViews(stmts []stmt) {
527 for _, s := range stmts {
528 if s.keyword != "view" {
529 continue
530 }
531
532 // LikeC4: view <key> [of <element>] { ... }
533 // args can be: ["key"], ["key", "of", "element"], or ["key", "of", "element", "title"]
534 viewKey := ""
535 scope := ""
536 title := ""
537
538 args := s.args
539 if len(args) > 0 {
540 viewKey = args[0]
541 args = args[1:]
542 }
543
544 // Check for "of" keyword
545 if len(args) >= 2 && args[0] == "of" {
546 scope = ls.resolveVar(args[1])
547 args = args[2:]
548 }
549
550 title = strings.Join(args, " ")
551
552 if viewKey == "" {
553 baseKey := "view"
554 if scope != "" {
555 baseKey = scope
556 }
557 viewKey = baseKey
558 if ls.viewKeys[baseKey] > 0 {
559 viewKey = fmt.Sprintf("%s_%d", baseKey, ls.viewKeys[baseKey])
560 }
561 }
562 ls.viewKeys[viewKey]++
563
564 if title == "" {
565 title = viewKey
566 }
567
568 v := model.View{Title: title, Scope: scope, Include: []string{"*"}}
569
570 for _, bs := range s.body {
571 switch bs.keyword {
572 case "title":
573 if len(bs.args) > 0 {
574 v.Title = bs.args[0]
575 }
576 case "description":
577 if len(bs.args) > 0 {
578 v.Description = bs.args[0]
579 }
580 case "include":
581 if len(bs.args) == 1 && bs.args[0] == "*" {
582 v.Include = []string{"*"}
583 } else {
584 v.Include = nil
585 for _, arg := range bs.args {
586 if arg == "*" {
587 v.Include = []string{"*"}
588 break
589 }
590 v.Include = append(v.Include, ls.resolveVar(arg))
591 }
592 }
593 case "exclude":
594 for _, arg := range bs.args {
595 v.Exclude = append(v.Exclude, ls.resolveVar(arg))
596 }
597 }
598 }
599
600 ls.views[viewKey] = v
601 }
602 }
603
604 func (ls *lc4State) buildRelationships() []model.Relationship {
605 var rels []model.Relationship
606 for _, pr := range ls.pendingRels {
607 fromPath := ls.resolveVar(pr.from)
608 toPath := ls.resolveVar(pr.to)
609 if fromPath == "" || toPath == "" {
610 ls.warnings = append(ls.warnings, fmt.Sprintf("line %d: relationship skipped (unresolved variable)", pr.line))
611 continue
612 }
613 rels = append(rels, model.Relationship{From: fromPath, To: toPath, Label: pr.label})
614 }
615 return rels
616 }
617
618 func (ls *lc4State) updateSpecWithContainers() {
619 for kind, ek := range ls.spec {
620 if ls.kindsContainer[kind] {
621 ek.Container = true
622 ls.spec[kind] = ek
623 }
624 }
625 }
626
627 func slugify(s string) string {
628 s = strings.ToLower(s)
629 var sb strings.Builder
630 prevUnderscore := false
631 for _, r := range s {
632 if unicode.IsLetter(r) || unicode.IsDigit(r) {
633 sb.WriteRune(r)
634 prevUnderscore = false
635 } else if !prevUnderscore && sb.Len() > 0 {
636 sb.WriteRune('_')
637 prevUnderscore = true
638 }
639 }
640 result := strings.TrimRight(sb.String(), "_")
641 if result == "" {
642 return "element"
643 }
644 return result
645 }
646
647 // ─── Public API ──────────────────────────────────────────────────────────────
648
649 const schemaURL = "https://raw.githubusercontent.com/docToolchain/Bausteinsicht/main/schema/bausteinsicht.schema.json"
650
651 // Import reads the LikeC4 DSL file at path and returns an ImportResult.
652 func Import(path string) (*importer.ImportResult, error) {
653 data, err := os.ReadFile(path)
654 if err != nil {
655 return nil, fmt.Errorf("reading %s: %w", path, err)
656 }
657 _ = filepath.Dir(path) // reserved for future !include support
658 return importSource(string(data))
659 }
660
661 // ImportSource parses a LikeC4 DSL string directly (useful for testing).
662 func ImportSource(src string) (*importer.ImportResult, error) {
663 return importSource(src)
664 }
665
666 func importSource(src string) (*importer.ImportResult, error) {
667 toks, err := tokenize(src)
668 if err != nil {
669 return nil, fmt.Errorf("tokenize: %w", err)
670 }
671
672 p := &dslParser{toks: toks}
673 stmts, err := p.parseAll()
674 if err != nil {
675 return nil, fmt.Errorf("parse: %w", err)
676 }
677
678 ls := newLC4State()
679
680 for _, s := range stmts {
681 switch s.keyword {
682 case "specification":
683 ls.processSpecification(s.body)
684 case "model":
685 ls.processModelStmts(s.body, "", "", ls.elements)
686 case "views":
687 ls.processViews(s.body)
688 }
689 }
690
691 ls.updateSpecWithContainers()
692
693 rels := ls.buildRelationships()
694
695 spec := model.Specification{Elements: make(map[string]model.ElementKind)}
696 for k, v := range ls.spec {
697 spec.Elements[k] = v
698 }
699
700 m := &model.BausteinsichtModel{
701 Schema: schemaURL,
702 Specification: spec,
703 Model: ls.elements,
704 Relationships: rels,
705 Views: ls.views,
706 }
707 if m.Relationships == nil {
708 m.Relationships = []model.Relationship{}
709 }
710 if m.Views == nil {
711 m.Views = make(map[string]model.View)
712 }
713
714 return &importer.ImportResult{Model: m, Warnings: ls.warnings}, nil
715 }
716
github.com/docToolchain/Bausteinsicht/internal/importer/structurizr/structurizr.go 71.8%
1 // Package structurizr parses Structurizr DSL files and converts them to the
2 // Bausteinsicht model format.
3 package structurizr
4
5 import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "unicode"
11
12 "github.com/docToolchain/Bausteinsicht/internal/importer"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 )
15
16 // ─── Tokenizer ───────────────────────────────────────────────────────────────
17
18 type tokKind int
19
20 const (
21 tokEOF tokKind = iota
22 tokNewline // statement separator — emitted for each (group of) newline(s)
23 tokString
24 tokIdent
25 tokLBrace
26 tokRBrace
27 tokAssign
28 tokArrow
29 )
30
31 type token struct {
32 kind tokKind
33 val string
34 line int
35 }
36
37 type scanner struct {
38 src []rune
39 pos int
40 line int
41 }
42
43 func tokenize(src string) ([]token, error) {
44 s := &scanner{src: []rune(src), line: 1}
45 var toks []token
46 for {
47 tok, err := s.next()
48 if err != nil {
49 return nil, err
50 }
51 toks = append(toks, tok)
52 if tok.kind == tokEOF {
53 break
54 }
55 }
56 return toks, nil
57 }
58
59 func (s *scanner) at(offset int) (rune, bool) {
60 i := s.pos + offset
61 if i >= len(s.src) {
62 return 0, false
63 }
64 return s.src[i], true
65 }
66
67 func (s *scanner) consume() rune {
68 r := s.src[s.pos]
69 s.pos++
70 if r == '\n' {
71 s.line++
72 }
73 return r
74 }
75
76 func (s *scanner) next() (token, error) {
77 // Skip horizontal whitespace and handle comments.
78 // Newlines are NOT skipped here — they are emitted as tokNewline.
79 for {
80 c, ok := s.at(0)
81 if !ok {
82 return token{kind: tokEOF, line: s.line}, nil
83 }
84 if c == ' ' || c == '\t' || c == '\r' {
85 s.consume()
86 continue
87 }
88 if c == '/' {
89 n, _ := s.at(1)
90 if n == '/' {
91 // Line comment: consume to end of line (leave \n for next call)
92 for {
93 ch, ok := s.at(0)
94 if !ok || ch == '\n' {
95 break
96 }
97 s.consume()
98 }
99 continue
100 }
101 if n == '*' {
102 s.consume()
103 s.consume()
104 for {
105 ch, ok := s.at(0)
106 if !ok {
107 return token{}, fmt.Errorf("unterminated block comment")
108 }
109 s.consume()
110 if ch == '*' {
111 if nn, _ := s.at(0); nn == '/' {
112 s.consume()
113 break
114 }
115 }
116 }
117 continue
118 }
119 }
120 break
121 }
122
123 c, ok := s.at(0)
124 if !ok {
125 return token{kind: tokEOF, line: s.line}, nil
126 }
127 line := s.line
128
129 // Collapse consecutive newlines into a single tokNewline.
130 if c == '\n' {
131 for {
132 ch, ok := s.at(0)
133 if !ok || ch != '\n' {
134 break
135 }
136 s.consume()
137 }
138 return token{kind: tokNewline, line: line}, nil
139 }
140
141 switch {
142 case c == '{':
143 s.consume()
144 return token{kind: tokLBrace, val: "{", line: line}, nil
145 case c == '}':
146 s.consume()
147 return token{kind: tokRBrace, val: "}", line: line}, nil
148 case c == '=':
149 s.consume()
150 return token{kind: tokAssign, val: "=", line: line}, nil
151 case c == '-':
152 if n, _ := s.at(1); n == '>' {
153 s.consume()
154 s.consume()
155 return token{kind: tokArrow, val: "->", line: line}, nil
156 }
157 s.consume()
158 return s.next()
159 case c == '"':
160 return s.scanString(line)
161 case c == '!' || unicode.IsLetter(c) || c == '_':
162 return s.scanIdent(line)
163 default:
164 s.consume()
165 return s.next()
166 }
167 }
168
169 func (s *scanner) scanString(line int) (token, error) {
170 s.consume()
171 var sb strings.Builder
172 for {
173 c, ok := s.at(0)
174 if !ok {
175 return token{}, fmt.Errorf("line %d: unterminated string", line)
176 }
177 if c == '"' {
178 s.consume()
179 break
180 }
181 if c == '\\' {
182 s.consume()
183 esc, ok := s.at(0)
184 if !ok {
185 return token{}, fmt.Errorf("line %d: EOF in string escape", line)
186 }
187 s.consume()
188 switch esc {
189 case '"', '\\':
190 sb.WriteRune(esc)
191 case 'n':
192 sb.WriteRune('\n')
193 default:
194 sb.WriteRune('\\')
195 sb.WriteRune(esc)
196 }
197 continue
198 }
199 sb.WriteRune(s.consume())
200 }
201 return token{kind: tokString, val: sb.String(), line: line}, nil
202 }
203
204 func (s *scanner) scanIdent(line int) (token, error) {
205 var sb strings.Builder
206 if c, _ := s.at(0); c == '!' {
207 sb.WriteRune(s.consume())
208 }
209 for {
210 c, ok := s.at(0)
211 if !ok {
212 break
213 }
214 if c == '-' {
215 if n, _ := s.at(1); n == '>' {
216 break
217 }
218 sb.WriteRune(s.consume())
219 continue
220 }
221 if unicode.IsLetter(c) || unicode.IsDigit(c) || c == '_' || c == '.' || c == '/' || c == ':' {
222 sb.WriteRune(s.consume())
223 continue
224 }
225 break
226 }
227 return token{kind: tokIdent, val: sb.String(), line: line}, nil
228 }
229
230 // ─── Parser ──────────────────────────────────────────────────────────────────
231
232 // stmt represents one parsed statement in the DSL.
233 type stmt struct {
234 line int
235 varName string
236 keyword string
237 args []string
238 isRel bool
239 relFrom string
240 relTo string
241 body []stmt
242 }
243
244 type dslParser struct {
245 toks []token
246 pos int
247 }
248
249 func (p *dslParser) peek() token {
250 if p.pos >= len(p.toks) {
251 return token{kind: tokEOF}
252 }
253 return p.toks[p.pos]
254 }
255
256 func (p *dslParser) advance() token {
257 t := p.peek()
258 if t.kind != tokEOF {
259 p.pos++
260 }
261 return t
262 }
263
264 func (p *dslParser) skipNewlines() {
265 for p.peek().kind == tokNewline {
266 p.advance()
267 }
268 }
269
270 func (p *dslParser) parseAll() ([]stmt, error) {
271 return p.parseStmts(false)
272 }
273
274 func (p *dslParser) parseStmts(inBlock bool) ([]stmt, error) {
275 var stmts []stmt
276 for {
277 p.skipNewlines()
278 tok := p.peek()
279 if tok.kind == tokEOF {
280 break
281 }
282 if inBlock && tok.kind == tokRBrace {
283 break
284 }
285 s, err := p.parseOneStmt()
286 if err != nil {
287 return nil, err
288 }
289 if s != nil {
290 stmts = append(stmts, *s)
291 }
292 }
293 return stmts, nil
294 }
295
296 func (p *dslParser) parseBlock() ([]stmt, error) {
297 if p.peek().kind != tokLBrace {
298 return nil, nil
299 }
300 p.advance() // {
301 p.skipNewlines()
302 stmts, err := p.parseStmts(true)
303 if err != nil {
304 return nil, err
305 }
306 if p.peek().kind == tokRBrace {
307 p.advance()
308 }
309 return stmts, nil
310 }
311
312 // optBlock skips newlines then reads a block if the next token is {.
313 func (p *dslParser) optBlock(s *stmt) error {
314 p.skipNewlines()
315 if p.peek().kind == tokLBrace {
316 body, err := p.parseBlock()
317 if err != nil {
318 return err
319 }
320 s.body = body
321 }
322 return nil
323 }
324
325 func (p *dslParser) parseOneStmt() (*stmt, error) {
326 tok := p.peek()
327 if tok.kind == tokEOF || tok.kind == tokRBrace {
328 return nil, nil
329 }
330
331 line := tok.line
332
333 if tok.kind == tokArrow {
334 p.advance()
335 to := p.advance()
336 s := &stmt{line: line, isRel: true, relTo: to.val, args: p.collectArgs()}
337 if err := p.optBlock(s); err != nil {
338 return nil, err
339 }
340 return s, nil
341 }
342
343 if tok.kind == tokLBrace {
344 if _, err := p.parseBlock(); err != nil {
345 return nil, err
346 }
347 return nil, nil
348 }
349
350 if tok.kind != tokIdent && tok.kind != tokString {
351 p.advance()
352 return nil, nil
353 }
354
355 p.advance()
356
357 switch p.peek().kind {
358 case tokAssign:
359 p.advance()
360 kw := p.advance()
361 s := &stmt{line: line, varName: tok.val, keyword: kw.val, args: p.collectArgs()}
362 if err := p.optBlock(s); err != nil {
363 return nil, err
364 }
365 return s, nil
366
367 case tokArrow:
368 p.advance()
369 to := p.advance()
370 s := &stmt{line: line, isRel: true, relFrom: tok.val, relTo: to.val, args: p.collectArgs()}
371 if err := p.optBlock(s); err != nil {
372 return nil, err
373 }
374 return s, nil
375
376 default:
377 s := &stmt{line: line, keyword: tok.val, args: p.collectArgs()}
378 if err := p.optBlock(s); err != nil {
379 return nil, err
380 }
381 return s, nil
382 }
383 }
384
385 func (p *dslParser) collectArgs() []string {
386 var args []string
387 for {
388 k := p.peek().kind
389 if k == tokString || k == tokIdent {
390 args = append(args, p.advance().val)
391 } else {
392 break
393 }
394 }
395 return args
396 }
397
398 // ─── Mapper ──────────────────────────────────────────────────────────────────
399
400 type kindDef struct {
401 kind string
402 notation string
403 container bool
404 }
405
406 // elementKindOrder defines the canonical C4 layer order for specification.elements.
407 var elementKindOrder = []kindDef{
408 {"person", "Person", false},
409 {"system", "Software System", true},
410 {"container", "Container", true},
411 {"component", "Component", false},
412 }
413
414 var structurizrKindMap = map[string]kindDef{
415 "person": elementKindOrder[0],
416 "softwareSystem": elementKindOrder[1],
417 "container": elementKindOrder[2],
418 "component": elementKindOrder[3],
419 }
420
421 type pendingRel struct {
422 from string
423 to string
424 label string
425 line int
426 }
427
428 type importState struct {
429 specAdded map[string]bool
430 spec map[string]model.ElementKind
431 elements map[string]model.Element
432 varToPath map[string]string
433 pendingRels []pendingRel
434 views map[string]model.View
435 viewKeys map[string]int
436 warnings []string
437 }
438
439 func newImportState() *importState {
440 return &importState{
441 specAdded: make(map[string]bool),
442 spec: make(map[string]model.ElementKind),
443 elements: make(map[string]model.Element),
444 varToPath: make(map[string]string),
445 views: make(map[string]model.View),
446 viewKeys: make(map[string]int),
447 }
448 }
449
450 func (is *importState) registerKind(kw string) {
451 kd := structurizrKindMap[kw]
452 if !is.specAdded[kd.kind] {
453 is.spec[kd.kind] = model.ElementKind{
454 Notation: kd.notation,
455 Container: kd.container,
456 }
457 is.specAdded[kd.kind] = true
458 }
459 }
460
461 func (is *importState) resolveVar(v string) string {
462 if p, ok := is.varToPath[v]; ok {
463 return p
464 }
465 return v
466 }
467
468 func (is *importState) processModelStmts(stmts []stmt, parentPath, parentVar string, dest map[string]model.Element) {
469 for _, s := range stmts {
470 is.processModelStmt(s, parentPath, parentVar, dest)
471 }
472 }
473
474 func (is *importState) processModelStmt(s stmt, parentPath, parentVar string, dest map[string]model.Element) {
475 if s.isRel {
476 from := s.relFrom
477 if from == "" {
478 from = parentVar
479 }
480 label := ""
481 if len(s.args) > 0 {
482 label = s.args[0]
483 }
484 is.pendingRels = append(is.pendingRels, pendingRel{from: from, to: s.relTo, label: label, line: s.line})
485 return
486 }
487
488 kd, isElement := structurizrKindMap[s.keyword]
489 if !isElement {
490 switch s.keyword {
491 case "enterprise", "group":
492 is.processModelStmts(s.body, parentPath, parentVar, dest)
493 }
494 return
495 }
496
497 is.registerKind(s.keyword)
498
499 key := s.varName
500 if key == "" {
501 if len(s.args) > 0 {
502 key = slugify(s.args[0])
503 } else {
504 key = kd.kind
505 }
506 is.warnings = append(is.warnings, fmt.Sprintf("line %d: element has no variable name, using %q", s.line, key))
507 }
508
509 path := key
510 if parentPath != "" {
511 path = parentPath + "." + key
512 }
513 is.varToPath[key] = path
514
515 el := model.Element{Kind: kd.kind}
516 if len(s.args) > 0 {
517 el.Title = s.args[0]
518 }
519 if len(s.args) > 1 {
520 el.Description = s.args[1]
521 }
522 if (kd.kind == "container" || kd.kind == "component") && len(s.args) > 2 {
523 el.Technology = s.args[2]
524 }
525
526 children := make(map[string]model.Element)
527 for _, child := range s.body {
528 switch {
529 case child.isRel:
530 from := child.relFrom
531 if from == "" {
532 from = key
533 }
534 label := ""
535 if len(child.args) > 0 {
536 label = child.args[0]
537 }
538 is.pendingRels = append(is.pendingRels, pendingRel{from: from, to: child.relTo, label: label, line: child.line})
539 case structurizrKindMap[child.keyword].kind != "":
540 is.processModelStmt(child, path, key, children)
541 case child.keyword == "description" && len(child.args) > 0:
542 el.Description = child.args[0]
543 case child.keyword == "technology" && len(child.args) > 0:
544 el.Technology = child.args[0]
545 case child.keyword == "tags":
546 el.Tags = child.args
547 case child.keyword == "properties":
548 el.Metadata = parseProperties(child.body)
549 }
550 }
551
552 if len(children) > 0 {
553 el.Children = children
554 }
555 dest[key] = el
556 }
557
558 func (is *importState) processViewsStmts(stmts []stmt) {
559 for _, s := range stmts {
560 switch s.keyword {
561 case "systemContext", "container", "component", "systemLandscape":
562 case "filtered", "dynamic", "deployment":
563 is.warnings = append(is.warnings, fmt.Sprintf("line %d: %s view not supported, skipped", s.line, s.keyword))
564 continue
565 default:
566 continue
567 }
568
569 scope := ""
570 if s.keyword != "systemLandscape" && len(s.args) > 0 {
571 scope = is.resolveVar(s.args[0])
572 }
573
574 titleArgs := s.args
575 if scope != "" {
576 titleArgs = s.args[1:]
577 }
578 title := strings.Join(titleArgs, " ")
579
580 baseKey := s.keyword
581 if scope != "" {
582 baseKey = scope
583 }
584 viewKey := baseKey
585 if is.viewKeys[baseKey] > 0 {
586 viewKey = fmt.Sprintf("%s_%d", baseKey, is.viewKeys[baseKey])
587 }
588 is.viewKeys[baseKey]++
589
590 if title == "" {
591 title = viewKey
592 }
593
594 v := model.View{Title: title, Scope: scope, Include: []string{"*"}}
595
596 for _, bs := range s.body {
597 switch bs.keyword {
598 case "include":
599 if len(bs.args) == 1 && bs.args[0] == "*" {
600 v.Include = []string{"*"}
601 } else {
602 v.Include = nil
603 for _, arg := range bs.args {
604 if arg != "*" {
605 v.Include = append(v.Include, is.resolveVar(arg))
606 } else {
607 v.Include = []string{"*"}
608 break
609 }
610 }
611 }
612 case "exclude":
613 for _, arg := range bs.args {
614 v.Exclude = append(v.Exclude, is.resolveVar(arg))
615 }
616 case "title":
617 if len(bs.args) > 0 {
618 v.Title = bs.args[0]
619 }
620 case "description":
621 if len(bs.args) > 0 {
622 v.Description = bs.args[0]
623 }
624 case "autoLayout":
625 v.Layout = "auto"
626 }
627 }
628
629 is.views[viewKey] = v
630 }
631 }
632
633 func (is *importState) buildRelationships() []model.Relationship {
634 var rels []model.Relationship
635 for _, pr := range is.pendingRels {
636 fromPath := is.resolveVar(pr.from)
637 toPath := is.resolveVar(pr.to)
638 if fromPath == "" || toPath == "" {
639 is.warnings = append(is.warnings, fmt.Sprintf("line %d: relationship skipped (unresolved variable)", pr.line))
640 continue
641 }
642 rels = append(rels, model.Relationship{From: fromPath, To: toPath, Label: pr.label})
643 }
644 return rels
645 }
646
647 func parseProperties(body []stmt) map[string]string {
648 m := make(map[string]string)
649 for _, s := range body {
650 if s.keyword != "" && len(s.args) > 0 {
651 m[s.keyword] = s.args[0]
652 }
653 }
654 return m
655 }
656
657 func slugify(s string) string {
658 s = strings.ToLower(s)
659 var sb strings.Builder
660 prevUnderscore := false
661 for _, r := range s {
662 if unicode.IsLetter(r) || unicode.IsDigit(r) {
663 sb.WriteRune(r)
664 prevUnderscore = false
665 } else if !prevUnderscore && sb.Len() > 0 {
666 sb.WriteRune('_')
667 prevUnderscore = true
668 }
669 }
670 result := strings.TrimRight(sb.String(), "_")
671 if result == "" {
672 return "element"
673 }
674 return result
675 }
676
677 // ─── Public API ──────────────────────────────────────────────────────────────
678
679 const schemaURL = "https://raw.githubusercontent.com/docToolchain/Bausteinsicht/main/schema/bausteinsicht.schema.json"
680
681 // ImportSource parses a Structurizr DSL string directly (useful for testing).
682 func ImportSource(src string) (*importer.ImportResult, error) {
683 return importSource(src)
684 }
685
686 // Import reads the Structurizr DSL file at path and returns an ImportResult.
687 func Import(path string) (*importer.ImportResult, error) {
688 data, err := os.ReadFile(path)
689 if err != nil {
690 return nil, fmt.Errorf("reading %s: %w", path, err)
691 }
692 baseDir := filepath.Dir(path)
693 src, includeWarnings := resolveIncludes(string(data), baseDir, map[string]bool{})
694 result, err := importSource(src)
695 if err != nil {
696 return nil, err
697 }
698 result.Warnings = append(includeWarnings, result.Warnings...)
699 return result, nil
700 }
701
702 func resolveIncludes(src, baseDir string, visited map[string]bool) (string, []string) {
703 var warnings []string
704 var out strings.Builder
705 absDirBase, _ := filepath.Abs(baseDir)
706 for _, line := range strings.Split(src, "\n") {
707 trimmed := strings.TrimSpace(line)
708 if strings.HasPrefix(trimmed, "!include ") {
709 includePath := strings.TrimSpace(trimmed[len("!include "):])
710 if strings.HasPrefix(includePath, "http://") || strings.HasPrefix(includePath, "https://") {
711 warnings = append(warnings, "!include: HTTP includes not supported, skipped: "+includePath)
712 out.WriteByte('\n')
713 continue
714 }
715 cleanedPath := filepath.Clean(includePath)
716 fullPath := filepath.Join(baseDir, cleanedPath)
717 absFullPath, _ := filepath.Abs(fullPath)
718
719 // Verify that the resolved path is within baseDir (prevent path traversal).
720 // Use filepath.Rel to check if the path escapes the base directory via .. sequences.
721 relPath, err := filepath.Rel(absDirBase, absFullPath)
722 if err != nil || strings.HasPrefix(relPath, "..") {
723 warnings = append(warnings, "!include: path traversal rejected: "+includePath)
724 out.WriteByte('\n')
725 continue
726 }
727
728 if visited[absFullPath] {
729 warnings = append(warnings, "!include: circular include ignored: "+includePath)
730 out.WriteByte('\n')
731 continue
732 }
733 data, err := os.ReadFile(absFullPath)
734 if err != nil {
735 warnings = append(warnings, fmt.Sprintf("!include: cannot read %s: %v", includePath, err))
736 out.WriteByte('\n')
737 continue
738 }
739 newVisited := make(map[string]bool, len(visited)+1)
740 for k, v := range visited {
741 newVisited[k] = v
742 }
743 newVisited[absFullPath] = true
744 included, w := resolveIncludes(string(data), filepath.Dir(absFullPath), newVisited)
745 warnings = append(warnings, w...)
746 out.WriteString(included)
747 out.WriteByte('\n')
748 continue
749 }
750 out.WriteString(line)
751 out.WriteByte('\n')
752 }
753 return out.String(), warnings
754 }
755
756 func importSource(src string) (*importer.ImportResult, error) {
757 toks, err := tokenize(src)
758 if err != nil {
759 return nil, fmt.Errorf("tokenize: %w", err)
760 }
761
762 p := &dslParser{toks: toks}
763 stmts, err := p.parseAll()
764 if err != nil {
765 return nil, fmt.Errorf("parse: %w", err)
766 }
767
768 is := newImportState()
769
770 var modelStmts, viewsStmts []stmt
771 for _, s := range stmts {
772 switch s.keyword {
773 case "workspace":
774 for _, ws := range s.body {
775 switch ws.keyword {
776 case "model":
777 modelStmts = ws.body
778 case "views":
779 viewsStmts = ws.body
780 }
781 }
782 case "model":
783 modelStmts = s.body
784 case "views":
785 viewsStmts = s.body
786 }
787 }
788
789 is.processModelStmts(modelStmts, "", "", is.elements)
790 if len(viewsStmts) > 0 {
791 is.processViewsStmts(viewsStmts)
792 }
793
794 rels := is.buildRelationships()
795
796 spec := model.Specification{Elements: make(map[string]model.ElementKind)}
797 for _, kd := range elementKindOrder {
798 if ek, ok := is.spec[kd.kind]; ok {
799 spec.Elements[kd.kind] = ek
800 }
801 }
802
803 m := &model.BausteinsichtModel{
804 Schema: schemaURL,
805 Specification: spec,
806 Model: is.elements,
807 Relationships: rels,
808 Views: is.views,
809 }
810 if m.Relationships == nil {
811 m.Relationships = []model.Relationship{}
812 }
813 if m.Views == nil {
814 m.Views = make(map[string]model.View)
815 }
816
817 return &importer.ImportResult{Model: m, Warnings: is.warnings}, nil
818 }
819
github.com/docToolchain/Bausteinsicht/internal/layout/apply.go 0.0%
1 package layout
2
3 import (
4 "fmt"
5
6 "github.com/beevik/etree"
7 "github.com/docToolchain/Bausteinsicht/internal/drawio"
8 )
9
10 // Apply applies layout positions to draw.io diagram.
11 // Reads pinned status from existing draw.io elements and respects PreservePinned setting.
12 func Apply(doc *drawio.Document, result LayoutResult, preservePinned bool) error {
13 if len(result.Positions) == 0 {
14 return fmt.Errorf("layout result is empty")
15 }
16
17 // Build map of existing draw.io elements with their pinned status
18 pinnedMap := readPinnedStatus(doc)
19
20 // For each computed position, update the draw.io element
21 for elemID, pos := range result.Positions {
22 // Skip pinned elements if preservePinned is enabled
23 if preservePinned && pinnedMap[elemID] {
24 continue
25 }
26
27 // Find and update element in draw.io
28 if err := updateElementPosition(doc, elemID, pos); err != nil {
29 continue
30 }
31 }
32
33 return nil
34 }
35
36 // readPinnedStatus reads the bausteinsicht-pinned property from draw.io elements.
37 func readPinnedStatus(doc *drawio.Document) map[string]bool {
38 pinned := make(map[string]bool)
39
40 for _, page := range doc.Pages() {
41 if root := page.Root(); root != nil {
42 walkElements(root, func(elem *etree.Element) {
43 if id, ok := getAttr(elem, "bausteinsicht_id"); ok {
44 if pinValue, ok := getAttr(elem, "bausteinsicht-pinned"); ok && pinValue == "true" {
45 pinned[id] = true
46 }
47 }
48 })
49 }
50 }
51
52 return pinned
53 }
54
55 // updateElementPosition updates the x, y, width, height of a draw.io element.
56 func updateElementPosition(doc *drawio.Document, elemID string, pos ElementPosition) error {
57 for _, page := range doc.Pages() {
58 if root := page.Root(); root != nil {
59 found := false
60 walkElements(root, func(elem *etree.Element) {
61 if found {
62 return
63 }
64 if id, ok := getAttr(elem, "bausteinsicht_id"); ok && id == elemID {
65 // Find mxGeometry child and update coordinates
66 for _, child := range elem.ChildElements() {
67 if child.Tag == "mxGeometry" {
68 child.CreateAttr("x", fmt.Sprintf("%.0f", pos.X))
69 child.CreateAttr("y", fmt.Sprintf("%.0f", pos.Y))
70 child.CreateAttr("width", fmt.Sprintf("%.0f", pos.Width))
71 child.CreateAttr("height", fmt.Sprintf("%.0f", pos.Height))
72 found = true
73 break
74 }
75 }
76 }
77 })
78 if found {
79 return nil
80 }
81 }
82 }
83
84 return fmt.Errorf("element %s not found in diagram", elemID)
85 }
86
87 // walkElements recursively walks through all elements in the tree.
88 func walkElements(elem *etree.Element, fn func(*etree.Element)) {
89 fn(elem)
90 for _, child := range elem.ChildElements() {
91 walkElements(child, fn)
92 }
93 }
94
95 // getAttr extracts attribute value from element safely.
96 func getAttr(elem *etree.Element, name string) (string, bool) {
97 for _, attr := range elem.Attr {
98 if attr.Key == name {
99 return attr.Value, true
100 }
101 }
102 return "", false
103 }
104
github.com/docToolchain/Bausteinsicht/internal/layout/hierarchical.go 98.1%
1 package layout
2
3 import (
4 "github.com/docToolchain/Bausteinsicht/internal/model"
5 )
6
7 // HierarchicalLayout computes layer assignments via longest-path algorithm,
8 // then positions elements in layers with horizontal alignment.
9 type HierarchicalLayout struct {
10 model *model.BausteinsichtModel
11 rankDir string // TB or LR
12 spacing float64
13 layerGap float64
14 }
15
16 // NewHierarchicalLayout creates a hierarchical layout engine.
17 func NewHierarchicalLayout(m *model.BausteinsichtModel, rankDir string) *HierarchicalLayout {
18 if rankDir == "" {
19 rankDir = "TB"
20 }
21 return &HierarchicalLayout{
22 model: m,
23 rankDir: rankDir,
24 spacing: 20, // pixels between elements in same layer
25 layerGap: 100, // pixels between layers
26 }
27 }
28
29 // Compute calculates positions for all elements.
30 func (h *HierarchicalLayout) Compute() LayoutResult {
31 outgoing := h.buildOutgoingMap()
32
33 // Assign layers using longest-path algorithm
34 layers := h.assignLayers(outgoing)
35
36 // Position elements based on layers
37 positions := h.positionElements(layers)
38
39 return LayoutResult{
40 Positions: positions,
41 Algorithm: Hierarchical,
42 }
43 }
44
45 // buildOutgoingMap creates a map of outgoing relationships per element.
46 func (h *HierarchicalLayout) buildOutgoingMap() map[string][]string {
47 outgoing := make(map[string][]string)
48 for _, rel := range h.model.Relationships {
49 outgoing[rel.From] = append(outgoing[rel.From], rel.To)
50 }
51 return outgoing
52 }
53
54 // assignLayers assigns each element to a layer using longest-path algorithm.
55 func (h *HierarchicalLayout) assignLayers(outgoing map[string][]string) map[int][]string {
56 flat, _ := model.FlattenElements(h.model)
57
58 // Compute longest path from each node
59 depths := make(map[string]int)
60 for id := range flat {
61 depths[id] = h.longestPath(id, outgoing, make(map[string]bool))
62 }
63
64 // Group elements by layer
65 layers := make(map[int][]string)
66 maxLayer := 0
67 for id, depth := range depths {
68 layers[depth] = append(layers[depth], id)
69 if depth > maxLayer {
70 maxLayer = depth
71 }
72 }
73
74 return layers
75 }
76
77 // longestPath computes longest outgoing path from a node (memoized).
78 func (h *HierarchicalLayout) longestPath(id string, outgoing map[string][]string, visited map[string]bool) int {
79 if visited[id] {
80 return 0 // cycle detected, break here
81 }
82
83 targets := outgoing[id]
84 if len(targets) == 0 {
85 return 0
86 }
87
88 visited[id] = true
89 maxDepth := 0
90 for _, target := range targets {
91 depth := h.longestPath(target, outgoing, visited)
92 if depth > maxDepth {
93 maxDepth = depth
94 }
95 }
96 delete(visited, id)
97
98 return maxDepth + 1
99 }
100
101 // positionElements places elements horizontally within each layer.
102 func (h *HierarchicalLayout) positionElements(layers map[int][]string) map[string]ElementPosition {
103 positions := make(map[string]ElementPosition)
104
105 for layer, ids := range layers {
106 // Default sizes
107 elemWidth := 160.0
108 elemHeight := 60.0
109
110 // Calculate layer positions
111 var x, y float64
112 if h.rankDir == "TB" {
113 // Top-to-bottom: layer determines Y, elements spread horizontally
114 y = float64(layer) * h.layerGap
115 x = 50.0
116 } else {
117 // Left-to-right: layer determines X, elements spread vertically
118 x = float64(layer) * h.layerGap
119 y = 50.0
120 }
121
122 // Position elements in this layer
123 for i, id := range ids {
124 elemX, elemY := x, y
125 if h.rankDir == "TB" {
126 elemX = x + float64(i)*(elemWidth+h.spacing)
127 } else {
128 elemY = y + float64(i)*(elemHeight+h.spacing)
129 }
130
131 positions[id] = ElementPosition{
132 ID: id,
133 X: elemX,
134 Y: elemY,
135 Width: elemWidth,
136 Height: elemHeight,
137 Layer: layer,
138 }
139 }
140 }
141
142 return positions
143 }
144
github.com/docToolchain/Bausteinsicht/internal/lsp/codelens.go 97.3%
1 package lsp
2
3 import (
4 "fmt"
5 "path/filepath"
6 "regexp"
7 "strings"
8 )
9
10 type CodeLens struct {
11 Range Range `json:"range"`
12 Command *Command `json:"command,omitempty"`
13 Data interface{} `json:"data,omitempty"`
14 }
15
16 type Command struct {
17 Title string `json:"title"`
18 Command string `json:"command"`
19 Arguments []interface{} `json:"arguments,omitempty"`
20 }
21
22 type CodeLensData struct {
23 ElementID string
24 Kind string
25 Status string
26 ViewCount int
27 }
28
29 // GenerateCodeLens extracts element definitions from the document and generates CodeLens objects.
30 func GenerateCodeLens(doc *Document) []CodeLens {
31 // Check if filename matches *architecture*.jsonc pattern
32 base := filepath.Base(doc.Filename)
33 if !strings.Contains(base, "architecture") || !strings.HasSuffix(base, ".jsonc") {
34 return nil
35 }
36
37 var lenses []CodeLens
38 lines := strings.Split(doc.Content, "\n")
39
40 // Pattern: "elementName": { or "elementName": {, matching JSON object keys
41 elementPattern := regexp.MustCompile(`^\s*"([a-zA-Z_][a-zA-Z0-9_]*)"\s*:\s*{`)
42
43 for i, line := range lines {
44 matches := elementPattern.FindStringSubmatch(line)
45 if len(matches) < 2 {
46 continue
47 }
48
49 elementID := matches[1]
50
51 // Skip parent keys like "model", "views", etc.
52 if elementID == "model" || elementID == "views" || elementID == "relationships" {
53 continue
54 }
55
56 // Extract metadata (kind and status from nearby lines)
57 kind := extractKind(lines, i)
58 status := extractStatus(lines, i)
59 viewCount := estimateViewCount(doc.Content, elementID)
60
61 // Create CodeLens entry
62 lens := CodeLens{
63 Range: Range{
64 Start: Position{Line: i, Character: 0},
65 End: Position{Line: i, Character: len(line)},
66 },
67 Command: &Command{
68 Title: fmt.Sprintf("%s | status: %s | views: %d", kind, status, viewCount),
69 Command: "bausteinsicht.openInDrawio",
70 Arguments: []interface{}{
71 elementID,
72 map[string]interface{}{
73 "kind": kind,
74 "status": status,
75 "views": viewCount,
76 },
77 },
78 },
79 }
80
81 lenses = append(lenses, lens)
82 }
83
84 return lenses
85 }
86
87 // extractKind finds the "kind" field value in the element definition.
88 func extractKind(lines []string, startLine int) string {
89 // Search within the next 10 lines for a "kind" field
90 for i := startLine; i < startLine+10 && i < len(lines); i++ {
91 if strings.Contains(lines[i], `"kind"`) {
92 // Extract value: "kind": "service" → service
93 kindPattern := regexp.MustCompile(`"kind"\s*:\s*"([^"]*)"`)
94 matches := kindPattern.FindStringSubmatch(lines[i])
95 if len(matches) > 1 {
96 return matches[1]
97 }
98 }
99 }
100 return "unknown"
101 }
102
103 // extractStatus finds the "status" field value in the element definition.
104 func extractStatus(lines []string, startLine int) string {
105 // Search within the next 10 lines for a "status" field
106 for i := startLine; i < startLine+10 && i < len(lines); i++ {
107 if strings.Contains(lines[i], `"status"`) {
108 // Extract value: "status": "active" → active
109 statusPattern := regexp.MustCompile(`"status"\s*:\s*"([^"]*)"`)
110 matches := statusPattern.FindStringSubmatch(lines[i])
111 if len(matches) > 1 {
112 return matches[1]
113 }
114 }
115 }
116 return "active"
117 }
118
119 // estimateViewCount counts how many views reference this element.
120 func estimateViewCount(content string, elementID string) int {
121 // Count occurrences of the element ID in the document (rough estimate)
122 // A more precise implementation would parse the model and count actual view references
123 count := strings.Count(content, elementID) - 1 // Subtract 1 for the element definition itself
124 if count < 0 {
125 count = 0
126 }
127 return count
128 }
129
github.com/docToolchain/Bausteinsicht/internal/lsp/diagnostics.go 83.7%
1 package lsp
2
3 import (
4 "bytes"
5 "encoding/json"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 )
10
11 type Diagnostic struct {
12 Range Range `json:"range"`
13 Message string `json:"message"`
14 Severity int `json:"severity"`
15 Source string `json:"source"`
16 }
17
18 type Range struct {
19 Start Position `json:"start"`
20 End Position `json:"end"`
21 }
22
23 type Position struct {
24 Line int `json:"line"`
25 Character int `json:"character"`
26 }
27
28 const (
29 DiagnosticError = 1
30 DiagnosticWarning = 2
31 DiagnosticInfo = 3
32 DiagnosticHint = 4
33 )
34
35 type ValidateOutput struct {
36 Valid bool `json:"valid"`
37 Errors []ValidationError `json:"errors,omitempty"`
38 Warnings []ValidationWarning `json:"warnings,omitempty"`
39 }
40
41 type ValidationError struct {
42 Path string `json:"path"`
43 Message string `json:"message"`
44 Line int `json:"line,omitempty"`
45 }
46
47 type ValidationWarning struct {
48 Path string `json:"path"`
49 Message string `json:"message"`
50 Line int `json:"line,omitempty"`
51 }
52
53 func ValidateDocument(doc *Document, workDir string) []Diagnostic {
54 // Check if filename matches *architecture*.jsonc pattern
55 base := filepath.Base(doc.Filename)
56 if !strings.Contains(base, "architecture") || !strings.HasSuffix(base, ".jsonc") {
57 return nil
58 }
59
60 // Validate path to prevent directory traversal (SEC-001)
61 cleanPath := filepath.Clean(doc.Filename)
62 for _, component := range strings.Split(cleanPath, string(filepath.Separator)) {
63 if component == ".." {
64 return []Diagnostic{
65 {
66 Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 10}},
67 Message: "Invalid path: contains directory traversal",
68 Severity: DiagnosticError,
69 Source: "bausteinsicht",
70 },
71 }
72 }
73 }
74
75 // Call bausteinsicht validate --format json
76 cmd := exec.Command("bausteinsicht", "validate", "--format", "json", "--model", doc.Filename)
77 cmd.Dir = workDir
78
79 var stdout bytes.Buffer
80 var stderr bytes.Buffer
81 cmd.Stdout = &stdout
82 cmd.Stderr = &stderr
83
84 if err := cmd.Run(); err != nil {
85 // Command failed, but we might still get useful output from stdout/stderr
86 _ = err
87 }
88
89 // Parse JSON output
90 var output ValidateOutput
91 if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
92 // If parsing fails, try to extract errors from stderr
93 return parseValidationErrors(stderr.String())
94 }
95
96 return convertValidateOutput(&output, doc)
97 }
98
99 func convertValidateOutput(output *ValidateOutput, doc *Document) []Diagnostic {
100 var diags []Diagnostic
101
102 // Process errors
103 for _, err := range output.Errors {
104 line, char := findLineInDocument(doc, err.Path, err.Line)
105 diags = append(diags, Diagnostic{
106 Range: Range{
107 Start: Position{Line: line, Character: char},
108 End: Position{Line: line, Character: char + 10},
109 },
110 Message: err.Message,
111 Severity: DiagnosticError,
112 Source: "bausteinsicht",
113 })
114 }
115
116 // Process warnings
117 for _, warn := range output.Warnings {
118 line, char := findLineInDocument(doc, warn.Path, warn.Line)
119 diags = append(diags, Diagnostic{
120 Range: Range{
121 Start: Position{Line: line, Character: char},
122 End: Position{Line: line, Character: char + 10},
123 },
124 Message: warn.Message,
125 Severity: DiagnosticWarning,
126 Source: "bausteinsicht",
127 })
128 }
129
130 return diags
131 }
132
133 func findLineInDocument(doc *Document, path string, preferredLine int) (int, int) {
134 lines := strings.Split(doc.Content, "\n")
135
136 // If a preferred line is given, use it (adjusted for 0-indexing)
137 if preferredLine > 0 && preferredLine-1 < len(lines) {
138 return preferredLine - 1, 0
139 }
140
141 // Otherwise, search for the path in the document
142 // This is a simple search - real implementation would parse JSON
143 for i, line := range lines {
144 if strings.Contains(line, path) || strings.Contains(line, strings.TrimPrefix(path, "\"")) {
145 return i, 0
146 }
147 }
148
149 return 0, 0
150 }
151
152 func parseValidationErrors(stderr string) []Diagnostic {
153 var diags []Diagnostic
154
155 lines := strings.Split(stderr, "\n")
156 for _, line := range lines {
157 if strings.Contains(line, "Error:") || strings.Contains(line, "error:") {
158 // Extract error message
159 parts := strings.Split(line, ":")
160 if len(parts) > 1 {
161 msg := strings.TrimSpace(strings.Join(parts[1:], ":"))
162 diags = append(diags, Diagnostic{
163 Range: Range{
164 Start: Position{Line: 0, Character: 0},
165 End: Position{Line: 0, Character: 10},
166 },
167 Message: msg,
168 Severity: DiagnosticError,
169 Source: "bausteinsicht",
170 })
171 }
172 }
173 }
174
175 return diags
176 }
177
github.com/docToolchain/Bausteinsicht/internal/lsp/server.go 33.3%
1 package lsp
2
3 import (
4 "bufio"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log"
9 "net/url"
10 "os"
11 "path/filepath"
12 "runtime"
13 "strconv"
14 "strings"
15 )
16
17 type Server struct {
18 documents map[string]*Document
19 workDir string
20 modelPath string
21 }
22
23 type Document struct {
24 URI string
25 Content string
26 Version int
27 Text string
28 Filename string
29 }
30
31 type JSONRPCMessage struct {
32 JSONRPC string `json:"jsonrpc"`
33 ID interface{} `json:"id,omitempty"`
34 Method string `json:"method,omitempty"`
35 Params json.RawMessage `json:"params,omitempty"`
36 Result interface{} `json:"result,omitempty"`
37 Error interface{} `json:"error,omitempty"`
38 }
39
40 type InitializeParams struct {
41 RootPath string `json:"rootPath"`
42 RootURI string `json:"rootUri"`
43 Workspace struct {
44 WorkspaceFolders []struct {
45 URI string `json:"uri"`
46 Name string `json:"name"`
47 } `json:"workspaceFolders"`
48 } `json:"workspace"`
49 }
50
51 type InitializeResult struct {
52 Capabilities ServerCapabilities `json:"capabilities"`
53 }
54
55 type ServerCapabilities struct {
56 TextDocumentSync int `json:"textDocumentSync"`
57 DiagnosticProvider bool `json:"diagnosticProvider"`
58 CodeLensProvider struct {
59 CodeLensOptions
60 } `json:"codeLensProvider"`
61 }
62
63 type CodeLensOptions struct {
64 ResolveProvider bool `json:"resolveProvider"`
65 }
66
67 func NewServer() *Server {
68 return &Server{
69 documents: make(map[string]*Document),
70 workDir: ".",
71 }
72 }
73
74 func (s *Server) Run() error {
75 return s.readMessages()
76 }
77
78 func (s *Server) readMessages() error {
79 reader := bufio.NewReader(os.Stdin)
80
81 for {
82 // Read headers
83 headers := make(map[string]string)
84 for {
85 line, err := reader.ReadString('\n')
86 if err == io.EOF {
87 return nil // Client disconnected cleanly
88 }
89 if err != nil {
90 return err
91 }
92 line = strings.TrimSpace(line)
93 if line == "" {
94 break
95 }
96 parts := strings.Split(line, ": ")
97 if len(parts) == 2 {
98 headers[parts[0]] = parts[1]
99 }
100 }
101
102 // Read body
103 contentLength := headers["Content-Length"]
104 if contentLength == "" {
105 continue
106 }
107
108 length, err := strconv.Atoi(contentLength)
109 if err != nil {
110 continue
111 }
112
113 body := make([]byte, length)
114 _, err = io.ReadFull(reader, body)
115 if err != nil {
116 if err == io.EOF {
117 return fmt.Errorf("unexpected EOF while reading message body")
118 }
119 return err
120 }
121
122 // Parse and handle message
123 var msg JSONRPCMessage
124 if err := json.Unmarshal(body, &msg); err != nil {
125 log.Printf("Failed to parse message: %v", err)
126 continue
127 }
128
129 // Handle message
130 response := s.handleMessage(&msg)
131 if response != nil {
132 s.sendMessage(response)
133 }
134 }
135 }
136
137 func (s *Server) handleMessage(msg *JSONRPCMessage) interface{} {
138 switch msg.Method {
139 case "initialize":
140 var params InitializeParams
141 if err := json.Unmarshal(msg.Params, &params); err != nil {
142 return nil
143 }
144 return s.handleInitialize(msg.ID, &params)
145
146 case "initialized":
147 return nil
148
149 case "textDocument/didOpen":
150 var params struct {
151 TextDocument struct {
152 URI string `json:"uri"`
153 Text string `json:"text"`
154 } `json:"textDocument"`
155 }
156 if err := json.Unmarshal(msg.Params, &params); err != nil {
157 return nil
158 }
159 s.handleDidOpen(&params.TextDocument.URI, &params.TextDocument.Text)
160 return nil
161
162 case "textDocument/didChange":
163 var params struct {
164 TextDocument struct {
165 URI string `json:"uri"`
166 Version int `json:"version"`
167 } `json:"textDocument"`
168 ContentChanges []struct {
169 Text string `json:"text"`
170 } `json:"contentChanges"`
171 }
172 if err := json.Unmarshal(msg.Params, &params); err != nil {
173 return nil
174 }
175 if len(params.ContentChanges) > 0 {
176 s.handleDidChange(&params.TextDocument.URI, params.TextDocument.Version, params.ContentChanges[0].Text)
177 }
178 return nil
179
180 case "textDocument/didSave":
181 var params struct {
182 TextDocument struct {
183 URI string `json:"uri"`
184 } `json:"textDocument"`
185 }
186 if err := json.Unmarshal(msg.Params, &params); err != nil {
187 return nil
188 }
189 s.handleDidSave(&params.TextDocument.URI)
190 return nil
191
192 case "shutdown":
193 // Send response before exiting (LSP spec requirement)
194 response := &JSONRPCMessage{
195 JSONRPC: "2.0",
196 ID: msg.ID,
197 Result: map[string]interface{}{},
198 }
199 s.sendMessage(response)
200 os.Exit(0)
201
202 default:
203 return nil
204 }
205
206 return nil
207 }
208
209 func (s *Server) handleInitialize(id interface{}, params *InitializeParams) interface{} {
210 if params.RootPath != "" {
211 s.workDir = params.RootPath
212 }
213 // Auto-detect model file
214 s.detectModel()
215
216 return &JSONRPCMessage{
217 JSONRPC: "2.0",
218 ID: id,
219 Result: InitializeResult{
220 Capabilities: ServerCapabilities{
221 TextDocumentSync: 2, // Full document sync
222 DiagnosticProvider: true,
223 CodeLensProvider: struct {
224 CodeLensOptions
225 }{CodeLensOptions{ResolveProvider: false}},
226 },
227 },
228 }
229 }
230
231 func (s *Server) handleDidOpen(uri *string, text *string) {
232 if uri == nil || text == nil {
233 return
234 }
235
236 filename := URIToPath(*uri)
237 doc := &Document{
238 URI: *uri,
239 Content: *text,
240 Text: *text,
241 Version: 1,
242 Filename: filename,
243 }
244 s.documents[*uri] = doc
245
246 // Publish diagnostics if this is the model file
247 if s.isModelFile(filename) {
248 s.publishDiagnostics(uri)
249 }
250 }
251
252 func (s *Server) handleDidChange(uri *string, version int, text string) {
253 if uri == nil {
254 return
255 }
256
257 doc, ok := s.documents[*uri]
258 if !ok {
259 return
260 }
261
262 doc.Content = text
263 doc.Text = text
264 doc.Version = version
265
266 if s.isModelFile(doc.Filename) {
267 s.publishDiagnostics(uri)
268 }
269 }
270
271 func (s *Server) handleDidSave(uri *string) {
272 if uri == nil {
273 return
274 }
275
276 doc, ok := s.documents[*uri]
277 if !ok {
278 return
279 }
280
281 if s.isModelFile(doc.Filename) {
282 s.publishDiagnostics(uri)
283 }
284 }
285
286 func (s *Server) publishDiagnostics(uri *string) {
287 doc, ok := s.documents[*uri]
288 if !ok || doc == nil {
289 return
290 }
291
292 diags := ValidateDocument(doc, s.workDir)
293
294 params := map[string]interface{}{
295 "uri": *uri,
296 "diagnostics": diags,
297 }
298 paramsData, _ := json.Marshal(params)
299
300 msg := &JSONRPCMessage{
301 JSONRPC: "2.0",
302 Method: "textDocument/publishDiagnostics",
303 Params: paramsData,
304 }
305
306 s.sendMessage(msg)
307 }
308
309 func (s *Server) sendMessage(msg interface{}) {
310 data, err := json.Marshal(msg)
311 if err != nil {
312 log.Printf("Failed to marshal message: %v", err)
313 return
314 }
315
316 header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data))
317 _, _ = os.Stdout.WriteString(header)
318 _, _ = os.Stdout.Write(data)
319 }
320
321 func (s *Server) detectModel() {
322 // Look for architecture.jsonc in work directory
323 modelPath := filepath.Join(s.workDir, "architecture.jsonc")
324 if _, err := os.Stat(modelPath); err == nil {
325 s.modelPath = modelPath
326 }
327 }
328
329 func (s *Server) isModelFile(filename string) bool {
330 base := filepath.Base(filename)
331 return strings.Contains(base, "architecture") && strings.HasSuffix(base, ".jsonc")
332 }
333
334 func URIToPath(uri string) string {
335 // Parse URI to handle cross-platform paths and URL-encoded characters
336 u, err := url.Parse(uri)
337 if err != nil {
338 // Fall back to simple prefix removal on parse error
339 if strings.HasPrefix(uri, "file://") {
340 return uri[7:]
341 }
342 return uri
343 }
344
345 // Extract path from parsed URI (keep forward slashes per RFC 8089)
346 path := u.Path
347
348 // On Windows, remove leading slash from absolute paths (C:/path not /C:/path)
349 if runtime.GOOS == "windows" && len(path) > 0 && path[0] == '/' && len(path) > 2 && path[2] == ':' {
350 path = path[1:]
351 }
352
353 return path
354 }
355
github.com/docToolchain/Bausteinsicht/internal/model/add.go 83.9%
1 package model
2
3 import (
4 "fmt"
5 "sort"
6 )
7
8 // AddView creates a new view or merges fields into an existing view.
9 // For existing views:
10 // - --include elements are merged (deduplicated)
11 // - --title, --scope, --description are updated (if specified)
12 // Returns an error if:
13 // - View key is empty
14 // - Scope element doesn't exist (if specified)
15 // - Include elements don't exist (if specified)
16 func (m *BausteinsichtModel) AddView(key string, view View) error {
17 if key == "" {
18 return fmt.Errorf("view key must not be empty")
19 }
20
21 // Initialize views map if needed
22 if m.Views == nil {
23 m.Views = make(map[string]View)
24 }
25
26 // Validate scope exists (if specified)
27 if view.Scope != "" {
28 if _, err := Resolve(m, view.Scope); err != nil {
29 return fmt.Errorf("scope %q not found: %w", view.Scope, err)
30 }
31 }
32
33 // Validate all include elements exist
34 for _, include := range view.Include {
35 // Skip wildcard patterns — they're validated at render time
36 if include == "" || (len(include) > 0 && include[len(include)-1] == '*') {
37 continue
38 }
39 if _, err := Resolve(m, include); err != nil {
40 return fmt.Errorf("include %q not found: %w", include, err)
41 }
42 }
43
44 // Check if view already exists
45 existingView, exists := m.Views[key]
46 if exists {
47 // Merge: keep existing fields, override with new values, merge include lists
48 if view.Title != "" {
49 existingView.Title = view.Title
50 }
51 if view.Scope != "" {
52 existingView.Scope = view.Scope
53 }
54 if view.Description != "" {
55 existingView.Description = view.Description
56 }
57 if view.Layout != "" {
58 existingView.Layout = view.Layout
59 }
60 // Merge include lists (deduplicate, sort for deterministic output)
61 if len(view.Include) > 0 {
62 includedSet := make(map[string]bool)
63 for _, elem := range existingView.Include {
64 includedSet[elem] = true
65 }
66 for _, elem := range view.Include {
67 includedSet[elem] = true
68 }
69 merged := make([]string, 0, len(includedSet))
70 for elem := range includedSet {
71 merged = append(merged, elem)
72 }
73 sort.Strings(merged)
74 existingView.Include = merged
75 }
76 m.Views[key] = existingView
77 } else {
78 // New view: use as-is
79 m.Views[key] = view
80 }
81
82 return nil
83 }
84
85 // AddSpecificationElement adds an element kind to the specification.
86 // Returns an error if the element kind already exists.
87 func (m *BausteinsichtModel) AddSpecificationElement(key string, kind ElementKind) error {
88 if key == "" {
89 return fmt.Errorf("element key must not be empty")
90 }
91
92 if kind.Notation == "" {
93 return fmt.Errorf("notation must not be empty")
94 }
95
96 // Initialize elements map if needed
97 if m.Specification.Elements == nil {
98 m.Specification.Elements = make(map[string]ElementKind)
99 }
100
101 // Check for duplicate
102 if _, exists := m.Specification.Elements[key]; exists {
103 return fmt.Errorf("element kind %q already exists in specification", key)
104 }
105
106 m.Specification.Elements[key] = kind
107
108 return nil
109 }
110
111 // AddSpecificationRelationship adds a relationship kind to the specification.
112 // Returns an error if the relationship kind already exists.
113 func (m *BausteinsichtModel) AddSpecificationRelationship(key string, kind RelationshipKind) error {
114 if key == "" {
115 return fmt.Errorf("relationship key must not be empty")
116 }
117
118 if kind.Notation == "" {
119 return fmt.Errorf("notation must not be empty")
120 }
121
122 // Check for duplicate
123 if _, exists := m.Specification.Relationships[key]; exists {
124 return fmt.Errorf("relationship kind %q already exists in specification", key)
125 }
126
127 // Initialize relationships map if needed
128 if m.Specification.Relationships == nil {
129 m.Specification.Relationships = make(map[string]RelationshipKind)
130 }
131
132 // Add relationship kind
133 m.Specification.Relationships[key] = kind
134
135 return nil
136 }
137
github.com/docToolchain/Bausteinsicht/internal/model/loader.go 80.9%
1 package model
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strings"
11 )
12
13 // MaxModelFileSize is the maximum allowed model file size (10 MB).
14 const MaxModelFileSize = 10 * 1024 * 1024
15
16 // Load reads a JSONC file, strips comments and trailing commas, and parses it.
17 func Load(path string) (*BausteinsichtModel, error) {
18 info, err := os.Stat(path)
19 if err != nil {
20 return nil, fmt.Errorf("reading %s: %w", path, err)
21 }
22 if info.Size() > MaxModelFileSize {
23 return nil, fmt.Errorf("reading %s: file size %d exceeds limit of %d bytes", path, info.Size(), MaxModelFileSize)
24 }
25 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
26 if err != nil {
27 return nil, fmt.Errorf("reading %s: %w", path, err)
28 }
29 clean := StripJSONC(data)
30
31 // Reject null JSON root — json.Unmarshal silently accepts "null"
32 // and produces a zero-value struct, which passes validation vacuously.
33 trimmed := strings.TrimSpace(string(clean))
34 if trimmed == "null" || trimmed == "" {
35 return nil, fmt.Errorf("parsing %s: model file is empty or contains a null JSON root", path)
36 }
37
38 var m BausteinsichtModel
39 if err := json.Unmarshal(clean, &m); err != nil {
40 return nil, fmt.Errorf("parsing %s: %w", path, err)
41 }
42
43 m.ElementOrder = extractElementOrder(clean)
44
45 return &m, nil
46 }
47
48 // extractElementOrder walks the JSON with a streaming decoder to capture the
49 // definition order of keys in specification.elements. Go maps don't preserve
50 // insertion order, so we need this to determine layer assignment for layout.
51 func extractElementOrder(data []byte) []string {
52 // Parse into a raw structure to navigate to specification.elements,
53 // then re-decode that object with a streaming decoder to get key order.
54 var raw map[string]json.RawMessage
55 if err := json.Unmarshal(data, &raw); err != nil {
56 return nil
57 }
58 specRaw, ok := raw["specification"]
59 if !ok {
60 return nil
61 }
62 var spec map[string]json.RawMessage
63 if err := json.Unmarshal(specRaw, &spec); err != nil {
64 return nil
65 }
66 elemsRaw, ok := spec["elements"]
67 if !ok {
68 return nil
69 }
70
71 // Stream-decode the elements object to capture key order.
72 dec := json.NewDecoder(bytes.NewReader(elemsRaw))
73 tok, err := dec.Token() // consume opening '{'
74 if err != nil {
75 return nil
76 }
77 if d, ok := tok.(json.Delim); !ok || d != '{' {
78 return nil
79 }
80
81 var order []string
82 for dec.More() {
83 tok, err := dec.Token()
84 if err != nil {
85 break
86 }
87 key, ok := tok.(string)
88 if !ok {
89 continue
90 }
91 order = append(order, key)
92 // Skip the value (the element kind object).
93 var discard json.RawMessage
94 if err := dec.Decode(&discard); err != nil {
95 break
96 }
97 }
98 return order
99 }
100
101 // Save marshals the model and atomically writes it to path.
102 // Preserves any preamble (comments/whitespace before the root `{`) from the
103 // existing file so that users' header comments are not lost (#242).
104 // Uses os.CreateTemp for a randomized temp file name to prevent TOCTOU attacks.
105 func Save(path string, model *BausteinsichtModel) error {
106 data, err := json.MarshalIndent(model, "", " ")
107 if err != nil {
108 return fmt.Errorf("marshaling model: %w", err)
109 }
110
111 // Preserve preamble from the existing file (comments before root `{`).
112 if existing, readErr := os.ReadFile(path); readErr == nil { // #nosec G304
113 if preamble := extractPreamble(existing); len(preamble) > 0 {
114 data = append(preamble, data...)
115 }
116 }
117
118 dir := filepath.Dir(path)
119 tmp, err := os.CreateTemp(dir, ".model-tmp-*")
120 if err != nil {
121 return fmt.Errorf("creating temp file: %w", err)
122 }
123 tmpName := tmp.Name()
124 if _, err := tmp.Write(data); err != nil {
125 _ = tmp.Close()
126 _ = os.Remove(tmpName)
127 return fmt.Errorf("writing temp file: %w", err)
128 }
129 if err := tmp.Close(); err != nil {
130 _ = os.Remove(tmpName)
131 return fmt.Errorf("closing temp file: %w", err)
132 }
133 if err := os.Rename(tmpName, path); err != nil {
134 _ = os.Remove(tmpName)
135 return fmt.Errorf("renaming temp file: %w", err)
136 }
137 return nil
138 }
139
140 // AutoDetect finds the first *.jsonc file in dir.
141 func AutoDetect(dir string) (string, error) {
142 matches, err := filepath.Glob(filepath.Join(dir, "*.jsonc"))
143 if err != nil {
144 return "", fmt.Errorf("scanning %s: %w", dir, err)
145 }
146 if len(matches) == 0 {
147 return "", fmt.Errorf("no .jsonc file found in %s", dir)
148 }
149 if len(matches) > 1 {
150 return "", fmt.Errorf("multiple .jsonc files in %s — use --model to select one", dir)
151 }
152 return matches[0], nil
153 }
154
155 // StripJSONC removes single-line comments and trailing commas from JSONC data.
156 // Comments inside strings are preserved.
157 func StripJSONC(data []byte) []byte {
158 // Strip UTF-8 BOM if present.
159 if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
160 data = data[3:]
161 }
162
163 var sb strings.Builder
164 src := string(data)
165 i := 0
166 for i < len(src) {
167 // Handle string literals — skip their content intact
168 if src[i] == '"' {
169 sb.WriteByte(src[i])
170 i++
171 for i < len(src) {
172 if src[i] == '\\' && i+1 < len(src) {
173 sb.WriteByte(src[i])
174 sb.WriteByte(src[i+1])
175 i += 2
176 continue
177 }
178 sb.WriteByte(src[i])
179 if src[i] == '"' {
180 i++
181 break
182 }
183 i++
184 }
185 continue
186 }
187 // Handle block comments
188 if i+1 < len(src) && src[i] == '/' && src[i+1] == '*' {
189 // Trim trailing whitespace before comment if it's only
190 // whitespace since the last newline (i.e., comment on its own line).
191 s := sb.String()
192 lastNL := strings.LastIndex(s, "\n")
193 linePrefix := s[lastNL+1:]
194 if strings.TrimRight(linePrefix, " \t") == "" {
195 sb.Reset()
196 sb.WriteString(s[:lastNL+1])
197 }
198 i += 2
199 for i+1 < len(src) {
200 if src[i] == '*' && src[i+1] == '/' {
201 i += 2
202 break
203 }
204 i++
205 }
206 continue
207 }
208 // Handle single-line comments
209 if i+1 < len(src) && src[i] == '/' && src[i+1] == '/' {
210 // Trim trailing whitespace written before the comment
211 s := sb.String()
212 trimmed := strings.TrimRight(s, " \t")
213 sb.Reset()
214 sb.WriteString(trimmed)
215 for i < len(src) && src[i] != '\n' {
216 i++
217 }
218 continue
219 }
220 sb.WriteByte(src[i])
221 i++
222 }
223
224 // Remove trailing commas before } or ]
225 result := trailingCommaRe.ReplaceAllString(sb.String(), "$1")
226 return []byte(result)
227 }
228
229 // trailingCommaRe matches a comma optionally followed by whitespace before } or ]
230 var trailingCommaRe = regexp.MustCompile(`,(\s*[}\]])`)
231
232 // extractPreamble returns everything before the first `{` in the file.
233 // This captures comment lines and blank lines that precede the root object.
234 // Returns nil if there is no preamble or the file starts with `{`.
235 func extractPreamble(data []byte) []byte {
236 for i, b := range data {
237 if b == '{' {
238 if i == 0 {
239 return nil
240 }
241 return data[:i]
242 }
243 }
244 return nil
245 }
246
github.com/docToolchain/Bausteinsicht/internal/model/patch.go 64.7%
1 package model
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 )
8
9 // PatchOp describes a single value replacement in a JSONC file.
10 type PatchOp struct {
11 Path []string // JSON path segments, e.g., ["model", "api", "technology"]
12 Value string // New JSON-encoded value, e.g., `"Go 1.24"`
13 }
14
15 // PatchSave reads the JSONC file at path, applies each PatchOp, and writes
16 // the result back atomically. Comments, formatting, and key ordering are
17 // preserved because only the target values are replaced in the raw text.
18 func PatchSave(path string, ops []PatchOp) error {
19 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
20 if err != nil {
21 return fmt.Errorf("reading %s: %w", path, err)
22 }
23
24 for _, op := range ops {
25 data, err = PatchValue(data, op.Path, op.Value)
26 if err != nil {
27 return fmt.Errorf("patching %v: %w", op.Path, err)
28 }
29 }
30
31 dir := filepath.Dir(path)
32 tmp, err := os.CreateTemp(dir, ".model-tmp-*")
33 if err != nil {
34 return fmt.Errorf("creating temp file: %w", err)
35 }
36 tmpName := tmp.Name()
37 if _, err := tmp.Write(data); err != nil {
38 _ = tmp.Close()
39 _ = os.Remove(tmpName)
40 return fmt.Errorf("writing temp file: %w", err)
41 }
42 if err := tmp.Close(); err != nil {
43 _ = os.Remove(tmpName)
44 return fmt.Errorf("closing temp file: %w", err)
45 }
46 if err := os.Rename(tmpName, path); err != nil {
47 _ = os.Remove(tmpName)
48 return fmt.Errorf("renaming temp file: %w", err)
49 }
50 return nil
51 }
52
53 // PatchInsert reads the JSONC file at path, applies a raw data transformation,
54 // and writes the result back atomically. Used by InsertObjectEntry and
55 // AppendArrayEntry for comment-preserving insertions.
56 func PatchInsert(path string, transform func([]byte) ([]byte, error)) error {
57 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
58 if err != nil {
59 return fmt.Errorf("reading %s: %w", path, err)
60 }
61
62 data, err = transform(data)
63 if err != nil {
64 return err
65 }
66
67 dir := filepath.Dir(path)
68 tmp, err := os.CreateTemp(dir, ".model-tmp-*")
69 if err != nil {
70 return fmt.Errorf("creating temp file: %w", err)
71 }
72 tmpName := tmp.Name()
73 if _, err := tmp.Write(data); err != nil {
74 _ = tmp.Close()
75 _ = os.Remove(tmpName)
76 return fmt.Errorf("writing temp file: %w", err)
77 }
78 if err := tmp.Close(); err != nil {
79 _ = os.Remove(tmpName)
80 return fmt.Errorf("closing temp file: %w", err)
81 }
82 if err := os.Rename(tmpName, path); err != nil {
83 _ = os.Remove(tmpName)
84 return fmt.Errorf("renaming temp file: %w", err)
85 }
86 return nil
87 }
88
89 // PatchValue finds the JSON value at path in JSONC data and replaces it with
90 // newValue. The rest of the document (comments, whitespace, key ordering)
91 // is preserved. Returns the patched data or an error if the path is not found.
92 func PatchValue(data []byte, path []string, newValue string) ([]byte, error) {
93 if len(path) == 0 {
94 return nil, fmt.Errorf("empty path")
95 }
96
97 start, end, err := findValueRange(data, path)
98 if err != nil {
99 return nil, err
100 }
101
102 result := make([]byte, 0, len(data)+len(newValue))
103 result = append(result, data[:start]...)
104 result = append(result, []byte(newValue)...)
105 result = append(result, data[end:]...)
106 return result, nil
107 }
108
109 // findValueRange locates the byte range [start, end) of the JSON value at
110 // the given path within JSONC data. It handles single-line comments and
111 // string escaping.
112 func findValueRange(data []byte, path []string) (int, int, error) {
113 i := 0
114 n := len(data)
115
116 for depth := 0; depth < len(path); depth++ {
117 key := path[depth]
118 // Find the opening { of the current object.
119 i = skipToChar(data, i, n, '{')
120 if i >= n {
121 return 0, 0, fmt.Errorf("path %v: expected object, not found", path[:depth+1])
122 }
123 i++ // skip '{'
124
125 // Find the matching key within this object.
126 found := false
127 for i < n {
128 i = skipWhitespaceAndComments(data, i, n)
129 if i >= n {
130 break
131 }
132 if data[i] == '}' {
133 break
134 }
135
136 // Read the key.
137 if data[i] != '"' {
138 return 0, 0, fmt.Errorf("expected '\"' at offset %d", i)
139 }
140 keyStart := i
141 keyEnd := skipString(data, i, n)
142 currentKey := string(data[keyStart+1 : keyEnd-1]) // strip quotes
143 i = keyEnd
144
145 // Skip colon.
146 i = skipWhitespaceAndComments(data, i, n)
147 if i >= n || data[i] != ':' {
148 return 0, 0, fmt.Errorf("expected ':' after key %q at offset %d", currentKey, i)
149 }
150 i++ // skip ':'
151 i = skipWhitespaceAndComments(data, i, n)
152
153 if currentKey == key {
154 if depth == len(path)-1 {
155 // This is the target value — find its extent.
156 valStart := i
157 valEnd := skipValue(data, i, n)
158 return valStart, valEnd, nil
159 }
160 // Need to descend into this value (next iteration).
161 found = true
162 break
163 }
164
165 // Skip the value to move to the next key.
166 i = skipValue(data, i, n)
167
168 // Skip optional comma.
169 i = skipWhitespaceAndComments(data, i, n)
170 if i < n && data[i] == ',' {
171 i++
172 }
173 }
174 if !found && depth < len(path)-1 {
175 return 0, 0, fmt.Errorf("key %q not found in path %v", key, path[:depth+1])
176 }
177 }
178
179 return 0, 0, fmt.Errorf("path %v not found", path)
180 }
181
182 // skipWhitespaceAndComments advances past whitespace and // comments.
183 func skipWhitespaceAndComments(data []byte, i, n int) int {
184 for i < n {
185 if data[i] == ' ' || data[i] == '\t' || data[i] == '\n' || data[i] == '\r' {
186 i++
187 continue
188 }
189 if i+1 < n && data[i] == '/' && data[i+1] == '/' {
190 // Skip to end of line.
191 for i < n && data[i] != '\n' {
192 i++
193 }
194 continue
195 }
196 if i+1 < n && data[i] == '/' && data[i+1] == '*' {
197 i += 2
198 for i+1 < n {
199 if data[i] == '*' && data[i+1] == '/' {
200 i += 2
201 break
202 }
203 i++
204 }
205 continue
206 }
207 break
208 }
209 return i
210 }
211
212 // skipString skips a JSON string starting at data[i] (which must be '"')
213 // and returns the index after the closing quote.
214 func skipString(data []byte, i, n int) int {
215 i++ // skip opening '"'
216 for i < n {
217 if data[i] == '\\' && i+1 < n {
218 i += 2
219 continue
220 }
221 if data[i] == '"' {
222 return i + 1
223 }
224 i++
225 }
226 return i
227 }
228
229 // skipValue skips a complete JSON value (string, number, object, array, bool, null).
230 func skipValue(data []byte, i, n int) int {
231 if i >= n {
232 return i
233 }
234 switch data[i] {
235 case '"':
236 return skipString(data, i, n)
237 case '{':
238 return skipBraced(data, i, n, '{', '}')
239 case '[':
240 return skipBraced(data, i, n, '[', ']')
241 default:
242 // Number, bool, null — skip until delimiter.
243 for i < n {
244 c := data[i]
245 if c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || c == '\n' || c == '\r' {
246 break
247 }
248 // Also stop at // or /* comment.
249 if c == '/' && i+1 < n && (data[i+1] == '/' || data[i+1] == '*') {
250 break
251 }
252 i++
253 }
254 return i
255 }
256 }
257
258 // skipBraced skips a matched pair of braces/brackets, handling strings and
259 // comments within.
260 func skipBraced(data []byte, i, n int, open, close byte) int {
261 depth := 0
262 for i < n {
263 c := data[i]
264 if c == '"' {
265 i = skipString(data, i, n)
266 continue
267 }
268 if c == '/' && i+1 < n && data[i+1] == '/' {
269 for i < n && data[i] != '\n' {
270 i++
271 }
272 continue
273 }
274 if c == '/' && i+1 < n && data[i+1] == '*' {
275 i += 2
276 for i+1 < n {
277 if data[i] == '*' && data[i+1] == '/' {
278 i += 2
279 break
280 }
281 i++
282 }
283 continue
284 }
285 switch c {
286 case open:
287 depth++
288 case close:
289 depth--
290 if depth == 0 {
291 return i + 1
292 }
293 }
294 i++
295 }
296 return i
297 }
298
299 // InsertObjectEntry inserts a new key-value pair into the object at the given
300 // path. The value is inserted before the closing '}' of the target object.
301 // Comments and formatting are preserved.
302 func InsertObjectEntry(data []byte, objectPath []string, key, valueJSON string) ([]byte, error) {
303 // Find the object's value range.
304 start, end, err := findValueRange(data, objectPath)
305 if err != nil {
306 return nil, err
307 }
308 if data[start] != '{' {
309 return nil, fmt.Errorf("value at path %v is not an object", objectPath)
310 }
311
312 // Find the closing '}' of this object (end-1 since findValueRange returns after it).
313 closeBrace := end - 1
314 for closeBrace > start && data[closeBrace] != '}' {
315 closeBrace--
316 }
317
318 // Detect indentation from the closing brace line.
319 indent := detectIndent(data, closeBrace)
320
321 // Check if the object has existing entries by scanning for non-whitespace
322 // between '{' and '}'.
323 hasEntries := false
324 scan := start + 1
325 scan = skipWhitespaceAndComments(data, scan, len(data))
326 if scan < closeBrace {
327 hasEntries = true
328 }
329
330 // Build the insertion text.
331 var insertion string
332 if hasEntries {
333 // Find the last non-whitespace/comment byte before closeBrace to
334 // append a comma after the previous entry (not before the new one).
335 lastContent := closeBrace - 1
336 for lastContent > start && (data[lastContent] == ' ' || data[lastContent] == '\t' || data[lastContent] == '\n' || data[lastContent] == '\r') {
337 lastContent--
338 }
339 // Insert comma after last entry, then newline + new entry.
340 comma := ""
341 if lastContent > start && data[lastContent] != ',' {
342 comma = ","
343 }
344 insertion = fmt.Sprintf("%s\n%s %q: %s\n%s", comma, indent, key, valueJSON, indent)
345 // Replace from after last content to closing brace (inclusive) with:
346 // comma + \n + indent + new entry + \n + indent + }
347 result := make([]byte, 0, len(data)+len(insertion))
348 result = append(result, data[:lastContent+1]...)
349 result = append(result, []byte(insertion)...)
350 result = append(result, data[closeBrace:]...)
351 return result, nil
352 }
353
354 insertion = fmt.Sprintf("\n%s %q: %s\n%s", indent, key, valueJSON, indent)
355 result := make([]byte, 0, len(data)+len(insertion))
356 result = append(result, data[:closeBrace]...)
357 result = append(result, []byte(insertion)...)
358 result = append(result, data[closeBrace:]...)
359 return result, nil
360 }
361
362 // AppendArrayEntry appends a new value to the array at the given path.
363 // The value is inserted before the closing ']'. Comments and formatting
364 // are preserved.
365 func AppendArrayEntry(data []byte, arrayPath []string, valueJSON string) ([]byte, error) {
366 start, end, err := findValueRange(data, arrayPath)
367 if err != nil {
368 return nil, err
369 }
370 if data[start] != '[' {
371 return nil, fmt.Errorf("value at path %v is not an array", arrayPath)
372 }
373
374 // Find the closing ']'.
375 closeBracket := end - 1
376 for closeBracket > start && data[closeBracket] != ']' {
377 closeBracket--
378 }
379
380 indent := detectIndent(data, closeBracket)
381
382 // Check if the array has existing entries.
383 hasEntries := false
384 scan := start + 1
385 scan = skipWhitespaceAndComments(data, scan, len(data))
386 if scan < closeBracket {
387 hasEntries = true
388 }
389
390 var insertion string
391 if hasEntries {
392 // Find the last non-whitespace byte before closeBracket to
393 // append comma after the previous entry.
394 lastContent := closeBracket - 1
395 for lastContent > start && (data[lastContent] == ' ' || data[lastContent] == '\t' || data[lastContent] == '\n' || data[lastContent] == '\r') {
396 lastContent--
397 }
398 comma := ""
399 if lastContent > start && data[lastContent] != ',' {
400 comma = ","
401 }
402 insertion = fmt.Sprintf("%s\n%s %s\n%s", comma, indent, valueJSON, indent)
403 result := make([]byte, 0, len(data)+len(insertion))
404 result = append(result, data[:lastContent+1]...)
405 result = append(result, []byte(insertion)...)
406 result = append(result, data[closeBracket:]...)
407 return result, nil
408 }
409
410 insertion = fmt.Sprintf("\n%s %s\n%s", indent, valueJSON, indent)
411 result := make([]byte, 0, len(data)+len(insertion))
412 result = append(result, data[:closeBracket]...)
413 result = append(result, []byte(insertion)...)
414 result = append(result, data[closeBracket:]...)
415 return result, nil
416 }
417
418 // detectIndent returns the whitespace prefix of the line containing position pos.
419 func detectIndent(data []byte, pos int) string {
420 lineStart := pos
421 for lineStart > 0 && data[lineStart-1] != '\n' {
422 lineStart--
423 }
424 indent := ""
425 for i := lineStart; i < pos; i++ {
426 if data[i] == ' ' || data[i] == '\t' {
427 indent += string(data[i])
428 } else {
429 break
430 }
431 }
432 return indent
433 }
434
435 // skipToChar advances to the first occurrence of ch, skipping strings and comments.
436 func skipToChar(data []byte, i, n int, ch byte) int {
437 for i < n {
438 if data[i] == '"' {
439 i = skipString(data, i, n)
440 continue
441 }
442 if data[i] == '/' && i+1 < n && data[i+1] == '/' {
443 for i < n && data[i] != '\n' {
444 i++
445 }
446 continue
447 }
448 if data[i] == '/' && i+1 < n && data[i+1] == '*' {
449 i += 2
450 for i+1 < n {
451 if data[i] == '*' && data[i+1] == '/' {
452 i += 2
453 break
454 }
455 i++
456 }
457 continue
458 }
459 if data[i] == ch {
460 return i
461 }
462 i++
463 }
464 return i
465 }
466
github.com/docToolchain/Bausteinsicht/internal/model/patterns.go 0.0%
1 package model
2
3 import (
4 "strings"
5 "unicode"
6 )
7
8 // ExpandPattern takes a pattern definition and applies variable substitution
9 // to generate concrete elements and relationships.
10 // baseID is used for {base}, title is used for {Title} and {BASE}.
11 func ExpandPattern(pattern PatternDefinition, baseID, title string) ([]Element, []Relationship, error) {
12 if title == "" {
13 title = baseID
14 }
15
16 vars := map[string]string{
17 "{base}": baseID,
18 "{Title}": toTitleCase(title),
19 "{BASE}": strings.ToUpper(baseID),
20 }
21
22 // Expand elements (including nested children)
23 elements := make([]Element, len(pattern.Elements))
24 for i, tmpl := range pattern.Elements {
25 elements[i] = expandPatternElement(tmpl, vars)
26 }
27
28 // Expand relationships
29 relationships := make([]Relationship, len(pattern.Relationships))
30 for i, tmpl := range pattern.Relationships {
31 relationships[i] = Relationship{
32 From: replaceVars(tmpl.From, vars),
33 To: replaceVars(tmpl.To, vars),
34 Label: replaceVars(tmpl.Label, vars),
35 Kind: tmpl.Kind,
36 Description: replaceVars(tmpl.Description, vars),
37 }
38 }
39
40 return elements, relationships, nil
41 }
42
43 // expandPatternElement recursively expands an element template, including children
44 func expandPatternElement(tmpl PatternElement, vars map[string]string) Element {
45 elem := Element{
46 Kind: tmpl.Kind,
47 Title: replaceVars(tmpl.Title, vars),
48 Description: replaceVars(tmpl.Description, vars),
49 Technology: replaceVars(tmpl.Technology, vars),
50 Tags: tmpl.Tags,
51 }
52
53 // Recursively expand children if present
54 if len(tmpl.Children) > 0 {
55 elem.Children = make(map[string]Element, len(tmpl.Children))
56 for _, childTmpl := range tmpl.Children {
57 childID := replaceVars(childTmpl.ID, vars)
58 elem.Children[childID] = expandPatternElement(childTmpl, vars)
59 }
60 }
61
62 return elem
63 }
64
65 // ExpandPatternIDs applies variable substitution to element and relationship IDs
66 func ExpandPatternIDs(pattern PatternDefinition, baseID string) ([]string, []string, error) {
67 vars := map[string]string{
68 "{base}": baseID,
69 "{BASE}": strings.ToUpper(baseID),
70 }
71
72 var elemIDs []string
73 for _, tmpl := range pattern.Elements {
74 elemIDs = append(elemIDs, expandPatternElementIDs(tmpl, vars)...)
75 }
76
77 relIDs := make([]string, len(pattern.Relationships))
78 for i, tmpl := range pattern.Relationships {
79 relIDs[i] = replaceVars(tmpl.ID, vars)
80 }
81
82 return elemIDs, relIDs, nil
83 }
84
85 // expandPatternElementIDs recursively extracts all element IDs from a pattern element
86 func expandPatternElementIDs(tmpl PatternElement, vars map[string]string) []string {
87 ids := []string{replaceVars(tmpl.ID, vars)}
88 for _, childTmpl := range tmpl.Children {
89 ids = append(ids, expandPatternElementIDs(childTmpl, vars)...)
90 }
91 return ids
92 }
93
94 // replaceVars substitutes template variables in a string
95 func replaceVars(s string, vars map[string]string) string {
96 result := s
97 for k, v := range vars {
98 result = strings.ReplaceAll(result, k, v)
99 }
100 return result
101 }
102
103 // toTitleCase converts "order" to "Order"
104 func toTitleCase(s string) string {
105 if len(s) == 0 {
106 return s
107 }
108 runes := []rune(s)
109 runes[0] = unicode.ToUpper(runes[0])
110 return string(runes)
111 }
112
113 // CheckPatternConflicts checks if any generated IDs already exist in the model
114 func CheckPatternConflicts(m *BausteinsichtModel, pattern PatternDefinition, baseID string) ([]string, error) {
115 elemIDs, _, err := ExpandPatternIDs(pattern, baseID)
116 if err != nil {
117 return nil, err
118 }
119
120 flat, _ := FlattenElements(m)
121 var conflicts []string
122
123 for _, id := range elemIDs {
124 if _, exists := flat[id]; exists {
125 conflicts = append(conflicts, id)
126 }
127 }
128
129 return conflicts, nil
130 }
131
github.com/docToolchain/Bausteinsicht/internal/model/resolve.go 49.5%
1 package model
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7 )
8
9 // MaxElementDepth is the maximum nesting depth for elements.
10 // This prevents stack overflow from deeply nested or circular model definitions.
11 const MaxElementDepth = 50
12
13 // Resolve traverses the model hierarchy using dot notation (e.g., "webshop.api.auth").
14 func Resolve(m *BausteinsichtModel, id string) (*Element, error) {
15 parts := strings.Split(id, ".")
16 root := parts[0]
17
18 elem, ok := m.Model[root]
19 if !ok {
20 return nil, fmt.Errorf("element %q not found", id)
21 }
22
23 for _, part := range parts[1:] {
24 if elem.Children == nil {
25 return nil, fmt.Errorf("element %q not found: no children at this level", id)
26 }
27 child, ok := elem.Children[part]
28 if !ok {
29 return nil, fmt.Errorf("element %q not found", id)
30 }
31 elem = child
32 }
33
34 return &elem, nil
35 }
36
37 // flattenInto recursively adds elements to the map with their full dot-notation path.
38 func flattenInto(children map[string]Element, prefix string, depth int, result map[string]*Element) error {
39 if depth > MaxElementDepth {
40 return fmt.Errorf("element nesting exceeds maximum depth of %d at %q", MaxElementDepth, strings.TrimSuffix(prefix, "."))
41 }
42 for key, elem := range children {
43 fullID := prefix + key
44 e := elem
45 result[fullID] = &e
46 if elem.Children != nil {
47 if err := flattenInto(elem.Children, fullID+".", depth+1, result); err != nil {
48 return err
49 }
50 }
51 }
52 return nil
53 }
54
55 // FlattenElements returns all elements keyed by full dot-notation ID path.
56 // Returns an error if the element hierarchy exceeds MaxElementDepth.
57 func FlattenElements(m *BausteinsichtModel) (map[string]*Element, error) {
58 result := make(map[string]*Element)
59 if err := flattenInto(m.Model, "", 1, result); err != nil {
60 return nil, err
61 }
62 return result, nil
63 }
64
65 // MatchPattern matches elements in the flat map against a pattern.
66 // Supported patterns:
67 // - "id" — exact match
68 // - "prefix.*" — direct children of prefix (one level deep)
69 // - "prefix.**" — all descendants of prefix (recursive)
70 // - "*" — all top-level elements (no dots in ID)
71 // - "**" — all elements
72 func MatchPattern(flatMap map[string]*Element, pattern string) []string {
73 var matches []string
74
75 switch {
76 case pattern == "**":
77 // Match all elements.
78 for id := range flatMap {
79 matches = append(matches, id)
80 }
81
82 case pattern == "*":
83 // Match top-level elements only (no dots in ID).
84 for id := range flatMap {
85 if !strings.Contains(id, ".") {
86 matches = append(matches, id)
87 }
88 }
89
90 case strings.HasSuffix(pattern, ".**"):
91 // Match all descendants of prefix (recursive).
92 prefix := strings.TrimSuffix(pattern, "**")
93 for id := range flatMap {
94 if strings.HasPrefix(id, prefix) {
95 matches = append(matches, id)
96 }
97 }
98
99 case strings.HasSuffix(pattern, ".*"):
100 // Match direct children only (one level deep).
101 prefix := strings.TrimSuffix(pattern, "*")
102 depth := strings.Count(prefix, ".")
103 for id := range flatMap {
104 if !strings.HasPrefix(id, prefix) {
105 continue
106 }
107 rest := id[len(prefix):]
108 if !strings.Contains(rest, ".") && strings.Count(id, ".") == depth {
109 matches = append(matches, id)
110 }
111 }
112
113 default:
114 // Exact match.
115 if _, ok := flatMap[pattern]; ok {
116 matches = append(matches, pattern)
117 }
118 }
119
120 return matches
121 }
122
123 // ResolveView resolves view includes/excludes to a list of element IDs.
124 // Starts with include patterns, then removes exclude patterns.
125 func ResolveView(m *BausteinsichtModel, view *View) ([]string, error) {
126 if len(view.Include) == 0 {
127 return []string{}, nil
128 }
129
130 flatMap, err := FlattenElements(m)
131 if err != nil {
132 return nil, err
133 }
134
135 included := make(map[string]bool)
136 for _, pattern := range view.Include {
137 for _, id := range MatchPattern(flatMap, pattern) {
138 included[id] = true
139 }
140 }
141
142 for _, pattern := range view.Exclude {
143 for _, id := range MatchPattern(flatMap, pattern) {
144 delete(included, id)
145 }
146 }
147
148 result := make([]string, 0, len(included))
149 for id := range included {
150 result = append(result, id)
151 }
152 sort.Strings(result)
153 return result, nil
154 }
155
156 // FilterElementsByTags applies tag-based filtering to a flat element map.
157 // It includes elements that have ALL FilterTags and excludes elements with ANY ExcludeTags.
158 func FilterElementsByTags(elements map[string]*Element, filterTags, excludeTags []string) map[string]*Element {
159 // Quick exit: no filtering
160 if len(filterTags) == 0 && len(excludeTags) == 0 {
161 return elements
162 }
163
164 result := make(map[string]*Element)
165 for id, elem := range elements {
166 // Check exclude first: if ANY exclude-tag matches, skip
167 excluded := false
168 for _, excludeTag := range excludeTags {
169 for _, elemTag := range elem.Tags {
170 if elemTag == excludeTag {
171 excluded = true
172 break
173 }
174 }
175 if excluded {
176 break
177 }
178 }
179 if excluded {
180 continue
181 }
182
183 // Check include: if ANY filter-tags are specified, element must have ALL of them
184 if len(filterTags) > 0 {
185 hasAllFilterTags := true
186 for _, filterTag := range filterTags {
187 found := false
188 for _, elemTag := range elem.Tags {
189 if elemTag == filterTag {
190 found = true
191 break
192 }
193 }
194 if !found {
195 hasAllFilterTags = false
196 break
197 }
198 }
199 if !hasAllFilterTags {
200 continue
201 }
202 }
203
204 result[id] = elem
205 }
206
207 return result
208 }
209
github.com/docToolchain/Bausteinsicht/internal/model/types.go 61.5%
1 package model
2
3 // Element lifecycle status values
4 const (
5 StatusProposed = "proposed"
6 StatusDesign = "design"
7 StatusImplementing = "implementation"
8 StatusDeployed = "deployed"
9 StatusDeprecated = "deprecated"
10 StatusArchived = "archived"
11 )
12
13 var ValidStatuses = []string{
14 StatusProposed,
15 StatusDesign,
16 StatusImplementing,
17 StatusDeployed,
18 StatusDeprecated,
19 StatusArchived,
20 }
21
22 // StatusColor returns the draw.io badge color for a given status
23 func StatusColor(status string) string {
24 switch status {
25 case StatusProposed:
26 return "#fff2cc" // yellow
27 case StatusDesign:
28 return "#dae8fc" // blue
29 case StatusImplementing:
30 return "#ffe6cc" // orange
31 case StatusDeployed:
32 return "#d5e8d4" // green
33 case StatusDeprecated:
34 return "#f8cecc" // red
35 case StatusArchived:
36 return "#f5f5f5" // grey
37 default:
38 return "#ffffff" // white
39 }
40 }
41
42 // DecisionBadgeColor returns the draw.io badge color for a given ADR status
43 func DecisionBadgeColor(status ADRStatus) string {
44 switch status {
45 case ADRActive:
46 return "#0066cc" // blue
47 case ADRSuperseded, ADRDeprecated:
48 return "#999999" // grey
49 case ADRProposed:
50 return "#ffcc00" // yellow
51 default:
52 return "#ffffff" // white
53 }
54 }
55
56 // Relationship cardinality values
57 const (
58 CardinalityOneToOne = "1:1"
59 CardinalityOneToMany = "1:N"
60 CardinalityManyToMany = "N:N"
61 )
62
63 var ValidCardinalities = []string{
64 CardinalityOneToOne,
65 CardinalityOneToMany,
66 CardinalityManyToMany,
67 }
68
69 // Data flow annotation values
70 const (
71 DataFlowSync = "sync"
72 DataFlowAsync = "async"
73 DataFlowRequestResponse = "request/response"
74 DataFlowPublishSubscribe = "publish/subscribe"
75 )
76
77 var ValidDataFlows = []string{
78 DataFlowSync,
79 DataFlowAsync,
80 DataFlowRequestResponse,
81 DataFlowPublishSubscribe,
82 }
83
84 // Config holds top-level configuration for diagram generation.
85 type Config struct {
86 Metadata *bool `json:"metadata,omitempty"`
87 Legend *bool `json:"legend,omitempty"`
88 Author string `json:"author,omitempty"`
89 Repo string `json:"repo,omitempty"`
90 }
91
92 // ModelSnapshot represents a snapshot of architecture (as-is or to-be)
93 type ModelSnapshot struct {
94 Elements map[string]Element `json:"elements"`
95 Relationships []Relationship `json:"relationships"`
96 }
97
98 // BausteinsichtModel is the top-level model file
99 type BausteinsichtModel struct {
100 Schema string `json:"$schema,omitempty"`
101 Config Config `json:"config,omitempty"`
102 Specification Specification `json:"specification"`
103 Model map[string]Element `json:"model"`
104 Relationships []Relationship `json:"relationships"`
105 Views map[string]View `json:"views"`
106 DynamicViews []DynamicView `json:"dynamicViews,omitempty"`
107 Constraints []Constraint `json:"constraints,omitempty"`
108 AsIs *ModelSnapshot `json:"asIs,omitempty"`
109 ToBe *ModelSnapshot `json:"toBe,omitempty"`
110
111 // ElementOrder stores the definition order of element kinds from
112 // specification.elements. Used by the layout engine for layer assignment.
113 ElementOrder []string `json:"-"`
114 }
115
116 // StepType describes how a sequence step arrow is rendered.
117 type StepType string
118
119 const (
120 StepSync StepType = "sync"
121 StepAsync StepType = "async"
122 StepReturn StepType = "return"
123 )
124
125 // SequenceStep is one message/call in a dynamic view.
126 type SequenceStep struct {
127 Index int `json:"index"`
128 From string `json:"from"`
129 To string `json:"to"`
130 Label string `json:"label"`
131 Type StepType `json:"type,omitempty"`
132 }
133
134 // DynamicView describes a sequence of interactions between elements.
135 type DynamicView struct {
136 Key string `json:"key"`
137 Title string `json:"title"`
138 Description string `json:"description,omitempty"`
139 Steps []SequenceStep `json:"steps"`
140 }
141
142 // Constraint defines an architectural rule that can be enforced via `bausteinsicht lint`.
143 type Constraint struct {
144 ID string `json:"id"`
145 Description string `json:"description"`
146 Rule string `json:"rule"`
147
148 // no-relationship / allowed-relationship
149 FromKind string `json:"from-kind,omitempty"`
150 ToKind string `json:"to-kind,omitempty"`
151 FromKinds []string `json:"from-kinds,omitempty"`
152
153 // required-field
154 ElementKind string `json:"element-kind,omitempty"`
155 Field string `json:"field,omitempty"`
156
157 // max-depth
158 Max int `json:"max,omitempty"`
159
160 // technology-allowed
161 Technologies []string `json:"technologies,omitempty"`
162 }
163
164 // TagDefinition describes a tag with optional styling for draw.io rendering.
165 type TagDefinition struct {
166 ID string `json:"id"`
167 Description string `json:"description,omitempty"`
168 Style map[string]interface{} `json:"style,omitempty"`
169 }
170
171 // PatternElement describes an element template in a pattern
172 type PatternElement struct {
173 ID string `json:"id"`
174 Kind string `json:"kind"`
175 Title string `json:"title"`
176 Technology string `json:"technology,omitempty"`
177 Description string `json:"description,omitempty"`
178 Tags []string `json:"tags,omitempty"`
179 Children []PatternElement `json:"children,omitempty"`
180 }
181
182 // PatternRelationship describes a relationship template in a pattern
183 type PatternRelationship struct {
184 ID string `json:"id"`
185 From string `json:"from"`
186 To string `json:"to"`
187 Label string `json:"label,omitempty"`
188 Kind string `json:"kind,omitempty"`
189 Description string `json:"description,omitempty"`
190 }
191
192 // PatternDefinition describes a reusable topology pattern
193 type PatternDefinition struct {
194 Description string `json:"description,omitempty"`
195 Elements []PatternElement `json:"elements"`
196 Relationships []PatternRelationship `json:"relationships,omitempty"`
197 }
198
199 // ADRStatus describes the status of an architecture decision record
200 type ADRStatus string
201
202 const (
203 ADRProposed ADRStatus = "proposed"
204 ADRActive ADRStatus = "active"
205 ADRDeprecated ADRStatus = "deprecated"
206 ADRSuperseded ADRStatus = "superseded"
207 )
208
209 // DecisionRecord represents an architecture decision record (ADR)
210 type DecisionRecord struct {
211 ID string `json:"id"`
212 Title string `json:"title"`
213 Status ADRStatus `json:"status"`
214 Date string `json:"date,omitempty"`
215 FilePath string `json:"file,omitempty"`
216 }
217
218 type Specification struct {
219 Elements map[string]ElementKind `json:"elements"`
220 Relationships map[string]RelationshipKind `json:"relationships,omitempty"`
221 Tags []TagDefinition `json:"tags,omitempty"`
222 Patterns map[string]PatternDefinition `json:"patterns,omitempty"`
223 Decisions []DecisionRecord `json:"decisions,omitempty"`
224 }
225
226 type ElementKind struct {
227 Notation string `json:"notation"`
228 Description string `json:"description,omitempty"`
229 Container bool `json:"container,omitempty"`
230 }
231
232 type RelationshipKind struct {
233 Notation string `json:"notation"`
234 Dashed bool `json:"dashed,omitempty"`
235 }
236
237 type Element struct {
238 Kind string `json:"kind"`
239 Title string `json:"title"`
240 Description string `json:"description,omitempty"`
241 Technology string `json:"technology,omitempty"`
242 Tags []string `json:"tags,omitempty"`
243 Status string `json:"status,omitempty"`
244 Decisions []string `json:"decisions,omitempty"`
245 Children map[string]Element `json:"children,omitempty"`
246 Metadata map[string]string `json:"metadata,omitempty"`
247 }
248
249 type Relationship struct {
250 From string `json:"from"`
251 To string `json:"to"`
252 Label string `json:"label,omitempty"`
253 Kind string `json:"kind,omitempty"`
254 Description string `json:"description,omitempty"`
255 Decisions []string `json:"decisions,omitempty"`
256 Cardinality string `json:"cardinality,omitempty"`
257 DataFlow string `json:"dataFlow,omitempty"`
258 }
259
260 type View struct {
261 Title string `json:"title"`
262 Scope string `json:"scope,omitempty"`
263 Include []string `json:"include,omitempty"`
264 Exclude []string `json:"exclude,omitempty"`
265 FilterTags []string `json:"filter-tags,omitempty"` // Include only elements with ALL of these tags
266 ExcludeTags []string `json:"exclude-tags,omitempty"` // Exclude elements with ANY of these tags
267 Description string `json:"description,omitempty"`
268 Layout string `json:"layout,omitempty"`
269 }
270
github.com/docToolchain/Bausteinsicht/internal/model/validate.go 84.0%
1 package model
2
3 import (
4 "fmt"
5 "strings"
6 )
7
8 // ValidationError describes a single validation problem with its model path.
9 type ValidationError struct {
10 Path string
11 Message string
12 }
13
14 func (e ValidationError) Error() string {
15 return fmt.Sprintf("%s: %s", e.Path, e.Message)
16 }
17
18 // ValidationWarning describes a non-fatal issue with the model.
19 type ValidationWarning struct {
20 Path string
21 Message string
22 }
23
24 // ValidationResult holds both errors and warnings from validation.
25 type ValidationResult struct {
26 Errors []ValidationError
27 Warnings []ValidationWarning
28 }
29
30 // Validate checks the model for consistency and returns all found errors.
31 func Validate(m *BausteinsichtModel) []ValidationError {
32 result := ValidateWithWarnings(m)
33 return result.Errors
34 }
35
36 // ValidateWithWarnings checks the model for consistency and returns errors and warnings.
37 func ValidateWithWarnings(m *BausteinsichtModel) ValidationResult {
38 var result ValidationResult
39 result.Errors = append(result.Errors, validateElements(m)...)
40 result.Errors = append(result.Errors, validateRelationships(m)...)
41 result.Errors = append(result.Errors, validateViews(m)...)
42 result.Errors = append(result.Errors, validateDynamicViews(m)...)
43 result.Errors = append(result.Errors, validatePatterns(m)...)
44 result.Errors = append(result.Errors, validateDecisions(m)...)
45 result.Warnings = append(result.Warnings, validateEmptyModel(m)...)
46 result.Warnings = append(result.Warnings, validateLifecycleStatus(m)...)
47 result.Warnings = append(result.Warnings, validateOrphanDecisions(m)...)
48 result.Warnings = append(result.Warnings, validateSupersededDecisions(m)...)
49 return result
50 }
51
52 // validateEmptyModel checks for models with no specification or no elements.
53 func validateEmptyModel(m *BausteinsichtModel) []ValidationWarning {
54 var warnings []ValidationWarning
55 if len(m.Specification.Elements) == 0 {
56 warnings = append(warnings, ValidationWarning{
57 Path: "specification",
58 Message: "no element kinds defined in specification",
59 })
60 }
61 if len(m.Model) == 0 {
62 warnings = append(warnings, ValidationWarning{
63 Path: "model",
64 Message: "model is empty (no elements defined)",
65 })
66 }
67 return warnings
68 }
69
70 func validateElements(m *BausteinsichtModel) []ValidationError {
71 var errs []ValidationError
72 for id, elem := range m.Model {
73 if err := validateElementID(id); err != nil {
74 errs = append(errs, ValidationError{Path: "model." + id, Message: err.Error()})
75 }
76 errs = append(errs, validateElement(m, "model."+id, elem, 1)...)
77 }
78 return errs
79 }
80
81 func validateElement(m *BausteinsichtModel, path string, elem Element, depth int) []ValidationError {
82 var errs []ValidationError
83
84 if depth > MaxElementDepth {
85 errs = append(errs, ValidationError{
86 Path: path,
87 Message: fmt.Sprintf("element nesting exceeds maximum depth of %d", MaxElementDepth),
88 })
89 return errs
90 }
91
92 if elem.Kind == "" {
93 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"kind\""})
94 } else {
95 kindDef, known := m.Specification.Elements[elem.Kind]
96 if !known {
97 errs = append(errs, ValidationError{
98 Path: path,
99 Message: fmt.Sprintf("unknown kind %q", elem.Kind),
100 })
101 } else if len(elem.Children) > 0 && !kindDef.Container {
102 errs = append(errs, ValidationError{
103 Path: path,
104 Message: fmt.Sprintf("kind %q does not allow children (container: false)", elem.Kind),
105 })
106 }
107 }
108
109 if elem.Title == "" {
110 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"title\""})
111 }
112
113 for childID, child := range elem.Children {
114 if err := validateElementID(childID); err != nil {
115 errs = append(errs, ValidationError{Path: path + "." + childID, Message: err.Error()})
116 }
117 errs = append(errs, validateElement(m, path+"."+childID, child, depth+1)...)
118 }
119
120 return errs
121 }
122
123 func validateRelationships(m *BausteinsichtModel) []ValidationError {
124 var errs []ValidationError
125 // Track seen relationships keyed by "from->to->kind->label" to allow
126 // multiple relationships between the same pair with different kind or label. (#142)
127 type relSig struct {
128 from, to, kind, label string
129 }
130 seen := make(map[relSig]int) // signature → first index
131
132 for i, rel := range m.Relationships {
133 path := fmt.Sprintf("relationships[%d]", i)
134
135 if _, err := lookupElement(m, rel.From); err != nil {
136 errs = append(errs, ValidationError{
137 Path: path,
138 Message: fmt.Sprintf("from %q does not resolve to an existing element", rel.From),
139 })
140 }
141 if _, err := lookupElement(m, rel.To); err != nil {
142 errs = append(errs, ValidationError{
143 Path: path,
144 Message: fmt.Sprintf("to %q does not resolve to an existing element", rel.To),
145 })
146 }
147 if rel.Kind != "" {
148 if _, known := m.Specification.Relationships[rel.Kind]; !known {
149 errs = append(errs, ValidationError{
150 Path: path,
151 Message: fmt.Sprintf("unknown relationship kind %q", rel.Kind),
152 })
153 }
154 }
155
156 // Validate cardinality
157 if rel.Cardinality != "" {
158 valid := false
159 for _, c := range ValidCardinalities {
160 if rel.Cardinality == c {
161 valid = true
162 break
163 }
164 }
165 if !valid {
166 errs = append(errs, ValidationError{
167 Path: path,
168 Message: fmt.Sprintf("invalid cardinality %q (valid: %v)", rel.Cardinality, ValidCardinalities),
169 })
170 }
171 }
172
173 // Validate data flow
174 if rel.DataFlow != "" {
175 valid := false
176 for _, df := range ValidDataFlows {
177 if rel.DataFlow == df {
178 valid = true
179 break
180 }
181 }
182 if !valid {
183 errs = append(errs, ValidationError{
184 Path: path,
185 Message: fmt.Sprintf("invalid dataFlow %q (valid: %v)", rel.DataFlow, ValidDataFlows),
186 })
187 }
188 }
189
190 // Detect fully duplicate relationships (same from, to, kind, and label). (#117, #142)
191 // Multiple relationships between the same pair are allowed if they
192 // differ in kind or label.
193 sig := relSig{from: rel.From, to: rel.To, kind: rel.Kind, label: rel.Label}
194 if firstIdx, exists := seen[sig]; exists {
195 errs = append(errs, ValidationError{
196 Path: path,
197 Message: fmt.Sprintf("duplicate relationship %s → %s (first at relationships[%d])", rel.From, rel.To, firstIdx),
198 })
199 } else {
200 seen[sig] = i
201 }
202 }
203 return errs
204 }
205
206 // validLayouts is the set of allowed values for View.Layout.
207 var validLayouts = map[string]bool{
208 "": true,
209 "layered": true,
210 "grid": true,
211 "none": true,
212 }
213
214 func validateViews(m *BausteinsichtModel) []ValidationError {
215 var errs []ValidationError
216 for id, view := range m.Views {
217 path := "views." + id
218 if view.Title == "" {
219 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"title\""})
220 }
221 if !validLayouts[view.Layout] {
222 errs = append(errs, ValidationError{
223 Path: path,
224 Message: fmt.Sprintf("invalid layout %q (must be \"layered\", \"grid\", \"none\", or empty)", view.Layout),
225 })
226 }
227 if view.Scope != "" {
228 if _, err := lookupElement(m, view.Scope); err != nil {
229 errs = append(errs, ValidationError{
230 Path: path,
231 Message: fmt.Sprintf("scope %q does not resolve to an existing element", view.Scope),
232 })
233 }
234 }
235 for _, entry := range view.Include {
236 if strings.Contains(entry, "*") {
237 continue
238 }
239 if _, err := lookupElement(m, entry); err != nil {
240 errs = append(errs, ValidationError{
241 Path: path + ".include",
242 Message: fmt.Sprintf("element %q does not exist", entry),
243 })
244 }
245 }
246 for _, entry := range view.Exclude {
247 if strings.Contains(entry, "*") {
248 continue
249 }
250 if _, err := lookupElement(m, entry); err != nil {
251 errs = append(errs, ValidationError{
252 Path: path + ".exclude",
253 Message: fmt.Sprintf("element %q does not exist", entry),
254 })
255 }
256 }
257
258 // Validate filter-tags
259 validTagIDs := make(map[string]bool)
260 for _, tag := range m.Specification.Tags {
261 validTagIDs[tag.ID] = true
262 }
263 for _, tag := range view.FilterTags {
264 if !validTagIDs[tag] {
265 errs = append(errs, ValidationError{
266 Path: path + ".filter-tags",
267 Message: fmt.Sprintf("tag %q is not defined in specification.tags", tag),
268 })
269 }
270 }
271
272 // Validate exclude-tags
273 for _, tag := range view.ExcludeTags {
274 if !validTagIDs[tag] {
275 errs = append(errs, ValidationError{
276 Path: path + ".exclude-tags",
277 Message: fmt.Sprintf("tag %q is not defined in specification.tags", tag),
278 })
279 }
280 }
281 }
282 return errs
283 }
284
285 var validStepTypes = map[StepType]bool{
286 StepSync: true,
287 StepAsync: true,
288 StepReturn: true,
289 "": true, // omitted → default sync
290 }
291
292 func validateDynamicViews(m *BausteinsichtModel) []ValidationError {
293 var errs []ValidationError
294 for vi, dv := range m.DynamicViews {
295 path := fmt.Sprintf("dynamicViews[%d]", vi)
296 if dv.Key == "" {
297 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"key\""})
298 }
299 if dv.Title == "" {
300 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"title\""})
301 }
302 if len(dv.Steps) == 0 {
303 errs = append(errs, ValidationError{Path: path, Message: "dynamic view must have at least one step"})
304 continue
305 }
306 seenIndex := make(map[int]bool)
307 for si, step := range dv.Steps {
308 spath := fmt.Sprintf("%s.steps[%d]", path, si)
309 if _, err := lookupElement(m, step.From); err != nil {
310 errs = append(errs, ValidationError{
311 Path: spath,
312 Message: fmt.Sprintf("from %q does not resolve to an existing element", step.From),
313 })
314 }
315 if _, err := lookupElement(m, step.To); err != nil {
316 errs = append(errs, ValidationError{
317 Path: spath,
318 Message: fmt.Sprintf("to %q does not resolve to an existing element", step.To),
319 })
320 }
321 if !validStepTypes[step.Type] {
322 errs = append(errs, ValidationError{
323 Path: spath,
324 Message: fmt.Sprintf("invalid type %q (must be \"sync\", \"async\", or \"return\")", step.Type),
325 })
326 }
327 if seenIndex[step.Index] {
328 errs = append(errs, ValidationError{
329 Path: spath,
330 Message: fmt.Sprintf("duplicate step index %d", step.Index),
331 })
332 }
333 seenIndex[step.Index] = true
334 }
335 }
336 return errs
337 }
338
339 // validateElementID checks that an element ID is valid.
340 func validateElementID(id string) error {
341 if strings.TrimSpace(id) == "" {
342 return fmt.Errorf("invalid element ID %q: must not be empty or whitespace", id)
343 }
344 return nil
345 }
346
347 // lookupElement resolves a dot-notation path to an Element within the model.
348 func lookupElement(m *BausteinsichtModel, path string) (Element, error) {
349 head, rest, hasDot := strings.Cut(path, ".")
350 elem, ok := m.Model[head]
351 if !ok {
352 return Element{}, fmt.Errorf("element %q not found", head)
353 }
354 if !hasDot {
355 return elem, nil
356 }
357 return lookupChild(elem, rest)
358 }
359
360 func lookupChild(parent Element, path string) (Element, error) {
361 head, rest, hasDot := strings.Cut(path, ".")
362 child, ok := parent.Children[head]
363 if !ok {
364 return Element{}, fmt.Errorf("element %q not found", head)
365 }
366 if !hasDot {
367 return child, nil
368 }
369 return lookupChild(child, rest)
370 }
371
372 // validateLifecycleStatus checks element status fields for validity and lifecycle warnings.
373 func validateLifecycleStatus(m *BausteinsichtModel) []ValidationWarning {
374 var warnings []ValidationWarning
375 flatElements, _ := FlattenElements(m)
376
377 // Collect outgoing relationships by element
378 outgoing := make(map[string][]string)
379 for _, rel := range m.Relationships {
380 outgoing[rel.From] = append(outgoing[rel.From], rel.To)
381 }
382
383 for id, elem := range flatElements {
384 if elem.Status == "" {
385 continue // Status is optional
386 }
387
388 // Validate status value
389 validStatus := false
390 for _, valid := range ValidStatuses {
391 if elem.Status == valid {
392 validStatus = true
393 break
394 }
395 }
396 if !validStatus {
397 warnings = append(warnings, ValidationWarning{
398 Path: "model." + id,
399 Message: fmt.Sprintf("unknown status %q; valid values are: %v", elem.Status, ValidStatuses),
400 })
401 continue
402 }
403
404 // Rule: archived elements should not have outgoing relationships
405 if elem.Status == StatusArchived && len(outgoing[id]) > 0 {
406 warnings = append(warnings, ValidationWarning{
407 Path: "model." + id,
408 Message: fmt.Sprintf("archived element has %d outgoing relationships; archived elements should not have active relationships", len(outgoing[id])),
409 })
410 }
411
412 // Rule: deprecated elements should ideally have a successor
413 if elem.Status == StatusDeprecated {
414 hasSuccessor := false
415 for _, target := range outgoing[id] {
416 if targetElem, ok := flatElements[target]; ok && targetElem.Status == StatusDeployed && targetElem.Kind == elem.Kind {
417 hasSuccessor = true
418 break
419 }
420 }
421 if !hasSuccessor {
422 warnings = append(warnings, ValidationWarning{
423 Path: "model." + id,
424 Message: "deprecated element has no deployed successor of the same kind; consider linking to a replacement",
425 })
426 }
427 }
428 }
429
430 return warnings
431 }
432
433 // validatePatterns checks pattern definitions for consistency
434 func validatePatterns(m *BausteinsichtModel) []ValidationError {
435 var errs []ValidationError
436
437 for patternID, pattern := range m.Specification.Patterns {
438 path := "specification.patterns." + patternID
439
440 // Validate all element kinds referenced in the pattern exist
441 for i, elem := range pattern.Elements {
442 elemPath := fmt.Sprintf("%s.elements[%d]", path, i)
443 if elem.Kind == "" {
444 errs = append(errs, ValidationError{
445 Path: elemPath,
446 Message: "missing required field \"kind\"",
447 })
448 } else if _, exists := m.Specification.Elements[elem.Kind]; !exists {
449 errs = append(errs, ValidationError{
450 Path: elemPath,
451 Message: fmt.Sprintf("unknown kind %q", elem.Kind),
452 })
453 }
454 }
455
456 // Validate all relationship kinds referenced in the pattern exist
457 for i, rel := range pattern.Relationships {
458 relPath := fmt.Sprintf("%s.relationships[%d]", path, i)
459 if rel.Kind != "" {
460 if _, exists := m.Specification.Relationships[rel.Kind]; !exists {
461 errs = append(errs, ValidationError{
462 Path: relPath,
463 Message: fmt.Sprintf("unknown relationship kind %q", rel.Kind),
464 })
465 }
466 }
467 }
468 }
469
470 return errs
471 }
472
473 // validateDecisions checks for unknown ADR IDs referenced by elements and relationships.
474 func validateDecisions(m *BausteinsichtModel) []ValidationError {
475 var errs []ValidationError
476
477 // Build a map of known decision IDs
478 knownDecisions := make(map[string]bool)
479 for _, decision := range m.Specification.Decisions {
480 knownDecisions[decision.ID] = true
481 }
482
483 // Check elements
484 flatElements, _ := FlattenElements(m)
485 for id, elem := range flatElements {
486 for _, decisionID := range elem.Decisions {
487 if !knownDecisions[decisionID] {
488 errs = append(errs, ValidationError{
489 Path: "model." + id,
490 Message: fmt.Sprintf("references unknown decision %q", decisionID),
491 })
492 }
493 }
494 }
495
496 // Check relationships
497 for i, rel := range m.Relationships {
498 for _, decisionID := range rel.Decisions {
499 if !knownDecisions[decisionID] {
500 errs = append(errs, ValidationError{
501 Path: fmt.Sprintf("relationships[%d]", i),
502 Message: fmt.Sprintf("references unknown decision %q", decisionID),
503 })
504 }
505 }
506 }
507
508 return errs
509 }
510
511 // validateOrphanDecisions checks for decisions that are not referenced by any element or relationship.
512 func validateOrphanDecisions(m *BausteinsichtModel) []ValidationWarning {
513 var warnings []ValidationWarning
514
515 // Build a set of referenced decisions
516 referencedDecisions := make(map[string]bool)
517
518 flatElements, _ := FlattenElements(m)
519 for _, elem := range flatElements {
520 for _, decisionID := range elem.Decisions {
521 referencedDecisions[decisionID] = true
522 }
523 }
524
525 for _, rel := range m.Relationships {
526 for _, decisionID := range rel.Decisions {
527 referencedDecisions[decisionID] = true
528 }
529 }
530
531 // Check for orphans
532 for _, decision := range m.Specification.Decisions {
533 if !referencedDecisions[decision.ID] {
534 warnings = append(warnings, ValidationWarning{
535 Path: "specification.decisions",
536 Message: fmt.Sprintf("decision %q is not referenced by any element or relationship", decision.ID),
537 })
538 }
539 }
540
541 return warnings
542 }
543
544 // validateSupersededDecisions checks for superseded decisions that are still referenced.
545 func validateSupersededDecisions(m *BausteinsichtModel) []ValidationWarning {
546 var warnings []ValidationWarning
547
548 // Build a map of decision status
549 decisionStatus := make(map[string]ADRStatus)
550 for _, decision := range m.Specification.Decisions {
551 decisionStatus[decision.ID] = decision.Status
552 }
553
554 // Check elements
555 flatElements, _ := FlattenElements(m)
556 for id, elem := range flatElements {
557 for _, decisionID := range elem.Decisions {
558 if status, exists := decisionStatus[decisionID]; exists && status == ADRSuperseded {
559 warnings = append(warnings, ValidationWarning{
560 Path: "model." + id,
561 Message: fmt.Sprintf("references superseded decision %q", decisionID),
562 })
563 }
564 }
565 }
566
567 // Check relationships
568 for i, rel := range m.Relationships {
569 for _, decisionID := range rel.Decisions {
570 if status, exists := decisionStatus[decisionID]; exists && status == ADRSuperseded {
571 warnings = append(warnings, ValidationWarning{
572 Path: fmt.Sprintf("relationships[%d]", i),
573 Message: fmt.Sprintf("references superseded decision %q", decisionID),
574 })
575 }
576 }
577 }
578
579 return warnings
580 }
581
582
github.com/docToolchain/Bausteinsicht/internal/overlay/apply.go 0.0%
1 package overlay
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7
8 "github.com/beevik/etree"
9 )
10
11 const OriginalFillAttr = "data-original-fill"
12
13 func LoadMetricsFile(path string) (*MetricsFile, error) {
14 data, err := os.ReadFile(path)
15 if err != nil {
16 return nil, fmt.Errorf("reading metrics file: %w", err)
17 }
18 var mf MetricsFile
19 if err := json.Unmarshal(data, &mf); err != nil {
20 return nil, fmt.Errorf("parsing metrics file: %w", err)
21 }
22 return &mf, nil
23 }
24
25 func Apply(drawioPath string, metrics *MetricsFile, metricKey string, scheme ColorScheme) error {
26 doc := etree.NewDocument()
27 if err := doc.ReadFromFile(drawioPath); err != nil {
28 return fmt.Errorf("reading draw.io file: %w", err)
29 }
30
31 extracted, err := ExtractMetric(metrics.Metrics, metricKey)
32 if err != nil {
33 return fmt.Errorf("extracting metric %q: %w", metricKey, err)
34 }
35
36 if len(extracted) == 0 {
37 return fmt.Errorf("no elements found for metric %q", metricKey)
38 }
39
40 higherIsBetter := IsMetricBetter(metricKey)
41 normalized := Normalize(extracted, higherIsBetter)
42
43 root := doc.Root()
44 for _, page := range root.FindElements(".//mxGraphModel/root/mxCell") {
45 elementID := page.SelectAttrValue("id", "")
46 if elementID == "" || elementID == "0" || elementID == "1" {
47 continue
48 }
49
50 if normVal, ok := normalized[elementID]; ok {
51 applyColor(page, normVal, scheme)
52 }
53 }
54
55 if err := doc.WriteToFile(drawioPath); err != nil {
56 return fmt.Errorf("writing draw.io file: %w", err)
57 }
58 return nil
59 }
60
61 func Remove(drawioPath string) error {
62 doc := etree.NewDocument()
63 if err := doc.ReadFromFile(drawioPath); err != nil {
64 return fmt.Errorf("reading draw.io file: %w", err)
65 }
66
67 root := doc.Root()
68 for _, cell := range root.FindElements(".//mxGraphModel/root/mxCell") {
69 originalFill := cell.SelectAttrValue(OriginalFillAttr, "")
70 if originalFill != "" {
71 geometry := cell.FindElement("mxGeometry")
72 if geometry != nil {
73 style := geometry.SelectAttrValue("style", "")
74 if style != "" {
75 style = updateStyleFill(style, originalFill)
76 geometry.CreateAttr("style", style)
77 }
78 }
79 cell.RemoveAttr(OriginalFillAttr)
80 }
81 }
82
83 if err := doc.WriteToFile(drawioPath); err != nil {
84 return fmt.Errorf("writing draw.io file: %w", err)
85 }
86 return nil
87 }
88
89 func applyColor(cell *etree.Element, normalized float64, scheme ColorScheme) {
90 color := ColorForValue(normalized, scheme)
91
92 geometry := cell.FindElement("mxGeometry")
93 if geometry == nil {
94 return
95 }
96
97 style := geometry.SelectAttrValue("style", "")
98 originalFill := geometry.SelectAttrValue("fillColor", "")
99
100 if originalFill == "" {
101 originalFill = "#ffffff"
102 }
103 if cell.SelectAttrValue(OriginalFillAttr, "") == "" {
104 cell.CreateAttr(OriginalFillAttr, originalFill)
105 }
106
107 style = updateStyleFill(style, color)
108 geometry.CreateAttr("style", style)
109 }
110
111 func updateStyleFill(style, color string) string {
112 if style == "" {
113 return "fillColor=" + color
114 }
115
116 result := ""
117 hasKey := false
118 for _, part := range parseStyleParts(style) {
119 if len(part) > 0 && startsWithKey(part, "fillColor") {
120 result += "fillColor=" + color + ";"
121 hasKey = true
122 } else {
123 if part != "" {
124 result += part + ";"
125 }
126 }
127 }
128 if !hasKey {
129 result += "fillColor=" + color + ";"
130 }
131 return result
132 }
133
134 func parseStyleParts(style string) []string {
135 var result []string
136 var current string
137 for _, ch := range style {
138 if ch == ';' {
139 if current != "" {
140 result = append(result, current)
141 current = ""
142 }
143 } else {
144 current += string(ch)
145 }
146 }
147 if current != "" {
148 result = append(result, current)
149 }
150 return result
151 }
152
153 func startsWithKey(part, key string) bool {
154 return len(part) >= len(key) && part[:len(key)] == key
155 }
156
github.com/docToolchain/Bausteinsicht/internal/overlay/normalize.go 97.2%
1 package overlay
2
3 import (
4 "cmp"
5 "slices"
6 )
7
8 func IsMetricBetter(metricName string) bool {
9 goodMetrics := map[string]bool{
10 "coverage": true,
11 "uptime": true,
12 "deploy_freq": true,
13 "success_rate": true,
14 "availability": true,
15 }
16 badMetrics := map[string]bool{
17 "error_rate": true,
18 "latency": true,
19 "p99": true,
20 "p99_ms": true,
21 "response_time": true,
22 "cpu_usage": true,
23 "memory_usage": true,
24 "error_count": true,
25 "failures": true,
26 }
27
28 if goodMetrics[metricName] {
29 return true
30 }
31 if badMetrics[metricName] {
32 return false
33 }
34 return false
35 }
36
37 func Normalize(metrics []NormalizedMetric, higherIsBetter bool) map[string]float64 {
38 if len(metrics) == 0 {
39 return make(map[string]float64)
40 }
41
42 values := make([]float64, len(metrics))
43 for i, m := range metrics {
44 values[i] = m.Value
45 }
46
47 min := slices.Min(values)
48 max := slices.Max(values)
49 span := max - min
50
51 result := make(map[string]float64)
52 for _, m := range metrics {
53 normalized := 0.0
54 if span > 0 {
55 normalized = (m.Value - min) / span
56 }
57 if !higherIsBetter {
58 normalized = 1 - normalized
59 }
60 result[m.ElementID] = normalized
61 }
62 return result
63 }
64
65 func ColorForValue(normalized float64, scheme ColorScheme) string {
66 switch {
67 case normalized < 0.25:
68 return scheme.Green
69 case normalized < 0.50:
70 return scheme.Yellow
71 case normalized < 0.75:
72 return scheme.Orange
73 default:
74 return scheme.Red
75 }
76 }
77
78 func ExtractMetric(metrics []ElementMetric, metricKey string) ([]NormalizedMetric, error) {
79 result := make([]NormalizedMetric, 0, len(metrics))
80 for _, m := range metrics {
81 if val, ok := m.Values[metricKey]; ok {
82 result = append(result, NormalizedMetric{
83 ElementID: m.ElementID,
84 Value: val,
85 })
86 }
87 }
88 slices.SortFunc(result, func(a, b NormalizedMetric) int {
89 return cmp.Compare(a.ElementID, b.ElementID)
90 })
91 return result, nil
92 }
93
github.com/docToolchain/Bausteinsicht/internal/overlay/types.go 0.0%
1 package overlay
2
3 import (
4 "encoding/json"
5 )
6
7 type MetricsFile struct {
8 Meta MetaInfo `json:"meta"`
9 Metrics []ElementMetric `json:"metrics"`
10 }
11
12 type MetaInfo struct {
13 Generated string `json:"generated"`
14 Source string `json:"source"`
15 MetricDescriptions map[string]string `json:"metric_descriptions"`
16 }
17
18 type ElementMetric struct {
19 ElementID string `json:"elementId"`
20 Values map[string]float64
21 }
22
23 func (em *ElementMetric) UnmarshalJSON(data []byte) error {
24 var raw map[string]interface{}
25 if err := json.Unmarshal(data, &raw); err != nil {
26 return err
27 }
28
29 em.Values = make(map[string]float64)
30 for key, val := range raw {
31 if key == "elementId" {
32 if str, ok := val.(string); ok {
33 em.ElementID = str
34 }
35 } else if num, ok := val.(float64); ok {
36 em.Values[key] = num
37 }
38 }
39 return nil
40 }
41
42 type NormalizedMetric struct {
43 ElementID string
44 Value float64
45 }
46
47 type ColorScheme struct {
48 Green string
49 Yellow string
50 Orange string
51 Red string
52 }
53
54 var DefaultColorScheme = ColorScheme{
55 Green: "#d5e8d4",
56 Yellow: "#fff2cc",
57 Orange: "#ffe6cc",
58 Red: "#f8cecc",
59 }
60
github.com/docToolchain/Bausteinsicht/internal/schema/generator.go 85.9%
1 package schema
2
3 import (
4 "encoding/json"
5 "reflect"
6 )
7
8 // JSONSchema represents a JSON Schema Draft 7 schema
9 type JSONSchema struct {
10 Schema string `json:"$schema"`
11 Title string `json:"title"`
12 Description string `json:"description,omitempty"`
13 Type string `json:"type"`
14 Properties map[string]interface{} `json:"properties,omitempty"`
15 Required []string `json:"required,omitempty"`
16 Definitions map[string]interface{} `json:"definitions,omitempty"`
17 }
18
19 // Generator generates JSON Schema from Go types
20 type Generator struct {
21 definitions map[string]interface{}
22 }
23
24 // NewGenerator creates a new schema generator
25 func NewGenerator() *Generator {
26 return &Generator{
27 definitions: make(map[string]interface{}),
28 }
29 }
30
31 // Generate generates JSON Schema for a given type
32 func (g *Generator) Generate(v interface{}) *JSONSchema {
33 schema := &JSONSchema{
34 Schema: "http://json-schema.org/draft-07/schema#",
35 Title: "Bausteinsicht Model",
36 Description: "Architecture model in Bausteinsicht format",
37 Type: "object",
38 Properties: make(map[string]interface{}),
39 Definitions: g.definitions,
40 }
41
42 // Generate properties from struct fields
43 t := reflect.TypeOf(v)
44 if t.Kind() == reflect.Ptr { //nolint:govet
45 t = t.Elem()
46 }
47
48 for i := 0; i < t.NumField(); i++ {
49 field := t.Field(i)
50 jsonTag := field.Tag.Get("json")
51 if jsonTag == "" || jsonTag == "-" {
52 continue
53 }
54
55 fieldName := jsonTag
56 if idx := findComma(jsonTag); idx >= 0 {
57 fieldName = jsonTag[:idx]
58 }
59
60 schema.Properties[fieldName] = g.generateFieldSchema(field.Type)
61
62 // Add to required if no omitempty
63 if !hasOmitempty(jsonTag) {
64 schema.Required = append(schema.Required, fieldName)
65 }
66 }
67
68 return schema
69 }
70
71 // generateFieldSchema generates schema for a single field
72 func (g *Generator) generateFieldSchema(t reflect.Type) interface{} {
73 if t.Kind() == reflect.Ptr { //nolint:govet
74 return g.generateFieldSchema(t.Elem())
75 }
76
77 switch t.Kind() {
78 case reflect.String:
79 return map[string]interface{}{"type": "string"}
80 case reflect.Bool:
81 return map[string]interface{}{"type": "boolean"}
82 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
83 return map[string]interface{}{"type": "integer"}
84 case reflect.Float32, reflect.Float64:
85 return map[string]interface{}{"type": "number"}
86 case reflect.Slice, reflect.Array:
87 return map[string]interface{}{
88 "type": "array",
89 "items": g.generateFieldSchema(t.Elem()),
90 }
91 case reflect.Map:
92 return map[string]interface{}{
93 "type": "object",
94 }
95 case reflect.Struct:
96 return g.generateObjectSchema(t)
97 default:
98 return map[string]interface{}{"type": "object"}
99 }
100 }
101
102 // generateObjectSchema generates schema for a struct type
103 func (g *Generator) generateObjectSchema(t reflect.Type) interface{} {
104 typeName := t.Name()
105 if typeName == "" {
106 return map[string]interface{}{"type": "object"}
107 }
108
109 // Check if already defined
110 if _, exists := g.definitions[typeName]; exists {
111 return map[string]interface{}{"$ref": "#/definitions/" + typeName}
112 }
113
114 schema := map[string]interface{}{
115 "type": "object",
116 "properties": make(map[string]interface{}),
117 }
118
119 properties := schema["properties"].(map[string]interface{})
120 required := []string{}
121
122 for i := 0; i < t.NumField(); i++ {
123 field := t.Field(i)
124 jsonTag := field.Tag.Get("json")
125 if jsonTag == "" || jsonTag == "-" {
126 continue
127 }
128
129 fieldName := jsonTag
130 if idx := findComma(jsonTag); idx >= 0 {
131 fieldName = jsonTag[:idx]
132 }
133
134 properties[fieldName] = g.generateFieldSchema(field.Type)
135
136 if !hasOmitempty(jsonTag) {
137 required = append(required, fieldName)
138 }
139 }
140
141 if len(required) > 0 {
142 schema["required"] = required
143 }
144
145 g.definitions[typeName] = schema
146 return map[string]interface{}{"$ref": "#/definitions/" + typeName}
147 }
148
149 // ToJSON returns the schema as formatted JSON
150 func (s *JSONSchema) ToJSON() ([]byte, error) {
151 return json.MarshalIndent(s, "", " ")
152 }
153
154 // helper functions
155
156 func findComma(s string) int {
157 for i, c := range s {
158 if c == ',' {
159 return i
160 }
161 }
162 return -1
163 }
164
165 func hasOmitempty(jsonTag string) bool {
166 idx := findComma(jsonTag)
167 if idx < 0 {
168 return false
169 }
170 return json.Unmarshal([]byte(`"`+jsonTag[idx+1:]+`"`), new(string)) == nil &&
171 contains(jsonTag[idx+1:], "omitempty")
172 }
173
174 func contains(s, substr string) bool {
175 for i := 0; i <= len(s)-len(substr); i++ {
176 if s[i:i+len(substr)] == substr {
177 return true
178 }
179 }
180 return false
181 }
182
github.com/docToolchain/Bausteinsicht/internal/search/scorer.go 95.6%
1 package search
2
3 import (
4 "strings"
5 )
6
7 // fieldMatch checks whether the field value contains all query words (case-insensitive).
8 // Returns the weight if it matches, 0 otherwise. An exact full-string match
9 // (after lowercasing) returns 10× the weight to prioritise ID hits.
10 func fieldMatch(value string, words []string, weight int) (score int, matched bool) {
11 if value == "" || weight == 0 {
12 return 0, false
13 }
14 lower := strings.ToLower(value)
15 for _, w := range words {
16 if !strings.Contains(lower, w) {
17 return 0, false
18 }
19 }
20 // Exact match bonus: single-word query that equals the whole field value.
21 if len(words) == 1 && lower == words[0] {
22 return weight * 10, true
23 }
24 return weight, true
25 }
26
27 // scoreElement computes a relevance score for an element.
28 // Returns the total score and the list of field names that contributed.
29 func scoreElement(id, title, description, technology, kind string, tags []string, words []string) (int, []string) {
30 type field struct {
31 name string
32 value string
33 weight int
34 }
35 fields := []field{
36 {"id", id, 3},
37 {"title", title, 3},
38 {"technology", technology, 2},
39 {"kind", kind, 2},
40 {"description", description, 1},
41 }
42
43 total := 0
44 var matched []string
45 for _, f := range fields {
46 if s, ok := fieldMatch(f.value, words, f.weight); ok {
47 total += s
48 matched = append(matched, f.name)
49 }
50 }
51 for _, tag := range tags {
52 if s, ok := fieldMatch(tag, words, 2); ok {
53 total += s
54 if !contains(matched, "tags") {
55 matched = append(matched, "tags")
56 }
57 }
58 }
59 return total, matched
60 }
61
62 // scoreRelationship computes a relevance score for a relationship.
63 func scoreRelationship(id, label, kind, fromTitle, toTitle string, words []string) (int, []string) {
64 type field struct {
65 name string
66 value string
67 weight int
68 }
69 fields := []field{
70 {"id", id, 3},
71 {"label", label, 3},
72 {"kind", kind, 2},
73 {"from", fromTitle, 2},
74 {"to", toTitle, 2},
75 }
76
77 total := 0
78 var matched []string
79 for _, f := range fields {
80 if s, ok := fieldMatch(f.value, words, f.weight); ok {
81 total += s
82 matched = append(matched, f.name)
83 }
84 }
85 return total, matched
86 }
87
88 // scoreView computes a relevance score for a view.
89 func scoreView(key, title, description string, words []string) (int, []string) {
90 type field struct {
91 name string
92 value string
93 weight int
94 }
95 fields := []field{
96 {"key", key, 3},
97 {"title", title, 3},
98 {"description", description, 1},
99 }
100
101 total := 0
102 var matched []string
103 for _, f := range fields {
104 if s, ok := fieldMatch(f.value, words, f.weight); ok {
105 total += s
106 matched = append(matched, f.name)
107 }
108 }
109 return total, matched
110 }
111
112 func contains(slice []string, s string) bool {
113 for _, v := range slice {
114 if v == s {
115 return true
116 }
117 }
118 return false
119 }
120
github.com/docToolchain/Bausteinsicht/internal/search/search.go 94.9%
1 // Package search implements full-text search over Bausteinsicht model objects
2 // (elements, relationships, views) with field-weighted relevance scoring.
3 package search
4
5 import (
6 "sort"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // Run searches the model for the given query and returns ranked results.
13 // The query is split into words; all words must appear (AND semantics).
14 // Results are sorted by descending score, then alphabetically by ID.
15 func Run(query string, m *model.BausteinsichtModel, opts Options) Response {
16 words := tokenise(query)
17 if len(words) == 0 {
18 return Response{Query: query, Results: []Result{}, Total: 0}
19 }
20
21 flat, err := model.FlattenElements(m)
22 if err != nil {
23 return Response{Query: query, Results: []Result{}, Total: 0}
24 }
25
26 // Build a title lookup for relationships (from/to display).
27 titleOf := func(id string) string {
28 if e, ok := flat[id]; ok && e.Title != "" {
29 return e.Title
30 }
31 return id
32 }
33
34 var results []Result
35
36 if opts.Type == "" || opts.Type == ResultElement {
37 for id, elem := range flat {
38 score, matched := scoreElement(id, elem.Title, elem.Description, elem.Technology, elem.Kind, elem.Tags, words)
39 if score == 0 {
40 continue
41 }
42 results = append(results, Result{
43 Type: ResultElement,
44 ID: id,
45 Title: elem.Title,
46 Kind: elem.Kind,
47 Technology: elem.Technology,
48 Description: elem.Description,
49 Score: score,
50 MatchedFields: matched,
51 })
52 }
53 }
54
55 if opts.Type == "" || opts.Type == ResultRelationship {
56 for _, rel := range m.Relationships {
57 id := rel.From + "->" + rel.To
58 score, matched := scoreRelationship(id, rel.Label, rel.Kind, titleOf(rel.From), titleOf(rel.To), words)
59 if score == 0 {
60 continue
61 }
62 results = append(results, Result{
63 Type: ResultRelationship,
64 ID: id,
65 Title: rel.Label,
66 Kind: rel.Kind,
67 From: rel.From,
68 To: rel.To,
69 Description: rel.Description,
70 Score: score,
71 MatchedFields: matched,
72 })
73 }
74 }
75
76 if opts.Type == "" || opts.Type == ResultView {
77 for key, view := range m.Views {
78 score, matched := scoreView(key, view.Title, view.Description, words)
79 if score == 0 {
80 continue
81 }
82 results = append(results, Result{
83 Type: ResultView,
84 ID: key,
85 Title: view.Title,
86 Description: view.Description,
87 Score: score,
88 MatchedFields: matched,
89 })
90 }
91 }
92
93 sort.Slice(results, func(i, j int) bool {
94 if results[i].Score != results[j].Score {
95 return results[i].Score > results[j].Score
96 }
97 return results[i].ID < results[j].ID
98 })
99
100 return Response{
101 Query: query,
102 Results: results,
103 Total: len(results),
104 }
105 }
106
107 // tokenise lowercases the query and splits it into words.
108 func tokenise(query string) []string {
109 lower := strings.ToLower(strings.TrimSpace(query))
110 if lower == "" {
111 return nil
112 }
113 return strings.Fields(lower)
114 }
115
github.com/docToolchain/Bausteinsicht/internal/snapshot/storage.go 0.0%
1 package snapshot
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9 "time"
10 )
11
12 const (
13 snapshotDir = ".bausteinsicht-snapshots"
14 indexFile = "index.json"
15 snapshotDir0755 = 0o755
16 fileMode0644 = 0o644
17 )
18
19 // Manager handles snapshot storage and retrieval
20 type Manager struct {
21 baseDir string
22 }
23
24 // NewManager creates a new snapshot manager for a given directory
25 func NewManager(baseDir string) *Manager {
26 return &Manager{baseDir: baseDir}
27 }
28
29 // snapshotPath returns the path to the snapshot directory
30 func (m *Manager) snapshotPath() string {
31 return filepath.Join(m.baseDir, snapshotDir)
32 }
33
34 // indexPath returns the path to the snapshot index file
35 func (m *Manager) indexPath() string {
36 return filepath.Join(m.snapshotPath(), indexFile)
37 }
38
39 // snapshotFilePath returns the path to a specific snapshot file
40 func (m *Manager) snapshotFilePath(id string) string {
41 return filepath.Join(m.snapshotPath(), id+".json")
42 }
43
44 // Save stores a snapshot to disk
45 func (m *Manager) Save(snapshot *Snapshot) error {
46 // Create snapshot directory if it doesn't exist
47 snapPath := m.snapshotPath()
48 if err := os.MkdirAll(snapPath, snapshotDir0755); err != nil {
49 return fmt.Errorf("creating snapshot directory: %w", err)
50 }
51
52 // Write snapshot file
53 snapshotFile := m.snapshotFilePath(snapshot.ID)
54 data, err := json.MarshalIndent(snapshot, "", " ")
55 if err != nil {
56 return fmt.Errorf("marshaling snapshot: %w", err)
57 }
58 if err := os.WriteFile(snapshotFile, data, fileMode0644); err != nil {
59 return fmt.Errorf("writing snapshot file: %w", err)
60 }
61
62 // Update index
63 if err := m.updateIndex(snapshot); err != nil {
64 return fmt.Errorf("updating index: %w", err)
65 }
66
67 return nil
68 }
69
70 // Load retrieves a snapshot from disk
71 func (m *Manager) Load(id string) (*Snapshot, error) {
72 snapshotFile := m.snapshotFilePath(id)
73 data, err := os.ReadFile(snapshotFile)
74 if err != nil {
75 return nil, fmt.Errorf("reading snapshot file: %w", err)
76 }
77
78 var snapshot Snapshot
79 if err := json.Unmarshal(data, &snapshot); err != nil {
80 return nil, fmt.Errorf("parsing snapshot: %w", err)
81 }
82
83 return &snapshot, nil
84 }
85
86 // List returns all saved snapshots sorted by timestamp (newest first)
87 func (m *Manager) List() ([]SnapshotMetadata, error) {
88 indexPath := m.indexPath()
89 data, err := os.ReadFile(indexPath)
90 if err != nil {
91 if os.IsNotExist(err) {
92 return []SnapshotMetadata{}, nil
93 }
94 return nil, fmt.Errorf("reading index: %w", err)
95 }
96
97 var index SnapshotIndex
98 if err := json.Unmarshal(data, &index); err != nil {
99 return nil, fmt.Errorf("parsing index: %w", err)
100 }
101
102 return index.Snapshots, nil
103 }
104
105 // Delete removes a snapshot from disk
106 func (m *Manager) Delete(id string) error {
107 snapshotFile := m.snapshotFilePath(id)
108 if err := os.Remove(snapshotFile); err != nil {
109 return fmt.Errorf("deleting snapshot file: %w", err)
110 }
111
112 // Update index to remove the snapshot
113 if err := m.removeFromIndex(id); err != nil {
114 return fmt.Errorf("updating index: %w", err)
115 }
116
117 return nil
118 }
119
120 // updateIndex adds or updates a snapshot in the index
121 func (m *Manager) updateIndex(snapshot *Snapshot) error {
122 snapPath := m.snapshotPath()
123 if err := os.MkdirAll(snapPath, snapshotDir0755); err != nil {
124 return err
125 }
126
127 indexPath := m.indexPath()
128 var index SnapshotIndex
129
130 // Read existing index if it exists
131 if data, err := os.ReadFile(indexPath); err == nil {
132 if err := json.Unmarshal(data, &index); err != nil {
133 return err
134 }
135 }
136
137 // Remove old entry if it exists
138 index.Snapshots = removeMetadataByID(index.Snapshots, snapshot.ID)
139
140 // Add new entry
141 index.Snapshots = append(index.Snapshots, snapshot.ToMetadata())
142
143 // Sort by timestamp (newest first)
144 sort.Slice(index.Snapshots, func(i, j int) bool {
145 return index.Snapshots[i].Timestamp.After(index.Snapshots[j].Timestamp)
146 })
147
148 index.Version = 1
149 index.UpdatedAt = time.Now().UTC()
150
151 // Write updated index
152 data, err := json.MarshalIndent(index, "", " ")
153 if err != nil {
154 return err
155 }
156 return os.WriteFile(indexPath, data, fileMode0644)
157 }
158
159 // removeFromIndex removes a snapshot from the index
160 func (m *Manager) removeFromIndex(id string) error {
161 indexPath := m.indexPath()
162 data, err := os.ReadFile(indexPath)
163 if err != nil {
164 if os.IsNotExist(err) {
165 return nil
166 }
167 return err
168 }
169
170 var index SnapshotIndex
171 if err := json.Unmarshal(data, &index); err != nil {
172 return err
173 }
174
175 index.Snapshots = removeMetadataByID(index.Snapshots, id)
176 index.UpdatedAt = time.Now().UTC()
177
178 updatedData, err := json.MarshalIndent(index, "", " ")
179 if err != nil {
180 return err
181 }
182
183 return os.WriteFile(indexPath, updatedData, fileMode0644)
184 }
185
186 // removeMetadataByID removes a metadata entry by ID
187 func removeMetadataByID(metadata []SnapshotMetadata, id string) []SnapshotMetadata {
188 var result []SnapshotMetadata
189 for _, m := range metadata {
190 if m.ID != id {
191 result = append(result, m)
192 }
193 }
194 return result
195 }
196
197 // Exists checks if a snapshot exists
198 func (m *Manager) Exists(id string) bool {
199 snapshotFile := m.snapshotFilePath(id)
200 _, err := os.Stat(snapshotFile)
201 return err == nil
202 }
203
204 // ListFiles returns all snapshot files in the directory
205 func (m *Manager) ListFiles() ([]string, error) {
206 snapPath := m.snapshotPath()
207 entries, err := os.ReadDir(snapPath)
208 if err != nil {
209 if os.IsNotExist(err) {
210 return []string{}, nil
211 }
212 return nil, err
213 }
214
215 var files []string
216 for _, entry := range entries {
217 if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" && entry.Name() != indexFile {
218 files = append(files, entry.Name())
219 }
220 }
221
222 return files, nil
223 }
224
github.com/docToolchain/Bausteinsicht/internal/snapshot/types.go 0.0%
1 package snapshot
2
3 import (
4 "fmt"
5 "time"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // Snapshot represents a point-in-time capture of the architecture model
11 type Snapshot struct {
12 ID string `json:"id"`
13 Timestamp time.Time `json:"timestamp"`
14 Message string `json:"message,omitempty"`
15 Model *model.BausteinsichtModel `json:"model"`
16 }
17
18 // SnapshotMetadata is lightweight metadata for snapshots in the index
19 type SnapshotMetadata struct {
20 ID string `json:"id"`
21 Timestamp time.Time `json:"timestamp"`
22 Message string `json:"message,omitempty"`
23 ElementCount int `json:"elementCount"`
24 RelCount int `json:"relationshipCount"`
25 }
26
27 // SnapshotIndex holds the list of all snapshots
28 type SnapshotIndex struct {
29 Version int `json:"version"`
30 Snapshots []SnapshotMetadata `json:"snapshots"`
31 UpdatedAt time.Time `json:"updatedAt"`
32 }
33
34 // NewSnapshot creates a snapshot from a model
35 func NewSnapshot(message string, model *model.BausteinsichtModel) *Snapshot {
36 snapshot := &Snapshot{
37 ID: generateSnapshotID(),
38 Timestamp: time.Now().UTC(),
39 Message: message,
40 Model: model,
41 }
42 return snapshot
43 }
44
45 // generateSnapshotID creates a timestamp-based snapshot ID with nanosecond precision
46 func generateSnapshotID() string {
47 now := time.Now().UTC()
48 return now.Format("snapshot-2006-01-02T15-04-05") + fmt.Sprintf(".%09dZ", now.Nanosecond())
49 }
50
51 // ToMetadata converts a snapshot to its metadata representation
52 func (s *Snapshot) ToMetadata() SnapshotMetadata {
53 elementCount := 0
54 if s.Model != nil {
55 elementCount = len(flattenElements(s.Model.Model))
56 }
57
58 relationCount := 0
59 if s.Model != nil {
60 relationCount = len(s.Model.Relationships)
61 }
62
63 return SnapshotMetadata{
64 ID: s.ID,
65 Timestamp: s.Timestamp,
66 Message: s.Message,
67 ElementCount: elementCount,
68 RelCount: relationCount,
69 }
70 }
71
72 // flattenElements counts total elements including nested ones
73 func flattenElements(elems map[string]model.Element) map[string]model.Element {
74 result := make(map[string]model.Element)
75 for key, elem := range elems {
76 result[key] = elem
77 if len(elem.Children) > 0 {
78 children := flattenElements(elem.Children)
79 for k, v := range children {
80 result[key+"."+k] = v
81 }
82 }
83 }
84 return result
85 }
86
github.com/docToolchain/Bausteinsicht/internal/sync/badge.go 51.7%
1 package sync
2
3 import (
4 "fmt"
5
6 "github.com/beevik/etree"
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // AddStatusBadge adds a status badge as a child cell to an element shape.
11 // The badge is positioned in the top-right corner of the element.
12 func AddStatusBadge(elementCell *etree.Element, status string) {
13 if status == "" {
14 return // No badge for unset status
15 }
16
17 // Get element width (or use default if not set)
18 width := getAttrFloat(elementCell, "width", 100)
19
20 // Badge dimensions and positioning
21 badgeWidth := 60.0
22 badgeHeight := 20.0
23 badgeX := width - badgeWidth - 2
24 badgeY := 2.0
25
26 // Create badge cell
27 badge := etree.NewElement("mxCell")
28 badge.CreateAttr("id", fmt.Sprintf("%s_badge", getAttr(elementCell, "id")))
29 badge.CreateAttr("value", status)
30 badge.CreateAttr("style", fmt.Sprintf(
31 "rounded=1;fillColor=%s;strokeColor=%s;fontSize=11;fontColor=#000000;"+
32 "whiteSpace=wrap;overflow=hidden;connectable=0",
33 model.StatusColor(status), model.StatusColor(status)))
34 badge.CreateAttr("vertex", "1")
35 badge.CreateAttr("parent", getAttr(elementCell, "id"))
36
37 // Geometry for the badge
38 geom := etree.NewElement("mxGeometry")
39 geom.CreateAttr("x", fmt.Sprintf("%.0f", badgeX))
40 geom.CreateAttr("y", fmt.Sprintf("%.0f", badgeY))
41 geom.CreateAttr("width", fmt.Sprintf("%.0f", badgeWidth))
42 geom.CreateAttr("height", fmt.Sprintf("%.0f", badgeHeight))
43 geom.CreateAttr("as", "geometry")
44
45 badge.AddChild(geom)
46 elementCell.AddChild(badge)
47 }
48
49 // getAttr retrieves a string attribute value
50 func getAttr(el *etree.Element, name string) string {
51 attr := el.SelectAttr(name)
52 if attr == nil {
53 return ""
54 }
55 return attr.Value
56 }
57
58 // AddDecisionBadges adds decision badges as child cells to an element shape.
59 // One badge is created per decision, positioned in a row in the top-right corner.
60 func AddDecisionBadges(elementCell *etree.Element, decisions []string, decisionMap map[string]*model.DecisionRecord) {
61 if len(decisions) == 0 {
62 return // No badges if no decisions
63 }
64
65 // Get element width (or use default if not set)
66 width := getAttrFloat(elementCell, "width", 100)
67
68 // Badge dimensions and positioning
69 badgeWidth := 30.0
70 badgeHeight := 20.0
71 badgeStartX := width - (badgeWidth * float64(len(decisions))) - 4
72 badgeY := 2.0
73
74 for i, decisionID := range decisions {
75 badge := etree.NewElement("mxCell")
76 badge.CreateAttr("id", fmt.Sprintf("%s_decision_%d", getAttr(elementCell, "id"), i))
77 badge.CreateAttr("value", "⚖")
78
79 // Determine color based on decision status
80 color := model.DecisionBadgeColor("")
81 if decision, ok := decisionMap[decisionID]; ok {
82 color = model.DecisionBadgeColor(decision.Status)
83 }
84
85 badge.CreateAttr("style", fmt.Sprintf(
86 "rounded=0;fillColor=%s;strokeColor=%s;fontSize=14;fontColor=#ffffff;"+
87 "whiteSpace=wrap;overflow=hidden;connectable=0;align=center;verticalAlign=middle",
88 color, color))
89 badge.CreateAttr("vertex", "1")
90 badge.CreateAttr("parent", getAttr(elementCell, "id"))
91 badge.CreateAttr("bausteinsicht_decision_id", decisionID)
92
93 // Geometry for the badge
94 badgeX := badgeStartX + (badgeWidth * float64(i))
95 geom := etree.NewElement("mxGeometry")
96 geom.CreateAttr("x", fmt.Sprintf("%.0f", badgeX))
97 geom.CreateAttr("y", fmt.Sprintf("%.0f", badgeY))
98 geom.CreateAttr("width", fmt.Sprintf("%.0f", badgeWidth))
99 geom.CreateAttr("height", fmt.Sprintf("%.0f", badgeHeight))
100 geom.CreateAttr("as", "geometry")
101
102 badge.AddChild(geom)
103 elementCell.AddChild(badge)
104 }
105 }
106
107 // getAttrFloat retrieves a float attribute value with default
108 func getAttrFloat(el *etree.Element, name string, defaultVal float64) float64 {
109 attr := el.SelectAttr(name)
110 if attr == nil {
111 return defaultVal
112 }
113 var val float64
114 _, _ = fmt.Sscanf(attr.Value, "%f", &val)
115 return val
116 }
117
github.com/docToolchain/Bausteinsicht/internal/sync/conflict.go 100.0%
1 package sync
2
3 import "fmt"
4
5 // Conflict represents a field that was changed on both sides since the last sync.
6 type Conflict struct {
7 ElementID string
8 Field string // "title", "description", "technology"
9 ModelValue string
10 DrawioValue string
11 LastSyncValue string
12 }
13
14 // ResolvedConflict is a conflict with its resolution decision.
15 type ResolvedConflict struct {
16 Conflict
17 Winner string // "model" or "drawio"
18 Warning string // human-readable warning message
19 }
20
21 // ConflictResolver resolves conflicts between model and draw.io changes.
22 // Designed as an interface for future extension (interactive, merge strategies).
23 type ConflictResolver interface {
24 Resolve(conflicts []Conflict) []ResolvedConflict
25 }
26
27 // ModelWinsResolver always picks the model value (v1 default strategy).
28 type ModelWinsResolver struct{}
29
30 // NewModelWinsResolver creates a new ModelWinsResolver.
31 func NewModelWinsResolver() *ModelWinsResolver {
32 return &ModelWinsResolver{}
33 }
34
35 // Resolve resolves all conflicts by choosing the model value.
36 func (r *ModelWinsResolver) Resolve(conflicts []Conflict) []ResolvedConflict {
37 resolved := make([]ResolvedConflict, 0, len(conflicts))
38 for _, c := range conflicts {
39 warning := fmt.Sprintf(
40 "Conflict detected for element %q:\n"+
41 " Field: %s\n"+
42 " Model value: %q\n"+
43 " draw.io value: %q\n"+
44 " Last sync: %q\n"+
45 " → Keeping model value. Edit draw.io manually if needed.",
46 c.ElementID, c.Field, c.ModelValue, c.DrawioValue, c.LastSyncValue,
47 )
48 resolved = append(resolved, ResolvedConflict{
49 Conflict: c,
50 Winner: "model",
51 Warning: warning,
52 })
53 }
54 return resolved
55 }
56
github.com/docToolchain/Bausteinsicht/internal/sync/diff.go 91.3%
1 package sync
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/drawio"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // ChangeType classifies a change.
13 type ChangeType int
14
15 const (
16 Added ChangeType = iota
17 Modified // nolint:deadcode
18 Deleted
19 )
20
21 // ElementChange represents a change to a single element.
22 type ElementChange struct {
23 ID string
24 Type ChangeType
25 Field string // "title", "description", "technology", "" for add/delete
26 OldValue string
27 NewValue string
28 }
29
30 // RelationshipChange represents a change to a relationship.
31 type RelationshipChange struct {
32 From string
33 To string
34 Index int // relationship array index for disambiguation
35 Type ChangeType
36 Field string // "label", "" for add/delete
37 OldValue string
38 NewValue string
39 }
40
41 // ChangeSet contains all detected changes from both sides.
42 type ChangeSet struct {
43 ModelElementChanges []ElementChange
44 ModelRelationshipChanges []RelationshipChange
45 DrawioElementChanges []ElementChange
46 DrawioRelationshipChanges []RelationshipChange
47 Conflicts []Conflict
48 }
49
50 // drawioElemSnapshot holds extracted data from a draw.io element.
51 type drawioElemSnapshot struct {
52 title string
53 technology string
54 description string
55 kind string
56 }
57
58 // relKey returns a canonical key for a relationship.
59 // The index disambiguates multiple relationships between the same pair.
60 func relKey(from, to string, index int) string {
61 return fmt.Sprintf("%s:%s:%d", from, to, index)
62 }
63
64 // computeVisibleElements returns the set of element IDs that should be visible
65 // across all views. If the model has no views, returns nil (meaning ALL elements
66 // are visible on the single page).
67 func computeVisibleElements(m *model.BausteinsichtModel) map[string]bool {
68 if len(m.Views) == 0 {
69 return nil // all elements visible
70 }
71 visible := make(map[string]bool)
72 for _, view := range m.Views {
73 v := view
74 resolved, err := model.ResolveView(m, &v)
75 if err != nil {
76 continue
77 }
78 for _, id := range resolved {
79 visible[id] = true
80 }
81 // The scope element itself is also visible (rendered as boundary).
82 if view.Scope != "" {
83 visible[view.Scope] = true
84 }
85 }
86 return visible
87 }
88
89 // computeVisibleRelationships returns the set of relationship keys (from relKey)
90 // that should have a connector on at least one view page. A relationship is
91 // visible if both endpoints (or a lifted ancestor of each) are present on the
92 // same view's resolved element set.
93 // If the model has no views, returns nil (meaning ALL relationships are visible).
94 // This prevents reverse sync from treating connectors removed by view filter
95 // changes as user deletions (#167).
96 func computeVisibleRelationships(m *model.BausteinsichtModel) map[string]bool {
97 if len(m.Views) == 0 {
98 return nil // all relationships visible
99 }
100
101 // Resolve each view's element set.
102 type viewSet struct {
103 elems map[string]bool
104 }
105 var views []viewSet
106 for _, view := range m.Views {
107 v := view
108 resolved, err := model.ResolveView(m, &v)
109 if err != nil {
110 continue
111 }
112 elemSet := make(map[string]bool, len(resolved)+1)
113 for _, id := range resolved {
114 elemSet[id] = true
115 }
116 if view.Scope != "" {
117 elemSet[view.Scope] = true
118 }
119 views = append(views, viewSet{elems: elemSet})
120 }
121
122 visible := make(map[string]bool)
123 for i, r := range m.Relationships {
124 for _, vs := range views {
125 from := liftEndpoint(r.From, vs.elems)
126 to := liftEndpoint(r.To, vs.elems)
127 if from != "" && to != "" && from != to {
128 visible[relKey(r.From, r.To, i)] = true
129 break // found on at least one view
130 }
131 }
132 }
133 return visible
134 }
135
136 // computeNewPageOnlyElements returns the set of element IDs that are visible
137 // exclusively on newly created view pages (not on any pre-existing page).
138 // Elements on new pages should not be treated as "deleted from draw.io" because
139 // they simply haven't been forward-synced to those pages yet (#184, #188, #189).
140 func computeNewPageOnlyElements(m *model.BausteinsichtModel, newPageIDs map[string]bool) map[string]bool {
141 if len(newPageIDs) == 0 || len(m.Views) == 0 {
142 return nil
143 }
144
145 // Compute elements visible on existing (non-new) pages.
146 existingPageElems := make(map[string]bool)
147 for viewID, view := range m.Views {
148 pageID := "view-" + viewID
149 if newPageIDs[pageID] {
150 continue // Skip new pages.
151 }
152 v := view
153 resolved, _ := model.ResolveView(m, &v)
154 for _, id := range resolved {
155 existingPageElems[id] = true
156 }
157 if view.Scope != "" {
158 existingPageElems[view.Scope] = true
159 }
160 }
161
162 // Find elements that are visible but ONLY on new pages.
163 allVisible := computeVisibleElements(m)
164 if allVisible == nil {
165 return nil
166 }
167 newOnly := make(map[string]bool)
168 for id := range allVisible {
169 if !existingPageElems[id] {
170 newOnly[id] = true
171 }
172 }
173 return newOnly
174 }
175
176 // DetectChanges performs a three-way diff between the model, draw.io document,
177 // and the last known sync state.
178 // newPageIDs is the set of page IDs that were just created (not yet populated
179 // by forward sync). Elements expected only on new pages are excluded from
180 // draw.io-side deletion detection (#184, #188, #189).
181 func DetectChanges(m *model.BausteinsichtModel, doc *drawio.Document, lastState *SyncState, newPageIDs map[string]bool) *ChangeSet {
182 cs := &ChangeSet{}
183
184 flatModel, _ := model.FlattenElements(m)
185 drawioElems := extractDrawioElements(doc)
186 visibleElems := computeVisibleElements(m)
187 newPageOnly := computeNewPageOnlyElements(m, newPageIDs)
188 detectElementChanges(cs, flatModel, drawioElems, lastState, visibleElems, newPageOnly)
189 detectCrossViewInconsistencies(cs, doc, flatModel, lastState)
190 detectUnmanagedDrawioElements(cs, doc)
191
192 modelRels := buildModelRelMap(m)
193 drawioRels := extractDrawioRelationships(doc)
194 visibleRels := computeVisibleRelationships(m)
195 detectRelationshipChanges(cs, modelRels, drawioRels, lastState, visibleRels)
196
197 return cs
198 }
199
200 // extractDrawioElements gathers element data from all pages in the document.
201 // It reads from child text sub-cells first, falling back to HTML label parsing
202 // for backward compatibility with older draw.io files.
203 func extractDrawioElements(doc *drawio.Document) map[string]drawioElemSnapshot {
204 result := make(map[string]drawioElemSnapshot)
205 for _, page := range doc.Pages() {
206 for _, obj := range page.FindAllElements() {
207 id := obj.SelectAttrValue("bausteinsicht_id", "")
208 if id == "" {
209 continue
210 }
211 // ReadElementFields checks for child text sub-cells first,
212 // then falls back to ParseLabel for backward compat.
213 title, technology, labelDesc := page.ReadElementFields(obj)
214 // Fall back to XML attribute if label doesn't contain technology (#186).
215 if technology == "" {
216 technology = obj.SelectAttrValue("technology", "")
217 }
218 tooltipDesc := obj.SelectAttrValue("tooltip", "")
219 description := tooltipDesc
220 if description == "" {
221 description = labelDesc
222 }
223 // Skip duplicate bausteinsicht_id — keep first occurrence (#213).
224 if _, exists := result[id]; exists {
225 continue
226 }
227 result[id] = drawioElemSnapshot{
228 title: title,
229 technology: technology,
230 description: description,
231 kind: obj.SelectAttrValue("bausteinsicht_kind", ""),
232 }
233 }
234 }
235 return result
236 }
237
238 // detectUnmanagedDrawioElements finds shapes in draw.io that have no
239 // bausteinsicht_id attribute and emits Added element changes for them.
240 // This allows reverse sync to import new elements drawn by the user (#196).
241 func detectUnmanagedDrawioElements(cs *ChangeSet, doc *drawio.Document) {
242 for _, page := range doc.Pages() {
243 root := page.Root()
244 if root == nil {
245 continue
246 }
247 for _, obj := range root.SelectElements("object") {
248 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
249 continue // managed element, already handled
250 }
251 // Skip non-model elements created by forward sync.
252 objID := obj.SelectAttrValue("id", "")
253 if strings.HasPrefix(objID, "nav-back-") ||
254 strings.HasPrefix(objID, metadataPrefix) ||
255 strings.HasPrefix(objID, legendPrefix) {
256 continue
257 }
258 // Check that it wraps a vertex cell (not a connector).
259 cell := obj.SelectElement("mxCell")
260 if cell == nil || cell.SelectAttrValue("vertex", "") != "1" {
261 continue
262 }
263 // Try sub-cell reading first, then HTML label.
264 title, _, _ := page.ReadElementFields(obj)
265 if title == "" {
266 label := obj.SelectAttrValue("label", "")
267 if label == "" {
268 continue
269 }
270 title, _, _ = drawio.ParseLabel(label)
271 }
272 id := sanitizeID(title)
273 if id == "" {
274 continue
275 }
276 cs.DrawioElementChanges = append(cs.DrawioElementChanges, ElementChange{
277 ID: id,
278 Type: Added,
279 NewValue: title,
280 })
281 }
282 }
283 }
284
285 // sanitizeID converts a title to a lowercase, hyphen-separated ID suitable
286 // for use as a model element key. Strips dots, slashes, and backslashes to
287 // prevent IDs that interfere with dot-notation hierarchy (SEC-013).
288 func sanitizeID(title string) string {
289 title = strings.TrimSpace(title)
290 title = strings.ToLower(title)
291 title = strings.ReplaceAll(title, " ", "-")
292 title = strings.NewReplacer(".", "", "/", "", "\\", "").Replace(title)
293 return title
294 }
295
296 // stripScopedPrefix removes the view prefix from a scoped cell ID.
297 // Scoped cell IDs have the format "viewID--elemID" where "--" is the separator.
298 // If the ID does not contain "--", it is returned unchanged (legacy documents).
299 func stripScopedPrefix(cellID string) string {
300 if idx := strings.Index(cellID, "--"); idx >= 0 {
301 return cellID[idx+2:]
302 }
303 return cellID
304 }
305
306 // resolveCellID maps a draw.io cell ID to a canonical element ID using the
307 // cellToElem lookup table. If the cell ID is not in the table (e.g., because
308 // the element was deleted), it falls back to stripping the scoped view prefix.
309 func resolveCellID(cellID string, cellToElem map[string]string) string {
310 if elemID, ok := cellToElem[cellID]; ok {
311 return elemID
312 }
313 return stripScopedPrefix(cellID)
314 }
315
316 // buildCellIDToElemID builds a mapping from draw.io cell IDs to bausteinsicht
317 // element IDs. When views are used, cell IDs are scoped (e.g., "context--customer")
318 // while element IDs are un-scoped (e.g., "customer").
319 func buildCellIDToElemID(doc *drawio.Document) map[string]string {
320 m := make(map[string]string)
321 for _, page := range doc.Pages() {
322 for _, obj := range page.FindAllElements() {
323 elemID := obj.SelectAttrValue("bausteinsicht_id", "")
324 cellID := obj.SelectAttrValue("id", "")
325 if elemID != "" && cellID != "" {
326 m[cellID] = elemID
327 }
328 }
329 // Also map unmanaged elements (no bausteinsicht_id) to their
330 // sanitized label-based IDs so that connectors targeting them
331 // resolve correctly during reverse sync (#211).
332 root := page.Root()
333 if root == nil {
334 continue
335 }
336 for _, obj := range root.SelectElements("object") {
337 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
338 continue
339 }
340 cellID := obj.SelectAttrValue("id", "")
341 if cellID == "" {
342 continue
343 }
344 if strings.HasPrefix(cellID, "nav-back-") {
345 continue
346 }
347 cell := obj.SelectElement("mxCell")
348 if cell == nil || cell.SelectAttrValue("vertex", "") != "1" {
349 continue
350 }
351 label := obj.SelectAttrValue("label", "")
352 if label == "" {
353 continue
354 }
355 title, _, _ := drawio.ParseLabel(label)
356 id := sanitizeID(title)
357 if id != "" {
358 m[cellID] = id
359 }
360 }
361 }
362 return m
363 }
364
365 // extractDrawioRelationships gathers connector data from all pages.
366 // Connector source/target cell IDs are resolved to element IDs using the
367 // bausteinsicht_id attributes of referenced elements.
368 // Lifted connectors (where an endpoint was lifted to a parent because the
369 // original target is not visible on a view) are excluded to avoid phantom
370 // reverse changes.
371 func extractDrawioRelationships(doc *drawio.Document) map[string]RelationshipState {
372 cellToElem := buildCellIDToElemID(doc)
373 result := make(map[string]RelationshipState)
374 for _, page := range doc.Pages() {
375 for _, cell := range page.FindAllConnectors() {
376 fromCell := cell.SelectAttrValue("source", "")
377 toCell := cell.SelectAttrValue("target", "")
378 if fromCell == "" || toCell == "" {
379 continue
380 }
381 // Resolve scoped cell IDs to element IDs.
382 // Fall back to stripping the view prefix from scoped cell IDs
383 // (e.g., "components--onlineshop.db" → "onlineshop.db") when
384 // the element was deleted and is no longer in cellToElem (#166).
385 // For legacy (non-view) documents the raw cell ID is used as-is.
386 from := resolveCellID(fromCell, cellToElem)
387 to := resolveCellID(toCell, cellToElem)
388 // Skip connectors targeting navigation buttons (#205).
389 if strings.HasPrefix(from, "nav-back-") || strings.HasPrefix(to, "nav-back-") {
390 continue
391 }
392 // Extract the relationship index from the connector ID.
393 cellID := cell.SelectAttrValue("id", "")
394 index := parseConnectorIndex(cellID)
395 key := relKey(from, to, index)
396 if _, exists := result[key]; !exists {
397 result[key] = RelationshipState{
398 From: from,
399 To: to,
400 Index: index,
401 Label: cell.SelectAttrValue("value", ""),
402 }
403 }
404 }
405 }
406 return result
407 }
408
409 // parseConnectorIndex extracts the index from a connector ID of the form
410 // "rel-<from>-<to>-<index>". Returns 0 if the ID does not contain an index
411 // (backward compatibility with old connector IDs "rel-<from>-<to>").
412 func parseConnectorIndex(id string) int {
413 if !strings.HasPrefix(id, "rel-") {
414 return 0
415 }
416 // The index is the last segment after the last '-'.
417 lastDash := strings.LastIndex(id, "-")
418 if lastDash < 0 {
419 return 0
420 }
421 indexStr := id[lastDash+1:]
422 var index int
423 if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil {
424 return 0
425 }
426 return index
427 }
428
429 // buildModelRelMap converts model relationships to a map keyed by relKey.
430 func buildModelRelMap(m *model.BausteinsichtModel) map[string]RelationshipState {
431 modelRels := make(map[string]RelationshipState, len(m.Relationships))
432 for i, r := range m.Relationships {
433 modelRels[relKey(r.From, r.To, i)] = RelationshipState{
434 From: r.From,
435 To: r.To,
436 Index: i,
437 Label: r.Label,
438 Kind: r.Kind,
439 }
440 }
441 return modelRels
442 }
443
444 // detectElementChanges performs three-way comparison for elements.
445 // visibleElems is the set of element IDs visible across all views. If nil,
446 // all elements are considered visible (no views defined).
447 // newPageOnly is the set of elements visible ONLY on newly created pages
448 // (not yet populated by forward sync). These are excluded from draw.io-side
449 // deletion detection (#184, #188, #189).
450 func detectElementChanges(
451 cs *ChangeSet,
452 flatModel map[string]*model.Element,
453 drawioElems map[string]drawioElemSnapshot,
454 lastState *SyncState,
455 visibleElems map[string]bool,
456 newPageOnly map[string]bool,
457 ) {
458 allIDsMap := unionElementIDs(flatModel, drawioElems, lastState)
459 allIDs := make([]string, 0, len(allIDsMap))
460 for id := range allIDsMap {
461 allIDs = append(allIDs, id)
462 }
463 sort.Strings(allIDs)
464
465 for _, id := range allIDs {
466 me, inModel := flatModel[id]
467 de, inDrawio := drawioElems[id]
468 lastElem, inLast := lastState.Elements[id]
469
470 // Model side changes
471 switch {
472 case inModel && !inLast:
473 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{ID: id, Type: Added})
474 case !inModel && inLast:
475 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{ID: id, Type: Deleted})
476 case inModel && inLast:
477 appendIfChanged(id, "title", lastElem.Title, me.Title, &cs.ModelElementChanges)
478 appendIfChanged(id, "description", lastElem.Description, me.Description, &cs.ModelElementChanges)
479 appendIfChanged(id, "technology", lastElem.Technology, me.Technology, &cs.ModelElementChanges)
480 appendIfChanged(id, "kind", lastElem.Kind, me.Kind, &cs.ModelElementChanges)
481 }
482
483 // Draw.io side changes
484 switch {
485 case inDrawio && !inLast:
486 cs.DrawioElementChanges = append(cs.DrawioElementChanges, ElementChange{ID: id, Type: Added})
487 case !inDrawio && inLast:
488 // Only treat as deleted if the element should be visible on at least one
489 // view page. Elements not in any view's resolved set are simply filtered
490 // out and their absence from draw.io is expected, not a deletion. (#108, #118)
491 // Also skip elements that are only expected on newly created pages — those
492 // pages haven't been populated by forward sync yet (#184, #188, #189).
493 if visibleElems == nil || visibleElems[id] {
494 if newPageOnly != nil && newPageOnly[id] {
495 continue
496 }
497 // Skip elements that were never rendered to a draw.io page.
498 // When RenderedElements is available (state from v2+), an element
499 // absent from draw.io but not in RenderedElements was never on any
500 // page — it was filtered by views. Forward sync will create it now
501 // that views include it. (#240)
502 // When RenderedElements is nil (old state files), fall back to
503 // treating all elements as rendered (preserving old behavior).
504 if lastState.RenderedElements != nil && !lastState.RenderedElements[id] {
505 continue
506 }
507 cs.DrawioElementChanges = append(cs.DrawioElementChanges, ElementChange{ID: id, Type: Deleted})
508 }
509 case inDrawio && inLast:
510 appendIfChanged(id, "title", lastElem.Title, de.title, &cs.DrawioElementChanges)
511 appendIfChanged(id, "description", lastElem.Description, de.description, &cs.DrawioElementChanges)
512 appendIfChanged(id, "technology", lastElem.Technology, de.technology, &cs.DrawioElementChanges)
513 // Note: kind is not compared on the draw.io side because scope
514 // boundary elements have a derived kind (e.g. "system_boundary")
515 // that legitimately differs from the model kind ("system").
516 }
517
518 // Conflicts: both sides modified the same field
519 if inModel && inDrawio && inLast {
520 checkElemConflict(cs, id, "title", lastElem.Title, me.Title, de.title)
521 checkElemConflict(cs, id, "description", lastElem.Description, me.Description, de.description)
522 checkElemConflict(cs, id, "technology", lastElem.Technology, me.Technology, de.technology)
523 // Note: kind conflicts are not checked because kind is
524 // model-authoritative and draw.io boundary kinds are derived.
525 }
526 }
527 }
528
529 // unionElementIDs returns the union of IDs across all three sources.
530 func unionElementIDs(
531 flatModel map[string]*model.Element,
532 drawioElems map[string]drawioElemSnapshot,
533 lastState *SyncState,
534 ) map[string]struct{} {
535 all := make(map[string]struct{})
536 for id := range flatModel {
537 all[id] = struct{}{}
538 }
539 for id := range lastState.Elements {
540 all[id] = struct{}{}
541 }
542 for id := range drawioElems {
543 all[id] = struct{}{}
544 }
545 return all
546 }
547
548 // detectCrossViewInconsistencies scans ALL pages in the draw.io document
549 // and emits forward changes when an element on any page shows a stale value
550 // that doesn't match the model, even though model and state agree.
551 // This handles the case where reverse sync updated one view but other views
552 // still show the old value (#236).
553 func detectCrossViewInconsistencies(
554 cs *ChangeSet,
555 doc *drawio.Document,
556 flatModel map[string]*model.Element,
557 lastState *SyncState,
558 ) {
559 // Track which element+field combos we've already emitted to avoid duplicates.
560 type fieldKey struct {
561 id, field string
562 }
563 emitted := make(map[fieldKey]bool)
564 // Skip fields that detectElementChanges already emitted as model changes.
565 for _, ch := range cs.ModelElementChanges {
566 if ch.Field != "" {
567 emitted[fieldKey{ch.ID, ch.Field}] = true
568 }
569 }
570 // Skip fields that have a legitimate draw.io-side change — those are user
571 // edits that should be reverse-synced, not overwritten by forward sync.
572 for _, ch := range cs.DrawioElementChanges {
573 if ch.Field != "" {
574 emitted[fieldKey{ch.ID, ch.Field}] = true
575 }
576 }
577
578 for _, page := range doc.Pages() {
579 for _, obj := range page.FindAllElements() {
580 id := obj.SelectAttrValue("bausteinsicht_id", "")
581 if id == "" {
582 continue
583 }
584 me, inModel := flatModel[id]
585 if !inModel {
586 continue
587 }
588 lastElem, inLast := lastState.Elements[id]
589 if !inLast {
590 continue
591 }
592
593 title, technology, labelDesc := page.ReadElementFields(obj)
594 if technology == "" {
595 technology = obj.SelectAttrValue("technology", "")
596 }
597 tooltipDesc := obj.SelectAttrValue("tooltip", "")
598 description := tooltipDesc
599 if description == "" {
600 description = labelDesc
601 }
602
603 // If model and state agree but this page shows a stale value,
604 // emit a forward change so all views are brought up to date.
605 if me.Title == lastElem.Title && title != me.Title {
606 fk := fieldKey{id, "title"}
607 if !emitted[fk] {
608 emitted[fk] = true
609 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{
610 ID: id, Type: Modified, Field: "title",
611 OldValue: title, NewValue: me.Title,
612 })
613 }
614 }
615 if me.Description == lastElem.Description && description != me.Description {
616 fk := fieldKey{id, "description"}
617 if !emitted[fk] {
618 emitted[fk] = true
619 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{
620 ID: id, Type: Modified, Field: "description",
621 OldValue: description, NewValue: me.Description,
622 })
623 }
624 }
625 if me.Technology == lastElem.Technology && technology != me.Technology {
626 fk := fieldKey{id, "technology"}
627 if !emitted[fk] {
628 emitted[fk] = true
629 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{
630 ID: id, Type: Modified, Field: "technology",
631 OldValue: technology, NewValue: me.Technology,
632 })
633 }
634 }
635 }
636 }
637 }
638
639 // appendIfChanged adds a Modified ElementChange if newValue differs from lastValue.
640 func appendIfChanged(id, field, lastValue, newValue string, changes *[]ElementChange) {
641 if newValue != lastValue {
642 *changes = append(*changes, ElementChange{
643 ID: id,
644 Type: Modified,
645 Field: field,
646 OldValue: lastValue,
647 NewValue: newValue,
648 })
649 }
650 }
651
652 // checkElemConflict adds a Conflict when both model and draw.io changed the same field.
653 func checkElemConflict(cs *ChangeSet, id, field, last, modelVal, drawioVal string) {
654 if modelVal != last && drawioVal != last {
655 cs.Conflicts = append(cs.Conflicts, Conflict{
656 ElementID: id,
657 Field: field,
658 ModelValue: modelVal,
659 DrawioValue: drawioVal,
660 LastSyncValue: last,
661 })
662 }
663 }
664
665 // detectRelationshipChanges performs three-way comparison for relationships.
666 // visibleRels is the set of relationship keys that should have a connector on
667 // at least one view page. If nil, all relationships are considered visible
668 // (no views defined). Used to prevent treating filter-removed connectors as
669 // user deletions (#167).
670 func detectRelationshipChanges(
671 cs *ChangeSet,
672 modelRels map[string]RelationshipState,
673 drawioRels map[string]RelationshipState,
674 lastState *SyncState,
675 visibleRels map[string]bool,
676 ) {
677 lastRels := make(map[string]RelationshipState, len(lastState.Relationships))
678 for _, r := range lastState.Relationships {
679 lastRels[relKey(r.From, r.To, r.Index)] = r
680 }
681
682 allKeys := unionRelKeys(modelRels, drawioRels, lastRels)
683
684 for k := range allKeys {
685 mr, inModel := modelRels[k]
686 dr, inDrawio := drawioRels[k]
687 lr, inLast := lastRels[k]
688
689 from, to, index := resolveRelFromTo(mr, lr, dr)
690
691 // Model side
692 switch {
693 case inModel && !inLast:
694 cs.ModelRelationshipChanges = append(cs.ModelRelationshipChanges, RelationshipChange{
695 From: from, To: to, Index: index, Type: Added, NewValue: mr.Label,
696 })
697 case !inModel && inLast:
698 cs.ModelRelationshipChanges = append(cs.ModelRelationshipChanges, RelationshipChange{
699 From: from, To: to, Index: index, Type: Deleted,
700 })
701 case inModel && inLast && mr.Label != lr.Label:
702 cs.ModelRelationshipChanges = append(cs.ModelRelationshipChanges, RelationshipChange{
703 From: from, To: to, Index: index, Type: Modified, Field: "label",
704 OldValue: lr.Label, NewValue: mr.Label,
705 })
706 }
707
708 // Draw.io side
709 switch {
710 case inDrawio && !inLast:
711 // Skip lifted connectors: when a view lifts a relationship
712 // endpoint to a parent (e.g., A→B.child becomes A→B),
713 // the lifted connector should not be treated as a new relationship.
714 if isLiftedRelationship(from, to, modelRels) {
715 continue
716 }
717 cs.DrawioRelationshipChanges = append(cs.DrawioRelationshipChanges, RelationshipChange{
718 From: from, To: to, Index: index, Type: Added, NewValue: dr.Label,
719 })
720 case !inDrawio && inLast:
721 // Only treat as deleted if the relationship should have a connector
722 // on at least one view page. Relationships whose endpoints are not
723 // visible on any view (due to filter changes) are simply absent from
724 // draw.io — not user deletions. (#167)
725 if visibleRels != nil && !visibleRels[k] {
726 continue
727 }
728 // Skip if a lifted version of this relationship exists in draw.io.
729 // When a view lifts endpoints (e.g., cli→model.loader becomes
730 // cli→model), the connector has different keys but still represents
731 // this relationship. Without this check, the original relationship
732 // would be incorrectly deleted (#223).
733 if hasLiftedConnectorInDrawio(from, to, drawioRels) {
734 continue
735 }
736 cs.DrawioRelationshipChanges = append(cs.DrawioRelationshipChanges, RelationshipChange{
737 From: from, To: to, Index: index, Type: Deleted,
738 })
739 case inDrawio && inLast && dr.Label != lr.Label:
740 cs.DrawioRelationshipChanges = append(cs.DrawioRelationshipChanges, RelationshipChange{
741 From: from, To: to, Index: index, Type: Modified, Field: "label",
742 OldValue: lr.Label, NewValue: dr.Label,
743 })
744 }
745 }
746 }
747
748 // unionRelKeys returns the union of relationship keys from all three sources.
749 func unionRelKeys(
750 modelRels, drawioRels, lastRels map[string]RelationshipState,
751 ) map[string]struct{} {
752 all := make(map[string]struct{})
753 for k := range modelRels {
754 all[k] = struct{}{}
755 }
756 for k := range lastRels {
757 all[k] = struct{}{}
758 }
759 for k := range drawioRels {
760 all[k] = struct{}{}
761 }
762 return all
763 }
764
765 // isLiftedRelationship returns true if the relationship from→to is a "lifted"
766 // version of an existing model relationship. A relationship is lifted when a
767 // view shows a connector between parent elements because the original endpoint
768 // is not visible. For example, model has A→B.child but the view only shows A
769 // and B, so the connector is lifted to A→B.
770 func isLiftedRelationship(from, to string, modelRels map[string]RelationshipState) bool {
771 // A self-referencing relationship (from == to) can never be a lifted
772 // version of a child-to-child relationship, because lifting never
773 // collapses two distinct endpoints into the same element (#212).
774 if from == to {
775 return false
776 }
777 for _, mr := range modelRels {
778 // Same from, model to is more specific (to is ancestor of mr.To)
779 if mr.From == from && mr.To != to && strings.HasPrefix(mr.To, to+".") {
780 return true
781 }
782 // Same to, model from is more specific
783 if mr.To == to && mr.From != from && strings.HasPrefix(mr.From, from+".") {
784 return true
785 }
786 // Both endpoints lifted
787 if mr.From != from && mr.To != to &&
788 strings.HasPrefix(mr.From, from+".") && strings.HasPrefix(mr.To, to+".") {
789 return true
790 }
791 }
792 return false
793 }
794
795 // hasLiftedConnectorInDrawio returns true if a connector in drawioRels is a
796 // lifted version of the relationship from→to. This is the inverse of
797 // isLiftedRelationship: here we check if the drawio connector endpoints are
798 // ancestors of the model relationship endpoints.
799 // For example, model has cli→model.loader but drawio has cli→model (lifted).
800 func hasLiftedConnectorInDrawio(from, to string, drawioRels map[string]RelationshipState) bool {
801 for _, dr := range drawioRels {
802 fromMatch := dr.From == from || (dr.From != from && strings.HasPrefix(from, dr.From+"."))
803 toMatch := dr.To == to || (dr.To != to && strings.HasPrefix(to, dr.To+"."))
804 if fromMatch && toMatch && (dr.From != from || dr.To != to) {
805 return true
806 }
807 }
808 return false
809 }
810
811 // resolveRelFromTo returns the from/to/index from the first non-empty source.
812 func resolveRelFromTo(mr, lr, dr RelationshipState) (from, to string, index int) {
813 from, to, index = mr.From, mr.To, mr.Index
814 if from == "" {
815 from, to, index = lr.From, lr.To, lr.Index
816 }
817 if from == "" {
818 from, to, index = dr.From, dr.To, dr.Index
819 }
820 return from, to, index
821 }
822
github.com/docToolchain/Bausteinsicht/internal/sync/engine.go 95.9%
1 package sync
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/drawio"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // SyncResult contains the comprehensive result of a sync cycle.
13 type SyncResult struct {
14 Forward *ForwardResult
15 Reverse *ReverseResult
16 Changes *ChangeSet // The (post-conflict-resolution) changes used for sync.
17 Conflicts []ResolvedConflict
18 Warnings []string
19 }
20
21 // Run executes one full bidirectional sync cycle.
22 // It is a pure function — no file I/O. All data is passed as parameters.
23 //
24 // Sequence (Chapter 6 - Runtime View):
25 // 1. DetectChanges → ChangeSet
26 // 2. Resolve conflicts (model wins)
27 // 3. Remove conflicting fields from DrawioElementChanges
28 // 4. ApplyForward → ForwardResult
29 // 5. ApplyReverse → ReverseResult
30 // 6. Collect all warnings
31 func Run(
32 m *model.BausteinsichtModel,
33 doc *drawio.Document,
34 lastState *SyncState,
35 templates *drawio.TemplateSet,
36 newPageIDs map[string]bool,
37 opts ...ForwardOptions,
38 ) *SyncResult {
39 result := &SyncResult{}
40
41 // Step 1: Detect changes from both sides.
42 // Pass newPageIDs so that elements expected only on newly created pages
43 // are not mistakenly treated as "deleted from draw.io" (#184, #188, #189).
44 changes := DetectChanges(m, doc, lastState, newPageIDs)
45
46 // Step 2: Resolve conflicts.
47 if len(changes.Conflicts) > 0 {
48 resolved := NewModelWinsResolver().Resolve(changes.Conflicts)
49 result.Conflicts = resolved
50
51 // Step 3: For model-wins conflicts, drop the draw.io change so that
52 // ApplyReverse does not overwrite the model value.
53 changes.DrawioElementChanges = filterConflictingDrawioChanges(
54 changes.DrawioElementChanges, resolved,
55 )
56 }
57
58 result.Changes = changes
59
60 // Step 4: Forward sync (model → draw.io).
61 result.Forward = ApplyForward(changes, doc, templates, m, opts...)
62
63 // Step 5: Reverse sync (draw.io → model).
64 result.Reverse = ApplyReverse(changes, m)
65
66 // Step 6: Warn about model elements not visible in any view (#183).
67 if len(m.Views) > 0 {
68 visible := computeVisibleElements(m)
69 flat, _ := model.FlattenElements(m)
70 var invisible []string
71 for id := range flat {
72 if visible != nil && !visible[id] {
73 invisible = append(invisible, id)
74 }
75 }
76 if len(invisible) > 0 {
77 sort.Strings(invisible)
78 for _, id := range invisible {
79 result.Warnings = append(result.Warnings,
80 fmt.Sprintf("Element %q exists in the model but is not visible in any view — add it to a view's include list", id))
81 }
82 }
83 }
84
85 // Step 7: Collect all warnings.
86 result.Warnings = append(result.Warnings, result.Forward.Warnings...)
87 result.Warnings = append(result.Warnings, result.Reverse.Warnings...)
88 for _, rc := range result.Conflicts {
89 result.Warnings = append(result.Warnings, rc.Warning)
90 }
91
92 return result
93 }
94
95 // RemoveOrphanedViewPages removes pages from the draw.io document that were
96 // created for views that no longer exist in the model. Pages are identified as
97 // view-managed if their id starts with the "view-" prefix. Pages whose id does
98 // not start with "view-" are preserved (e.g., default template pages).
99 func RemoveOrphanedViewPages(doc *drawio.Document, m *model.BausteinsichtModel) {
100 // Build the set of expected view page IDs from the model.
101 expectedPages := make(map[string]bool, len(m.Views))
102 for viewID := range m.Views {
103 expectedPages["view-"+viewID] = true
104 }
105
106 // Iterate pages and collect orphaned view page IDs.
107 var orphans []string
108 for _, page := range doc.Pages() {
109 pageID := page.ID()
110 if !strings.HasPrefix(pageID, "view-") {
111 continue // Not a view-managed page; preserve it.
112 }
113 if !expectedPages[pageID] {
114 orphans = append(orphans, pageID)
115 }
116 }
117
118 // Remove orphaned pages.
119 for _, id := range orphans {
120 doc.RemovePage(id)
121 }
122 }
123
124 // filterConflictingDrawioChanges removes draw.io element changes for fields
125 // that were resolved in favour of the model (Winner == "model").
126 func filterConflictingDrawioChanges(
127 drawioChanges []ElementChange,
128 resolved []ResolvedConflict,
129 ) []ElementChange {
130 // Build a set of (elementID, field) pairs that model won.
131 type conflictKey struct {
132 id string
133 field string
134 }
135 modelWins := make(map[conflictKey]struct{}, len(resolved))
136 for _, rc := range resolved {
137 if rc.Winner == "model" {
138 modelWins[conflictKey{rc.ElementID, rc.Field}] = struct{}{}
139 }
140 }
141
142 if len(modelWins) == 0 {
143 return drawioChanges
144 }
145
146 filtered := make([]ElementChange, 0, len(drawioChanges))
147 for _, ch := range drawioChanges {
148 if _, skip := modelWins[conflictKey{ch.ID, ch.Field}]; !skip {
149 filtered = append(filtered, ch)
150 }
151 }
152 return filtered
153 }
154
github.com/docToolchain/Bausteinsicht/internal/sync/forward.go 80.8%
1 package sync
2
3 import (
4 "fmt"
5 "sort"
6 "strconv"
7 "strings"
8
9 "github.com/beevik/etree"
10 "github.com/docToolchain/Bausteinsicht/internal/drawio"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 )
13
14 const (
15 newElementMarker = "strokeColor=#FF0000;dashed=1;"
16 elementGap = 40.0
17 defaultWidth = 120.0
18 defaultHeight = 60.0
19 )
20
21 // applyTagStyles applies styles defined in tag definitions to an element's style.
22 // For each tag on the element, looks up the corresponding TagDefinition and merges
23 // any styles defined there into the element's style string.
24 func applyTagStyles(elem *model.Element, spec *model.Specification, baseStyle string) string {
25 if len(elem.Tags) == 0 {
26 return baseStyle
27 }
28
29 // Build a map of tag ID to TagDefinition for quick lookup
30 tagDefMap := make(map[string]model.TagDefinition)
31 for _, tagDef := range spec.Tags {
32 tagDefMap[tagDef.ID] = tagDef
33 }
34
35 // Collect all style properties from tags that apply to this element
36 styleProps := make(map[string]interface{})
37 for _, tagID := range elem.Tags {
38 if tagDef, ok := tagDefMap[tagID]; ok && len(tagDef.Style) > 0 {
39 for k, v := range tagDef.Style {
40 styleProps[k] = v
41 }
42 }
43 }
44
45 // If no tag styles found, return base style
46 if len(styleProps) == 0 {
47 return baseStyle
48 }
49
50 // Convert style properties to a string and merge with baseStyle
51 tagStyleStr := ""
52 for k, v := range styleProps {
53 tagStyleStr += k + "=" + toString(v) + ";"
54 }
55
56 return mergeStyles(baseStyle, tagStyleStr)
57 }
58
59 // toString converts a value to a string representation suitable for draw.io style attributes.
60 func toString(v interface{}) string {
61 switch val := v.(type) {
62 case string:
63 return val
64 case float64:
65 if val == float64(int64(val)) {
66 return strconv.FormatInt(int64(val), 10)
67 }
68 return strconv.FormatFloat(val, 'f', -1, 64)
69 case int:
70 return strconv.Itoa(val)
71 case bool:
72 return strconv.FormatBool(val)
73 default:
74 return fmt.Sprintf("%v", v)
75 }
76 }
77
78 // ForwardOptions holds optional parameters for forward sync.
79 type ForwardOptions struct {
80 ModelPath string // path to the model file, shown in metadata box
81 SyncTime string // timestamp string, shown in metadata box
82 Relayout bool // when true, clear and re-layout all view pages
83 }
84
85 // ForwardResult summarises the changes applied to a draw.io document.
86 type ForwardResult struct {
87 ElementsCreated int
88 ElementsUpdated int
89 ElementsDeleted int
90 ConnectorsCreated int
91 ConnectorsUpdated int
92 ConnectorsDeleted int
93 MetadataUpdated int // metadata and legend boxes created/updated
94 Warnings []string
95 }
96
97 // ApplyForward applies ModelElementChanges and ModelRelationshipChanges from cs
98 // to doc, using templates for styles and m for element data.
99 // When the model defines views, elements and relationships are placed on their
100 // corresponding view pages. Without views, falls back to the first page.
101 // opts is optional — pass nil to skip metadata/legend generation.
102 func ApplyForward(
103 cs *ChangeSet,
104 doc *drawio.Document,
105 templates *drawio.TemplateSet,
106 m *model.BausteinsichtModel,
107 opts ...ForwardOptions,
108 ) *ForwardResult {
109 result := &ForwardResult{}
110 flat, _ := model.FlattenElements(m)
111
112 var fwdOpts ForwardOptions
113 if len(opts) > 0 {
114 fwdOpts = opts[0]
115 }
116
117 if len(m.Views) == 0 {
118 applyForwardToPage(cs, doc, templates, flat, nil, m, result)
119 return result
120 }
121
122 applyForwardPerView(cs, doc, templates, flat, m, &fwdOpts, result)
123 return result
124 }
125
126 // applyForwardToPage applies all changes to a single page (legacy/no-views mode).
127 func applyForwardToPage(
128 cs *ChangeSet,
129 doc *drawio.Document,
130 templates *drawio.TemplateSet,
131 flat map[string]*model.Element,
132 elemFilter map[string]bool,
133 m *model.BausteinsichtModel,
134 result *ForwardResult,
135 ) {
136 page := firstPage(doc)
137 if page == nil {
138 result.Warnings = append(result.Warnings, "no page found in document")
139 return
140 }
141 applyChangesToPage(cs, page, templates, flat, elemFilter, "", "", &m.Specification, result)
142
143 // Reconcile orphaned elements: remove any element on the page whose
144 // bausteinsicht_id is not present in the current model. This handles
145 // cases where the sync state is missing or the model was emptied. (#110)
146 reconcileOrphanedElements(page, flat, result)
147
148 // Synchronize decision badges with the model
149 synchronizeDecisionBadges(page, m)
150 }
151
152 // applyForwardPerView iterates over model views and applies changes per page.
153 func applyForwardPerView(
154 cs *ChangeSet,
155 doc *drawio.Document,
156 templates *drawio.TemplateSet,
157 flat map[string]*model.Element,
158 m *model.BausteinsichtModel,
159 opts *ForwardOptions,
160 result *ForwardResult,
161 ) {
162 // Build drill-down link map: elementID → "data:page/id,view-<viewID>"
163 // An element gets a link when a view's scope matches that element.
164 drillDownLinks := make(map[string]string)
165 for vID, v := range m.Views {
166 if v.Scope != "" {
167 drillDownLinks[v.Scope] = "data:page/id,view-" + vID
168 }
169 }
170
171 for viewID, view := range m.Views {
172 pageID := "view-" + viewID
173 page := doc.GetPage(pageID)
174 if page == nil {
175 result.Warnings = append(result.Warnings,
176 "no page found for view: "+viewID)
177 continue
178 }
179
180 viewCopy := view
181 resolved, err := model.ResolveView(m, &viewCopy)
182 if err != nil {
183 result.Warnings = append(result.Warnings,
184 "resolving view "+viewID+": "+err.Error())
185 continue
186 }
187
188 elemSet := make(map[string]bool, len(resolved))
189 for _, id := range resolved {
190 elemSet[id] = true
191 }
192
193 scopeID := view.Scope
194
195 // --relayout: clear all managed elements from the page so
196 // populateNewPage treats it as a fresh page.
197 if opts != nil && opts.Relayout {
198 clearPageElements(page)
199 }
200
201 if scopeID != "" {
202 createScopeBoundary(scopeID, viewID, page, templates, flat, &m.Specification, result)
203 // Include scope element in the filter so connectors targeting the
204 // boundary element are rendered (#217).
205 elemSet[scopeID] = true
206 }
207
208 // Populate resolved elements that aren't already on the page.
209 // This runs BEFORE applyChangesToPage so the layout engine can
210 // position elements on fresh pages. applyChangesToPage will then
211 // skip Added elements that are already placed. For existing pages,
212 // this handles elements newly included via view changes (#231).
213 populateNewPage(page, viewID, scopeID, templates, flat, elemSet, m, result)
214
215 applyChangesToPage(cs, page, templates, flat, elemSet, viewID, scopeID, &m.Specification, result)
216
217 // Populate connectors for relationships whose endpoints are both
218 // on the page but whose connector doesn't exist yet. This handles
219 // relationships involving newly populated elements (#231).
220 populateConnectors(page, viewID, scopeID, m, elemSet, templates, result)
221
222 // Reconciliation: remove elements on the page that are no longer
223 // in the resolved view (e.g., after exclude list changes). #102
224 reconcileViewPage(page, elemSet, flat, scopeID, viewID, result)
225
226 // Set drill-down links on elements that have a detail view. (#198)
227 applyDrillDownLinks(page, drillDownLinks)
228
229 // Create back-navigation button on detail views (views with scope). (#198)
230 if scopeID != "" {
231 createBackNavButton(page, viewID, scopeID, m)
232 }
233
234 // Create metadata and legend boxes on each view page. (#233)
235 metadataEnabled := m.Config.Metadata == nil || *m.Config.Metadata
236 legendEnabled := m.Config.Legend == nil || *m.Config.Legend
237
238 if metadataEnabled && opts != nil {
239 if createMetadata(page, viewID, view, m.Config, opts.ModelPath, opts.SyncTime) {
240 result.MetadataUpdated++
241 }
242 }
243 if legendEnabled {
244 if createLegend(page, viewID, m.Specification, templates, elemSet, flat) {
245 result.MetadataUpdated++
246 }
247 }
248
249 // Synchronize decision badges with the model
250 synchronizeDecisionBadges(page, m)
251 }
252 }
253
254 // populateNewPage creates elements on a view page for all elements in
255 // the view's resolved set that aren't already present.
256 // For new pages, this populates elements already in sync state (#184, #188).
257 // For existing pages, this adds elements newly included via view changes (#231).
258 func populateNewPage(
259 page *drawio.Page,
260 viewID string,
261 scopeID string,
262 templates *drawio.TemplateSet,
263 flat map[string]*model.Element,
264 elemSet map[string]bool,
265 m *model.BausteinsichtModel,
266 result *ForwardResult,
267 ) {
268 // Collect elements that need placement.
269 var toPlace []string
270 for id := range elemSet {
271 if id == scopeID {
272 continue
273 }
274 if page.FindElement(id) != nil {
275 continue
276 }
277 toPlace = append(toPlace, id)
278 }
279 if len(toPlace) == 0 {
280 return
281 }
282
283 // Determine if this is a fresh page (no existing bausteinsicht elements
284 // besides the scope boundary which is created before this function runs).
285 existingElems := page.FindAllElements()
286 nonBoundaryCount := 0
287 for _, obj := range existingElems {
288 if obj.SelectAttrValue("bausteinsicht_id", "") != scopeID || scopeID == "" {
289 nonBoundaryCount++
290 }
291 }
292 isFreshPage := nonBoundaryCount == 0
293
294 // Look up the view's layout mode.
295 layoutMode := ""
296 for vID, v := range m.Views {
297 if "view-"+vID == page.ID() {
298 layoutMode = v.Layout
299 break
300 }
301 }
302
303 if isFreshPage {
304 // Use layout engine for fresh pages.
305 lr := computeLayout(toPlace, flat, templates, m.ElementOrder, scopeID, layoutMode, m.Relationships)
306
307 // Resize and reposition the scope boundary if the layout engine computed dimensions.
308 if scopeID != "" && lr.BoundaryWidth > 0 && lr.BoundaryHeight > 0 {
309 resizeScopeBoundary(page, scopeID, lr.BoundaryX, lr.BoundaryY, lr.BoundaryWidth, lr.BoundaryHeight)
310 }
311
312 sort.Strings(toPlace)
313 for _, id := range toPlace {
314 pos, ok := lr.Positions[id]
315 if !ok {
316 continue
317 }
318 placeSingleElement(id, viewID, scopeID, page, templates, flat, &m.Specification, pos.X, pos.Y, false, result)
319 }
320 } else {
321 // Incremental: fall back to cursor-based placement.
322 pl := computePlacement(page)
323 for _, id := range toPlace {
324 applyElementAdded(id, viewID, scopeID, page, templates, flat, &m.Specification, &pl, result)
325 }
326 }
327 }
328
329 // populateConnectors creates connectors for all model relationships whose
330 // (possibly lifted) endpoints are both on the page but no connector exists yet.
331 // This ensures relationships involving newly populated elements are rendered (#231).
332 func populateConnectors(
333 page *drawio.Page,
334 viewID string,
335 scopeID string,
336 m *model.BausteinsichtModel,
337 elemSet map[string]bool,
338 templates *drawio.TemplateSet,
339 result *ForwardResult,
340 ) {
341 liftedSeen := make(map[string]bool)
342 for i, rel := range m.Relationships {
343 from := liftEndpoint(rel.From, elemSet)
344 to := liftEndpoint(rel.To, elemSet)
345 if from == "" || to == "" {
346 continue
347 }
348 // Skip self-referencing lifted relationships.
349 if from == to && (from != rel.From || to != rel.To) {
350 continue
351 }
352 // Skip connectors between the scope boundary and external elements.
353 // The boundary is a visual container — scope↔external relationships
354 // belong in the parent view. Child↔scope connections are allowed.
355 if scopeID != "" && isScopeExternalConnector(from, to, scopeID) {
356 continue
357 }
358
359 isLifted := from != rel.From || to != rel.To
360 pairKey := from + "->" + to
361 if isLifted {
362 if liftedSeen[pairKey] {
363 continue
364 }
365 liftedSeen[pairKey] = true
366 } else {
367 liftedSeen[pairKey] = true
368 }
369
370 srcRef := scopedCellID(viewID, from)
371 tgtRef := scopedCellID(viewID, to)
372 if page.FindConnector(srcRef, tgtRef, i) != nil {
373 continue // Already exists.
374 }
375 style := templates.GetConnectorStyle()
376 data := drawio.ConnectorData{
377 From: from,
378 To: to,
379 Label: rel.Label,
380 SourceRef: srcRef,
381 TargetRef: tgtRef,
382 Index: i,
383 }
384 page.CreateConnector(data, style)
385 result.ConnectorsCreated++
386 }
387 }
388
389 // scopedCellID returns a page-scoped cell ID to ensure file-wide uniqueness.
390 // If viewID is empty, returns the raw element ID (legacy mode).
391 func scopedCellID(viewID, elemID string) string {
392 if viewID == "" {
393 return elemID
394 }
395 return viewID + "--" + elemID
396 }
397
398 // isChildOf returns true if id is a direct or nested child of parentID.
399 // Example: isChildOf("shop.api", "shop") → true
400 func isChildOf(id, parentID string) bool {
401 return strings.HasPrefix(id, parentID+".")
402 }
403
404 // isScopeExternalConnector returns true if one endpoint is the scope and
405 // the other is NOT a child of the scope. Child↔scope connections are allowed,
406 // but scope↔external connections should be shown in the parent view.
407 func isScopeExternalConnector(from, to, scopeID string) bool {
408 if from == scopeID && !isChildOf(to, scopeID) {
409 return true
410 }
411 if to == scopeID && !isChildOf(from, scopeID) {
412 return true
413 }
414 return false
415 }
416
417 // liftEndpoint returns id if it is in elemFilter. Otherwise it walks up the
418 // parent chain (by removing the last dot-segment) until a parent is found in
419 // the filter. Returns "" if no ancestor is present on the page.
420 func liftEndpoint(id string, elemFilter map[string]bool) string {
421 if elemFilter[id] {
422 return id
423 }
424 for {
425 dot := strings.LastIndex(id, ".")
426 if dot < 0 {
427 return ""
428 }
429 id = id[:dot]
430 if elemFilter[id] {
431 return id
432 }
433 }
434 }
435
436 // createScopeBoundary creates a boundary/swimlane element for the scope element
437 // of a view (e.g., the parent system in a container view).
438 // spec provides tag definitions for applying tag-based styles.
439 func createScopeBoundary(
440 scopeID string,
441 viewID string,
442 page *drawio.Page,
443 templates *drawio.TemplateSet,
444 flat map[string]*model.Element,
445 spec *model.Specification,
446 result *ForwardResult,
447 ) {
448 // Skip if already present on the page.
449 if page.FindElement(scopeID) != nil {
450 return
451 }
452
453 elem, ok := flat[scopeID]
454 if !ok {
455 result.Warnings = append(result.Warnings, "scope element not found in model: "+scopeID)
456 return
457 }
458
459 boundaryKind := elem.Kind + "_boundary"
460 ts, ok := templates.GetBoundaryStyle(boundaryKind)
461 if !ok {
462 ts = drawio.TemplateStyle{Width: 400, Height: 300}
463 result.Warnings = append(result.Warnings, "no boundary template for kind: "+boundaryKind)
464 }
465
466 baseStyle := ts.Style
467 // Apply tag-based styles if any tags are defined on the element
468 if spec != nil && len(elem.Tags) > 0 {
469 baseStyle = applyTagStyles(elem, spec, baseStyle)
470 }
471
472 style := baseStyle
473 width := ts.Width
474 if width == 0 {
475 width = 400
476 }
477 height := ts.Height
478 if height == 0 {
479 height = 300
480 }
481
482 data := drawio.ElementData{
483 ID: scopeID,
484 CellID: scopedCellID(viewID, scopeID),
485 Kind: boundaryKind,
486 Title: elem.Title,
487 Technology: elem.Technology,
488 Description: elem.Description,
489 X: elementGap,
490 Y: elementGap,
491 Width: width,
492 Height: height,
493 // Boundaries don't use sub-cells — they use the swimlane header for the label.
494 }
495
496 if err := page.CreateElement(data, style); err != nil {
497 result.Warnings = append(result.Warnings, "failed to create scope boundary "+scopeID+": "+err.Error())
498 }
499 }
500
501 // applyChangesToPage applies element and relationship changes to a single page.
502 // If elemFilter is nil, all changes are applied. Otherwise only elements in the
503 // filter set are processed, and relationships are only created when both
504 // endpoints are in the filter set.
505 // viewID is used to scope cell IDs for file-wide uniqueness (empty = legacy).
506 // scopeID identifies the boundary element for parenting children (empty = no scope).
507 // spec provides tag definitions for applying tag-based styles.
508 func applyChangesToPage(
509 cs *ChangeSet,
510 page *drawio.Page,
511 templates *drawio.TemplateSet,
512 flat map[string]*model.Element,
513 elemFilter map[string]bool,
514 viewID string,
515 scopeID string,
516 spec *model.Specification,
517 result *ForwardResult,
518 ) {
519 pl := computePlacement(page)
520
521 for _, ch := range cs.ModelElementChanges {
522 switch ch.Type {
523 case Added:
524 if elemFilter != nil && !elemFilter[ch.ID] {
525 continue
526 }
527 applyElementAdded(ch.ID, viewID, scopeID, page, templates, flat, spec, &pl, result)
528 case Modified:
529 if elemFilter != nil && !elemFilter[ch.ID] && ch.ID != scopeID {
530 continue
531 }
532 applyElementModified(ch, page, templates, flat, result)
533 case Deleted:
534 // Deleted elements are removed from all pages where they exist,
535 // regardless of the current view filter — a deleted element is
536 // no longer in the model, so it can't appear in any view's
537 // resolved set. We just check if it exists on this page.
538 cellID := scopedCellID(viewID, ch.ID)
539 if page.FindElement(ch.ID) != nil {
540 // Delete connectors referencing this element's cell ID before
541 // removing the element itself. (#101)
542 result.ConnectorsDeleted += countConnectorsFor(page, cellID)
543 page.DeleteConnectorsFor(cellID)
544 page.DeleteElement(ch.ID)
545 result.ElementsDeleted++
546 }
547 }
548 }
549
550 liftedSeen := make(map[string]bool)
551
552 // Process relationships in two passes: direct first, then lifted.
553 // This ensures that when a direct relationship (e.g., api→db) and a
554 // lifted relationship (e.g., api.catalog→db lifted to api→db) map to
555 // the same pair, the direct one's label is used for the connector.
556 for pass := 0; pass < 2; pass++ {
557 for _, ch := range cs.ModelRelationshipChanges {
558 switch ch.Type {
559 case Deleted:
560 if pass != 0 {
561 continue
562 }
563 // For deletions, use scoped cell IDs to find and remove the
564 // connector. The original endpoints may no longer be in the
565 // view's element filter, so we bypass lifting entirely.
566 fromRef := scopedCellID(viewID, ch.From)
567 toRef := scopedCellID(viewID, ch.To)
568 if page.FindConnector(fromRef, toRef, ch.Index) != nil {
569 page.DeleteConnector(fromRef, toRef, ch.Index)
570 result.ConnectorsDeleted++
571 }
572 default:
573 from := ch.From
574 to := ch.To
575 if elemFilter != nil {
576 from = liftEndpoint(from, elemFilter)
577 to = liftEndpoint(to, elemFilter)
578 if from == "" || to == "" || (from == to && (from != ch.From || to != ch.To)) {
579 continue
580 }
581 // Skip connectors between scope boundary and externals.
582 if scopeID != "" && isScopeExternalConnector(from, to, scopeID) {
583 continue
584 }
585 }
586 isLifted := from != ch.From || to != ch.To
587 if pass == 0 && isLifted {
588 continue // First pass: only direct relationships
589 }
590 if pass == 1 && !isLifted {
591 continue // Second pass: only lifted relationships
592 }
593 lifted := RelationshipChange{From: from, To: to, Index: ch.Index, Type: ch.Type, NewValue: ch.NewValue}
594 switch ch.Type {
595 case Added:
596 // Only deduplicate lifted relationships. When multiple
597 // child relationships (e.g., a.x→b.z and a.y→b.z) are
598 // lifted to the same parent pair (a→b), only one connector
599 // should be created. Direct (non-lifted) relationships
600 // with the same pair must not be deduplicated. (#142)
601 pairKey := from + "->" + to
602 if isLifted {
603 // Skip lifted relationships when a direct relationship
604 // or another lifted relationship already covers this
605 // pair. (#142, #197)
606 if liftedSeen[pairKey] {
607 continue
608 }
609 liftedSeen[pairKey] = true
610 } else {
611 // Record direct relationships so lifted ones targeting
612 // the same pair are suppressed in pass 1. (#197)
613 liftedSeen[pairKey] = true
614 }
615 applyRelAdded(lifted, viewID, page, templates, result)
616 case Modified:
617 page.UpdateConnectorLabel(from, to, ch.Index, ch.NewValue)
618 result.ConnectorsUpdated++
619 }
620 }
621 }
622 }
623 }
624
625 // firstPage returns the first page in doc, or nil if there are none.
626 func firstPage(doc *drawio.Document) *drawio.Page {
627 pages := doc.Pages()
628 if len(pages) == 0 {
629 return nil
630 }
631 return pages[0]
632 }
633
634 // placement tracks where the next new element should be placed.
635 type placement struct {
636 nextX float64
637 nextY float64
638 }
639
640 // computePlacement scans existing elements on a page and returns a placement
641 // state positioned one row below all existing content.
642 func computePlacement(page *drawio.Page) placement {
643 maxY := 0.0
644 for _, obj := range page.FindAllElements() {
645 cell := obj.FindElement("mxCell")
646 if cell == nil {
647 continue
648 }
649 geo := cell.FindElement("mxGeometry")
650 if geo == nil {
651 continue
652 }
653 y, _ := strconv.ParseFloat(geo.SelectAttrValue("y", "0"), 64)
654 h, _ := strconv.ParseFloat(geo.SelectAttrValue("height", "0"), 64)
655 if bottom := y + h; bottom > maxY {
656 maxY = bottom
657 }
658 }
659
660 startY := maxY
661 if maxY > 0 {
662 startY = maxY + elementGap
663 }
664 return placement{nextX: elementGap, nextY: startY}
665 }
666
667 // applyElementAdded creates a new element on page with a visual new-element marker.
668 // If scopeID is set and the element is a child of the scope, it is parented to the
669 // scope boundary cell.
670 // spec provides tag definitions for applying tag-based styles.
671 func applyElementAdded(
672 id string,
673 viewID string,
674 scopeID string,
675 page *drawio.Page,
676 templates *drawio.TemplateSet,
677 flat map[string]*model.Element,
678 spec *model.Specification,
679 pl *placement,
680 result *ForwardResult,
681 ) {
682 // Skip layout/creation if element already exists on the page (prevents duplicates on sync
683 // state reset, #141). Still update content so model values are applied (e.g. description
684 // truncation kicks in even on existing elements after a state reset).
685 if page.FindElement(id) != nil {
686 if elem, ok := flat[id]; ok {
687 page.UpdateElement(id, drawio.ElementData{
688 Title: elem.Title,
689 Technology: elem.Technology,
690 Description: elem.Description,
691 })
692 }
693 return
694 }
695
696 elem, ok := flat[id]
697 if !ok {
698 result.Warnings = append(result.Warnings, "element not found in model: "+id)
699 return
700 }
701
702 ts, ok := templates.GetStyle(elem.Kind)
703 if !ok {
704 ts = drawio.TemplateStyle{Width: defaultWidth, Height: defaultHeight}
705 result.Warnings = append(result.Warnings, "no template style for kind: "+elem.Kind)
706 }
707
708 // Apply tag-based styles if any tags are defined on the element
709 baseStyle := ts.Style
710 if spec != nil && len(elem.Tags) > 0 {
711 baseStyle = applyTagStyles(elem, spec, baseStyle)
712 }
713
714 style := mergeStyles(baseStyle, newElementMarker)
715
716 width := ts.Width
717 if width == 0 {
718 width = defaultWidth
719 }
720 height := ts.Height
721 if height == 0 {
722 height = defaultHeight
723 }
724
725 data := drawio.ElementData{
726 ID: id,
727 CellID: scopedCellID(viewID, id),
728 Kind: elem.Kind,
729 Title: elem.Title,
730 Technology: elem.Technology,
731 Description: elem.Description,
732 X: pl.nextX,
733 Y: pl.nextY,
734 Width: width,
735 Height: height,
736 SubCells: subCellsFromTemplate(ts),
737 }
738
739 // Parent children of the scope element to the boundary cell.
740 if scopeID != "" && isChildOf(id, scopeID) {
741 data.ParentID = scopedCellID(viewID, scopeID)
742 }
743
744 if err := page.CreateElement(data, style); err != nil {
745 result.Warnings = append(result.Warnings, "failed to create element "+id+": "+err.Error())
746 return
747 }
748
749 pl.nextX += width + elementGap
750 result.ElementsCreated++
751 }
752
753 // placeSingleElement creates a new element at specific coordinates (used by layout engine).
754 func placeSingleElement(
755 id string,
756 viewID string,
757 scopeID string,
758 page *drawio.Page,
759 templates *drawio.TemplateSet,
760 flat map[string]*model.Element,
761 spec *model.Specification,
762 x, y float64,
763 markNew bool,
764 result *ForwardResult,
765 ) {
766 if page.FindElement(id) != nil {
767 return
768 }
769
770 elem, ok := flat[id]
771 if !ok {
772 result.Warnings = append(result.Warnings, "element not found in model: "+id)
773 return
774 }
775
776 ts, ok := templates.GetStyle(elem.Kind)
777 if !ok {
778 ts = drawio.TemplateStyle{Width: defaultWidth, Height: defaultHeight}
779 result.Warnings = append(result.Warnings, "no template style for kind: "+elem.Kind)
780 }
781
782 baseStyle := ts.Style
783 // Apply tag-based styles if any tags are defined on the element
784 if spec != nil && len(elem.Tags) > 0 {
785 baseStyle = applyTagStyles(elem, spec, baseStyle)
786 }
787
788 style := baseStyle
789 if markNew {
790 style = mergeStyles(baseStyle, newElementMarker)
791 }
792
793 width := ts.Width
794 if width == 0 {
795 width = defaultWidth
796 }
797 height := ts.Height
798 if height == 0 {
799 height = defaultHeight
800 }
801
802 data := drawio.ElementData{
803 ID: id,
804 CellID: scopedCellID(viewID, id),
805 Kind: elem.Kind,
806 Title: elem.Title,
807 Technology: elem.Technology,
808 Description: elem.Description,
809 X: x,
810 Y: y,
811 Width: width,
812 Height: height,
813 SubCells: subCellsFromTemplate(ts),
814 }
815
816 if scopeID != "" && isChildOf(id, scopeID) {
817 data.ParentID = scopedCellID(viewID, scopeID)
818 }
819
820 if err := page.CreateElement(data, style); err != nil {
821 result.Warnings = append(result.Warnings, "failed to create element "+id+": "+err.Error())
822 return
823 }
824
825 result.ElementsCreated++
826 }
827
828 // resizeScopeBoundary updates the geometry and position of an existing scope boundary element.
829 func resizeScopeBoundary(page *drawio.Page, scopeID string, x, y, width, height float64) {
830 obj := page.FindElement(scopeID)
831 if obj == nil {
832 return
833 }
834 cell := obj.FindElement("mxCell")
835 if cell == nil {
836 return
837 }
838 geo := cell.FindElement("mxGeometry")
839 if geo == nil {
840 return
841 }
842 geo.CreateAttr("x", strconv.FormatFloat(x, 'f', -1, 64))
843 geo.CreateAttr("y", strconv.FormatFloat(y, 'f', -1, 64))
844 geo.CreateAttr("width", strconv.FormatFloat(width, 'f', -1, 64))
845 geo.CreateAttr("height", strconv.FormatFloat(height, 'f', -1, 64))
846 }
847
848 // clearPageElements removes all managed bausteinsicht elements and connectors
849 // from a page. Used by --relayout to allow the layout engine to reposition
850 // everything from scratch.
851 func clearPageElements(page *drawio.Page) {
852 root := page.Root()
853 if root == nil {
854 return
855 }
856 // Collect elements to remove (cannot modify tree while iterating).
857 var toRemove []*etree.Element
858 for _, obj := range root.SelectElements("object") {
859 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
860 toRemove = append(toRemove, obj)
861 }
862 }
863 for _, cell := range root.SelectElements("mxCell") {
864 if strings.HasPrefix(cell.SelectAttrValue("id", ""), "rel-") {
865 toRemove = append(toRemove, cell)
866 }
867 }
868 for _, el := range toRemove {
869 root.RemoveChild(el)
870 }
871 }
872
873 // subCellsFromTemplate creates SubCellTemplates from a TemplateStyle.
874 // Returns nil if the template has no sub-cell definitions.
875 func subCellsFromTemplate(ts drawio.TemplateStyle) *drawio.SubCellTemplates {
876 if ts.TitleStyle == nil {
877 return nil
878 }
879 return &drawio.SubCellTemplates{
880 Title: ts.TitleStyle,
881 Tech: ts.TechStyle,
882 Desc: ts.DescStyle,
883 }
884 }
885
886 // applyElementModified updates the changed field of an existing element.
887 func applyElementModified(
888 ch ElementChange,
889 page *drawio.Page,
890 templates *drawio.TemplateSet,
891 flat map[string]*model.Element,
892 result *ForwardResult,
893 ) {
894 elem, ok := flat[ch.ID]
895 if !ok {
896 result.Warnings = append(result.Warnings, "element not found in model for update: "+ch.ID)
897 return
898 }
899
900 // Handle kind changes separately (only updates attribute and style).
901 if ch.Field == "kind" {
902 ts, ok := templates.GetStyle(elem.Kind)
903 if ok {
904 page.UpdateElementKind(ch.ID, elem.Kind, ts.Style)
905 } else {
906 page.UpdateElementKind(ch.ID, elem.Kind, "")
907 }
908 result.ElementsUpdated++
909 return
910 }
911
912 // When a specific field is known, read the current draw.io values and only
913 // override the changed field. This prevents overwriting draw.io-side changes
914 // to other fields during concurrent modification. (#109)
915 title := elem.Title
916 technology := elem.Technology
917 description := elem.Description
918
919 if ch.Field != "" {
920 obj := page.FindElement(ch.ID)
921 if obj != nil {
922 // Use ReadElementFields which handles both sub-cells and HTML labels.
923 curTitle, curTech, curDesc := page.ReadElementFields(obj)
924 curTooltip := obj.SelectAttrValue("tooltip", "")
925 if curDesc == "" {
926 curDesc = curTooltip
927 }
928
929 // Start from current draw.io values, override only the changed field.
930 title = curTitle
931 technology = curTech
932 description = curDesc
933
934 switch ch.Field {
935 case "title":
936 title = elem.Title
937 case "technology":
938 technology = elem.Technology
939 case "description":
940 description = elem.Description
941 }
942 }
943 }
944
945 data := drawio.ElementData{
946 ID: ch.ID,
947 Title: title,
948 Technology: technology,
949 Description: description,
950 }
951 page.UpdateElement(ch.ID, data)
952 result.ElementsUpdated++
953 }
954
955 // countConnectorsFor counts the number of connectors on page where source or
956 // target matches cellID. This is used to increment ConnectorsDeleted before
957 // calling page.DeleteConnectorsFor.
958 func countConnectorsFor(page *drawio.Page, cellID string) int {
959 n := 0
960 for _, c := range page.FindAllConnectors() {
961 src := c.SelectAttrValue("source", "")
962 tgt := c.SelectAttrValue("target", "")
963 if src == cellID || tgt == cellID {
964 n++
965 }
966 }
967 return n
968 }
969
970 // applyRelAdded creates a new connector on page. If the connector already
971 // exists (e.g., sync state was deleted), it is skipped to avoid duplicates. (#119)
972 func applyRelAdded(
973 ch RelationshipChange,
974 viewID string,
975 page *drawio.Page,
976 templates *drawio.TemplateSet,
977 result *ForwardResult,
978 ) {
979 srcRef := scopedCellID(viewID, ch.From)
980 tgtRef := scopedCellID(viewID, ch.To)
981
982 // Skip if connector already exists to prevent duplicates. (#119)
983 if page.FindConnector(srcRef, tgtRef, ch.Index) != nil {
984 return
985 }
986
987 style := templates.GetConnectorStyle()
988 data := drawio.ConnectorData{
989 From: ch.From,
990 To: ch.To,
991 Label: ch.NewValue,
992 SourceRef: srcRef,
993 TargetRef: tgtRef,
994 Index: ch.Index,
995 }
996 page.CreateConnector(data, style)
997 result.ConnectorsCreated++
998 }
999
1000 // reconcileViewPage removes elements from the page that are not in the
1001 // resolved view filter. This handles cases where view include/exclude rules
1002 // change without corresponding model element changes (no ChangeSet entries).
1003 // Elements not present in the flat model map are preserved — they were
1004 // manually added by the user in draw.io and should not be deleted. (#115)
1005 func reconcileViewPage(
1006 page *drawio.Page,
1007 elemFilter map[string]bool,
1008 flat map[string]*model.Element,
1009 scopeID string,
1010 viewID string,
1011 result *ForwardResult,
1012 ) {
1013 if elemFilter == nil {
1014 return
1015 }
1016
1017 for _, obj := range page.FindAllElements() {
1018 id := obj.SelectAttrValue("bausteinsicht_id", "")
1019 if id == "" {
1020 continue
1021 }
1022
1023 // Skip the scope boundary element — it's rendered separately
1024 // and is not subject to the normal element filter.
1025 if id == scopeID {
1026 continue
1027 }
1028
1029 if elemFilter[id] {
1030 continue
1031 }
1032
1033 // Preserve elements not in the model — they were manually added
1034 // by the user in draw.io and should not be deleted. (#115)
1035 if _, inModel := flat[id]; !inModel {
1036 continue
1037 }
1038
1039 // Element is on the page but not in the view's resolved set.
1040 // Remove its connectors first (using scoped cell ID), then the element.
1041 // On view pages, connectors reference scoped cell IDs, not raw element IDs.
1042 cellID := scopedCellID(viewID, id)
1043 result.ConnectorsDeleted += countConnectorsFor(page, cellID)
1044 page.DeleteConnectorsFor(cellID)
1045
1046 page.DeleteElement(id)
1047 result.ElementsDeleted++
1048 }
1049 }
1050
1051 // reconcileOrphanedElements removes elements from the page whose
1052 // bausteinsicht_id does not exist in the current model. This is the
1053 // no-views equivalent of reconcileViewPage and handles cases where
1054 // sync state is missing or the model was emptied. (#110)
1055 func reconcileOrphanedElements(
1056 page *drawio.Page,
1057 flat map[string]*model.Element,
1058 result *ForwardResult,
1059 ) {
1060 for _, obj := range page.FindAllElements() {
1061 id := obj.SelectAttrValue("bausteinsicht_id", "")
1062 if id == "" {
1063 continue
1064 }
1065 if _, inModel := flat[id]; inModel {
1066 continue
1067 }
1068 // Element is on the page but not in the model — remove it.
1069 cellID := id // no view scoping in legacy mode
1070 result.ConnectorsDeleted += countConnectorsFor(page, cellID)
1071 page.DeleteConnectorsFor(cellID)
1072 page.DeleteElement(id)
1073 result.ElementsDeleted++
1074 }
1075 }
1076
1077 // mergeStyles merges overlay style properties into a base style string.
1078 // If both base and overlay define the same key (e.g., strokeColor), the
1079 // overlay value wins. This prevents duplicate keys in the style string (#187).
1080 func mergeStyles(base, overlay string) string {
1081 if overlay == "" {
1082 return base
1083 }
1084 if base == "" {
1085 return overlay
1086 }
1087
1088 // Parse overlay keys.
1089 overlayKeys := make(map[string]string)
1090 for _, part := range strings.Split(overlay, ";") {
1091 part = strings.TrimSpace(part)
1092 if part == "" {
1093 continue
1094 }
1095 if idx := strings.IndexByte(part, '='); idx > 0 {
1096 overlayKeys[part[:idx]] = part
1097 } else {
1098 overlayKeys[part] = part
1099 }
1100 }
1101
1102 // Build result: base properties (skipping those overridden) + overlay.
1103 var sb strings.Builder
1104 for _, part := range strings.Split(base, ";") {
1105 part = strings.TrimSpace(part)
1106 if part == "" {
1107 continue
1108 }
1109 key := part
1110 if idx := strings.IndexByte(part, '='); idx > 0 {
1111 key = part[:idx]
1112 }
1113 if _, overridden := overlayKeys[key]; overridden {
1114 continue
1115 }
1116 sb.WriteString(part)
1117 sb.WriteByte(';')
1118 }
1119 for _, v := range overlayKeys {
1120 sb.WriteString(v)
1121 sb.WriteByte(';')
1122 }
1123 return sb.String()
1124 }
1125
1126 // applyDrillDownLinks sets the link attribute on elements that have a detail
1127 // view (a view whose scope matches the element's bausteinsicht_id). (#198)
1128 func applyDrillDownLinks(page *drawio.Page, links map[string]string) {
1129 for _, obj := range page.FindAllElements() {
1130 bid := obj.SelectAttrValue("bausteinsicht_id", "")
1131 if link, ok := links[bid]; ok {
1132 setAttrOn(obj, "link", link)
1133 }
1134 }
1135 }
1136
1137 // setAttrOn sets or creates an attribute on an etree element.
1138 func setAttrOn(el *etree.Element, key, value string) {
1139 for i, a := range el.Attr {
1140 if a.Key == key {
1141 el.Attr[i].Value = value
1142 return
1143 }
1144 }
1145 el.CreateAttr(key, value)
1146 }
1147
1148 // createBackNavButton adds a small navigation button to a detail view page
1149 // that links back to the parent view. The parent view is the one that
1150 // contains the scope element. (#198)
1151 func createBackNavButton(
1152 page *drawio.Page,
1153 viewID string,
1154 scopeID string,
1155 m *model.BausteinsichtModel,
1156 ) {
1157 navCellID := "nav-back-" + viewID
1158
1159 // Don't create if already exists.
1160 root := page.Root()
1161 if root == nil {
1162 return
1163 }
1164 for _, obj := range root.SelectElements("object") {
1165 if obj.SelectAttrValue("id", "") == navCellID {
1166 return
1167 }
1168 }
1169
1170 // Find the parent view: a view that includes the scope element.
1171 var parentViewID string
1172 var parentTitle string
1173 for vID, v := range m.Views {
1174 if vID == viewID {
1175 continue
1176 }
1177 viewCopy := v
1178 resolved, err := model.ResolveView(m, &viewCopy)
1179 if err != nil {
1180 continue
1181 }
1182 for _, id := range resolved {
1183 if id == scopeID {
1184 parentViewID = vID
1185 parentTitle = v.Title
1186 break
1187 }
1188 }
1189 if parentViewID != "" {
1190 break
1191 }
1192 }
1193
1194 if parentViewID == "" {
1195 return // No parent view found.
1196 }
1197
1198 obj := root.CreateElement("object")
1199 obj.CreateAttr("label", "&larr; "+parentTitle)
1200 obj.CreateAttr("id", navCellID)
1201 obj.CreateAttr("link", "data:page/id,view-"+parentViewID)
1202
1203 cell := obj.CreateElement("mxCell")
1204 cell.CreateAttr("style", "rounded=1;fillColor=#f8cecc;strokeColor=#b85450;html=1;fontSize=10;")
1205 cell.CreateAttr("vertex", "1")
1206 cell.CreateAttr("parent", "1")
1207
1208 geo := cell.CreateElement("mxGeometry")
1209 geo.CreateAttr("x", "20")
1210 geo.CreateAttr("y", "20")
1211 geo.CreateAttr("width", "140")
1212 geo.CreateAttr("height", "30")
1213 geo.CreateAttr("as", "geometry")
1214 }
1215
1216 // synchronizeDecisionBadges updates decision badges on all elements in a page
1217 // to match the model. It removes old badges and creates new ones based on the
1218 // element's decision links in the model.
1219 func synchronizeDecisionBadges(page *drawio.Page, m *model.BausteinsichtModel) {
1220 if m == nil {
1221 return
1222 }
1223
1224 // Build decision map for quick lookup
1225 decisionMap := make(map[string]*model.DecisionRecord)
1226 for i := range m.Specification.Decisions {
1227 decisionMap[m.Specification.Decisions[i].ID] = &m.Specification.Decisions[i]
1228 }
1229
1230 // Find all elements on the page
1231 root := page.Root()
1232 if root == nil {
1233 return
1234 }
1235
1236 for _, obj := range root.SelectElements("object") {
1237 elemID := obj.SelectAttrValue("bausteinsicht_id", "")
1238 if elemID == "" {
1239 continue
1240 }
1241
1242 // Look up element in model
1243 elem, ok := findElementByID(m, elemID)
1244 if !ok || elem == nil {
1245 continue
1246 }
1247
1248 // Find the mxCell for this element
1249 cell := obj.SelectElement("mxCell")
1250 if cell == nil {
1251 continue
1252 }
1253
1254 // Remove old decision badges
1255 children := cell.SelectElements("mxCell")
1256 for i := len(children) - 1; i >= 0; i-- {
1257 badgeID := children[i].SelectAttrValue("bausteinsicht_decision_id", "")
1258 if badgeID != "" {
1259 cell.RemoveChild(children[i])
1260 }
1261 }
1262
1263 // Add new decision badges
1264 if len(elem.Decisions) > 0 {
1265 AddDecisionBadges(cell, elem.Decisions, decisionMap)
1266 }
1267 }
1268 }
1269
1270 // findElementByID finds an element in the model by its dot-path ID (e.g., "system.backend.api")
1271 func findElementByID(m *model.BausteinsichtModel, id string) (*model.Element, bool) {
1272 parts := strings.Split(id, ".")
1273 if len(parts) == 0 {
1274 return nil, false
1275 }
1276
1277 elem, ok := m.Model[parts[0]]
1278 if !ok {
1279 return nil, false
1280 }
1281
1282 // Navigate through child elements
1283 current := &elem
1284 for _, part := range parts[1:] {
1285 child, ok := current.Children[part]
1286 if !ok {
1287 return nil, false
1288 }
1289 current = &child
1290 }
1291
1292 return current, true
1293 }
1294
github.com/docToolchain/Bausteinsicht/internal/sync/layout.go 85.5%
1 package sync
2
3 import (
4 "math"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/drawio"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 type position struct {
13 X, Y float64
14 }
15
16 type layoutConfig struct {
17 pageWidth float64 // 1169 (A4 landscape)
18 elementGap float64 // 40
19 padding float64 // 60 (boundary inner padding)
20 startX float64 // 40
21 startY float64 // 40
22 }
23
24 var defaultLayoutConfig = layoutConfig{
25 pageWidth: 1169,
26 elementGap: 60,
27 padding: 60,
28 startX: 40,
29 startY: 40,
30 }
31
32 // layoutResult holds computed positions and optional boundary dimensions.
33 type layoutResult struct {
34 Positions map[string]position
35 BoundaryX float64
36 BoundaryY float64
37 BoundaryWidth float64
38 BoundaryHeight float64
39 }
40
41 // computeLayout returns positions for elements to place on a fresh page.
42 // It only computes positions; it does not modify the document.
43 func computeLayout(
44 ids []string,
45 flat map[string]*model.Element,
46 templates *drawio.TemplateSet,
47 elementOrder []string,
48 scopeID string,
49 layout string,
50 relationships []model.Relationship,
51 ) layoutResult {
52 switch layout {
53 case "grid":
54 return computeGridLayout(ids, flat, templates)
55 case "none":
56 return computeNoneLayout(ids, flat, templates)
57 default: // "layered" or ""
58 return computeLayeredLayout(ids, flat, templates, elementOrder, scopeID, relationships)
59 }
60 }
61
62 // computeLayeredLayout arranges elements in horizontal rows grouped by kind.
63 // Kinds are ordered according to elementOrder (from specification.elements).
64 // Elements within each layer are sorted alphabetically.
65 //
66 // For scoped views the layout is:
67 // 1. Actor-like externals (kinds whose notation contains "Actor") → top rows
68 // 2. Scope boundary with children → middle
69 // 3. Non-actor externals → bottom rows
70 func computeLayeredLayout(
71 ids []string,
72 flat map[string]*model.Element,
73 templates *drawio.TemplateSet,
74 elementOrder []string,
75 scopeID string,
76 relationships []model.Relationship,
77 ) layoutResult {
78 cfg := defaultLayoutConfig
79 result := layoutResult{Positions: make(map[string]position)}
80
81 // Build kind → tier mapping from elementOrder.
82 kindTier := make(map[string]int)
83 for i, k := range elementOrder {
84 kindTier[k] = i
85 }
86 maxTier := len(elementOrder) // unknown kinds go here
87
88 // Separate scoped children from external elements.
89 // For externals, further split into actors (top) and non-actors (bottom).
90 var scopeChildren []string
91 var actorExternals []string
92 var otherExternals []string
93 for _, id := range ids {
94 if id == scopeID {
95 continue
96 }
97 if scopeID != "" && isChildOf(id, scopeID) {
98 scopeChildren = append(scopeChildren, id)
99 } else {
100 if isActorKind(id, flat) {
101 actorExternals = append(actorExternals, id)
102 } else {
103 otherExternals = append(otherExternals, id)
104 }
105 }
106 }
107
108 // If no scope, treat everything as one group (actors first, then rest).
109 if scopeID == "" {
110 all := append(actorExternals, otherExternals...)
111 all = append(all, scopeChildren...)
112 placeLayered(all, flat, templates, kindTier, maxTier, cfg, cfg.startX, cfg.startY, result.Positions)
113 return result
114 }
115
116 // Compute boundary dimensions first (needed for centering actors/externals).
117 boundaryX := cfg.startX
118 var boundaryContentW, boundaryContentH float64
119 boundaryStartSize := 30.0
120
121 scopeChildPositions := make(map[string]position)
122 if len(scopeChildren) > 0 {
123 innerX := cfg.padding
124 innerY := boundaryStartSize + cfg.padding
125 minContentWidth := minBoundaryContentWidth(scopeChildren, flat, templates, cfg)
126
127 boundaryContentW, boundaryContentH = placeBFS(scopeChildren, flat, templates, cfg, innerX, innerY, minContentWidth, actorExternals, relationships, scopeChildPositions)
128
129 if boundaryContentW < minContentWidth {
130 boundaryContentW = minContentWidth
131 }
132
133 result.BoundaryWidth = boundaryContentW + 2*cfg.padding
134 result.BoundaryHeight = boundaryContentH + boundaryStartSize + 2*cfg.padding
135
136 if result.BoundaryWidth < 400 {
137 result.BoundaryWidth = 400
138 }
139 if result.BoundaryHeight < 300 {
140 result.BoundaryHeight = 300
141 }
142 }
143
144 // Reference width for centering: the wider of boundary or page content.
145 refWidth := result.BoundaryWidth
146 if refWidth < 400 {
147 refWidth = 400
148 }
149
150 curY := cfg.startY
151
152 // 1. Place actor externals above the boundary, centered to boundary width.
153 // Reserve the first row even when there are no actors so users can add them later.
154 if len(actorExternals) > 0 {
155 actorW, actorH := placeLayered(actorExternals, flat, templates, kindTier, maxTier, cfg, cfg.startX, curY, result.Positions)
156 // Center actor row relative to boundary width.
157 if actorW < refWidth {
158 offset := (refWidth - actorW) / 2
159 for _, id := range actorExternals {
160 if p, ok := result.Positions[id]; ok {
161 p.X += offset
162 result.Positions[id] = p
163 }
164 }
165 }
166 curY += actorH + cfg.elementGap
167 } else {
168 // No actors — still reserve space for a potential actor row.
169 curY += defaultHeight + cfg.elementGap
170 }
171
172 // 2. Place scope boundary and its children.
173 boundaryY := curY
174 if len(scopeChildren) > 0 {
175 result.BoundaryX = boundaryX
176 result.BoundaryY = boundaryY
177
178 // Copy pre-computed child positions into result.
179 for id, pos := range scopeChildPositions {
180 result.Positions[id] = pos
181 }
182
183 curY = boundaryY + result.BoundaryHeight + cfg.elementGap
184 }
185
186 // 3. Place non-actor externals below the boundary, centered.
187 if len(otherExternals) > 0 {
188 extW, _ := placeLayered(otherExternals, flat, templates, kindTier, maxTier, cfg, cfg.startX, curY, result.Positions)
189 if extW < refWidth {
190 offset := (refWidth - extW) / 2
191 for _, id := range otherExternals {
192 if p, ok := result.Positions[id]; ok {
193 p.X += offset
194 result.Positions[id] = p
195 }
196 }
197 }
198 }
199
200 return result
201 }
202
203 // isActorKind returns true if the element's kind contains "actor" (case-insensitive).
204 func isActorKind(id string, flat map[string]*model.Element) bool {
205 elem := flat[id]
206 if elem == nil {
207 return false
208 }
209 k := elem.Kind
210 return k == "actor" || k == "Actor" || k == "user" || k == "User" ||
211 k == "person" || k == "Person"
212 }
213
214 // minBoundaryContentWidth computes the minimum content width to fit at least
215 // 3 average-sized elements side by side.
216 func minBoundaryContentWidth(ids []string, flat map[string]*model.Element, templates *drawio.TemplateSet, cfg layoutConfig) float64 {
217 if len(ids) == 0 {
218 return 0
219 }
220 // Use the most common element width as reference.
221 totalW := 0.0
222 for _, id := range ids {
223 w, _ := elementSize(id, flat, templates)
224 totalW += w
225 }
226 avgW := totalW / float64(len(ids))
227
228 cols := 3
229 if len(ids) < cols {
230 cols = len(ids)
231 }
232 return float64(cols)*avgW + float64(cols-1)*cfg.elementGap
233 }
234
235 // placeLayered places elements in layered rows, centered horizontally,
236 // and returns the content width and height.
237 func placeLayered(
238 ids []string,
239 flat map[string]*model.Element,
240 templates *drawio.TemplateSet,
241 kindTier map[string]int,
242 maxTier int,
243 cfg layoutConfig,
244 originX, originY float64,
245 positions map[string]position,
246 ) (contentWidth, contentHeight float64) {
247 // Group by tier.
248 tiers := make(map[int][]string)
249 for _, id := range ids {
250 elem := flat[id]
251 if elem == nil {
252 continue
253 }
254 tier, ok := kindTier[elem.Kind]
255 if !ok {
256 tier = maxTier
257 }
258 tiers[tier] = append(tiers[tier], id)
259 }
260
261 // Sort tier keys.
262 tierKeys := make([]int, 0, len(tiers))
263 for k := range tiers {
264 tierKeys = append(tierKeys, k)
265 }
266 sort.Ints(tierKeys)
267
268 // First pass: place left-aligned and track row membership for centering.
269 type rowInfo struct {
270 ids []string
271 width float64
272 height float64
273 y float64
274 }
275 var rows []rowInfo
276 curY := originY
277 maxRowWidth := 0.0
278
279 for _, tier := range tierKeys {
280 elems := tiers[tier]
281 sort.Strings(elems)
282
283 curX := originX
284 rowHeight := 0.0
285 var currentRow []string
286
287 for _, id := range elems {
288 w, h := elementSize(id, flat, templates)
289
290 // Row wrapping.
291 if curX > originX && curX+w > cfg.pageWidth-cfg.startX {
292 rowWidth := curX - cfg.elementGap - originX
293 rows = append(rows, rowInfo{ids: currentRow, width: rowWidth, height: rowHeight, y: curY})
294 if rowWidth > maxRowWidth {
295 maxRowWidth = rowWidth
296 }
297 curY += rowHeight + cfg.elementGap
298 curX = originX
299 rowHeight = 0
300 currentRow = nil
301 }
302
303 positions[id] = position{X: curX, Y: curY}
304 currentRow = append(currentRow, id)
305 curX += w + cfg.elementGap
306 if h > rowHeight {
307 rowHeight = h
308 }
309 }
310
311 // Finish last row of this tier.
312 if len(currentRow) > 0 {
313 rowWidth := curX - cfg.elementGap - originX
314 rows = append(rows, rowInfo{ids: currentRow, width: rowWidth, height: rowHeight, y: curY})
315 if rowWidth > maxRowWidth {
316 maxRowWidth = rowWidth
317 }
318 }
319 curY += rowHeight + cfg.elementGap
320 }
321
322 // Second pass: center each row within the max row width.
323 for _, row := range rows {
324 if row.width >= maxRowWidth {
325 continue
326 }
327 offset := (maxRowWidth - row.width) / 2
328 for _, id := range row.ids {
329 p := positions[id]
330 p.X += offset
331 positions[id] = p
332 }
333 }
334
335 contentWidth = maxRowWidth
336 contentHeight = curY - originY - cfg.elementGap // subtract trailing gap
337 if contentHeight < 0 {
338 contentHeight = 0
339 }
340 return contentWidth, contentHeight
341 }
342
343 // placeBFS places scope children using relationship-based BFS ordering:
344 // 1. Row 1: elements connected to actors (external seeds)
345 // 2. Row 2: elements connected to row 1
346 // 3. Row N: elements connected to row N-1
347 // 4. Remaining: any unconnected elements at the end
348 //
349 // Within each row, the next element is chosen by adjacency to the last placed
350 // element (greedy neighbor selection), with alphabetical fallback.
351 func placeBFS(
352 ids []string,
353 flat map[string]*model.Element,
354 templates *drawio.TemplateSet,
355 cfg layoutConfig,
356 originX, originY float64,
357 minRowWidth float64,
358 actorIDs []string,
359 relationships []model.Relationship,
360 positions map[string]position,
361 ) (contentWidth, contentHeight float64) {
362 // Build an adjacency map among the scope children.
363 idSet := make(map[string]bool, len(ids))
364 for _, id := range ids {
365 idSet[id] = true
366 }
367
368 // adj maps each scope child to its neighbors (other scope children
369 // or external elements it is connected to).
370 adj := make(map[string]map[string]bool)
371 for _, id := range ids {
372 adj[id] = make(map[string]bool)
373 }
374
375 actorSet := make(map[string]bool, len(actorIDs))
376 for _, id := range actorIDs {
377 actorSet[id] = true
378 }
379
380 // connectedToActor tracks which scope children have a direct or
381 // indirect (via lifted) relationship to an actor.
382 connectedToActor := make(map[string]bool)
383
384 for _, rel := range relationships {
385 from, to := rel.From, rel.To
386
387 // Check if this relationship connects a scope child to an actor.
388 if idSet[from] && actorSet[to] {
389 connectedToActor[from] = true
390 }
391 if idSet[to] && actorSet[from] {
392 connectedToActor[to] = true
393 }
394
395 // Also check lifted relationships: if an actor connects to a
396 // parent of a scope child (e.g., actor→onlineshop.frontend where
397 // frontend is a scope child via onlineshop.frontend).
398 fromInScope := idSet[from] || hasChildInSet(from, idSet)
399 toInScope := idSet[to] || hasChildInSet(to, idSet)
400 _ = fromInScope
401 _ = toInScope
402
403 // Build adjacency between scope children.
404 fromResolved := resolveToScopeChild(from, idSet)
405 toResolved := resolveToScopeChild(to, idSet)
406 if fromResolved != "" && toResolved != "" && fromResolved != toResolved {
407 if adj[fromResolved] != nil && adj[toResolved] != nil {
408 adj[fromResolved][toResolved] = true
409 adj[toResolved][fromResolved] = true
410 }
411 }
412
413 // Track actor connections via resolved scope children.
414 if fromResolved != "" && actorSet[to] {
415 connectedToActor[fromResolved] = true
416 }
417 if toResolved != "" && actorSet[from] {
418 connectedToActor[toResolved] = true
419 }
420 }
421
422 // BFS: assign elements to rows.
423 placed := make(map[string]bool)
424 var rows [][]string
425
426 // Row 0: elements connected to actors.
427 var row0 []string
428 for _, id := range ids {
429 if connectedToActor[id] {
430 row0 = append(row0, id)
431 placed[id] = true
432 }
433 }
434 sort.Strings(row0)
435 if len(row0) > 0 {
436 rows = append(rows, orderByAdjacency(row0, adj))
437 }
438
439 // Subsequent rows: BFS from previous row.
440 for len(rows) > 0 {
441 if len(placed) >= len(ids) {
442 break
443 }
444 prevRow := rows[len(rows)-1]
445 var nextRow []string
446 for _, prev := range prevRow {
447 for neighbor := range adj[prev] {
448 if !placed[neighbor] {
449 nextRow = append(nextRow, neighbor)
450 placed[neighbor] = true
451 }
452 }
453 }
454 if len(nextRow) == 0 {
455 break
456 }
457 sort.Strings(nextRow)
458 rows = append(rows, orderByAdjacency(nextRow, adj))
459 }
460
461 // Remaining: elements not reached by BFS (no relationships).
462 var remaining []string
463 for _, id := range ids {
464 if !placed[id] {
465 remaining = append(remaining, id)
466 }
467 }
468 if len(remaining) > 0 {
469 sort.Strings(remaining)
470 rows = append(rows, remaining)
471 }
472
473 // Place rows.
474 curY := originY
475 maxWidth := 0.0
476
477 for _, row := range rows {
478 curX := originX
479 rowHeight := 0.0
480
481 for _, id := range row {
482 w, h := elementSize(id, flat, templates)
483
484 // Row wrapping.
485 if curX > originX && curX+w > originX+minRowWidth {
486 curY += rowHeight + cfg.elementGap
487 curX = originX
488 rowHeight = 0
489 }
490
491 positions[id] = position{X: curX, Y: curY}
492 curX += w + cfg.elementGap
493 usedWidth := curX - cfg.elementGap - originX
494 if usedWidth > maxWidth {
495 maxWidth = usedWidth
496 }
497 if h > rowHeight {
498 rowHeight = h
499 }
500 }
501
502 curY += rowHeight + cfg.elementGap
503 }
504
505 contentWidth = maxWidth
506 contentHeight = curY - originY - cfg.elementGap
507 if contentHeight < 0 {
508 contentHeight = 0
509 }
510 return contentWidth, contentHeight
511 }
512
513 // orderByAdjacency reorders elements so that each next element is a neighbor
514 // of the previously placed one (greedy). Falls back to original order.
515 func orderByAdjacency(ids []string, adj map[string]map[string]bool) []string {
516 if len(ids) <= 1 {
517 return ids
518 }
519
520 remaining := make(map[string]bool, len(ids))
521 for _, id := range ids {
522 remaining[id] = true
523 }
524
525 result := make([]string, 0, len(ids))
526 // Start with the first element (alphabetically).
527 current := ids[0]
528 result = append(result, current)
529 delete(remaining, current)
530
531 for len(remaining) > 0 {
532 // Find a neighbor of current that is in remaining.
533 var next string
534 var candidates []string
535 for neighbor := range adj[current] {
536 if remaining[neighbor] {
537 candidates = append(candidates, neighbor)
538 }
539 }
540 if len(candidates) > 0 {
541 sort.Strings(candidates)
542 next = candidates[0]
543 } else {
544 // No neighbor found — pick alphabetically first remaining.
545 var fallback []string
546 for id := range remaining {
547 fallback = append(fallback, id)
548 }
549 if len(fallback) == 0 {
550 break
551 }
552 sort.Strings(fallback)
553 next = fallback[0]
554 }
555 result = append(result, next)
556 delete(remaining, next)
557 current = next
558 }
559
560 return result
561 }
562
563 // resolveToScopeChild resolves an element ID to a scope child ID.
564 // If the ID is directly in the set, returns it. If a parent is in the set,
565 // returns the parent. Returns "" if no match.
566 func resolveToScopeChild(id string, scopeChildren map[string]bool) string {
567 if scopeChildren[id] {
568 return id
569 }
570 // Walk up the hierarchy to find a scope child ancestor.
571 for {
572 dot := strings.LastIndex(id, ".")
573 if dot < 0 {
574 return ""
575 }
576 id = id[:dot]
577 if scopeChildren[id] {
578 return id
579 }
580 }
581 }
582
583 // hasChildInSet returns true if any key in the set is a child of id.
584 func hasChildInSet(id string, set map[string]bool) bool {
585 prefix := id + "."
586 for k := range set {
587 if strings.HasPrefix(k, prefix) {
588 return true
589 }
590 }
591 return false
592 }
593
594 // computeGridLayout arranges all elements in a simple grid.
595 func computeGridLayout(
596 ids []string,
597 flat map[string]*model.Element,
598 templates *drawio.TemplateSet,
599 ) layoutResult {
600 cfg := defaultLayoutConfig
601 result := layoutResult{Positions: make(map[string]position)}
602
603 sorted := make([]string, len(ids))
604 copy(sorted, ids)
605 sort.Strings(sorted)
606
607 // Determine max element width for column calculation.
608 maxW := 0.0
609 for _, id := range sorted {
610 w, _ := elementSize(id, flat, templates)
611 if w > maxW {
612 maxW = w
613 }
614 }
615
616 columns := int(math.Floor((cfg.pageWidth - 2*cfg.startX) / (maxW + cfg.elementGap)))
617 if columns < 1 {
618 columns = 1
619 }
620
621 col, row := 0, 0
622 for _, id := range sorted {
623 _, h := elementSize(id, flat, templates)
624 _ = h
625 x := cfg.startX + float64(col)*(maxW+cfg.elementGap)
626 y := cfg.startY + float64(row)*(defaultHeight+cfg.elementGap)
627 result.Positions[id] = position{X: x, Y: y}
628 col++
629 if col >= columns {
630 col = 0
631 row++
632 }
633 }
634
635 return result
636 }
637
638 // computeNoneLayout uses the legacy horizontal row placement.
639 func computeNoneLayout(
640 ids []string,
641 flat map[string]*model.Element,
642 templates *drawio.TemplateSet,
643 ) layoutResult {
644 cfg := defaultLayoutConfig
645 result := layoutResult{Positions: make(map[string]position)}
646
647 sorted := make([]string, len(ids))
648 copy(sorted, ids)
649 sort.Strings(sorted)
650
651 curX := cfg.startX
652 for _, id := range sorted {
653 w, _ := elementSize(id, flat, templates)
654 result.Positions[id] = position{X: curX, Y: cfg.startY}
655 curX += w + cfg.elementGap
656 }
657
658 return result
659 }
660
661 // elementSize returns the width and height for an element, based on its
662 // template style or default dimensions.
663 func elementSize(id string, flat map[string]*model.Element, templates *drawio.TemplateSet) (float64, float64) {
664 elem := flat[id]
665 if elem == nil {
666 return defaultWidth, defaultHeight
667 }
668 ts, ok := templates.GetStyle(elem.Kind)
669 if !ok {
670 return defaultWidth, defaultHeight
671 }
672 w := ts.Width
673 if w == 0 {
674 w = defaultWidth
675 }
676 h := ts.Height
677 if h == 0 {
678 h = defaultHeight
679 }
680 return w, h
681 }
682
github.com/docToolchain/Bausteinsicht/internal/sync/metadata.go 81.8%
1 package sync
2
3 import (
4 "fmt"
5 "html"
6 "sort"
7 "strconv"
8 "strings"
9
10 "github.com/beevik/etree"
11 "github.com/docToolchain/Bausteinsicht/internal/drawio"
12 "github.com/docToolchain/Bausteinsicht/internal/model"
13 )
14
15 const (
16 metadataPrefix = "metadata-"
17 legendPrefix = "legend-"
18 infoBoxGap = 80.0
19 metadataX = 40.0
20 metadataWidth = 500.0
21 legendWidthRatio = 0.30
22 legendGap = 60.0
23 )
24
25 // createMetadata creates or updates a metadata info box on the view page.
26 // Returns true if the label was created or changed.
27 func createMetadata(
28 page *drawio.Page,
29 viewID string,
30 view model.View,
31 cfg model.Config,
32 modelPath string,
33 timestamp string,
34 ) bool {
35 cellID := metadataPrefix + viewID
36 label := buildMetadataLabel(view, cfg, modelPath, timestamp)
37
38 root := page.Root()
39 if root == nil {
40 return false
41 }
42
43 // Update existing metadata cell — only if label actually changed.
44 for _, obj := range root.SelectElements("object") {
45 if obj.SelectAttrValue("id", "") == cellID {
46 if obj.SelectAttrValue("label", "") == label {
47 return false
48 }
49 obj.CreateAttr("label", label)
50 return true
51 }
52 }
53
54 // Create new metadata cell — 60% of content width.
55 legendCellID := legendPrefix + viewID
56 y := computeMaxY(page, cellID, legendCellID) + infoBoxGap
57 contentWidth := computeContentWidth(page, cellID, legendCellID)
58 if contentWidth < 600 {
59 contentWidth = 600
60 }
61 width := contentWidth * 0.60
62 createInfoBox(root, cellID, label, metadataX, y, width)
63 return true
64 }
65
66 // createLegend creates or updates a legend box on the view page.
67 // Returns true if the label was created or changed.
68 func createLegend(
69 page *drawio.Page,
70 viewID string,
71 spec model.Specification,
72 templates *drawio.TemplateSet,
73 elemSet map[string]bool,
74 flat map[string]*model.Element,
75 ) bool {
76 cellID := legendPrefix + viewID
77 label := buildLegendLabel(spec, templates, elemSet, flat)
78
79 root := page.Root()
80 if root == nil {
81 return false
82 }
83
84 // Update existing legend cell — only if label actually changed.
85 for _, obj := range root.SelectElements("object") {
86 if obj.SelectAttrValue("id", "") == cellID {
87 if obj.SelectAttrValue("label", "") == label {
88 return false
89 }
90 obj.CreateAttr("label", label)
91 return true
92 }
93 }
94
95 // Create new legend cell — positioned right of the metadata box, same Y.
96 metaCellID := metadataPrefix + viewID
97 y := computeMaxY(page, cellID, metaCellID) + infoBoxGap
98
99 // Compute content width for legend positioning.
100 contentWidth := computeContentWidth(page, cellID, metaCellID)
101 if contentWidth < 600 {
102 contentWidth = 600
103 }
104 legendWidth := contentWidth * legendWidthRatio
105 legendX := contentWidth - legendWidth + metadataX
106 createInfoBox(root, cellID, label, legendX, y, legendWidth)
107 return true
108 }
109
110 // buildMetadataLabel returns an HTML label for the metadata box.
111 func buildMetadataLabel(view model.View, cfg model.Config, modelPath string, timestamp string) string {
112 var sb strings.Builder
113 sb.WriteString("<b>")
114 sb.WriteString(html.EscapeString(view.Title))
115 sb.WriteString("</b>")
116 if view.Description != "" {
117 sb.WriteString("<br>")
118 sb.WriteString(html.EscapeString(view.Description))
119 }
120 sb.WriteString("<br><br>")
121 sb.WriteString("<font point-size=\"9\">")
122 if cfg.Repo != "" {
123 sb.WriteString("Repo: ")
124 sb.WriteString(html.EscapeString(cfg.Repo))
125 sb.WriteString("<br>")
126 }
127 sb.WriteString("Source: ")
128 sb.WriteString(html.EscapeString(modelPath))
129 sb.WriteString("<br>Last synced: ")
130 sb.WriteString(html.EscapeString(timestamp))
131 if cfg.Author != "" {
132 sb.WriteString("<br>Author: ")
133 sb.WriteString(html.EscapeString(cfg.Author))
134 }
135 sb.WriteString("<br>Generated by Bausteinsicht</font>")
136 return sb.String()
137 }
138
139 // buildLegendLabel returns an HTML label for the legend box.
140 func buildLegendLabel(
141 spec model.Specification,
142 templates *drawio.TemplateSet,
143 elemSet map[string]bool,
144 flat map[string]*model.Element,
145 ) string {
146 // Collect kinds actually used in this view.
147 usedKinds := make(map[string]bool)
148 for id := range elemSet {
149 if elem, ok := flat[id]; ok {
150 usedKinds[elem.Kind] = true
151 }
152 }
153
154 // Sort kinds for deterministic output.
155 kinds := make([]string, 0, len(usedKinds))
156 for k := range usedKinds {
157 kinds = append(kinds, k)
158 }
159 sort.Strings(kinds)
160
161 allStyles := templates.GetAllStyles()
162
163 var sb strings.Builder
164 sb.WriteString("<b>Legend</b>")
165
166 for _, kind := range kinds {
167 ekind, ok := spec.Elements[kind]
168 if !ok {
169 continue
170 }
171
172 color := extractFillColor(allStyles, kind)
173 sb.WriteString("<br>")
174 fmt.Fprintf(&sb,
175 "<font color=\"%s\">\u25a0</font> %s",
176 html.EscapeString(color),
177 html.EscapeString(ekind.Notation),
178 )
179 }
180
181 return sb.String()
182 }
183
184 // extractFillColor extracts the fillColor from a template style string.
185 // Returns a default gray if not found.
186 func extractFillColor(allStyles map[string]drawio.TemplateStyle, kind string) string {
187 ts, ok := allStyles[kind]
188 if !ok {
189 return "#666666"
190 }
191 for _, part := range strings.Split(ts.Style, ";") {
192 if strings.HasPrefix(part, "fillColor=") {
193 return strings.TrimPrefix(part, "fillColor=")
194 }
195 }
196 return "#666666"
197 }
198
199 // createInfoBox creates a non-model mxCell info box at the given position.
200 func createInfoBox(root *etree.Element, id, label string, x, y, width float64) {
201 obj := root.CreateElement("object")
202 obj.CreateAttr("label", label)
203 obj.CreateAttr("id", id)
204
205 cell := obj.CreateElement("mxCell")
206 cell.CreateAttr("style", "rounded=0;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=none;fontColor=#333333;fontSize=10;align=left;verticalAlign=top;")
207 cell.CreateAttr("vertex", "1")
208 cell.CreateAttr("parent", "1")
209
210 geo := cell.CreateElement("mxGeometry")
211 geo.CreateAttr("x", fmt.Sprintf("%.0f", x))
212 geo.CreateAttr("y", fmt.Sprintf("%.0f", y))
213 geo.CreateAttr("width", fmt.Sprintf("%.0f", width))
214 geo.CreateAttr("height", "120")
215 geo.CreateAttr("as", "geometry")
216 }
217
218 // computeMaxY finds the bottom-most Y coordinate of elements on the page,
219 // excluding the specified cell IDs (used to ignore metadata/legend boxes).
220 func computeMaxY(page *drawio.Page, excludeIDs ...string) float64 {
221 exclude := make(map[string]bool, len(excludeIDs))
222 for _, id := range excludeIDs {
223 exclude[id] = true
224 }
225
226 maxY := 0.0
227 root := page.Root()
228 if root == nil {
229 return maxY
230 }
231 for _, obj := range root.ChildElements() {
232 // Skip excluded cells.
233 if id := obj.SelectAttrValue("id", ""); exclude[id] {
234 continue
235 }
236 cell := obj.FindElement("mxCell")
237 if cell == nil {
238 if obj.Tag == "mxCell" {
239 cell = obj
240 } else {
241 continue
242 }
243 }
244 geo := cell.FindElement("mxGeometry")
245 if geo == nil {
246 continue
247 }
248 y := parseFloat(geo.SelectAttrValue("y", "0"))
249 h := parseFloat(geo.SelectAttrValue("height", "0"))
250 if bottom := y + h; bottom > maxY {
251 maxY = bottom
252 }
253 }
254 return maxY
255 }
256
257 // computeContentWidth finds the rightmost X+Width of elements on the page,
258 // excluding the specified cell IDs.
259 func computeContentWidth(page *drawio.Page, excludeIDs ...string) float64 {
260 exclude := make(map[string]bool, len(excludeIDs))
261 for _, id := range excludeIDs {
262 exclude[id] = true
263 }
264
265 maxRight := 0.0
266 root := page.Root()
267 if root == nil {
268 return maxRight
269 }
270 for _, obj := range root.ChildElements() {
271 if id := obj.SelectAttrValue("id", ""); exclude[id] {
272 continue
273 }
274 cell := obj.FindElement("mxCell")
275 if cell == nil {
276 if obj.Tag == "mxCell" {
277 cell = obj
278 } else {
279 continue
280 }
281 }
282 geo := cell.FindElement("mxGeometry")
283 if geo == nil {
284 continue
285 }
286 x := parseFloat(geo.SelectAttrValue("x", "0"))
287 w := parseFloat(geo.SelectAttrValue("width", "0"))
288 if right := x + w; right > maxRight {
289 maxRight = right
290 }
291 }
292 return maxRight
293 }
294
295 // parseFloat parses a string to float64, returning 0 on failure.
296 func parseFloat(s string) float64 {
297 f, _ := strconv.ParseFloat(s, 64)
298 return f
299 }
300
github.com/docToolchain/Bausteinsicht/internal/sync/patchops.go 81.5%
1 package sync
2
3 import (
4 "strings"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // ReversePatchOps converts the reverse (draw.io → model) changes in cs into
10 // PatchOps that can be applied to the JSONC file directly. Returns the ops
11 // and true if all changes are patchable (only element field modifications).
12 // Returns nil, false if any structural change (add/delete) or relationship
13 // change is present — the caller should fall back to full Save.
14 func ReversePatchOps(cs *ChangeSet) ([]model.PatchOp, bool) {
15 // Any relationship change or structural element change means we can't patch.
16 if len(cs.DrawioRelationshipChanges) > 0 {
17 return nil, false
18 }
19
20 var ops []model.PatchOp
21 for _, ch := range cs.DrawioElementChanges {
22 if ch.Type != Modified || ch.Field == "" {
23 return nil, false
24 }
25 path := elementFieldPath(ch.ID, ch.Field)
26 ops = append(ops, model.PatchOp{
27 Path: path,
28 Value: `"` + jsonEscapeString(ch.NewValue) + `"`,
29 })
30 }
31 return ops, true
32 }
33
34 // elementFieldPath converts a dot-separated element ID and field name into
35 // a JSON path. E.g., ("webshop.api", "technology") →
36 // ["model", "webshop", "children", "api", "technology"]
37 func elementFieldPath(id, field string) []string {
38 parts := strings.Split(id, ".")
39 path := []string{"model"}
40 for i, part := range parts {
41 path = append(path, part)
42 if i < len(parts)-1 {
43 path = append(path, "children")
44 }
45 }
46 path = append(path, field)
47 return path
48 }
49
50 // jsonEscapeString escapes special characters for JSON string values.
51 func jsonEscapeString(s string) string {
52 var b strings.Builder
53 for _, r := range s {
54 switch r {
55 case '"':
56 b.WriteString(`\"`)
57 case '\\':
58 b.WriteString(`\\`)
59 case '\n':
60 b.WriteString(`\n`)
61 case '\r':
62 b.WriteString(`\r`)
63 case '\t':
64 b.WriteString(`\t`)
65 default:
66 b.WriteRune(r)
67 }
68 }
69 return b.String()
70 }
71
github.com/docToolchain/Bausteinsicht/internal/sync/reverse.go 83.3%
1 package sync
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // maxReverseDepth limits recursion in modifyInMap/deleteFromMap to prevent stack overflow.
11 const maxReverseDepth = model.MaxElementDepth
12
13 // ReverseResult summarizes the changes applied back to the model.
14 type ReverseResult struct {
15 ElementsCreated int
16 ElementsUpdated int
17 ElementsDeleted int
18 RelationshipsCreated int
19 RelationshipsUpdated int
20 RelationshipsDeleted int
21 Warnings []string
22 }
23
24 // ApplyReverse applies draw.io-side changes back to the model.
25 func ApplyReverse(changes *ChangeSet, m *model.BausteinsichtModel) *ReverseResult {
26 result := &ReverseResult{}
27
28 // Pre-compute the flat model for accurate existence checks.
29 // m.Model is a top-level map, so a key like "parent.child" won't be found
30 // there directly. We need the flattened view to properly detect nested elements.
31 flatModel, _ := model.FlattenElements(m)
32
33 for _, ch := range changes.DrawioElementChanges {
34 applyElementChange(ch, m, flatModel, result)
35 }
36
37 // Detect direction-swap pairs (Deleted a→b + Added b→a) so we can
38 // update the relationship in-place and preserve metadata (#185).
39 swaps := detectRelSwaps(changes.DrawioRelationshipChanges)
40
41 for _, ch := range changes.DrawioRelationshipChanges {
42 if swaps[relSwapKey{ch.From, ch.To, ch.Type}] {
43 continue // handled as part of a swap pair
44 }
45 applyRelationshipChange(ch, m, flatModel, result)
46 }
47
48 // Apply swaps: update direction in-place, preserving kind/label/description.
49 for _, sw := range collectSwapPairs(changes.DrawioRelationshipChanges) {
50 applyRelSwap(sw.from, sw.to, m, result)
51 }
52
53 return result
54 }
55
56 type relSwapKey struct {
57 from, to string
58 typ ChangeType
59 }
60
61 type swapPair struct {
62 from, to string // new direction (from the Added change)
63 }
64
65 // detectRelSwaps returns a set of (from, to, type) triples that are part of
66 // a direction-swap pair. A swap is a Deleted(a→b) paired with Added(b→a).
67 func detectRelSwaps(changes []RelationshipChange) map[relSwapKey]bool {
68 deleted := make(map[[2]string]bool)
69 added := make(map[[2]string]bool)
70 for _, ch := range changes {
71 switch ch.Type {
72 case Deleted:
73 deleted[[2]string{ch.From, ch.To}] = true
74 case Added:
75 added[[2]string{ch.From, ch.To}] = true
76 }
77 }
78 result := make(map[relSwapKey]bool)
79 for pair := range deleted {
80 reverse := [2]string{pair[1], pair[0]}
81 if added[reverse] {
82 result[relSwapKey{pair[0], pair[1], Deleted}] = true
83 result[relSwapKey{pair[1], pair[0], Added}] = true
84 }
85 }
86 return result
87 }
88
89 // collectSwapPairs returns the new-direction pairs for detected swaps.
90 func collectSwapPairs(changes []RelationshipChange) []swapPair {
91 swaps := detectRelSwaps(changes)
92 var pairs []swapPair
93 for key := range swaps {
94 if key.typ == Added {
95 pairs = append(pairs, swapPair{from: key.from, to: key.to})
96 }
97 }
98 return pairs
99 }
100
101 // applyRelSwap updates a relationship's direction in-place, preserving metadata.
102 func applyRelSwap(newFrom, newTo string, m *model.BausteinsichtModel, result *ReverseResult) {
103 for i, r := range m.Relationships {
104 if r.From == newTo && r.To == newFrom {
105 m.Relationships[i].From = newFrom
106 m.Relationships[i].To = newTo
107 result.RelationshipsUpdated++
108 return
109 }
110 }
111 // Fallback: original already deleted somehow; create new.
112 m.Relationships = append(m.Relationships, model.Relationship{
113 From: newFrom,
114 To: newTo,
115 })
116 result.RelationshipsCreated++
117 }
118
119 func applyElementChange(ch ElementChange, m *model.BausteinsichtModel, flatModel map[string]*model.Element, result *ReverseResult) {
120 switch ch.Type {
121 case Modified:
122 // Reject empty title updates from draw.io (#150).
123 if ch.Field == "title" && strings.TrimSpace(ch.NewValue) == "" {
124 result.Warnings = append(result.Warnings,
125 fmt.Sprintf("Element %q: ignoring empty title from draw.io", ch.ID))
126 return
127 }
128 err := modifyElement(m, ch.ID, func(e *model.Element) {
129 switch ch.Field {
130 case "title":
131 e.Title = ch.NewValue
132 case "description":
133 e.Description = ch.NewValue
134 case "technology":
135 e.Technology = ch.NewValue
136 }
137 })
138 if err != nil {
139 result.Warnings = append(result.Warnings,
140 fmt.Sprintf("Element %q not found in model: %v", ch.ID, err))
141 return
142 }
143 result.ElementsUpdated++
144
145 case Deleted:
146 err := deleteElement(m, ch.ID)
147 if err != nil {
148 result.Warnings = append(result.Warnings,
149 fmt.Sprintf("Element %q could not be deleted: %v", ch.ID, err))
150 return
151 }
152 result.ElementsDeleted++
153 // Clean orphaned relationships referencing the deleted element (#266).
154 prefix := ch.ID + "."
155 var kept []model.Relationship
156 for _, r := range m.Relationships {
157 if r.From == ch.ID || r.To == ch.ID ||
158 strings.HasPrefix(r.From, prefix) || strings.HasPrefix(r.To, prefix) {
159 result.RelationshipsDeleted++
160 continue
161 }
162 kept = append(kept, r)
163 }
164 m.Relationships = kept
165 // Clean stale references from view include/exclude lists.
166 for viewID, v := range m.Views {
167 v.Include = removeFromSlice(v.Include, ch.ID)
168 v.Exclude = removeFromSlice(v.Exclude, ch.ID)
169 m.Views[viewID] = v
170 }
171 result.Warnings = append(result.Warnings,
172 fmt.Sprintf("Element %q was deleted in draw.io and removed from model", ch.ID))
173
174 case Added:
175 if m.Model == nil {
176 m.Model = make(map[string]model.Element)
177 }
178 // Use the pre-computed flat model to check existence across the full
179 // hierarchy. A naive m.Model[ch.ID] check misses nested elements whose
180 // IDs are dot-paths (e.g. "parent.child"), causing them to be
181 // re-created as spurious top-level entries with empty titles (#307).
182 if _, exists := flatModel[ch.ID]; exists {
183 result.Warnings = append(result.Warnings,
184 fmt.Sprintf("New element %q from draw.io skipped: ID already exists in model.", ch.ID))
185 return
186 }
187 kind := firstSpecKind(m)
188 m.Model[ch.ID] = model.Element{
189 Kind: kind,
190 Title: ch.NewValue,
191 }
192 result.ElementsCreated++
193 result.Warnings = append(result.Warnings,
194 fmt.Sprintf("New element %q added from draw.io (kind=%q) — review and assign a meaningful ID and kind.", ch.ID, kind))
195 }
196 }
197
198 func applyRelationshipChange(ch RelationshipChange, m *model.BausteinsichtModel, flatModel map[string]*model.Element, result *ReverseResult) {
199 switch ch.Type {
200 case Modified:
201 updated := false
202 if ch.Index >= 0 && ch.Index < len(m.Relationships) {
203 r := m.Relationships[ch.Index]
204 if r.From == ch.From && r.To == ch.To {
205 if ch.Field == "label" {
206 m.Relationships[ch.Index].Label = ch.NewValue
207 }
208 updated = true
209 }
210 }
211 // Fallback: search by from/to if index does not match.
212 if !updated {
213 for i, r := range m.Relationships {
214 if r.From == ch.From && r.To == ch.To {
215 if ch.Field == "label" {
216 m.Relationships[i].Label = ch.NewValue
217 }
218 updated = true
219 break
220 }
221 }
222 }
223 if updated {
224 result.RelationshipsUpdated++
225 } else {
226 result.Warnings = append(result.Warnings,
227 fmt.Sprintf("Relationship %q->%q not found in model", ch.From, ch.To))
228 }
229
230 case Deleted:
231 before := len(m.Relationships)
232 if ch.Index >= 0 && ch.Index < len(m.Relationships) {
233 r := m.Relationships[ch.Index]
234 if r.From == ch.From && r.To == ch.To {
235 m.Relationships = append(m.Relationships[:ch.Index], m.Relationships[ch.Index+1:]...)
236 } else {
237 // Fallback: filter by from/to.
238 m.Relationships = filterRelationships(m.Relationships, ch.From, ch.To)
239 }
240 } else {
241 m.Relationships = filterRelationships(m.Relationships, ch.From, ch.To)
242 }
243 if len(m.Relationships) < before {
244 result.RelationshipsDeleted++
245 }
246
247 case Added:
248 // Validate that both endpoints exist in the model before adding (#329).
249 // Prevents stale relationships from old draw.io files being imported after model replacement.
250 if _, fromExists := flatModel[ch.From]; !fromExists {
251 result.Warnings = append(result.Warnings,
252 fmt.Sprintf("Relationship %q->%q rejected: From element %q does not exist in model", ch.From, ch.To, ch.From))
253 return
254 }
255 if _, toExists := flatModel[ch.To]; !toExists {
256 result.Warnings = append(result.Warnings,
257 fmt.Sprintf("Relationship %q->%q rejected: To element %q does not exist in model", ch.From, ch.To, ch.To))
258 return
259 }
260 m.Relationships = append(m.Relationships, model.Relationship{
261 From: ch.From,
262 To: ch.To,
263 Label: ch.NewValue,
264 })
265 result.RelationshipsCreated++
266 }
267 }
268
269 // filterRelationships returns all relationships except those matching from/to.
270 func filterRelationships(rels []model.Relationship, from, to string) []model.Relationship {
271 result := make([]model.Relationship, 0, len(rels))
272 for _, r := range rels {
273 if r.From != from || r.To != to {
274 result = append(result, r)
275 }
276 }
277 return result
278 }
279
280 // modifyElement finds an element by dot-notation ID and applies fn to it.
281 func modifyElement(m *model.BausteinsichtModel, id string, fn func(*model.Element)) error {
282 parts := strings.Split(id, ".")
283 return modifyInMap(m.Model, parts, id, fn)
284 }
285
286 // modifyInMap recursively walks the map to find and modify the target element.
287 func modifyInMap(elems map[string]model.Element, parts []string, fullID string, fn func(*model.Element)) error {
288 if len(parts) == 0 {
289 return fmt.Errorf("empty path")
290 }
291 if len(parts) > maxReverseDepth {
292 return fmt.Errorf("element path %q exceeds maximum depth of %d", fullID, maxReverseDepth)
293 }
294 key := parts[0]
295 elem, ok := elems[key]
296 if !ok {
297 return fmt.Errorf("element %q not found", fullID)
298 }
299 if len(parts) == 1 {
300 fn(&elem)
301 elems[key] = elem
302 return nil
303 }
304 if elem.Children == nil {
305 return fmt.Errorf("element %q not found: no children at this level", fullID)
306 }
307 if err := modifyInMap(elem.Children, parts[1:], fullID, fn); err != nil {
308 return err
309 }
310 elems[key] = elem
311 return nil
312 }
313
314 // deleteElement removes an element by dot-notation ID from the model hierarchy.
315 func deleteElement(m *model.BausteinsichtModel, id string) error {
316 parts := strings.Split(id, ".")
317 return deleteFromMap(m.Model, parts, id)
318 }
319
320 // deleteFromMap recursively walks to find the parent map and deletes the element.
321 func deleteFromMap(elems map[string]model.Element, parts []string, fullID string) error {
322 if len(parts) == 0 {
323 return fmt.Errorf("empty path")
324 }
325 if len(parts) > maxReverseDepth {
326 return fmt.Errorf("element path %q exceeds maximum depth of %d", fullID, maxReverseDepth)
327 }
328 key := parts[0]
329 if len(parts) == 1 {
330 if _, ok := elems[key]; !ok {
331 return fmt.Errorf("element %q not found", fullID)
332 }
333 delete(elems, key)
334 return nil
335 }
336 elem, ok := elems[key]
337 if !ok {
338 return fmt.Errorf("element %q not found", fullID)
339 }
340 if elem.Children == nil {
341 return fmt.Errorf("element %q not found: no children at this level", fullID)
342 }
343 if err := deleteFromMap(elem.Children, parts[1:], fullID); err != nil {
344 return err
345 }
346 elems[key] = elem
347 return nil
348 }
349
350 // firstSpecKind returns the first element kind defined in the specification,
351 // sorted alphabetically for determinism. Returns "" if no kinds are defined.
352 func firstSpecKind(m *model.BausteinsichtModel) string {
353 if len(m.Specification.Elements) == 0 {
354 return ""
355 }
356 var best string
357 for k := range m.Specification.Elements {
358 if best == "" || k < best {
359 best = k
360 }
361 }
362 return best
363 }
364
365 // removeFromSlice returns a new slice with all occurrences of val removed.
366 func removeFromSlice(s []string, val string) []string {
367 result := make([]string, 0, len(s))
368 for _, v := range s {
369 if v != val {
370 result = append(result, v)
371 }
372 }
373 if len(result) == 0 {
374 return nil
375 }
376 return result
377 }
378
github.com/docToolchain/Bausteinsicht/internal/sync/state.go 71.8%
1 // Package sync handles bidirectional synchronization between the model and draw.io files.
2 package sync
3
4 import (
5 "crypto/sha256"
6 "encoding/json"
7 "fmt"
8 "os"
9 "path/filepath"
10 "time"
11
12 "github.com/docToolchain/Bausteinsicht/internal/drawio"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 )
15
16 // SyncState stores the state after each successful sync.
17 type SyncState struct {
18 Checksum string `json:"checksum,omitempty"`
19 Timestamp string `json:"timestamp"`
20 ModelHash string `json:"model_hash"`
21 DrawioHash string `json:"drawio_hash"`
22 Elements map[string]ElementState `json:"elements"`
23 Relationships []RelationshipState `json:"relationships"`
24 RenderedElements map[string]bool `json:"rendered_elements,omitempty"`
25 }
26
27 // ElementState captures an element's synced values.
28 type ElementState struct {
29 Title string `json:"title"`
30 Description string `json:"description,omitempty"`
31 Technology string `json:"technology,omitempty"`
32 Kind string `json:"kind"`
33 }
34
35 // RelationshipState captures a relationship's synced values.
36 type RelationshipState struct {
37 From string `json:"from"`
38 To string `json:"to"`
39 Index int `json:"index"`
40 Label string `json:"label,omitempty"`
41 Kind string `json:"kind,omitempty"`
42 }
43
44 // LoadState reads a SyncState from the given path.
45 // If the file does not exist, an empty SyncState is returned (first-sync scenario).
46 func LoadState(path string) (*SyncState, error) {
47 data, err := os.ReadFile(path) // #nosec G304 -- path derived from model location
48 if err != nil {
49 if os.IsNotExist(err) {
50 return &SyncState{
51 Elements: make(map[string]ElementState),
52 Relationships: []RelationshipState{},
53 }, nil
54 }
55 return nil, fmt.Errorf("LoadState %q: %w", path, err)
56 }
57
58 // Treat a zero-byte file as empty/missing state (e.g. truncated write).
59 if len(data) == 0 {
60 return &SyncState{
61 Elements: make(map[string]ElementState),
62 Relationships: []RelationshipState{},
63 }, nil
64 }
65
66 var state SyncState
67 if err := json.Unmarshal(data, &state); err != nil {
68 return nil, fmt.Errorf("LoadState %q: %w", path, err)
69 }
70
71 // Verify integrity checksum if present (backward compat: old files without checksum skip validation).
72 if state.Checksum != "" {
73 savedChecksum := state.Checksum
74 state.Checksum = ""
75 canonical, err := json.Marshal(&state)
76 if err != nil {
77 return nil, fmt.Errorf("LoadState %q: checksum verification marshal: %w", path, err)
78 }
79 sum := sha256.Sum256(canonical)
80 computed := fmt.Sprintf("sha256:%x", sum)
81 if computed != savedChecksum {
82 return nil, fmt.Errorf("LoadState %q: sync state corrupted (checksum mismatch); delete .bausteinsicht-sync and re-run sync", path)
83 }
84 }
85
86 if state.Elements == nil {
87 state.Elements = make(map[string]ElementState)
88 }
89 if state.Relationships == nil {
90 state.Relationships = []RelationshipState{}
91 }
92 return &state, nil
93 }
94
95 // SaveState atomically writes state to path using a temp file + rename.
96 func SaveState(path string, state *SyncState) error {
97 // Compute integrity checksum: marshal without checksum → hash → set checksum → marshal with checksum.
98 state.Checksum = ""
99 canonical, err := json.Marshal(state)
100 if err != nil {
101 return fmt.Errorf("SaveState checksum marshal: %w", err)
102 }
103 sum := sha256.Sum256(canonical)
104 state.Checksum = fmt.Sprintf("sha256:%x", sum)
105
106 data, err := json.MarshalIndent(state, "", " ")
107 if err != nil {
108 return fmt.Errorf("SaveState marshal: %w", err)
109 }
110
111 dir := filepath.Dir(path)
112 tmp, err := os.CreateTemp(dir, ".bausteinsicht-sync-tmp-*")
113 if err != nil {
114 return fmt.Errorf("SaveState create temp: %w", err)
115 }
116 tmpName := tmp.Name()
117
118 if _, err := tmp.Write(data); err != nil {
119 _ = tmp.Close()
120 _ = os.Remove(tmpName)
121 return fmt.Errorf("SaveState write: %w", err)
122 }
123 if err := tmp.Close(); err != nil {
124 _ = os.Remove(tmpName)
125 return fmt.Errorf("SaveState close: %w", err)
126 }
127 if err := os.Rename(tmpName, path); err != nil {
128 _ = os.Remove(tmpName)
129 return fmt.Errorf("SaveState rename: %w", err)
130 }
131 return nil
132 }
133
134 // ComputeHash reads the file at path and returns a "sha256:<hex>" fingerprint.
135 func ComputeHash(path string) (string, error) {
136 data, err := os.ReadFile(path) // #nosec G304 -- path derived from model location
137 if err != nil {
138 return "", fmt.Errorf("ComputeHash %q: %w", path, err)
139 }
140 sum := sha256.Sum256(data)
141 return fmt.Sprintf("sha256:%x", sum), nil
142 }
143
144 // BuildState creates a SyncState snapshot from the current model and draw.io document.
145 func BuildState(m *model.BausteinsichtModel, doc *drawio.Document, modelPath, drawioPath string) (*SyncState, error) {
146 modelHash, err := ComputeHash(modelPath)
147 if err != nil {
148 return nil, fmt.Errorf("BuildState model hash: %w", err)
149 }
150
151 drawioHash, err := ComputeHash(drawioPath)
152 if err != nil {
153 return nil, fmt.Errorf("BuildState drawio hash: %w", err)
154 }
155
156 flat, err := model.FlattenElements(m)
157 if err != nil {
158 return nil, fmt.Errorf("BuildState flatten: %w", err)
159 }
160 elements := make(map[string]ElementState, len(flat))
161 for id, elem := range flat {
162 elements[id] = ElementState{
163 Title: elem.Title,
164 Description: elem.Description,
165 Technology: elem.Technology,
166 Kind: elem.Kind,
167 }
168 }
169
170 rels := make([]RelationshipState, 0, len(m.Relationships))
171 for i, r := range m.Relationships {
172 rels = append(rels, RelationshipState{
173 From: r.From,
174 To: r.To,
175 Index: i,
176 Label: r.Label,
177 Kind: r.Kind,
178 })
179 }
180
181 // Record which elements are actually present on draw.io pages.
182 // This allows deletion detection to distinguish "user deleted from draw.io"
183 // from "element was never rendered because views didn't include it" (#240).
184 rendered := make(map[string]bool)
185 if doc != nil {
186 for _, page := range doc.Pages() {
187 for _, obj := range page.FindAllElements() {
188 id := obj.SelectAttrValue("bausteinsicht_id", "")
189 if id != "" {
190 rendered[id] = true
191 }
192 }
193 }
194 }
195
196 return &SyncState{
197 Timestamp: time.Now().UTC().Format(time.RFC3339),
198 ModelHash: modelHash,
199 DrawioHash: drawioHash,
200 Elements: elements,
201 Relationships: rels,
202 RenderedElements: rendered,
203 }, nil
204 }
205
github.com/docToolchain/Bausteinsicht/internal/table/table.go 61.2%
1 package table
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // Format represents the output format for table export.
12 type Format int
13
14 const (
15 AsciiDoc Format = iota
16 Markdown
17 )
18
19 // Row represents a single element row in the table export.
20 type Row struct {
21 ID string `json:"id"`
22 Title string `json:"title"`
23 Kind string `json:"kind"`
24 Technology string `json:"technology,omitempty"`
25 Description string `json:"description,omitempty"`
26 }
27
28 // FormatView renders a single view's elements as a table.
29 func FormatView(m *model.BausteinsichtModel, viewKey string, f Format) (string, error) {
30 view, ok := m.Views[viewKey]
31 if !ok {
32 return "", fmt.Errorf("view %q not found", viewKey)
33 }
34
35 rows, err := resolveRows(m, &view)
36 if err != nil {
37 return "", err
38 }
39
40 var b strings.Builder
41 writeTitle(&b, view.Title, f)
42 writeTable(&b, rows, f)
43 return b.String(), nil
44 }
45
46 // FormatAllViews renders all views as tables in a single document.
47 func FormatAllViews(m *model.BausteinsichtModel, f Format) (string, error) {
48 keys := sortedViewKeys(m)
49 var b strings.Builder
50 for i, key := range keys {
51 if i > 0 {
52 b.WriteString("\n")
53 }
54 view, ok := m.Views[key]
55 if !ok {
56 continue
57 }
58 rows, err := resolveRows(m, &view)
59 if err != nil {
60 return "", err
61 }
62 writeTitle(&b, view.Title, f)
63 writeTable(&b, rows, f)
64 }
65 return b.String(), nil
66 }
67
68 // FormatCombined renders all elements across all views (deduplicated) as a single table.
69 func FormatCombined(m *model.BausteinsichtModel, f Format) (string, error) {
70 seen := make(map[string]bool)
71 var rows []Row
72
73 flat, _ := model.FlattenElements(m)
74 keys := sortedViewKeys(m)
75
76 for _, key := range keys {
77 view, ok := m.Views[key]
78 if !ok {
79 continue
80 }
81 v := view
82 resolved, err := model.ResolveView(m, &v)
83 if err != nil {
84 continue
85 }
86 for _, id := range resolved {
87 if seen[id] {
88 continue
89 }
90 seen[id] = true
91 elem := flat[id]
92 if elem == nil {
93 continue
94 }
95 rows = append(rows, Row{
96 ID: id,
97 Title: elem.Title,
98 Kind: elem.Kind,
99 Technology: elem.Technology,
100 Description: elem.Description,
101 })
102 }
103 }
104
105 sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
106
107 var b strings.Builder
108 writeTitle(&b, "All Elements", f)
109 writeTable(&b, rows, f)
110 return b.String(), nil
111 }
112
113 func resolveRows(m *model.BausteinsichtModel, view *model.View) ([]Row, error) {
114 resolved, err := model.ResolveView(m, view)
115 if err != nil {
116 return nil, err
117 }
118
119 flat, _ := model.FlattenElements(m)
120 sort.Strings(resolved)
121
122 var rows []Row
123 for _, id := range resolved {
124 elem := flat[id]
125 if elem == nil {
126 continue
127 }
128 rows = append(rows, Row{
129 ID: id,
130 Title: elem.Title,
131 Kind: elem.Kind,
132 Technology: elem.Technology,
133 Description: elem.Description,
134 })
135 }
136 return rows, nil
137 }
138
139 func writeTitle(b *strings.Builder, title string, f Format) {
140 switch f {
141 case AsciiDoc:
142 fmt.Fprintf(b, "=== %s\n\n", title)
143 case Markdown:
144 fmt.Fprintf(b, "### %s\n\n", title)
145 }
146 }
147
148 func writeTable(b *strings.Builder, rows []Row, f Format) {
149 switch f {
150 case AsciiDoc:
151 writeAsciiDocTable(b, rows)
152 case Markdown:
153 writeMarkdownTable(b, rows)
154 }
155 }
156
157 func writeAsciiDocTable(b *strings.Builder, rows []Row) {
158 b.WriteString("[cols=\"2,1,1,3\"]\n|===\n")
159 b.WriteString("| Element | Kind | Technology | Description\n\n")
160 for _, r := range rows {
161 fmt.Fprintf(b, "| %s\n| %s\n| %s\n| %s\n\n", r.Title, r.Kind, r.Technology, r.Description)
162 }
163 b.WriteString("|===\n")
164 }
165
166 func writeMarkdownTable(b *strings.Builder, rows []Row) {
167 b.WriteString("| Element | Kind | Technology | Description |\n")
168 b.WriteString("|---------|------|------------|-------------|\n")
169 for _, r := range rows {
170 fmt.Fprintf(b, "| %s | %s | %s | %s |\n", r.Title, r.Kind, r.Technology, r.Description)
171 }
172 }
173
174 // CollectRows returns the row data for JSON export.
175 // If viewKey is set, only that view's rows are returned.
176 // If combined is true, all views are merged (deduplicated).
177 // Otherwise, all views' rows are returned.
178 func CollectRows(m *model.BausteinsichtModel, viewKey string, combined bool) ([]Row, error) {
179 switch {
180 case combined:
181 return collectCombinedRows(m)
182 case viewKey != "":
183 view, ok := m.Views[viewKey]
184 if !ok {
185 return nil, fmt.Errorf("view %q not found", viewKey)
186 }
187 return resolveRows(m, &view)
188 default:
189 return collectAllRows(m)
190 }
191 }
192
193 func collectCombinedRows(m *model.BausteinsichtModel) ([]Row, error) {
194 seen := make(map[string]bool)
195 var rows []Row
196 flat, _ := model.FlattenElements(m)
197 for _, key := range sortedViewKeys(m) {
198 view, ok := m.Views[key]
199 if !ok {
200 continue
201 }
202 v := view
203 resolved, err := model.ResolveView(m, &v)
204 if err != nil {
205 continue
206 }
207 for _, id := range resolved {
208 if seen[id] {
209 continue
210 }
211 seen[id] = true
212 elem := flat[id]
213 if elem == nil {
214 continue
215 }
216 rows = append(rows, Row{
217 ID: id, Title: elem.Title, Kind: elem.Kind,
218 Technology: elem.Technology, Description: elem.Description,
219 })
220 }
221 }
222 sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
223 return rows, nil
224 }
225
226 func collectAllRows(m *model.BausteinsichtModel) ([]Row, error) {
227 var rows []Row
228 for _, key := range sortedViewKeys(m) {
229 view, ok := m.Views[key]
230 if !ok {
231 continue
232 }
233 viewRows, err := resolveRows(m, &view)
234 if err != nil {
235 return nil, err
236 }
237 rows = append(rows, viewRows...)
238 }
239 return rows, nil
240 }
241
242 func sortedViewKeys(m *model.BausteinsichtModel) []string {
243 keys := make([]string, 0, len(m.Views))
244 for k := range m.Views {
245 keys = append(keys, k)
246 }
247 sort.Strings(keys)
248 return keys
249 }
250
github.com/docToolchain/Bausteinsicht/internal/template/generator.go 96.1%
1 package template
2
3 import (
4 "bytes"
5 "fmt"
6 "sort"
7 "strings"
8
9 etree "github.com/beevik/etree"
10 "github.com/docToolchain/Bausteinsicht/internal/model"
11 )
12
13 // Generator creates a draw.io template from an element specification.
14 type Generator struct {
15 spec model.Specification
16 style string
17 nextID int
18 }
19
20 // NewGenerator creates a new template generator.
21 func NewGenerator(spec model.Specification, style string) *Generator {
22 if style == "" {
23 style = DefaultStyle
24 }
25 return &Generator{
26 spec: spec,
27 style: style,
28 nextID: 2,
29 }
30 }
31
32 // Generate produces the draw.io template XML as a complete mxfile.
33 func (g *Generator) Generate() string {
34 doc := etree.NewDocument()
35 doc.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
36
37 mxfile := doc.CreateElement("mxfile")
38 mxfile.CreateAttr("host", "app.diagrams.net")
39 mxfile.CreateAttr("modified", "2026-01-01T00:00:00.000Z")
40 mxfile.CreateAttr("agent", "Bausteinsicht")
41 mxfile.CreateAttr("version", "1.0")
42 mxfile.CreateAttr("type", "device")
43
44 diagram := mxfile.CreateElement("diagram")
45 diagram.CreateAttr("id", "1")
46 diagram.CreateAttr("name", "Template")
47
48 root := diagram.CreateElement("mxGraphModel")
49 root.CreateAttr("dx", "0")
50 root.CreateAttr("dy", "0")
51 root.CreateAttr("grid", "1")
52 root.CreateAttr("gridSize", "10")
53 root.CreateAttr("guides", "1")
54 root.CreateAttr("tooltips", "1")
55 root.CreateAttr("connect", "1")
56 root.CreateAttr("arrows", "1")
57 root.CreateAttr("fold", "1")
58 root.CreateAttr("page", "1")
59 root.CreateAttr("pageScale", "1")
60 root.CreateAttr("pageWidth", "827")
61 root.CreateAttr("pageHeight", "1169")
62 root.CreateAttr("background", "#ffffff")
63 root.CreateAttr("math", "0")
64 root.CreateAttr("shadow", "0")
65
66 rootElem := root.CreateElement("root")
67 cell0 := rootElem.CreateElement("mxCell")
68 cell0.CreateAttr("id", "0")
69 cell1 := rootElem.CreateElement("mxCell")
70 cell1.CreateAttr("id", "1")
71 cell1.CreateAttr("parent", "0")
72
73 g.nextID = 2
74
75 // Collect kinds in sorted order
76 var kinds []string
77 for kind := range g.spec.Elements {
78 kinds = append(kinds, kind)
79 }
80
81 // Sort for consistent output
82 sort.Strings(kinds)
83
84 // Layout elements in grid (4 columns)
85 layout := GridLayout(kinds, 4)
86
87 for _, elem := range layout {
88 g.addElement(rootElem, elem.Kind, elem.Position.X, elem.Position.Y)
89 }
90
91 doc.Indent(2)
92 var buf bytes.Buffer
93 if _, err := doc.WriteTo(&buf); err != nil {
94 return ""
95 }
96 return buf.String()
97 }
98
99 func (g *Generator) addElement(parent *etree.Element, kind string, x, y int) {
100 cfg := DefaultShapeConfig(kind)
101 colors := ColorForKind(g.style, kind)
102 elementSpec := g.spec.Elements[kind]
103
104 // Create label with kind name and type
105 kindTitle := strings.ToUpper(kind[:1]) + kind[1:]
106 label := fmt.Sprintf("<b>%s</b><br/>[%s]", kindTitle, kind)
107
108 // Build style string
109 style := g.buildStyle(cfg, colors, elementSpec)
110
111 cell := parent.CreateElement("mxCell")
112 cell.CreateAttr("id", fmt.Sprintf("%d", g.nextID))
113 g.nextID++
114 cell.CreateAttr("value", label) // Don't escape: draw.io uses html=1 and requires raw HTML markup for rich text
115 cell.CreateAttr("style", style+";html=1") // Enable HTML rendering in draw.io
116 cell.CreateAttr("vertex", "1")
117 cell.CreateAttr("parent", "1")
118
119 geometry := cell.CreateElement("mxGeometry")
120 geometry.CreateAttr("x", fmt.Sprintf("%d", x))
121 geometry.CreateAttr("y", fmt.Sprintf("%d", y))
122 geometry.CreateAttr("width", fmt.Sprintf("%d", cfg.Width))
123 geometry.CreateAttr("height", fmt.Sprintf("%d", cfg.Height))
124 geometry.CreateAttr("as", "geometry")
125 }
126
127 func (g *Generator) buildStyle(cfg ShapeConfig, colors ColorStyle, _ model.ElementKind) string {
128 parts := []string{
129 fmt.Sprintf("fillColor=%s", colors.Fill),
130 fmt.Sprintf("strokeColor=%s", colors.Stroke),
131 "fontColor=#000000",
132 "fontSize=12",
133 }
134
135 // Add shape if specified
136 if cfg.Shape != "" {
137 if strings.HasPrefix(cfg.Shape, "shape=") {
138 parts = append(parts, cfg.Shape)
139 } else if !strings.Contains(cfg.Shape, "=") {
140 parts = append(parts, fmt.Sprintf("shape=%s", cfg.Shape))
141 } else {
142 parts = append(parts, cfg.Shape)
143 }
144 }
145
146 return strings.Join(parts, ";")
147 }
148
github.com/docToolchain/Bausteinsicht/internal/template/layout.go 94.7%
1 package template
2
3 // Position represents the X, Y coordinates of an element.
4 type Position struct {
5 X int
6 Y int
7 }
8
9 // Element holds a kind and its position.
10 type Element struct {
11 Kind string
12 Position Position
13 }
14
15 // GridLayout arranges elements in a grid.
16 func GridLayout(kinds []string, cols int) []Element {
17 if cols <= 0 {
18 cols = 4
19 }
20
21 var elements []Element
22 x, y := 40, 40
23 colCount := 0
24 maxHeight := 0
25
26 for _, kind := range kinds {
27 cfg := DefaultShapeConfig(kind)
28 elements = append(elements, Element{
29 Kind: kind,
30 Position: Position{X: x, Y: y},
31 })
32
33 if cfg.Height > maxHeight {
34 maxHeight = cfg.Height
35 }
36
37 colCount++
38 if colCount >= cols {
39 x = 40
40 y += maxHeight + 40
41 colCount = 0
42 maxHeight = 0
43 } else {
44 x += cfg.Width + 40
45 }
46 }
47
48 return elements
49 }
50
github.com/docToolchain/Bausteinsicht/internal/template/shapes.go 100.0%
1 package template
2
3 // ShapeConfig defines the draw.io shape and dimensions for a kind.
4 type ShapeConfig struct {
5 Shape string
6 Width int
7 Height int
8 }
9
10 // KindShapes maps element kinds to their shape configurations.
11 var KindShapes = map[string]ShapeConfig{
12 "person": {Shape: "mxgraph.archimate3.actor", Width: 60, Height: 80},
13 "actor": {Shape: "mxgraph.archimate3.actor", Width: 60, Height: 80},
14 "system": {Shape: "rounded=1", Width: 160, Height: 60},
15 "service": {Shape: "rounded=1", Width: 120, Height: 60},
16 "container": {Shape: "rounded=1;container=1", Width: 200, Height: 120},
17 "database": {Shape: "mxgraph.flowchart.database", Width: 60, Height: 80},
18 "datastore": {Shape: "mxgraph.flowchart.database", Width: 60, Height: 80},
19 "cache": {Shape: "mxgraph.flowchart.stored_data", Width: 80, Height: 60},
20 "queue": {Shape: "mxgraph.flowchart.process", Width: 120, Height: 60},
21 "filestore": {Shape: "mxgraph.flowchart.stored_data", Width: 80, Height: 60},
22 "component": {Shape: "rounded=1", Width: 120, Height: 60},
23 "frontend": {Shape: "rounded=1", Width: 120, Height: 60},
24 "mobile": {Shape: "mxgraph.iphone.phone3", Width: 60, Height: 100},
25 "ui": {Shape: "rounded=1", Width: 120, Height: 60},
26 "external_system": {Shape: "dashed=1;dashPattern=5 5;rounded=1", Width: 160, Height: 60},
27 }
28
29 // ColorStyle defines fill and stroke colors for a style preset.
30 type ColorStyle struct {
31 Fill string
32 Stroke string
33 }
34
35 // StylePresets defines the visual presets for different kinds.
36 var StylePresets = map[string]map[string]ColorStyle{
37 "default": {
38 "person": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
39 "actor": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
40 "system": {Fill: "#d5e8d4", Stroke: "#82b366"},
41 "service": {Fill: "#d5e8d4", Stroke: "#82b366"},
42 "container": {Fill: "#d5e8d4", Stroke: "#82b366"},
43 "database": {Fill: "#fff2cc", Stroke: "#d6b656"},
44 "datastore": {Fill: "#fff2cc", Stroke: "#d6b656"},
45 "cache": {Fill: "#fff2cc", Stroke: "#d6b656"},
46 "queue": {Fill: "#f8cecc", Stroke: "#b85450"},
47 "filestore": {Fill: "#fff2cc", Stroke: "#d6b656"},
48 "component": {Fill: "#d5e8d4", Stroke: "#82b366"},
49 "frontend": {Fill: "#e1d5e7", Stroke: "#9673a6"},
50 "mobile": {Fill: "#e1d5e7", Stroke: "#9673a6"},
51 "ui": {Fill: "#e1d5e7", Stroke: "#9673a6"},
52 "external_system": {Fill: "#f5f5f5", Stroke: "#999999"},
53 },
54 "c4": {
55 "person": {Fill: "#08427b", Stroke: "#08427b"},
56 "actor": {Fill: "#08427b", Stroke: "#08427b"},
57 "system": {Fill: "#1168bd", Stroke: "#0b4884"},
58 "service": {Fill: "#1168bd", Stroke: "#0b4884"},
59 "container": {Fill: "#1168bd", Stroke: "#0b4884"},
60 "database": {Fill: "#438dd5", Stroke: "#3c7fc0"},
61 "datastore": {Fill: "#438dd5", Stroke: "#3c7fc0"},
62 "cache": {Fill: "#438dd5", Stroke: "#3c7fc0"},
63 "queue": {Fill: "#999999", Stroke: "#666666"},
64 "filestore": {Fill: "#438dd5", Stroke: "#3c7fc0"},
65 "component": {Fill: "#438dd5", Stroke: "#3c7fc0"},
66 "frontend": {Fill: "#1168bd", Stroke: "#0b4884"},
67 "mobile": {Fill: "#1168bd", Stroke: "#0b4884"},
68 "ui": {Fill: "#1168bd", Stroke: "#0b4884"},
69 "external_system": {Fill: "#999999", Stroke: "#666666"},
70 },
71 "minimal": {
72 "person": {Fill: "#ffffff", Stroke: "#999999"},
73 "actor": {Fill: "#ffffff", Stroke: "#999999"},
74 "system": {Fill: "#ffffff", Stroke: "#999999"},
75 "service": {Fill: "#ffffff", Stroke: "#999999"},
76 "container": {Fill: "#ffffff", Stroke: "#999999"},
77 "database": {Fill: "#ffffff", Stroke: "#999999"},
78 "datastore": {Fill: "#ffffff", Stroke: "#999999"},
79 "cache": {Fill: "#ffffff", Stroke: "#999999"},
80 "queue": {Fill: "#ffffff", Stroke: "#999999"},
81 "filestore": {Fill: "#ffffff", Stroke: "#999999"},
82 "component": {Fill: "#ffffff", Stroke: "#999999"},
83 "frontend": {Fill: "#ffffff", Stroke: "#999999"},
84 "mobile": {Fill: "#ffffff", Stroke: "#999999"},
85 "ui": {Fill: "#ffffff", Stroke: "#999999"},
86 "external_system": {Fill: "#ffffff", Stroke: "#999999"},
87 },
88 "dark": {
89 "person": {Fill: "#ffb74d", Stroke: "#ff8a00"},
90 "actor": {Fill: "#ffb74d", Stroke: "#ff8a00"},
91 "system": {Fill: "#4dd0e1", Stroke: "#00acc1"},
92 "service": {Fill: "#4dd0e1", Stroke: "#00acc1"},
93 "container": {Fill: "#4dd0e1", Stroke: "#00acc1"},
94 "database": {Fill: "#81c784", Stroke: "#66bb6a"},
95 "datastore": {Fill: "#81c784", Stroke: "#66bb6a"},
96 "cache": {Fill: "#81c784", Stroke: "#66bb6a"},
97 "queue": {Fill: "#e57373", Stroke: "#ef5350"},
98 "filestore": {Fill: "#81c784", Stroke: "#66bb6a"},
99 "component": {Fill: "#4dd0e1", Stroke: "#00acc1"},
100 "frontend": {Fill: "#ba68c8", Stroke: "#ab47bc"},
101 "mobile": {Fill: "#ba68c8", Stroke: "#ab47bc"},
102 "ui": {Fill: "#ba68c8", Stroke: "#ab47bc"},
103 "external_system": {Fill: "#bbdefb", Stroke: "#64b5f6"},
104 },
105 }
106
107 // DefaultStyle is the default visual preset.
108 const DefaultStyle = "default"
109
110 // ColorForKind returns the color style for a kind in a given preset.
111 // Falls back to default preset if not found.
112 func ColorForKind(preset, kind string) ColorStyle {
113 if colors, ok := StylePresets[preset]; ok {
114 if color, ok := colors[kind]; ok {
115 return color
116 }
117 }
118 // Fall back to default preset
119 if colors, ok := StylePresets[DefaultStyle]; ok {
120 if color, ok := colors[kind]; ok {
121 return color
122 }
123 }
124 // Ultimate fallback
125 return ColorStyle{Fill: "#d5e8d4", Stroke: "#82b366"}
126 }
127
128 // DefaultShapeConfig returns the shape config for a kind.
129 // Falls back to rounded rectangle if not found.
130 func DefaultShapeConfig(kind string) ShapeConfig {
131 if cfg, ok := KindShapes[kind]; ok {
132 return cfg
133 }
134 return ShapeConfig{Shape: "rounded=1", Width: 120, Height: 60}
135 }
136
github.com/docToolchain/Bausteinsicht/internal/watcher/watcher.go 89.4%
1 // Package watcher monitors file system changes for watch mode operation.
2 package watcher
3
4 import (
5 "os"
6 "sync"
7 "time"
8
9 "github.com/fsnotify/fsnotify"
10 )
11
12 // DefaultDebounce is the default debounce duration.
13 const DefaultDebounce = 300 * time.Millisecond
14
15 // OnChange is the callback type invoked when a watched file changes.
16 type OnChange func(changedFile string)
17
18 // Watcher monitors specific files for write changes with debounce support.
19 type Watcher struct {
20 fsWatcher *fsnotify.Watcher
21 debounce time.Duration
22 onChange OnChange
23 done chan struct{}
24 syncing bool
25 mu sync.Mutex
26 syncMu sync.Mutex // serializes sync execution to prevent concurrent onChange calls
27 }
28
29 // New creates a Watcher that monitors the given files. The onChange callback
30 // is invoked after the debounce duration elapses following a write event.
31 func New(files []string, debounce time.Duration, onChange OnChange) (*Watcher, error) {
32 fsw, err := fsnotify.NewWatcher()
33 if err != nil {
34 return nil, err
35 }
36
37 for _, f := range files {
38 if err := fsw.Add(f); err != nil {
39 _ = fsw.Close()
40 return nil, err
41 }
42 }
43
44 return &Watcher{
45 fsWatcher: fsw,
46 debounce: debounce,
47 onChange: onChange,
48 done: make(chan struct{}),
49 }, nil
50 }
51
52 // Start begins listening for file change events in a background goroutine.
53 func (w *Watcher) Start() error {
54 go w.loop()
55 return nil
56 }
57
58 // Stop signals the watcher to shut down and closes the underlying fsnotify watcher.
59 func (w *Watcher) Stop() {
60 close(w.done)
61 _ = w.fsWatcher.Close()
62 }
63
64 // SetSyncing sets the syncing flag. While true, file change events are ignored
65 // to prevent re-triggering from the watcher's own writes.
66 func (w *Watcher) SetSyncing(v bool) {
67 w.mu.Lock()
68 defer w.mu.Unlock()
69 w.syncing = v
70 }
71
72 func (w *Watcher) isSyncing() bool {
73 w.mu.Lock()
74 defer w.mu.Unlock()
75 return w.syncing
76 }
77
78 func (w *Watcher) loop() {
79 var timer *time.Timer
80 var lastFile string
81
82 for {
83 select {
84 case <-w.done:
85 if timer != nil {
86 timer.Stop()
87 }
88 return
89
90 case event, ok := <-w.fsWatcher.Events:
91 if !ok {
92 return
93 }
94
95 // When a file is removed or renamed, try to re-add it.
96 // Editors and git often use atomic writes (delete + create).
97 if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
98 go w.rewatch(event.Name)
99 continue
100 }
101
102 if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
103 continue
104 }
105 if w.isSyncing() {
106 continue
107 }
108
109 lastFile = event.Name
110
111 if timer != nil {
112 timer.Stop()
113 }
114 captured := lastFile // capture by value to avoid data race with AfterFunc goroutine
115 timer = time.AfterFunc(w.debounce, func() {
116 if !w.isSyncing() {
117 w.syncMu.Lock()
118 defer w.syncMu.Unlock()
119 if !w.isSyncing() {
120 w.onChange(captured)
121 }
122 }
123 })
124
125 case _, ok := <-w.fsWatcher.Errors:
126 if !ok {
127 return
128 }
129 }
130 }
131 }
132
133 // rewatch polls for a removed file to reappear, then re-adds it to the watcher.
134 // Uses exponential backoff (50ms → 100ms → ... → 2s max) and polls indefinitely
135 // until the file reappears or Stop() is called (#268).
136 func (w *Watcher) rewatch(path string) {
137 backoff := 50 * time.Millisecond
138 const maxBackoff = 2 * time.Second
139
140 for {
141 select {
142 case <-w.done:
143 return
144 default:
145 }
146
147 if _, err := os.Stat(path); err == nil {
148 // File exists again — re-add to watcher.
149 _ = w.fsWatcher.Add(path)
150 // File was replaced via atomic rename — trigger callback
151 // since the content has changed but no Write event will fire.
152 if !w.isSyncing() {
153 time.AfterFunc(w.debounce, func() {
154 if !w.isSyncing() {
155 w.syncMu.Lock()
156 defer w.syncMu.Unlock()
157 if !w.isSyncing() {
158 w.onChange(path)
159 }
160 }
161 })
162 }
163 return
164 }
165 time.Sleep(backoff)
166 if backoff < maxBackoff {
167 backoff *= 2
168 if backoff > maxBackoff {
169 backoff = maxBackoff
170 }
171 }
172 }
173 }
174
github.com/docToolchain/Bausteinsicht/internal/workspace/loader.go 0.0%
1 package workspace
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // LoadConfig reads and parses a workspace configuration file.
13 func LoadConfig(path string) (*Config, error) {
14 data, err := os.ReadFile(path)
15 if err != nil {
16 return nil, fmt.Errorf("reading config: %w", err)
17 }
18
19 var cfg Config
20 if err := json.Unmarshal(data, &cfg); err != nil {
21 return nil, fmt.Errorf("parsing config: %w", err)
22 }
23
24 return &cfg, nil
25 }
26
27 // LoadModels loads all models referenced in the configuration.
28 // The basePath is used to resolve relative model paths.
29 func LoadModels(cfg *Config, basePath string) ([]LoadedModel, error) {
30 var loaded []LoadedModel
31
32 for _, ref := range cfg.Models {
33 modelPath := ref.Path
34 // If path is relative, resolve it relative to basePath
35 if !filepath.IsAbs(modelPath) {
36 modelPath = filepath.Join(basePath, modelPath)
37 }
38
39 m, err := model.Load(modelPath)
40 if err != nil {
41 return nil, fmt.Errorf("loading model %s (%s): %w", ref.ID, ref.Path, err)
42 }
43
44 loaded = append(loaded, LoadedModel{
45 Ref: ref,
46 Model: m,
47 })
48 }
49
50 return loaded, nil
51 }
52
53 // SaveConfig writes a workspace configuration to a file.
54 func SaveConfig(cfg *Config, path string) error {
55 data, err := json.MarshalIndent(cfg, "", " ")
56 if err != nil {
57 return fmt.Errorf("marshaling config: %w", err)
58 }
59
60 if err := os.WriteFile(path, data, 0644); err != nil {
61 return fmt.Errorf("writing config: %w", err)
62 }
63
64 return nil
65 }
66
github.com/docToolchain/Bausteinsicht/internal/workspace/merge.go 53.8%
1 package workspace
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // MergeModels combines multiple models into a single unified model.
11 // Element IDs are prefixed to avoid collisions:
12 // - If ModelRef.Prefix is set, use it as prefix
13 // - Otherwise, use ModelRef.ID as prefix
14 // Cross-model relationships are resolved using prefixed IDs.
15 func MergeModels(loaded []LoadedModel) (*model.BausteinsichtModel, error) {
16 if len(loaded) == 0 {
17 return nil, fmt.Errorf("no models to merge")
18 }
19
20 merged := &model.BausteinsichtModel{
21 Specification: model.Specification{
22 Elements: make(map[string]model.ElementKind),
23 Relationships: make(map[string]model.RelationshipKind),
24 },
25 Model: make(map[string]model.Element),
26 Relationships: []model.Relationship{},
27 Views: make(map[string]model.View),
28 DynamicViews: []model.DynamicView{},
29 Constraints: []model.Constraint{},
30 }
31
32 // Merge specifications (element and relationship kinds)
33 for _, lm := range loaded {
34 for kind, def := range lm.Model.Specification.Elements {
35 if _, exists := merged.Specification.Elements[kind]; !exists {
36 merged.Specification.Elements[kind] = def
37 }
38 }
39 for kind, def := range lm.Model.Specification.Relationships {
40 if _, exists := merged.Specification.Relationships[kind]; !exists {
41 merged.Specification.Relationships[kind] = def
42 }
43 }
44 }
45
46 // Map to track ID transformations for relationship resolution
47 idMap := make(map[string]string) // original ID → prefixed ID
48
49 // Merge elements with prefixing
50 for _, lm := range loaded {
51 prefix := lm.Ref.Prefix
52 if prefix == "" {
53 prefix = lm.Ref.ID
54 }
55
56 flatElems, _ := model.FlattenElements(lm.Model)
57 for id, elemPtr := range flatElems {
58 prefixedID := prefixElementID(id, prefix)
59 idMap[id] = prefixedID
60
61 merged.Model[prefixedID] = *elemPtr
62 }
63 }
64
65 // Merge relationships with ID remapping
66 for _, lm := range loaded {
67 prefix := lm.Ref.Prefix
68 if prefix == "" {
69 prefix = lm.Ref.ID
70 }
71
72 for _, rel := range lm.Model.Relationships {
73 remappedRel := rel
74 remappedRel.From = prefixElementID(rel.From, prefix)
75 remappedRel.To = prefixElementID(rel.To, prefix)
76 merged.Relationships = append(merged.Relationships, remappedRel)
77 }
78 }
79
80 // Merge views (each model's views are prefixed with model ID)
81 for _, lm := range loaded {
82 prefix := lm.Ref.Prefix
83 if prefix == "" {
84 prefix = lm.Ref.ID
85 }
86
87 for viewID, view := range lm.Model.Views {
88 viewKey := prefix + "_" + viewID
89 remappedView := view
90 remappedView.Include = remapElementIDs(view.Include, prefix)
91 remappedView.Exclude = remapElementIDs(view.Exclude, prefix)
92 if view.Scope != "" {
93 remappedView.Scope = prefixElementID(view.Scope, prefix)
94 }
95 merged.Views[viewKey] = remappedView
96 }
97 }
98
99 // Merge dynamic views
100 for _, lm := range loaded {
101 prefix := lm.Ref.Prefix
102 if prefix == "" {
103 prefix = lm.Ref.ID
104 }
105
106 for _, dv := range lm.Model.DynamicViews {
107 remappedDV := dv
108 remappedDV.Key = prefix + "_" + dv.Key
109 for i := range remappedDV.Steps {
110 remappedDV.Steps[i].From = prefixElementID(remappedDV.Steps[i].From, prefix)
111 remappedDV.Steps[i].To = prefixElementID(remappedDV.Steps[i].To, prefix)
112 }
113 merged.DynamicViews = append(merged.DynamicViews, remappedDV)
114 }
115 }
116
117 // Merge constraints
118 for _, lm := range loaded {
119 prefix := lm.Ref.Prefix
120 if prefix == "" {
121 prefix = lm.Ref.ID
122 }
123 _ = prefix // TODO: use prefix for remapping element IDs
124
125 for _, constraint := range lm.Model.Constraints {
126 remappedConstraint := constraint
127 if constraint.FromKind != "" {
128 remappedConstraint.FromKind = constraint.FromKind
129 }
130 merged.Constraints = append(merged.Constraints, remappedConstraint)
131 }
132 }
133
134 return merged, nil
135 }
136
137 // prefixElementID adds a prefix to an element ID.
138 // Dot-notation paths like "a.b.c" become "prefix_a.b.c".
139 func prefixElementID(id, prefix string) string {
140 parts := strings.SplitN(id, ".", 2)
141 return prefix + "_" + parts[0] + func() string {
142 if len(parts) > 1 {
143 return "." + parts[1]
144 }
145 return ""
146 }()
147 }
148
149 // remapElementIDs applies prefixing to a list of element IDs.
150 func remapElementIDs(ids []string, prefix string) []string {
151 var result []string
152 for _, id := range ids {
153 result = append(result, prefixElementID(id, prefix))
154 }
155 return result
156 }
157
158 // ResolveWorkspaceView resolves a workspace view by expanding element filters
159 // across all loaded models and returning a unified element set.
160 func ResolveWorkspaceView(cfg *Config, loaded []LoadedModel, view *WorkspaceView) (map[string]*model.Element, error) {
161 merged, err := MergeModels(loaded)
162 if err != nil {
163 return nil, err
164 }
165
166 flatElems, _ := model.FlattenElements(merged)
167 result := make(map[string]*model.Element)
168
169 // Start with includes
170 if len(view.IncludeFrom) > 0 {
171 // Include from specific models
172 for _, modelID := range view.IncludeFrom {
173 for _, lm := range loaded {
174 if lm.Ref.ID == modelID {
175 prefix := lm.Ref.Prefix
176 if prefix == "" {
177 prefix = lm.Ref.ID
178 }
179 flatLM, _ := model.FlattenElements(lm.Model)
180 for id, elemPtr := range flatLM {
181 prefixedID := prefixElementID(id, prefix)
182 if len(view.IncludeKinds) == 0 || contains(view.IncludeKinds, elemPtr.Kind) {
183 result[prefixedID] = elemPtr
184 }
185 }
186 break
187 }
188 }
189 }
190 } else if len(view.IncludeKinds) > 0 {
191 // Include by kinds across all models
192 for id, elemPtr := range flatElems {
193 if contains(view.IncludeKinds, elemPtr.Kind) && !contains(view.ExcludeKinds, elemPtr.Kind) {
194 result[id] = elemPtr
195 }
196 }
197 } else {
198 // Include all
199 result = flatElems
200 }
201
202 // Apply excludes
203 for _, kind := range view.ExcludeKinds {
204 for id, elemPtr := range result {
205 if elemPtr.Kind == kind {
206 delete(result, id)
207 }
208 }
209 }
210
211 return result, nil
212 }
213
214 func contains(slice []string, s string) bool {
215 for _, item := range slice {
216 if item == s {
217 return true
218 }
219 }
220 return false
221 }
222
832
Total Tests
99.6%
Pass Rate
69.0%
Coverage
6.84s
Duration

📈 Changes from Previous Run

Metric Change

📦 Test Results by Package

🔍 Package Details

Package Tests ✅ Passed ❌ Failed ⏭️ Skipped Pass Rate
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht 193 191 0 2 99.0%
github.com/docToolchain/Bausteinsicht/internal/changelog 14 14 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/constraints 16 16 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/diagram 40 40 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/diff 12 12 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/drawio 99 99 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/export 17 16 0 1 94.1%
github.com/docToolchain/Bausteinsicht/internal/exporter/structurizr 18 18 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/graph 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/health 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/importer/likec4 3 3 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/importer/structurizr 7 7 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/layout 7 7 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/lsp 17 17 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/model 143 143 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/overlay 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/schema 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/search 13 13 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/sync 159 159 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/table 6 6 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/template 22 22 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/watcher 7 7 0 0 100.0%
github.com/docToolchain/Bausteinsicht/internal/workspace 11 11 0 0 100.0%
github.com/docToolchain/Bausteinsicht/templates 4 4 0 0 100.0%

🔬 Line-Level Coverage

github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht-lsp/main.go 0.0%
1 package main
2
3 import (
4 "flag"
5 "log"
6 "os"
7
8 "github.com/docToolchain/Bausteinsicht/internal/lsp"
9 )
10
11 func main() {
12 var debug bool
13 var stdio bool // Accept -stdio flag (used by LSP clients, can be ignored)
14 flag.BoolVar(&debug, "debug", false, "Enable debug logging to stderr")
15 flag.BoolVar(&stdio, "stdio", false, "Use stdio for LSP communication (default behavior)")
16 flag.Parse()
17
18 // Set up logging
19 logFile := os.Stderr
20 if !debug {
21 logFile, _ = os.OpenFile(os.DevNull, os.O_WRONLY, 0)
22 }
23 log.SetOutput(logFile)
24
25 // Create and run LSP server
26 server := lsp.NewServer()
27 if err := server.Run(); err != nil {
28 log.Fatalf("LSP server error: %v", err)
29 }
30 }
31
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add.go 100.0%
1 package main
2
3 import "github.com/spf13/cobra"
4
5 func newAddCmd() *cobra.Command {
6 cmd := &cobra.Command{
7 Use: "add",
8 Short: "Add elements, relationships, views, or specification types to the model",
9 }
10
11 cmd.AddCommand(newAddElementCmd())
12 cmd.AddCommand(newAddRelationshipCmd())
13 cmd.AddCommand(newAddFromPatternCmd())
14
15 // Create a pattern sub-group
16 patternCmd := &cobra.Command{
17 Use: "pattern",
18 Short: "Manage patterns",
19 }
20 patternCmd.AddCommand(newListPatternsCmd())
21
22 cmd.AddCommand(patternCmd)
23 cmd.AddCommand(newAddViewCmd())
24 cmd.AddCommand(newAddSpecificationCmd())
25
26 return cmd
27 }
28
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_element.go 77.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "regexp"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 // validIDPattern matches element IDs: letters, digits, hyphens, underscores.
14 // Dots are NOT allowed since they serve as hierarchy separators.
15 var validIDPattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
16
17 // isValidID checks if the given ID is a valid element identifier.
18 func isValidID(id string) bool {
19 return validIDPattern.MatchString(id)
20 }
21
22 func newAddElementCmd() *cobra.Command {
23 cmd := &cobra.Command{
24 Use: "element",
25 Short: "Add an element to the model",
26 RunE: runAddElement,
27 }
28
29 cmd.Flags().String("id", "", "Unique identifier for the element (required)")
30 cmd.Flags().String("kind", "", "Element kind as defined in specification (required)")
31 cmd.Flags().String("title", "", "Display title (required)")
32 cmd.Flags().String("parent", "", "Parent element ID (dot notation)")
33 cmd.Flags().String("technology", "", "Technology description")
34 cmd.Flags().String("description", "", "Element description")
35
36 _ = cmd.MarkFlagRequired("id")
37 _ = cmd.MarkFlagRequired("kind")
38 _ = cmd.MarkFlagRequired("title")
39
40 return cmd
41 }
42
43 func runAddElement(cmd *cobra.Command, args []string) error {
44 id, _ := cmd.Flags().GetString("id")
45 kind, _ := cmd.Flags().GetString("kind")
46 title, _ := cmd.Flags().GetString("title")
47 parent, _ := cmd.Flags().GetString("parent")
48 technology, _ := cmd.Flags().GetString("technology")
49 description, _ := cmd.Flags().GetString("description")
50
51 modelPath, _ := cmd.Flags().GetString("model")
52 format, _ := cmd.Flags().GetString("format")
53
54 // Validate ID format. (#123)
55 if !isValidID(id) {
56 return exitWithCode(
57 fmt.Errorf("invalid element ID %q: must contain only letters, digits, hyphens, or underscores", id),
58 1,
59 )
60 }
61
62 // Validate title is not empty. (#124)
63 if title == "" {
64 return exitWithCode(fmt.Errorf("title must not be empty"), 1)
65 }
66
67 // Load model
68 if modelPath == "" {
69 detected, err := model.AutoDetect(".")
70 if err != nil {
71 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
72 }
73 modelPath = detected
74 }
75
76 m, err := model.Load(modelPath)
77 if err != nil {
78 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
79 }
80
81 // Validate kind
82 if _, ok := m.Specification.Elements[kind]; !ok {
83 return exitWithCode(
84 fmt.Errorf("unknown element kind %q; valid kinds: %s", kind, validKinds(m)),
85 1,
86 )
87 }
88
89 // Build element
90 elem := model.Element{
91 Kind: kind,
92 Title: title,
93 Technology: technology,
94 Description: description,
95 }
96
97 fullID := id
98
99 if parent != "" {
100 // Validate parent exists
101 parentElem, err := model.Resolve(m, parent)
102 if err != nil {
103 return exitWithCode(fmt.Errorf("parent %q not found: %w", parent, err), 1)
104 }
105
106 // Validate parent's kind allows children.
107 if spec, ok := m.Specification.Elements[parentElem.Kind]; !ok || !spec.Container {
108 return exitWithCode(
109 fmt.Errorf("element %q (kind: %s) is not a container and cannot have children", parent, parentElem.Kind),
110 1,
111 )
112 }
113
114 // Check duplicate within parent's children
115 if parentElem.Children != nil {
116 if _, exists := parentElem.Children[id]; exists {
117 return exitWithCode(fmt.Errorf("element %q already exists under %q", id, parent), 1)
118 }
119 }
120
121 // Add to parent's children — need to update in-place through the model
122 if err := addChildToParent(m, parent, id, elem); err != nil {
123 return exitWithCode(err, 1)
124 }
125
126 fullID = parent + "." + id
127 } else {
128 // Check duplicate at top level
129 if _, exists := m.Model[id]; exists {
130 return exitWithCode(fmt.Errorf("element %q already exists at top level", id), 1)
131 }
132
133 if m.Model == nil {
134 m.Model = make(map[string]model.Element)
135 }
136 m.Model[id] = elem
137 }
138
139 // Save model — use comment-preserving insertion. (#122)
140 if err := saveAddedElement(modelPath, m, fullID, parent, id, elem); err != nil {
141 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
142 }
143
144 // Output
145 if format == "json" {
146 out := map[string]string{
147 "id": fullID,
148 "kind": kind,
149 "title": title,
150 }
151 if technology != "" {
152 out["technology"] = technology
153 }
154 if description != "" {
155 out["description"] = description
156 }
157 data, _ := json.Marshal(out)
158 fmt.Println(string(data))
159 } else {
160 fmt.Printf("Added element '%s' (kind: %s) to model.\n", fullID, kind)
161 }
162
163 return nil
164 }
165
166 // addChildToParent traverses the model to the parent and adds the child element.
167 func addChildToParent(m *model.BausteinsichtModel, parentPath, childID string, child model.Element) error {
168 parts := splitDotPath(parentPath)
169
170 // Get root element
171 root, ok := m.Model[parts[0]]
172 if !ok {
173 return fmt.Errorf("element %q not found", parts[0])
174 }
175
176 if len(parts) == 1 {
177 if root.Children == nil {
178 root.Children = make(map[string]model.Element)
179 }
180 root.Children[childID] = child
181 m.Model[parts[0]] = root
182 return nil
183 }
184
185 // Traverse to parent, building a stack of elements to update
186 stack := []model.Element{root}
187 current := root
188 for _, part := range parts[1:] {
189 if current.Children == nil {
190 return fmt.Errorf("no children at %q", part)
191 }
192 next, ok := current.Children[part]
193 if !ok {
194 return fmt.Errorf("element %q not found", part)
195 }
196 stack = append(stack, next)
197 current = next
198 }
199
200 // Add child to the deepest parent
201 if current.Children == nil {
202 current.Children = make(map[string]model.Element)
203 }
204 current.Children[childID] = child
205 stack[len(stack)-1] = current
206
207 // Walk back up the stack updating parents
208 for i := len(stack) - 1; i > 0; i-- {
209 parentElem := stack[i-1]
210 if parentElem.Children == nil {
211 parentElem.Children = make(map[string]model.Element)
212 }
213 parentElem.Children[parts[i]] = stack[i]
214 stack[i-1] = parentElem
215 }
216
217 m.Model[parts[0]] = stack[0]
218 return nil
219 }
220
221 func splitDotPath(path string) []string {
222 result := []string{}
223 current := ""
224 for _, c := range path {
225 if c == '.' {
226 if current != "" {
227 result = append(result, current)
228 }
229 current = ""
230 } else {
231 current += string(c)
232 }
233 }
234 if current != "" {
235 result = append(result, current)
236 }
237 return result
238 }
239
240 // saveAddedElement saves a newly added element using comment-preserving
241 // insertion. Falls back to model.Save if patching fails. (#122)
242 func saveAddedElement(modelPath string, m *model.BausteinsichtModel, fullID, parent, id string, elem model.Element) error {
243 // Compute indent depth: "model" is depth 1, each dot-path segment adds 2
244 // (one for the parent element, one for "children").
245 depth := 2 // "model" → "X" is at depth 2
246 if parent != "" {
247 depth += len(splitDotPath(parent)) * 2 // each parent level adds element + children
248 }
249 elemJSON := marshalElementJSON(elem, depth)
250
251 // Build the object path for insertion.
252 var objectPath []string
253 if parent != "" {
254 // Insert into the parent's "children" object.
255 parts := splitDotPath(parent)
256 objectPath = append([]string{"model"}, parts...)
257 objectPath = append(objectPath, "children")
258 } else {
259 objectPath = []string{"model"}
260 }
261
262 err := model.PatchInsert(modelPath, func(data []byte) ([]byte, error) {
263 return model.InsertObjectEntry(data, objectPath, id, elemJSON)
264 })
265 if err != nil {
266 // Fall back to full save if patching fails.
267 return model.Save(modelPath, m)
268 }
269 return nil
270 }
271
272 // marshalElementJSON builds a formatted JSON object for an element.
273 // depth is the nesting depth of the entry key (e.g., 2 for "model.X",
274 // 4 for "model.X.children.Y"). Each level adds 2 spaces.
275 func marshalElementJSON(elem model.Element, depth int) string {
276 fieldIndent := strings.Repeat(" ", (depth+1)*2)
277 closeIndent := strings.Repeat(" ", depth*2)
278
279 parts := []string{fmt.Sprintf(`"kind": %q`, elem.Kind)}
280 parts = append(parts, fmt.Sprintf(`"title": %q`, elem.Title))
281 if elem.Technology != "" {
282 parts = append(parts, fmt.Sprintf(`"technology": %q`, elem.Technology))
283 }
284 if elem.Description != "" {
285 parts = append(parts, fmt.Sprintf(`"description": %q`, elem.Description))
286 }
287
288 result := "{\n"
289 for i, p := range parts {
290 result += fieldIndent + p
291 if i < len(parts)-1 {
292 result += ","
293 }
294 result += "\n"
295 }
296 result += closeIndent + "}"
297 return result
298 }
299
300 func validKinds(m *model.BausteinsichtModel) string {
301 kinds := ""
302 for k := range m.Specification.Elements {
303 if kinds != "" {
304 kinds += ", "
305 }
306 kinds += k
307 }
308 return kinds
309 }
310
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_from_pattern.go 8.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 func newAddFromPatternCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "add-from-pattern <pattern-id>",
15 Short: "Add elements and relationships from a pattern",
16 Long: "Expand a pattern from the specification into concrete elements and relationships in the model.",
17 Args: cobra.ExactArgs(1),
18 RunE: func(cmd *cobra.Command, args []string) error {
19 return runAddFromPattern(cmd, args)
20 },
21 }
22
23 cmd.Flags().String("id", "", "Base ID for generated elements (required)")
24 cmd.Flags().String("title", "", "Base title for generated elements (default: --id)")
25 cmd.Flags().String("prefix", "", "Namespace prefix (modifies {base} to prefix-base)")
26 _ = cmd.MarkFlagRequired("id")
27
28 return cmd
29 }
30
31 func newListPatternsCmd() *cobra.Command {
32 return &cobra.Command{
33 Use: "list",
34 Short: "List all available patterns",
35 Long: "List all patterns defined in specification with their element and relationship counts.",
36 RunE: func(cmd *cobra.Command, args []string) error {
37 return runListPatterns(cmd)
38 },
39 }
40 }
41
42 func runAddFromPattern(cmd *cobra.Command, args []string) error {
43 patternID := args[0]
44 modelPath, _ := cmd.Flags().GetString("model")
45 baseID, _ := cmd.Flags().GetString("id")
46 title, _ := cmd.Flags().GetString("title")
47 prefix, _ := cmd.Flags().GetString("prefix")
48
49 // Apply prefix if provided
50 if prefix != "" {
51 baseID = prefix + "-" + baseID
52 }
53
54 if modelPath == "" {
55 detected, err := model.AutoDetect(".")
56 if err != nil {
57 return exitWithCode(err, 2)
58 }
59 modelPath = detected
60 }
61
62 m, err := model.Load(modelPath)
63 if err != nil {
64 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
65 }
66
67 // Check if pattern exists
68 pattern, exists := m.Specification.Patterns[patternID]
69 if !exists {
70 return exitWithCode(fmt.Errorf("pattern %q not found in specification", patternID), 2)
71 }
72
73 // Check for conflicts
74 conflicts, err := model.CheckPatternConflicts(m, pattern, baseID)
75 if err != nil {
76 return exitWithCode(err, 2)
77 }
78
79 if len(conflicts) > 0 {
80 return exitWithCode(fmt.Errorf("conflict: elements already exist: %v (use a different --id)", conflicts), 2)
81 }
82
83 // Expand the pattern
84 elements, relationships, err := model.ExpandPattern(pattern, baseID, title)
85 if err != nil {
86 return exitWithCode(err, 2)
87 }
88
89 // Get expanded IDs
90 elemIDs, relIDs, err := model.ExpandPatternIDs(pattern, baseID)
91 if err != nil {
92 return exitWithCode(err, 2)
93 }
94
95 // Add elements to model (at top level for now)
96 if m.Model == nil {
97 m.Model = make(map[string]model.Element)
98 }
99 for i, elem := range elements {
100 m.Model[elemIDs[i]] = elem
101 }
102
103 // Add relationships (From and To are already expanded by ExpandPattern)
104 m.Relationships = append(m.Relationships, relationships...)
105
106 // Save the updated model
107 if err := model.Save(modelPath, m); err != nil {
108 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
109 }
110
111 // Output summary
112 fmt.Printf("✅ Pattern '%s' applied with base ID '%s':\n", patternID, baseID)
113 for i, id := range elemIDs {
114 fmt.Printf(" + %-20s [%-10s] \"%s\"\n", id, elements[i].Kind, elements[i].Title)
115 }
116 for i, id := range relIDs {
117 fmt.Printf(" + %-20s %s → %s \"%s\"\n", id, relationships[i].From, relationships[i].To, relationships[i].Label)
118 }
119
120 return nil
121 }
122
123 func runListPatterns(cmd *cobra.Command) error {
124 modelPath, _ := cmd.Flags().GetString("model")
125
126 if modelPath == "" {
127 detected, err := model.AutoDetect(".")
128 if err != nil {
129 return exitWithCode(err, 2)
130 }
131 modelPath = detected
132 }
133
134 m, err := model.Load(modelPath)
135 if err != nil {
136 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
137 }
138
139 if len(m.Specification.Patterns) == 0 {
140 fmt.Println("No patterns defined in specification")
141 return nil
142 }
143
144 // Sort pattern IDs
145 var patternIDs []string
146 for id := range m.Specification.Patterns {
147 patternIDs = append(patternIDs, id)
148 }
149 sort.Strings(patternIDs)
150
151 format, _ := cmd.Flags().GetString("format")
152 if format == "json" {
153 // Output as JSON
154 type patternInfo struct {
155 ID string `json:"id"`
156 Description string `json:"description"`
157 ElementCount int `json:"elementCount"`
158 RelationshipCount int `json:"relationshipCount"`
159 }
160 var patterns []patternInfo
161 for _, id := range patternIDs {
162 p := m.Specification.Patterns[id]
163 patterns = append(patterns, patternInfo{
164 ID: id,
165 Description: p.Description,
166 ElementCount: len(p.Elements),
167 RelationshipCount: len(p.Relationships),
168 })
169 }
170 b, err := json.MarshalIndent(patterns, "", " ")
171 if err != nil {
172 return err
173 }
174 fmt.Println(string(b))
175 return nil
176 }
177
178 // Text output
179 fmt.Println("Available patterns:")
180 fmt.Println("──────────────────────────────────────────────────────────────")
181 for _, id := range patternIDs {
182 p := m.Specification.Patterns[id]
183 elemCount := len(p.Elements)
184 relCount := len(p.Relationships)
185 fmt.Printf(" %-25s %s (%d elements, %d relationships)\n",
186 id, p.Description, elemCount, relCount)
187 }
188
189 return nil
190 }
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_relationship.go 87.5%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/spf13/cobra"
9 )
10
11 func newAddRelationshipCmd() *cobra.Command {
12 cmd := &cobra.Command{
13 Use: "relationship",
14 Short: "Add a relationship between two elements",
15 Long: "Adds a new relationship to the architecture model. Both --from and --to must reference existing elements.",
16 RunE: runAddRelationship,
17 }
18
19 cmd.Flags().String("from", "", "Source element (dot-notation path, e.g. webshop.api)")
20 cmd.Flags().String("to", "", "Target element (dot-notation path, e.g. webshop.db)")
21 cmd.Flags().String("label", "", "Relationship label")
22 cmd.Flags().String("kind", "", "Relationship kind (must be defined in specification)")
23 cmd.Flags().String("description", "", "Relationship description")
24
25 _ = cmd.MarkFlagRequired("from")
26 _ = cmd.MarkFlagRequired("to")
27
28 return cmd
29 }
30
31 func runAddRelationship(cmd *cobra.Command, args []string) error {
32 format, _ := cmd.Flags().GetString("format")
33 modelPath, _ := cmd.Flags().GetString("model")
34 from, _ := cmd.Flags().GetString("from")
35 to, _ := cmd.Flags().GetString("to")
36 label, _ := cmd.Flags().GetString("label")
37 kind, _ := cmd.Flags().GetString("kind")
38 description, _ := cmd.Flags().GetString("description")
39
40 if modelPath == "" {
41 detected, err := model.AutoDetect(".")
42 if err != nil {
43 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
44 }
45 modelPath = detected
46 }
47
48 m, err := model.Load(modelPath)
49 if err != nil {
50 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
51 }
52
53 if _, err := model.Resolve(m, from); err != nil {
54 return exitWithCode(fmt.Errorf("--from: element %q not found", from), 1)
55 }
56
57 if _, err := model.Resolve(m, to); err != nil {
58 return exitWithCode(fmt.Errorf("--to: element %q not found", to), 1)
59 }
60
61 if kind != "" {
62 if m.Specification.Relationships == nil {
63 return exitWithCode(fmt.Errorf("--kind: %q not defined (no relationship kinds in specification)", kind), 1)
64 }
65 if _, ok := m.Specification.Relationships[kind]; !ok {
66 return exitWithCode(fmt.Errorf("--kind: %q not defined in specification", kind), 1)
67 }
68 }
69
70 for _, r := range m.Relationships {
71 if r.From == from && r.To == to && r.Kind == kind {
72 return exitWithCode(fmt.Errorf("relationship %s -> %s (kind %q) already exists", from, to, kind), 1)
73 }
74 }
75
76 rel := model.Relationship{
77 From: from,
78 To: to,
79 Label: label,
80 Kind: kind,
81 Description: description,
82 }
83 m.Relationships = append(m.Relationships, rel)
84
85 // Save using comment-preserving array append. (#122)
86 relJSON := marshalRelationshipJSON(rel)
87 err = model.PatchInsert(modelPath, func(data []byte) ([]byte, error) {
88 return model.AppendArrayEntry(data, []string{"relationships"}, relJSON)
89 })
90 if err != nil {
91 // Fall back to full save if patching fails.
92 if err := model.Save(modelPath, m); err != nil {
93 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
94 }
95 }
96
97 if format == "json" {
98 return printRelationshipJSON(rel)
99 }
100 printRelationshipText(rel)
101 return nil
102 }
103
104 // marshalRelationshipJSON builds a compact JSON object for a relationship.
105 func marshalRelationshipJSON(rel model.Relationship) string {
106 parts := []string{
107 fmt.Sprintf(`"from": %q`, rel.From),
108 fmt.Sprintf(`"to": %q`, rel.To),
109 }
110 if rel.Label != "" {
111 parts = append(parts, fmt.Sprintf(`"label": %q`, rel.Label))
112 }
113 if rel.Kind != "" {
114 parts = append(parts, fmt.Sprintf(`"kind": %q`, rel.Kind))
115 }
116 if rel.Description != "" {
117 parts = append(parts, fmt.Sprintf(`"description": %q`, rel.Description))
118 }
119
120 result := "{\n"
121 for i, p := range parts {
122 result += " " + p
123 if i < len(parts)-1 {
124 result += ","
125 }
126 result += "\n"
127 }
128 result += " }"
129 return result
130 }
131
132 func printRelationshipText(r model.Relationship) {
133 if r.Label != "" {
134 fmt.Printf("Added relationship: %s -> %s (%s)\n", r.From, r.To, r.Label)
135 } else {
136 fmt.Printf("Added relationship: %s -> %s\n", r.From, r.To)
137 }
138 }
139
140 func printRelationshipJSON(r model.Relationship) error {
141 out := struct {
142 From string `json:"from"`
143 To string `json:"to"`
144 Label string `json:"label,omitempty"`
145 Kind string `json:"kind,omitempty"`
146 Description string `json:"description,omitempty"`
147 }{
148 From: r.From,
149 To: r.To,
150 Label: r.Label,
151 Kind: r.Kind,
152 Description: r.Description,
153 }
154 data, err := json.MarshalIndent(out, "", " ")
155 if err != nil {
156 return fmt.Errorf("marshaling JSON: %w", err)
157 }
158 fmt.Println(string(data))
159 return nil
160 }
161
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_specification.go 65.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "regexp"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 // validSpecKeyPattern matches specification keys: lowercase letters, digits, underscores, hyphens.
13 var validSpecKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
14
15 // isValidSpecKey checks if the given spec key is valid.
16 func isValidSpecKey(key string) bool {
17 return validSpecKeyPattern.MatchString(key)
18 }
19
20 func newAddSpecificationCmd() *cobra.Command {
21 cmd := &cobra.Command{
22 Use: "specification",
23 Short: "Add element or relationship types to the specification",
24 }
25
26 cmd.AddCommand(newAddSpecificationElementCmd())
27 cmd.AddCommand(newAddSpecificationRelationshipCmd())
28
29 return cmd
30 }
31
32 func newAddSpecificationElementCmd() *cobra.Command {
33 cmd := &cobra.Command{
34 Use: "element <key>",
35 Short: "Add an element type to the specification",
36 Args: cobra.ExactArgs(1),
37 RunE: runAddSpecificationElement,
38 }
39
40 cmd.Flags().String("notation", "", "Notation/display text for this element type (required)")
41 cmd.Flags().String("description", "", "Description of this element type")
42 cmd.Flags().Bool("container", false, "Whether this element can contain children")
43
44 _ = cmd.MarkFlagRequired("notation")
45
46 return cmd
47 }
48
49 func runAddSpecificationElement(cmd *cobra.Command, args []string) error {
50 key := args[0]
51 notation, _ := cmd.Flags().GetString("notation")
52 description, _ := cmd.Flags().GetString("description")
53 container, _ := cmd.Flags().GetBool("container")
54
55 modelPath, _ := cmd.Flags().GetString("model")
56 format, _ := cmd.Flags().GetString("format")
57
58 // Validate key format
59 if !isValidSpecKey(key) {
60 return exitWithCode(
61 fmt.Errorf("invalid specification key %q: must contain only lowercase letters, digits, hyphens, or underscores", key),
62 1,
63 )
64 }
65
66 // Load model
67 if modelPath == "" {
68 detected, err := model.AutoDetect(".")
69 if err != nil {
70 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
71 }
72 modelPath = detected
73 }
74
75 m, err := model.Load(modelPath)
76 if err != nil {
77 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
78 }
79
80 // Add element kind
81 err = m.AddSpecificationElement(key, model.ElementKind{
82 Notation: notation,
83 Description: description,
84 Container: container,
85 })
86 if err != nil {
87 return exitWithCode(fmt.Errorf("adding element: %w", err), 1)
88 }
89
90 // Save model
91 if err := model.Save(modelPath, m); err != nil {
92 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
93 }
94
95 // Output result
96 if format == "json" {
97 result := map[string]interface{}{
98 "key": key,
99 "notation": notation,
100 "container": container,
101 }
102 if description != "" {
103 result["description"] = description
104 }
105 jsonBytes, err := json.MarshalIndent(result, "", " ")
106 if err != nil {
107 return fmt.Errorf("encoding result: %w", err)
108 }
109 fmt.Println(string(jsonBytes))
110 } else {
111 fmt.Printf("Element type '%s' added to specification\n", key)
112 fmt.Printf(" Notation: %s\n", notation)
113 if description != "" {
114 fmt.Printf(" Description: %s\n", description)
115 }
116 if container {
117 fmt.Printf(" Container: yes\n")
118 }
119 }
120
121 return nil
122 }
123
124 func newAddSpecificationRelationshipCmd() *cobra.Command {
125 cmd := &cobra.Command{
126 Use: "relationship <key>",
127 Short: "Add a relationship type to the specification",
128 Args: cobra.ExactArgs(1),
129 RunE: runAddSpecificationRelationship,
130 }
131
132 cmd.Flags().String("notation", "", "Notation/display text for this relationship type (required)")
133 cmd.Flags().String("description", "", "Description of this relationship type")
134 cmd.Flags().Bool("dashed", false, "Whether this relationship is displayed as a dashed line")
135
136 _ = cmd.MarkFlagRequired("notation")
137
138 return cmd
139 }
140
141 func runAddSpecificationRelationship(cmd *cobra.Command, args []string) error {
142 key := args[0]
143 notation, _ := cmd.Flags().GetString("notation")
144 description, _ := cmd.Flags().GetString("description")
145 dashed, _ := cmd.Flags().GetBool("dashed")
146
147 modelPath, _ := cmd.Flags().GetString("model")
148 format, _ := cmd.Flags().GetString("format")
149
150 // Validate key format
151 if !isValidSpecKey(key) {
152 return exitWithCode(
153 fmt.Errorf("invalid specification key %q: must contain only lowercase letters, digits, hyphens, or underscores", key),
154 1,
155 )
156 }
157
158 // Load model
159 if modelPath == "" {
160 detected, err := model.AutoDetect(".")
161 if err != nil {
162 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
163 }
164 modelPath = detected
165 }
166
167 m, err := model.Load(modelPath)
168 if err != nil {
169 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
170 }
171
172 // Add relationship kind
173 err = m.AddSpecificationRelationship(key, model.RelationshipKind{
174 Notation: notation,
175 Dashed: dashed,
176 })
177 if err != nil {
178 return exitWithCode(fmt.Errorf("adding relationship: %w", err), 1)
179 }
180
181 // Save model
182 if err := model.Save(modelPath, m); err != nil {
183 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
184 }
185
186 // Output result
187 if format == "json" {
188 result := map[string]interface{}{
189 "key": key,
190 "notation": notation,
191 "dashed": dashed,
192 }
193 if description != "" {
194 result["description"] = description
195 }
196 jsonBytes, err := json.MarshalIndent(result, "", " ")
197 if err != nil {
198 return fmt.Errorf("encoding result: %w", err)
199 }
200 fmt.Println(string(jsonBytes))
201 } else {
202 fmt.Printf("Relationship type '%s' added to specification\n", key)
203 fmt.Printf(" Notation: %s\n", notation)
204 if description != "" {
205 fmt.Printf(" Description: %s\n", description)
206 }
207 if dashed {
208 fmt.Printf(" Style: dashed\n")
209 }
210 }
211
212 return nil
213 }
214
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/add_view.go 70.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "regexp"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 // validViewKeyPattern matches view keys: lowercase letters, digits, hyphens.
13 var validViewKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
14
15 // isValidViewKey checks if the given view key is valid.
16 func isValidViewKey(key string) bool {
17 return validViewKeyPattern.MatchString(key)
18 }
19
20 func newAddViewCmd() *cobra.Command {
21 cmd := &cobra.Command{
22 Use: "view <view-key>",
23 Short: "Create a new view or modify a view's include list",
24 Args: cobra.ExactArgs(1),
25 RunE: runAddView,
26 }
27
28 cmd.Flags().String("scope", "", "Scope element ID (parent element to show)")
29 cmd.Flags().StringSlice("include", []string{}, "Elements to include in view (repeatable)")
30 cmd.Flags().String("title", "", "View title (display name) (required for new views)")
31 cmd.Flags().String("description", "", "View description")
32
33 return cmd
34 }
35
36 func runAddView(cmd *cobra.Command, args []string) error {
37 viewKey := args[0]
38 scope, _ := cmd.Flags().GetString("scope")
39 includes, _ := cmd.Flags().GetStringSlice("include")
40 title, _ := cmd.Flags().GetString("title")
41 description, _ := cmd.Flags().GetString("description")
42
43 modelPath, _ := cmd.Flags().GetString("model")
44 format, _ := cmd.Flags().GetString("format")
45
46 // Validate view key format
47 if !isValidViewKey(viewKey) {
48 return exitWithCode(
49 fmt.Errorf("invalid view key %q: must contain only lowercase letters, digits, hyphens, or underscores", viewKey),
50 1,
51 )
52 }
53
54 // Load model
55 if modelPath == "" {
56 detected, err := model.AutoDetect(".")
57 if err != nil {
58 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
59 }
60 modelPath = detected
61 }
62
63 m, err := model.Load(modelPath)
64 if err != nil {
65 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
66 }
67
68 // Check if this is a new view or update
69 _, viewExists := m.Views[viewKey]
70 if !viewExists && title == "" {
71 return exitWithCode(
72 fmt.Errorf("title is required for new views"),
73 1,
74 )
75 }
76
77 // Create view struct
78 view := model.View{
79 Title: title,
80 Scope: scope,
81 Include: includes,
82 Description: description,
83 }
84
85 // Add or update view
86 err = m.AddView(viewKey, view)
87 if err != nil {
88 return exitWithCode(fmt.Errorf("adding view: %w", err), 1)
89 }
90
91 // Save model
92 if err := model.Save(modelPath, m); err != nil {
93 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
94 }
95
96 // Output result
97 if format == "json" {
98 result := map[string]interface{}{
99 "view_key": viewKey,
100 "title": title,
101 "scope": scope,
102 "include": includes,
103 }
104 jsonBytes, err := json.MarshalIndent(result, "", " ")
105 if err != nil {
106 return fmt.Errorf("encoding result: %w", err)
107 }
108 fmt.Println(string(jsonBytes))
109 } else {
110 fmt.Printf("View '%s' added to model\n", viewKey)
111 if title != "" {
112 fmt.Printf(" Title: %s\n", title)
113 }
114 if scope != "" {
115 fmt.Printf(" Scope: %s\n", scope)
116 }
117 if len(includes) > 0 {
118 fmt.Printf(" Includes: %v\n", includes)
119 }
120 }
121
122 return nil
123 }
124
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/adr.go 10.4%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 func newADRCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "adr",
16 Short: "Manage Architecture Decision Records (ADRs)",
17 Long: "List, show, and manage architecture decision records linked to model elements.",
18 RunE: func(cmd *cobra.Command, args []string) error {
19 return cmd.Help()
20 },
21 }
22
23 cmd.AddCommand(newADRListCmd())
24 cmd.AddCommand(newADRShowCmd())
25
26 return cmd
27 }
28
29 func newADRListCmd() *cobra.Command {
30 cmd := &cobra.Command{
31 Use: "list",
32 Short: "List all ADRs or ADRs linked to an element",
33 Long: "List architecture decision records, optionally filtered by element.",
34 RunE: func(cmd *cobra.Command, args []string) error {
35 modelPath := cmd.Flag("model").Value.String()
36 elementID := cmd.Flag("element").Value.String()
37 format := cmd.Flag("format").Value.String()
38
39 if modelPath == "" {
40 modelPath = "architecture.jsonc"
41 }
42
43 m, err := model.Load(modelPath)
44 if err != nil {
45 return fmt.Errorf("loading model: %w", err)
46 }
47
48 // Collect decisions to display
49 var decisions []model.DecisionRecord
50 if elementID != "" {
51 // Filter decisions for a specific element
52 elem, ok := findElementByID(m, elementID)
53 if !ok || elem == nil {
54 return fmt.Errorf("element not found: %s", elementID)
55 }
56
57 for _, decisionID := range elem.Decisions {
58 for _, d := range m.Specification.Decisions {
59 if d.ID == decisionID {
60 decisions = append(decisions, d)
61 break
62 }
63 }
64 }
65 } else {
66 // All decisions
67 decisions = m.Specification.Decisions
68 }
69
70 // Sort by ID
71 sort.Slice(decisions, func(i, j int) bool {
72 return decisions[i].ID < decisions[j].ID
73 })
74
75 // Format output
76 if format == "json" {
77 b, err := json.MarshalIndent(decisions, "", " ")
78 if err != nil {
79 return fmt.Errorf("marshaling JSON: %w", err)
80 }
81 fmt.Println(string(b))
82 return nil
83 }
84
85 // Default text format
86 if len(decisions) == 0 {
87 if elementID != "" {
88 fmt.Printf("No decisions linked to element %q\n", elementID)
89 } else {
90 fmt.Println("No decisions defined")
91 }
92 return nil
93 }
94
95 fmt.Printf("Decisions (%d):\n", len(decisions))
96 fmt.Println("──────────────────────────────────────────")
97 for _, d := range decisions {
98 statusIcon := getStatusIcon(d.Status)
99 fmt.Printf("%-20s %s %s\n", d.ID, statusIcon, d.Title)
100 if d.Date != "" {
101 fmt.Printf(" Date: %s\n", d.Date)
102 }
103 if d.FilePath != "" {
104 fmt.Printf(" File: %s\n", d.FilePath)
105 }
106 }
107
108 return nil
109 },
110 }
111
112 cmd.Flags().StringP("model", "m", "", "Path to architecture model (default: architecture.jsonc)")
113 cmd.Flags().String("element", "", "Filter decisions linked to this element")
114 cmd.Flags().String("format", "text", "Output format: text or json")
115
116 return cmd
117 }
118
119 func newADRShowCmd() *cobra.Command {
120 cmd := &cobra.Command{
121 Use: "show <adr-id>",
122 Short: "Show details of a specific ADR",
123 Long: "Display detailed information about an architecture decision record.",
124 Args: cobra.ExactArgs(1),
125 RunE: func(cmd *cobra.Command, args []string) error {
126 modelPath := cmd.Flag("model").Value.String()
127 decisionID := args[0]
128
129 if modelPath == "" {
130 modelPath = "architecture.jsonc"
131 }
132
133 m, err := model.Load(modelPath)
134 if err != nil {
135 return fmt.Errorf("loading model: %w", err)
136 }
137
138 // Find the decision
139 var decision *model.DecisionRecord
140 for i, d := range m.Specification.Decisions {
141 if d.ID == decisionID {
142 decision = &m.Specification.Decisions[i]
143 break
144 }
145 }
146
147 if decision == nil {
148 return fmt.Errorf("decision not found: %s", decisionID)
149 }
150
151 // Collect elements and relationships that reference this decision
152 var references []string
153 flat, _ := model.FlattenElements(m)
154 for elemID, elem := range flat {
155 for _, dID := range elem.Decisions {
156 if dID == decisionID {
157 references = append(references, "element: "+elemID)
158 break
159 }
160 }
161 }
162 for _, rel := range m.Relationships {
163 for _, dID := range rel.Decisions {
164 if dID == decisionID {
165 references = append(references, fmt.Sprintf("relationship: %s → %s", rel.From, rel.To))
166 break
167 }
168 }
169 }
170
171 // Display information
172 statusIcon := getStatusIcon(decision.Status)
173 fmt.Printf("ADR: %s %s\n", decision.ID, statusIcon)
174 fmt.Println("──────────────────────────────────────────")
175 fmt.Printf("Title: %s\n", decision.Title)
176 fmt.Printf("Status: %s\n", decision.Status)
177 if decision.Date != "" {
178 fmt.Printf("Date: %s\n", decision.Date)
179 }
180 if decision.FilePath != "" {
181 fmt.Printf("File: %s\n", decision.FilePath)
182 }
183
184 if len(references) > 0 {
185 sort.Strings(references)
186 fmt.Println("\nReferenced by:")
187 for _, ref := range references {
188 fmt.Printf(" - %s\n", ref)
189 }
190 }
191
192 return nil
193 },
194 }
195
196 cmd.Flags().StringP("model", "m", "", "Path to architecture model (default: architecture.jsonc)")
197
198 return cmd
199 }
200
201 func getStatusIcon(status model.ADRStatus) string {
202 switch status {
203 case model.ADRActive:
204 return "✓"
205 case model.ADRProposed:
206 return "◯"
207 case model.ADRDeprecated:
208 return "⚠"
209 case model.ADRSuperseded:
210 return "✗"
211 default:
212 return "?"
213 }
214 }
215
216 func findElementByID(m *model.BausteinsichtModel, id string) (*model.Element, bool) {
217 parts := strings.Split(id, ".")
218 if len(parts) == 0 {
219 return nil, false
220 }
221
222 elem, ok := m.Model[parts[0]]
223 if !ok {
224 return nil, false
225 }
226
227 // Navigate through child elements
228 current := &elem
229 for _, part := range parts[1:] {
230 child, ok := current.Children[part]
231 if !ok {
232 return nil, false
233 }
234 current = &child
235 }
236
237 return current, true
238 }
239
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/changelog_cmd.go 16.3%
1 package main
2
3 import (
4 "fmt"
5 "os"
6
7 "github.com/docToolchain/Bausteinsicht/internal/changelog"
8 "github.com/spf13/cobra"
9 )
10
11 func newChangelogCmd() *cobra.Command {
12 cmd := &cobra.Command{
13 Use: "changelog",
14 Short: "Generate architecture changelog between two points in time",
15 Long: "Compare two versions of the architecture model and generate a human-readable changelog showing what changed.",
16 RunE: runChangelog,
17 }
18
19 cmd.Flags().String("model", "architecture.jsonc", "Model file path")
20 cmd.Flags().String("since", "", "Starting git ref or snapshot ID (default: previous tag)")
21 cmd.Flags().String("until", "HEAD", "Ending git ref or snapshot ID")
22 cmd.Flags().String("format", "markdown", "Output format: markdown, asciidoc, or json")
23 cmd.Flags().String("output", "", "Output file path (default: stdout)")
24
25 return cmd
26 }
27
28 func runChangelog(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 since, _ := cmd.Flags().GetString("since")
31 until, _ := cmd.Flags().GetString("until")
32 format, _ := cmd.Flags().GetString("format")
33 output, _ := cmd.Flags().GetString("output")
34
35 if err := validatePathContainment(modelPath); err != nil {
36 return exitWithCode(err, 2)
37 }
38
39 // Validate format
40 if format != "markdown" && format != "asciidoc" && format != "json" {
41 return exitWithCode(fmt.Errorf("invalid format: %s (expected markdown, asciidoc, or json)", format), 2)
42 }
43
44 // Load models at the two refs
45 fromModel, err := changelog.LoadModelAtGitRef(modelPath, since)
46 if err != nil {
47 return exitWithCode(fmt.Errorf("loading model at %q: %w", since, err), 2)
48 }
49
50 toModel, err := changelog.LoadModelAtGitRef(modelPath, until)
51 if err != nil {
52 return exitWithCode(fmt.Errorf("loading model at %q: %w", until, err), 2)
53 }
54
55 // Get reference info for display
56 fromRef := changelog.Reference{Ref: since}
57 toRef := changelog.Reference{Ref: until}
58
59 if fromInfo, err := changelog.GetCommitInfo(since); err == nil {
60 fromRef.Date = fromInfo.Date
61 }
62 if toInfo, err := changelog.GetCommitInfo(until); err == nil {
63 toRef.Date = toInfo.Date
64 }
65
66 // Generate changelog
67 cl := changelog.Generate(fromModel, toModel, fromRef, toRef)
68
69 // Render output
70 var result string
71 switch format {
72 case "markdown":
73 result = changelog.RenderMarkdown(cl)
74 case "asciidoc":
75 result = changelog.RenderAsciiDoc(cl)
76 case "json":
77 var err error
78 result, err = changelog.RenderJSON(cl)
79 if err != nil {
80 return exitWithCode(fmt.Errorf("rendering JSON: %w", err), 2)
81 }
82 }
83
84 // Write output
85 if output == "" {
86 // Write to stdout
87 if _, err := fmt.Fprint(cmd.OutOrStdout(), result); err != nil {
88 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
89 }
90 } else {
91 // Write to file
92 if err := os.WriteFile(output, []byte(result), 0o644); err != nil {
93 return exitWithCode(fmt.Errorf("writing to %q: %w", output, err), 2)
94 }
95 }
96
97 return nil
98 }
99
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/cmd_schema.go 30.0%
1 package main
2
3 import (
4 "fmt"
5 "os"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/docToolchain/Bausteinsicht/internal/schema"
9 "github.com/spf13/cobra"
10 )
11
12 func newSchemaCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "schema",
15 Short: "Manage JSON Schema for architecture models",
16 Long: "Generate and manage JSON Schema definitions for Bausteinsicht models.",
17 }
18
19 cmd.AddCommand(newSchemaGenerateCmd())
20
21 return cmd
22 }
23
24 func newSchemaGenerateCmd() *cobra.Command {
25 cmd := &cobra.Command{
26 Use: "generate",
27 Short: "Generate JSON Schema from Go types",
28 Long: "Generate the JSON Schema from model type definitions and save to schemas/bausteinsicht.schema.json.",
29 RunE: runSchemaGenerate,
30 }
31
32 cmd.Flags().String("output", "schemas/bausteinsicht.schema.json", "Output file for the schema")
33
34 return cmd
35 }
36
37 func runSchemaGenerate(cmd *cobra.Command, _ []string) error {
38 outputFile, _ := cmd.Flags().GetString("output")
39
40 // Validate output path to prevent directory traversal (SEC-001)
41 if err := validatePathContainment(outputFile); err != nil {
42 return exitWithCode(fmt.Errorf("--output: %w", err), 1)
43 }
44
45 // Create schema generator
46 gen := schema.NewGenerator()
47
48 // Generate schema for BausteinsichtModel
49 schemaObj := gen.Generate(model.BausteinsichtModel{})
50
51 // Convert to JSON
52 jsonBytes, err := schemaObj.ToJSON()
53 if err != nil {
54 return fmt.Errorf("failed to convert schema to JSON: %w", err)
55 }
56
57 // Write to file
58 if err := os.WriteFile(outputFile, jsonBytes, 0600); err != nil {
59 return fmt.Errorf("failed to write schema file: %w", err)
60 }
61
62 // Print success message
63 fmt.Printf("✅ Schema generated: %s\n", outputFile)
64 fmt.Printf("📊 Properties: %d\n", len(schemaObj.Properties))
65 fmt.Printf("📌 Required fields: %d\n", len(schemaObj.Required))
66
67 return nil
68 }
69
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/diff.go 56.5%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/diff"
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 func newDiffCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "diff",
15 Short: "Show differences between as-is and to-be architecture",
16 Long: "Compare as-is and to-be sections of the model and report changes.",
17 RunE: runDiff,
18 }
19
20 cmd.Flags().String("model", "architecture.jsonc", "Model file path")
21 cmd.Flags().String("view", "", "Show diff for one view only (optional)")
22 cmd.Flags().String("format", "text", "Output format: text or json")
23
24 return cmd
25 }
26
27 func runDiff(cmd *cobra.Command, _ []string) error {
28 modelPath, _ := cmd.Flags().GetString("model")
29 format, _ := cmd.Flags().GetString("format")
30
31 if err := validatePathContainment(modelPath); err != nil {
32 return exitWithCode(err, 2)
33 }
34
35 m, err := model.Load(modelPath)
36 if err != nil {
37 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
38 }
39
40 if m.AsIs == nil || m.ToBe == nil {
41 return exitWithCode(fmt.Errorf("model does not contain asIs and toBe sections"), 1)
42 }
43
44 result := diff.Compare(m.AsIs, m.ToBe)
45
46 switch format {
47 case "json":
48 data, err := json.MarshalIndent(result, "", " ")
49 if err != nil {
50 return exitWithCode(fmt.Errorf("marshaling JSON: %w", err), 2)
51 }
52 if _, err := fmt.Fprint(cmd.OutOrStdout(), string(data)); err != nil {
53 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
54 }
55 case "text":
56 output := formatDiffAsText(result)
57 if _, err := fmt.Fprint(cmd.OutOrStdout(), output); err != nil {
58 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
59 }
60 default:
61 return exitWithCode(fmt.Errorf("invalid format: %s (expected text or json)", format), 2)
62 }
63
64 return nil
65 }
66
67 func formatDiffAsText(result *diff.DiffResult) string {
68 output := "Architecture Diff\n"
69 output += "=================\n\n"
70
71 // Added elements
72 if result.Summary.AddedElements > 0 {
73 output += fmt.Sprintf("Added (%d):\n", result.Summary.AddedElements)
74 for _, change := range result.Elements {
75 if change.Type == diff.ChangeAdded && change.ToBe != nil {
76 output += fmt.Sprintf(" + %-20s [%s] \"%s\"\n",
77 change.ID, change.ToBe.Kind, change.ToBe.Title)
78 }
79 }
80 output += "\n"
81 }
82
83 // Removed elements
84 if result.Summary.RemovedElements > 0 {
85 output += fmt.Sprintf("Removed (%d):\n", result.Summary.RemovedElements)
86 for _, change := range result.Elements {
87 if change.Type == diff.ChangeRemoved && change.AsIs != nil {
88 output += fmt.Sprintf(" - %-20s [%s] \"%s\"\n",
89 change.ID, change.AsIs.Kind, change.AsIs.Title)
90 }
91 }
92 output += "\n"
93 }
94
95 // Changed elements
96 if result.Summary.ChangedElements > 0 {
97 output += fmt.Sprintf("Changed (%d):\n", result.Summary.ChangedElements)
98 for _, change := range result.Elements {
99 if change.Type == diff.ChangeChanged && change.AsIs != nil && change.ToBe != nil {
100 output += fmt.Sprintf(" ~ %-20s [%s]\n", change.ID, change.AsIs.Kind)
101
102 // Show what changed
103 if change.AsIs.Title != change.ToBe.Title {
104 output += fmt.Sprintf(" title: \"%s\" → \"%s\"\n",
105 change.AsIs.Title, change.ToBe.Title)
106 }
107 if change.AsIs.Technology != change.ToBe.Technology {
108 output += fmt.Sprintf(" technology: \"%s\" → \"%s\"\n",
109 change.AsIs.Technology, change.ToBe.Technology)
110 }
111 if change.AsIs.Description != change.ToBe.Description {
112 output += " description: changed\n"
113 }
114 if change.AsIs.Status != change.ToBe.Status {
115 output += fmt.Sprintf(" status: \"%s\" → \"%s\"\n",
116 change.AsIs.Status, change.ToBe.Status)
117 }
118 }
119 }
120 output += "\n"
121 }
122
123 // Relationship changes
124 if result.Summary.AddedRelationships > 0 {
125 output += fmt.Sprintf("Added Relationships (%d):\n", result.Summary.AddedRelationships)
126 for _, change := range result.Relationships {
127 if change.Type == diff.ChangeAdded && change.ToBe != nil {
128 output += fmt.Sprintf(" + %s → %s (%s)\n",
129 change.From, change.To, change.ToBe.Label)
130 }
131 }
132 output += "\n"
133 }
134
135 if result.Summary.RemovedRelationships > 0 {
136 output += fmt.Sprintf("Removed Relationships (%d):\n", result.Summary.RemovedRelationships)
137 for _, change := range result.Relationships {
138 if change.Type == diff.ChangeRemoved && change.AsIs != nil {
139 output += fmt.Sprintf(" - %s → %s (%s)\n",
140 change.From, change.To, change.AsIs.Label)
141 }
142 }
143 output += "\n"
144 }
145
146 if result.Summary.AddedElements == 0 && result.Summary.RemovedElements == 0 &&
147 result.Summary.ChangedElements == 0 && result.Summary.AddedRelationships == 0 &&
148 result.Summary.RemovedRelationships == 0 {
149 output += "No changes found between as-is and to-be architecture.\n"
150 }
151
152 return output
153 }
154
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export.go 8.8%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/drawio"
10 "github.com/docToolchain/Bausteinsicht/internal/export"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 "github.com/spf13/cobra"
13 )
14
15 func newExportCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "export",
18 Short: "Export diagram views to PNG or SVG",
19 Long: "Exports draw.io diagram pages to image files using the draw.io CLI.",
20 RunE: runExport,
21 }
22 cmd.Flags().String("image-format", "png", "Image format: png or svg")
23 cmd.Flags().String("view", "", "Export only this view (by key)")
24 cmd.Flags().String("output", ".", "Output directory")
25 cmd.Flags().Bool("embed-diagram", false, "Embed draw.io XML source in output")
26 cmd.Flags().Float64("scale", 1.0, "Export scale factor (e.g. 2.0 for retina, 3.0 for print); scale > 1 requires hardware GPU")
27 return cmd
28 }
29
30 type exportResultJSON struct {
31 Files []string `json:"files"`
32 Errors []string `json:"errors,omitempty"`
33 Success bool `json:"success"`
34 }
35
36 func runExport(cmd *cobra.Command, _ []string) error {
37 format, _ := cmd.Flags().GetString("format")
38 modelPath, _ := cmd.Flags().GetString("model")
39 verbose, _ := cmd.Flags().GetBool("verbose")
40 imageFormat, _ := cmd.Flags().GetString("image-format")
41 viewFilter, _ := cmd.Flags().GetString("view")
42 outputDir, _ := cmd.Flags().GetString("output")
43 embedDiagram, _ := cmd.Flags().GetBool("embed-diagram")
44 scale, _ := cmd.Flags().GetFloat64("scale")
45
46 if outputDir != "" {
47 if err := validatePathContainment(outputDir); err != nil {
48 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
49 }
50 }
51
52 // Validate image format.
53 if imageFormat != "png" && imageFormat != "svg" {
54 return exitWithCode(fmt.Errorf("unsupported image format %q; use png or svg", imageFormat), 2)
55 }
56
57 // Auto-detect model file.
58 if modelPath == "" {
59 detected, err := model.AutoDetect(".")
60 if err != nil {
61 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
62 }
63 modelPath = detected
64 }
65
66 // Derive drawio path from model path.
67 dir := filepath.Dir(modelPath)
68 drawioPath := filepath.Join(dir, "architecture.drawio")
69
70 // Load model to get view keys.
71 m, err := model.Load(modelPath)
72 if err != nil {
73 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
74 }
75
76 if len(m.Views) == 0 {
77 return exitWithCode(fmt.Errorf("no views to export"), 2)
78 }
79
80 // If a specific view was requested, check it exists.
81 if viewFilter != "" {
82 if _, ok := m.Views[viewFilter]; !ok {
83 return exitWithCode(fmt.Errorf("view %q not found in model", viewFilter), 2)
84 }
85 }
86
87 // Load draw.io document to get page ordering.
88 doc, err := drawio.LoadDocument(drawioPath)
89 if err != nil {
90 return exitWithCode(fmt.Errorf("loading draw.io file: %w", err), 2)
91 }
92
93 // Detect draw.io CLI binary.
94 binary, err := export.DetectDrawioBinary()
95 if err != nil {
96 return exitWithCode(err, 2)
97 }
98
99 if verbose && format != "json" {
100 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Using draw.io CLI: %s\n", binary)
101 }
102
103 // Ensure output directory exists.
104 if err := os.MkdirAll(outputDir, 0750); err != nil {
105 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
106 }
107
108 // Build the list of pages to export.
109 pages := doc.Pages()
110 type viewExport struct {
111 key string
112 pageIndex int // 1-based
113 }
114 var exports []viewExport
115
116 for viewKey := range m.Views {
117 if viewFilter != "" && viewKey != viewFilter {
118 continue
119 }
120 pageID := "view-" + viewKey
121 for i, p := range pages {
122 if p.ID() == pageID {
123 exports = append(exports, viewExport{key: viewKey, pageIndex: i + 1})
124 break
125 }
126 }
127 }
128
129 if len(exports) == 0 {
130 return exitWithCode(fmt.Errorf("no matching pages found in draw.io file"), 2)
131 }
132
133 // Export each page.
134 var files []string
135 var exportErrors []string
136
137 for _, ex := range exports {
138 outFile := filepath.Join(outputDir, export.OutputFileName(ex.key, imageFormat))
139
140 if verbose && format != "json" {
141 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exporting view %q to %s\n", ex.key, outFile)
142 }
143
144 err := export.ExportPage(binary, export.ExportOptions{
145 Format: imageFormat,
146 PageIndex: ex.pageIndex,
147 OutputPath: outFile,
148 EmbedDiagram: embedDiagram,
149 InputFile: drawioPath,
150 Scale: scale,
151 })
152 if err != nil {
153 exportErrors = append(exportErrors, fmt.Sprintf("view %q: %v", ex.key, err))
154 continue
155 }
156 files = append(files, outFile)
157 }
158
159 // Output results.
160 if format == "json" {
161 result := exportResultJSON{
162 Files: files,
163 Errors: exportErrors,
164 Success: len(exportErrors) == 0,
165 }
166 out, _ := json.MarshalIndent(result, "", " ")
167 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
168 } else {
169 for _, f := range files {
170 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Exported: %s\n", f)
171 }
172 for _, e := range exportErrors {
173 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ERROR: %s\n", e)
174 }
175 }
176
177 if len(exportErrors) > 0 {
178 return exitWithCode(fmt.Errorf("%d export(s) failed", len(exportErrors)), 1)
179 }
180 return nil
181 }
182
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export_diagram.go 56.7%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9
10 "github.com/docToolchain/Bausteinsicht/internal/diagram"
11 "github.com/docToolchain/Bausteinsicht/internal/export"
12 dslexport "github.com/docToolchain/Bausteinsicht/internal/exporter/structurizr"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 "github.com/spf13/cobra"
15 )
16
17 func newExportDiagramCmd() *cobra.Command {
18 cmd := &cobra.Command{
19 Use: "export-diagram",
20 Short: "Export views as C4 diagrams (PlantUML, Mermaid, DOT, D2, HTML5, Structurizr DSL)",
21 Long: "Exports architecture views as text-based C4 diagrams (PlantUML, Mermaid, DOT, D2), interactive HTML5 viewer, or Structurizr DSL workspace.",
22 RunE: runExportDiagram,
23 }
24
25 cmd.Flags().String("view", "", "Export only this view (by key)")
26 cmd.Flags().String("diagram-format", "plantuml", "Diagram format: plantuml, mermaid, dot, d2, html, or structurizr")
27 cmd.Flags().String("output", "", "Output directory (default: stdout)")
28
29 return cmd
30 }
31
32 func runExportDiagram(cmd *cobra.Command, _ []string) error {
33 modelPath, _ := cmd.Flags().GetString("model")
34 viewKey, _ := cmd.Flags().GetString("view")
35 diagramFormat, _ := cmd.Flags().GetString("diagram-format")
36 outputDir, _ := cmd.Flags().GetString("output")
37
38 if outputDir != "" {
39 if err := validatePathContainment(outputDir); err != nil {
40 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
41 }
42 }
43
44 if modelPath == "" {
45 detected, err := model.AutoDetect(".")
46 if err != nil {
47 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
48 }
49 modelPath = detected
50 }
51
52 m, err := model.Load(modelPath)
53 if err != nil {
54 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
55 }
56
57 // Structurizr DSL export: outputs the whole workspace in one file.
58 if diagramFormat == "structurizr" {
59 // Structurizr exports the entire workspace, not individual views
60 if viewKey != "" {
61 return exitWithCode(fmt.Errorf("--view is not supported with structurizr format (exports entire workspace)"), 1)
62 }
63 dsl := dslexport.Export(m)
64 if outputDir == "" {
65 _, _ = fmt.Fprint(cmd.OutOrStdout(), dsl)
66 return nil
67 }
68 if err := os.MkdirAll(outputDir, 0750); err != nil {
69 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
70 }
71 outPath := filepath.Join(outputDir, "workspace.dsl")
72 if err := os.WriteFile(outPath, []byte(dsl), 0600); err != nil { //nolint:gosec
73 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
74 }
75 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
76 return nil
77 }
78
79 // Determine which views to export.
80 views := make(map[string]model.View)
81 if viewKey != "" {
82 v, ok := m.Views[viewKey]
83 if !ok {
84 return exitWithCode(fmt.Errorf("view %q not found", viewKey), 1)
85 }
86 views[viewKey] = v
87 } else {
88 views = m.Views
89 }
90
91 outputFormat, _ := cmd.Flags().GetString("format")
92
93 // Handle new export formats (DOT, D2, HTML) — with JSON envelope support
94 switch diagramFormat {
95 case "dot", "d2", "html":
96 return handleNewFormats(cmd, m, views, diagramFormat, outputFormat, outputDir, viewKey)
97 }
98
99 var f diagram.Format
100 var ext string
101 switch diagramFormat {
102 case "plantuml":
103 f = diagram.PlantUML
104 ext = "puml"
105 case "mermaid":
106 f = diagram.Mermaid
107 ext = "mmd"
108 default:
109 return exitWithCode(fmt.Errorf("unknown diagram format %q: valid values are \"plantuml\", \"mermaid\", \"dot\", \"d2\", \"html\", or \"structurizr\"", diagramFormat), 2)
110 }
111
112 // When --format json, output structured JSON with diagram source. (#241)
113 if outputFormat == "json" {
114 type diagramEntry struct {
115 View string `json:"view"`
116 Format string `json:"format"`
117 Source string `json:"source"`
118 }
119 var entries []diagramEntry
120 keys := sortedKeys(views)
121 for _, key := range keys {
122 result, fmtErr := diagram.FormatView(m, key, f)
123 if fmtErr != nil {
124 return exitWithCode(fmtErr, 1)
125 }
126 entries = append(entries, diagramEntry{
127 View: key,
128 Format: diagramFormat,
129 Source: result,
130 })
131 }
132 data, _ := json.MarshalIndent(entries, "", " ")
133 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
134 return nil
135 }
136
137 for key := range views {
138 result, fmtErr := diagram.FormatView(m, key, f)
139 if fmtErr != nil {
140 return exitWithCode(fmtErr, 1)
141 }
142
143 if outputDir == "" {
144 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
145 continue
146 }
147
148 if err := os.MkdirAll(outputDir, 0750); err != nil {
149 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
150 }
151 outPath := filepath.Join(outputDir, export.SafeViewKey(key)+"."+ext)
152 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil { //nolint:gosec // output files are non-sensitive documentation
153 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
154 }
155 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
156 }
157
158 return nil
159 }
160
161 func handleNewFormats(cmd *cobra.Command, m *model.BausteinsichtModel, views map[string]model.View, diagramFormat, outputFormat, outputDir, viewKey string) error {
162 var renderFunc func(*model.BausteinsichtModel, string) (string, error)
163 var ext string
164
165 switch diagramFormat {
166 case "dot":
167 renderFunc = diagram.RenderDOT
168 ext = "dot"
169 case "d2":
170 renderFunc = diagram.RenderD2
171 ext = "d2"
172 case "html":
173 renderFunc = diagram.RenderHTML
174 ext = "html"
175 default:
176 return exitWithCode(fmt.Errorf("unsupported format: %s", diagramFormat), 2)
177 }
178
179 // When --format json, output structured JSON with diagram source
180 if outputFormat == "json" {
181 type diagramEntry struct {
182 View string `json:"view"`
183 Format string `json:"format"`
184 Source string `json:"source"`
185 }
186 var entries []diagramEntry
187 keys := sortedKeys(views)
188 for _, key := range keys {
189 result, fmtErr := renderFunc(m, key)
190 if fmtErr != nil {
191 return exitWithCode(fmtErr, 1)
192 }
193 entries = append(entries, diagramEntry{
194 View: key,
195 Format: diagramFormat,
196 Source: result,
197 })
198 }
199 data, _ := json.MarshalIndent(entries, "", " ")
200 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
201 return nil
202 }
203
204 // For HTML, create a single file containing all views
205 if diagramFormat == "html" {
206 // When exporting to HTML, we need to handle multiple views in a single file
207 if viewKey != "" {
208 // Single view HTML export
209 result, err := renderFunc(m, viewKey)
210 if err != nil {
211 return exitWithCode(err, 1)
212 }
213
214 if outputDir == "" {
215 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
216 return nil
217 }
218
219 if err := os.MkdirAll(outputDir, 0750); err != nil {
220 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
221 }
222
223 outPath := filepath.Join(outputDir, export.SafeViewKey(viewKey)+".html")
224 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil {
225 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
226 }
227 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
228 return nil
229 }
230
231 // Multiple views: export each as separate HTML file
232 keys := sortedKeys(views)
233 for _, key := range keys {
234 result, err := renderFunc(m, key)
235 if err != nil {
236 return exitWithCode(err, 1)
237 }
238
239 if outputDir == "" {
240 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
241 continue
242 }
243
244 if err := os.MkdirAll(outputDir, 0750); err != nil {
245 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
246 }
247
248 outPath := filepath.Join(outputDir, export.SafeViewKey(key)+".html")
249 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil {
250 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
251 }
252 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
253 }
254 return nil
255 }
256
257 // For DOT and D2: export each view separately
258 keys := sortedKeys(views)
259 for _, key := range keys {
260 result, err := renderFunc(m, key)
261 if err != nil {
262 return exitWithCode(err, 1)
263 }
264
265 if outputDir == "" {
266 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
267 continue
268 }
269
270 if err := os.MkdirAll(outputDir, 0750); err != nil {
271 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
272 }
273
274 outPath := filepath.Join(outputDir, "architecture-"+export.SafeViewKey(key)+"."+ext)
275 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil {
276 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
277 }
278 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
279 }
280
281 return nil
282 }
283
284 func sortedKeys(views map[string]model.View) []string {
285 keys := make([]string, 0, len(views))
286 for k := range views {
287 keys = append(keys, k)
288 }
289 sort.Strings(keys)
290 return keys
291 }
292
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export_sequence.go 86.6%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/diagram"
10 "github.com/docToolchain/Bausteinsicht/internal/export"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 "github.com/spf13/cobra"
13 )
14
15 func newExportSequenceCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "export-sequence",
18 Short: "Export dynamic views as PlantUML or Mermaid sequence diagrams",
19 Long: "Exports dynamic views (sequence diagrams) as PlantUML (.puml) or Mermaid (.md) text files.",
20 RunE: runExportSequence,
21 }
22 cmd.Flags().String("view", "", "Export only this dynamic view (by key)")
23 cmd.Flags().String("diagram-format", "plantuml", "Diagram format: plantuml or mermaid")
24 cmd.Flags().String("output", "", "Output directory (default: stdout)")
25 return cmd
26 }
27
28 func runExportSequence(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 viewKey, _ := cmd.Flags().GetString("view")
31 diagramFormat, _ := cmd.Flags().GetString("diagram-format")
32 outputDir, _ := cmd.Flags().GetString("output")
33 format, _ := cmd.Flags().GetString("format")
34
35 if outputDir != "" {
36 if err := validatePathContainment(outputDir); err != nil {
37 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
38 }
39 }
40
41 if modelPath == "" {
42 detected, err := model.AutoDetect(".")
43 if err != nil {
44 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
45 }
46 modelPath = detected
47 }
48
49 m, err := model.Load(modelPath)
50 if err != nil {
51 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
52 }
53
54 var ext string
55 switch diagramFormat {
56 case "plantuml":
57 ext = "puml"
58 case "mermaid":
59 ext = "md"
60 default:
61 return exitWithCode(fmt.Errorf("unknown diagram format %q: valid values are \"plantuml\" and \"mermaid\"", diagramFormat), 2)
62 }
63
64 // Select views to export.
65 views := m.DynamicViews
66 if viewKey != "" {
67 var found *model.DynamicView
68 for i := range m.DynamicViews {
69 if m.DynamicViews[i].Key == viewKey {
70 found = &m.DynamicViews[i]
71 break
72 }
73 }
74 if found == nil {
75 return exitWithCode(fmt.Errorf("dynamic view %q not found", viewKey), 1)
76 }
77 views = []model.DynamicView{*found}
78 }
79
80 if len(views) == 0 {
81 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "No dynamic views defined in model.")
82 return nil
83 }
84
85 flat, err := model.FlattenElements(m)
86 if err != nil {
87 return exitWithCode(fmt.Errorf("flattening elements: %w", err), 2)
88 }
89
90 render := func(v model.DynamicView) string {
91 if diagramFormat == "mermaid" {
92 return diagram.RenderSequenceMermaid(v, flat)
93 }
94 return diagram.RenderSequencePlantUML(v, flat)
95 }
96
97 // JSON output.
98 if format == "json" {
99 type entry struct {
100 View string `json:"view"`
101 Format string `json:"format"`
102 Source string `json:"source"`
103 }
104 var entries []entry
105 for _, v := range views {
106 entries = append(entries, entry{View: v.Key, Format: diagramFormat, Source: render(v)})
107 }
108 data, _ := json.MarshalIndent(entries, "", " ")
109 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
110 return nil
111 }
112
113 // Text / file output.
114 for _, v := range views {
115 source := render(v)
116
117 if outputDir == "" {
118 _, _ = fmt.Fprint(cmd.OutOrStdout(), source)
119 continue
120 }
121
122 if err := os.MkdirAll(outputDir, 0750); err != nil {
123 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
124 }
125 filename := "sequence-" + export.SafeViewKey(v.Key) + "." + ext
126 outPath := filepath.Join(outputDir, filename)
127 if err := os.WriteFile(outPath, []byte(source), 0600); err != nil { //nolint:gosec // output files are non-sensitive documentation
128 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
129 }
130 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
131 }
132
133 return nil
134 }
135
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/export_table.go 71.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/export"
10 "github.com/docToolchain/Bausteinsicht/internal/model"
11 "github.com/docToolchain/Bausteinsicht/internal/table"
12 "github.com/spf13/cobra"
13 )
14
15 func newExportTableCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "export-table",
18 Short: "Export element attributes as AsciiDoc or Markdown table",
19 Long: "Exports view elements as a table with columns: Element, Kind, Technology, Description.",
20 RunE: runExportTable,
21 }
22
23 cmd.Flags().String("view", "", "Export only this view (by key)")
24 cmd.Flags().String("table-format", "adoc", "Table format: adoc or md")
25 cmd.Flags().String("output", "", "Output directory (default: stdout)")
26 cmd.Flags().Bool("combined", false, "Export all elements across all views (deduplicated)")
27
28 return cmd
29 }
30
31 func runExportTable(cmd *cobra.Command, _ []string) error {
32 modelPath, _ := cmd.Flags().GetString("model")
33 format, _ := cmd.Flags().GetString("format")
34 viewKey, _ := cmd.Flags().GetString("view")
35 tableFormat, _ := cmd.Flags().GetString("table-format")
36 outputDir, _ := cmd.Flags().GetString("output")
37 combined, _ := cmd.Flags().GetBool("combined")
38
39 if outputDir != "" {
40 if err := validatePathContainment(outputDir); err != nil {
41 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
42 }
43 }
44
45 if modelPath == "" {
46 detected, err := model.AutoDetect(".")
47 if err != nil {
48 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
49 }
50 modelPath = detected
51 }
52
53 m, err := model.Load(modelPath)
54 if err != nil {
55 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
56 }
57
58 // When --format json is set, output structured JSON instead of a table. (#239)
59 if format == "json" {
60 return exportTableJSON(cmd, m, viewKey, combined)
61 }
62
63 var f table.Format
64 switch tableFormat {
65 case "adoc":
66 f = table.AsciiDoc
67 case "md":
68 f = table.Markdown
69 default:
70 return exitWithCode(fmt.Errorf("unknown table format %q: valid values are \"adoc\" and \"md\"", tableFormat), 2)
71 }
72
73 var result string
74 var filename string
75
76 switch {
77 case combined:
78 result, err = table.FormatCombined(m, f)
79 filename = "elements." + tableFormat
80 case viewKey != "":
81 result, err = table.FormatView(m, viewKey, f)
82 filename = export.SafeViewKey(viewKey) + "-elements." + tableFormat
83 default:
84 result, err = table.FormatAllViews(m, f)
85 filename = "all-views-elements." + tableFormat
86 }
87 if err != nil {
88 return exitWithCode(err, 1)
89 }
90
91 if outputDir == "" {
92 _, _ = fmt.Fprint(cmd.OutOrStdout(), result)
93 return nil
94 }
95
96 outPath := filepath.Join(outputDir, filename)
97 if err := os.MkdirAll(outputDir, 0750); err != nil {
98 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
99 }
100 if err := os.WriteFile(outPath, []byte(result), 0600); err != nil { //nolint:gosec // output files are non-sensitive documentation
101 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
102 }
103
104 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exported: %s\n", outPath)
105 return nil
106 }
107
108 // exportTableJSON outputs the table data as JSON. (#239)
109 func exportTableJSON(cmd *cobra.Command, m *model.BausteinsichtModel, viewKey string, combined bool) error {
110 rows, err := table.CollectRows(m, viewKey, combined)
111 if err != nil {
112 return exitWithCode(err, 1)
113 }
114 data, err := json.MarshalIndent(rows, "", " ")
115 if err != nil {
116 return exitWithCode(fmt.Errorf("marshaling JSON: %w", err), 2)
117 }
118 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
119 return nil
120 }
121
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/find.go 75.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/search"
10 "github.com/spf13/cobra"
11 )
12
13 func newFindCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "find <query>",
16 Short: "Search elements, relationships, and views by free-text query",
17 Long: `Search all model objects (elements, relationships, views) for the given query.
18
19 All words in a multi-word query must match (AND semantics). Matching is
20 case-insensitive and partial (e.g. "pay" matches "payment-service").
21
22 Results are ranked by relevance score. Use --format json for LLM workflows.`,
23 Args: cobra.MinimumNArgs(1),
24 SilenceUsage: true,
25 SilenceErrors: true,
26 RunE: runFind,
27 }
28 cmd.Flags().String("type", "all", "Limit results to: element, relationship, view, all")
29 return cmd
30 }
31
32 func runFind(cmd *cobra.Command, args []string) error {
33 query := strings.Join(args, " ")
34 format, _ := cmd.Flags().GetString("format")
35 modelPath, _ := cmd.Flags().GetString("model")
36 typeFlag, _ := cmd.Flags().GetString("type")
37
38 if modelPath == "" {
39 detected, err := model.AutoDetect(".")
40 if err != nil {
41 return exitWithCode(err, 2)
42 }
43 modelPath = detected
44 }
45
46 m, err := model.Load(modelPath)
47 if err != nil {
48 return exitWithCode(err, 2)
49 }
50
51 opts := search.Options{}
52 switch typeFlag {
53 case "element":
54 opts.Type = search.ResultElement
55 case "relationship":
56 opts.Type = search.ResultRelationship
57 case "view":
58 opts.Type = search.ResultView
59 case "all", "":
60 // no filter
61 default:
62 return exitWithCode(fmt.Errorf("unknown --type %q: use element, relationship, view, or all", typeFlag), 2)
63 }
64
65 resp := search.Run(query, m, opts)
66
67 if format == "json" {
68 data, err := json.MarshalIndent(resp, "", " ")
69 if err != nil {
70 return err
71 }
72 _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
73 return err
74 }
75
76 return printFindText(cmd, resp)
77 }
78
79 func printFindText(cmd *cobra.Command, resp search.Response) error {
80 out := cmd.OutOrStdout()
81 if resp.Total == 0 {
82 _, err := fmt.Fprintf(out, "No results for %q.\n", resp.Query)
83 return err
84 }
85
86 header := fmt.Sprintf("Search results for %q (%d match", resp.Query, resp.Total)
87 if resp.Total != 1 {
88 header += "es"
89 }
90 header += ")"
91 if _, err := fmt.Fprintln(out, header); err != nil {
92 return err
93 }
94 if _, err := fmt.Fprintln(out, strings.Repeat("=", len(header))); err != nil {
95 return err
96 }
97 if _, err := fmt.Fprintln(out); err != nil {
98 return err
99 }
100
101 // Group by type for display.
102 var elements, relationships, views []search.Result
103 for _, r := range resp.Results {
104 switch r.Type {
105 case search.ResultElement:
106 elements = append(elements, r)
107 case search.ResultRelationship:
108 relationships = append(relationships, r)
109 case search.ResultView:
110 views = append(views, r)
111 }
112 }
113
114 if len(elements) > 0 {
115 if _, err := fmt.Fprintf(out, "Elements (%d):\n", len(elements)); err != nil {
116 return err
117 }
118 for _, r := range elements {
119 extra := ""
120 if r.Technology != "" {
121 extra = " technology: " + r.Technology
122 }
123 if _, err := fmt.Fprintf(out, " %-28s [%-10s] %-35s%s score: %d\n",
124 r.ID, r.Kind, fmt.Sprintf("%q", r.Title), extra, r.Score); err != nil {
125 return err
126 }
127 }
128 if _, err := fmt.Fprintln(out); err != nil {
129 return err
130 }
131 }
132
133 if len(relationships) > 0 {
134 if _, err := fmt.Fprintf(out, "Relationships (%d):\n", len(relationships)); err != nil {
135 return err
136 }
137 for _, r := range relationships {
138 label := ""
139 if r.Title != "" {
140 label = fmt.Sprintf("%q", r.Title)
141 }
142 if _, err := fmt.Fprintf(out, " %-28s %s → %s %s score: %d\n",
143 r.ID, r.From, r.To, label, r.Score); err != nil {
144 return err
145 }
146 }
147 if _, err := fmt.Fprintln(out); err != nil {
148 return err
149 }
150 }
151
152 if len(views) > 0 {
153 if _, err := fmt.Fprintf(out, "Views (%d):\n", len(views)); err != nil {
154 return err
155 }
156 for _, r := range views {
157 if _, err := fmt.Fprintf(out, " %-28s %-35s score: %d\n",
158 r.ID, fmt.Sprintf("%q", r.Title), r.Score); err != nil {
159 return err
160 }
161 }
162 if _, err := fmt.Fprintln(out); err != nil {
163 return err
164 }
165 }
166
167 return nil
168 }
169
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/generate_template.go 83.9%
1 package main
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/template"
10 "github.com/spf13/cobra"
11 )
12
13 func newGenerateTemplateCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "generate-template",
16 Short: "Generate a draw.io template from element specification",
17 Long: "Creates a draw.io template file with visual styles for all element kinds defined in the spec.",
18 RunE: runGenerateTemplate,
19 }
20
21 cmd.Flags().String("model", "", "Model file (default: auto-detect)")
22 cmd.Flags().String("output", "architecture-template.drawio", "Output template file")
23 cmd.Flags().String("style", "default", "Visual preset: default, c4, minimal, or dark")
24
25 return cmd
26 }
27
28 func runGenerateTemplate(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 outputPath, _ := cmd.Flags().GetString("output")
31 style, _ := cmd.Flags().GetString("style")
32
33 // Validate output path containment
34 if err := validatePathContainment(outputPath); err != nil {
35 return exitWithCode(fmt.Errorf("--output: %w", err), 2)
36 }
37
38 // Auto-detect model if not provided
39 if modelPath == "" {
40 detected, err := model.AutoDetect(".")
41 if err != nil {
42 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
43 }
44 modelPath = detected
45 }
46
47 // Load model
48 m, err := model.Load(modelPath)
49 if err != nil {
50 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
51 }
52
53 // Validate style
54 validStyles := map[string]bool{
55 "default": true,
56 "c4": true,
57 "minimal": true,
58 "dark": true,
59 }
60 if !validStyles[style] {
61 return exitWithCode(fmt.Errorf("unknown style %q: valid values are default, c4, minimal, dark", style), 2)
62 }
63
64 // Generate template
65 gen := template.NewGenerator(m.Specification, style)
66 templateXML := gen.Generate()
67
68 // Write output
69 outputDir := filepath.Dir(outputPath)
70 if outputDir != "." && outputDir != "" {
71 if err := os.MkdirAll(outputDir, 0750); err != nil {
72 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 2)
73 }
74 }
75
76 if err := os.WriteFile(outputPath, []byte(templateXML), 0600); err != nil { //nolint:gosec
77 return exitWithCode(fmt.Errorf("writing template: %w", err), 2)
78 }
79
80 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Generated template: %s\n", outputPath)
81 return nil
82 }
83
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/graph.go 6.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "sort"
8 "strings"
9
10 "github.com/docToolchain/Bausteinsicht/internal/graph"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 "github.com/spf13/cobra"
13 )
14
15 func newGraphCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "graph",
18 Short: "Analyze relationship graph for cycles and dependencies",
19 Long: "Analyzes the relationship graph to detect cycles, calculate centrality metrics, and identify dependency patterns.",
20 RunE: runGraph,
21 }
22
23 cmd.Flags().String("output", "", "Output file for analysis report (default: stdout)")
24 cmd.Flags().Bool("cycles-only", false, "Show only detected cycles")
25 cmd.Flags().Bool("centrality", false, "Show centrality metrics for each element")
26
27 return cmd
28 }
29
30 func runGraph(cmd *cobra.Command, _ []string) error {
31 modelPath, _ := cmd.Flags().GetString("model")
32 format, _ := cmd.Flags().GetString("format")
33 outputPath, _ := cmd.Flags().GetString("output")
34 cyclesOnly, _ := cmd.Flags().GetBool("cycles-only")
35 showCentrality, _ := cmd.Flags().GetBool("centrality")
36
37 if modelPath == "" {
38 return exitWithCode(fmt.Errorf("--model flag is required"), 2)
39 }
40
41 m, err := model.Load(modelPath)
42 if err != nil {
43 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
44 }
45
46 // Analyze graph
47 analyzer := graph.NewAnalyzer(m)
48 result := analyzer.Analyze()
49
50 // Format output
51 var output string
52
53 if format == "json" {
54 data, _ := json.MarshalIndent(result, "", " ")
55 output = string(data)
56 } else {
57 output = formatGraphReport(result, cyclesOnly, showCentrality)
58 }
59
60 // Write output
61 if outputPath != "" {
62 if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil {
63 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
64 }
65 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Graph analysis written to %s\n", outputPath)
66 } else {
67 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
68 }
69
70 return nil
71 }
72
73 func formatGraphReport(result *graph.GraphAnalysis, cyclesOnly, showCentrality bool) string {
74 var sb strings.Builder
75
76 sb.WriteString("Relationship Graph Analysis\n")
77 sb.WriteString("===========================\n\n")
78
79 // Summary
80 sb.WriteString("Summary\n")
81 sb.WriteString("-------\n")
82 fmt.Fprintf(&sb, "Elements: %d\n", result.ElementCount)
83 fmt.Fprintf(&sb, "Relationships: %d\n", result.RelationshipCount)
84 fmt.Fprintf(&sb, "Max Dependency Depth: %d\n", result.MaxDepth)
85 sb.WriteString("Graph Type: ")
86 if result.IDAGValid {
87 sb.WriteString("DAG (acyclic)")
88 } else {
89 sb.WriteString("Cyclic (contains cycles)")
90 }
91 sb.WriteString("\n\n")
92
93 // Cycles
94 if len(result.Cycles) > 0 {
95 fmt.Fprintf(&sb, "Cycles Found: %d\n", len(result.Cycles))
96 sb.WriteString("--------\n")
97 for idx, cycle := range result.Cycles {
98 fmt.Fprintf(&sb, "Cycle %d (length %d): %s\n", idx+1, cycle.Length, strings.Join(cycle.Elements, " → "))
99 }
100 sb.WriteString("\n")
101 } else {
102 sb.WriteString("No cycles detected (valid DAG)\n\n")
103 }
104
105 if cyclesOnly {
106 return sb.String()
107 }
108
109 // Strongly connected components
110 if len(result.Components) > 0 {
111 cycleCount := 0
112 for _, comp := range result.Components {
113 if comp.IsCycle {
114 cycleCount++
115 }
116 }
117 fmt.Fprintf(&sb, "Strongly Connected Components: %d\n", len(result.Components))
118 if cycleCount > 0 {
119 fmt.Fprintf(&sb, " (includes %d cycle(s))\n", cycleCount)
120 }
121 sb.WriteString("--------\n")
122 for _, comp := range result.Components {
123 if comp.IsCycle {
124 fmt.Fprintf(&sb, "Component %d (CYCLE): %v\n", comp.ID+1, comp.Elements)
125 }
126 }
127 sb.WriteString("\n")
128 }
129
130 // Centrality metrics
131 if showCentrality && len(result.Centrality) > 0 {
132 sb.WriteString("Centrality Metrics\n")
133 sb.WriteString("------------------\n")
134 sb.WriteString("Element | In-Degree | Out-Degree | Betweenness | Closeness\n")
135 sb.WriteString("---------------------- | --------- | ---------- | ----------- | ---------\n")
136
137 // Sort by out-degree descending
138 sorted := make([]graph.Centrality, len(result.Centrality))
139 copy(sorted, result.Centrality)
140 sort.Slice(sorted, func(i, j int) bool {
141 if sorted[i].OutDegree != sorted[j].OutDegree {
142 return sorted[i].OutDegree > sorted[j].OutDegree
143 }
144 return sorted[i].ID < sorted[j].ID
145 })
146
147 for _, c := range sorted {
148 elemName := c.ID
149 if len(elemName) > 22 {
150 elemName = elemName[:19] + "..."
151 }
152 fmt.Fprintf(&sb, "%-22s | %9d | %10d | %11.2f | %9.2f\n",
153 elemName, c.InDegree, c.OutDegree, c.Betweenness, c.Closeness)
154 }
155 sb.WriteString("\n")
156 }
157
158 return sb.String()
159 }
160
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/health.go 6.8%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/health"
10 "github.com/docToolchain/Bausteinsicht/internal/model"
11 "github.com/spf13/cobra"
12 )
13
14 func newHealthCmd() *cobra.Command {
15 cmd := &cobra.Command{
16 Use: "health",
17 Short: "Assess architecture health score",
18 Long: "Computes a comprehensive architecture health score across multiple dimensions including completeness, conformance, and complexity.",
19 RunE: runHealth,
20 }
21
22 cmd.Flags().String("output", "", "Output file for health report (default: stdout)")
23 cmd.Flags().Bool("summary", false, "Show only the overall score and grade")
24
25 return cmd
26 }
27
28 func runHealth(cmd *cobra.Command, _ []string) error {
29 modelPath, _ := cmd.Flags().GetString("model")
30 format, _ := cmd.Flags().GetString("format")
31 outputPath, _ := cmd.Flags().GetString("output")
32 summaryOnly, _ := cmd.Flags().GetBool("summary")
33
34 if modelPath == "" {
35 return exitWithCode(fmt.Errorf("--model flag is required"), 2)
36 }
37
38 m, err := model.Load(modelPath)
39 if err != nil {
40 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
41 }
42
43 // Compute health score
44 analyzer := health.NewAnalyzer(m)
45 score := analyzer.Analyze()
46
47 // Format output
48 var output string
49
50 if format == "json" {
51 if summaryOnly {
52 summary := map[string]interface{}{
53 "overall": score.Overall,
54 "grade": score.Grade,
55 "summary": score.Summary,
56 "timestamp": score.Timestamp,
57 }
58 data, _ := json.MarshalIndent(summary, "", " ")
59 output = string(data)
60 } else {
61 data, _ := json.MarshalIndent(score, "", " ")
62 output = string(data)
63 }
64 } else {
65 output = formatHealthReport(score, summaryOnly)
66 }
67
68 // Write output
69 if outputPath != "" {
70 if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil {
71 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
72 }
73 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Health report written to %s\n", outputPath)
74 } else {
75 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
76 }
77
78 return nil
79 }
80
81 func formatHealthReport(score *health.HealthScore, summaryOnly bool) string {
82 var sb strings.Builder
83
84 // Header
85 sb.WriteString("Architecture Health Report\n")
86 sb.WriteString("==========================\n\n")
87
88 // Overall score
89 fmt.Fprintf(&sb, "Overall Score: %.1f/100 [%s]\n", score.Overall, score.Grade)
90 fmt.Fprintf(&sb, "Summary: %s\n", score.Summary)
91 fmt.Fprintf(&sb, "Timestamp: %s\n\n", score.Timestamp)
92
93 if summaryOnly {
94 return sb.String()
95 }
96
97 // Model stats
98 sb.WriteString("Model Statistics\n")
99 sb.WriteString("----------------\n")
100 fmt.Fprintf(&sb, "Elements: %d\n", score.ElementCnt)
101 fmt.Fprintf(&sb, "Relationships: %d\n", score.RelCnt)
102 fmt.Fprintf(&sb, "Views: %d\n\n", score.ViewCnt)
103
104 // Category scores
105 sb.WriteString("Category Scores\n")
106 sb.WriteString("---------------\n")
107 for _, cat := range score.Categories {
108 fmt.Fprintf(&sb, "%s: %.1f/100 (weight: %.0f%%)\n", cat.Category, cat.Score, cat.Weight*100)
109 if cat.Details != "" {
110 fmt.Fprintf(&sb, " Details: %s\n", cat.Details)
111 }
112 }
113
114 // Findings
115 if len(score.Categories) > 0 {
116 sb.WriteString("\nFindings\n")
117 sb.WriteString("--------\n")
118
119 for _, cat := range score.Categories {
120 if len(cat.Findings) > 0 {
121 fmt.Fprintf(&sb, "\n%s (%d findings):\n", cat.Category, len(cat.Findings))
122 for _, f := range cat.Findings {
123 fmt.Fprintf(&sb, " [%s] %s\n", f.Severity, f.Title)
124 fmt.Fprintf(&sb, " %s\n", f.Message)
125 }
126 }
127 }
128 }
129
130 return sb.String()
131 }
132
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/import.go 79.2%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "github.com/docToolchain/Bausteinsicht/internal/importer/likec4"
11 "github.com/docToolchain/Bausteinsicht/internal/importer/structurizr"
12 "github.com/spf13/cobra"
13 )
14
15 func newImportCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "import <input-file>",
18 Short: "Import an architecture model from Structurizr DSL or LikeC4",
19 Long: `Imports an architecture model from an external DSL format and writes a
20 Bausteinsicht-compatible architecture.jsonc file.
21
22 Supported formats:
23 structurizr Structurizr DSL (.dsl)
24 likec4 LikeC4 DSL (.c4)
25
26 Exit codes:
27 0 import successful
28 1 parse error
29 2 output file already exists (use --force to overwrite)`,
30 Args: cobra.ExactArgs(1),
31 SilenceUsage: true,
32 SilenceErrors: true,
33 RunE: runImport,
34 }
35 cmd.Flags().String("from", "", "Source format: structurizr or likec4 (required)")
36 cmd.Flags().String("output", "architecture.jsonc", "Output model file path")
37 cmd.Flags().Bool("dry-run", false, "Print generated model to stdout instead of writing file")
38 cmd.Flags().Bool("force", false, "Overwrite output file if it already exists")
39 _ = cmd.MarkFlagRequired("from")
40 return cmd
41 }
42
43 func runImport(cmd *cobra.Command, args []string) error {
44 inputPath := args[0]
45 from, _ := cmd.Flags().GetString("from")
46 outputPath, _ := cmd.Flags().GetString("output")
47 dryRun, _ := cmd.Flags().GetBool("dry-run")
48 force, _ := cmd.Flags().GetBool("force")
49
50 from = strings.ToLower(strings.TrimSpace(from))
51 if from != "structurizr" && from != "likec4" {
52 return exitWithCode(fmt.Errorf("unknown format %q: valid values are \"structurizr\" and \"likec4\"", from), 1)
53 }
54
55 if err := validatePathContainment(inputPath); err != nil {
56 return exitWithCode(fmt.Errorf("input: %w", err), 1)
57 }
58 if err := validatePathContainment(outputPath); err != nil {
59 return exitWithCode(fmt.Errorf("--output: %w", err), 1)
60 }
61
62 if !dryRun && !force {
63 if _, err := os.Stat(outputPath); err == nil {
64 return exitWithCode(
65 fmt.Errorf("output file %q already exists — use --force to overwrite", outputPath),
66 2,
67 )
68 }
69 }
70
71 var (
72 importedModel any
73 warnings []string
74 )
75
76 switch from {
77 case "structurizr":
78 r, err := structurizr.Import(inputPath)
79 if err != nil {
80 return exitWithCode(fmt.Errorf("import failed: %w", err), 1)
81 }
82 importedModel, warnings = r.Model, r.Warnings
83 case "likec4":
84 r, err := likec4.Import(inputPath)
85 if err != nil {
86 return exitWithCode(fmt.Errorf("import failed: %w", err), 1)
87 }
88 importedModel, warnings = r.Model, r.Warnings
89 }
90
91 data, err := json.MarshalIndent(importedModel, "", " ")
92 if err != nil {
93 return exitWithCode(fmt.Errorf("encoding model: %w", err), 1)
94 }
95
96 if dryRun {
97 if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
98 return err
99 }
100 } else {
101 if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
102 return exitWithCode(fmt.Errorf("creating output directory: %w", err), 1)
103 }
104 if err := os.WriteFile(outputPath, append(data, '\n'), 0o644); err != nil {
105 return exitWithCode(fmt.Errorf("writing %s: %w", outputPath, err), 1)
106 }
107 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Imported model written to %s\n", outputPath); err != nil {
108 return err
109 }
110 }
111
112 for _, w := range warnings {
113 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "WARNING: %s\n", w); err != nil {
114 return err
115 }
116 }
117
118 return nil
119 }
120
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/init.go 86.8%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "time"
9
10 "github.com/docToolchain/Bausteinsicht/internal/drawio"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 bsync "github.com/docToolchain/Bausteinsicht/internal/sync"
13 "github.com/docToolchain/Bausteinsicht/internal/template"
14 "github.com/docToolchain/Bausteinsicht/templates"
15 "github.com/spf13/cobra"
16 )
17
18 const (
19 defaultModelFile = "architecture.jsonc"
20 defaultDrawioFile = "architecture.drawio"
21 defaultTemplFile = "template.drawio"
22 defaultSyncState = ".bausteinsicht-sync"
23 )
24
25 func newInitCmd() *cobra.Command {
26 cmd := &cobra.Command{
27 Use: "init",
28 Short: "Initialize a new architecture project",
29 Long: "Creates a sample model, template, and initial draw.io diagram in the current directory.",
30 RunE: runInit,
31 }
32 cmd.Flags().Bool("generate-template", false, "Generate template from spec instead of using default")
33 return cmd
34 }
35
36 func runInit(cmd *cobra.Command, _ []string) error {
37 format, _ := cmd.Flags().GetString("format")
38 generateTemplate, _ := cmd.Flags().GetBool("generate-template")
39
40 // Check if files already exist.
41 for _, name := range []string{defaultModelFile, defaultDrawioFile, defaultTemplFile} {
42 if _, err := os.Stat(name); err == nil {
43 return exitWithCode(
44 fmt.Errorf("file %q already exists; remove it or use a different directory", name),
45 2,
46 )
47 }
48 }
49
50 // Write sample model.
51 if err := os.WriteFile(defaultModelFile, templates.SampleModel, 0600); err != nil {
52 return exitWithCode(fmt.Errorf("writing %s: %w", defaultModelFile, err), 2)
53 }
54
55 // Load model for sync.
56 m, err := model.Load(defaultModelFile)
57 if err != nil {
58 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
59 }
60
61 // Generate or use default template.
62 var templateBytes []byte
63 if generateTemplate {
64 gen := template.NewGenerator(m.Specification, "default")
65 templateXML := gen.Generate()
66 templateBytes = []byte(templateXML)
67 } else {
68 templateBytes = templates.DefaultTemplate
69 }
70
71 // Write template.
72 if err := os.WriteFile(defaultTemplFile, templateBytes, 0600); err != nil {
73 return exitWithCode(fmt.Errorf("writing %s: %w", defaultTemplFile, err), 2)
74 }
75
76 // Load template.
77 tmpl, err := drawio.LoadTemplateFromBytes(templateBytes)
78 if err != nil {
79 return exitWithCode(fmt.Errorf("loading template: %w", err), 2)
80 }
81
82 // Create empty document and run initial forward sync.
83 doc := drawio.NewDocument()
84 emptyState := &bsync.SyncState{
85 Elements: make(map[string]bsync.ElementState),
86 Relationships: []bsync.RelationshipState{},
87 }
88
89 // Add pages for each view before sync.
90 for viewID, view := range m.Views {
91 doc.AddPage("view-"+viewID, view.Title)
92 }
93
94 // Pass ForwardOptions so metadata/legend boxes are created during init,
95 // preventing the first sync from reporting metadata changes (#265).
96 // Use the same relative model path that sync would use.
97 fwdOpts := bsync.ForwardOptions{
98 ModelPath: defaultModelFile,
99 SyncTime: time.Now().Format("2006-01-02 15:04"),
100 }
101 _ = bsync.Run(m, doc, emptyState, tmpl, nil, fwdOpts)
102
103 // Save generated draw.io file.
104 if err := drawio.SaveDocument(defaultDrawioFile, doc); err != nil {
105 return exitWithCode(fmt.Errorf("writing %s: %w", defaultDrawioFile, err), 2)
106 }
107
108 // Build and save sync state.
109 absModel, _ := filepath.Abs(defaultModelFile)
110 absDrawio, _ := filepath.Abs(defaultDrawioFile)
111 state, err := bsync.BuildState(m, doc, absModel, absDrawio)
112 if err != nil {
113 return exitWithCode(fmt.Errorf("building sync state: %w", err), 2)
114 }
115 if err := bsync.SaveState(defaultSyncState, state); err != nil {
116 return exitWithCode(fmt.Errorf("writing %s: %w", defaultSyncState, err), 2)
117 }
118
119 // Output result.
120 createdFiles := []string{defaultModelFile, defaultTemplFile, defaultDrawioFile, defaultSyncState}
121
122 if format == "json" {
123 out := map[string]interface{}{
124 "success": true,
125 "files": createdFiles,
126 }
127 data, _ := json.Marshal(out)
128 fmt.Println(string(data))
129 } else {
130 fmt.Println("Initialized Bausteinsicht project:")
131 for _, f := range createdFiles {
132 fmt.Printf(" - %s\n", f)
133 }
134 fmt.Println()
135 fmt.Println("Next steps:")
136 fmt.Println(" 1. Edit architecture.jsonc to define your architecture")
137 fmt.Println(" 2. Run 'bausteinsicht sync' to update the draw.io diagram")
138 fmt.Println(" 3. Open architecture.drawio in draw.io to arrange elements")
139 }
140
141 return nil
142 }
143
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/layout.go 16.1%
1 package main
2
3 import (
4 "fmt"
5 "path/filepath"
6
7 "github.com/docToolchain/Bausteinsicht/internal/drawio"
8 "github.com/docToolchain/Bausteinsicht/internal/layout"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 func newLayoutCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "layout",
16 Short: "Auto-layout elements in draw.io diagram",
17 Long: `Computes hierarchical layout for diagram elements and writes positions back to draw.io.
18 Pinned elements (with bausteinsicht-pinned=true) are preserved by default.`,
19 RunE: runLayout,
20 }
21
22 cmd.Flags().String("algorithm", "hierarchical", "Layout algorithm: hierarchical (currently only option)")
23 cmd.Flags().String("rank-dir", "TB", "Ranking direction: TB (top-to-bottom) or LR (left-to-right)")
24 cmd.Flags().Bool("preserve-pinned", true, "Don't move pinned elements (bausteinsicht-pinned=true)")
25
26 return cmd
27 }
28
29 func runLayout(cmd *cobra.Command, _ []string) error {
30 modelPath, _ := cmd.Flags().GetString("model")
31 if modelPath == "" {
32 detected, err := model.AutoDetect(".")
33 if err != nil {
34 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
35 }
36 modelPath = detected
37 }
38
39 // Load model
40 m, err := model.Load(modelPath)
41 if err != nil {
42 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
43 }
44
45 // Validate model
46 if errs := model.Validate(m); len(errs) > 0 {
47 return exitWithCode(fmt.Errorf("model validation failed: %v", errs), 2)
48 }
49
50 // Derive draw.io path from model path
51 dir := filepath.Dir(modelPath)
52 drawioPath := filepath.Join(dir, "architecture.drawio")
53
54 doc, err := drawio.LoadDocument(drawioPath)
55 if err != nil {
56 return exitWithCode(fmt.Errorf("loading diagram: %w", err), 2)
57 }
58
59 rankDir, _ := cmd.Flags().GetString("rank-dir")
60 preservePinned, _ := cmd.Flags().GetBool("preserve-pinned")
61
62 // Compute hierarchical layout
63 h := layout.NewHierarchicalLayout(m, rankDir)
64 result := h.Compute()
65
66 // Apply layout to diagram
67 if err := layout.Apply(doc, result, preservePinned); err != nil {
68 return exitWithCode(fmt.Errorf("applying layout: %w", err), 2)
69 }
70
71 // Save diagram
72 if err := drawio.SaveDocument(drawioPath, doc); err != nil {
73 return exitWithCode(fmt.Errorf("saving diagram: %w", err), 2)
74 }
75
76 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Layout applied (hierarchical): %s\n", drawioPath)
77 return nil
78 }
79
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/lint.go 78.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/constraints"
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 func newLintCmd() *cobra.Command {
13 return &cobra.Command{
14 Use: "lint",
15 Short: "Check architecture constraints",
16 Long: "Evaluates all constraints defined in the model and reports violations.",
17 SilenceUsage: true,
18 SilenceErrors: true,
19 RunE: runLint,
20 }
21 }
22
23 func runLint(cmd *cobra.Command, _ []string) error {
24 format, _ := cmd.Flags().GetString("format")
25 modelPath, _ := cmd.Flags().GetString("model")
26
27 if modelPath == "" {
28 detected, err := model.AutoDetect(".")
29 if err != nil {
30 return exitWithCode(err, 2)
31 }
32 modelPath = detected
33 }
34
35 m, err := model.Load(modelPath)
36 if err != nil {
37 return exitWithCode(err, 2)
38 }
39
40 if len(m.Constraints) == 0 {
41 if _, err := fmt.Fprintln(cmd.OutOrStdout(), "No constraints defined."); err != nil {
42 return err
43 }
44 return nil
45 }
46
47 result := constraints.Evaluate(m)
48
49 if format == "json" {
50 return lintOutputJSON(cmd, result)
51 }
52 return lintOutputText(cmd, result)
53 }
54
55 func lintOutputJSON(cmd *cobra.Command, r constraints.Result) error {
56 type jsonResult struct {
57 Passed bool `json:"passed"`
58 Total int `json:"total"`
59 Violations []constraints.Violation `json:"violations"`
60 }
61
62 out := jsonResult{
63 Passed: r.Total == 0,
64 Total: r.Total,
65 Violations: r.Violations,
66 }
67 if out.Violations == nil {
68 out.Violations = []constraints.Violation{}
69 }
70
71 data, err := json.MarshalIndent(out, "", " ")
72 if err != nil {
73 return err
74 }
75 if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
76 return err
77 }
78 if r.Total > 0 {
79 return exitWithCode(fmt.Errorf("lint: %d violation(s) found", r.Total), 1)
80 }
81 return nil
82 }
83
84 func lintOutputText(cmd *cobra.Command, r constraints.Result) error {
85 if r.Total == 0 {
86 _, err := fmt.Fprintln(cmd.OutOrStdout(), "All constraints passed.")
87 return err
88 }
89
90 for _, v := range r.Violations {
91 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "VIOLATION [%s]: %s\n", v.ConstraintID, v.Message); err != nil {
92 return err
93 }
94 for _, el := range v.Elements {
95 if _, err := fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", el); err != nil {
96 return err
97 }
98 }
99 }
100
101 return exitWithCode(fmt.Errorf("lint: %d violation(s) found", r.Total), 1)
102 }
103
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/main.go 0.0%
1 package main
2
3 import "os"
4
5 var version = "dev"
6
7 func main() {
8 rootCmd := NewRootCmd()
9
10 if err := ExecuteRoot(rootCmd); err != nil {
11 if e, ok := err.(*exitError); ok {
12 os.Exit(e.code)
13 }
14 os.Exit(1)
15 }
16 }
17
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/overlay.go 18.6%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/overlay"
10 "github.com/spf13/cobra"
11 )
12
13 func newOverlayCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "overlay",
16 Short: "Apply or remove metric heatmap overlays on architecture diagrams",
17 Long: "Load external metrics (error rate, coverage, etc.) from JSON and overlay them as a heatmap on draw.io elements. Original styles are preserved.",
18 }
19
20 cmd.AddCommand(newOverlayApplyCmd())
21 cmd.AddCommand(newOverlayRemoveCmd())
22 cmd.AddCommand(newOverlayListCmd())
23
24 return cmd
25 }
26
27 func newOverlayApplyCmd() *cobra.Command {
28 cmd := &cobra.Command{
29 Use: "apply <metrics-file>",
30 Short: "Apply metric heatmap to draw.io diagram",
31 Long: "Load metrics from JSON file and apply heatmap colors to elements. Original colors are saved in metadata.",
32 Args: cobra.ExactArgs(1),
33 RunE: func(cmd *cobra.Command, args []string) error {
34 metricsPath := args[0]
35 modelPath, _ := cmd.Flags().GetString("model")
36 metricKey, _ := cmd.Flags().GetString("metric")
37 outputPath, _ := cmd.Flags().GetString("output")
38
39 m, err := model.Load(modelPath)
40 if err != nil {
41 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
42 }
43
44 drawioPath := outputPath
45 if drawioPath == "" {
46 drawioPath = filepath.Join(filepath.Dir(modelPath), "architecture.drawio")
47 }
48
49 mf, err := overlay.LoadMetricsFile(metricsPath)
50 if err != nil {
51 return exitWithCode(fmt.Errorf("loading metrics: %w", err), 2)
52 }
53
54 if metricKey == "" {
55 if len(mf.Metrics) > 0 && len(mf.Metrics[0].Values) > 0 {
56 for k := range mf.Metrics[0].Values {
57 metricKey = k
58 break
59 }
60 }
61 if metricKey == "" {
62 return exitWithCode(fmt.Errorf("no metrics found in file"), 2)
63 }
64 }
65
66 if err := overlay.Apply(drawioPath, mf, metricKey, overlay.DefaultColorScheme); err != nil {
67 return exitWithCode(fmt.Errorf("applying overlay: %w", err), 2)
68 }
69
70 format, _ := cmd.Flags().GetString("format")
71 if format == "json" {
72 out, _ := json.Marshal(map[string]interface{}{
73 "status": "applied",
74 "metric": metricKey,
75 "file": drawioPath,
76 "model": m.Specification,
77 })
78 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
79 } else {
80 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "✅ Overlay applied: %s (metric: %s)\n", drawioPath, metricKey)
81 }
82 return nil
83 },
84 }
85 cmd.Flags().String("metric", "", "Metric key to visualize (default: first available)")
86 cmd.Flags().String("output", "", "Output draw.io file (default: architecture.drawio)")
87 return cmd
88 }
89
90 func newOverlayRemoveCmd() *cobra.Command {
91 cmd := &cobra.Command{
92 Use: "remove",
93 Short: "Remove metric overlay from diagram (restore original colors)",
94 RunE: func(cmd *cobra.Command, args []string) error {
95 modelPath, _ := cmd.Flags().GetString("model")
96 outputPath, _ := cmd.Flags().GetString("output")
97
98 _, err := model.Load(modelPath)
99 if err != nil {
100 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
101 }
102
103 drawioPath := outputPath
104 if drawioPath == "" {
105 drawioPath = filepath.Join(filepath.Dir(modelPath), "architecture.drawio")
106 }
107
108 if err := overlay.Remove(drawioPath); err != nil {
109 return exitWithCode(fmt.Errorf("removing overlay: %w", err), 2)
110 }
111
112 format, _ := cmd.Flags().GetString("format")
113 if format == "json" {
114 out, _ := json.Marshal(map[string]interface{}{
115 "status": "removed",
116 "file": drawioPath,
117 })
118 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
119 } else {
120 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "✅ Overlay removed: %s (original colors restored)\n", drawioPath)
121 }
122 return nil
123 },
124 }
125 cmd.Flags().String("output", "", "Output draw.io file (default: architecture.drawio)")
126 return cmd
127 }
128
129 func newOverlayListCmd() *cobra.Command {
130 return &cobra.Command{
131 Use: "list <metrics-file>",
132 Short: "List available metrics in file",
133 Args: cobra.ExactArgs(1),
134 RunE: func(cmd *cobra.Command, args []string) error {
135 metricsPath := args[0]
136
137 mf, err := overlay.LoadMetricsFile(metricsPath)
138 if err != nil {
139 return exitWithCode(fmt.Errorf("loading metrics: %w", err), 2)
140 }
141
142 format, _ := cmd.Flags().GetString("format")
143 if format == "json" {
144 out, _ := json.Marshal(map[string]interface{}{
145 "source": mf.Meta.Source,
146 "generated": mf.Meta.Generated,
147 "metric_descriptions": mf.Meta.MetricDescriptions,
148 "element_count": len(mf.Metrics),
149 })
150 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
151 } else {
152 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "📊 Metrics from: %s (%s)\n\n", mf.Meta.Source, mf.Meta.Generated)
153 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Available metrics (%d elements):\n", len(mf.Metrics))
154 for metric, desc := range mf.Meta.MetricDescriptions {
155 _, _ = fmt.Fprintf(cmd.OutOrStdout(), " • %s: %s\n", metric, desc)
156 }
157 }
158 return nil
159 },
160 }
161 }
162
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/root.go 98.5%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7 "strings"
8
9 "github.com/spf13/cobra"
10 )
11
12 // NewRootCmd creates and returns the root cobra command with global flags.
13 func NewRootCmd() *cobra.Command {
14 rootCmd := &cobra.Command{
15 Use: "bausteinsicht",
16 Short: "Architecture-as-code with draw.io synchronization",
17 Version: version,
18 PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
19 format, _ := cmd.Flags().GetString("format")
20 format = strings.ToLower(format)
21 if format != "" && format != "text" && format != "json" {
22 return fmt.Errorf("unknown format %q: valid values are \"text\" and \"json\"", format)
23 }
24 // Normalize to lowercase for all subcommands.
25 _ = cmd.Flags().Set("format", format)
26
27 // Validate --template extension when provided.
28 templatePath, _ := cmd.Flags().GetString("template")
29 if templatePath != "" && filepath.Ext(templatePath) != ".drawio" {
30 return fmt.Errorf("template file %q must have a .drawio extension", templatePath)
31 }
32
33 // Validate --model path is under working directory (SEC-001).
34 modelPath, _ := cmd.Flags().GetString("model")
35 if modelPath != "" {
36 if err := validatePathContainment(modelPath); err != nil {
37 return fmt.Errorf("--model: %w", err)
38 }
39 }
40 if templatePath != "" {
41 if err := validatePathContainment(templatePath); err != nil {
42 return fmt.Errorf("--template: %w", err)
43 }
44 }
45
46 return nil
47 },
48 }
49
50 rootCmd.PersistentFlags().String("format", "text", "Output format: text or json")
51 rootCmd.PersistentFlags().String("model", "", "Path to model file (.jsonc)")
52 rootCmd.PersistentFlags().String("template", "", "Path to draw.io template file")
53 rootCmd.PersistentFlags().Bool("verbose", false, "Enable verbose output")
54
55 rootCmd.AddCommand(newInitCmd())
56 rootCmd.AddCommand(newSyncCmd())
57 rootCmd.AddCommand(newValidateCmd())
58 rootCmd.AddCommand(newAddCmd())
59 rootCmd.AddCommand(newWatchCmd())
60 rootCmd.AddCommand(newLayoutCmd())
61 rootCmd.AddCommand(newExportCmd())
62 rootCmd.AddCommand(newExportTableCmd())
63 rootCmd.AddCommand(newExportDiagramCmd())
64 rootCmd.AddCommand(newSchemaCmd())
65 rootCmd.AddCommand(newImportCmd())
66 rootCmd.AddCommand(newExportSequenceCmd())
67 rootCmd.AddCommand(newFindCmd())
68 rootCmd.AddCommand(newShowCmd())
69 rootCmd.AddCommand(newDiffCmd())
70 rootCmd.AddCommand(newChangelogCmd())
71 rootCmd.AddCommand(newLintCmd())
72 rootCmd.AddCommand(newStatusCmd())
73 rootCmd.AddCommand(newGenerateTemplateCmd())
74 rootCmd.AddCommand(newSnapshotCmd())
75 rootCmd.AddCommand(newADRCmd())
76 rootCmd.AddCommand(newWorkspaceCmd())
77 rootCmd.AddCommand(newHealthCmd())
78 rootCmd.AddCommand(newGraphCmd())
79 rootCmd.AddCommand(newOverlayCmd())
80
81 return rootCmd
82 }
83
84 type exitError struct {
85 err error
86 code int
87 }
88
89 func (e *exitError) Error() string { return e.err.Error() }
90
91 func exitWithCode(err error, code int) *exitError {
92 return &exitError{err: err, code: code}
93 }
94
95 // validatePathContainment normalizes a path and rejects directory traversal
96 // sequences that could be used to write files at unexpected locations
97 // (SEC-001, SEC-016).
98 func validatePathContainment(path string) error {
99 cleaned := filepath.Clean(path)
100 for _, component := range strings.Split(cleaned, string(filepath.Separator)) {
101 if component == ".." {
102 return fmt.Errorf("path %q contains directory traversal", path)
103 }
104 }
105 return nil
106 }
107
108 // ExecuteRoot runs the root command and writes errors in the appropriate format
109 // (JSON or plain text) to the command's error writer.
110 func ExecuteRoot(cmd *cobra.Command) error {
111 cmd.SilenceErrors = true
112 cmd.SilenceUsage = true
113 err := cmd.Execute()
114 if err == nil {
115 return nil
116 }
117 format, _ := cmd.PersistentFlags().GetString("format")
118 code := 1
119 if e, ok := err.(*exitError); ok {
120 code = e.code
121 }
122 if format == "json" {
123 out, _ := json.Marshal(map[string]interface{}{
124 "error": err.Error(),
125 "code": code,
126 })
127 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), string(out))
128 } else {
129 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), err.Error())
130 }
131 return err
132 }
133
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/show.go 75.0%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/spf13/cobra"
11 )
12
13 func newShowCmd() *cobra.Command {
14 return &cobra.Command{
15 Use: "show <element-id>",
16 Short: "Show full details of a model element",
17 Long: `Display all fields, relationships, and views for a single element.
18
19 The element ID uses dot-notation for nested elements (e.g. "system.backend.api").
20 Use 'bausteinsicht find <query>' to discover element IDs.`,
21 Args: cobra.ExactArgs(1),
22 SilenceUsage: true,
23 SilenceErrors: true,
24 RunE: runShow,
25 }
26 }
27
28 type showRelEntry struct {
29 Direction string
30 Other string
31 Label string
32 Kind string
33 }
34
35 type showRelJSON struct {
36 Direction string `json:"direction"`
37 Other string `json:"other"`
38 Label string `json:"label,omitempty"`
39 Kind string `json:"kind,omitempty"`
40 }
41
42 type showJSONOutput struct {
43 ID string `json:"id"`
44 Kind string `json:"kind"`
45 Title string `json:"title,omitempty"`
46 Description string `json:"description,omitempty"`
47 Technology string `json:"technology,omitempty"`
48 Tags []string `json:"tags,omitempty"`
49 Metadata map[string]string `json:"metadata,omitempty"`
50 Rels []showRelJSON `json:"relationships"`
51 Views []string `json:"views"`
52 }
53
54 func runShow(cmd *cobra.Command, args []string) error {
55 elementID := args[0]
56 format, _ := cmd.Flags().GetString("format")
57 modelPath, _ := cmd.Flags().GetString("model")
58
59 if modelPath == "" {
60 detected, err := model.AutoDetect(".")
61 if err != nil {
62 return exitWithCode(err, 2)
63 }
64 modelPath = detected
65 }
66
67 m, err := model.Load(modelPath)
68 if err != nil {
69 return exitWithCode(err, 2)
70 }
71
72 flat, err := model.FlattenElements(m)
73 if err != nil {
74 return exitWithCode(err, 2)
75 }
76
77 elem, ok := flat[elementID]
78 if !ok {
79 return exitWithCode(fmt.Errorf("element %q not found", elementID), 1)
80 }
81
82 var rels []showRelEntry
83 for _, rel := range m.Relationships {
84 if rel.From == elementID {
85 rels = append(rels, showRelEntry{"→", rel.To, rel.Label, rel.Kind})
86 } else if rel.To == elementID {
87 rels = append(rels, showRelEntry{"←", rel.From, rel.Label, rel.Kind})
88 }
89 }
90 sort.Slice(rels, func(i, j int) bool {
91 return rels[i].Direction+rels[i].Other < rels[j].Direction+rels[j].Other
92 })
93
94 var viewKeys []string
95 for key, view := range m.Views {
96 for _, inc := range view.Include {
97 prefix := strings.TrimSuffix(inc, ".*")
98 if inc == elementID || (strings.HasSuffix(inc, ".*") && strings.HasPrefix(elementID, prefix+".")) {
99 viewKeys = append(viewKeys, key)
100 break
101 }
102 }
103 }
104 sort.Strings(viewKeys)
105
106 if format == "json" {
107 return printShowJSON(cmd, elementID, elem, rels, viewKeys)
108 }
109 return printShowText(cmd, elementID, elem, rels, viewKeys)
110 }
111
112 func printShowJSON(cmd *cobra.Command, id string, elem *model.Element, rels []showRelEntry, viewKeys []string) error {
113 out := showJSONOutput{
114 ID: id,
115 Kind: elem.Kind,
116 Title: elem.Title,
117 Description: elem.Description,
118 Technology: elem.Technology,
119 Tags: elem.Tags,
120 Metadata: elem.Metadata,
121 Views: viewKeys,
122 Rels: []showRelJSON{},
123 }
124 if out.Views == nil {
125 out.Views = []string{}
126 }
127 for _, r := range rels {
128 out.Rels = append(out.Rels, showRelJSON(r))
129 }
130 data, err := json.MarshalIndent(out, "", " ")
131 if err != nil {
132 return err
133 }
134 _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
135 return err
136 }
137
138 func printShowText(cmd *cobra.Command, id string, elem *model.Element, rels []showRelEntry, viewKeys []string) error {
139 o := cmd.OutOrStdout()
140 header := "Element: " + id
141 if _, err := fmt.Fprintln(o, header); err != nil {
142 return err
143 }
144 if _, err := fmt.Fprintln(o, strings.Repeat("=", len(header))); err != nil {
145 return err
146 }
147
148 printField := func(label, value string) error {
149 if value == "" {
150 return nil
151 }
152 _, err := fmt.Fprintf(o, "%-14s %s\n", label+":", value)
153 return err
154 }
155
156 if err := printField("Kind", elem.Kind); err != nil {
157 return err
158 }
159 if err := printField("Title", elem.Title); err != nil {
160 return err
161 }
162 if err := printField("Description", elem.Description); err != nil {
163 return err
164 }
165 if err := printField("Technology", elem.Technology); err != nil {
166 return err
167 }
168 if len(elem.Tags) > 0 {
169 if err := printField("Tags", "["+strings.Join(elem.Tags, ", ")+"]"); err != nil {
170 return err
171 }
172 }
173 for k, v := range elem.Metadata {
174 if err := printField(k, v); err != nil {
175 return err
176 }
177 }
178
179 if len(rels) > 0 {
180 if _, err := fmt.Fprintln(o, "\nRelationships:"); err != nil {
181 return err
182 }
183 for _, r := range rels {
184 label := ""
185 if r.Label != "" {
186 label = fmt.Sprintf(" %q", r.Label)
187 }
188 kind := ""
189 if r.Kind != "" {
190 kind = " [" + r.Kind + "]"
191 }
192 if _, err := fmt.Fprintf(o, " %s %-30s%s%s\n", r.Direction, r.Other, label, kind); err != nil {
193 return err
194 }
195 }
196 }
197
198 if len(viewKeys) > 0 {
199 if _, err := fmt.Fprintf(o, "\nViews: %s\n", strings.Join(viewKeys, ", ")); err != nil {
200 return err
201 }
202 }
203
204 return nil
205 }
206
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot.go 100.0%
1 package main
2
3 import (
4 "github.com/spf13/cobra"
5 )
6
7 func newSnapshotCmd() *cobra.Command {
8 cmd := &cobra.Command{
9 Use: "snapshot",
10 Short: "Manage versioned architecture snapshots",
11 Long: "Save, list, delete, diff, and restore architecture snapshots stored in .bausteinsicht-snapshots/",
12 }
13
14 cmd.AddCommand(newSnapshotSaveCmd())
15 cmd.AddCommand(newSnapshotListCmd())
16 cmd.AddCommand(newSnapshotDeleteCmd())
17 cmd.AddCommand(newSnapshotDiffCmd())
18 cmd.AddCommand(newSnapshotRestoreCmd())
19
20 return cmd
21 }
22
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_delete.go 90.9%
1 package main
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
7 "github.com/spf13/cobra"
8 )
9
10 func newSnapshotDeleteCmd() *cobra.Command {
11 cmd := &cobra.Command{
12 Use: "delete <snapshot-id>",
13 Short: "Delete a saved snapshot",
14 Long: "Remove a snapshot from .bausteinsicht-snapshots/",
15 Args: cobra.ExactArgs(1),
16 RunE: runSnapshotDelete,
17 }
18
19 return cmd
20 }
21
22 func runSnapshotDelete(cmd *cobra.Command, args []string) error {
23 snapshotID := args[0]
24
25 manager := snapshot.NewManager(".")
26 if !manager.Exists(snapshotID) {
27 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID), 2)
28 }
29
30 if err := manager.Delete(snapshotID); err != nil {
31 return exitWithCode(fmt.Errorf("deleting snapshot: %w", err), 2)
32 }
33
34 output := fmt.Sprintf("Snapshot deleted: %s\n", snapshotID)
35 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
36
37 return nil
38 }
39
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_diff.go 65.3%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
10 "github.com/spf13/cobra"
11 )
12
13 func newSnapshotDiffCmd() *cobra.Command {
14 cmd := &cobra.Command{
15 Use: "diff <snapshot-id-1> [snapshot-id-2]",
16 Short: "Diff two snapshots or a snapshot vs current state",
17 Long: "Compare two snapshots or compare a snapshot against the current model state.",
18 Args: cobra.RangeArgs(1, 2),
19 RunE: runSnapshotDiff,
20 }
21
22 cmd.Flags().String("format", "text", "Output format: text or json")
23
24 return cmd
25 }
26
27 func runSnapshotDiff(cmd *cobra.Command, args []string) error {
28 snapshotID1 := args[0]
29 format, _ := cmd.Flags().GetString("format")
30 modelPath, _ := cmd.Flags().GetString("model")
31
32 manager := snapshot.NewManager(".")
33
34 // Load first snapshot
35 if !manager.Exists(snapshotID1) {
36 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID1), 2)
37 }
38
39 snap1, err := manager.Load(snapshotID1)
40 if err != nil {
41 return exitWithCode(fmt.Errorf("loading snapshot %s: %w", snapshotID1, err), 2)
42 }
43
44 var model2 *model.BausteinsichtModel
45
46 // Load second snapshot or current state
47 if len(args) == 2 {
48 snapshotID2 := args[1]
49 if !manager.Exists(snapshotID2) {
50 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID2), 2)
51 }
52 snap2, err := manager.Load(snapshotID2)
53 if err != nil {
54 return exitWithCode(fmt.Errorf("loading snapshot %s: %w", snapshotID2, err), 2)
55 }
56 model2 = snap2.Model
57 } else {
58 // Load current model
59 if modelPath == "" {
60 modelPath = "architecture.jsonc"
61 }
62 m, err := model.Load(modelPath)
63 if err != nil {
64 return exitWithCode(fmt.Errorf("loading current model: %w", err), 2)
65 }
66 model2 = m
67 }
68
69 // Compare models
70 diffs := diffModels(snap1.Model, model2)
71
72 // Output results
73 switch format {
74 case "json":
75 data, err := json.MarshalIndent(diffs, "", " ")
76 if err != nil {
77 return exitWithCode(fmt.Errorf("marshaling diff: %w", err), 2)
78 }
79 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
80 case "text":
81 _, _ = fmt.Fprint(cmd.OutOrStdout(), formatDiffText(diffs))
82 default:
83 return exitWithCode(fmt.Errorf("unknown format: %s", format), 2)
84 }
85
86 return nil
87 }
88
89 type ModelDiff struct {
90 AddedElements []string `json:"addedElements,omitempty"`
91 RemovedElements []string `json:"removedElements,omitempty"`
92 ChangedElements map[string][]string `json:"changedElements,omitempty"`
93 AddedRelationships []RelDiff `json:"addedRelationships,omitempty"`
94 RemovedRelationships []RelDiff `json:"removedRelationships,omitempty"`
95 }
96
97 type RelDiff struct {
98 From string `json:"from"`
99 To string `json:"to"`
100 Label string `json:"label,omitempty"`
101 }
102
103 func diffModels(m1, m2 *model.BausteinsichtModel) *ModelDiff {
104 result := &ModelDiff{
105 ChangedElements: make(map[string][]string),
106 }
107
108 // Flatten elements for easier comparison
109 flat1 := flattenAll(m1.Model)
110 flat2 := flattenAll(m2.Model)
111
112 // Find added and removed elements
113 for id := range flat2 {
114 if _, exists := flat1[id]; !exists {
115 result.AddedElements = append(result.AddedElements, id)
116 }
117 }
118
119 for id := range flat1 {
120 if _, exists := flat2[id]; !exists {
121 result.RemovedElements = append(result.RemovedElements, id)
122 }
123 }
124
125 // Find changed elements
126 for id, elem1 := range flat1 {
127 if elem2, exists := flat2[id]; exists {
128 changes := compareElements(elem1, elem2)
129 if len(changes) > 0 {
130 result.ChangedElements[id] = changes
131 }
132 }
133 }
134
135 // Compare relationships
136 relMap1 := relationshipMapString(m1.Relationships)
137 relMap2 := relationshipMapString(m2.Relationships)
138
139 for key, rel2 := range relMap2 {
140 if _, exists := relMap1[key]; !exists {
141 result.AddedRelationships = append(result.AddedRelationships, rel2)
142 }
143 }
144
145 for key, rel1 := range relMap1 {
146 if _, exists := relMap2[key]; !exists {
147 result.RemovedRelationships = append(result.RemovedRelationships, rel1)
148 }
149 }
150
151 return result
152 }
153
154 func flattenAll(elems map[string]model.Element) map[string]model.Element {
155 result := make(map[string]model.Element)
156 for key, elem := range elems {
157 result[key] = elem
158 if len(elem.Children) > 0 {
159 children := flattenAll(elem.Children)
160 for k, v := range children {
161 result[key+"."+k] = v
162 }
163 }
164 }
165 return result
166 }
167
168 func compareElements(e1, e2 model.Element) []string {
169 var changes []string
170 if e1.Title != e2.Title {
171 changes = append(changes, fmt.Sprintf("title: %q → %q", e1.Title, e2.Title))
172 }
173 if e1.Kind != e2.Kind {
174 changes = append(changes, fmt.Sprintf("kind: %q → %q", e1.Kind, e2.Kind))
175 }
176 if e1.Description != e2.Description {
177 changes = append(changes, fmt.Sprintf("description: %q → %q", e1.Description, e2.Description))
178 }
179 if e1.Technology != e2.Technology {
180 changes = append(changes, fmt.Sprintf("technology: %q → %q", e1.Technology, e2.Technology))
181 }
182 if e1.Status != e2.Status {
183 changes = append(changes, fmt.Sprintf("status: %q → %q", e1.Status, e2.Status))
184 }
185 return changes
186 }
187
188 func relationshipMapString(rels []model.Relationship) map[string]RelDiff {
189 m := make(map[string]RelDiff)
190 for _, rel := range rels {
191 key := fmt.Sprintf("%s:%s", rel.From, rel.To)
192 m[key] = RelDiff{From: rel.From, To: rel.To, Label: rel.Label}
193 }
194 return m
195 }
196
197 func formatDiffText(diffs *ModelDiff) string {
198 var sb strings.Builder
199
200 totalChanges := len(diffs.AddedElements) + len(diffs.RemovedElements) +
201 len(diffs.ChangedElements) + len(diffs.AddedRelationships) +
202 len(diffs.RemovedRelationships)
203
204 if totalChanges == 0 {
205 return "No differences found.\n"
206 }
207
208 fmt.Fprintf(&sb, "Architecture Differences (Total Changes: %d)\n", totalChanges)
209 sb.WriteString(strings.Repeat("=", 50))
210 sb.WriteString("\n\n")
211
212 if len(diffs.AddedElements) > 0 {
213 fmt.Fprintf(&sb, "Added Elements (%d):\n", len(diffs.AddedElements))
214 for _, id := range diffs.AddedElements {
215 fmt.Fprintf(&sb, " + %s\n", id)
216 }
217 sb.WriteString("\n")
218 }
219
220 if len(diffs.RemovedElements) > 0 {
221 fmt.Fprintf(&sb, "Removed Elements (%d):\n", len(diffs.RemovedElements))
222 for _, id := range diffs.RemovedElements {
223 fmt.Fprintf(&sb, " - %s\n", id)
224 }
225 sb.WriteString("\n")
226 }
227
228 if len(diffs.ChangedElements) > 0 {
229 fmt.Fprintf(&sb, "Changed Elements (%d):\n", len(diffs.ChangedElements))
230 for id, changes := range diffs.ChangedElements {
231 fmt.Fprintf(&sb, " ~ %s\n", id)
232 for _, change := range changes {
233 fmt.Fprintf(&sb, " %s\n", change)
234 }
235 }
236 sb.WriteString("\n")
237 }
238
239 if len(diffs.AddedRelationships) > 0 {
240 fmt.Fprintf(&sb, "Added Relationships (%d):\n", len(diffs.AddedRelationships))
241 for _, rel := range diffs.AddedRelationships {
242 fmt.Fprintf(&sb, " + %s → %s", rel.From, rel.To)
243 if rel.Label != "" {
244 fmt.Fprintf(&sb, " (%s)", rel.Label)
245 }
246 sb.WriteString("\n")
247 }
248 sb.WriteString("\n")
249 }
250
251 if len(diffs.RemovedRelationships) > 0 {
252 fmt.Fprintf(&sb, "Removed Relationships (%d):\n", len(diffs.RemovedRelationships))
253 for _, rel := range diffs.RemovedRelationships {
254 fmt.Fprintf(&sb, " - %s → %s", rel.From, rel.To)
255 if rel.Label != "" {
256 fmt.Fprintf(&sb, " (%s)", rel.Label)
257 }
258 sb.WriteString("\n")
259 }
260 }
261
262 return sb.String()
263 }
264
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_list.go 84.6%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "text/tabwriter"
7
8 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
9 "github.com/spf13/cobra"
10 )
11
12 func newSnapshotListCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "list",
15 Short: "List all saved snapshots",
16 Long: "Display all snapshots with timestamps, messages, and element/relationship counts.",
17 RunE: runSnapshotList,
18 }
19
20 cmd.Flags().String("format", "table", "Output format: table or json")
21
22 return cmd
23 }
24
25 func runSnapshotList(cmd *cobra.Command, _ []string) error {
26 format, _ := cmd.Flags().GetString("format")
27
28 manager := snapshot.NewManager(".")
29 snapshots, err := manager.List()
30 if err != nil {
31 return exitWithCode(fmt.Errorf("listing snapshots: %w", err), 2)
32 }
33
34 if len(snapshots) == 0 {
35 _, _ = fmt.Fprint(cmd.OutOrStdout(), "No snapshots found.\n")
36 return nil
37 }
38
39 switch format {
40 case "json":
41 data, err := json.MarshalIndent(snapshots, "", " ")
42 if err != nil {
43 return exitWithCode(fmt.Errorf("marshaling snapshots: %w", err), 2)
44 }
45 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
46 case "table":
47 w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
48 _, _ = fmt.Fprintln(w, "ID\tTIMESTAMP\tELEMENTS\tRELATIONSHIPS\tMESSAGE")
49 for _, s := range snapshots {
50 message := s.Message
51 if len(message) > 30 {
52 message = message[:27] + "..."
53 }
54 _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
55 s.ID,
56 s.Timestamp.Format("2006-01-02 15:04:05"),
57 s.ElementCount,
58 s.RelCount,
59 message,
60 )
61 }
62 _ = w.Flush()
63 default:
64 return exitWithCode(fmt.Errorf("unknown format: %s", format), 2)
65 }
66
67 return nil
68 }
69
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_restore.go 85.7%
1 package main
2
3 import (
4 "fmt"
5 "os"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
9 "github.com/spf13/cobra"
10 )
11
12 func newSnapshotRestoreCmd() *cobra.Command {
13 cmd := &cobra.Command{
14 Use: "restore <snapshot-id> <output-path>",
15 Short: "Restore a snapshot to a file",
16 Long: "Export a snapshot's model to a JSONC file.",
17 Args: cobra.ExactArgs(2),
18 RunE: runSnapshotRestore,
19 }
20
21 cmd.Flags().Bool("force", false, "Overwrite output file if it exists")
22
23 return cmd
24 }
25
26 func runSnapshotRestore(cmd *cobra.Command, args []string) error {
27 snapshotID := args[0]
28 outputPath := args[1]
29 force, _ := cmd.Flags().GetBool("force")
30
31 if err := validatePathContainment(outputPath); err != nil {
32 return exitWithCode(err, 2)
33 }
34
35 manager := snapshot.NewManager(".")
36 if !manager.Exists(snapshotID) {
37 return exitWithCode(fmt.Errorf("snapshot not found: %s", snapshotID), 2)
38 }
39
40 snap, err := manager.Load(snapshotID)
41 if err != nil {
42 return exitWithCode(fmt.Errorf("loading snapshot: %w", err), 2)
43 }
44
45 // Check if output file already exists
46 if _, err := os.Stat(outputPath); err == nil && !force {
47 return exitWithCode(fmt.Errorf("output file already exists: %s (use --force to overwrite)", outputPath), 2)
48 }
49
50 // Save the model to the output file
51 if err := model.Save(outputPath, snap.Model); err != nil {
52 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
53 }
54
55 output := fmt.Sprintf("Snapshot restored: %s → %s\n", snapshotID, outputPath)
56 _, _ = fmt.Fprint(cmd.OutOrStdout(), output)
57
58 return nil
59 }
60
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/snapshot_save.go 11.8%
1 package main
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 "github.com/docToolchain/Bausteinsicht/internal/snapshot"
8 "github.com/spf13/cobra"
9 )
10
11 func newSnapshotSaveCmd() *cobra.Command {
12 cmd := &cobra.Command{
13 Use: "save",
14 Short: "Save current architecture model as a snapshot",
15 Long: "Capture the current architecture model state with an optional message and store it in .bausteinsicht-snapshots/",
16 RunE: runSnapshotSave,
17 }
18
19 cmd.Flags().String("model", "architecture.jsonc", "Model file path")
20 cmd.Flags().String("message", "", "Optional message describing the snapshot")
21
22 return cmd
23 }
24
25 func runSnapshotSave(cmd *cobra.Command, _ []string) error {
26 modelPath, _ := cmd.Flags().GetString("model")
27 message, _ := cmd.Flags().GetString("message")
28
29 if err := validatePathContainment(modelPath); err != nil {
30 return exitWithCode(err, 2)
31 }
32
33 // Load current model
34 m, err := model.Load(modelPath)
35 if err != nil {
36 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
37 }
38
39 // Create snapshot
40 snap := snapshot.NewSnapshot(message, m)
41
42 // Save snapshot
43 manager := snapshot.NewManager(".")
44 if err := manager.Save(snap); err != nil {
45 return exitWithCode(fmt.Errorf("saving snapshot: %w", err), 2)
46 }
47
48 // Report success
49 elementCount := len(flattenElements(m.Model))
50 relCount := len(m.Relationships)
51
52 output := fmt.Sprintf("Snapshot saved: %s\n", snap.ID)
53 output += fmt.Sprintf(" Timestamp: %s\n", snap.Timestamp.Format("2006-01-02T15:04:05Z"))
54 output += fmt.Sprintf(" Elements: %d\n", elementCount)
55 output += fmt.Sprintf(" Relationships: %d\n", relCount)
56 if message != "" {
57 output += fmt.Sprintf(" Message: %s\n", message)
58 }
59
60 if _, err := fmt.Fprint(cmd.OutOrStdout(), output); err != nil {
61 return exitWithCode(fmt.Errorf("writing output: %w", err), 2)
62 }
63
64 return nil
65 }
66
67 // flattenElements counts total elements including nested ones
68 func flattenElements(elems map[string]model.Element) map[string]model.Element {
69 result := make(map[string]model.Element)
70 for key, elem := range elems {
71 result[key] = elem
72 if len(elem.Children) > 0 {
73 children := flattenElements(elem.Children)
74 for k, v := range children {
75 result[key+"."+k] = v
76 }
77 }
78 }
79 return result
80 }
81
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/status.go 85.7%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "sort"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 "github.com/spf13/cobra"
10 )
11
12 type statusResult struct {
13 Summary map[string]int `json:"summary"`
14 Elements []statusElement `json:"elements"`
15 }
16
17 type statusElement struct {
18 ID string `json:"id"`
19 Kind string `json:"kind"`
20 Title string `json:"title"`
21 Status string `json:"status"`
22 }
23
24 func newStatusCmd() *cobra.Command {
25 cmd := &cobra.Command{
26 Use: "status",
27 Short: "Show element lifecycle status",
28 Long: "Lists all elements and their lifecycle status (proposed, design, implementation, deployed, deprecated, archived).",
29 RunE: runStatus,
30 }
31
32 cmd.Flags().StringP("filter", "f", "", "Filter elements by status (proposed, design, implementation, deployed, deprecated, archived)")
33 // Note: --model is registered as PersistentFlag on root command, not locally
34
35 return cmd
36 }
37
38 func runStatus(cmd *cobra.Command, _ []string) error {
39 modelPath, _ := cmd.Flags().GetString("model")
40 filter, _ := cmd.Flags().GetString("filter")
41 format, _ := cmd.Flags().GetString("format")
42
43 if err := validatePathContainment(modelPath); err != nil {
44 return exitWithCode(fmt.Errorf("model path: %w", err), 1)
45 }
46
47 m, err := model.Load(modelPath)
48 if err != nil {
49 return exitWithCode(fmt.Errorf("loading model: %w", err), 1)
50 }
51
52 // Validate filter if provided
53 if filter != "" {
54 valid := false
55 for _, status := range model.ValidStatuses {
56 if filter == status {
57 valid = true
58 break
59 }
60 }
61 if !valid {
62 return exitWithCode(
63 fmt.Errorf("invalid status filter %q; valid values: %v", filter, model.ValidStatuses), 1)
64 }
65 }
66
67 // Collect all elements with their status
68 flatElements, err := model.FlattenElements(m)
69 if err != nil {
70 return exitWithCode(fmt.Errorf("flattening model: %w", err), 1)
71 }
72
73 result := statusResult{
74 Summary: make(map[string]int),
75 Elements: []statusElement{},
76 }
77
78 // Initialize summary counts
79 for _, status := range model.ValidStatuses {
80 result.Summary[status] = 0
81 }
82 result.Summary["unset"] = 0
83
84 // Process elements
85 for id, elem := range flatElements {
86 status := elem.Status
87 if status == "" {
88 status = "unset"
89 }
90
91 // Apply filter
92 if filter != "" && status != filter {
93 continue
94 }
95
96 // Count
97 if status != "unset" {
98 result.Summary[status]++
99 } else {
100 result.Summary["unset"]++
101 }
102
103 // Collect element
104 result.Elements = append(result.Elements, statusElement{
105 ID: id,
106 Kind: elem.Kind,
107 Title: elem.Title,
108 Status: status,
109 })
110 }
111
112 // Sort by status, then by ID
113 sort.Slice(result.Elements, func(i, j int) bool {
114 if result.Elements[i].Status != result.Elements[j].Status {
115 return result.Elements[i].Status < result.Elements[j].Status
116 }
117 return result.Elements[i].ID < result.Elements[j].ID
118 })
119
120 if format == "json" {
121 return outputStatusJSON(cmd, result)
122 }
123 return outputStatusText(cmd, result)
124 }
125
126 func outputStatusJSON(cmd *cobra.Command, result statusResult) error {
127 data, err := json.MarshalIndent(result, "", " ")
128 if err != nil {
129 return err
130 }
131 _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
132 return err
133 }
134
135 func outputStatusText(cmd *cobra.Command, result statusResult) error {
136 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Element Lifecycle Status\n"); err != nil {
137 return err
138 }
139 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "==================================================\n\n"); err != nil {
140 return err
141 }
142
143 // Print summary
144 for _, status := range append(model.ValidStatuses, "unset") {
145 count := result.Summary[status]
146 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s (%d):\n", status, count); err != nil {
147 return err
148 }
149
150 // Print elements for this status
151 for _, elem := range result.Elements {
152 if elem.Status == status {
153 if _, err := fmt.Fprintf(cmd.OutOrStdout(), " %-20s [%-12s] %q\n", elem.ID, elem.Kind, elem.Title); err != nil {
154 return err
155 }
156 }
157 }
158 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "\n"); err != nil {
159 return err
160 }
161 }
162
163 return nil
164 }
165
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/sync.go 88.1%
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io/fs"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "github.com/docToolchain/Bausteinsicht/internal/diagram"
14 "github.com/docToolchain/Bausteinsicht/internal/drawio"
15 "github.com/docToolchain/Bausteinsicht/internal/model"
16 bsync "github.com/docToolchain/Bausteinsicht/internal/sync"
17 "github.com/docToolchain/Bausteinsicht/templates"
18 "github.com/spf13/cobra"
19 )
20
21 func newSyncCmd() *cobra.Command {
22 cmd := &cobra.Command{
23 Use: "sync",
24 Short: "Synchronize model and draw.io diagram",
25 Long: "Runs one bidirectional sync cycle between the architecture model and the draw.io diagram.",
26 RunE: runSync,
27 }
28 cmd.Flags().Bool("relayout", false, "Re-apply auto-layout to all view pages (resets element positions)")
29 cmd.Flags().Bool("mermaid", false, "Export Mermaid diagrams to Markdown file")
30 cmd.Flags().String("mermaid-output", "architecture.md", "Output file for Mermaid diagrams")
31 return cmd
32 }
33
34 func runSync(cmd *cobra.Command, _ []string) error {
35 format, _ := cmd.Flags().GetString("format")
36 modelPath, _ := cmd.Flags().GetString("model")
37 templatePath, _ := cmd.Flags().GetString("template")
38 verbose, _ := cmd.Flags().GetBool("verbose")
39 relayout, _ := cmd.Flags().GetBool("relayout")
40
41 // Auto-detect model file.
42 if modelPath == "" {
43 detected, err := model.AutoDetect(".")
44 if err != nil {
45 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
46 }
47 modelPath = detected
48 }
49
50 // Derive drawio and state paths from model path.
51 dir := filepath.Dir(modelPath)
52 drawioPath := filepath.Join(dir, "architecture.drawio")
53 statePath := filepath.Join(dir, ".bausteinsicht-sync")
54
55 // Load model.
56 m, err := model.Load(modelPath)
57 if err != nil {
58 return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
59 }
60
61 // Validate model before syncing to catch invalid view include/exclude
62 // patterns and other consistency errors. Without this, typos like
63 // "customer." (trailing dot) silently remove elements from draw.io. (#176)
64 if validationErrs := model.Validate(m); len(validationErrs) > 0 {
65 for _, ve := range validationErrs {
66 fmt.Fprintln(os.Stderr, "ERROR:", ve)
67 }
68 return exitWithCode(fmt.Errorf("model validation failed with %d error(s); fix the model before syncing", len(validationErrs)), 1)
69 }
70
71 // Load draw.io document. If the file was deleted or is an empty mxfile
72 // (no diagram pages — e.g., after all views were removed), recreate it
73 // from the template and reset sync state so forward sync repopulates it
74 // (#149, #175).
75 var recreated bool
76 doc, err := drawio.LoadDocument(drawioPath)
77 if err != nil {
78 if !errors.Is(err, fs.ErrNotExist) && !isEmptyMxfileError(err) {
79 return exitWithCode(fmt.Errorf("loading draw.io file: %w", err), 2)
80 }
81 if errors.Is(err, fs.ErrNotExist) {
82 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "WARNING: Draw.io file not found, recreating from template")
83 } else {
84 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "WARNING: Draw.io file has no diagram pages, recreating structure")
85 }
86 doc = drawio.NewDocument()
87 recreated = true
88 }
89
90 // Load sync state (empty on first sync).
91 // When the draw.io file was recreated, discard any stale state so the
92 // sync engine treats all model elements as new.
93 var state *bsync.SyncState
94 if recreated {
95 // Remove stale state file if it exists.
96 _ = os.Remove(statePath)
97 state = &bsync.SyncState{
98 Elements: make(map[string]bsync.ElementState),
99 Relationships: []bsync.RelationshipState{},
100 }
101 } else {
102 state, err = bsync.LoadState(statePath)
103 if err != nil {
104 return exitWithCode(fmt.Errorf("loading sync state: %w", err), 2)
105 }
106 }
107
108 // Load template.
109 var tmpl *drawio.TemplateSet
110 if templatePath != "" {
111 tmpl, err = drawio.LoadTemplate(templatePath)
112 } else {
113 tmpl, err = drawio.LoadTemplateFromBytes(templates.DefaultTemplate)
114 }
115 if err != nil {
116 return exitWithCode(fmt.Errorf("loading template: %w", err), 2)
117 }
118
119 // Verbose output goes to stderr so it doesn't interfere with JSON on stdout.
120 if verbose && format != "json" {
121 flat, _ := model.FlattenElements(m)
122 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Syncing model: %s\n", modelPath)
123 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %d elements, %d relationships, %d views\n",
124 len(flat), len(m.Relationships), len(m.Views))
125 }
126
127 // Ensure pages exist for all views; track which pages are newly created
128 // so the sync engine can avoid treating their missing elements as deletions
129 // (#184, #188, #189).
130 newPageIDs := make(map[string]bool)
131 for viewID, view := range m.Views {
132 pageID := "view-" + viewID
133 if doc.GetPage(pageID) == nil {
134 doc.AddPage(pageID, view.Title)
135 newPageIDs[pageID] = true
136 }
137 }
138
139 // Remove orphaned view pages (views deleted or renamed in model). (#143)
140 bsync.RemoveOrphanedViewPages(doc, m)
141
142 // Run sync.
143 fwdOpts := bsync.ForwardOptions{
144 ModelPath: modelPath,
145 SyncTime: time.Now().Format("2006-01-02 15:04"),
146 Relayout: relayout,
147 }
148 result := bsync.Run(m, doc, state, tmpl, newPageIDs, fwdOpts)
149
150 // Save updated model: use PatchSave to preserve JSONC comments and key
151 // ordering when possible, fall back to full Save for structural changes.
152 if err := saveModel(modelPath, m, result); err != nil {
153 return exitWithCode(fmt.Errorf("saving model: %w", err), 2)
154 }
155 if err := drawio.SaveDocument(drawioPath, doc); err != nil {
156 return exitWithCode(fmt.Errorf("saving draw.io file: %w", err), 2)
157 }
158
159 absModel, _ := filepath.Abs(modelPath)
160 absDrawio, _ := filepath.Abs(drawioPath)
161 newState, err := bsync.BuildState(m, doc, absModel, absDrawio)
162 if err != nil {
163 return exitWithCode(fmt.Errorf("building sync state: %w", err), 2)
164 }
165 if err := bsync.SaveState(statePath, newState); err != nil {
166 return exitWithCode(fmt.Errorf("saving sync state: %w", err), 2)
167 }
168
169 // Export Mermaid diagrams if requested
170 enableMermaid, _ := cmd.Flags().GetBool("mermaid")
171 if enableMermaid {
172 mermaidOutput, _ := cmd.Flags().GetString("mermaid-output")
173 viewKeys, diagrams, err := diagram.ExportAllViewsToMermaid(m)
174 if err != nil {
175 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "WARNING: Mermaid export failed: %v\n", err)
176 } else {
177 viewTitles := make(map[string]string)
178 for _, viewKey := range viewKeys {
179 if view, ok := m.Views[viewKey]; ok {
180 viewTitles[viewKey] = view.Title
181 }
182 }
183 markdownContent := diagram.WrapDiagramsInMarkdown(viewKeys, diagrams, viewTitles)
184 if err := os.WriteFile(mermaidOutput, []byte(markdownContent), 0o600); err != nil {
185 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "WARNING: Failed to write Mermaid file: %v\n", err)
186 } else {
187 if verbose && format != "json" {
188 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Exporting Mermaid... ✅ %s (%d views)\n", mermaidOutput, len(viewKeys))
189 }
190 }
191 }
192 }
193
194 // Verbose post-sync details to stderr.
195 if verbose && format != "json" {
196 if result.Forward != nil {
197 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Forward sync: %d elements created, %d updated, %d deleted; %d connectors created, %d updated, %d deleted\n",
198 result.Forward.ElementsCreated, result.Forward.ElementsUpdated, result.Forward.ElementsDeleted,
199 result.Forward.ConnectorsCreated, result.Forward.ConnectorsUpdated, result.Forward.ConnectorsDeleted)
200 }
201 if result.Reverse != nil {
202 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Reverse sync: %d elements created, %d updated, %d deleted; %d relationships created, %d updated, %d deleted\n",
203 result.Reverse.ElementsCreated, result.Reverse.ElementsUpdated, result.Reverse.ElementsDeleted,
204 result.Reverse.RelationshipsCreated, result.Reverse.RelationshipsUpdated, result.Reverse.RelationshipsDeleted)
205 }
206 if len(result.Conflicts) > 0 {
207 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Conflicts resolved: %d (model wins)\n", len(result.Conflicts))
208 }
209 }
210
211 // Print warnings to stderr.
212 for _, w := range result.Warnings {
213 fmt.Fprintln(os.Stderr, "WARNING:", w)
214 }
215
216 // Output summary.
217 summary := buildSyncSummary(result)
218 if format == "json" {
219 data, _ := json.MarshalIndent(summary, "", " ")
220 fmt.Println(string(data))
221 } else {
222 printSyncSummary(summary)
223 }
224
225 // Exit code 1 if conflicts detected.
226 if len(result.Conflicts) > 0 {
227 return exitWithCode(fmt.Errorf("%d conflict(s) resolved (model wins)", len(result.Conflicts)), 1)
228 }
229
230 return nil
231 }
232
233 type syncSummary struct {
234 ForwardAdded int `json:"forward_added"`
235 ForwardUpdated int `json:"forward_updated"`
236 ForwardDeleted int `json:"forward_deleted"`
237 ReverseAdded int `json:"reverse_added"`
238 ReverseUpdated int `json:"reverse_updated"`
239 ReverseDeleted int `json:"reverse_deleted"`
240 MetadataUpdated int `json:"metadata_updated"`
241 Conflicts int `json:"conflicts"`
242 }
243
244 func buildSyncSummary(result *bsync.SyncResult) syncSummary {
245 s := syncSummary{
246 Conflicts: len(result.Conflicts),
247 }
248 if result.Forward != nil {
249 s.ForwardAdded = result.Forward.ElementsCreated
250 s.ForwardUpdated = result.Forward.ElementsUpdated
251 s.ForwardDeleted = result.Forward.ElementsDeleted
252 s.ForwardAdded += result.Forward.ConnectorsCreated
253 s.ForwardUpdated += result.Forward.ConnectorsUpdated
254 s.ForwardDeleted += result.Forward.ConnectorsDeleted
255 s.MetadataUpdated = result.Forward.MetadataUpdated
256 }
257 if result.Reverse != nil {
258 s.ReverseAdded = result.Reverse.ElementsCreated
259 s.ReverseUpdated = result.Reverse.ElementsUpdated
260 s.ReverseDeleted = result.Reverse.ElementsDeleted
261 s.ReverseAdded += result.Reverse.RelationshipsCreated
262 s.ReverseUpdated += result.Reverse.RelationshipsUpdated
263 s.ReverseDeleted += result.Reverse.RelationshipsDeleted
264 }
265 return s
266 }
267
268 // saveModel saves the model to path, preserving JSONC comments and key ordering
269 // when the reverse changes are simple field modifications. Falls back to full
270 // Save for structural changes or when patching fails.
271 func saveModel(path string, m *model.BausteinsichtModel, result *bsync.SyncResult) error {
272 hasReverse := result.Reverse != nil &&
273 (result.Reverse.ElementsUpdated+result.Reverse.ElementsCreated+
274 result.Reverse.ElementsDeleted+result.Reverse.RelationshipsCreated+
275 result.Reverse.RelationshipsUpdated+result.Reverse.RelationshipsDeleted) > 0
276
277 if !hasReverse {
278 // No reverse changes — model file doesn't need updating.
279 return nil
280 }
281
282 if result.Changes != nil {
283 ops, patchable := bsync.ReversePatchOps(result.Changes)
284 if patchable && len(ops) > 0 {
285 if err := model.PatchSave(path, ops); err == nil {
286 return nil
287 }
288 // PatchSave failed — fall through to full Save.
289 }
290 }
291
292 return model.Save(path, m)
293 }
294
295 // isEmptyMxfileError returns true if the error indicates that the draw.io file
296 // is a valid XML mxfile but contains no <diagram> elements. This happens when
297 // all views are removed from the model and sync removes all diagram pages (#175).
298 func isEmptyMxfileError(err error) bool {
299 return err != nil && strings.Contains(err.Error(), "no <diagram> elements")
300 }
301
302 func printSyncSummary(s syncSummary) {
303 total := s.ForwardAdded + s.ForwardUpdated + s.ForwardDeleted +
304 s.ReverseAdded + s.ReverseUpdated + s.ReverseDeleted
305 if total == 0 && s.Conflicts == 0 && s.MetadataUpdated == 0 {
306 fmt.Println("Already in sync. No changes.")
307 return
308 }
309 if s.ForwardAdded+s.ForwardUpdated+s.ForwardDeleted > 0 {
310 fmt.Printf("Forward (model → draw.io): %d added, %d updated, %d deleted\n",
311 s.ForwardAdded, s.ForwardUpdated, s.ForwardDeleted)
312 }
313 if s.MetadataUpdated > 0 && total == 0 {
314 fmt.Printf("Metadata/legend updated on %d view page(s).\n", s.MetadataUpdated/2)
315 }
316 if s.ReverseAdded+s.ReverseUpdated+s.ReverseDeleted > 0 {
317 fmt.Printf("Reverse (draw.io → model): %d added, %d updated, %d deleted\n",
318 s.ReverseAdded, s.ReverseUpdated, s.ReverseDeleted)
319 }
320 if s.Conflicts > 0 {
321 fmt.Printf("Conflicts: %d (resolved: model wins)\n", s.Conflicts)
322 }
323 }
324
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/validate.go 91.1%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 "github.com/spf13/cobra"
9 )
10
11 type validateResult struct {
12 Valid bool `json:"valid"`
13 Errors []validateErrJSON `json:"errors"`
14 Warnings []validateErrJSON `json:"warnings,omitempty"`
15 }
16
17 type validateErrJSON struct {
18 Path string `json:"path"`
19 Message string `json:"message"`
20 }
21
22 func newValidateCmd() *cobra.Command {
23 return &cobra.Command{
24 Use: "validate",
25 Short: "Validate the architecture model",
26 Long: "Validates the architecture model file for consistency and reports any errors.",
27 SilenceUsage: true,
28 SilenceErrors: true,
29 RunE: runValidate,
30 }
31 }
32
33 func runValidate(cmd *cobra.Command, args []string) error {
34 format, _ := cmd.Flags().GetString("format")
35 modelPath, _ := cmd.Flags().GetString("model")
36 verbose, _ := cmd.Flags().GetBool("verbose")
37
38 if modelPath == "" {
39 detected, err := model.AutoDetect(".")
40 if err != nil {
41 return exitWithCode(err, 2)
42 }
43 modelPath = detected
44 }
45
46 m, err := model.Load(modelPath)
47 if err != nil {
48 return exitWithCode(err, 2)
49 }
50
51 // Verbose output goes to stderr so it doesn't interfere with JSON on stdout.
52 if verbose && format != "json" {
53 flat, _ := model.FlattenElements(m)
54 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Validating model: %s\n", modelPath)
55 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %d elements, %d relationships, %d views\n",
56 len(flat), len(m.Relationships), len(m.Views))
57 }
58
59 result := model.ValidateWithWarnings(m)
60
61 if format == "json" {
62 return outputJSON(cmd, result)
63 }
64 return outputText(cmd, result)
65 }
66
67 func outputJSON(cmd *cobra.Command, vr model.ValidationResult) error {
68 result := validateResult{
69 Valid: len(vr.Errors) == 0,
70 Errors: make([]validateErrJSON, len(vr.Errors)),
71 }
72 for i, e := range vr.Errors {
73 result.Errors[i] = validateErrJSON{Path: e.Path, Message: e.Message}
74 }
75 if len(vr.Warnings) > 0 {
76 result.Warnings = make([]validateErrJSON, len(vr.Warnings))
77 for i, w := range vr.Warnings {
78 result.Warnings[i] = validateErrJSON{Path: w.Path, Message: w.Message}
79 }
80 }
81 data, err := json.MarshalIndent(result, "", " ")
82 if err != nil {
83 return err
84 }
85 if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
86 return err
87 }
88 if !result.Valid {
89 return exitWithCode(fmt.Errorf("validation failed"), 1)
90 }
91 return nil
92 }
93
94 func outputText(cmd *cobra.Command, vr model.ValidationResult) error {
95 for _, w := range vr.Warnings {
96 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "WARNING: [%s] %s\n", w.Path, w.Message); err != nil {
97 return err
98 }
99 }
100 if len(vr.Errors) == 0 {
101 _, err := fmt.Fprintln(cmd.OutOrStdout(), "Model is valid.")
102 return err
103 }
104 for _, e := range vr.Errors {
105 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "ERROR: [%s] %s\n", e.Path, e.Message); err != nil {
106 return err
107 }
108 }
109 return exitWithCode(fmt.Errorf("validation failed"), 1)
110 }
111
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/watch.go 16.8%
1 package main
2
3 import (
4 "fmt"
5 "os"
6 "os/signal"
7 "path/filepath"
8 "syscall"
9 "time"
10
11 "github.com/docToolchain/Bausteinsicht/internal/diagram"
12 "github.com/docToolchain/Bausteinsicht/internal/drawio"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 bsync "github.com/docToolchain/Bausteinsicht/internal/sync"
15 "github.com/docToolchain/Bausteinsicht/internal/watcher"
16 "github.com/docToolchain/Bausteinsicht/templates"
17 "github.com/spf13/cobra"
18 )
19
20 func newWatchCmd() *cobra.Command {
21 cmd := &cobra.Command{
22 Use: "watch",
23 Short: "Watch model and diagram for changes and auto-sync",
24 Long: "Watches the model and draw.io files for changes and automatically runs a sync cycle on each change.",
25 RunE: runWatch,
26 }
27 cmd.Flags().Bool("mermaid", false, "Export Mermaid diagrams to Markdown file")
28 cmd.Flags().String("mermaid-output", "architecture.md", "Output file for Mermaid diagrams")
29 return cmd
30 }
31
32 func runWatch(cmd *cobra.Command, _ []string) error {
33 modelPath, _ := cmd.Flags().GetString("model")
34 templatePath, _ := cmd.Flags().GetString("template")
35 enableMermaid, _ := cmd.Flags().GetBool("mermaid")
36 mermaidOutput, _ := cmd.Flags().GetString("mermaid-output")
37
38 // Auto-detect model file.
39 if modelPath == "" {
40 detected, err := model.AutoDetect(".")
41 if err != nil {
42 return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
43 }
44 modelPath = detected
45 }
46
47 // Derive drawio path from model path.
48 dir := filepath.Dir(modelPath)
49 drawioPath := filepath.Join(dir, "architecture.drawio")
50
51 // Verify both files exist before starting the watcher.
52 if _, err := os.Stat(modelPath); err != nil {
53 return exitWithCode(fmt.Errorf("model file not found: %w", err), 2)
54 }
55 if _, err := os.Stat(drawioPath); err != nil {
56 return exitWithCode(fmt.Errorf("draw.io file not found: %w", err), 2)
57 }
58
59 absModel, _ := filepath.Abs(modelPath)
60 absDrawio, _ := filepath.Abs(drawioPath)
61
62 var err error
63
64 fmt.Printf("Watching %s and %s...\n", modelPath, drawioPath)
65
66 // Create the file watcher. Use a variable so the callback can access the watcher.
67 var w *watcher.Watcher
68 w, err = watcher.New(
69 []string{absModel, absDrawio},
70 watcher.DefaultDebounce,
71 func(changedFile string) {
72 w.SetSyncing(true)
73 defer w.SetSyncing(false)
74 doSync(changedFile, modelPath, drawioPath, templatePath, enableMermaid, mermaidOutput)
75 },
76 )
77 if err != nil {
78 return exitWithCode(fmt.Errorf("creating watcher: %w", err), 2)
79 }
80
81 if err := w.Start(); err != nil {
82 return exitWithCode(fmt.Errorf("starting watcher: %w", err), 2)
83 }
84
85 // Block until SIGINT/SIGTERM.
86 sigCh := make(chan os.Signal, 1)
87 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
88 <-sigCh
89
90 w.Stop()
91 fmt.Println("Stopped watching.")
92 return nil
93 }
94
95 func doSync(changedFile, modelPath, drawioPath, templatePath string, enableMermaid bool, mermaidOutput string) {
96 fmt.Printf("[%s] Sync triggered by %s\n", time.Now().Format("15:04:05"), changedFile)
97
98 dir := filepath.Dir(modelPath)
99 statePath := filepath.Join(dir, ".bausteinsicht-sync")
100
101 m, err := model.Load(modelPath)
102 if err != nil {
103 fmt.Fprintf(os.Stderr, "ERROR loading model: %v\n", err)
104 return
105 }
106
107 // Load draw.io document. If the file is an empty mxfile (no diagram
108 // pages — e.g., after all views were removed), recreate it and reset
109 // sync state (#175).
110 var watchRecreated bool
111 doc, err := drawio.LoadDocument(drawioPath)
112 if err != nil {
113 if isEmptyMxfileError(err) {
114 fmt.Fprintln(os.Stderr, "WARNING: Draw.io file has no diagram pages, recreating structure")
115 doc = drawio.NewDocument()
116 watchRecreated = true
117 } else {
118 fmt.Fprintf(os.Stderr, "ERROR loading draw.io file: %v\n", err)
119 return
120 }
121 }
122
123 var state *bsync.SyncState
124 if watchRecreated {
125 _ = os.Remove(statePath)
126 state = &bsync.SyncState{
127 Elements: make(map[string]bsync.ElementState),
128 Relationships: []bsync.RelationshipState{},
129 }
130 } else {
131 state, err = bsync.LoadState(statePath)
132 if err != nil {
133 fmt.Fprintf(os.Stderr, "ERROR loading sync state: %v\n", err)
134 return
135 }
136 }
137
138 var tmpl *drawio.TemplateSet
139 if templatePath != "" {
140 tmpl, err = drawio.LoadTemplate(templatePath)
141 } else {
142 tmpl, err = drawio.LoadTemplateFromBytes(templates.DefaultTemplate)
143 }
144 if err != nil {
145 fmt.Fprintf(os.Stderr, "ERROR loading template: %v\n", err)
146 return
147 }
148
149 // Ensure pages exist for all views; track new pages (#184, #188, #189).
150 newPageIDs := make(map[string]bool)
151 for viewID, view := range m.Views {
152 pageID := "view-" + viewID
153 if doc.GetPage(pageID) == nil {
154 doc.AddPage(pageID, view.Title)
155 newPageIDs[pageID] = true
156 }
157 }
158
159 // Remove orphaned view pages (views deleted or renamed in model). (#143)
160 bsync.RemoveOrphanedViewPages(doc, m)
161
162 result := bsync.Run(m, doc, state, tmpl, newPageIDs)
163
164 if err := saveModel(modelPath, m, result); err != nil {
165 fmt.Fprintf(os.Stderr, "ERROR saving model: %v\n", err)
166 return
167 }
168 if err := drawio.SaveDocument(drawioPath, doc); err != nil {
169 fmt.Fprintf(os.Stderr, "ERROR saving draw.io file: %v\n", err)
170 return
171 }
172
173 absModel, _ := filepath.Abs(modelPath)
174 absDrawio, _ := filepath.Abs(drawioPath)
175 newState, err := bsync.BuildState(m, doc, absModel, absDrawio)
176 if err != nil {
177 fmt.Fprintf(os.Stderr, "ERROR building sync state: %v\n", err)
178 return
179 }
180 if err := bsync.SaveState(statePath, newState); err != nil {
181 fmt.Fprintf(os.Stderr, "ERROR saving sync state: %v\n", err)
182 return
183 }
184
185 // Export Mermaid diagrams if requested and sync succeeded
186 if enableMermaid {
187 viewKeys, diagrams, err := diagram.ExportAllViewsToMermaid(m)
188 if err != nil {
189 fmt.Fprintf(os.Stderr, "WARNING: Mermaid export failed: %v\n", err)
190 } else {
191 viewTitles := make(map[string]string)
192 for _, viewKey := range viewKeys {
193 if view, ok := m.Views[viewKey]; ok {
194 viewTitles[viewKey] = view.Title
195 }
196 }
197 markdownContent := diagram.WrapDiagramsInMarkdown(viewKeys, diagrams, viewTitles)
198 if err := os.WriteFile(mermaidOutput, []byte(markdownContent), 0o600); err != nil {
199 fmt.Fprintf(os.Stderr, "WARNING: Failed to write Mermaid file: %v\n", err)
200 } else {
201 fmt.Printf("[%s] Exporting Mermaid... ✅ %s (%d views)\n", time.Now().Format("15:04:05"), mermaidOutput, len(viewKeys))
202 }
203 }
204 }
205
206 for _, w := range result.Warnings {
207 fmt.Fprintln(os.Stderr, "WARNING:", w)
208 }
209
210 printSyncSummary(buildSyncSummary(result))
211 }
212
github.com/docToolchain/Bausteinsicht/cmd/bausteinsicht/workspace.go 9.3%
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 "github.com/docToolchain/Bausteinsicht/internal/workspace"
11 "github.com/spf13/cobra"
12 )
13
14 func newWorkspaceCmd() *cobra.Command {
15 cmd := &cobra.Command{
16 Use: "workspace",
17 Short: "Manage multi-model workspaces",
18 Long: "Work with multi-model workspaces combining multiple architecture models.",
19 }
20
21 cmd.AddCommand(newWorkspaceMergeCmd())
22 cmd.AddCommand(newWorkspaceValidateCmd())
23 cmd.AddCommand(newWorkspaceListCmd())
24
25 return cmd
26 }
27
28 func newWorkspaceMergeCmd() *cobra.Command {
29 return &cobra.Command{
30 Use: "merge <config-file> <output-file>",
31 Short: "Merge multiple models into a single unified model",
32 Long: "Reads a workspace configuration file and merges all referenced models into a single output file. Element IDs are prefixed to avoid collisions.",
33 Args: cobra.ExactArgs(2),
34 RunE: func(cmd *cobra.Command, args []string) error {
35 return runWorkspaceMerge(cmd, args[0], args[1])
36 },
37 }
38 }
39
40 func runWorkspaceMerge(cmd *cobra.Command, configPath, outputPath string) error {
41 cfg, err := workspace.LoadConfig(configPath)
42 if err != nil {
43 return exitWithCode(fmt.Errorf("loading workspace config: %w", err), 2)
44 }
45
46 baseDir := filepath.Dir(configPath)
47 loaded, err := workspace.LoadModels(cfg, baseDir)
48 if err != nil {
49 return exitWithCode(fmt.Errorf("loading models: %w", err), 2)
50 }
51
52 merged, err := workspace.MergeModels(loaded)
53 if err != nil {
54 return exitWithCode(fmt.Errorf("merging models: %w", err), 2)
55 }
56
57 // Validate merged model
58 if errs := model.Validate(merged); len(errs) > 0 {
59 return exitWithCode(fmt.Errorf("validation failed: %v", errs), 2)
60 }
61
62 // Save merged model
63 data, err := json.MarshalIndent(merged, "", " ")
64 if err != nil {
65 return exitWithCode(fmt.Errorf("marshaling merged model: %w", err), 2)
66 }
67
68 if err := os.WriteFile(outputPath, data, 0644); err != nil {
69 return exitWithCode(fmt.Errorf("writing output file: %w", err), 2)
70 }
71
72 format, _ := cmd.Flags().GetString("format")
73 if format == "json" {
74 out, _ := json.Marshal(map[string]interface{}{
75 "message": "Models merged successfully",
76 "output": outputPath,
77 "models": len(loaded),
78 })
79 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
80 } else {
81 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Merged %d models into %s\n", len(loaded), outputPath)
82 }
83
84 return nil
85 }
86
87 func newWorkspaceValidateCmd() *cobra.Command {
88 return &cobra.Command{
89 Use: "validate <config-file>",
90 Short: "Validate a workspace configuration",
91 Long: "Validates that a workspace configuration is well-formed and all referenced models are valid.",
92 Args: cobra.ExactArgs(1),
93 RunE: func(cmd *cobra.Command, args []string) error {
94 return runWorkspaceValidate(cmd, args[0])
95 },
96 }
97 }
98
99 func runWorkspaceValidate(cmd *cobra.Command, configPath string) error {
100 cfg, err := workspace.LoadConfig(configPath)
101 if err != nil {
102 return exitWithCode(fmt.Errorf("loading workspace config: %w", err), 2)
103 }
104
105 baseDir := filepath.Dir(configPath)
106 loaded, err := workspace.LoadModels(cfg, baseDir)
107 if err != nil {
108 return exitWithCode(fmt.Errorf("loading models: %w", err), 2)
109 }
110
111 // Validate each model individually
112 var validationErrs []error
113 for _, lm := range loaded {
114 if errs := model.Validate(lm.Model); len(errs) > 0 {
115 for _, e := range errs {
116 validationErrs = append(validationErrs, fmt.Errorf("%s: %v", lm.Ref.ID, e))
117 }
118 }
119 }
120
121 if len(validationErrs) > 0 {
122 format, _ := cmd.Flags().GetString("format")
123 if format == "json" {
124 var errMsgs []string
125 for _, e := range validationErrs {
126 errMsgs = append(errMsgs, e.Error())
127 }
128 out, _ := json.Marshal(map[string]interface{}{
129 "valid": false,
130 "errors": errMsgs,
131 })
132 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
133 } else {
134 for _, e := range validationErrs {
135 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), e.Error())
136 }
137 }
138 return exitWithCode(fmt.Errorf("validation failed"), 2)
139 }
140
141 format, _ := cmd.Flags().GetString("format")
142 if format == "json" {
143 out, _ := json.Marshal(map[string]interface{}{
144 "valid": true,
145 "models": len(loaded),
146 })
147 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
148 } else {
149 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "✓ Workspace configuration is valid (%d models)\n", len(loaded))
150 }
151
152 return nil
153 }
154
155 func newWorkspaceListCmd() *cobra.Command {
156 return &cobra.Command{
157 Use: "list <config-file>",
158 Short: "List models in a workspace configuration",
159 Long: "Shows all models referenced in a workspace configuration with their IDs, paths, and prefixes.",
160 Args: cobra.ExactArgs(1),
161 RunE: func(cmd *cobra.Command, args []string) error {
162 return runWorkspaceList(cmd, args[0])
163 },
164 }
165 }
166
167 func runWorkspaceList(cmd *cobra.Command, configPath string) error {
168 cfg, err := workspace.LoadConfig(configPath)
169 if err != nil {
170 return exitWithCode(fmt.Errorf("loading workspace config: %w", err), 2)
171 }
172
173 format, _ := cmd.Flags().GetString("format")
174 if format == "json" {
175 out, _ := json.MarshalIndent(cfg, "", " ")
176 _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(out))
177 return nil
178 }
179
180 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Workspace: %s\n", cfg.Workspace.Name)
181 if cfg.Workspace.Description != "" {
182 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Description: %s\n\n", cfg.Workspace.Description)
183 } else {
184 _, _ = fmt.Fprint(cmd.OutOrStdout(), "\n")
185 }
186
187 _, _ = fmt.Fprint(cmd.OutOrStdout(), "Models:\n")
188 for i, ref := range cfg.Models {
189 _, _ = fmt.Fprintf(cmd.OutOrStdout(), " %d. ID: %s, Path: %s", i+1, ref.ID, ref.Path)
190 if ref.Prefix != "" {
191 _, _ = fmt.Fprintf(cmd.OutOrStdout(), ", Prefix: %s", ref.Prefix)
192 }
193 _, _ = fmt.Fprint(cmd.OutOrStdout(), "\n")
194 }
195
196 if len(cfg.CrossRels) > 0 {
197 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nCross-Model Relationships: %d\n", len(cfg.CrossRels))
198 }
199
200 if len(cfg.Views) > 0 {
201 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Workspace Views: %d\n", len(cfg.Views))
202 }
203
204 return nil
205 }
206
github.com/docToolchain/Bausteinsicht/internal/changelog/changelog.go 89.7%
1 package changelog
2
3 import (
4 "github.com/docToolchain/Bausteinsicht/internal/diff"
5 "github.com/docToolchain/Bausteinsicht/internal/model"
6 )
7
8 // Generate creates a changelog by comparing two model versions
9 func Generate(from, to *model.BausteinsichtModel, fromRef, toRef Reference) *Changelog {
10 // Convert models to snapshots for diff comparison
11 fromSnap := modelToSnapshot(from)
12 toSnap := modelToSnapshot(to)
13
14 // Use diff package to compute changes
15 result := diff.Compare(fromSnap, toSnap)
16
17 // Organize changes by type
18 return &Changelog{
19 From: fromRef,
20 To: toRef,
21 Elements: ElementChanges{
22 Added: filterElementsByType(result.Elements, diff.ChangeAdded),
23 Removed: filterElementsByType(result.Elements, diff.ChangeRemoved),
24 Changed: filterElementsByType(result.Elements, diff.ChangeChanged),
25 },
26 Relationships: RelationshipChanges{
27 Added: filterRelationshipsByType(result.Relationships, diff.ChangeAdded),
28 Removed: filterRelationshipsByType(result.Relationships, diff.ChangeRemoved),
29 },
30 }
31 }
32
33 // modelToSnapshot converts a BausteinsichtModel to a ModelSnapshot for diff computation
34 func modelToSnapshot(m *model.BausteinsichtModel) *model.ModelSnapshot {
35 if m == nil {
36 return &model.ModelSnapshot{
37 Elements: make(map[string]model.Element),
38 Relationships: []model.Relationship{},
39 }
40 }
41
42 // Flatten nested elements into a single-level map using dot notation
43 elements := flattenElements(m.Model, "")
44
45 return &model.ModelSnapshot{
46 Elements: elements,
47 Relationships: m.Relationships,
48 }
49 }
50
51 // flattenElements recursively flattens nested element maps into a single-level map
52 // with dot-separated keys (e.g., "system.backend.api")
53 func flattenElements(elems map[string]model.Element, prefix string) map[string]model.Element {
54 result := make(map[string]model.Element)
55
56 for key, elem := range elems {
57 fullKey := key
58 if prefix != "" {
59 fullKey = prefix + "." + key
60 }
61
62 // Add this element
63 result[fullKey] = elem
64
65 // Recursively flatten child elements if they exist
66 if len(elem.Children) > 0 {
67 children := flattenElements(elem.Children, fullKey)
68 for k, v := range children {
69 result[k] = v
70 }
71 }
72 }
73
74 return result
75 }
76
77 // filterElementsByType returns only elements with the specified change type
78 func filterElementsByType(changes []diff.ElementChange, changeType diff.ChangeType) []diff.ElementChange {
79 var result []diff.ElementChange
80 for _, c := range changes {
81 if c.Type == changeType {
82 result = append(result, c)
83 }
84 }
85 return result
86 }
87
88 // filterRelationshipsByType returns only relationships with the specified change type
89 func filterRelationshipsByType(changes []diff.RelationshipChange, changeType diff.ChangeType) []diff.RelationshipChange {
90 var result []diff.RelationshipChange
91 for _, c := range changes {
92 if c.Type == changeType {
93 result = append(result, c)
94 }
95 }
96 return result
97 }
98
github.com/docToolchain/Bausteinsicht/internal/changelog/git.go 0.0%
1 package changelog
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os/exec"
7 "strconv"
8 "strings"
9 "time"
10
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 )
13
14 // LoadModelAtGitRef loads a model from a specific git reference
15 func LoadModelAtGitRef(modelPath, gitRef string) (*model.BausteinsichtModel, error) {
16 // Resolve the git ref to its full commit hash
17 hash, _, err := resolveGitRef(gitRef)
18 if err != nil {
19 return nil, fmt.Errorf("resolving git ref %q: %w", gitRef, err)
20 }
21
22 // Load model from git
23 m, err := loadModelFromGit(modelPath, hash)
24 if err != nil {
25 return nil, fmt.Errorf("loading model from git ref %q: %w", gitRef, err)
26 }
27
28 // Store metadata for changelog generation
29 if m == nil {
30 m = &model.BausteinsichtModel{}
31 }
32
33 return m, nil
34 }
35
36 // resolveGitRef converts a git ref (tag, branch, commit) to its hash and timestamp
37 func resolveGitRef(gitRef string) (hash string, date time.Time, err error) {
38 // Get commit hash
39 hashCmd := exec.Command("git", "rev-parse", gitRef)
40 hashOut, err := hashCmd.Output()
41 if err != nil {
42 return "", time.Time{}, fmt.Errorf("failed to resolve git ref: %w", err)
43 }
44 hash = strings.TrimSpace(string(hashOut))
45
46 // Get commit date
47 dateCmd := exec.Command("git", "log", "-1", "--format=%ct", hash)
48 dateOut, err := dateCmd.Output()
49 if err != nil {
50 return "", time.Time{}, fmt.Errorf("failed to get commit date: %w", err)
51 }
52
53 timestamp, err := strconv.ParseInt(strings.TrimSpace(string(dateOut)), 10, 64)
54 if err != nil {
55 return "", time.Time{}, fmt.Errorf("failed to parse commit timestamp: %w", err)
56 }
57
58 date = time.Unix(timestamp, 0).UTC()
59 return hash, date, nil
60 }
61
62 // loadModelFromGit retrieves the model file from git at a specific commit
63 func loadModelFromGit(modelPath, commitHash string) (*model.BausteinsichtModel, error) {
64 cmd := exec.Command("git", "show", commitHash+":"+modelPath)
65 out, err := cmd.Output()
66 if err != nil {
67 return nil, fmt.Errorf("git show failed: %w", err)
68 }
69
70 // Strip JSONC comments and parse
71 clean := model.StripJSONC(out)
72 trimmed := strings.TrimSpace(string(clean))
73 if trimmed == "null" || trimmed == "" {
74 return nil, fmt.Errorf("model file is empty or null at %s in %s", modelPath, commitHash)
75 }
76
77 var m model.BausteinsichtModel
78 if err := json.Unmarshal(clean, &m); err != nil {
79 return nil, fmt.Errorf("parsing model: %w", err)
80 }
81
82 m.ElementOrder = extractElementOrder(clean)
83 return &m, nil
84 }
85
86 // extractElementOrder extracts the definition order of element kinds from specification
87 func extractElementOrder(data []byte) []string {
88 var raw map[string]json.RawMessage
89 if err := json.Unmarshal(data, &raw); err != nil {
90 return nil
91 }
92 specRaw, ok := raw["specification"]
93 if !ok {
94 return nil
95 }
96 var spec map[string]json.RawMessage
97 if err := json.Unmarshal(specRaw, &spec); err != nil {
98 return nil
99 }
100 elemsRaw, ok := spec["elements"]
101 if !ok {
102 return nil
103 }
104 var elems map[string]interface{}
105 d := json.NewDecoder(strings.NewReader(string(elemsRaw)))
106 d.UseNumber()
107 if err := d.Decode(&elems); err != nil {
108 return nil
109 }
110 var order []string
111 for k := range elems {
112 order = append(order, k)
113 }
114 return order
115 }
116
117 // GetCommitInfo retrieves metadata about a git commit
118 func GetCommitInfo(gitRef string) (*CommitInfo, error) {
119 hash, date, err := resolveGitRef(gitRef)
120 if err != nil {
121 return nil, err
122 }
123
124 // Get author
125 authorCmd := exec.Command("git", "log", "-1", "--format=%an", hash)
126 authorOut, err := authorCmd.Output()
127 if err != nil {
128 return nil, fmt.Errorf("failed to get commit author: %w", err)
129 }
130
131 // Get message
132 msgCmd := exec.Command("git", "log", "-1", "--format=%s", hash)
133 msgOut, err := msgCmd.Output()
134 if err != nil {
135 return nil, fmt.Errorf("failed to get commit message: %w", err)
136 }
137
138 return &CommitInfo{
139 Hash: hash,
140 Author: strings.TrimSpace(string(authorOut)),
141 Date: date,
142 Message: strings.TrimSpace(string(msgOut)),
143 Timestamp: date.Unix(),
144 }, nil
145 }
146
github.com/docToolchain/Bausteinsicht/internal/changelog/render.go 41.3%
1 package changelog
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderMarkdown renders the changelog as Markdown
12 func RenderMarkdown(cl *Changelog) string {
13 var sb strings.Builder
14
15 sb.WriteString("# Architecture Changelog\n\n")
16
17 // Title with date range
18 dateRange := fmt.Sprintf("%s → %s", cl.From.Ref, cl.To.Ref)
19 if !cl.From.Date.IsZero() && !cl.To.Date.IsZero() {
20 dateRange = fmt.Sprintf("%s → %s (%s → %s)",
21 cl.From.Ref, cl.To.Ref,
22 cl.From.Date.Format("2006-01-02"),
23 cl.To.Date.Format("2006-01-02"))
24 }
25 fmt.Fprintf(&sb, "## %s\n\n", dateRange)
26
27 // Added elements
28 if cl.Elements.CountAdded() > 0 {
29 fmt.Fprintf(&sb, "### Added (%d elements)\n", cl.Elements.CountAdded())
30 for _, change := range cl.Elements.Added {
31 if change.ToBe != nil {
32 desc := ""
33 if change.ToBe.Description != "" {
34 desc = fmt.Sprintf(" _{%s}_", change.ToBe.Description)
35 }
36 fmt.Fprintf(&sb, "- **%s** `[%s]` — %s%s\n",
37 change.ID, change.ToBe.Kind, change.ToBe.Title, desc)
38 }
39 }
40 sb.WriteString("\n")
41 }
42
43 // Removed elements
44 if cl.Elements.CountRemoved() > 0 {
45 fmt.Fprintf(&sb, "### Removed (%d elements)\n", cl.Elements.CountRemoved())
46 for _, change := range cl.Elements.Removed {
47 if change.AsIs != nil {
48 desc := ""
49 if change.AsIs.Description != "" {
50 desc = fmt.Sprintf(" _{%s}_", change.AsIs.Description)
51 }
52 fmt.Fprintf(&sb, "- ~~**%s**~~ `[%s]` — %s%s\n",
53 change.ID, change.AsIs.Kind, change.AsIs.Title, desc)
54 }
55 }
56 sb.WriteString("\n")
57 }
58
59 // Changed elements
60 if cl.Elements.CountChanged() > 0 {
61 fmt.Fprintf(&sb, "### Changed (%d elements)\n", cl.Elements.CountChanged())
62 for _, change := range cl.Elements.Changed {
63 if change.AsIs != nil && change.ToBe != nil {
64 fmt.Fprintf(&sb, "- **%s** — ", change.ID)
65 changes := renderElementChanges(*change.AsIs, *change.ToBe)
66 sb.WriteString(changes)
67 sb.WriteString("\n")
68 }
69 }
70 sb.WriteString("\n")
71 }
72
73 // Added relationships
74 if cl.Relationships.CountAddedRelationships() > 0 {
75 fmt.Fprintf(&sb, "### New Relationships (%d)\n", cl.Relationships.CountAddedRelationships())
76 for _, change := range cl.Relationships.Added {
77 label := ""
78 if change.ToBe != nil && change.ToBe.Label != "" {
79 label = fmt.Sprintf(" (%s)", change.ToBe.Label)
80 }
81 fmt.Fprintf(&sb, "- %s → %s%s\n", change.From, change.To, label)
82 }
83 sb.WriteString("\n")
84 }
85
86 // Removed relationships
87 if cl.Relationships.CountRemovedRelationships() > 0 {
88 fmt.Fprintf(&sb, "### Removed Relationships (%d)\n", cl.Relationships.CountRemovedRelationships())
89 for _, change := range cl.Relationships.Removed {
90 label := ""
91 if change.AsIs != nil && change.AsIs.Label != "" {
92 label = fmt.Sprintf(" (%s)", change.AsIs.Label)
93 }
94 fmt.Fprintf(&sb, "- ~~%s → %s~~%s\n", change.From, change.To, label)
95 }
96 sb.WriteString("\n")
97 }
98
99 if cl.Elements.CountAdded() == 0 && cl.Elements.CountRemoved() == 0 &&
100 cl.Elements.CountChanged() == 0 && cl.Relationships.CountAddedRelationships() == 0 &&
101 cl.Relationships.CountRemovedRelationships() == 0 {
102 sb.WriteString("No architectural changes detected.\n")
103 }
104
105 return sb.String()
106 }
107
108 // RenderAsciiDoc renders the changelog as AsciiDoc
109 func RenderAsciiDoc(cl *Changelog) string {
110 var sb strings.Builder
111
112 sb.WriteString("= Architecture Changelog\n\n")
113
114 // Title with date range
115 dateRange := fmt.Sprintf("%s → %s", cl.From.Ref, cl.To.Ref)
116 if !cl.From.Date.IsZero() && !cl.To.Date.IsZero() {
117 dateRange = fmt.Sprintf("%s → %s (%s → %s)",
118 cl.From.Ref, cl.To.Ref,
119 cl.From.Date.Format("2006-01-02"),
120 cl.To.Date.Format("2006-01-02"))
121 }
122 fmt.Fprintf(&sb, "== %s\n\n", dateRange)
123
124 // Added elements
125 if cl.Elements.CountAdded() > 0 {
126 fmt.Fprintf(&sb, "=== Added (%d elements)\n", cl.Elements.CountAdded())
127 for _, change := range cl.Elements.Added {
128 if change.ToBe != nil {
129 desc := ""
130 if change.ToBe.Description != "" {
131 desc = fmt.Sprintf(": %s", change.ToBe.Description)
132 }
133 fmt.Fprintf(&sb, "* *%s* `[%s]` – %s%s\n",
134 change.ID, change.ToBe.Kind, change.ToBe.Title, desc)
135 }
136 }
137 sb.WriteString("\n")
138 }
139
140 // Removed elements
141 if cl.Elements.CountRemoved() > 0 {
142 fmt.Fprintf(&sb, "=== Removed (%d elements)\n", cl.Elements.CountRemoved())
143 for _, change := range cl.Elements.Removed {
144 if change.AsIs != nil {
145 desc := ""
146 if change.AsIs.Description != "" {
147 desc = fmt.Sprintf(": %s", change.AsIs.Description)
148 }
149 fmt.Fprintf(&sb, "* [line-through]#*%s* `[%s]` – %s#%s\n",
150 change.ID, change.AsIs.Kind, change.AsIs.Title, desc)
151 }
152 }
153 sb.WriteString("\n")
154 }
155
156 // Changed elements
157 if cl.Elements.CountChanged() > 0 {
158 fmt.Fprintf(&sb, "=== Changed (%d elements)\n", cl.Elements.CountChanged())
159 for _, change := range cl.Elements.Changed {
160 if change.AsIs != nil && change.ToBe != nil {
161 fmt.Fprintf(&sb, "* *%s* – ", change.ID)
162 changes := renderElementChanges(*change.AsIs, *change.ToBe)
163 sb.WriteString(changes)
164 sb.WriteString("\n")
165 }
166 }
167 sb.WriteString("\n")
168 }
169
170 // Added relationships
171 if cl.Relationships.CountAddedRelationships() > 0 {
172 fmt.Fprintf(&sb, "=== New Relationships (%d)\n", cl.Relationships.CountAddedRelationships())
173 for _, change := range cl.Relationships.Added {
174 label := ""
175 if change.ToBe != nil && change.ToBe.Label != "" {
176 label = fmt.Sprintf(" (%s)", change.ToBe.Label)
177 }
178 fmt.Fprintf(&sb, "* %s → %s%s\n", change.From, change.To, label)
179 }
180 sb.WriteString("\n")
181 }
182
183 // Removed relationships
184 if cl.Relationships.CountRemovedRelationships() > 0 {
185 fmt.Fprintf(&sb, "=== Removed Relationships (%d)\n", cl.Relationships.CountRemovedRelationships())
186 for _, change := range cl.Relationships.Removed {
187 label := ""
188 if change.AsIs != nil && change.AsIs.Label != "" {
189 label = fmt.Sprintf(" (%s)", change.AsIs.Label)
190 }
191 fmt.Fprintf(&sb, "* [line-through]#%s → %s#%s\n", change.From, change.To, label)
192 }
193 sb.WriteString("\n")
194 }
195
196 if cl.Elements.CountAdded() == 0 && cl.Elements.CountRemoved() == 0 &&
197 cl.Elements.CountChanged() == 0 && cl.Relationships.CountAddedRelationships() == 0 &&
198 cl.Relationships.CountRemovedRelationships() == 0 {
199 sb.WriteString("No architectural changes detected.\n")
200 }
201
202 return sb.String()
203 }
204
205 // RenderJSON renders the changelog as JSON
206 func RenderJSON(cl *Changelog) (string, error) {
207 data, err := json.MarshalIndent(cl, "", " ")
208 if err != nil {
209 return "", err
210 }
211 return string(data), nil
212 }
213
214 // renderElementChanges formats what changed in an element
215 func renderElementChanges(asIs, toBe model.Element) string {
216 var sb strings.Builder
217
218 if asIs.Title != toBe.Title {
219 fmt.Fprintf(&sb, "title: \"%s\" → \"%s\"; ", asIs.Title, toBe.Title)
220 }
221 if asIs.Kind != toBe.Kind {
222 fmt.Fprintf(&sb, "kind: \"%s\" → \"%s\"; ", asIs.Kind, toBe.Kind)
223 }
224 if asIs.Technology != toBe.Technology {
225 fmt.Fprintf(&sb, "technology: \"%s\" → \"%s\"; ", asIs.Technology, toBe.Technology)
226 }
227 if asIs.Description != toBe.Description {
228 sb.WriteString("description: changed; ")
229 }
230 if asIs.Status != toBe.Status {
231 fmt.Fprintf(&sb, "status: \"%s\" → \"%s\"; ", asIs.Status, toBe.Status)
232 }
233
234 result := sb.String()
235 return strings.TrimSuffix(result, "; ")
236 }
237
github.com/docToolchain/Bausteinsicht/internal/changelog/types.go 87.5%
1 package changelog
2
3 import (
4 "time"
5
6 "github.com/docToolchain/Bausteinsicht/internal/diff"
7 )
8
9 // Reference represents a git ref or snapshot identifier
10 type Reference struct {
11 Ref string `json:"ref"` // git tag/commit SHA or snapshot ID
12 Date time.Time `json:"date"` // date of the reference
13 }
14
15 // Changelog describes changes between two architecture snapshots
16 type Changelog struct {
17 From Reference `json:"from"`
18 To Reference `json:"to"`
19 Elements ElementChanges `json:"elements"`
20 Relationships RelationshipChanges `json:"relationships"`
21 }
22
23 // ElementChanges groups element changes by type
24 type ElementChanges struct {
25 Added []diff.ElementChange `json:"added"`
26 Removed []diff.ElementChange `json:"removed"`
27 Changed []diff.ElementChange `json:"changed"`
28 }
29
30 // RelationshipChanges groups relationship changes by type
31 type RelationshipChanges struct {
32 Added []diff.RelationshipChange `json:"added"`
33 Removed []diff.RelationshipChange `json:"removed"`
34 }
35
36 // CommitInfo retrieves commit metadata for a git ref
37 type CommitInfo struct {
38 Hash string `json:"hash"`
39 Author string `json:"author"`
40 Date time.Time `json:"date"`
41 Message string `json:"message"`
42 Timestamp int64 `json:"timestamp"`
43 }
44
45 // FilterChangesByKind returns only changes of a specific element kind
46 func (ec ElementChanges) FilterByKind(kind string) ElementChanges {
47 return ElementChanges{
48 Added: filterElementsByKind(ec.Added, kind),
49 Removed: filterElementsByKind(ec.Removed, kind),
50 Changed: filterElementsByKind(ec.Changed, kind),
51 }
52 }
53
54 func filterElementsByKind(changes []diff.ElementChange, kind string) []diff.ElementChange {
55 var result []diff.ElementChange
56 for _, c := range changes {
57 var k string
58 if c.ToBe != nil {
59 k = c.ToBe.Kind
60 } else if c.AsIs != nil {
61 k = c.AsIs.Kind
62 }
63 if k == kind {
64 result = append(result, c)
65 }
66 }
67 return result
68 }
69
70 // CountAdded returns the number of added elements
71 func (ec ElementChanges) CountAdded() int {
72 return len(ec.Added)
73 }
74
75 // CountRemoved returns the number of removed elements
76 func (ec ElementChanges) CountRemoved() int {
77 return len(ec.Removed)
78 }
79
80 // CountChanged returns the number of changed elements
81 func (ec ElementChanges) CountChanged() int {
82 return len(ec.Changed)
83 }
84
85 // CountAddedRelationships returns the number of added relationships
86 func (rc RelationshipChanges) CountAddedRelationships() int {
87 return len(rc.Added)
88 }
89
90 // CountRemovedRelationships returns the number of removed relationships
91 func (rc RelationshipChanges) CountRemovedRelationships() int {
92 return len(rc.Removed)
93 }
94
github.com/docToolchain/Bausteinsicht/internal/chaos/chaos.go 0.0%
1 package chaos
2
3 import (
4 "os"
5 "path/filepath"
6 "testing"
7 )
8
9 type TestChaos struct {
10 t *testing.T
11 tmpDir string
12 }
13
14 // NewTestChaos creates a chaos injection helper for tests.
15 func NewTestChaos(t *testing.T) *TestChaos {
16 tmpDir := t.TempDir()
17 return &TestChaos{
18 t: t,
19 tmpDir: tmpDir,
20 }
21 }
22
23 // TmpDir returns the temporary directory for this test.
24 func (tc *TestChaos) TmpDir() string {
25 return tc.tmpDir
26 }
27
28 // CorruptFile truncates a file (simulating partial write).
29 func (tc *TestChaos) CorruptFile(path string) {
30 f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
31 if err != nil {
32 tc.t.Fatalf("CorruptFile: %v", err)
33 }
34 defer f.Close() //nolint:errcheck
35 if _, err := f.WriteString(""); err != nil {
36 tc.t.Fatalf("CorruptFile truncate: %v", err)
37 }
38 }
39
40 // CorruptFilePartial truncates file to partial content.
41 func (tc *TestChaos) CorruptFilePartial(path string, content string) {
42 f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
43 if err != nil {
44 tc.t.Fatalf("CorruptFilePartial: %v", err)
45 }
46 defer f.Close() //nolint:errcheck
47 if _, err := f.WriteString(content); err != nil {
48 tc.t.Fatalf("CorruptFilePartial write: %v", err)
49 }
50 }
51
52 // MakeReadOnly sets file permissions to read-only.
53 func (tc *TestChaos) MakeReadOnly(path string) {
54 if err := os.Chmod(path, 0444); err != nil {
55 tc.t.Fatalf("MakeReadOnly: %v", err)
56 }
57 }
58
59 // MakeWritable sets file permissions to writable.
60 func (tc *TestChaos) MakeWritable(path string) {
61 if err := os.Chmod(path, 0644); err != nil {
62 tc.t.Fatalf("MakeWritable: %v", err)
63 }
64 }
65
66 // DeleteFile removes a file.
67 func (tc *TestChaos) DeleteFile(path string) {
68 if err := os.Remove(path); err != nil {
69 tc.t.Fatalf("DeleteFile: %v", err)
70 }
71 }
72
73 // CreateEmptyFile creates an empty file at path.
74 func (tc *TestChaos) CreateEmptyFile(path string) string {
75 absPath := filepath.Join(tc.tmpDir, path)
76 if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
77 tc.t.Fatalf("CreateEmptyFile mkdir: %v", err)
78 }
79 f, err := os.Create(absPath)
80 if err != nil {
81 tc.t.Fatalf("CreateEmptyFile: %v", err)
82 }
83 defer f.Close() //nolint:errcheck
84 return absPath
85 }
86
87 // CreateFileWithContent creates a file with specific content.
88 func (tc *TestChaos) CreateFileWithContent(path string, content string) string {
89 absPath := filepath.Join(tc.tmpDir, path)
90 if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
91 tc.t.Fatalf("CreateFileWithContent mkdir: %v", err)
92 }
93 if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
94 tc.t.Fatalf("CreateFileWithContent: %v", err)
95 }
96 return absPath
97 }
98
99 // FileExists checks if a file exists.
100 func (tc *TestChaos) FileExists(path string) bool {
101 _, err := os.Stat(path)
102 return err == nil
103 }
104
105 // ReadFile reads a file's content.
106 func (tc *TestChaos) ReadFile(path string) string {
107 content, err := os.ReadFile(path)
108 if err != nil {
109 tc.t.Fatalf("ReadFile: %v", err)
110 }
111 return string(content)
112 }
113
github.com/docToolchain/Bausteinsicht/internal/constraints/engine.go 100.0%
1 package constraints
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // ErrUnknownRule is returned when a constraint specifies an unsupported rule type.
10 type ErrUnknownRule struct {
11 Rule string
12 }
13
14 func (e ErrUnknownRule) Error() string {
15 return fmt.Sprintf("unknown constraint rule %q", e.Rule)
16 }
17
18 // Evaluate runs all constraints against the model and returns the aggregated result.
19 // Unknown rule types are reported as violations so they don't silently pass.
20 func Evaluate(m *model.BausteinsichtModel) Result {
21 var all []Violation
22 for _, c := range m.Constraints {
23 vs, err := evaluate(c, m)
24 if err != nil {
25 all = append(all, Violation{
26 ConstraintID: c.ID,
27 Message: err.Error(),
28 })
29 continue
30 }
31 all = append(all, vs...)
32 }
33 return Result{Violations: all, Total: len(all)}
34 }
35
36 func evaluate(c model.Constraint, m *model.BausteinsichtModel) ([]Violation, error) {
37 switch c.Rule {
38 case "no-relationship":
39 return noRelationship(c, m), nil
40 case "allowed-relationship":
41 return allowedRelationship(c, m), nil
42 case "required-field":
43 return requiredField(c, m), nil
44 case "max-depth":
45 return maxDepth(c, m), nil
46 case "no-circular-dependency":
47 return noCircularDependency(c, m), nil
48 case "technology-allowed":
49 return technologyAllowed(c, m), nil
50 default:
51 return nil, ErrUnknownRule{Rule: c.Rule}
52 }
53 }
54
github.com/docToolchain/Bausteinsicht/internal/constraints/rules.go 92.5%
1 package constraints
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // noRelationship enforces that no relationship exists from any element of
11 // fromKind to any element of toKind.
12 func noRelationship(c model.Constraint, m *model.BausteinsichtModel) []Violation {
13 flat, err := model.FlattenElements(m)
14 if err != nil {
15 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
16 }
17 kindOf := buildKindMap(flat)
18
19 var bad []string
20 for _, rel := range m.Relationships {
21 if kindOf[rel.From] == c.FromKind && kindOf[rel.To] == c.ToKind {
22 bad = append(bad, fmt.Sprintf("%s → %s", rel.From, rel.To))
23 }
24 }
25 if len(bad) == 0 {
26 return nil
27 }
28 return []Violation{{
29 ConstraintID: c.ID,
30 Message: fmt.Sprintf("%s: %s kind must not relate to %s kind", c.Description, c.FromKind, c.ToKind),
31 Elements: bad,
32 }}
33 }
34
35 // allowedRelationship enforces that only elements whose kind is in fromKinds
36 // may have relationships pointing to elements of toKind.
37 func allowedRelationship(c model.Constraint, m *model.BausteinsichtModel) []Violation {
38 flat, err := model.FlattenElements(m)
39 if err != nil {
40 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
41 }
42 kindOf := buildKindMap(flat)
43
44 allowed := make(map[string]bool, len(c.FromKinds))
45 for _, k := range c.FromKinds {
46 allowed[k] = true
47 }
48
49 var bad []string
50 for _, rel := range m.Relationships {
51 if kindOf[rel.To] == c.ToKind && !allowed[kindOf[rel.From]] {
52 bad = append(bad, fmt.Sprintf("%s (%s) → %s", rel.From, kindOf[rel.From], rel.To))
53 }
54 }
55 if len(bad) == 0 {
56 return nil
57 }
58 return []Violation{{
59 ConstraintID: c.ID,
60 Message: fmt.Sprintf("%s: only [%s] may relate to %s kind", c.Description, strings.Join(c.FromKinds, ", "), c.ToKind),
61 Elements: bad,
62 }}
63 }
64
65 // requiredField enforces that all elements of elementKind have the given field
66 // set to a non-empty value. Supported fields: "description", "technology", "title".
67 func requiredField(c model.Constraint, m *model.BausteinsichtModel) []Violation {
68 flat, err := model.FlattenElements(m)
69 if err != nil {
70 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
71 }
72
73 var bad []string
74 for id, el := range flat {
75 if el.Kind != c.ElementKind {
76 continue
77 }
78 var missing bool
79 switch c.Field {
80 case "description":
81 missing = el.Description == ""
82 case "technology":
83 missing = el.Technology == ""
84 case "title":
85 missing = el.Title == ""
86 default:
87 // Unsupported field name — return error violation immediately
88 return []Violation{{
89 ConstraintID: c.ID,
90 Message: fmt.Sprintf("%s: unsupported field %q (valid: description, technology, title)", c.Description, c.Field),
91 }}
92 }
93 if missing {
94 bad = append(bad, fmt.Sprintf("%s: missing %s", id, c.Field))
95 }
96 }
97 if len(bad) == 0 {
98 return nil
99 }
100 return []Violation{{
101 ConstraintID: c.ID,
102 Message: fmt.Sprintf("%s: all %s elements must have %q set", c.Description, c.ElementKind, c.Field),
103 Elements: bad,
104 }}
105 }
106
107 // maxDepth enforces that no element is nested deeper than max levels.
108 // Root-level elements have depth 1.
109 func maxDepth(c model.Constraint, m *model.BausteinsichtModel) []Violation {
110 var bad []string
111 walkDepth(m.Model, 1, c.Max, &bad)
112 if len(bad) == 0 {
113 return nil
114 }
115 return []Violation{{
116 ConstraintID: c.ID,
117 Message: fmt.Sprintf("%s: maximum nesting depth is %d", c.Description, c.Max),
118 Elements: bad,
119 }}
120 }
121
122 func walkDepth(elements map[string]model.Element, depth, max int, bad *[]string) {
123 for id, el := range elements {
124 if depth > max {
125 *bad = append(*bad, fmt.Sprintf("%s (depth %d)", id, depth))
126 }
127 if len(el.Children) > 0 {
128 walkDepth(el.Children, depth+1, max, bad)
129 }
130 }
131 }
132
133 // noCircularDependency detects cycles in the relationship graph using DFS.
134 func noCircularDependency(c model.Constraint, m *model.BausteinsichtModel) []Violation {
135 // Build adjacency list.
136 adj := make(map[string][]string)
137 flat, err := model.FlattenElements(m)
138 if err != nil {
139 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
140 }
141 for id := range flat {
142 adj[id] = nil
143 }
144 for _, rel := range m.Relationships {
145 adj[rel.From] = append(adj[rel.From], rel.To)
146 }
147
148 visited := make(map[string]bool)
149 inStack := make(map[string]bool)
150 var cycles []string
151
152 var dfs func(node string, path []string)
153 dfs = func(node string, path []string) {
154 visited[node] = true
155 inStack[node] = true
156 path = append(path, node)
157
158 for _, neighbour := range adj[node] {
159 if !visited[neighbour] {
160 dfs(neighbour, path)
161 } else if inStack[neighbour] {
162 // Found a cycle — record the loop segment.
163 for i, n := range path {
164 if n == neighbour {
165 cycle := strings.Join(append(path[i:], neighbour), " → ")
166 cycles = append(cycles, cycle)
167 break
168 }
169 }
170 }
171 }
172 inStack[node] = false
173 }
174
175 for node := range adj {
176 if !visited[node] {
177 dfs(node, nil)
178 }
179 }
180
181 if len(cycles) == 0 {
182 return nil
183 }
184 return []Violation{{
185 ConstraintID: c.ID,
186 Message: c.Description + ": circular dependencies detected",
187 Elements: cycles,
188 }}
189 }
190
191 // technologyAllowed enforces that elements of elementKind only use technologies
192 // from the given allowed list.
193 func technologyAllowed(c model.Constraint, m *model.BausteinsichtModel) []Violation {
194 flat, err := model.FlattenElements(m)
195 if err != nil {
196 return []Violation{{ConstraintID: c.ID, Message: err.Error()}}
197 }
198 allowed := make(map[string]bool, len(c.Technologies))
199 for _, t := range c.Technologies {
200 allowed[strings.ToLower(t)] = true
201 }
202
203 var bad []string
204 for id, el := range flat {
205 if el.Kind != c.ElementKind {
206 continue
207 }
208 if el.Technology == "" {
209 continue // technology not set — use required-field rule to enforce that separately
210 }
211 if !allowed[strings.ToLower(el.Technology)] {
212 bad = append(bad, fmt.Sprintf("%s: technology %q not in allowed list [%s]",
213 id, el.Technology, strings.Join(c.Technologies, ", ")))
214 }
215 }
216 if len(bad) == 0 {
217 return nil
218 }
219 return []Violation{{
220 ConstraintID: c.ID,
221 Message: fmt.Sprintf("%s: %s elements must use one of [%s]", c.Description, c.ElementKind, strings.Join(c.Technologies, ", ")),
222 Elements: bad,
223 }}
224 }
225
226 // buildKindMap returns a map from element ID to its kind for all flattened elements.
227 func buildKindMap(flat map[string]*model.Element) map[string]string {
228 m := make(map[string]string, len(flat))
229 for id, el := range flat {
230 m[id] = el.Kind
231 }
232 return m
233 }
234
github.com/docToolchain/Bausteinsicht/internal/diagram/colors.go 100.0%
1 package diagram
2
3 // KindStyle defines fill and stroke colors for element kinds.
4 type KindStyle struct {
5 Fill string
6 Stroke string
7 }
8
9 // DefaultKindColors maps element kinds to consistent visual styles.
10 // Used by all diagram renderers (PlantUML, Mermaid, DOT, D2, HTML5).
11 var DefaultKindColors = map[string]KindStyle{
12 "actor": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
13 "person": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
14 "system": {Fill: "#f5f5f5", Stroke: "#666666"},
15 "external_system": {Fill: "#e1d5e7", Stroke: "#9673a6"},
16 "container": {Fill: "#d5e8d4", Stroke: "#82b366"},
17 "ui": {Fill: "#d5e8d4", Stroke: "#82b366"},
18 "mobile": {Fill: "#d5e8d4", Stroke: "#82b366"},
19 "datastore": {Fill: "#fff2cc", Stroke: "#d6b656"},
20 "database": {Fill: "#fff2cc", Stroke: "#d6b656"},
21 "queue": {Fill: "#ffe6cc", Stroke: "#d5a74e"},
22 "filestore": {Fill: "#fff2cc", Stroke: "#d6b656"},
23 "component": {Fill: "#d5e8d4", Stroke: "#82b366"},
24 }
25
26 // ColorForKind returns the style for a given element kind.
27 // Falls back to a default gray color if the kind is not defined.
28 func ColorForKind(kind string) KindStyle {
29 if style, ok := DefaultKindColors[kind]; ok {
30 return style
31 }
32 return KindStyle{Fill: "#f5f5f5", Stroke: "#666666"}
33 }
34
github.com/docToolchain/Bausteinsicht/internal/diagram/d2.go 89.1%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderD2 renders a view as a D2 diagram.
12 func RenderD2(m *model.BausteinsichtModel, viewKey string) (string, error) {
13 view, ok := m.Views[viewKey]
14 if !ok {
15 return "", fmt.Errorf("view %q not found", viewKey)
16 }
17
18 resolved, err := model.ResolveView(m, &view)
19 if err != nil {
20 return "", err
21 }
22
23 flat, _ := model.FlattenElements(m)
24 sort.Strings(resolved)
25
26 // Filter elements visible in this view
27 elemSet := make(map[string]bool, len(resolved))
28 for _, id := range resolved {
29 elemSet[id] = true
30 }
31 if view.Scope != "" {
32 elemSet[view.Scope] = true
33 }
34
35 // Filter relationships
36 rels := filterRelationships(m.Relationships, elemSet)
37
38 var b strings.Builder
39 b.WriteString("direction: right\n\n")
40
41 // Write nodes
42 for _, id := range resolved {
43 elem := flat[id]
44 if elem == nil {
45 continue
46 }
47
48 style := ColorForKind(elem.Kind)
49 nodeID := sanitizeD2ID(id)
50
51 title := elem.Title
52 if title == "" {
53 title = id
54 }
55
56 // Node with styling
57 nodeLabel := escapeD2String(title)
58 if elem.Kind != "" {
59 nodeLabel = fmt.Sprintf("%s [%s]", escapeD2String(title), elem.Kind)
60 }
61 fmt.Fprintf(&b, "%s: %s {\n", nodeID, nodeLabel)
62 fmt.Fprintf(&b, " shape: rectangle\n")
63 fmt.Fprintf(&b, " style.fill: \"%s\"\n", style.Fill)
64 fmt.Fprintf(&b, " style.stroke: \"%s\"\n", style.Stroke)
65 if elem.Description != "" {
66 fmt.Fprintf(&b, " note: %s\n", escapeD2String(elem.Description))
67 }
68 b.WriteString("}\n\n")
69 }
70
71 // Write relationships
72 for _, r := range rels {
73 fromID := sanitizeD2ID(r.From)
74 toID := sanitizeD2ID(r.To)
75 if r.Label != "" {
76 fmt.Fprintf(&b, "%s -> %s: %s\n", fromID, toID, escapeD2String(r.Label))
77 } else {
78 fmt.Fprintf(&b, "%s -> %s\n", fromID, toID)
79 }
80 }
81
82 return b.String(), nil
83 }
84
85 // sanitizeD2ID converts a dot-notation ID to a valid D2 identifier.
86 func sanitizeD2ID(id string) string {
87 s := strings.ReplaceAll(id, ".", "_")
88 s = strings.ReplaceAll(s, "-", "_")
89 return s
90 }
91
92 // escapeD2String escapes a string for use in D2 string literals.
93 func escapeD2String(s string) string {
94 return "\"" + strings.ReplaceAll(s, "\"", "\\\"") + "\""
95 }
96
github.com/docToolchain/Bausteinsicht/internal/diagram/diagram.go 90.7%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // Format represents the output diagram format.
12 type Format int
13
14 const (
15 PlantUML Format = iota
16 Mermaid
17 )
18
19 // C4-PlantUML is part of the PlantUML stdlib since v2.x, so we use
20 // the <C4/...> include syntax which resolves locally without network access.
21
22 // applyTagFiltering filters resolved element IDs based on tag criteria.
23 // Elements must have ALL filterTags (intersection) and must not have ANY excludeTags (union).
24 func applyTagFiltering(resolved []string, flat map[string]*model.Element, filterTags, excludeTags []string) []string {
25 if len(filterTags) == 0 && len(excludeTags) == 0 {
26 return resolved
27 }
28
29 var result []string
30 for _, id := range resolved {
31 elem := flat[id]
32 if elem == nil {
33 // Element not found in flat map, skip it (shouldn't happen)
34 continue
35 }
36
37 // Check exclude tags: if ANY exclude-tag matches, skip
38 excluded := false
39 for _, excludeTag := range excludeTags {
40 for _, elemTag := range elem.Tags {
41 if elemTag == excludeTag {
42 excluded = true
43 break
44 }
45 }
46 if excluded {
47 break
48 }
49 }
50 if excluded {
51 continue
52 }
53
54 // Check filter tags: if ANY filter-tags are specified, element must have ALL of them
55 if len(filterTags) > 0 {
56 hasAllFilterTags := true
57 for _, filterTag := range filterTags {
58 found := false
59 for _, elemTag := range elem.Tags {
60 if elemTag == filterTag {
61 found = true
62 break
63 }
64 }
65 if !found {
66 hasAllFilterTags = false
67 break
68 }
69 }
70 if !hasAllFilterTags {
71 continue
72 }
73 }
74
75 result = append(result, id)
76 }
77
78 return result
79 }
80
81 // FormatView renders a view as a C4 diagram in the given format.
82 func FormatView(m *model.BausteinsichtModel, viewKey string, f Format) (string, error) {
83 view, ok := m.Views[viewKey]
84 if !ok {
85 return "", fmt.Errorf("view %q not found", viewKey)
86 }
87
88 resolved, err := model.ResolveView(m, &view)
89 if err != nil {
90 return "", err
91 }
92
93 flat, _ := model.FlattenElements(m)
94
95 // Apply tag-based filtering if specified in the view.
96 resolved = applyTagFiltering(resolved, flat, view.FilterTags, view.ExcludeTags)
97 sort.Strings(resolved)
98
99 // Determine C4 level from view content.
100 level := detectLevel(resolved, flat, view.Scope)
101
102 // Separate scope-internal elements from external ones.
103 scopeElems, externalElems := partitionElements(resolved, flat, view.Scope)
104
105 // Filter relationships to those visible in this view.
106 elemSet := make(map[string]bool, len(resolved))
107 for _, id := range resolved {
108 elemSet[id] = true
109 }
110 if view.Scope != "" {
111 elemSet[view.Scope] = true
112 }
113 rels := filterRelationships(m.Relationships, elemSet)
114
115 var b strings.Builder
116 switch f {
117 case PlantUML:
118 writePlantUML(&b, view, level, scopeElems, externalElems, rels, flat)
119 case Mermaid:
120 writeMermaid(&b, view, level, scopeElems, externalElems, rels, flat)
121 }
122 return b.String(), nil
123 }
124
125 type elemEntry struct {
126 ID string
127 Elem *model.Element
128 }
129
130 func detectLevel(resolved []string, flat map[string]*model.Element, scope string) string {
131 hasContainer := false
132 for _, id := range resolved {
133 elem := flat[id]
134 if elem == nil {
135 continue
136 }
137 if elem.Kind == "component" {
138 return "Component"
139 }
140 if elem.Kind == "container" {
141 hasContainer = true
142 }
143 }
144 if hasContainer || scope != "" {
145 return "Container"
146 }
147 return "Context"
148 }
149
150 func partitionElements(resolved []string, flat map[string]*model.Element, scope string) (inside, outside []elemEntry) {
151 for _, id := range resolved {
152 elem := flat[id]
153 if elem == nil {
154 continue
155 }
156 if scope != "" && strings.HasPrefix(id, scope+".") {
157 inside = append(inside, elemEntry{id, elem})
158 } else {
159 outside = append(outside, elemEntry{id, elem})
160 }
161 }
162 return
163 }
164
165 type relEntry struct {
166 From string `json:"from"`
167 To string `json:"to"`
168 Label string `json:"label,omitempty"`
169 }
170
171 func filterRelationships(rels []model.Relationship, elemSet map[string]bool) []relEntry {
172 var result []relEntry
173 seen := make(map[string]bool)
174 for _, r := range rels {
175 from := liftToVisible(r.From, elemSet)
176 to := liftToVisible(r.To, elemSet)
177 if from == "" || to == "" || from == to {
178 continue
179 }
180 key := from + ":" + to
181 if seen[key] {
182 continue
183 }
184 seen[key] = true
185 result = append(result, relEntry{from, to, r.Label})
186 }
187 return result
188 }
189
190 func liftToVisible(id string, elemSet map[string]bool) string {
191 if elemSet[id] {
192 return id
193 }
194 for {
195 dot := strings.LastIndex(id, ".")
196 if dot < 0 {
197 return ""
198 }
199 id = id[:dot]
200 if elemSet[id] {
201 return id
202 }
203 }
204 }
205
206 func c4Macro(kind string) string {
207 switch kind {
208 case "actor":
209 return "Person"
210 case "system":
211 return "System"
212 case "external_system":
213 return "System_Ext"
214 case "container", "ui", "mobile":
215 return "Container"
216 case "datastore":
217 return "ContainerDb"
218 case "queue":
219 return "ContainerQueue"
220 case "filestore":
221 return "Container"
222 case "component":
223 return "Component"
224 default:
225 return "System"
226 }
227 }
228
229 func sanitizeID(id string) string {
230 return strings.ReplaceAll(strings.ReplaceAll(id, ".", "_"), "-", "_")
231 }
232
233 func escapeQuotes(s string) string {
234 return strings.ReplaceAll(s, "\"", "'")
235 }
236
237 // --- PlantUML ---
238
239 func writePlantUML(b *strings.Builder, view model.View, level string, inside, outside []elemEntry, rels []relEntry, flat map[string]*model.Element) {
240 b.WriteString("@startuml\n")
241 fmt.Fprintf(b, "!include <C4/C4_%s>\n\n", level)
242
243 // External elements (outside scope boundary).
244 for _, e := range outside {
245 writePlantUMLElement(b, e, "")
246 }
247
248 // Scope boundary with internal elements.
249 if view.Scope != "" {
250 scopeElem := flat[view.Scope]
251 scopeTitle := view.Scope
252 if scopeElem != nil {
253 scopeTitle = scopeElem.Title
254 }
255 boundaryMacro := "System_Boundary"
256 if scopeElem != nil && scopeElem.Kind == "container" {
257 boundaryMacro = "Container_Boundary"
258 }
259 fmt.Fprintf(b, "%s(%s, \"%s\") {\n", boundaryMacro, sanitizeID(view.Scope), escapeQuotes(scopeTitle))
260 for _, e := range inside {
261 writePlantUMLElement(b, e, " ")
262 }
263 b.WriteString("}\n")
264 } else {
265 for _, e := range inside {
266 writePlantUMLElement(b, e, "")
267 }
268 }
269
270 // Relationships.
271 if len(rels) > 0 {
272 b.WriteString("\n")
273 }
274 for _, r := range rels {
275 fmt.Fprintf(b, "Rel(%s, %s, \"%s\")\n", sanitizeID(r.From), sanitizeID(r.To), escapeQuotes(r.Label))
276 }
277
278 b.WriteString("@enduml\n")
279 }
280
281 func writePlantUMLElement(b *strings.Builder, e elemEntry, indent string) {
282 macro := c4Macro(e.Elem.Kind)
283 if e.Elem.Technology != "" {
284 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\", \"%s\")\n",
285 indent, macro, sanitizeID(e.ID),
286 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Technology), escapeQuotes(e.Elem.Description))
287 } else {
288 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\")\n",
289 indent, macro, sanitizeID(e.ID),
290 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Description))
291 }
292 }
293
294 // --- Mermaid ---
295
296 func writeMermaid(b *strings.Builder, view model.View, level string, inside, outside []elemEntry, rels []relEntry, flat map[string]*model.Element) {
297 fmt.Fprintf(b, "C4%s\n", level)
298 fmt.Fprintf(b, " title %s\n\n", view.Title)
299
300 for _, e := range outside {
301 writeMermaidElement(b, e, " ")
302 }
303
304 if view.Scope != "" {
305 scopeElem := flat[view.Scope]
306 scopeTitle := view.Scope
307 if scopeElem != nil {
308 scopeTitle = scopeElem.Title
309 }
310 boundaryMacro := "System_Boundary"
311 if scopeElem != nil && scopeElem.Kind == "container" {
312 boundaryMacro = "Container_Boundary"
313 }
314 fmt.Fprintf(b, " %s(%s, \"%s\") {\n", boundaryMacro, sanitizeID(view.Scope), escapeQuotes(scopeTitle))
315 for _, e := range inside {
316 writeMermaidElement(b, e, " ")
317 }
318 b.WriteString(" }\n")
319 } else {
320 for _, e := range inside {
321 writeMermaidElement(b, e, " ")
322 }
323 }
324
325 if len(rels) > 0 {
326 b.WriteString("\n")
327 }
328 for _, r := range rels {
329 fmt.Fprintf(b, " Rel(%s, %s, \"%s\")\n", sanitizeID(r.From), sanitizeID(r.To), escapeQuotes(r.Label))
330 }
331 }
332
333 func writeMermaidElement(b *strings.Builder, e elemEntry, indent string) {
334 macro := c4Macro(e.Elem.Kind)
335 if e.Elem.Technology != "" {
336 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\", \"%s\")\n",
337 indent, macro, sanitizeID(e.ID),
338 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Technology), escapeQuotes(e.Elem.Description))
339 } else {
340 fmt.Fprintf(b, "%s%s(%s, \"%s\", \"%s\")\n",
341 indent, macro, sanitizeID(e.ID),
342 escapeQuotes(e.Elem.Title), escapeQuotes(e.Elem.Description))
343 }
344 }
345
346 // ExportAllViewsToMermaid exports all views from the model as Mermaid diagrams.
347 // Returns a slice of view keys in order and a map of view key → Mermaid diagram code.
348 func ExportAllViewsToMermaid(m *model.BausteinsichtModel) ([]string, map[string]string, error) {
349 diagrams := make(map[string]string)
350 var viewKeys []string
351
352 for viewKey := range m.Views {
353 viewKeys = append(viewKeys, viewKey)
354 }
355 sort.Strings(viewKeys)
356
357 for _, viewKey := range viewKeys {
358 diagramCode, err := FormatView(m, viewKey, Mermaid)
359 if err != nil {
360 return nil, nil, err
361 }
362 diagrams[viewKey] = diagramCode
363 }
364
365 return viewKeys, diagrams, nil
366 }
367
github.com/docToolchain/Bausteinsicht/internal/diagram/dot.go 87.8%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderDOT renders a view as a GraphViz DOT graph.
12 func RenderDOT(m *model.BausteinsichtModel, viewKey string) (string, error) {
13 view, ok := m.Views[viewKey]
14 if !ok {
15 return "", fmt.Errorf("view %q not found", viewKey)
16 }
17
18 resolved, err := model.ResolveView(m, &view)
19 if err != nil {
20 return "", err
21 }
22
23 flat, _ := model.FlattenElements(m)
24 sort.Strings(resolved)
25
26 // Filter elements visible in this view
27 elemSet := make(map[string]bool, len(resolved))
28 for _, id := range resolved {
29 elemSet[id] = true
30 }
31 if view.Scope != "" {
32 elemSet[view.Scope] = true
33 }
34
35 // Filter relationships
36 rels := filterRelationships(m.Relationships, elemSet)
37
38 var b strings.Builder
39 b.WriteString("digraph \"" + escapeQuotes(view.Title) + "\" {\n")
40 b.WriteString(" rankdir=LR\n")
41 b.WriteString(" node [shape=box style=filled fontname=\"Arial\" fontsize=9]\n")
42 b.WriteString(" edge [fontsize=8]\n\n")
43
44 // Write nodes
45 for _, id := range resolved {
46 elem := flat[id]
47 if elem == nil {
48 continue
49 }
50
51 style := ColorForKind(elem.Kind)
52 nodeID := sanitizeID(id)
53
54 label := elem.Title
55 if elem.Title == "" {
56 label = id
57 }
58 if elem.Kind != "" {
59 label = label + "\n[" + elem.Kind + "]"
60 }
61
62 fmt.Fprintf(&b, " %s [label=\"%s\" fillcolor=\"%s\" color=\"%s\"]\n",
63 nodeID, escapeQuotes(label), style.Fill, style.Stroke)
64 }
65
66 // Write relationships
67 if len(rels) > 0 {
68 b.WriteString("\n")
69 for _, r := range rels {
70 fromID := sanitizeID(r.From)
71 toID := sanitizeID(r.To)
72 if r.Label != "" {
73 fmt.Fprintf(&b, " %s -> %s [label=\"%s\"]\n", fromID, toID, escapeQuotes(r.Label))
74 } else {
75 fmt.Fprintf(&b, " %s -> %s\n", fromID, toID)
76 }
77 }
78 }
79
80 b.WriteString("}\n")
81 return b.String(), nil
82 }
83
github.com/docToolchain/Bausteinsicht/internal/diagram/html.go 83.8%
1 package diagram
2
3 import (
4 "encoding/json"
5 "fmt"
6 "html"
7 "sort"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // HTMLNode represents a node in the interactive diagram.
13 type HTMLNode struct {
14 ID string `json:"id"`
15 Title string `json:"title"`
16 Kind string `json:"kind"`
17 Description string `json:"description,omitempty"`
18 Technology string `json:"technology,omitempty"`
19 X float64 `json:"x"`
20 Y float64 `json:"y"`
21 Fill string `json:"fill"`
22 Stroke string `json:"stroke"`
23 }
24
25 // HTMLEdge is a type alias for relEntry used in HTML diagram output.
26 type HTMLEdge = relEntry
27
28 // HTMLDiagramData is the data structure embedded in the HTML output.
29 type HTMLDiagramData struct {
30 Title string `json:"title"`
31 Nodes []HTMLNode `json:"nodes"`
32 Edges []HTMLEdge `json:"edges"`
33 }
34
35 // RenderHTML renders a view as an interactive HTML5 diagram.
36 func RenderHTML(m *model.BausteinsichtModel, viewKey string) (string, error) {
37 view, ok := m.Views[viewKey]
38 if !ok {
39 return "", fmt.Errorf("view %q not found", viewKey)
40 }
41
42 resolved, err := model.ResolveView(m, &view)
43 if err != nil {
44 return "", err
45 }
46
47 flat, _ := model.FlattenElements(m)
48 sort.Strings(resolved)
49
50 // Filter elements visible in this view
51 elemSet := make(map[string]bool, len(resolved))
52 for _, id := range resolved {
53 elemSet[id] = true
54 }
55 if view.Scope != "" {
56 elemSet[view.Scope] = true
57 }
58
59 // Filter relationships
60 rels := filterRelationships(m.Relationships, elemSet)
61
62 // Build node list with simple grid layout
63 nodes := []HTMLNode{}
64 x, y := 50.0, 50.0
65 for _, id := range resolved {
66 elem := flat[id]
67 if elem == nil {
68 continue
69 }
70
71 style := ColorForKind(elem.Kind)
72 title := elem.Title
73 if title == "" {
74 title = id
75 }
76
77 nodes = append(nodes, HTMLNode{
78 ID: id,
79 Title: title,
80 Kind: elem.Kind,
81 Description: elem.Description,
82 Technology: elem.Technology,
83 X: x,
84 Y: y,
85 Fill: style.Fill,
86 Stroke: style.Stroke,
87 })
88
89 x += 200
90 if x > 800 {
91 x = 50
92 y += 150
93 }
94 }
95
96 // Build edge list from relationships
97 edges := make([]HTMLEdge, len(rels))
98 for i, r := range rels {
99 edges[i] = HTMLEdge(r)
100 }
101
102 // Create diagram data
103 data := HTMLDiagramData{
104 Title: view.Title,
105 Nodes: nodes,
106 Edges: edges,
107 }
108
109 dataJSON, _ := json.Marshal(data)
110
111 // Generate HTML with embedded JavaScript renderer (escape title for HTML safety)
112 htmlContent := generateHTMLTemplate(html.EscapeString(view.Title), string(dataJSON))
113 return htmlContent, nil
114 }
115
116 func generateHTMLTemplate(title, dataJSON string) string {
117 return fmt.Sprintf(`<!DOCTYPE html>
118 <html lang="en">
119 <head>
120 <meta charset="UTF-8">
121 <meta name="viewport" content="width=device-width, initial-scale=1.0">
122 <title>Architecture — %s</title>
123 <style>
124 * { margin: 0; padding: 0; box-sizing: border-box; }
125 body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; }
126
127 #header {
128 background: white;
129 padding: 16px;
130 border-bottom: 1px solid #ddd;
131 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
132 }
133
134 #header h1 { font-size: 20px; font-weight: 600; color: #333; }
135
136 #controls {
137 display: flex;
138 gap: 12px;
139 margin-top: 12px;
140 align-items: center;
141 }
142
143 input[type="text"] {
144 flex: 1;
145 max-width: 300px;
146 padding: 8px 12px;
147 border: 1px solid #ddd;
148 border-radius: 4px;
149 font-size: 14px;
150 }
151
152 button {
153 padding: 8px 16px;
154 background: #0066cc;
155 color: white;
156 border: none;
157 border-radius: 4px;
158 font-size: 14px;
159 cursor: pointer;
160 }
161
162 button:hover { background: #0052a3; }
163
164 #canvas {
165 flex: 1;
166 background: white;
167 position: relative;
168 overflow: auto;
169 }
170
171 svg { display: block; }
172
173 #details {
174 width: 300px;
175 background: white;
176 border-left: 1px solid #ddd;
177 padding: 16px;
178 overflow-y: auto;
179 display: none;
180 }
181
182 #details.show { display: block; }
183
184 #details h3 { font-size: 16px; font-weight: 600; color: #333; margin-bottom: 12px; }
185 #details p { margin: 8px 0; font-size: 13px; color: #666; word-break: break-word; }
186 #details strong { color: #333; }
187
188 .grid { display: flex; }
189 #canvas { flex: 1; }
190
191 .node { cursor: pointer; transition: opacity 0.2s; }
192 .node:hover { opacity: 0.8; }
193 .node.highlighted { filter: drop-shadow(0 0 4px #0066cc); }
194 .node.faded { opacity: 0.3; }
195
196 .edge { stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
197 .edge.highlighted { stroke: #0066cc; stroke-width: 3; }
198 .edge.faded { opacity: 0.2; }
199
200 .edge-label { font-size: 12px; fill: #333; pointer-events: none; }
201 </style>
202 </head>
203 <body>
204 <div id="header">
205 <h1>Architecture Diagram</h1>
206 <div id="controls">
207 <input type="text" id="searchInput" placeholder="Search elements..." />
208 <button onclick="resetZoom()">Reset View</button>
209 </div>
210 </div>
211
212 <div class="grid">
213 <div id="canvas"></div>
214 <div id="details">
215 <h3 id="detailsTitle"></h3>
216 <p><strong>Kind:</strong> <span id="detailsKind"></span></p>
217 <p id="detailsTech" style="display:none;"><strong>Technology:</strong> <span id="detailsTechVal"></span></p>
218 <p id="detailsDesc" style="display:none;"><strong>Description:</strong> <span id="detailsDescVal"></span></p>
219 </div>
220 </div>
221
222 <script>
223 const DIAGRAM_DATA = %s;
224
225 const state = {
226 zoom: 1,
227 pan: { x: 0, y: 0 },
228 selected: null,
229 search: ""
230 };
231
232 function initDiagram() {
233 const canvas = document.getElementById('canvas');
234 const width = canvas.clientWidth;
235 const height = canvas.clientHeight;
236
237 const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
238 svg.setAttribute('width', width);
239 svg.setAttribute('height', height);
240
241 // Add arrowhead marker definition
242 const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
243 const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
244 marker.setAttribute('id', 'arrowhead');
245 marker.setAttribute('markerWidth', '10');
246 marker.setAttribute('markerHeight', '10');
247 marker.setAttribute('refX', '9');
248 marker.setAttribute('refY', '3');
249 marker.setAttribute('orient', 'auto');
250 const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
251 poly.setAttribute('points', '0 0, 10 3, 0 6');
252 poly.setAttribute('fill', '#333');
253 marker.appendChild(poly);
254 defs.appendChild(marker);
255 svg.appendChild(defs);
256
257 // Draw edges first (background)
258 for (const edge of DIAGRAM_DATA.edges) {
259 const fromNode = DIAGRAM_DATA.nodes.find(n => n.id === edge.from);
260 const toNode = DIAGRAM_DATA.nodes.find(n => n.id === edge.to);
261 if (fromNode && toNode) {
262 const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
263 line.setAttribute('x1', fromNode.x + 80);
264 line.setAttribute('y1', fromNode.y + 40);
265 line.setAttribute('x2', toNode.x + 80);
266 line.setAttribute('y2', toNode.y + 40);
267 line.setAttribute('stroke', '#999');
268 line.setAttribute('stroke-width', '2');
269 line.setAttribute('marker-end', 'url(#arrowhead)');
270 line.classList.add('edge');
271 line.dataset.from = edge.from;
272 line.dataset.to = edge.to;
273 svg.appendChild(line);
274
275 // Label
276 if (edge.label) {
277 const mid = {
278 x: (fromNode.x + toNode.x) / 2 + 80,
279 y: (fromNode.y + toNode.y) / 2 + 40
280 };
281 const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
282 text.setAttribute('x', mid.x);
283 text.setAttribute('y', mid.y - 5);
284 text.setAttribute('text-anchor', 'middle');
285 text.setAttribute('font-size', '12');
286 text.textContent = edge.label;
287 text.classList.add('edge-label');
288 svg.appendChild(text);
289 }
290 }
291 }
292
293 // Draw nodes
294 for (const node of DIAGRAM_DATA.nodes) {
295 const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
296 g.classList.add('node');
297 g.dataset.id = node.id;
298
299 const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
300 rect.setAttribute('x', node.x);
301 rect.setAttribute('y', node.y);
302 rect.setAttribute('width', '160');
303 rect.setAttribute('height', '80');
304 rect.setAttribute('fill', node.fill);
305 rect.setAttribute('stroke', node.stroke);
306 rect.setAttribute('stroke-width', '2');
307 rect.setAttribute('rx', '4');
308
309 const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
310 title.setAttribute('x', node.x + 80);
311 title.setAttribute('y', node.y + 25);
312 title.setAttribute('text-anchor', 'middle');
313 title.setAttribute('font-weight', 'bold');
314 title.setAttribute('font-size', '13');
315 title.textContent = node.title.substring(0, 20);
316
317 const kind = document.createElementNS('http://www.w3.org/2000/svg', 'text');
318 kind.setAttribute('x', node.x + 80);
319 kind.setAttribute('y', node.y + 50);
320 kind.setAttribute('text-anchor', 'middle');
321 kind.setAttribute('font-size', '11');
322 kind.setAttribute('fill', '#666');
323 kind.textContent = '[' + node.kind + ']';
324
325 g.appendChild(rect);
326 g.appendChild(title);
327 g.appendChild(kind);
328
329 g.onclick = () => selectNode(node);
330 svg.appendChild(g);
331 }
332
333 canvas.appendChild(svg);
334
335 // Search
336 document.getElementById('searchInput').addEventListener('input', (e) => {
337 state.search = e.target.value.toLowerCase();
338 highlightSearch();
339 });
340 }
341
342 function selectNode(node) {
343 state.selected = node.id;
344 updateDetails(node);
345 highlightNode(node.id);
346 }
347
348 function updateDetails(node) {
349 const details = document.getElementById('details');
350 document.getElementById('detailsTitle').textContent = node.title;
351 document.getElementById('detailsKind').textContent = node.kind;
352
353 const techEl = document.getElementById('detailsTech');
354 if (node.technology) {
355 techEl.style.display = 'block';
356 document.getElementById('detailsTechVal').textContent = node.technology;
357 } else {
358 techEl.style.display = 'none';
359 }
360
361 const descEl = document.getElementById('detailsDesc');
362 if (node.description) {
363 descEl.style.display = 'block';
364 document.getElementById('detailsDescVal').textContent = node.description;
365 } else {
366 descEl.style.display = 'none';
367 }
368
369 details.classList.add('show');
370 }
371
372 function highlightNode(nodeId) {
373 document.querySelectorAll('.node').forEach(el => {
374 if (el.dataset.id === nodeId) {
375 el.classList.add('highlighted');
376 } else {
377 el.classList.remove('highlighted');
378 }
379 });
380 }
381
382 function highlightSearch() {
383 if (!state.search) {
384 document.querySelectorAll('.node, .edge').forEach(el => el.classList.remove('faded'));
385 return;
386 }
387
388 document.querySelectorAll('.node').forEach(el => {
389 const nodeId = el.dataset.id;
390 const node = DIAGRAM_DATA.nodes.find(n => n.id === nodeId);
391 const matches = node && (node.id.toLowerCase().includes(state.search) || node.title.toLowerCase().includes(state.search));
392 el.classList.toggle('faded', !matches);
393 });
394
395 document.querySelectorAll('.edge').forEach(el => {
396 const from = el.dataset.from;
397 const to = el.dataset.to;
398 const matches = from.toLowerCase().includes(state.search) || to.toLowerCase().includes(state.search);
399 el.classList.toggle('faded', !matches);
400 });
401 }
402
403 function resetZoom() {
404 state.zoom = 1;
405 state.pan = { x: 0, y: 0 };
406 state.selected = null;
407 document.getElementById('details').classList.remove('show');
408 document.querySelectorAll('.node').forEach(el => el.classList.remove('highlighted', 'faded'));
409 document.getElementById('searchInput').value = '';
410 }
411
412 window.addEventListener('load', initDiagram);
413 </script>
414 </body>
415 </html>`, title, dataJSON)
416 }
417
github.com/docToolchain/Bausteinsicht/internal/diagram/markdown.go 93.8%
1 package diagram
2
3 import (
4 "fmt"
5 "strings"
6 "time"
7 )
8
9 // WrapDiagramsInMarkdown wraps multiple Mermaid diagrams in a Markdown document.
10 // viewKeys: ordered list of view keys
11 // diagrams: map of view key → Mermaid diagram code (without outer backticks)
12 func WrapDiagramsInMarkdown(viewKeys []string, diagrams map[string]string, viewTitles map[string]string) string {
13 var b strings.Builder
14
15 b.WriteString("# Architecture Diagrams\n\n")
16 b.WriteString("> Auto-generated by bausteinsicht — do not edit manually.\n")
17 fmt.Fprintf(&b, "> Last updated: %s\n\n", time.Now().UTC().Format(time.RFC3339))
18
19 for _, viewKey := range viewKeys {
20 diagramCode, exists := diagrams[viewKey]
21 if !exists || diagramCode == "" {
22 continue
23 }
24
25 title := viewKey
26 if customTitle, ok := viewTitles[viewKey]; ok {
27 title = customTitle
28 }
29
30 fmt.Fprintf(&b, "## %s\n\n", title)
31 b.WriteString("```mermaid\n")
32 b.WriteString(diagramCode)
33 b.WriteString("\n```\n\n")
34 }
35
36 return b.String()
37 }
38
github.com/docToolchain/Bausteinsicht/internal/diagram/sequence.go 100.0%
1 package diagram
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // RenderSequencePlantUML renders a DynamicView as a PlantUML sequence diagram.
12 func RenderSequencePlantUML(view model.DynamicView, flat map[string]*model.Element) string {
13 steps := sortedSteps(view.Steps)
14 participants := collectParticipants(steps)
15
16 var b strings.Builder
17 fmt.Fprintf(&b, "@startuml %s\n", sanitizeID(view.Key))
18 fmt.Fprintf(&b, "title %s\n\n", escapeQuotes(view.Title))
19
20 for _, id := range participants {
21 title := participantTitle(id, flat)
22 fmt.Fprintf(&b, "participant \"%s\" as %s\n", escapeQuotes(title), sanitizeID(id))
23 }
24 b.WriteString("\n")
25
26 for _, step := range steps {
27 arrow := plantUMLArrow(step.Type)
28 label := fmt.Sprintf("%d. %s", step.Index, escapeQuotes(step.Label))
29 fmt.Fprintf(&b, "%s %s %s : %s\n", sanitizeID(step.From), arrow, sanitizeID(step.To), label)
30 }
31
32 b.WriteString("\n@enduml\n")
33 return b.String()
34 }
35
36 // RenderSequenceMermaid renders a DynamicView as a Mermaid sequence diagram.
37 func RenderSequenceMermaid(view model.DynamicView, flat map[string]*model.Element) string {
38 steps := sortedSteps(view.Steps)
39 participants := collectParticipants(steps)
40
41 var b strings.Builder
42 b.WriteString("sequenceDiagram\n")
43 fmt.Fprintf(&b, " title %s\n\n", escapeQuotes(view.Title))
44
45 for _, id := range participants {
46 title := participantTitle(id, flat)
47 fmt.Fprintf(&b, " participant %s as %s\n", sanitizeID(id), escapeQuotes(title))
48 }
49 b.WriteString("\n")
50
51 for _, step := range steps {
52 arrow := mermaidArrow(step.Type)
53 label := fmt.Sprintf("%d. %s", step.Index, escapeQuotes(step.Label))
54 fmt.Fprintf(&b, " %s%s%s: %s\n", sanitizeID(step.From), arrow, sanitizeID(step.To), label)
55 }
56
57 return b.String()
58 }
59
60 // sortedSteps returns steps sorted by index.
61 func sortedSteps(steps []model.SequenceStep) []model.SequenceStep {
62 sorted := make([]model.SequenceStep, len(steps))
63 copy(sorted, steps)
64 sort.Slice(sorted, func(i, j int) bool {
65 return sorted[i].Index < sorted[j].Index
66 })
67 return sorted
68 }
69
70 // collectParticipants returns participant IDs in first-appearance order.
71 func collectParticipants(steps []model.SequenceStep) []string {
72 seen := make(map[string]bool)
73 var order []string
74 for _, s := range steps {
75 if !seen[s.From] {
76 seen[s.From] = true
77 order = append(order, s.From)
78 }
79 if !seen[s.To] {
80 seen[s.To] = true
81 order = append(order, s.To)
82 }
83 }
84 return order
85 }
86
87 func participantTitle(id string, flat map[string]*model.Element) string {
88 if flat != nil {
89 if e, ok := flat[id]; ok && e.Title != "" {
90 return e.Title
91 }
92 }
93 return id
94 }
95
96 func plantUMLArrow(t model.StepType) string {
97 switch t {
98 case model.StepAsync:
99 return "->>"
100 case model.StepReturn:
101 return "-->"
102 default: // sync or empty
103 return "->"
104 }
105 }
106
107 func mermaidArrow(t model.StepType) string {
108 switch t {
109 case model.StepAsync:
110 return "-)"
111 case model.StepReturn:
112 return "-->>"
113 default: // sync or empty
114 return "->>"
115 }
116 }
117
github.com/docToolchain/Bausteinsicht/internal/diff/diff.go 94.0%
1 package diff
2
3 import (
4 "fmt"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // Compare generates a diff between two architecture snapshots (asIs vs toBe)
10 func Compare(asIs, toBe *model.ModelSnapshot) *DiffResult {
11 result := &DiffResult{
12 Elements: []ElementChange{},
13 Relationships: []RelationshipChange{},
14 Summary: Summary{},
15 }
16
17 if asIs == nil || toBe == nil {
18 return result
19 }
20
21 compareElements(asIs.Elements, toBe.Elements, result)
22 compareRelationships(asIs.Relationships, toBe.Relationships, result)
23
24 calculateSummary(result)
25
26 return result
27 }
28
29 func compareElements(asIsElems, toBeElems map[string]model.Element, result *DiffResult) {
30 // Mark as-is elements
31 seenAsIs := make(map[string]bool)
32 for id, asIsElem := range asIsElems {
33 seenAsIs[id] = true
34
35 toBeElem, exists := toBeElems[id]
36 if !exists {
37 // Element removed
38 result.Elements = append(result.Elements, ElementChange{
39 ID: id,
40 Type: ChangeRemoved,
41 AsIs: &asIsElem,
42 Reason: "removed from to-be state",
43 })
44 continue
45 }
46
47 // Check if element changed
48 if hasElementChanged(&asIsElem, &toBeElem) {
49 result.Elements = append(result.Elements, ElementChange{
50 ID: id,
51 Type: ChangeChanged,
52 AsIs: &asIsElem,
53 ToBe: &toBeElem,
54 Reason: "element properties changed",
55 })
56 }
57 }
58
59 // Find added elements
60 for id, toBeElem := range toBeElems {
61 if !seenAsIs[id] {
62 result.Elements = append(result.Elements, ElementChange{
63 ID: id,
64 Type: ChangeAdded,
65 ToBe: &toBeElem,
66 Reason: "new element in to-be state",
67 })
68 }
69 }
70 }
71
72 func compareRelationships(asIsRels, toBeRels []model.Relationship, result *DiffResult) {
73 // Build maps for easier comparison
74 asIsMap := relationshipMap(asIsRels)
75 toBeMap := relationshipMap(toBeRels)
76
77 // Find removed and changed relationships
78 for key, asIsRel := range asIsMap {
79 toBeRel, exists := toBeMap[key]
80 if !exists {
81 // Relationship removed
82 result.Relationships = append(result.Relationships, RelationshipChange{
83 From: asIsRel.From,
84 To: asIsRel.To,
85 Type: ChangeRemoved,
86 AsIs: &asIsRel,
87 })
88 continue
89 }
90
91 // Check if changed (e.g., label changed)
92 if asIsRel.Label != toBeRel.Label {
93 result.Relationships = append(result.Relationships, RelationshipChange{
94 From: asIsRel.From,
95 To: asIsRel.To,
96 Type: ChangeChanged,
97 AsIs: &asIsRel,
98 ToBe: &toBeRel,
99 })
100 }
101 }
102
103 // Find added relationships
104 for key, toBeRel := range toBeMap {
105 if _, exists := asIsMap[key]; !exists {
106 result.Relationships = append(result.Relationships, RelationshipChange{
107 From: toBeRel.From,
108 To: toBeRel.To,
109 Type: ChangeAdded,
110 ToBe: &toBeRel,
111 })
112 }
113 }
114 }
115
116 func relationshipMap(rels []model.Relationship) map[string]model.Relationship {
117 m := make(map[string]model.Relationship)
118 for _, rel := range rels {
119 key := fmt.Sprintf("%s->%s", rel.From, rel.To)
120 m[key] = rel
121 }
122 return m
123 }
124
125 func hasElementChanged(asIs, toBe *model.Element) bool {
126 if asIs == nil || toBe == nil {
127 return true
128 }
129
130 // Compare relevant fields (excluding layout properties)
131 return asIs.Title != toBe.Title ||
132 asIs.Kind != toBe.Kind ||
133 asIs.Technology != toBe.Technology ||
134 asIs.Description != toBe.Description ||
135 asIs.Status != toBe.Status
136 }
137
138 func calculateSummary(result *DiffResult) {
139 for _, change := range result.Elements {
140 switch change.Type {
141 case ChangeAdded:
142 result.Summary.AddedElements++
143 result.Summary.TotalAddedElements++
144 case ChangeRemoved:
145 result.Summary.RemovedElements++
146 result.Summary.TotalRemovedElements++
147 case ChangeChanged:
148 result.Summary.ChangedElements++
149 }
150 }
151
152 for _, change := range result.Relationships {
153 switch change.Type {
154 case ChangeAdded:
155 result.Summary.AddedRelationships++
156 case ChangeRemoved:
157 result.Summary.RemovedRelationships++
158 }
159 }
160 }
161
github.com/docToolchain/Bausteinsicht/internal/diff/drawio.go 90.0%
1 package diff
2
3 // DrawIO color definitions for diff visualization
4 const (
5 ColorAdded = "#d5e8d4" // green
6 ColorRemoved = "#f8cecc" // red
7 ColorChanged = "#ffe6cc" // orange
8 ColorUnchanged = "#ffffff" // white (default)
9
10 StrokeAdded = "#82b366" // dark green
11 StrokeRemoved = "#b85450" // dark red
12 StrokeChanged = "#d6b656" // dark orange
13 )
14
15 // AppliedChangeStyle returns the fill and stroke colors for a changed element
16 func GetChangeColors(changeType ChangeType) (fillColor, strokeColor string) {
17 switch changeType {
18 case ChangeAdded:
19 return ColorAdded, StrokeAdded
20 case ChangeRemoved:
21 return ColorRemoved, StrokeRemoved
22 case ChangeChanged:
23 return ColorChanged, StrokeChanged
24 default:
25 return ColorUnchanged, "#999999"
26 }
27 }
28
29 // ElementStyle describes visual styling for a draw.io element
30 type ElementStyle struct {
31 FillColor string
32 StrokeColor string
33 StrokeWidth float64
34 Opacity float64
35 Label string // For removed elements, add strikethrough indicator
36 }
37
38 // GetElementStyle returns the draw.io styling for a given element change
39 func GetElementStyle(change ElementChange) ElementStyle {
40 fillColor, strokeColor := GetChangeColors(change.Type)
41
42 style := ElementStyle{
43 FillColor: fillColor,
44 StrokeColor: strokeColor,
45 StrokeWidth: 2,
46 Opacity: 1.0,
47 }
48
49 // For removed elements, add visual indication
50 if change.Type == ChangeRemoved && change.AsIs != nil {
51 style.Label = "~" + change.AsIs.Title // strikethrough indicator
52 }
53
54 return style
55 }
56
github.com/docToolchain/Bausteinsicht/internal/drawio/connector.go 89.2%
1 package drawio
2
3 import (
4 "fmt"
5
6 "github.com/beevik/etree"
7 )
8
9 // ConnectorData holds data for creating/updating connectors.
10 type ConnectorData struct {
11 From string // source bausteinsicht_id
12 To string // target bausteinsicht_id
13 Label string // display label on the connector
14 SourceRef string // source cell ID (defaults to From if empty)
15 TargetRef string // target cell ID (defaults to To if empty)
16 Index int // relationship index for disambiguation (0-based)
17 }
18
19 // connectorID returns the canonical ID for a connector between two elements.
20 // The index disambiguates multiple relationships between the same pair.
21 func connectorID(from, to string, index int) string {
22 return fmt.Sprintf("rel-%s-%s-%d", from, to, index)
23 }
24
25 // CreateConnector creates an edge mxCell connecting From to To.
26 // Connectors always use parent="1" regardless of container nesting.
27 func (p *Page) CreateConnector(data ConnectorData, style string) {
28 root := p.Root()
29 if root == nil {
30 return
31 }
32
33 srcRef := data.SourceRef
34 if srcRef == "" {
35 srcRef = data.From
36 }
37 tgtRef := data.TargetRef
38 if tgtRef == "" {
39 tgtRef = data.To
40 }
41
42 cell := root.CreateElement("mxCell")
43 cell.CreateAttr("id", connectorID(srcRef, tgtRef, data.Index))
44 cell.CreateAttr("value", data.Label)
45 cell.CreateAttr("style", style)
46 cell.CreateAttr("edge", "1")
47 cell.CreateAttr("source", srcRef)
48 cell.CreateAttr("target", tgtRef)
49 cell.CreateAttr("parent", "1")
50
51 geom := cell.CreateElement("mxGeometry")
52 geom.CreateAttr("relative", "1")
53 geom.CreateAttr("as", "geometry")
54 }
55
56 // FindConnector returns the mxCell edge with id="rel-<from>-<to>-<index>", or nil.
57 func (p *Page) FindConnector(from, to string, index int) *etree.Element {
58 root := p.Root()
59 if root == nil {
60 return nil
61 }
62 id := connectorID(from, to, index)
63 for _, cell := range root.SelectElements("mxCell") {
64 if cell.SelectAttrValue("id", "") == id {
65 return cell
66 }
67 }
68 return nil
69 }
70
71 // FindAllConnectors returns all mxCell elements with edge="1".
72 func (p *Page) FindAllConnectors() []*etree.Element {
73 root := p.Root()
74 if root == nil {
75 return nil
76 }
77 var result []*etree.Element
78 for _, cell := range root.SelectElements("mxCell") {
79 if cell.SelectAttrValue("edge", "") == "1" {
80 result = append(result, cell)
81 }
82 }
83 return result
84 }
85
86 // UpdateConnectorLabel sets the value attribute on the connector between from and to.
87 func (p *Page) UpdateConnectorLabel(from, to string, index int, label string) {
88 conn := p.FindConnector(from, to, index)
89 if conn == nil {
90 return
91 }
92 attr := conn.SelectAttr("value")
93 if attr != nil {
94 attr.Value = label
95 } else {
96 conn.CreateAttr("value", label)
97 }
98 }
99
100 // DeleteConnector removes the connector between from and to at the given index.
101 func (p *Page) DeleteConnector(from, to string, index int) {
102 root := p.Root()
103 if root == nil {
104 return
105 }
106 id := connectorID(from, to, index)
107 for _, cell := range root.SelectElements("mxCell") {
108 if cell.SelectAttrValue("id", "") == id {
109 root.RemoveChild(cell)
110 return
111 }
112 }
113 }
114
115 // DeleteConnectorsFor removes all connectors where source or target matches elementID.
116 func (p *Page) DeleteConnectorsFor(elementID string) {
117 root := p.Root()
118 if root == nil {
119 return
120 }
121 var toRemove []*etree.Element
122 for _, cell := range root.SelectElements("mxCell") {
123 if cell.SelectAttrValue("edge", "") != "1" {
124 continue
125 }
126 src := cell.SelectAttrValue("source", "")
127 tgt := cell.SelectAttrValue("target", "")
128 if src == elementID || tgt == elementID {
129 toRemove = append(toRemove, cell)
130 }
131 }
132 for _, cell := range toRemove {
133 root.RemoveChild(cell)
134 }
135 }
136
github.com/docToolchain/Bausteinsicht/internal/drawio/document.go 84.1%
1 // Package drawio handles reading and writing draw.io XML files.
2 package drawio
3
4 import (
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/beevik/etree"
10 )
11
12 // Document represents a draw.io file (mxfile).
13 type Document struct {
14 tree *etree.Document
15 }
16
17 // Page represents a single page (diagram element) within a Document.
18 type Page struct {
19 diagram *etree.Element
20 }
21
22 // LoadDocument parses a draw.io XML file from disk.
23 // It validates the document structure to prevent data loss from corrupt files.
24 func LoadDocument(path string) (*Document, error) {
25 tree := etree.NewDocument()
26 if err := tree.ReadFromFile(path); err != nil {
27 return nil, fmt.Errorf("LoadDocument %q: %w", path, err)
28 }
29
30 // Validate document structure to prevent data loss from corrupt files.
31 root := tree.Root()
32 if root == nil || root.Tag != "mxfile" {
33 return nil, fmt.Errorf("LoadDocument %q: not a valid draw.io file (missing <mxfile> root)", path)
34 }
35 diagrams := root.SelectElements("diagram")
36 if len(diagrams) == 0 {
37 return nil, fmt.Errorf("LoadDocument %q: not a valid draw.io file (no <diagram> elements)", path)
38 }
39 for _, d := range diagrams {
40 model := d.FindElement("mxGraphModel")
41 if model == nil {
42 return nil, fmt.Errorf("LoadDocument %q: diagram %q missing <mxGraphModel>",
43 path, d.SelectAttrValue("id", "?"))
44 }
45 if model.FindElement("root") == nil {
46 return nil, fmt.Errorf("LoadDocument %q: diagram %q missing <root> element",
47 path, d.SelectAttrValue("id", "?"))
48 }
49 }
50
51 return &Document{tree: tree}, nil
52 }
53
54 // SaveDocument writes a Document to disk using an atomic temp-file + rename.
55 func SaveDocument(path string, doc *Document) error {
56 doc.tree.Indent(2)
57
58 dir := filepath.Dir(path)
59 tmp, err := os.CreateTemp(dir, ".drawio-tmp-*")
60 if err != nil {
61 return fmt.Errorf("SaveDocument create temp: %w", err)
62 }
63 tmpName := tmp.Name()
64
65 if _, err := doc.tree.WriteTo(tmp); err != nil {
66 _ = tmp.Close()
67 _ = os.Remove(tmpName)
68 return fmt.Errorf("SaveDocument write: %w", err)
69 }
70 if err := tmp.Close(); err != nil {
71 _ = os.Remove(tmpName)
72 return fmt.Errorf("SaveDocument close: %w", err)
73 }
74 if err := os.Rename(tmpName, path); err != nil {
75 _ = os.Remove(tmpName)
76 return fmt.Errorf("SaveDocument rename: %w", err)
77 }
78 return nil
79 }
80
81 // NewDocument creates an empty mxfile document.
82 func NewDocument() *Document {
83 tree := etree.NewDocument()
84 tree.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
85 mxfile := tree.CreateElement("mxfile")
86 mxfile.CreateAttr("host", "bausteinsicht")
87 mxfile.CreateAttr("compressed", "false")
88 return &Document{tree: tree}
89 }
90
91 // Pages returns all pages in the document.
92 func (d *Document) Pages() []*Page {
93 root := d.tree.Root()
94 if root == nil {
95 return nil
96 }
97 diagrams := root.SelectElements("diagram")
98 pages := make([]*Page, len(diagrams))
99 for i, el := range diagrams {
100 pages[i] = &Page{diagram: el}
101 }
102 return pages
103 }
104
105 // GetPage returns the page with the given id, or nil if not found.
106 func (d *Document) GetPage(id string) *Page {
107 root := d.tree.Root()
108 if root == nil {
109 return nil
110 }
111 for _, el := range root.SelectElements("diagram") {
112 if el.SelectAttrValue("id", "") == id {
113 return &Page{diagram: el}
114 }
115 }
116 return nil
117 }
118
119 // AddPage adds a new page with the given id and name, initialised with base cells.
120 func (d *Document) AddPage(id, name string) *Page {
121 root := d.tree.Root()
122 if root == nil {
123 root = d.tree.CreateElement("mxfile")
124 }
125
126 diagram := root.CreateElement("diagram")
127 diagram.CreateAttr("id", id)
128 diagram.CreateAttr("name", name)
129
130 model := diagram.CreateElement("mxGraphModel")
131 model.CreateAttr("dx", "1422")
132 model.CreateAttr("dy", "794")
133 model.CreateAttr("grid", "1")
134 model.CreateAttr("gridSize", "10")
135 model.CreateAttr("page", "1")
136 model.CreateAttr("pageWidth", "1169")
137 model.CreateAttr("pageHeight", "827")
138 model.CreateAttr("background", "#ffffff")
139
140 rootEl := model.CreateElement("root")
141 cell0 := rootEl.CreateElement("mxCell")
142 cell0.CreateAttr("id", "0")
143 cell1 := rootEl.CreateElement("mxCell")
144 cell1.CreateAttr("id", "1")
145 cell1.CreateAttr("parent", "0")
146
147 return &Page{diagram: diagram}
148 }
149
150 // RemovePage removes the page (diagram element) with the given id from the document.
151 // If no page with the given id exists, RemovePage is a no-op.
152 func (d *Document) RemovePage(id string) {
153 root := d.tree.Root()
154 if root == nil {
155 return
156 }
157 for _, el := range root.SelectElements("diagram") {
158 if el.SelectAttrValue("id", "") == id {
159 root.RemoveChild(el)
160 return
161 }
162 }
163 }
164
165 // ID returns the id attribute of the page's diagram element.
166 func (p *Page) ID() string {
167 return p.diagram.SelectAttrValue("id", "")
168 }
169
170 // Root returns the <root> element of the page for direct manipulation.
171 func (p *Page) Root() *etree.Element {
172 model := p.diagram.FindElement("mxGraphModel")
173 if model == nil {
174 return nil
175 }
176 return model.FindElement("root")
177 }
178
github.com/docToolchain/Bausteinsicht/internal/drawio/element.go 88.8%
1 package drawio
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/beevik/etree"
8 )
9
10 // ElementData holds the data needed to create or update an element.
11 type ElementData struct {
12 ID string // bausteinsicht_id (e.g., "webshop.api")
13 CellID string // draw.io cell ID (file-wide unique); defaults to ID if empty
14 Kind string // bausteinsicht_kind (e.g., "container")
15 Title string // display title
16 Technology string // technology string
17 Description string // tooltip text
18 Link string // drill-down link (e.g., "data:page/id,view-containers")
19 ParentID string // parent cell ID ("1" for top-level, container ID for children)
20 X, Y float64 // position
21 Width float64 // element width
22 Height float64 // element height
23 SubCells *SubCellTemplates // sub-cell templates; nil for legacy HTML labels
24 }
25
26 // CreateElement creates an <object> wrapping an <mxCell vertex="1"> with <mxGeometry>.
27 // ParentID defaults to "1" if empty.
28 // When SubCells is non-nil, the element uses grouped sub-cells for title/tech/desc
29 // instead of an HTML label.
30 func (p *Page) CreateElement(data ElementData, style string) error {
31 root := p.Root()
32 if root == nil {
33 return fmt.Errorf("CreateElement: page has no root element")
34 }
35
36 parentID := data.ParentID
37 if parentID == "" {
38 parentID = "1"
39 }
40
41 cellID := data.CellID
42 if cellID == "" {
43 cellID = data.ID
44 }
45
46 obj := root.CreateElement("object")
47 if data.SubCells != nil {
48 obj.CreateAttr("label", "")
49 } else {
50 obj.CreateAttr("label", GenerateLabel(data.Title, data.Technology, data.Description))
51 }
52 obj.CreateAttr("id", cellID)
53 obj.CreateAttr("bausteinsicht_id", data.ID)
54 obj.CreateAttr("bausteinsicht_kind", data.Kind)
55 if data.Technology != "" {
56 obj.CreateAttr("technology", data.Technology)
57 }
58 if data.Description != "" {
59 obj.CreateAttr("tooltip", data.Description)
60 }
61 if data.Link != "" {
62 obj.CreateAttr("link", data.Link)
63 }
64
65 // Ensure container=1 is set when using sub-cells (required for child grouping).
66 if data.SubCells != nil {
67 // Replace container=0 with container=1, or append if missing.
68 if strings.Contains(style, "container=0") {
69 style = strings.Replace(style, "container=0", "container=1", 1)
70 } else if !strings.Contains(style, "container=1") {
71 style = strings.TrimRight(style, ";") + ";container=1;"
72 }
73 }
74
75 // HTML labels require html=1 in the cell style; without it draw.io renders
76 // the raw markup as plain text. This guard covers elements whose kind has no
77 // template entry and therefore receives an empty style fallback.
78 if data.SubCells == nil && !strings.Contains(style, "html=1") {
79 if style != "" && !strings.HasSuffix(style, ";") {
80 style += ";"
81 }
82 style += "html=1;"
83 }
84
85 cell := obj.CreateElement("mxCell")
86 cell.CreateAttr("style", style)
87 cell.CreateAttr("vertex", "1")
88 cell.CreateAttr("parent", parentID)
89
90 geom := cell.CreateElement("mxGeometry")
91 geom.CreateAttr("x", formatFloat(data.X))
92 geom.CreateAttr("y", formatFloat(data.Y))
93 geom.CreateAttr("width", formatFloat(data.Width))
94 geom.CreateAttr("height", formatFloat(data.Height))
95 geom.CreateAttr("as", "geometry")
96
97 // Create grouped sub-cells for title, technology, and description.
98 if data.SubCells != nil {
99 createSubCells(root, cellID, data, data.SubCells)
100 }
101
102 return nil
103 }
104
105 // SubCellTemplates holds the template styles for creating text sub-cells.
106 type SubCellTemplates struct {
107 Title *SubCellStyle
108 Tech *SubCellStyle
109 Desc *SubCellStyle
110 }
111
112 // createSubCells creates child mxCell text elements inside the parent element.
113 func createSubCells(root *etree.Element, parentCellID string, data ElementData, sc *SubCellTemplates) {
114 // Title sub-cell (always created).
115 if sc.Title != nil {
116 createTextSubCell(root, parentCellID+"-title", parentCellID, data.Title,
117 sc.Title, data.Width, data.Height)
118 }
119
120 // Technology sub-cell (only when technology is non-empty).
121 if sc.Tech != nil && data.Technology != "" {
122 createTextSubCell(root, parentCellID+"-tech", parentCellID, "["+data.Technology+"]",
123 sc.Tech, data.Width, data.Height)
124 }
125
126 // Description sub-cell (only when description is non-empty).
127 // The display value is truncated to avoid visual overflow; the full text
128 // is preserved in the element's tooltip attribute.
129 if sc.Desc != nil && data.Description != "" {
130 createTextSubCell(root, parentCellID+"-desc", parentCellID, truncateText(data.Description, 120),
131 sc.Desc, data.Width, data.Height)
132 }
133 }
134
135 // truncateText shortens s to maxLen runes, appending "…" if truncated.
136 func truncateText(s string, maxLen int) string {
137 runes := []rune(s)
138 if len(runes) <= maxLen {
139 return s
140 }
141 return string(runes[:maxLen-1]) + "…"
142 }
143
144 // createTextSubCell creates a single text mxCell child element.
145 // Sub-cells are locked (non-movable, non-resizable, non-deletable, non-connectable)
146 // so that clicking the shape always selects the parent element.
147 func createTextSubCell(root *etree.Element, id, parentID, value string, sub *SubCellStyle, parentW, parentH float64) {
148 cell := root.CreateElement("mxCell")
149 cell.CreateAttr("id", id)
150 cell.CreateAttr("value", value)
151 // Make sub-cells transparent to mouse events so clicks pass through
152 // to the parent element. This lets users grab the whole shape at once.
153 // overflow=hidden clips text at the fixed sub-cell boundary; the full
154 // content remains accessible via the element's tooltip attribute.
155 style := setStyleFlags(sub.Style, "pointerEvents=0", "overflow=hidden")
156 cell.CreateAttr("style", style)
157 cell.CreateAttr("vertex", "1")
158 cell.CreateAttr("connectable", "0")
159 cell.CreateAttr("parent", parentID)
160
161 geom := cell.CreateElement("mxGeometry")
162 // Scale sub-cell width to parent width, keep x/y/height from template.
163 w := parentW
164 if w == 0 {
165 w = sub.Width
166 }
167 geom.CreateAttr("x", formatFloat(sub.X))
168 geom.CreateAttr("y", formatFloat(sub.Y))
169 geom.CreateAttr("width", formatFloat(w))
170 geom.CreateAttr("height", formatFloat(sub.Height))
171 geom.CreateAttr("as", "geometry")
172 }
173
174 // FindElement returns the <object> element with the given bausteinsicht_id, or nil if not found.
175 func (p *Page) FindElement(bausteinsichtID string) *etree.Element {
176 root := p.Root()
177 if root == nil {
178 return nil
179 }
180 for _, obj := range root.SelectElements("object") {
181 if obj.SelectAttrValue("bausteinsicht_id", "") == bausteinsichtID {
182 return obj
183 }
184 }
185 return nil
186 }
187
188 // FindAllElements returns all <object> elements that have a bausteinsicht_id attribute.
189 func (p *Page) FindAllElements() []*etree.Element {
190 root := p.Root()
191 if root == nil {
192 return nil
193 }
194 var result []*etree.Element
195 for _, obj := range root.SelectElements("object") {
196 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
197 result = append(result, obj)
198 }
199 }
200 return result
201 }
202
203 // UpdateElement updates label, tooltip, technology, and link on an existing element.
204 // If the element uses sub-cells, child text cells are updated instead of the HTML label.
205 func (p *Page) UpdateElement(id string, data ElementData) {
206 obj := p.FindElement(id)
207 if obj == nil {
208 return
209 }
210
211 cellID := obj.SelectAttrValue("id", "")
212
213 // Check if element uses sub-cells (label is empty and has child text cells).
214 root := p.Root()
215 childCells := findChildTextCells(root, cellID)
216 if len(childCells) > 0 {
217 // Update existing sub-cells and manage tech/desc cells.
218 updateSubCells(root, cellID, childCells, data)
219 setAttr(obj, "label", "")
220 } else {
221 setAttr(obj, "label", GenerateLabel(data.Title, data.Technology, data.Description))
222 }
223
224 setAttr(obj, "tooltip", data.Description)
225 setAttr(obj, "link", data.Link)
226 if data.Technology != "" {
227 setAttr(obj, "technology", data.Technology)
228 } else {
229 setAttr(obj, "technology", "")
230 }
231 }
232
233 // findChildTextCells finds all text sub-cells that are children of the given parent cell ID.
234 // Returns a map of role ("title", "tech", "desc") to the mxCell element.
235 func findChildTextCells(root *etree.Element, parentCellID string) map[string]*etree.Element {
236 if root == nil || parentCellID == "" {
237 return nil
238 }
239 result := make(map[string]*etree.Element)
240 for _, cell := range root.SelectElements("mxCell") {
241 if cell.SelectAttrValue("parent", "") != parentCellID {
242 continue
243 }
244 cellID := cell.SelectAttrValue("id", "")
245 style := cell.SelectAttrValue("style", "")
246 if !isTextSubCell(style) {
247 continue
248 }
249 switch {
250 case hasSuffix(cellID, "-title"):
251 result["title"] = cell
252 case hasSuffix(cellID, "-tech"):
253 result["tech"] = cell
254 case hasSuffix(cellID, "-desc"):
255 result["desc"] = cell
256 }
257 }
258 return result
259 }
260
261 // isTextSubCell returns true if the style indicates a text sub-cell.
262 func isTextSubCell(style string) bool {
263 return containsStyleKey(style, "text")
264 }
265
266 // containsStyleKey checks if a draw.io style string starts with or contains the given key.
267 func containsStyleKey(style, key string) bool {
268 // Style format: "key1;key2=val;key3=val;..."
269 // "text" appears as a flag (no =) at the beginning.
270 if style == key || style == key+";" {
271 return true
272 }
273 if len(style) > len(key) && style[:len(key)+1] == key+";" {
274 return true
275 }
276 return false
277 }
278
279 // hasSuffix is a simple string suffix check.
280 func hasSuffix(s, suffix string) bool {
281 return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
282 }
283
284 // updateSubCells updates existing sub-cell values and adds/removes tech/desc cells.
285 func updateSubCells(root *etree.Element, parentCellID string, cells map[string]*etree.Element, data ElementData) {
286 // Update title.
287 if tc, ok := cells["title"]; ok {
288 setAttr(tc, "value", data.Title)
289 ensureSubCellStyle(tc)
290 }
291
292 // Update or add/remove technology cell.
293 if tc, ok := cells["tech"]; ok {
294 if data.Technology != "" {
295 setAttr(tc, "value", "["+data.Technology+"]")
296 ensureSubCellStyle(tc)
297 } else {
298 root.RemoveChild(tc)
299 }
300 }
301
302 // Update or add/remove description cell.
303 if dc, ok := cells["desc"]; ok {
304 if data.Description != "" {
305 setAttr(dc, "value", truncateText(data.Description, 120))
306 ensureSubCellStyle(dc)
307 } else {
308 root.RemoveChild(dc)
309 }
310 }
311 }
312
313 // ensureSubCellStyle applies required style flags to an existing sub-cell.
314 // This migrates older sub-cells that were created before these flags existed.
315 func ensureSubCellStyle(cell *etree.Element) {
316 style := cell.SelectAttrValue("style", "")
317 updated := setStyleFlags(style, "pointerEvents=0", "overflow=hidden")
318 if updated != style {
319 setAttr(cell, "style", updated)
320 }
321 }
322
323 // UpdateElementKind updates the bausteinsicht_kind attribute and the mxCell style
324 // of an existing element. If style is empty, the mxCell style is not changed.
325 func (p *Page) UpdateElementKind(id, kind, style string) {
326 obj := p.FindElement(id)
327 if obj == nil {
328 return
329 }
330 setAttr(obj, "bausteinsicht_kind", kind)
331 cell := obj.FindElement("mxCell")
332 if cell != nil && style != "" {
333 setAttr(cell, "style", style)
334 }
335 }
336
337 // DeleteElement removes the <object> element with the given bausteinsicht_id.
338 // It also removes any child text sub-cells and mxCell connectors that reference
339 // this element as source or target.
340 func (p *Page) DeleteElement(id string) {
341 root := p.Root()
342 if root == nil {
343 return
344 }
345
346 obj := p.FindElement(id)
347 if obj != nil {
348 cellID := obj.SelectAttrValue("id", "")
349 root.RemoveChild(obj)
350
351 // Remove child text sub-cells (their parent references the cell ID).
352 if cellID != "" {
353 removeChildCells(root, cellID)
354 }
355 }
356
357 for _, cell := range root.SelectElements("mxCell") {
358 src := cell.SelectAttrValue("source", "")
359 dst := cell.SelectAttrValue("target", "")
360 if src == id || dst == id {
361 root.RemoveChild(cell)
362 }
363 }
364 }
365
366 // removeChildCells removes all mxCell elements whose parent attribute matches parentID.
367 func removeChildCells(root *etree.Element, parentID string) {
368 var toRemove []*etree.Element
369 for _, cell := range root.SelectElements("mxCell") {
370 if cell.SelectAttrValue("parent", "") == parentID {
371 toRemove = append(toRemove, cell)
372 }
373 }
374 for _, cell := range toRemove {
375 root.RemoveChild(cell)
376 }
377 }
378
379 // ReadElementFields extracts title, technology, and description from an element.
380 // It first looks for child text sub-cells; if none are found, falls back to
381 // parsing the HTML label (backward compatibility).
382 func (p *Page) ReadElementFields(obj *etree.Element) (title, technology, description string) {
383 cellID := obj.SelectAttrValue("id", "")
384 root := p.Root()
385 childCells := findChildTextCells(root, cellID)
386
387 if len(childCells) > 0 {
388 if tc, ok := childCells["title"]; ok {
389 title = tc.SelectAttrValue("value", "")
390 }
391 if tc, ok := childCells["tech"]; ok {
392 technology = trimBrackets(tc.SelectAttrValue("value", ""))
393 }
394 if dc, ok := childCells["desc"]; ok {
395 description = dc.SelectAttrValue("value", "")
396 }
397 return title, technology, description
398 }
399
400 // Fallback: parse HTML label (backward compat).
401 label := obj.SelectAttrValue("label", "")
402 return ParseLabel(label)
403 }
404
405 // setAttr sets an attribute on an element, creating it if it doesn't exist.
406 // If value is empty, any existing attribute is removed.
407 func setAttr(el *etree.Element, key, value string) {
408 if value == "" {
409 el.RemoveAttr(key)
410 return
411 }
412 attr := el.SelectAttr(key)
413 if attr != nil {
414 attr.Value = value
415 } else {
416 el.CreateAttr(key, value)
417 }
418 }
419
420 // formatFloat formats a float64 as a string without trailing zeros where possible.
421 func formatFloat(f float64) string {
422 if f == float64(int(f)) {
423 return fmt.Sprintf("%d", int(f))
424 }
425 return fmt.Sprintf("%g", f)
426 }
427
428 // setStyleFlags sets key=value flags in a draw.io style string,
429 // replacing any existing value for each key.
430 func setStyleFlags(style string, flags ...string) string {
431 for _, flag := range flags {
432 parts := strings.SplitN(flag, "=", 2)
433 if len(parts) != 2 {
434 continue
435 }
436 key := parts[0]
437 // Remove existing key=value pair.
438 segments := strings.Split(style, ";")
439 var filtered []string
440 for _, seg := range segments {
441 if seg == "" {
442 continue
443 }
444 if strings.HasPrefix(seg, key+"=") {
445 continue
446 }
447 filtered = append(filtered, seg)
448 }
449 filtered = append(filtered, flag)
450 style = strings.Join(filtered, ";") + ";"
451 }
452 return style
453 }
454
github.com/docToolchain/Bausteinsicht/internal/drawio/label.go 91.0%
1 package drawio
2
3 import (
4 "strings"
5 )
6
7 // Label color constants for technology and description lines.
8 // These are light enough to be readable on dark C4 backgrounds (#08427B,
9 // #1168BD, #438DD5) while still providing contrast on the lighter
10 // component background (#85BBF0).
11 const (
12 techColor = "#CCCCCC"
13 descColor = "#BBBBBB"
14 )
15
16 // maxLabelDescLen is the maximum rune length of the description portion of an
17 // HTML label. HTML labels are rendered inside a fixed-size element box
18 // (typically 120×60 px) that has no sub-cell clipping, so descriptions must
19 // be kept short. The full text is always preserved in the tooltip attribute.
20 const maxLabelDescLen = 60
21
22 // GenerateLabel creates an HTML label for draw.io elements.
23 // Format: <b>Title</b><br><font color="..."><i>[Technology]</i></font><br><font color="..." style="font-size:11px">Description</font>
24 // Technology is wrapped in square brackets per C4 convention and rendered in italic.
25 // Empty technology or description lines are omitted.
26 // The returned string is unescaped HTML; etree handles XML attribute escaping.
27 func GenerateLabel(title, technology, description string) string {
28 var b strings.Builder
29 b.WriteString("<b>" + escapeHTML(title) + "</b>")
30 if technology != "" {
31 b.WriteString("<br><font color=\"" + techColor + "\"><i>[" + escapeHTML(technology) + "]</i></font>")
32 }
33 if description != "" {
34 b.WriteString("<br><font color=\"" + descColor + "\" style=\"font-size:11px\">" + escapeHTML(truncateText(description, maxLabelDescLen)) + "</font>")
35 }
36 return b.String()
37 }
38
39 // GenerateActorLabel creates a label for actor elements (just the title, no technology line).
40 func GenerateActorLabel(title string) string {
41 return "<b>" + escapeHTML(title) + "</b>"
42 }
43
44 // ParseLabel extracts title, technology and description from an HTML label.
45 // Expected format: <b>Title</b><br><font color="#666666">[Technology]</font><br><font color="#999999">Description</font>
46 // Also handles legacy format without brackets around technology.
47 // If the label doesn't match, return the full text as title.
48 func ParseLabel(html string) (title, technology, description string) {
49 if !strings.HasPrefix(html, "<b>") {
50 return stripTags(html), "", ""
51 }
52
53 rest := html[len("<b>"):]
54 closeB := strings.Index(rest, "</b>")
55 if closeB < 0 {
56 return stripTags(html), "", ""
57 }
58
59 titlePart := rest[:closeB]
60 after := rest[closeB+len("</b>"):]
61
62 cleanTitle := stripTags(titlePart)
63
64 if after == "" {
65 return cleanTitle, "", ""
66 }
67
68 // Parse remaining <br><font ...>...</font> segments
69 segments := parseFontSegments(after)
70
71 switch len(segments) {
72 case 1:
73 seg := segments[0]
74 if seg.color == descColor || seg.color == "#999999" {
75 // Description only (no technology)
76 return cleanTitle, "", unescapeHTML(stripTags(seg.text))
77 }
78 // Technology (with or without brackets)
79 return cleanTitle, unescapeHTML(trimBrackets(stripTags(seg.text))), ""
80 case 2:
81 tech := unescapeHTML(trimBrackets(stripTags(segments[0].text)))
82 desc := unescapeHTML(stripTags(segments[1].text))
83 return cleanTitle, tech, desc
84 default:
85 return cleanTitle, "", ""
86 }
87 }
88
89 type fontSegment struct {
90 color string
91 text string
92 }
93
94 // parseFontSegments extracts consecutive <br><font color="...">...</font> segments.
95 func parseFontSegments(s string) []fontSegment {
96 var segments []fontSegment
97 for strings.HasPrefix(s, "<br>") {
98 s = s[len("<br>"):]
99 if !strings.HasPrefix(s, "<font") {
100 break
101 }
102 // Extract color attribute
103 colorStart := strings.Index(s, `color="`)
104 if colorStart < 0 {
105 break
106 }
107 colorStart += len(`color="`)
108 colorEnd := strings.Index(s[colorStart:], `"`)
109 if colorEnd < 0 {
110 break
111 }
112 color := s[colorStart : colorStart+colorEnd]
113
114 // Extract text content
115 tagClose := strings.Index(s, ">")
116 if tagClose < 0 {
117 break
118 }
119 textStart := tagClose + 1
120 endFont := strings.Index(s[textStart:], "</font>")
121 if endFont < 0 {
122 break
123 }
124 text := s[textStart : textStart+endFont]
125 segments = append(segments, fontSegment{color: color, text: text})
126 s = s[textStart+endFont+len("</font>"):]
127 }
128 return segments
129 }
130
131 // trimBrackets removes surrounding square brackets if present.
132 func trimBrackets(s string) string {
133 if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") {
134 return s[1 : len(s)-1]
135 }
136 return s
137 }
138
139 // escapeHTML escapes special HTML characters in text content.
140 func escapeHTML(s string) string {
141 s = strings.ReplaceAll(s, "&", "&amp;")
142 s = strings.ReplaceAll(s, "<", "&lt;")
143 s = strings.ReplaceAll(s, ">", "&gt;")
144 s = strings.ReplaceAll(s, "\"", "&quot;")
145 return s
146 }
147
148 // unescapeHTML reverses HTML entity escaping.
149 func unescapeHTML(s string) string {
150 s = strings.ReplaceAll(s, "&quot;", "\"")
151 s = strings.ReplaceAll(s, "&gt;", ">")
152 s = strings.ReplaceAll(s, "&lt;", "<")
153 s = strings.ReplaceAll(s, "&amp;", "&")
154 return s
155 }
156
157 // stripTags removes all HTML tags from a string.
158 // Handles '>' inside quoted attribute values correctly (SEC-008).
159 func stripTags(s string) string {
160 var b strings.Builder
161 inTag := false
162 var quote rune
163 for _, r := range s {
164 switch {
165 case inTag && quote != 0:
166 if r == quote {
167 quote = 0
168 }
169 case inTag && (r == '"' || r == '\''):
170 quote = r
171 case r == '<':
172 inTag = true
173 case r == '>' && inTag:
174 inTag = false
175 case !inTag:
176 b.WriteRune(r)
177 }
178 }
179 return unescapeHTML(b.String())
180 }
181
github.com/docToolchain/Bausteinsicht/internal/drawio/template.go 83.7%
1 package drawio
2
3 import (
4 "fmt"
5 "os"
6 "strconv"
7 "strings"
8
9 "github.com/beevik/etree"
10 )
11
12 // CurrentTemplateVersion is the latest template format version supported.
13 const CurrentTemplateVersion = 1
14
15 // SubCellStyle holds style and geometry for a text sub-cell within an element.
16 type SubCellStyle struct {
17 Style string // mxCell style string
18 X, Y float64 // position relative to parent
19 Width, Height float64 // dimensions
20 }
21
22 // TemplateStyle holds the visual style and default dimensions for a draw.io element.
23 type TemplateStyle struct {
24 Style string // mxCell style string
25 Width float64 // default width from mxGeometry
26 Height float64 // default height from mxGeometry
27
28 // Sub-cell styles for grouped text labels (title, technology, description).
29 // Nil means no sub-cells defined (legacy template).
30 TitleStyle *SubCellStyle
31 TechStyle *SubCellStyle
32 DescStyle *SubCellStyle
33 }
34
35 // TemplateSet holds all styles parsed from a draw.io template file.
36 type TemplateSet struct {
37 Version int // template format version (0 means unset/v1)
38 elements map[string]TemplateStyle // keyed by kind (actor, system, container, component)
39 boundaries map[string]TemplateStyle // keyed by kind (system_boundary, container_boundary)
40 connector string // default connector style
41 }
42
43 // LoadTemplate parses a draw.io template file from disk.
44 func LoadTemplate(path string) (*TemplateSet, error) {
45 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
46 if err != nil {
47 return nil, fmt.Errorf("LoadTemplate %q: %w", path, err)
48 }
49 return LoadTemplateFromBytes(data)
50 }
51
52 // LoadTemplateFromBytes parses a draw.io template from raw XML bytes.
53 func LoadTemplateFromBytes(data []byte) (*TemplateSet, error) {
54 tree := etree.NewDocument()
55 if err := tree.ReadFromBytes(data); err != nil {
56 return nil, fmt.Errorf("LoadTemplateFromBytes: %w", err)
57 }
58
59 // Validate that the document is a valid draw.io file with an <mxfile> root.
60 root := tree.Root()
61 if root == nil || root.Tag != "mxfile" {
62 return nil, fmt.Errorf("LoadTemplateFromBytes: not a valid draw.io template (missing <mxfile> root element)")
63 }
64
65 // Read template version from <mxfile> root. Missing → version 1 (backward compat).
66 version := 1
67 if vStr := root.SelectAttrValue("bausteinsicht_template_version", ""); vStr != "" {
68 v, err := strconv.Atoi(vStr)
69 if err != nil {
70 return nil, fmt.Errorf("LoadTemplateFromBytes: invalid template version %q: %w", vStr, err)
71 }
72 version = v
73 }
74 if version > CurrentTemplateVersion {
75 return nil, fmt.Errorf("LoadTemplateFromBytes: template version %d not supported (max: %d)", version, CurrentTemplateVersion)
76 }
77
78 ts := &TemplateSet{
79 Version: version,
80 elements: make(map[string]TemplateStyle),
81 boundaries: make(map[string]TemplateStyle),
82 }
83
84 // Build maps for looking up child cells.
85 templateIDs := make(map[string]string) // template object id → kind
86 groupToKind := make(map[string]string) // group cell id → kind (when template is inside a group)
87
88 // Find all <object> elements with bausteinsicht_template attribute.
89 for _, obj := range tree.FindElements("//object[@bausteinsicht_template]") {
90 kind := obj.SelectAttrValue("bausteinsicht_template", "")
91 if kind == "" {
92 continue
93 }
94
95 cell := obj.FindElement("mxCell")
96 if cell == nil {
97 continue
98 }
99
100 style := cell.SelectAttrValue("style", "")
101 width, height := parseGeometry(cell)
102
103 objID := obj.SelectAttrValue("id", "")
104 if objID != "" {
105 templateIDs[objID] = kind
106 }
107
108 // If the template mxCell's parent is not "1", it's inside a group.
109 // Map the group ID so sub-cells with that group parent are found too.
110 cellParent := cell.SelectAttrValue("parent", "1")
111 if cellParent != "1" && cellParent != "0" && cellParent != "" {
112 groupToKind[cellParent] = kind
113 }
114
115 ts.categorize(kind, TemplateStyle{Style: style, Width: width, Height: height})
116 }
117
118 // Parse child mxCells that are sub-cells of template elements.
119 // Sub-cells may be children of the template object ID directly,
120 // or children of a group cell that contains the template object.
121 for _, cell := range tree.FindElements("//mxCell[@parent]") {
122 parentID := cell.SelectAttrValue("parent", "")
123 kind, ok := templateIDs[parentID]
124 if !ok {
125 kind, ok = groupToKind[parentID]
126 }
127 if !ok {
128 continue
129 }
130 cellID := cell.SelectAttrValue("id", "")
131 if cellID == "" {
132 continue
133 }
134 sub := parseSubCellStyle(cell)
135 if sub == nil {
136 continue
137 }
138
139 // Determine role from cell ID suffix or value heuristic.
140 role := ""
141 switch {
142 case strings.HasSuffix(cellID, "-title"):
143 role = "title"
144 case strings.HasSuffix(cellID, "-tech"):
145 role = "tech"
146 case strings.HasSuffix(cellID, "-desc"):
147 role = "desc"
148 default:
149 // Fallback: detect role by value attribute when ID doesn't follow convention
150 // (e.g., draw.io-generated IDs from manual template editing).
151 val := strings.TrimSpace(cell.SelectAttrValue("value", ""))
152 switch {
153 case strings.EqualFold(val, "title") || strings.HasSuffix(val, " Name"):
154 role = "title"
155 case strings.EqualFold(val, "[technology]"):
156 role = "tech"
157 case strings.EqualFold(val, "description"):
158 role = "desc"
159 }
160 }
161 if role != "" {
162 ts.setSubCell(kind, role, sub)
163 }
164 }
165
166 // Find relationship connector: bare <mxCell bausteinsicht_template="relationship">.
167 for _, cell := range tree.FindElements("//mxCell[@bausteinsicht_template='relationship']") {
168 ts.connector = cell.SelectAttrValue("style", "")
169 }
170
171 return ts, nil
172 }
173
174 // GetStyle returns the TemplateStyle for a given element kind.
175 func (t *TemplateSet) GetStyle(kind string) (TemplateStyle, bool) {
176 s, ok := t.elements[kind]
177 return s, ok
178 }
179
180 // GetBoundaryStyle returns the TemplateStyle for a given boundary kind.
181 func (t *TemplateSet) GetBoundaryStyle(kind string) (TemplateStyle, bool) {
182 s, ok := t.boundaries[kind]
183 return s, ok
184 }
185
186 // GetConnectorStyle returns the default connector style string.
187 func (t *TemplateSet) GetConnectorStyle() string {
188 return t.connector
189 }
190
191 // GetAllStyles returns a copy of all element styles keyed by kind.
192 func (t *TemplateSet) GetAllStyles() map[string]TemplateStyle {
193 out := make(map[string]TemplateStyle, len(t.elements))
194 for k, v := range t.elements {
195 out[k] = v
196 }
197 return out
198 }
199
200 // categorize places the style into the appropriate map based on its kind.
201 func (t *TemplateSet) categorize(kind string, style TemplateStyle) {
202 if strings.HasSuffix(kind, "_boundary") {
203 t.boundaries[kind] = style
204 } else {
205 t.elements[kind] = style
206 }
207 }
208
209 // setSubCell assigns a parsed sub-cell style to the appropriate field on a TemplateStyle.
210 func (t *TemplateSet) setSubCell(kind, role string, sub *SubCellStyle) {
211 // Look up in both maps.
212 if ts, ok := t.elements[kind]; ok {
213 setSubCellOnStyle(&ts, role, sub)
214 t.elements[kind] = ts
215 }
216 if ts, ok := t.boundaries[kind]; ok {
217 setSubCellOnStyle(&ts, role, sub)
218 t.boundaries[kind] = ts
219 }
220 }
221
222 func setSubCellOnStyle(ts *TemplateStyle, role string, sub *SubCellStyle) {
223 switch role {
224 case "title":
225 ts.TitleStyle = sub
226 case "tech":
227 ts.TechStyle = sub
228 case "desc":
229 ts.DescStyle = sub
230 }
231 }
232
233 // parseSubCellStyle parses style and geometry from a child mxCell element.
234 func parseSubCellStyle(cell *etree.Element) *SubCellStyle {
235 style := cell.SelectAttrValue("style", "")
236 if style == "" {
237 return nil
238 }
239 geo := cell.FindElement("mxGeometry")
240 if geo == nil {
241 return nil
242 }
243 x, _ := strconv.ParseFloat(geo.SelectAttrValue("x", "0"), 64)
244 y, _ := strconv.ParseFloat(geo.SelectAttrValue("y", "0"), 64)
245 w, _ := strconv.ParseFloat(geo.SelectAttrValue("width", "0"), 64)
246 h, _ := strconv.ParseFloat(geo.SelectAttrValue("height", "0"), 64)
247 return &SubCellStyle{Style: style, X: x, Y: y, Width: w, Height: h}
248 }
249
250 // parseGeometry extracts width and height from an mxCell's nested mxGeometry element.
251 func parseGeometry(cell *etree.Element) (float64, float64) {
252 geo := cell.FindElement("mxGeometry")
253 if geo == nil {
254 return 0, 0
255 }
256 w, _ := strconv.ParseFloat(geo.SelectAttrValue("width", "0"), 64)
257 h, _ := strconv.ParseFloat(geo.SelectAttrValue("height", "0"), 64)
258 return w, h
259 }
260
github.com/docToolchain/Bausteinsicht/internal/export/export.go 93.3%
1 // Package export handles exporting draw.io diagrams to PNG/SVG using the
2 // draw.io CLI.
3 package export
4
5 import (
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strconv"
12 "strings"
13 )
14
15 // ExportOptions configures a single page export operation.
16 type ExportOptions struct {
17 Format string // "png" or "svg"
18 PageIndex int // 1-based page index
19 OutputPath string // full path to output file
20 EmbedDiagram bool // embed draw.io XML source in output
21 InputFile string // path to the .drawio file
22 Scale float64 // export scale factor (0 = default, e.g. 2.0 for retina)
23 }
24
25 // platformPaths is a function variable so tests can override it.
26 var platformPaths = platformDrawioPaths
27
28 // DetectDrawioBinary finds the draw.io CLI binary.
29 // Search order:
30 // 1. "drawio-export" — devcontainer wrapper (Linux, adds xvfb + --no-sandbox)
31 // 2. "drawio" — on PATH (Linux package install)
32 // 3. Platform-native install paths (Windows, macOS) via platformPaths()
33 func DetectDrawioBinary() (string, error) {
34 var searched strings.Builder
35
36 // Try PATH first
37 for _, name := range []string{"drawio-export", "drawio"} {
38 path, err := exec.LookPath(name)
39 if err == nil {
40 return path, nil
41 }
42 fmt.Fprintf(&searched, " PATH: %s not found\n", name)
43 }
44
45 // Try platform-specific paths
46 for _, candidate := range platformPaths() {
47 if _, err := os.Stat(candidate); err == nil {
48 return candidate, nil
49 }
50 fmt.Fprintf(&searched, " %s not found\n", candidate)
51 }
52 return "", buildDrawioNotFoundError(searched.String())
53 }
54
55 // buildDrawioNotFoundError returns a detailed error message with troubleshooting steps and searched paths.
56 func buildDrawioNotFoundError(searchedPaths string) error {
57 msg := strings.Builder{}
58 msg.WriteString("draw.io CLI not found\n")
59 if searchedPaths != "" {
60 msg.WriteString("\nSearched locations:\n")
61 msg.WriteString(searchedPaths)
62 }
63 msg.WriteString("\nInstallation options:\n")
64 msg.WriteString(" Windows (Scoop): scoop install drawio\n")
65 msg.WriteString(" Windows (Choco): choco install drawio\n")
66 msg.WriteString(" macOS (Homebrew): brew install draw.io\n")
67 msg.WriteString(" Linux: See https://www.drawio.com\n\n")
68 msg.WriteString("If already installed, try these troubleshooting steps:\n")
69 msg.WriteString(" 1. Add draw.io to PATH (Scoop): scoop reset drawio\n")
70 msg.WriteString(" 2. Set env var: export BAUSTEINSICHT_DRAWIO_PATH=/path/to/draw.io\n")
71 msg.WriteString(" 3. Use CLI flag: bausteinsicht export --drawio-path /path/to/draw.io\n\n")
72 msg.WriteString("More info: https://github.com/docToolchain/Bausteinsicht/issues/385\n")
73 return errors.New(msg.String())
74 }
75
76 // BuildExportArgs constructs the command-line arguments for a draw.io export.
77 func BuildExportArgs(opts ExportOptions) []string {
78 args := []string{
79 "--export",
80 "--format", opts.Format,
81 "--page-index", strconv.Itoa(opts.PageIndex),
82 "--output", opts.OutputPath,
83 }
84 if opts.EmbedDiagram {
85 args = append(args, "--embed-diagram")
86 }
87 // Only pass --scale for values > 1. Scale=1 is draw.io's native resolution
88 // and does not need an explicit flag. Scale > 1 (e.g. 2.0 for retina) uses
89 // the GPU rendering pipeline and requires hardware GPU acceleration.
90 // Passing --scale 2 in headless containers (where the GPU process is
91 // disabled via ELECTRON_DISABLE_GPU) causes the GPU process to crash with
92 // exit code 9, resulting in a silent export failure (exit 0, no output file).
93 if opts.Scale > 1 {
94 args = append(args, "--scale", fmt.Sprintf("%g", opts.Scale))
95 }
96 args = append(args, "--", opts.InputFile)
97 return args
98 }
99
100 // SafeViewKey strips directory components from a view key to prevent
101 // path traversal when used in filenames (SEC-015).
102 func SafeViewKey(key string) string {
103 key = filepath.Base(strings.ReplaceAll(key, "\\", "/"))
104 return key
105 }
106
107 // OutputFileName returns the canonical output file name for a view export.
108 func OutputFileName(viewKey, format string) string {
109 return fmt.Sprintf("architecture-%s.%s", SafeViewKey(viewKey), format)
110 }
111
112 // ExportPage runs the draw.io CLI to export a single page.
113 func ExportPage(binary string, opts ExportOptions) error {
114 args := BuildExportArgs(opts)
115 cmd := exec.Command(binary, args...) // #nosec G204 -- binary is auto-detected draw.io CLI path
116 output, err := cmd.CombinedOutput()
117 if err != nil {
118 return fmt.Errorf("draw.io export failed: %w\nOutput: %s", err, string(output))
119 }
120 // Verify the output file was actually created (#195).
121 if _, err := os.Stat(opts.OutputPath); err != nil {
122 return fmt.Errorf("draw.io CLI exited successfully but output file not created: %s", opts.OutputPath)
123 }
124 return nil
125 }
126
github.com/docToolchain/Bausteinsicht/internal/export/platform_darwin.go 0.0%
1 //go:build darwin
2
3 package export
4
5 import (
6 "os"
7 "path/filepath"
8 )
9
10 // platformDrawioPaths returns platform-native draw.io install locations for macOS.
11 // Search order: Homebrew (Apple Silicon) → Homebrew (Intel) → App Store → Manual Install
12 func platformDrawioPaths() []string {
13 var paths []string
14
15 // Homebrew package manager (Apple Silicon - M1/M2/M3).
16 // Homebrew installs to /opt/homebrew/ on Apple Silicon Macs
17 paths = append(paths,
18 "/opt/homebrew/bin/draw.io",
19 "/opt/homebrew/opt/drawio/bin/draw.io",
20 )
21
22 // Homebrew package manager (Intel).
23 // Homebrew installs to /usr/local/ on Intel Macs
24 paths = append(paths,
25 "/usr/local/bin/draw.io",
26 "/usr/local/opt/drawio/bin/draw.io",
27 )
28
29 // Official installer or App Store install (system-wide).
30 paths = append(paths, "/Applications/draw.io.app/Contents/MacOS/draw.io")
31
32 // User-level install (~/Applications).
33 if home, err := os.UserHomeDir(); err == nil {
34 paths = append(paths, filepath.Join(home, "Applications", "draw.io.app", "Contents", "MacOS", "draw.io"))
35 }
36
37 return paths
38 }
39
github.com/docToolchain/Bausteinsicht/internal/exporter/structurizr/structurizr.go 93.9%
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 }
248
github.com/docToolchain/Bausteinsicht/internal/graph/analyzer.go 97.2%
1 package graph
2
3 import (
4 "sort"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // Analyzer performs graph analysis on model relationships.
10 type Analyzer struct {
11 model *model.BausteinsichtModel
12 graph map[string][]string // element ID → list of outgoing relationship targets
13 reverse map[string][]string // reverse graph: target → list of incoming sources
14 }
15
16 // NewAnalyzer creates a new graph analyzer.
17 func NewAnalyzer(m *model.BausteinsichtModel) *Analyzer {
18 a := &Analyzer{
19 model: m,
20 graph: make(map[string][]string),
21 reverse: make(map[string][]string),
22 }
23
24 // Build adjacency lists from relationships
25 flatElems, _ := model.FlattenElements(m)
26 for id := range flatElems {
27 a.graph[id] = []string{}
28 a.reverse[id] = []string{}
29 }
30
31 for _, rel := range m.Relationships {
32 if _, ok := flatElems[rel.From]; ok {
33 if _, ok := flatElems[rel.To]; ok {
34 a.graph[rel.From] = append(a.graph[rel.From], rel.To)
35 a.reverse[rel.To] = append(a.reverse[rel.To], rel.From)
36 }
37 }
38 }
39
40 return a
41 }
42
43 // Analyze performs comprehensive graph analysis.
44 func (a *Analyzer) Analyze() *GraphAnalysis {
45 flatElems, _ := model.FlattenElements(a.model)
46
47 result := &GraphAnalysis{
48 ElementCount: len(flatElems),
49 RelationshipCount: len(a.model.Relationships),
50 }
51
52 // Find all cycles
53 result.Cycles = a.findCycles()
54 result.IDAGValid = len(result.Cycles) == 0
55
56 // Calculate centrality metrics
57 result.Centrality = a.calculateCentrality()
58
59 // Find strongly connected components
60 result.Components = a.findStronglyConnectedComponents()
61
62 // Calculate maximum depth (longest path)
63 result.MaxDepth = a.calculateMaxDepth()
64
65 return result
66 }
67
68 // findCycles detects all cycles using Tarjan's algorithm.
69 func (a *Analyzer) findCycles() []Cycle {
70 var cycles []Cycle
71 index := 0
72 stack := []string{}
73 nodeInfo := make(map[string]*NodeInfo)
74
75 var strongconnect func(string)
76 strongconnect = func(v string) {
77 nodeInfo[v] = &NodeInfo{
78 index: index,
79 lowlink: index,
80 onStack: true,
81 }
82 index++
83 stack = append(stack, v)
84
85 for _, w := range a.graph[v] {
86 if _, ok := nodeInfo[w]; !ok {
87 strongconnect(w)
88 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].lowlink)
89 } else if nodeInfo[w].onStack {
90 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].index)
91 }
92 }
93
94 if nodeInfo[v].lowlink == nodeInfo[v].index {
95 var component []string
96 for {
97 w := stack[len(stack)-1]
98 stack = stack[:len(stack)-1]
99 nodeInfo[w].onStack = false
100 component = append(component, w)
101 if w == v {
102 break
103 }
104 }
105
106 // A cycle is an SCC with more than one element
107 if len(component) > 1 {
108 cycles = append(cycles, Cycle{Elements: component, Length: len(component)})
109 }
110 }
111 }
112
113 for v := range a.graph {
114 if _, ok := nodeInfo[v]; !ok {
115 strongconnect(v)
116 }
117 }
118
119 return cycles
120 }
121
122 // calculateCentrality computes centrality metrics for all elements.
123 func (a *Analyzer) calculateCentrality() []Centrality {
124 flatElems, _ := model.FlattenElements(a.model)
125 var results []Centrality
126
127 for id := range flatElems {
128 c := Centrality{
129 ID: id,
130 InDegree: len(a.reverse[id]),
131 OutDegree: len(a.graph[id]),
132 }
133
134 // Betweenness (simplified): count elements that depend on this element
135 betweenness := 0
136 for target := range flatElems {
137 if target != id && a.hasPath(id, target) {
138 betweenness++
139 }
140 }
141 c.Betweenness = float64(betweenness) / float64(len(flatElems)-1)
142
143 // Closeness (simplified): inverse of average distance
144 totalDist := 0
145 reachable := 0
146 for target := range flatElems {
147 if target != id {
148 if dist := a.shortestPath(id, target); dist > 0 {
149 totalDist += dist
150 reachable++
151 }
152 }
153 }
154 if reachable > 0 {
155 c.Closeness = 1.0 / (1.0 + float64(totalDist)/float64(reachable))
156 }
157
158 results = append(results, c)
159 }
160
161 // Sort by ID for consistent output
162 sort.Slice(results, func(i, j int) bool {
163 return results[i].ID < results[j].ID
164 })
165
166 return results
167 }
168
169 // findStronglyConnectedComponents finds all SCCs in the graph.
170 func (a *Analyzer) findStronglyConnectedComponents() []Component {
171 var components []Component
172 index := 0
173 stack := []string{}
174 nodeInfo := make(map[string]*NodeInfo)
175 componentID := 0
176
177 var strongconnect func(string)
178 strongconnect = func(v string) {
179 nodeInfo[v] = &NodeInfo{
180 index: index,
181 lowlink: index,
182 onStack: true,
183 }
184 index++
185 stack = append(stack, v)
186
187 for _, w := range a.graph[v] {
188 if _, ok := nodeInfo[w]; !ok {
189 strongconnect(w)
190 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].lowlink)
191 } else if nodeInfo[w].onStack {
192 nodeInfo[v].lowlink = min(nodeInfo[v].lowlink, nodeInfo[w].index)
193 }
194 }
195
196 if nodeInfo[v].lowlink == nodeInfo[v].index {
197 var component []string
198 for {
199 w := stack[len(stack)-1]
200 stack = stack[:len(stack)-1]
201 nodeInfo[w].onStack = false
202 component = append(component, w)
203 if w == v {
204 break
205 }
206 }
207
208 sort.Strings(component)
209 components = append(components, Component{
210 ID: componentID,
211 Elements: component,
212 IsCycle: len(component) > 1,
213 })
214 componentID++
215 }
216 }
217
218 flatElems, _ := model.FlattenElements(a.model)
219 for v := range flatElems {
220 if _, ok := nodeInfo[v]; !ok {
221 strongconnect(v)
222 }
223 }
224
225 return components
226 }
227
228 // calculateMaxDepth finds the longest dependency path in the graph.
229 // In cyclic graphs, returns 0 since there's no defined maximum.
230 func (a *Analyzer) calculateMaxDepth() int {
231 if !a.isDAG() {
232 return 0 // Cyclic graph has undefined max depth
233 }
234
235 flatElems, _ := model.FlattenElements(a.model)
236 maxDepth := 0
237
238 for start := range flatElems {
239 depth := a.longestPathDAG(start)
240 if depth > maxDepth {
241 maxDepth = depth
242 }
243 }
244
245 return maxDepth
246 }
247
248 // isDAG checks if the graph is acyclic.
249 func (a *Analyzer) isDAG() bool {
250 visited := make(map[string]int) // 0=unvisited, 1=visiting, 2=visited
251 var hasCycle bool
252
253 var visit func(string)
254 visit = func(node string) {
255 if visited[node] == 1 {
256 hasCycle = true
257 return
258 }
259 if visited[node] == 2 {
260 return
261 }
262
263 visited[node] = 1
264 for _, neighbor := range a.graph[node] {
265 visit(neighbor)
266 }
267 visited[node] = 2
268 }
269
270 flatElems, _ := model.FlattenElements(a.model)
271 for node := range flatElems {
272 if visited[node] == 0 {
273 visit(node)
274 }
275 }
276
277 return !hasCycle
278 }
279
280 // hasPath checks if there is a path from src to dst (BFS with limit).
281 func (a *Analyzer) hasPath(src, dst string) bool {
282 if src == dst {
283 return true
284 }
285
286 visited := make(map[string]bool)
287 queue := []string{src}
288
289 for len(queue) > 0 {
290 current := queue[0]
291 queue = queue[1:]
292
293 if visited[current] {
294 continue
295 }
296 visited[current] = true
297
298 if current == dst {
299 return true
300 }
301
302 for _, neighbor := range a.graph[current] {
303 if !visited[neighbor] {
304 queue = append(queue, neighbor)
305 }
306 }
307 }
308
309 return false
310 }
311
312 // shortestPath finds the shortest path from src to dst (BFS distance).
313 func (a *Analyzer) shortestPath(src, dst string) int {
314 if src == dst {
315 return 0
316 }
317
318 visited := make(map[string]bool)
319 queue := []string{src}
320 distances := map[string]int{src: 0}
321
322 for len(queue) > 0 {
323 current := queue[0]
324 queue = queue[1:]
325
326 if visited[current] {
327 continue
328 }
329 visited[current] = true
330
331 if current == dst {
332 return distances[current]
333 }
334
335 for _, neighbor := range a.graph[current] {
336 if !visited[neighbor] {
337 if _, ok := distances[neighbor]; !ok {
338 distances[neighbor] = distances[current] + 1
339 queue = append(queue, neighbor)
340 }
341 }
342 }
343 }
344
345 return -1 // no path found
346 }
347
348 // longestPathDAG finds the longest path starting from a node (DFS with memoization).
349 // Only valid for DAGs; cyclic graphs will have max depth 0.
350 func (a *Analyzer) longestPathDAG(start string) int {
351 memo := make(map[string]int)
352 return a.dfsLongestPath(start, memo)
353 }
354
355 func (a *Analyzer) dfsLongestPath(node string, memo map[string]int) int {
356 if depth, ok := memo[node]; ok {
357 return depth
358 }
359
360 maxDepth := 0
361 for _, neighbor := range a.graph[node] {
362 depth := 1 + a.dfsLongestPath(neighbor, memo)
363 if depth > maxDepth {
364 maxDepth = depth
365 }
366 }
367
368 memo[node] = maxDepth
369 return maxDepth
370 }
371
372 func min(a, b int) int {
373 if a < b {
374 return a
375 }
376 return b
377 }
378
github.com/docToolchain/Bausteinsicht/internal/health/analyzer.go 93.0%
1 package health
2
3 import (
4 "fmt"
5 "time"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // Analyzer computes health scores for a model.
11 type Analyzer struct {
12 model *model.BausteinsichtModel
13 }
14
15 // NewAnalyzer creates a new health analyzer.
16 func NewAnalyzer(m *model.BausteinsichtModel) *Analyzer {
17 return &Analyzer{model: m}
18 }
19
20 // Analyze computes a comprehensive health score.
21 func (a *Analyzer) Analyze() *HealthScore {
22 flatElems, _ := model.FlattenElements(a.model)
23
24 categories := []CategoryScore{
25 a.scoreCompleteness(flatElems),
26 a.scoreConformance(flatElems),
27 a.scoreComplexity(flatElems),
28 a.scoreDeprecation(flatElems),
29 a.scoreDocumentation(flatElems),
30 }
31
32 // Calculate weighted overall score
33 var totalScore float64
34 var totalWeight float64
35 for _, cat := range categories {
36 totalScore += cat.Score * cat.Weight
37 totalWeight += cat.Weight
38 }
39
40 overall := totalScore / totalWeight
41 if totalWeight == 0 {
42 overall = 0
43 }
44
45 return &HealthScore{
46 Overall: overall,
47 Categories: categories,
48 Grade: calculateGrade(overall),
49 Summary: summarizeHealth(overall),
50 Timestamp: time.Now().UTC().Format(time.RFC3339),
51 ElementCnt: len(flatElems),
52 RelCnt: len(a.model.Relationships),
53 ViewCnt: len(a.model.Views),
54 }
55 }
56
57 // scoreCompleteness measures how well-documented the model is.
58 func (a *Analyzer) scoreCompleteness(elems map[string]*model.Element) CategoryScore {
59 var findings []Finding
60 documented := 0
61 missing := 0
62
63 for id, elem := range elems {
64 if elem.Title == "" || (elem.Description == "" && elem.Technology == "") {
65 findings = append(findings, Finding{
66 Category: CategoryCompleteness,
67 Severity: "minor",
68 Title: "Missing element description or technology",
69 Message: fmt.Sprintf("element %q lacks description or technology", id),
70 Elements: []string{id},
71 })
72 missing++
73 } else {
74 documented++
75 }
76 }
77
78 score := float64(documented) / float64(documented+missing) * 100
79 if documented+missing == 0 {
80 score = 100
81 }
82
83 return CategoryScore{
84 Category: CategoryCompleteness,
85 Score: score,
86 Weight: 0.2,
87 Findings: findings,
88 Details: fmt.Sprintf("%d/%d elements documented", documented, documented+missing),
89 }
90 }
91
92 // scoreConformance checks for policy violations.
93 func (a *Analyzer) scoreConformance(elems map[string]*model.Element) CategoryScore {
94 var findings []Finding
95
96 // Check for undefined relationship kinds
97 for i, rel := range a.model.Relationships {
98 if rel.Kind != "" {
99 if _, ok := a.model.Specification.Relationships[rel.Kind]; !ok {
100 findings = append(findings, Finding{
101 Category: CategoryConformance,
102 Severity: "major",
103 Title: "Undefined relationship kind",
104 Message: fmt.Sprintf("relationships[%d] uses unknown kind %q", i, rel.Kind),
105 Elements: []string{rel.From, rel.To},
106 })
107 }
108 }
109 }
110
111 // Check for undefined element kinds
112 for id, elem := range elems {
113 if _, ok := a.model.Specification.Elements[elem.Kind]; !ok {
114 findings = append(findings, Finding{
115 Category: CategoryConformance,
116 Severity: "major",
117 Title: "Undefined element kind",
118 Message: fmt.Sprintf("element %q uses unknown kind %q", id, elem.Kind),
119 Elements: []string{id},
120 })
121 }
122 }
123
124 score := 100.0 - float64(len(findings)*5)
125 if score < 0 {
126 score = 0
127 }
128
129 return CategoryScore{
130 Category: CategoryConformance,
131 Score: score,
132 Weight: 0.3,
133 Findings: findings,
134 Details: fmt.Sprintf("%d violations found", len(findings)),
135 }
136 }
137
138 // scoreComplexity assesses architectural complexity.
139 func (a *Analyzer) scoreComplexity(elems map[string]*model.Element) CategoryScore {
140 var findings []Finding
141
142 // Measure relationship density
143 maxRels := len(elems) * (len(elems) - 1)
144 if maxRels == 0 {
145 maxRels = 1
146 }
147 density := float64(len(a.model.Relationships)) / float64(maxRels)
148
149 // Count high-degree nodes (elements with many relationships)
150 inDegree := make(map[string]int)
151 outDegree := make(map[string]int)
152 for _, rel := range a.model.Relationships {
153 inDegree[rel.To]++
154 outDegree[rel.From]++
155 }
156
157 for id, out := range outDegree {
158 if out > 5 {
159 findings = append(findings, Finding{
160 Category: CategoryComplexity,
161 Severity: "major",
162 Title: "High outgoing dependency count",
163 Message: fmt.Sprintf("element %q has %d outgoing relationships (threshold: 5)", id, out),
164 Elements: []string{id},
165 })
166 }
167 }
168
169 // Score based on density and high-degree nodes
170 score := 100.0
171 if density > 0.3 {
172 score -= 20
173 findings = append(findings, Finding{
174 Category: CategoryComplexity,
175 Severity: "minor",
176 Title: "High relationship density",
177 Message: fmt.Sprintf("Architecture has %d relationships across %d elements (density: %.2f)", len(a.model.Relationships), len(elems), density),
178 })
179 }
180 score -= float64(len(findings)) * 3
181
182 if score < 0 {
183 score = 0
184 }
185
186 return CategoryScore{
187 Category: CategoryComplexity,
188 Score: score,
189 Weight: 0.15,
190 Findings: findings,
191 Details: fmt.Sprintf("density: %.2f, high-degree nodes: %d", density, len(findings)),
192 }
193 }
194
195 // scoreDeprecation checks for deprecated elements still in use.
196 func (a *Analyzer) scoreDeprecation(elems map[string]*model.Element) CategoryScore {
197 var findings []Finding
198 deprecated := 0
199 active := 0
200
201 for id, elem := range elems {
202 switch elem.Status {
203 case model.StatusDeprecated:
204 deprecated++
205 findings = append(findings, Finding{
206 Category: CategoryDeprecation,
207 Severity: "major",
208 Title: "Deprecated element still present",
209 Message: fmt.Sprintf("element %q is marked as deprecated", id),
210 Elements: []string{id},
211 })
212 case model.StatusDeployed, "":
213 active++
214 }
215 }
216
217 score := 100.0 - float64(deprecated*10)
218 if score < 0 {
219 score = 0
220 }
221
222 return CategoryScore{
223 Category: CategoryDeprecation,
224 Score: score,
225 Weight: 0.15,
226 Findings: findings,
227 Details: fmt.Sprintf("%d active, %d deprecated", active, deprecated),
228 }
229 }
230
231 // scoreDocumentation measures the quality of documentation.
232 func (a *Analyzer) scoreDocumentation(elems map[string]*model.Element) CategoryScore {
233 var findings []Finding
234 withDocs := 0
235
236 for id, elem := range elems {
237 if elem.Description != "" && len(elem.Description) > 20 {
238 withDocs++
239 } else if elem.Description != "" {
240 findings = append(findings, Finding{
241 Category: CategoryDocumentation,
242 Severity: "minor",
243 Title: "Brief element description",
244 Message: fmt.Sprintf("element %q has short description (< 20 chars)", id),
245 Elements: []string{id},
246 })
247 }
248 }
249
250 score := (float64(withDocs) / float64(len(elems))) * 100
251 if len(elems) == 0 {
252 score = 100
253 }
254
255 return CategoryScore{
256 Category: CategoryDocumentation,
257 Score: score,
258 Weight: 0.2,
259 Findings: findings,
260 Details: fmt.Sprintf("%d/%d elements have substantial descriptions", withDocs, len(elems)),
261 }
262 }
263
264 // summarizeHealth creates a human-readable summary.
265 func summarizeHealth(score float64) string {
266 switch {
267 case score >= 90:
268 return "Excellent architecture. Well-structured, documented, and maintainable."
269 case score >= 80:
270 return "Good architecture. Minor improvements recommended."
271 case score >= 70:
272 return "Acceptable architecture. Several areas need attention."
273 case score >= 60:
274 return "Fair architecture. Multiple improvements needed."
275 default:
276 return "Poor architecture. Significant refactoring recommended."
277 }
278 }
279
github.com/docToolchain/Bausteinsicht/internal/health/types.go 100.0%
1 package health
2
3 // ScoreCategory represents a dimension of architectural health.
4 type ScoreCategory string
5
6 const (
7 CategoryCompleteness ScoreCategory = "completeness"
8 CategoryConformance ScoreCategory = "conformance"
9 CategoryComplexity ScoreCategory = "complexity"
10 CategoryDeprecation ScoreCategory = "deprecation"
11 CategoryDocumentation ScoreCategory = "documentation"
12 )
13
14 // Finding describes a single health issue or improvement area.
15 type Finding struct {
16 Category ScoreCategory `json:"category"`
17 Severity string `json:"severity"` // "critical", "major", "minor", "info"
18 Title string `json:"title"`
19 Message string `json:"message"`
20 Elements []string `json:"elements,omitempty"` // affected element IDs
21 }
22
23 // CategoryScore represents the score for a single dimension.
24 type CategoryScore struct {
25 Category ScoreCategory `json:"category"`
26 Score float64 `json:"score"` // 0-100
27 Weight float64 `json:"weight"` // 0-1
28 Findings []Finding `json:"findings"`
29 Details string `json:"details,omitempty"`
30 }
31
32 // HealthScore is the overall architecture health assessment.
33 type HealthScore struct {
34 Overall float64 `json:"overall"` // 0-100 weighted average
35 Categories []CategoryScore `json:"categories"`
36 Grade string `json:"grade"` // A+, A, B+, B, C+, C, D, F
37 Summary string `json:"summary"`
38 Timestamp string `json:"timestamp"` // ISO8601
39 ElementCnt int `json:"elementCnt"`
40 RelCnt int `json:"relCnt"`
41 ViewCnt int `json:"viewCnt"`
42 }
43
44 // calculateGrade converts a numeric score to a letter grade.
45 func calculateGrade(score float64) string {
46 switch {
47 case score >= 97:
48 return "A+"
49 case score >= 93:
50 return "A"
51 case score >= 90:
52 return "B+"
53 case score >= 87:
54 return "B"
55 case score >= 80:
56 return "C+"
57 case score >= 70:
58 return "C"
59 case score >= 60:
60 return "D"
61 default:
62 return "F"
63 }
64 }
65
github.com/docToolchain/Bausteinsicht/internal/importer/likec4/likec4.go 70.7%
1 // Package likec4 parses LikeC4 DSL files and converts them to the
2 // Bausteinsicht model format.
3 package likec4
4
5 import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "unicode"
11
12 "github.com/docToolchain/Bausteinsicht/internal/importer"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 )
15
16 // ─── Tokenizer (identical grammar to Structurizr) ────────────────────────────
17
18 type tokKind int
19
20 const (
21 tokEOF tokKind = iota
22 tokNewline
23 tokString
24 tokIdent
25 tokLBrace
26 tokRBrace
27 tokAssign
28 tokArrow
29 )
30
31 type token struct {
32 kind tokKind
33 val string
34 line int
35 }
36
37 type scanner struct {
38 src []rune
39 pos int
40 line int
41 }
42
43 func tokenize(src string) ([]token, error) {
44 s := &scanner{src: []rune(src), line: 1}
45 var toks []token
46 for {
47 tok, err := s.next()
48 if err != nil {
49 return nil, err
50 }
51 toks = append(toks, tok)
52 if tok.kind == tokEOF {
53 break
54 }
55 }
56 return toks, nil
57 }
58
59 func (s *scanner) at(offset int) (rune, bool) {
60 i := s.pos + offset
61 if i >= len(s.src) {
62 return 0, false
63 }
64 return s.src[i], true
65 }
66
67 func (s *scanner) consume() rune {
68 r := s.src[s.pos]
69 s.pos++
70 if r == '\n' {
71 s.line++
72 }
73 return r
74 }
75
76 func (s *scanner) next() (token, error) {
77 for {
78 c, ok := s.at(0)
79 if !ok {
80 return token{kind: tokEOF, line: s.line}, nil
81 }
82 if c == ' ' || c == '\t' || c == '\r' {
83 s.consume()
84 continue
85 }
86 if c == '/' {
87 n, _ := s.at(1)
88 if n == '/' {
89 for {
90 ch, ok := s.at(0)
91 if !ok || ch == '\n' {
92 break
93 }
94 s.consume()
95 }
96 continue
97 }
98 if n == '*' {
99 s.consume()
100 s.consume()
101 for {
102 ch, ok := s.at(0)
103 if !ok {
104 return token{}, fmt.Errorf("unterminated block comment")
105 }
106 s.consume()
107 if ch == '*' {
108 if nn, _ := s.at(0); nn == '/' {
109 s.consume()
110 break
111 }
112 }
113 }
114 continue
115 }
116 }
117 break
118 }
119
120 c, ok := s.at(0)
121 if !ok {
122 return token{kind: tokEOF, line: s.line}, nil
123 }
124 line := s.line
125
126 if c == '\n' {
127 for {
128 ch, ok := s.at(0)
129 if !ok || ch != '\n' {
130 break
131 }
132 s.consume()
133 }
134 return token{kind: tokNewline, line: line}, nil
135 }
136
137 switch {
138 case c == '{':
139 s.consume()
140 return token{kind: tokLBrace, val: "{", line: line}, nil
141 case c == '}':
142 s.consume()
143 return token{kind: tokRBrace, val: "}", line: line}, nil
144 case c == '=':
145 s.consume()
146 return token{kind: tokAssign, val: "=", line: line}, nil
147 case c == '-':
148 if n, _ := s.at(1); n == '>' {
149 s.consume()
150 s.consume()
151 return token{kind: tokArrow, val: "->", line: line}, nil
152 }
153 s.consume()
154 return s.next()
155 case c == '"':
156 return s.scanString(line)
157 case unicode.IsLetter(c) || c == '_':
158 return s.scanIdent(line)
159 default:
160 s.consume()
161 return s.next()
162 }
163 }
164
165 func (s *scanner) scanString(line int) (token, error) {
166 s.consume()
167 var sb strings.Builder
168 for {
169 c, ok := s.at(0)
170 if !ok {
171 return token{}, fmt.Errorf("line %d: unterminated string", line)
172 }
173 if c == '"' {
174 s.consume()
175 break
176 }
177 if c == '\\' {
178 s.consume()
179 esc, ok := s.at(0)
180 if !ok {
181 return token{}, fmt.Errorf("line %d: EOF in string escape", line)
182 }
183 s.consume()
184 switch esc {
185 case '"', '\\':
186 sb.WriteRune(esc)
187 case 'n':
188 sb.WriteRune('\n')
189 default:
190 sb.WriteRune('\\')
191 sb.WriteRune(esc)
192 }
193 continue
194 }
195 sb.WriteRune(s.consume())
196 }
197 return token{kind: tokString, val: sb.String(), line: line}, nil
198 }
199
200 func (s *scanner) scanIdent(line int) (token, error) {
201 var sb strings.Builder
202 for {
203 c, ok := s.at(0)
204 if !ok {
205 break
206 }
207 if c == '-' {
208 if n, _ := s.at(1); n == '>' {
209 break
210 }
211 sb.WriteRune(s.consume())
212 continue
213 }
214 if unicode.IsLetter(c) || unicode.IsDigit(c) || c == '_' || c == '.' || c == '/' || c == ':' {
215 sb.WriteRune(s.consume())
216 continue
217 }
218 break
219 }
220 return token{kind: tokIdent, val: sb.String(), line: line}, nil
221 }
222
223 // ─── Parser ──────────────────────────────────────────────────────────────────
224
225 type stmt struct {
226 line int
227 varName string
228 keyword string
229 args []string
230 isRel bool
231 relFrom string
232 relTo string
233 body []stmt
234 }
235
236 type dslParser struct {
237 toks []token
238 pos int
239 }
240
241 func (p *dslParser) peek() token {
242 if p.pos >= len(p.toks) {
243 return token{kind: tokEOF}
244 }
245 return p.toks[p.pos]
246 }
247
248 func (p *dslParser) advance() token {
249 t := p.peek()
250 if t.kind != tokEOF {
251 p.pos++
252 }
253 return t
254 }
255
256 func (p *dslParser) skipNewlines() {
257 for p.peek().kind == tokNewline {
258 p.advance()
259 }
260 }
261
262 func (p *dslParser) parseAll() ([]stmt, error) {
263 return p.parseStmts(false)
264 }
265
266 func (p *dslParser) parseStmts(inBlock bool) ([]stmt, error) {
267 var stmts []stmt
268 for {
269 p.skipNewlines()
270 tok := p.peek()
271 if tok.kind == tokEOF {
272 break
273 }
274 if inBlock && tok.kind == tokRBrace {
275 break
276 }
277 s, err := p.parseOneStmt()
278 if err != nil {
279 return nil, err
280 }
281 if s != nil {
282 stmts = append(stmts, *s)
283 }
284 }
285 return stmts, nil
286 }
287
288 func (p *dslParser) parseBlock() ([]stmt, error) {
289 if p.peek().kind != tokLBrace {
290 return nil, nil
291 }
292 p.advance()
293 p.skipNewlines()
294 stmts, err := p.parseStmts(true)
295 if err != nil {
296 return nil, err
297 }
298 if p.peek().kind == tokRBrace {
299 p.advance()
300 }
301 return stmts, nil
302 }
303
304 func (p *dslParser) optBlock(s *stmt) error {
305 p.skipNewlines()
306 if p.peek().kind == tokLBrace {
307 body, err := p.parseBlock()
308 if err != nil {
309 return err
310 }
311 s.body = body
312 }
313 return nil
314 }
315
316 func (p *dslParser) parseOneStmt() (*stmt, error) {
317 tok := p.peek()
318 if tok.kind == tokEOF || tok.kind == tokRBrace {
319 return nil, nil
320 }
321
322 line := tok.line
323
324 if tok.kind == tokArrow {
325 p.advance()
326 to := p.advance()
327 s := &stmt{line: line, isRel: true, relTo: to.val, args: p.collectArgs()}
328 if err := p.optBlock(s); err != nil {
329 return nil, err
330 }
331 return s, nil
332 }
333
334 if tok.kind == tokLBrace {
335 if _, err := p.parseBlock(); err != nil {
336 return nil, err
337 }
338 return nil, nil
339 }
340
341 if tok.kind != tokIdent && tok.kind != tokString {
342 p.advance()
343 return nil, nil
344 }
345
346 p.advance()
347
348 switch p.peek().kind {
349 case tokAssign:
350 p.advance()
351 kw := p.advance()
352 s := &stmt{line: line, varName: tok.val, keyword: kw.val, args: p.collectArgs()}
353 if err := p.optBlock(s); err != nil {
354 return nil, err
355 }
356 return s, nil
357
358 case tokArrow:
359 p.advance()
360 to := p.advance()
361 s := &stmt{line: line, isRel: true, relFrom: tok.val, relTo: to.val, args: p.collectArgs()}
362 if err := p.optBlock(s); err != nil {
363 return nil, err
364 }
365 return s, nil
366
367 default:
368 s := &stmt{line: line, keyword: tok.val, args: p.collectArgs()}
369 if err := p.optBlock(s); err != nil {
370 return nil, err
371 }
372 return s, nil
373 }
374 }
375
376 func (p *dslParser) collectArgs() []string {
377 var args []string
378 for {
379 k := p.peek().kind
380 if k == tokString || k == tokIdent {
381 args = append(args, p.advance().val)
382 } else {
383 break
384 }
385 }
386 return args
387 }
388
389 // ─── Mapper ──────────────────────────────────────────────────────────────────
390
391 type lc4State struct {
392 kinds map[string]bool // known element kind names from specification
393 kindsContainer map[string]bool // kinds that have children in the model
394 spec map[string]model.ElementKind
395 elements map[string]model.Element
396 varToPath map[string]string
397 pendingRels []pendingRel
398 views map[string]model.View
399 viewKeys map[string]int
400 warnings []string
401 }
402
403 type pendingRel struct {
404 from string
405 to string
406 label string
407 line int
408 }
409
410 func newLC4State() *lc4State {
411 return &lc4State{
412 kinds: make(map[string]bool),
413 kindsContainer: make(map[string]bool),
414 spec: make(map[string]model.ElementKind),
415 elements: make(map[string]model.Element),
416 varToPath: make(map[string]string),
417 views: make(map[string]model.View),
418 viewKeys: make(map[string]int),
419 }
420 }
421
422 func (ls *lc4State) processSpecification(stmts []stmt) {
423 for _, s := range stmts {
424 switch s.keyword {
425 case "element":
426 if len(s.args) == 0 {
427 continue
428 }
429 kindName := s.args[0]
430 ls.kinds[kindName] = true
431 notation := strings.ToUpper(kindName[:1]) + kindName[1:]
432 ls.spec[kindName] = model.ElementKind{Notation: notation}
433 case "relationship":
434 // ignore — Bausteinsicht relationships don't need pre-declared types
435 }
436 }
437 }
438
439 func (ls *lc4State) resolveVar(v string) string {
440 if p, ok := ls.varToPath[v]; ok {
441 return p
442 }
443 return v
444 }
445
446 func (ls *lc4State) processModelStmts(stmts []stmt, parentPath, parentVar string, dest map[string]model.Element) {
447 for _, s := range stmts {
448 ls.processModelStmt(s, parentPath, parentVar, dest)
449 }
450 }
451
452 func (ls *lc4State) processModelStmt(s stmt, parentPath, parentVar string, dest map[string]model.Element) {
453 if s.isRel {
454 from := s.relFrom
455 if from == "" {
456 from = parentVar
457 }
458 label := ""
459 if len(s.args) > 0 {
460 label = s.args[0]
461 }
462 ls.pendingRels = append(ls.pendingRels, pendingRel{from: from, to: s.relTo, label: label, line: s.line})
463 return
464 }
465
466 if !ls.kinds[s.keyword] {
467 // Not a known kind — treat as property inside element body
468 return
469 }
470
471 key := s.varName
472 if key == "" {
473 if len(s.args) > 0 {
474 key = slugify(s.args[0])
475 } else {
476 key = s.keyword
477 }
478 ls.warnings = append(ls.warnings, fmt.Sprintf("line %d: element has no variable name, using %q", s.line, key))
479 }
480
481 path := key
482 if parentPath != "" {
483 path = parentPath + "." + key
484 }
485 ls.varToPath[key] = path
486
487 el := model.Element{Kind: s.keyword}
488 if len(s.args) > 0 {
489 el.Title = s.args[0]
490 }
491
492 children := make(map[string]model.Element)
493 for _, child := range s.body {
494 switch {
495 case child.isRel:
496 from := child.relFrom
497 if from == "" {
498 from = key
499 }
500 label := ""
501 if len(child.args) > 0 {
502 label = child.args[0]
503 }
504 ls.pendingRels = append(ls.pendingRels, pendingRel{from: from, to: child.relTo, label: label, line: child.line})
505 case ls.kinds[child.keyword]:
506 ls.processModelStmt(child, path, key, children)
507 case child.keyword == "description" && len(child.args) > 0:
508 el.Description = child.args[0]
509 case child.keyword == "technology" && len(child.args) > 0:
510 el.Technology = child.args[0]
511 case child.keyword == "title" && len(child.args) > 0:
512 el.Title = child.args[0]
513 case child.keyword == "tags":
514 el.Tags = child.args
515 }
516 }
517
518 if len(children) > 0 {
519 el.Children = children
520 ls.kindsContainer[s.keyword] = true
521 }
522
523 dest[key] = el
524 }
525
526 func (ls *lc4State) processViews(stmts []stmt) {
527 for _, s := range stmts {
528 if s.keyword != "view" {
529 continue
530 }
531
532 // LikeC4: view <key> [of <element>] { ... }
533 // args can be: ["key"], ["key", "of", "element"], or ["key", "of", "element", "title"]
534 viewKey := ""
535 scope := ""
536 title := ""
537
538 args := s.args
539 if len(args) > 0 {
540 viewKey = args[0]
541 args = args[1:]
542 }
543
544 // Check for "of" keyword
545 if len(args) >= 2 && args[0] == "of" {
546 scope = ls.resolveVar(args[1])
547 args = args[2:]
548 }
549
550 title = strings.Join(args, " ")
551
552 if viewKey == "" {
553 baseKey := "view"
554 if scope != "" {
555 baseKey = scope
556 }
557 viewKey = baseKey
558 if ls.viewKeys[baseKey] > 0 {
559 viewKey = fmt.Sprintf("%s_%d", baseKey, ls.viewKeys[baseKey])
560 }
561 }
562 ls.viewKeys[viewKey]++
563
564 if title == "" {
565 title = viewKey
566 }
567
568 v := model.View{Title: title, Scope: scope, Include: []string{"*"}}
569
570 for _, bs := range s.body {
571 switch bs.keyword {
572 case "title":
573 if len(bs.args) > 0 {
574 v.Title = bs.args[0]
575 }
576 case "description":
577 if len(bs.args) > 0 {
578 v.Description = bs.args[0]
579 }
580 case "include":
581 if len(bs.args) == 1 && bs.args[0] == "*" {
582 v.Include = []string{"*"}
583 } else {
584 v.Include = nil
585 for _, arg := range bs.args {
586 if arg == "*" {
587 v.Include = []string{"*"}
588 break
589 }
590 v.Include = append(v.Include, ls.resolveVar(arg))
591 }
592 }
593 case "exclude":
594 for _, arg := range bs.args {
595 v.Exclude = append(v.Exclude, ls.resolveVar(arg))
596 }
597 }
598 }
599
600 ls.views[viewKey] = v
601 }
602 }
603
604 func (ls *lc4State) buildRelationships() []model.Relationship {
605 var rels []model.Relationship
606 for _, pr := range ls.pendingRels {
607 fromPath := ls.resolveVar(pr.from)
608 toPath := ls.resolveVar(pr.to)
609 if fromPath == "" || toPath == "" {
610 ls.warnings = append(ls.warnings, fmt.Sprintf("line %d: relationship skipped (unresolved variable)", pr.line))
611 continue
612 }
613 rels = append(rels, model.Relationship{From: fromPath, To: toPath, Label: pr.label})
614 }
615 return rels
616 }
617
618 func (ls *lc4State) updateSpecWithContainers() {
619 for kind, ek := range ls.spec {
620 if ls.kindsContainer[kind] {
621 ek.Container = true
622 ls.spec[kind] = ek
623 }
624 }
625 }
626
627 func slugify(s string) string {
628 s = strings.ToLower(s)
629 var sb strings.Builder
630 prevUnderscore := false
631 for _, r := range s {
632 if unicode.IsLetter(r) || unicode.IsDigit(r) {
633 sb.WriteRune(r)
634 prevUnderscore = false
635 } else if !prevUnderscore && sb.Len() > 0 {
636 sb.WriteRune('_')
637 prevUnderscore = true
638 }
639 }
640 result := strings.TrimRight(sb.String(), "_")
641 if result == "" {
642 return "element"
643 }
644 return result
645 }
646
647 // ─── Public API ──────────────────────────────────────────────────────────────
648
649 const schemaURL = "https://raw.githubusercontent.com/docToolchain/Bausteinsicht/main/schema/bausteinsicht.schema.json"
650
651 // Import reads the LikeC4 DSL file at path and returns an ImportResult.
652 func Import(path string) (*importer.ImportResult, error) {
653 data, err := os.ReadFile(path)
654 if err != nil {
655 return nil, fmt.Errorf("reading %s: %w", path, err)
656 }
657 _ = filepath.Dir(path) // reserved for future !include support
658 return importSource(string(data))
659 }
660
661 // ImportSource parses a LikeC4 DSL string directly (useful for testing).
662 func ImportSource(src string) (*importer.ImportResult, error) {
663 return importSource(src)
664 }
665
666 func importSource(src string) (*importer.ImportResult, error) {
667 toks, err := tokenize(src)
668 if err != nil {
669 return nil, fmt.Errorf("tokenize: %w", err)
670 }
671
672 p := &dslParser{toks: toks}
673 stmts, err := p.parseAll()
674 if err != nil {
675 return nil, fmt.Errorf("parse: %w", err)
676 }
677
678 ls := newLC4State()
679
680 for _, s := range stmts {
681 switch s.keyword {
682 case "specification":
683 ls.processSpecification(s.body)
684 case "model":
685 ls.processModelStmts(s.body, "", "", ls.elements)
686 case "views":
687 ls.processViews(s.body)
688 }
689 }
690
691 ls.updateSpecWithContainers()
692
693 rels := ls.buildRelationships()
694
695 spec := model.Specification{Elements: make(map[string]model.ElementKind)}
696 for k, v := range ls.spec {
697 spec.Elements[k] = v
698 }
699
700 m := &model.BausteinsichtModel{
701 Schema: schemaURL,
702 Specification: spec,
703 Model: ls.elements,
704 Relationships: rels,
705 Views: ls.views,
706 }
707 if m.Relationships == nil {
708 m.Relationships = []model.Relationship{}
709 }
710 if m.Views == nil {
711 m.Views = make(map[string]model.View)
712 }
713
714 return &importer.ImportResult{Model: m, Warnings: ls.warnings}, nil
715 }
716
github.com/docToolchain/Bausteinsicht/internal/importer/structurizr/structurizr.go 71.8%
1 // Package structurizr parses Structurizr DSL files and converts them to the
2 // Bausteinsicht model format.
3 package structurizr
4
5 import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "unicode"
11
12 "github.com/docToolchain/Bausteinsicht/internal/importer"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 )
15
16 // ─── Tokenizer ───────────────────────────────────────────────────────────────
17
18 type tokKind int
19
20 const (
21 tokEOF tokKind = iota
22 tokNewline // statement separator — emitted for each (group of) newline(s)
23 tokString
24 tokIdent
25 tokLBrace
26 tokRBrace
27 tokAssign
28 tokArrow
29 )
30
31 type token struct {
32 kind tokKind
33 val string
34 line int
35 }
36
37 type scanner struct {
38 src []rune
39 pos int
40 line int
41 }
42
43 func tokenize(src string) ([]token, error) {
44 s := &scanner{src: []rune(src), line: 1}
45 var toks []token
46 for {
47 tok, err := s.next()
48 if err != nil {
49 return nil, err
50 }
51 toks = append(toks, tok)
52 if tok.kind == tokEOF {
53 break
54 }
55 }
56 return toks, nil
57 }
58
59 func (s *scanner) at(offset int) (rune, bool) {
60 i := s.pos + offset
61 if i >= len(s.src) {
62 return 0, false
63 }
64 return s.src[i], true
65 }
66
67 func (s *scanner) consume() rune {
68 r := s.src[s.pos]
69 s.pos++
70 if r == '\n' {
71 s.line++
72 }
73 return r
74 }
75
76 func (s *scanner) next() (token, error) {
77 // Skip horizontal whitespace and handle comments.
78 // Newlines are NOT skipped here — they are emitted as tokNewline.
79 for {
80 c, ok := s.at(0)
81 if !ok {
82 return token{kind: tokEOF, line: s.line}, nil
83 }
84 if c == ' ' || c == '\t' || c == '\r' {
85 s.consume()
86 continue
87 }
88 if c == '/' {
89 n, _ := s.at(1)
90 if n == '/' {
91 // Line comment: consume to end of line (leave \n for next call)
92 for {
93 ch, ok := s.at(0)
94 if !ok || ch == '\n' {
95 break
96 }
97 s.consume()
98 }
99 continue
100 }
101 if n == '*' {
102 s.consume()
103 s.consume()
104 for {
105 ch, ok := s.at(0)
106 if !ok {
107 return token{}, fmt.Errorf("unterminated block comment")
108 }
109 s.consume()
110 if ch == '*' {
111 if nn, _ := s.at(0); nn == '/' {
112 s.consume()
113 break
114 }
115 }
116 }
117 continue
118 }
119 }
120 break
121 }
122
123 c, ok := s.at(0)
124 if !ok {
125 return token{kind: tokEOF, line: s.line}, nil
126 }
127 line := s.line
128
129 // Collapse consecutive newlines into a single tokNewline.
130 if c == '\n' {
131 for {
132 ch, ok := s.at(0)
133 if !ok || ch != '\n' {
134 break
135 }
136 s.consume()
137 }
138 return token{kind: tokNewline, line: line}, nil
139 }
140
141 switch {
142 case c == '{':
143 s.consume()
144 return token{kind: tokLBrace, val: "{", line: line}, nil
145 case c == '}':
146 s.consume()
147 return token{kind: tokRBrace, val: "}", line: line}, nil
148 case c == '=':
149 s.consume()
150 return token{kind: tokAssign, val: "=", line: line}, nil
151 case c == '-':
152 if n, _ := s.at(1); n == '>' {
153 s.consume()
154 s.consume()
155 return token{kind: tokArrow, val: "->", line: line}, nil
156 }
157 s.consume()
158 return s.next()
159 case c == '"':
160 return s.scanString(line)
161 case c == '!' || unicode.IsLetter(c) || c == '_':
162 return s.scanIdent(line)
163 default:
164 s.consume()
165 return s.next()
166 }
167 }
168
169 func (s *scanner) scanString(line int) (token, error) {
170 s.consume()
171 var sb strings.Builder
172 for {
173 c, ok := s.at(0)
174 if !ok {
175 return token{}, fmt.Errorf("line %d: unterminated string", line)
176 }
177 if c == '"' {
178 s.consume()
179 break
180 }
181 if c == '\\' {
182 s.consume()
183 esc, ok := s.at(0)
184 if !ok {
185 return token{}, fmt.Errorf("line %d: EOF in string escape", line)
186 }
187 s.consume()
188 switch esc {
189 case '"', '\\':
190 sb.WriteRune(esc)
191 case 'n':
192 sb.WriteRune('\n')
193 default:
194 sb.WriteRune('\\')
195 sb.WriteRune(esc)
196 }
197 continue
198 }
199 sb.WriteRune(s.consume())
200 }
201 return token{kind: tokString, val: sb.String(), line: line}, nil
202 }
203
204 func (s *scanner) scanIdent(line int) (token, error) {
205 var sb strings.Builder
206 if c, _ := s.at(0); c == '!' {
207 sb.WriteRune(s.consume())
208 }
209 for {
210 c, ok := s.at(0)
211 if !ok {
212 break
213 }
214 if c == '-' {
215 if n, _ := s.at(1); n == '>' {
216 break
217 }
218 sb.WriteRune(s.consume())
219 continue
220 }
221 if unicode.IsLetter(c) || unicode.IsDigit(c) || c == '_' || c == '.' || c == '/' || c == ':' {
222 sb.WriteRune(s.consume())
223 continue
224 }
225 break
226 }
227 return token{kind: tokIdent, val: sb.String(), line: line}, nil
228 }
229
230 // ─── Parser ──────────────────────────────────────────────────────────────────
231
232 // stmt represents one parsed statement in the DSL.
233 type stmt struct {
234 line int
235 varName string
236 keyword string
237 args []string
238 isRel bool
239 relFrom string
240 relTo string
241 body []stmt
242 }
243
244 type dslParser struct {
245 toks []token
246 pos int
247 }
248
249 func (p *dslParser) peek() token {
250 if p.pos >= len(p.toks) {
251 return token{kind: tokEOF}
252 }
253 return p.toks[p.pos]
254 }
255
256 func (p *dslParser) advance() token {
257 t := p.peek()
258 if t.kind != tokEOF {
259 p.pos++
260 }
261 return t
262 }
263
264 func (p *dslParser) skipNewlines() {
265 for p.peek().kind == tokNewline {
266 p.advance()
267 }
268 }
269
270 func (p *dslParser) parseAll() ([]stmt, error) {
271 return p.parseStmts(false)
272 }
273
274 func (p *dslParser) parseStmts(inBlock bool) ([]stmt, error) {
275 var stmts []stmt
276 for {
277 p.skipNewlines()
278 tok := p.peek()
279 if tok.kind == tokEOF {
280 break
281 }
282 if inBlock && tok.kind == tokRBrace {
283 break
284 }
285 s, err := p.parseOneStmt()
286 if err != nil {
287 return nil, err
288 }
289 if s != nil {
290 stmts = append(stmts, *s)
291 }
292 }
293 return stmts, nil
294 }
295
296 func (p *dslParser) parseBlock() ([]stmt, error) {
297 if p.peek().kind != tokLBrace {
298 return nil, nil
299 }
300 p.advance() // {
301 p.skipNewlines()
302 stmts, err := p.parseStmts(true)
303 if err != nil {
304 return nil, err
305 }
306 if p.peek().kind == tokRBrace {
307 p.advance()
308 }
309 return stmts, nil
310 }
311
312 // optBlock skips newlines then reads a block if the next token is {.
313 func (p *dslParser) optBlock(s *stmt) error {
314 p.skipNewlines()
315 if p.peek().kind == tokLBrace {
316 body, err := p.parseBlock()
317 if err != nil {
318 return err
319 }
320 s.body = body
321 }
322 return nil
323 }
324
325 func (p *dslParser) parseOneStmt() (*stmt, error) {
326 tok := p.peek()
327 if tok.kind == tokEOF || tok.kind == tokRBrace {
328 return nil, nil
329 }
330
331 line := tok.line
332
333 if tok.kind == tokArrow {
334 p.advance()
335 to := p.advance()
336 s := &stmt{line: line, isRel: true, relTo: to.val, args: p.collectArgs()}
337 if err := p.optBlock(s); err != nil {
338 return nil, err
339 }
340 return s, nil
341 }
342
343 if tok.kind == tokLBrace {
344 if _, err := p.parseBlock(); err != nil {
345 return nil, err
346 }
347 return nil, nil
348 }
349
350 if tok.kind != tokIdent && tok.kind != tokString {
351 p.advance()
352 return nil, nil
353 }
354
355 p.advance()
356
357 switch p.peek().kind {
358 case tokAssign:
359 p.advance()
360 kw := p.advance()
361 s := &stmt{line: line, varName: tok.val, keyword: kw.val, args: p.collectArgs()}
362 if err := p.optBlock(s); err != nil {
363 return nil, err
364 }
365 return s, nil
366
367 case tokArrow:
368 p.advance()
369 to := p.advance()
370 s := &stmt{line: line, isRel: true, relFrom: tok.val, relTo: to.val, args: p.collectArgs()}
371 if err := p.optBlock(s); err != nil {
372 return nil, err
373 }
374 return s, nil
375
376 default:
377 s := &stmt{line: line, keyword: tok.val, args: p.collectArgs()}
378 if err := p.optBlock(s); err != nil {
379 return nil, err
380 }
381 return s, nil
382 }
383 }
384
385 func (p *dslParser) collectArgs() []string {
386 var args []string
387 for {
388 k := p.peek().kind
389 if k == tokString || k == tokIdent {
390 args = append(args, p.advance().val)
391 } else {
392 break
393 }
394 }
395 return args
396 }
397
398 // ─── Mapper ──────────────────────────────────────────────────────────────────
399
400 type kindDef struct {
401 kind string
402 notation string
403 container bool
404 }
405
406 // elementKindOrder defines the canonical C4 layer order for specification.elements.
407 var elementKindOrder = []kindDef{
408 {"person", "Person", false},
409 {"system", "Software System", true},
410 {"container", "Container", true},
411 {"component", "Component", false},
412 }
413
414 var structurizrKindMap = map[string]kindDef{
415 "person": elementKindOrder[0],
416 "softwareSystem": elementKindOrder[1],
417 "container": elementKindOrder[2],
418 "component": elementKindOrder[3],
419 }
420
421 type pendingRel struct {
422 from string
423 to string
424 label string
425 line int
426 }
427
428 type importState struct {
429 specAdded map[string]bool
430 spec map[string]model.ElementKind
431 elements map[string]model.Element
432 varToPath map[string]string
433 pendingRels []pendingRel
434 views map[string]model.View
435 viewKeys map[string]int
436 warnings []string
437 }
438
439 func newImportState() *importState {
440 return &importState{
441 specAdded: make(map[string]bool),
442 spec: make(map[string]model.ElementKind),
443 elements: make(map[string]model.Element),
444 varToPath: make(map[string]string),
445 views: make(map[string]model.View),
446 viewKeys: make(map[string]int),
447 }
448 }
449
450 func (is *importState) registerKind(kw string) {
451 kd := structurizrKindMap[kw]
452 if !is.specAdded[kd.kind] {
453 is.spec[kd.kind] = model.ElementKind{
454 Notation: kd.notation,
455 Container: kd.container,
456 }
457 is.specAdded[kd.kind] = true
458 }
459 }
460
461 func (is *importState) resolveVar(v string) string {
462 if p, ok := is.varToPath[v]; ok {
463 return p
464 }
465 return v
466 }
467
468 func (is *importState) processModelStmts(stmts []stmt, parentPath, parentVar string, dest map[string]model.Element) {
469 for _, s := range stmts {
470 is.processModelStmt(s, parentPath, parentVar, dest)
471 }
472 }
473
474 func (is *importState) processModelStmt(s stmt, parentPath, parentVar string, dest map[string]model.Element) {
475 if s.isRel {
476 from := s.relFrom
477 if from == "" {
478 from = parentVar
479 }
480 label := ""
481 if len(s.args) > 0 {
482 label = s.args[0]
483 }
484 is.pendingRels = append(is.pendingRels, pendingRel{from: from, to: s.relTo, label: label, line: s.line})
485 return
486 }
487
488 kd, isElement := structurizrKindMap[s.keyword]
489 if !isElement {
490 switch s.keyword {
491 case "enterprise", "group":
492 is.processModelStmts(s.body, parentPath, parentVar, dest)
493 }
494 return
495 }
496
497 is.registerKind(s.keyword)
498
499 key := s.varName
500 if key == "" {
501 if len(s.args) > 0 {
502 key = slugify(s.args[0])
503 } else {
504 key = kd.kind
505 }
506 is.warnings = append(is.warnings, fmt.Sprintf("line %d: element has no variable name, using %q", s.line, key))
507 }
508
509 path := key
510 if parentPath != "" {
511 path = parentPath + "." + key
512 }
513 is.varToPath[key] = path
514
515 el := model.Element{Kind: kd.kind}
516 if len(s.args) > 0 {
517 el.Title = s.args[0]
518 }
519 if len(s.args) > 1 {
520 el.Description = s.args[1]
521 }
522 if (kd.kind == "container" || kd.kind == "component") && len(s.args) > 2 {
523 el.Technology = s.args[2]
524 }
525
526 children := make(map[string]model.Element)
527 for _, child := range s.body {
528 switch {
529 case child.isRel:
530 from := child.relFrom
531 if from == "" {
532 from = key
533 }
534 label := ""
535 if len(child.args) > 0 {
536 label = child.args[0]
537 }
538 is.pendingRels = append(is.pendingRels, pendingRel{from: from, to: child.relTo, label: label, line: child.line})
539 case structurizrKindMap[child.keyword].kind != "":
540 is.processModelStmt(child, path, key, children)
541 case child.keyword == "description" && len(child.args) > 0:
542 el.Description = child.args[0]
543 case child.keyword == "technology" && len(child.args) > 0:
544 el.Technology = child.args[0]
545 case child.keyword == "tags":
546 el.Tags = child.args
547 case child.keyword == "properties":
548 el.Metadata = parseProperties(child.body)
549 }
550 }
551
552 if len(children) > 0 {
553 el.Children = children
554 }
555 dest[key] = el
556 }
557
558 func (is *importState) processViewsStmts(stmts []stmt) {
559 for _, s := range stmts {
560 switch s.keyword {
561 case "systemContext", "container", "component", "systemLandscape":
562 case "filtered", "dynamic", "deployment":
563 is.warnings = append(is.warnings, fmt.Sprintf("line %d: %s view not supported, skipped", s.line, s.keyword))
564 continue
565 default:
566 continue
567 }
568
569 scope := ""
570 if s.keyword != "systemLandscape" && len(s.args) > 0 {
571 scope = is.resolveVar(s.args[0])
572 }
573
574 titleArgs := s.args
575 if scope != "" {
576 titleArgs = s.args[1:]
577 }
578 title := strings.Join(titleArgs, " ")
579
580 baseKey := s.keyword
581 if scope != "" {
582 baseKey = scope
583 }
584 viewKey := baseKey
585 if is.viewKeys[baseKey] > 0 {
586 viewKey = fmt.Sprintf("%s_%d", baseKey, is.viewKeys[baseKey])
587 }
588 is.viewKeys[baseKey]++
589
590 if title == "" {
591 title = viewKey
592 }
593
594 v := model.View{Title: title, Scope: scope, Include: []string{"*"}}
595
596 for _, bs := range s.body {
597 switch bs.keyword {
598 case "include":
599 if len(bs.args) == 1 && bs.args[0] == "*" {
600 v.Include = []string{"*"}
601 } else {
602 v.Include = nil
603 for _, arg := range bs.args {
604 if arg != "*" {
605 v.Include = append(v.Include, is.resolveVar(arg))
606 } else {
607 v.Include = []string{"*"}
608 break
609 }
610 }
611 }
612 case "exclude":
613 for _, arg := range bs.args {
614 v.Exclude = append(v.Exclude, is.resolveVar(arg))
615 }
616 case "title":
617 if len(bs.args) > 0 {
618 v.Title = bs.args[0]
619 }
620 case "description":
621 if len(bs.args) > 0 {
622 v.Description = bs.args[0]
623 }
624 case "autoLayout":
625 v.Layout = "auto"
626 }
627 }
628
629 is.views[viewKey] = v
630 }
631 }
632
633 func (is *importState) buildRelationships() []model.Relationship {
634 var rels []model.Relationship
635 for _, pr := range is.pendingRels {
636 fromPath := is.resolveVar(pr.from)
637 toPath := is.resolveVar(pr.to)
638 if fromPath == "" || toPath == "" {
639 is.warnings = append(is.warnings, fmt.Sprintf("line %d: relationship skipped (unresolved variable)", pr.line))
640 continue
641 }
642 rels = append(rels, model.Relationship{From: fromPath, To: toPath, Label: pr.label})
643 }
644 return rels
645 }
646
647 func parseProperties(body []stmt) map[string]string {
648 m := make(map[string]string)
649 for _, s := range body {
650 if s.keyword != "" && len(s.args) > 0 {
651 m[s.keyword] = s.args[0]
652 }
653 }
654 return m
655 }
656
657 func slugify(s string) string {
658 s = strings.ToLower(s)
659 var sb strings.Builder
660 prevUnderscore := false
661 for _, r := range s {
662 if unicode.IsLetter(r) || unicode.IsDigit(r) {
663 sb.WriteRune(r)
664 prevUnderscore = false
665 } else if !prevUnderscore && sb.Len() > 0 {
666 sb.WriteRune('_')
667 prevUnderscore = true
668 }
669 }
670 result := strings.TrimRight(sb.String(), "_")
671 if result == "" {
672 return "element"
673 }
674 return result
675 }
676
677 // ─── Public API ──────────────────────────────────────────────────────────────
678
679 const schemaURL = "https://raw.githubusercontent.com/docToolchain/Bausteinsicht/main/schema/bausteinsicht.schema.json"
680
681 // ImportSource parses a Structurizr DSL string directly (useful for testing).
682 func ImportSource(src string) (*importer.ImportResult, error) {
683 return importSource(src)
684 }
685
686 // Import reads the Structurizr DSL file at path and returns an ImportResult.
687 func Import(path string) (*importer.ImportResult, error) {
688 data, err := os.ReadFile(path)
689 if err != nil {
690 return nil, fmt.Errorf("reading %s: %w", path, err)
691 }
692 baseDir := filepath.Dir(path)
693 src, includeWarnings := resolveIncludes(string(data), baseDir, map[string]bool{})
694 result, err := importSource(src)
695 if err != nil {
696 return nil, err
697 }
698 result.Warnings = append(includeWarnings, result.Warnings...)
699 return result, nil
700 }
701
702 func resolveIncludes(src, baseDir string, visited map[string]bool) (string, []string) {
703 var warnings []string
704 var out strings.Builder
705 absDirBase, _ := filepath.Abs(baseDir)
706 for _, line := range strings.Split(src, "\n") {
707 trimmed := strings.TrimSpace(line)
708 if strings.HasPrefix(trimmed, "!include ") {
709 includePath := strings.TrimSpace(trimmed[len("!include "):])
710 if strings.HasPrefix(includePath, "http://") || strings.HasPrefix(includePath, "https://") {
711 warnings = append(warnings, "!include: HTTP includes not supported, skipped: "+includePath)
712 out.WriteByte('\n')
713 continue
714 }
715 cleanedPath := filepath.Clean(includePath)
716 fullPath := filepath.Join(baseDir, cleanedPath)
717 absFullPath, _ := filepath.Abs(fullPath)
718
719 // Verify that the resolved path is within baseDir (prevent path traversal).
720 // Use filepath.Rel to check if the path escapes the base directory via .. sequences.
721 relPath, err := filepath.Rel(absDirBase, absFullPath)
722 if err != nil || strings.HasPrefix(relPath, "..") {
723 warnings = append(warnings, "!include: path traversal rejected: "+includePath)
724 out.WriteByte('\n')
725 continue
726 }
727
728 if visited[absFullPath] {
729 warnings = append(warnings, "!include: circular include ignored: "+includePath)
730 out.WriteByte('\n')
731 continue
732 }
733 data, err := os.ReadFile(absFullPath)
734 if err != nil {
735 warnings = append(warnings, fmt.Sprintf("!include: cannot read %s: %v", includePath, err))
736 out.WriteByte('\n')
737 continue
738 }
739 newVisited := make(map[string]bool, len(visited)+1)
740 for k, v := range visited {
741 newVisited[k] = v
742 }
743 newVisited[absFullPath] = true
744 included, w := resolveIncludes(string(data), filepath.Dir(absFullPath), newVisited)
745 warnings = append(warnings, w...)
746 out.WriteString(included)
747 out.WriteByte('\n')
748 continue
749 }
750 out.WriteString(line)
751 out.WriteByte('\n')
752 }
753 return out.String(), warnings
754 }
755
756 func importSource(src string) (*importer.ImportResult, error) {
757 toks, err := tokenize(src)
758 if err != nil {
759 return nil, fmt.Errorf("tokenize: %w", err)
760 }
761
762 p := &dslParser{toks: toks}
763 stmts, err := p.parseAll()
764 if err != nil {
765 return nil, fmt.Errorf("parse: %w", err)
766 }
767
768 is := newImportState()
769
770 var modelStmts, viewsStmts []stmt
771 for _, s := range stmts {
772 switch s.keyword {
773 case "workspace":
774 for _, ws := range s.body {
775 switch ws.keyword {
776 case "model":
777 modelStmts = ws.body
778 case "views":
779 viewsStmts = ws.body
780 }
781 }
782 case "model":
783 modelStmts = s.body
784 case "views":
785 viewsStmts = s.body
786 }
787 }
788
789 is.processModelStmts(modelStmts, "", "", is.elements)
790 if len(viewsStmts) > 0 {
791 is.processViewsStmts(viewsStmts)
792 }
793
794 rels := is.buildRelationships()
795
796 spec := model.Specification{Elements: make(map[string]model.ElementKind)}
797 for _, kd := range elementKindOrder {
798 if ek, ok := is.spec[kd.kind]; ok {
799 spec.Elements[kd.kind] = ek
800 }
801 }
802
803 m := &model.BausteinsichtModel{
804 Schema: schemaURL,
805 Specification: spec,
806 Model: is.elements,
807 Relationships: rels,
808 Views: is.views,
809 }
810 if m.Relationships == nil {
811 m.Relationships = []model.Relationship{}
812 }
813 if m.Views == nil {
814 m.Views = make(map[string]model.View)
815 }
816
817 return &importer.ImportResult{Model: m, Warnings: is.warnings}, nil
818 }
819
github.com/docToolchain/Bausteinsicht/internal/layout/apply.go 0.0%
1 package layout
2
3 import (
4 "fmt"
5
6 "github.com/beevik/etree"
7 "github.com/docToolchain/Bausteinsicht/internal/drawio"
8 )
9
10 // Apply applies layout positions to draw.io diagram.
11 // Reads pinned status from existing draw.io elements and respects PreservePinned setting.
12 func Apply(doc *drawio.Document, result LayoutResult, preservePinned bool) error {
13 if len(result.Positions) == 0 {
14 return fmt.Errorf("layout result is empty")
15 }
16
17 // Build map of existing draw.io elements with their pinned status
18 pinnedMap := readPinnedStatus(doc)
19
20 // For each computed position, update the draw.io element
21 for elemID, pos := range result.Positions {
22 // Skip pinned elements if preservePinned is enabled
23 if preservePinned && pinnedMap[elemID] {
24 continue
25 }
26
27 // Find and update element in draw.io
28 if err := updateElementPosition(doc, elemID, pos); err != nil {
29 continue
30 }
31 }
32
33 return nil
34 }
35
36 // readPinnedStatus reads the bausteinsicht-pinned property from draw.io elements.
37 func readPinnedStatus(doc *drawio.Document) map[string]bool {
38 pinned := make(map[string]bool)
39
40 for _, page := range doc.Pages() {
41 if root := page.Root(); root != nil {
42 walkElements(root, func(elem *etree.Element) {
43 if id, ok := getAttr(elem, "bausteinsicht_id"); ok {
44 if pinValue, ok := getAttr(elem, "bausteinsicht-pinned"); ok && pinValue == "true" {
45 pinned[id] = true
46 }
47 }
48 })
49 }
50 }
51
52 return pinned
53 }
54
55 // updateElementPosition updates the x, y, width, height of a draw.io element.
56 func updateElementPosition(doc *drawio.Document, elemID string, pos ElementPosition) error {
57 for _, page := range doc.Pages() {
58 if root := page.Root(); root != nil {
59 found := false
60 walkElements(root, func(elem *etree.Element) {
61 if found {
62 return
63 }
64 if id, ok := getAttr(elem, "bausteinsicht_id"); ok && id == elemID {
65 // Find mxGeometry child and update coordinates
66 for _, child := range elem.ChildElements() {
67 if child.Tag == "mxGeometry" {
68 child.CreateAttr("x", fmt.Sprintf("%.0f", pos.X))
69 child.CreateAttr("y", fmt.Sprintf("%.0f", pos.Y))
70 child.CreateAttr("width", fmt.Sprintf("%.0f", pos.Width))
71 child.CreateAttr("height", fmt.Sprintf("%.0f", pos.Height))
72 found = true
73 break
74 }
75 }
76 }
77 })
78 if found {
79 return nil
80 }
81 }
82 }
83
84 return fmt.Errorf("element %s not found in diagram", elemID)
85 }
86
87 // walkElements recursively walks through all elements in the tree.
88 func walkElements(elem *etree.Element, fn func(*etree.Element)) {
89 fn(elem)
90 for _, child := range elem.ChildElements() {
91 walkElements(child, fn)
92 }
93 }
94
95 // getAttr extracts attribute value from element safely.
96 func getAttr(elem *etree.Element, name string) (string, bool) {
97 for _, attr := range elem.Attr {
98 if attr.Key == name {
99 return attr.Value, true
100 }
101 }
102 return "", false
103 }
104
github.com/docToolchain/Bausteinsicht/internal/layout/hierarchical.go 98.1%
1 package layout
2
3 import (
4 "github.com/docToolchain/Bausteinsicht/internal/model"
5 )
6
7 // HierarchicalLayout computes layer assignments via longest-path algorithm,
8 // then positions elements in layers with horizontal alignment.
9 type HierarchicalLayout struct {
10 model *model.BausteinsichtModel
11 rankDir string // TB or LR
12 spacing float64
13 layerGap float64
14 }
15
16 // NewHierarchicalLayout creates a hierarchical layout engine.
17 func NewHierarchicalLayout(m *model.BausteinsichtModel, rankDir string) *HierarchicalLayout {
18 if rankDir == "" {
19 rankDir = "TB"
20 }
21 return &HierarchicalLayout{
22 model: m,
23 rankDir: rankDir,
24 spacing: 20, // pixels between elements in same layer
25 layerGap: 100, // pixels between layers
26 }
27 }
28
29 // Compute calculates positions for all elements.
30 func (h *HierarchicalLayout) Compute() LayoutResult {
31 outgoing := h.buildOutgoingMap()
32
33 // Assign layers using longest-path algorithm
34 layers := h.assignLayers(outgoing)
35
36 // Position elements based on layers
37 positions := h.positionElements(layers)
38
39 return LayoutResult{
40 Positions: positions,
41 Algorithm: Hierarchical,
42 }
43 }
44
45 // buildOutgoingMap creates a map of outgoing relationships per element.
46 func (h *HierarchicalLayout) buildOutgoingMap() map[string][]string {
47 outgoing := make(map[string][]string)
48 for _, rel := range h.model.Relationships {
49 outgoing[rel.From] = append(outgoing[rel.From], rel.To)
50 }
51 return outgoing
52 }
53
54 // assignLayers assigns each element to a layer using longest-path algorithm.
55 func (h *HierarchicalLayout) assignLayers(outgoing map[string][]string) map[int][]string {
56 flat, _ := model.FlattenElements(h.model)
57
58 // Compute longest path from each node
59 depths := make(map[string]int)
60 for id := range flat {
61 depths[id] = h.longestPath(id, outgoing, make(map[string]bool))
62 }
63
64 // Group elements by layer
65 layers := make(map[int][]string)
66 maxLayer := 0
67 for id, depth := range depths {
68 layers[depth] = append(layers[depth], id)
69 if depth > maxLayer {
70 maxLayer = depth
71 }
72 }
73
74 return layers
75 }
76
77 // longestPath computes longest outgoing path from a node (memoized).
78 func (h *HierarchicalLayout) longestPath(id string, outgoing map[string][]string, visited map[string]bool) int {
79 if visited[id] {
80 return 0 // cycle detected, break here
81 }
82
83 targets := outgoing[id]
84 if len(targets) == 0 {
85 return 0
86 }
87
88 visited[id] = true
89 maxDepth := 0
90 for _, target := range targets {
91 depth := h.longestPath(target, outgoing, visited)
92 if depth > maxDepth {
93 maxDepth = depth
94 }
95 }
96 delete(visited, id)
97
98 return maxDepth + 1
99 }
100
101 // positionElements places elements horizontally within each layer.
102 func (h *HierarchicalLayout) positionElements(layers map[int][]string) map[string]ElementPosition {
103 positions := make(map[string]ElementPosition)
104
105 for layer, ids := range layers {
106 // Default sizes
107 elemWidth := 160.0
108 elemHeight := 60.0
109
110 // Calculate layer positions
111 var x, y float64
112 if h.rankDir == "TB" {
113 // Top-to-bottom: layer determines Y, elements spread horizontally
114 y = float64(layer) * h.layerGap
115 x = 50.0
116 } else {
117 // Left-to-right: layer determines X, elements spread vertically
118 x = float64(layer) * h.layerGap
119 y = 50.0
120 }
121
122 // Position elements in this layer
123 for i, id := range ids {
124 elemX, elemY := x, y
125 if h.rankDir == "TB" {
126 elemX = x + float64(i)*(elemWidth+h.spacing)
127 } else {
128 elemY = y + float64(i)*(elemHeight+h.spacing)
129 }
130
131 positions[id] = ElementPosition{
132 ID: id,
133 X: elemX,
134 Y: elemY,
135 Width: elemWidth,
136 Height: elemHeight,
137 Layer: layer,
138 }
139 }
140 }
141
142 return positions
143 }
144
github.com/docToolchain/Bausteinsicht/internal/lsp/codelens.go 97.3%
1 package lsp
2
3 import (
4 "fmt"
5 "path/filepath"
6 "regexp"
7 "strings"
8 )
9
10 type CodeLens struct {
11 Range Range `json:"range"`
12 Command *Command `json:"command,omitempty"`
13 Data interface{} `json:"data,omitempty"`
14 }
15
16 type Command struct {
17 Title string `json:"title"`
18 Command string `json:"command"`
19 Arguments []interface{} `json:"arguments,omitempty"`
20 }
21
22 type CodeLensData struct {
23 ElementID string
24 Kind string
25 Status string
26 ViewCount int
27 }
28
29 // GenerateCodeLens extracts element definitions from the document and generates CodeLens objects.
30 func GenerateCodeLens(doc *Document) []CodeLens {
31 // Check if filename matches *architecture*.jsonc pattern
32 base := filepath.Base(doc.Filename)
33 if !strings.Contains(base, "architecture") || !strings.HasSuffix(base, ".jsonc") {
34 return nil
35 }
36
37 var lenses []CodeLens
38 lines := strings.Split(doc.Content, "\n")
39
40 // Pattern: "elementName": { or "elementName": {, matching JSON object keys
41 elementPattern := regexp.MustCompile(`^\s*"([a-zA-Z_][a-zA-Z0-9_]*)"\s*:\s*{`)
42
43 for i, line := range lines {
44 matches := elementPattern.FindStringSubmatch(line)
45 if len(matches) < 2 {
46 continue
47 }
48
49 elementID := matches[1]
50
51 // Skip parent keys like "model", "views", etc.
52 if elementID == "model" || elementID == "views" || elementID == "relationships" {
53 continue
54 }
55
56 // Extract metadata (kind and status from nearby lines)
57 kind := extractKind(lines, i)
58 status := extractStatus(lines, i)
59 viewCount := estimateViewCount(doc.Content, elementID)
60
61 // Create CodeLens entry
62 lens := CodeLens{
63 Range: Range{
64 Start: Position{Line: i, Character: 0},
65 End: Position{Line: i, Character: len(line)},
66 },
67 Command: &Command{
68 Title: fmt.Sprintf("%s | status: %s | views: %d", kind, status, viewCount),
69 Command: "bausteinsicht.openInDrawio",
70 Arguments: []interface{}{
71 elementID,
72 map[string]interface{}{
73 "kind": kind,
74 "status": status,
75 "views": viewCount,
76 },
77 },
78 },
79 }
80
81 lenses = append(lenses, lens)
82 }
83
84 return lenses
85 }
86
87 // extractKind finds the "kind" field value in the element definition.
88 func extractKind(lines []string, startLine int) string {
89 // Search within the next 10 lines for a "kind" field
90 for i := startLine; i < startLine+10 && i < len(lines); i++ {
91 if strings.Contains(lines[i], `"kind"`) {
92 // Extract value: "kind": "service" → service
93 kindPattern := regexp.MustCompile(`"kind"\s*:\s*"([^"]*)"`)
94 matches := kindPattern.FindStringSubmatch(lines[i])
95 if len(matches) > 1 {
96 return matches[1]
97 }
98 }
99 }
100 return "unknown"
101 }
102
103 // extractStatus finds the "status" field value in the element definition.
104 func extractStatus(lines []string, startLine int) string {
105 // Search within the next 10 lines for a "status" field
106 for i := startLine; i < startLine+10 && i < len(lines); i++ {
107 if strings.Contains(lines[i], `"status"`) {
108 // Extract value: "status": "active" → active
109 statusPattern := regexp.MustCompile(`"status"\s*:\s*"([^"]*)"`)
110 matches := statusPattern.FindStringSubmatch(lines[i])
111 if len(matches) > 1 {
112 return matches[1]
113 }
114 }
115 }
116 return "active"
117 }
118
119 // estimateViewCount counts how many views reference this element.
120 func estimateViewCount(content string, elementID string) int {
121 // Count occurrences of the element ID in the document (rough estimate)
122 // A more precise implementation would parse the model and count actual view references
123 count := strings.Count(content, elementID) - 1 // Subtract 1 for the element definition itself
124 if count < 0 {
125 count = 0
126 }
127 return count
128 }
129
github.com/docToolchain/Bausteinsicht/internal/lsp/diagnostics.go 83.7%
1 package lsp
2
3 import (
4 "bytes"
5 "encoding/json"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 )
10
11 type Diagnostic struct {
12 Range Range `json:"range"`
13 Message string `json:"message"`
14 Severity int `json:"severity"`
15 Source string `json:"source"`
16 }
17
18 type Range struct {
19 Start Position `json:"start"`
20 End Position `json:"end"`
21 }
22
23 type Position struct {
24 Line int `json:"line"`
25 Character int `json:"character"`
26 }
27
28 const (
29 DiagnosticError = 1
30 DiagnosticWarning = 2
31 DiagnosticInfo = 3
32 DiagnosticHint = 4
33 )
34
35 type ValidateOutput struct {
36 Valid bool `json:"valid"`
37 Errors []ValidationError `json:"errors,omitempty"`
38 Warnings []ValidationWarning `json:"warnings,omitempty"`
39 }
40
41 type ValidationError struct {
42 Path string `json:"path"`
43 Message string `json:"message"`
44 Line int `json:"line,omitempty"`
45 }
46
47 type ValidationWarning struct {
48 Path string `json:"path"`
49 Message string `json:"message"`
50 Line int `json:"line,omitempty"`
51 }
52
53 func ValidateDocument(doc *Document, workDir string) []Diagnostic {
54 // Check if filename matches *architecture*.jsonc pattern
55 base := filepath.Base(doc.Filename)
56 if !strings.Contains(base, "architecture") || !strings.HasSuffix(base, ".jsonc") {
57 return nil
58 }
59
60 // Validate path to prevent directory traversal (SEC-001)
61 cleanPath := filepath.Clean(doc.Filename)
62 for _, component := range strings.Split(cleanPath, string(filepath.Separator)) {
63 if component == ".." {
64 return []Diagnostic{
65 {
66 Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 10}},
67 Message: "Invalid path: contains directory traversal",
68 Severity: DiagnosticError,
69 Source: "bausteinsicht",
70 },
71 }
72 }
73 }
74
75 // Call bausteinsicht validate --format json
76 cmd := exec.Command("bausteinsicht", "validate", "--format", "json", "--model", doc.Filename)
77 cmd.Dir = workDir
78
79 var stdout bytes.Buffer
80 var stderr bytes.Buffer
81 cmd.Stdout = &stdout
82 cmd.Stderr = &stderr
83
84 if err := cmd.Run(); err != nil {
85 // Command failed, but we might still get useful output from stdout/stderr
86 _ = err
87 }
88
89 // Parse JSON output
90 var output ValidateOutput
91 if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
92 // If parsing fails, try to extract errors from stderr
93 return parseValidationErrors(stderr.String())
94 }
95
96 return convertValidateOutput(&output, doc)
97 }
98
99 func convertValidateOutput(output *ValidateOutput, doc *Document) []Diagnostic {
100 var diags []Diagnostic
101
102 // Process errors
103 for _, err := range output.Errors {
104 line, char := findLineInDocument(doc, err.Path, err.Line)
105 diags = append(diags, Diagnostic{
106 Range: Range{
107 Start: Position{Line: line, Character: char},
108 End: Position{Line: line, Character: char + 10},
109 },
110 Message: err.Message,
111 Severity: DiagnosticError,
112 Source: "bausteinsicht",
113 })
114 }
115
116 // Process warnings
117 for _, warn := range output.Warnings {
118 line, char := findLineInDocument(doc, warn.Path, warn.Line)
119 diags = append(diags, Diagnostic{
120 Range: Range{
121 Start: Position{Line: line, Character: char},
122 End: Position{Line: line, Character: char + 10},
123 },
124 Message: warn.Message,
125 Severity: DiagnosticWarning,
126 Source: "bausteinsicht",
127 })
128 }
129
130 return diags
131 }
132
133 func findLineInDocument(doc *Document, path string, preferredLine int) (int, int) {
134 lines := strings.Split(doc.Content, "\n")
135
136 // If a preferred line is given, use it (adjusted for 0-indexing)
137 if preferredLine > 0 && preferredLine-1 < len(lines) {
138 return preferredLine - 1, 0
139 }
140
141 // Otherwise, search for the path in the document
142 // This is a simple search - real implementation would parse JSON
143 for i, line := range lines {
144 if strings.Contains(line, path) || strings.Contains(line, strings.TrimPrefix(path, "\"")) {
145 return i, 0
146 }
147 }
148
149 return 0, 0
150 }
151
152 func parseValidationErrors(stderr string) []Diagnostic {
153 var diags []Diagnostic
154
155 lines := strings.Split(stderr, "\n")
156 for _, line := range lines {
157 if strings.Contains(line, "Error:") || strings.Contains(line, "error:") {
158 // Extract error message
159 parts := strings.Split(line, ":")
160 if len(parts) > 1 {
161 msg := strings.TrimSpace(strings.Join(parts[1:], ":"))
162 diags = append(diags, Diagnostic{
163 Range: Range{
164 Start: Position{Line: 0, Character: 0},
165 End: Position{Line: 0, Character: 10},
166 },
167 Message: msg,
168 Severity: DiagnosticError,
169 Source: "bausteinsicht",
170 })
171 }
172 }
173 }
174
175 return diags
176 }
177
github.com/docToolchain/Bausteinsicht/internal/lsp/server.go 33.3%
1 package lsp
2
3 import (
4 "bufio"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log"
9 "net/url"
10 "os"
11 "path/filepath"
12 "runtime"
13 "strconv"
14 "strings"
15 )
16
17 type Server struct {
18 documents map[string]*Document
19 workDir string
20 modelPath string
21 }
22
23 type Document struct {
24 URI string
25 Content string
26 Version int
27 Text string
28 Filename string
29 }
30
31 type JSONRPCMessage struct {
32 JSONRPC string `json:"jsonrpc"`
33 ID interface{} `json:"id,omitempty"`
34 Method string `json:"method,omitempty"`
35 Params json.RawMessage `json:"params,omitempty"`
36 Result interface{} `json:"result,omitempty"`
37 Error interface{} `json:"error,omitempty"`
38 }
39
40 type InitializeParams struct {
41 RootPath string `json:"rootPath"`
42 RootURI string `json:"rootUri"`
43 Workspace struct {
44 WorkspaceFolders []struct {
45 URI string `json:"uri"`
46 Name string `json:"name"`
47 } `json:"workspaceFolders"`
48 } `json:"workspace"`
49 }
50
51 type InitializeResult struct {
52 Capabilities ServerCapabilities `json:"capabilities"`
53 }
54
55 type ServerCapabilities struct {
56 TextDocumentSync int `json:"textDocumentSync"`
57 DiagnosticProvider bool `json:"diagnosticProvider"`
58 CodeLensProvider struct {
59 CodeLensOptions
60 } `json:"codeLensProvider"`
61 }
62
63 type CodeLensOptions struct {
64 ResolveProvider bool `json:"resolveProvider"`
65 }
66
67 func NewServer() *Server {
68 return &Server{
69 documents: make(map[string]*Document),
70 workDir: ".",
71 }
72 }
73
74 func (s *Server) Run() error {
75 return s.readMessages()
76 }
77
78 func (s *Server) readMessages() error {
79 reader := bufio.NewReader(os.Stdin)
80
81 for {
82 // Read headers
83 headers := make(map[string]string)
84 for {
85 line, err := reader.ReadString('\n')
86 if err == io.EOF {
87 return nil // Client disconnected cleanly
88 }
89 if err != nil {
90 return err
91 }
92 line = strings.TrimSpace(line)
93 if line == "" {
94 break
95 }
96 parts := strings.Split(line, ": ")
97 if len(parts) == 2 {
98 headers[parts[0]] = parts[1]
99 }
100 }
101
102 // Read body
103 contentLength := headers["Content-Length"]
104 if contentLength == "" {
105 continue
106 }
107
108 length, err := strconv.Atoi(contentLength)
109 if err != nil {
110 continue
111 }
112
113 body := make([]byte, length)
114 _, err = io.ReadFull(reader, body)
115 if err != nil {
116 if err == io.EOF {
117 return fmt.Errorf("unexpected EOF while reading message body")
118 }
119 return err
120 }
121
122 // Parse and handle message
123 var msg JSONRPCMessage
124 if err := json.Unmarshal(body, &msg); err != nil {
125 log.Printf("Failed to parse message: %v", err)
126 continue
127 }
128
129 // Handle message
130 response := s.handleMessage(&msg)
131 if response != nil {
132 s.sendMessage(response)
133 }
134 }
135 }
136
137 func (s *Server) handleMessage(msg *JSONRPCMessage) interface{} {
138 switch msg.Method {
139 case "initialize":
140 var params InitializeParams
141 if err := json.Unmarshal(msg.Params, &params); err != nil {
142 return nil
143 }
144 return s.handleInitialize(msg.ID, &params)
145
146 case "initialized":
147 return nil
148
149 case "textDocument/didOpen":
150 var params struct {
151 TextDocument struct {
152 URI string `json:"uri"`
153 Text string `json:"text"`
154 } `json:"textDocument"`
155 }
156 if err := json.Unmarshal(msg.Params, &params); err != nil {
157 return nil
158 }
159 s.handleDidOpen(&params.TextDocument.URI, &params.TextDocument.Text)
160 return nil
161
162 case "textDocument/didChange":
163 var params struct {
164 TextDocument struct {
165 URI string `json:"uri"`
166 Version int `json:"version"`
167 } `json:"textDocument"`
168 ContentChanges []struct {
169 Text string `json:"text"`
170 } `json:"contentChanges"`
171 }
172 if err := json.Unmarshal(msg.Params, &params); err != nil {
173 return nil
174 }
175 if len(params.ContentChanges) > 0 {
176 s.handleDidChange(&params.TextDocument.URI, params.TextDocument.Version, params.ContentChanges[0].Text)
177 }
178 return nil
179
180 case "textDocument/didSave":
181 var params struct {
182 TextDocument struct {
183 URI string `json:"uri"`
184 } `json:"textDocument"`
185 }
186 if err := json.Unmarshal(msg.Params, &params); err != nil {
187 return nil
188 }
189 s.handleDidSave(&params.TextDocument.URI)
190 return nil
191
192 case "shutdown":
193 // Send response before exiting (LSP spec requirement)
194 response := &JSONRPCMessage{
195 JSONRPC: "2.0",
196 ID: msg.ID,
197 Result: map[string]interface{}{},
198 }
199 s.sendMessage(response)
200 os.Exit(0)
201
202 default:
203 return nil
204 }
205
206 return nil
207 }
208
209 func (s *Server) handleInitialize(id interface{}, params *InitializeParams) interface{} {
210 if params.RootPath != "" {
211 s.workDir = params.RootPath
212 }
213 // Auto-detect model file
214 s.detectModel()
215
216 return &JSONRPCMessage{
217 JSONRPC: "2.0",
218 ID: id,
219 Result: InitializeResult{
220 Capabilities: ServerCapabilities{
221 TextDocumentSync: 2, // Full document sync
222 DiagnosticProvider: true,
223 CodeLensProvider: struct {
224 CodeLensOptions
225 }{CodeLensOptions{ResolveProvider: false}},
226 },
227 },
228 }
229 }
230
231 func (s *Server) handleDidOpen(uri *string, text *string) {
232 if uri == nil || text == nil {
233 return
234 }
235
236 filename := URIToPath(*uri)
237 doc := &Document{
238 URI: *uri,
239 Content: *text,
240 Text: *text,
241 Version: 1,
242 Filename: filename,
243 }
244 s.documents[*uri] = doc
245
246 // Publish diagnostics if this is the model file
247 if s.isModelFile(filename) {
248 s.publishDiagnostics(uri)
249 }
250 }
251
252 func (s *Server) handleDidChange(uri *string, version int, text string) {
253 if uri == nil {
254 return
255 }
256
257 doc, ok := s.documents[*uri]
258 if !ok {
259 return
260 }
261
262 doc.Content = text
263 doc.Text = text
264 doc.Version = version
265
266 if s.isModelFile(doc.Filename) {
267 s.publishDiagnostics(uri)
268 }
269 }
270
271 func (s *Server) handleDidSave(uri *string) {
272 if uri == nil {
273 return
274 }
275
276 doc, ok := s.documents[*uri]
277 if !ok {
278 return
279 }
280
281 if s.isModelFile(doc.Filename) {
282 s.publishDiagnostics(uri)
283 }
284 }
285
286 func (s *Server) publishDiagnostics(uri *string) {
287 doc, ok := s.documents[*uri]
288 if !ok || doc == nil {
289 return
290 }
291
292 diags := ValidateDocument(doc, s.workDir)
293
294 params := map[string]interface{}{
295 "uri": *uri,
296 "diagnostics": diags,
297 }
298 paramsData, _ := json.Marshal(params)
299
300 msg := &JSONRPCMessage{
301 JSONRPC: "2.0",
302 Method: "textDocument/publishDiagnostics",
303 Params: paramsData,
304 }
305
306 s.sendMessage(msg)
307 }
308
309 func (s *Server) sendMessage(msg interface{}) {
310 data, err := json.Marshal(msg)
311 if err != nil {
312 log.Printf("Failed to marshal message: %v", err)
313 return
314 }
315
316 header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data))
317 _, _ = os.Stdout.WriteString(header)
318 _, _ = os.Stdout.Write(data)
319 }
320
321 func (s *Server) detectModel() {
322 // Look for architecture.jsonc in work directory
323 modelPath := filepath.Join(s.workDir, "architecture.jsonc")
324 if _, err := os.Stat(modelPath); err == nil {
325 s.modelPath = modelPath
326 }
327 }
328
329 func (s *Server) isModelFile(filename string) bool {
330 base := filepath.Base(filename)
331 return strings.Contains(base, "architecture") && strings.HasSuffix(base, ".jsonc")
332 }
333
334 func URIToPath(uri string) string {
335 // Parse URI to handle cross-platform paths and URL-encoded characters
336 u, err := url.Parse(uri)
337 if err != nil {
338 // Fall back to simple prefix removal on parse error
339 if strings.HasPrefix(uri, "file://") {
340 return uri[7:]
341 }
342 return uri
343 }
344
345 // Extract path from parsed URI (keep forward slashes per RFC 8089)
346 path := u.Path
347
348 // On Windows, remove leading slash from absolute paths (C:/path not /C:/path)
349 if runtime.GOOS == "windows" && len(path) > 0 && path[0] == '/' && len(path) > 2 && path[2] == ':' {
350 path = path[1:]
351 }
352
353 return path
354 }
355
github.com/docToolchain/Bausteinsicht/internal/model/add.go 83.9%
1 package model
2
3 import (
4 "fmt"
5 "sort"
6 )
7
8 // AddView creates a new view or merges fields into an existing view.
9 // For existing views:
10 // - --include elements are merged (deduplicated)
11 // - --title, --scope, --description are updated (if specified)
12 // Returns an error if:
13 // - View key is empty
14 // - Scope element doesn't exist (if specified)
15 // - Include elements don't exist (if specified)
16 func (m *BausteinsichtModel) AddView(key string, view View) error {
17 if key == "" {
18 return fmt.Errorf("view key must not be empty")
19 }
20
21 // Initialize views map if needed
22 if m.Views == nil {
23 m.Views = make(map[string]View)
24 }
25
26 // Validate scope exists (if specified)
27 if view.Scope != "" {
28 if _, err := Resolve(m, view.Scope); err != nil {
29 return fmt.Errorf("scope %q not found: %w", view.Scope, err)
30 }
31 }
32
33 // Validate all include elements exist
34 for _, include := range view.Include {
35 // Skip wildcard patterns — they're validated at render time
36 if include == "" || (len(include) > 0 && include[len(include)-1] == '*') {
37 continue
38 }
39 if _, err := Resolve(m, include); err != nil {
40 return fmt.Errorf("include %q not found: %w", include, err)
41 }
42 }
43
44 // Check if view already exists
45 existingView, exists := m.Views[key]
46 if exists {
47 // Merge: keep existing fields, override with new values, merge include lists
48 if view.Title != "" {
49 existingView.Title = view.Title
50 }
51 if view.Scope != "" {
52 existingView.Scope = view.Scope
53 }
54 if view.Description != "" {
55 existingView.Description = view.Description
56 }
57 if view.Layout != "" {
58 existingView.Layout = view.Layout
59 }
60 // Merge include lists (deduplicate, sort for deterministic output)
61 if len(view.Include) > 0 {
62 includedSet := make(map[string]bool)
63 for _, elem := range existingView.Include {
64 includedSet[elem] = true
65 }
66 for _, elem := range view.Include {
67 includedSet[elem] = true
68 }
69 merged := make([]string, 0, len(includedSet))
70 for elem := range includedSet {
71 merged = append(merged, elem)
72 }
73 sort.Strings(merged)
74 existingView.Include = merged
75 }
76 m.Views[key] = existingView
77 } else {
78 // New view: use as-is
79 m.Views[key] = view
80 }
81
82 return nil
83 }
84
85 // AddSpecificationElement adds an element kind to the specification.
86 // Returns an error if the element kind already exists.
87 func (m *BausteinsichtModel) AddSpecificationElement(key string, kind ElementKind) error {
88 if key == "" {
89 return fmt.Errorf("element key must not be empty")
90 }
91
92 if kind.Notation == "" {
93 return fmt.Errorf("notation must not be empty")
94 }
95
96 // Initialize elements map if needed
97 if m.Specification.Elements == nil {
98 m.Specification.Elements = make(map[string]ElementKind)
99 }
100
101 // Check for duplicate
102 if _, exists := m.Specification.Elements[key]; exists {
103 return fmt.Errorf("element kind %q already exists in specification", key)
104 }
105
106 m.Specification.Elements[key] = kind
107
108 return nil
109 }
110
111 // AddSpecificationRelationship adds a relationship kind to the specification.
112 // Returns an error if the relationship kind already exists.
113 func (m *BausteinsichtModel) AddSpecificationRelationship(key string, kind RelationshipKind) error {
114 if key == "" {
115 return fmt.Errorf("relationship key must not be empty")
116 }
117
118 if kind.Notation == "" {
119 return fmt.Errorf("notation must not be empty")
120 }
121
122 // Check for duplicate
123 if _, exists := m.Specification.Relationships[key]; exists {
124 return fmt.Errorf("relationship kind %q already exists in specification", key)
125 }
126
127 // Initialize relationships map if needed
128 if m.Specification.Relationships == nil {
129 m.Specification.Relationships = make(map[string]RelationshipKind)
130 }
131
132 // Add relationship kind
133 m.Specification.Relationships[key] = kind
134
135 return nil
136 }
137
github.com/docToolchain/Bausteinsicht/internal/model/loader.go 80.9%
1 package model
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strings"
11 )
12
13 // MaxModelFileSize is the maximum allowed model file size (10 MB).
14 const MaxModelFileSize = 10 * 1024 * 1024
15
16 // Load reads a JSONC file, strips comments and trailing commas, and parses it.
17 func Load(path string) (*BausteinsichtModel, error) {
18 info, err := os.Stat(path)
19 if err != nil {
20 return nil, fmt.Errorf("reading %s: %w", path, err)
21 }
22 if info.Size() > MaxModelFileSize {
23 return nil, fmt.Errorf("reading %s: file size %d exceeds limit of %d bytes", path, info.Size(), MaxModelFileSize)
24 }
25 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
26 if err != nil {
27 return nil, fmt.Errorf("reading %s: %w", path, err)
28 }
29 clean := StripJSONC(data)
30
31 // Reject null JSON root — json.Unmarshal silently accepts "null"
32 // and produces a zero-value struct, which passes validation vacuously.
33 trimmed := strings.TrimSpace(string(clean))
34 if trimmed == "null" || trimmed == "" {
35 return nil, fmt.Errorf("parsing %s: model file is empty or contains a null JSON root", path)
36 }
37
38 var m BausteinsichtModel
39 if err := json.Unmarshal(clean, &m); err != nil {
40 return nil, fmt.Errorf("parsing %s: %w", path, err)
41 }
42
43 m.ElementOrder = extractElementOrder(clean)
44
45 return &m, nil
46 }
47
48 // extractElementOrder walks the JSON with a streaming decoder to capture the
49 // definition order of keys in specification.elements. Go maps don't preserve
50 // insertion order, so we need this to determine layer assignment for layout.
51 func extractElementOrder(data []byte) []string {
52 // Parse into a raw structure to navigate to specification.elements,
53 // then re-decode that object with a streaming decoder to get key order.
54 var raw map[string]json.RawMessage
55 if err := json.Unmarshal(data, &raw); err != nil {
56 return nil
57 }
58 specRaw, ok := raw["specification"]
59 if !ok {
60 return nil
61 }
62 var spec map[string]json.RawMessage
63 if err := json.Unmarshal(specRaw, &spec); err != nil {
64 return nil
65 }
66 elemsRaw, ok := spec["elements"]
67 if !ok {
68 return nil
69 }
70
71 // Stream-decode the elements object to capture key order.
72 dec := json.NewDecoder(bytes.NewReader(elemsRaw))
73 tok, err := dec.Token() // consume opening '{'
74 if err != nil {
75 return nil
76 }
77 if d, ok := tok.(json.Delim); !ok || d != '{' {
78 return nil
79 }
80
81 var order []string
82 for dec.More() {
83 tok, err := dec.Token()
84 if err != nil {
85 break
86 }
87 key, ok := tok.(string)
88 if !ok {
89 continue
90 }
91 order = append(order, key)
92 // Skip the value (the element kind object).
93 var discard json.RawMessage
94 if err := dec.Decode(&discard); err != nil {
95 break
96 }
97 }
98 return order
99 }
100
101 // Save marshals the model and atomically writes it to path.
102 // Preserves any preamble (comments/whitespace before the root `{`) from the
103 // existing file so that users' header comments are not lost (#242).
104 // Uses os.CreateTemp for a randomized temp file name to prevent TOCTOU attacks.
105 func Save(path string, model *BausteinsichtModel) error {
106 data, err := json.MarshalIndent(model, "", " ")
107 if err != nil {
108 return fmt.Errorf("marshaling model: %w", err)
109 }
110
111 // Preserve preamble from the existing file (comments before root `{`).
112 if existing, readErr := os.ReadFile(path); readErr == nil { // #nosec G304
113 if preamble := extractPreamble(existing); len(preamble) > 0 {
114 data = append(preamble, data...)
115 }
116 }
117
118 dir := filepath.Dir(path)
119 tmp, err := os.CreateTemp(dir, ".model-tmp-*")
120 if err != nil {
121 return fmt.Errorf("creating temp file: %w", err)
122 }
123 tmpName := tmp.Name()
124 if _, err := tmp.Write(data); err != nil {
125 _ = tmp.Close()
126 _ = os.Remove(tmpName)
127 return fmt.Errorf("writing temp file: %w", err)
128 }
129 if err := tmp.Close(); err != nil {
130 _ = os.Remove(tmpName)
131 return fmt.Errorf("closing temp file: %w", err)
132 }
133 if err := os.Rename(tmpName, path); err != nil {
134 _ = os.Remove(tmpName)
135 return fmt.Errorf("renaming temp file: %w", err)
136 }
137 return nil
138 }
139
140 // AutoDetect finds the first *.jsonc file in dir.
141 func AutoDetect(dir string) (string, error) {
142 matches, err := filepath.Glob(filepath.Join(dir, "*.jsonc"))
143 if err != nil {
144 return "", fmt.Errorf("scanning %s: %w", dir, err)
145 }
146 if len(matches) == 0 {
147 return "", fmt.Errorf("no .jsonc file found in %s", dir)
148 }
149 if len(matches) > 1 {
150 return "", fmt.Errorf("multiple .jsonc files in %s — use --model to select one", dir)
151 }
152 return matches[0], nil
153 }
154
155 // StripJSONC removes single-line comments and trailing commas from JSONC data.
156 // Comments inside strings are preserved.
157 func StripJSONC(data []byte) []byte {
158 // Strip UTF-8 BOM if present.
159 if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
160 data = data[3:]
161 }
162
163 var sb strings.Builder
164 src := string(data)
165 i := 0
166 for i < len(src) {
167 // Handle string literals — skip their content intact
168 if src[i] == '"' {
169 sb.WriteByte(src[i])
170 i++
171 for i < len(src) {
172 if src[i] == '\\' && i+1 < len(src) {
173 sb.WriteByte(src[i])
174 sb.WriteByte(src[i+1])
175 i += 2
176 continue
177 }
178 sb.WriteByte(src[i])
179 if src[i] == '"' {
180 i++
181 break
182 }
183 i++
184 }
185 continue
186 }
187 // Handle block comments
188 if i+1 < len(src) && src[i] == '/' && src[i+1] == '*' {
189 // Trim trailing whitespace before comment if it's only
190 // whitespace since the last newline (i.e., comment on its own line).
191 s := sb.String()
192 lastNL := strings.LastIndex(s, "\n")
193 linePrefix := s[lastNL+1:]
194 if strings.TrimRight(linePrefix, " \t") == "" {
195 sb.Reset()
196 sb.WriteString(s[:lastNL+1])
197 }
198 i += 2
199 for i+1 < len(src) {
200 if src[i] == '*' && src[i+1] == '/' {
201 i += 2
202 break
203 }
204 i++
205 }
206 continue
207 }
208 // Handle single-line comments
209 if i+1 < len(src) && src[i] == '/' && src[i+1] == '/' {
210 // Trim trailing whitespace written before the comment
211 s := sb.String()
212 trimmed := strings.TrimRight(s, " \t")
213 sb.Reset()
214 sb.WriteString(trimmed)
215 for i < len(src) && src[i] != '\n' {
216 i++
217 }
218 continue
219 }
220 sb.WriteByte(src[i])
221 i++
222 }
223
224 // Remove trailing commas before } or ]
225 result := trailingCommaRe.ReplaceAllString(sb.String(), "$1")
226 return []byte(result)
227 }
228
229 // trailingCommaRe matches a comma optionally followed by whitespace before } or ]
230 var trailingCommaRe = regexp.MustCompile(`,(\s*[}\]])`)
231
232 // extractPreamble returns everything before the first `{` in the file.
233 // This captures comment lines and blank lines that precede the root object.
234 // Returns nil if there is no preamble or the file starts with `{`.
235 func extractPreamble(data []byte) []byte {
236 for i, b := range data {
237 if b == '{' {
238 if i == 0 {
239 return nil
240 }
241 return data[:i]
242 }
243 }
244 return nil
245 }
246
github.com/docToolchain/Bausteinsicht/internal/model/patch.go 64.7%
1 package model
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 )
8
9 // PatchOp describes a single value replacement in a JSONC file.
10 type PatchOp struct {
11 Path []string // JSON path segments, e.g., ["model", "api", "technology"]
12 Value string // New JSON-encoded value, e.g., `"Go 1.24"`
13 }
14
15 // PatchSave reads the JSONC file at path, applies each PatchOp, and writes
16 // the result back atomically. Comments, formatting, and key ordering are
17 // preserved because only the target values are replaced in the raw text.
18 func PatchSave(path string, ops []PatchOp) error {
19 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
20 if err != nil {
21 return fmt.Errorf("reading %s: %w", path, err)
22 }
23
24 for _, op := range ops {
25 data, err = PatchValue(data, op.Path, op.Value)
26 if err != nil {
27 return fmt.Errorf("patching %v: %w", op.Path, err)
28 }
29 }
30
31 dir := filepath.Dir(path)
32 tmp, err := os.CreateTemp(dir, ".model-tmp-*")
33 if err != nil {
34 return fmt.Errorf("creating temp file: %w", err)
35 }
36 tmpName := tmp.Name()
37 if _, err := tmp.Write(data); err != nil {
38 _ = tmp.Close()
39 _ = os.Remove(tmpName)
40 return fmt.Errorf("writing temp file: %w", err)
41 }
42 if err := tmp.Close(); err != nil {
43 _ = os.Remove(tmpName)
44 return fmt.Errorf("closing temp file: %w", err)
45 }
46 if err := os.Rename(tmpName, path); err != nil {
47 _ = os.Remove(tmpName)
48 return fmt.Errorf("renaming temp file: %w", err)
49 }
50 return nil
51 }
52
53 // PatchInsert reads the JSONC file at path, applies a raw data transformation,
54 // and writes the result back atomically. Used by InsertObjectEntry and
55 // AppendArrayEntry for comment-preserving insertions.
56 func PatchInsert(path string, transform func([]byte) ([]byte, error)) error {
57 data, err := os.ReadFile(path) // #nosec G304 -- path from CLI flag
58 if err != nil {
59 return fmt.Errorf("reading %s: %w", path, err)
60 }
61
62 data, err = transform(data)
63 if err != nil {
64 return err
65 }
66
67 dir := filepath.Dir(path)
68 tmp, err := os.CreateTemp(dir, ".model-tmp-*")
69 if err != nil {
70 return fmt.Errorf("creating temp file: %w", err)
71 }
72 tmpName := tmp.Name()
73 if _, err := tmp.Write(data); err != nil {
74 _ = tmp.Close()
75 _ = os.Remove(tmpName)
76 return fmt.Errorf("writing temp file: %w", err)
77 }
78 if err := tmp.Close(); err != nil {
79 _ = os.Remove(tmpName)
80 return fmt.Errorf("closing temp file: %w", err)
81 }
82 if err := os.Rename(tmpName, path); err != nil {
83 _ = os.Remove(tmpName)
84 return fmt.Errorf("renaming temp file: %w", err)
85 }
86 return nil
87 }
88
89 // PatchValue finds the JSON value at path in JSONC data and replaces it with
90 // newValue. The rest of the document (comments, whitespace, key ordering)
91 // is preserved. Returns the patched data or an error if the path is not found.
92 func PatchValue(data []byte, path []string, newValue string) ([]byte, error) {
93 if len(path) == 0 {
94 return nil, fmt.Errorf("empty path")
95 }
96
97 start, end, err := findValueRange(data, path)
98 if err != nil {
99 return nil, err
100 }
101
102 result := make([]byte, 0, len(data)+len(newValue))
103 result = append(result, data[:start]...)
104 result = append(result, []byte(newValue)...)
105 result = append(result, data[end:]...)
106 return result, nil
107 }
108
109 // findValueRange locates the byte range [start, end) of the JSON value at
110 // the given path within JSONC data. It handles single-line comments and
111 // string escaping.
112 func findValueRange(data []byte, path []string) (int, int, error) {
113 i := 0
114 n := len(data)
115
116 for depth := 0; depth < len(path); depth++ {
117 key := path[depth]
118 // Find the opening { of the current object.
119 i = skipToChar(data, i, n, '{')
120 if i >= n {
121 return 0, 0, fmt.Errorf("path %v: expected object, not found", path[:depth+1])
122 }
123 i++ // skip '{'
124
125 // Find the matching key within this object.
126 found := false
127 for i < n {
128 i = skipWhitespaceAndComments(data, i, n)
129 if i >= n {
130 break
131 }
132 if data[i] == '}' {
133 break
134 }
135
136 // Read the key.
137 if data[i] != '"' {
138 return 0, 0, fmt.Errorf("expected '\"' at offset %d", i)
139 }
140 keyStart := i
141 keyEnd := skipString(data, i, n)
142 currentKey := string(data[keyStart+1 : keyEnd-1]) // strip quotes
143 i = keyEnd
144
145 // Skip colon.
146 i = skipWhitespaceAndComments(data, i, n)
147 if i >= n || data[i] != ':' {
148 return 0, 0, fmt.Errorf("expected ':' after key %q at offset %d", currentKey, i)
149 }
150 i++ // skip ':'
151 i = skipWhitespaceAndComments(data, i, n)
152
153 if currentKey == key {
154 if depth == len(path)-1 {
155 // This is the target value — find its extent.
156 valStart := i
157 valEnd := skipValue(data, i, n)
158 return valStart, valEnd, nil
159 }
160 // Need to descend into this value (next iteration).
161 found = true
162 break
163 }
164
165 // Skip the value to move to the next key.
166 i = skipValue(data, i, n)
167
168 // Skip optional comma.
169 i = skipWhitespaceAndComments(data, i, n)
170 if i < n && data[i] == ',' {
171 i++
172 }
173 }
174 if !found && depth < len(path)-1 {
175 return 0, 0, fmt.Errorf("key %q not found in path %v", key, path[:depth+1])
176 }
177 }
178
179 return 0, 0, fmt.Errorf("path %v not found", path)
180 }
181
182 // skipWhitespaceAndComments advances past whitespace and // comments.
183 func skipWhitespaceAndComments(data []byte, i, n int) int {
184 for i < n {
185 if data[i] == ' ' || data[i] == '\t' || data[i] == '\n' || data[i] == '\r' {
186 i++
187 continue
188 }
189 if i+1 < n && data[i] == '/' && data[i+1] == '/' {
190 // Skip to end of line.
191 for i < n && data[i] != '\n' {
192 i++
193 }
194 continue
195 }
196 if i+1 < n && data[i] == '/' && data[i+1] == '*' {
197 i += 2
198 for i+1 < n {
199 if data[i] == '*' && data[i+1] == '/' {
200 i += 2
201 break
202 }
203 i++
204 }
205 continue
206 }
207 break
208 }
209 return i
210 }
211
212 // skipString skips a JSON string starting at data[i] (which must be '"')
213 // and returns the index after the closing quote.
214 func skipString(data []byte, i, n int) int {
215 i++ // skip opening '"'
216 for i < n {
217 if data[i] == '\\' && i+1 < n {
218 i += 2
219 continue
220 }
221 if data[i] == '"' {
222 return i + 1
223 }
224 i++
225 }
226 return i
227 }
228
229 // skipValue skips a complete JSON value (string, number, object, array, bool, null).
230 func skipValue(data []byte, i, n int) int {
231 if i >= n {
232 return i
233 }
234 switch data[i] {
235 case '"':
236 return skipString(data, i, n)
237 case '{':
238 return skipBraced(data, i, n, '{', '}')
239 case '[':
240 return skipBraced(data, i, n, '[', ']')
241 default:
242 // Number, bool, null — skip until delimiter.
243 for i < n {
244 c := data[i]
245 if c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || c == '\n' || c == '\r' {
246 break
247 }
248 // Also stop at // or /* comment.
249 if c == '/' && i+1 < n && (data[i+1] == '/' || data[i+1] == '*') {
250 break
251 }
252 i++
253 }
254 return i
255 }
256 }
257
258 // skipBraced skips a matched pair of braces/brackets, handling strings and
259 // comments within.
260 func skipBraced(data []byte, i, n int, open, close byte) int {
261 depth := 0
262 for i < n {
263 c := data[i]
264 if c == '"' {
265 i = skipString(data, i, n)
266 continue
267 }
268 if c == '/' && i+1 < n && data[i+1] == '/' {
269 for i < n && data[i] != '\n' {
270 i++
271 }
272 continue
273 }
274 if c == '/' && i+1 < n && data[i+1] == '*' {
275 i += 2
276 for i+1 < n {
277 if data[i] == '*' && data[i+1] == '/' {
278 i += 2
279 break
280 }
281 i++
282 }
283 continue
284 }
285 switch c {
286 case open:
287 depth++
288 case close:
289 depth--
290 if depth == 0 {
291 return i + 1
292 }
293 }
294 i++
295 }
296 return i
297 }
298
299 // InsertObjectEntry inserts a new key-value pair into the object at the given
300 // path. The value is inserted before the closing '}' of the target object.
301 // Comments and formatting are preserved.
302 func InsertObjectEntry(data []byte, objectPath []string, key, valueJSON string) ([]byte, error) {
303 // Find the object's value range.
304 start, end, err := findValueRange(data, objectPath)
305 if err != nil {
306 return nil, err
307 }
308 if data[start] != '{' {
309 return nil, fmt.Errorf("value at path %v is not an object", objectPath)
310 }
311
312 // Find the closing '}' of this object (end-1 since findValueRange returns after it).
313 closeBrace := end - 1
314 for closeBrace > start && data[closeBrace] != '}' {
315 closeBrace--
316 }
317
318 // Detect indentation from the closing brace line.
319 indent := detectIndent(data, closeBrace)
320
321 // Check if the object has existing entries by scanning for non-whitespace
322 // between '{' and '}'.
323 hasEntries := false
324 scan := start + 1
325 scan = skipWhitespaceAndComments(data, scan, len(data))
326 if scan < closeBrace {
327 hasEntries = true
328 }
329
330 // Build the insertion text.
331 var insertion string
332 if hasEntries {
333 // Find the last non-whitespace/comment byte before closeBrace to
334 // append a comma after the previous entry (not before the new one).
335 lastContent := closeBrace - 1
336 for lastContent > start && (data[lastContent] == ' ' || data[lastContent] == '\t' || data[lastContent] == '\n' || data[lastContent] == '\r') {
337 lastContent--
338 }
339 // Insert comma after last entry, then newline + new entry.
340 comma := ""
341 if lastContent > start && data[lastContent] != ',' {
342 comma = ","
343 }
344 insertion = fmt.Sprintf("%s\n%s %q: %s\n%s", comma, indent, key, valueJSON, indent)
345 // Replace from after last content to closing brace (inclusive) with:
346 // comma + \n + indent + new entry + \n + indent + }
347 result := make([]byte, 0, len(data)+len(insertion))
348 result = append(result, data[:lastContent+1]...)
349 result = append(result, []byte(insertion)...)
350 result = append(result, data[closeBrace:]...)
351 return result, nil
352 }
353
354 insertion = fmt.Sprintf("\n%s %q: %s\n%s", indent, key, valueJSON, indent)
355 result := make([]byte, 0, len(data)+len(insertion))
356 result = append(result, data[:closeBrace]...)
357 result = append(result, []byte(insertion)...)
358 result = append(result, data[closeBrace:]...)
359 return result, nil
360 }
361
362 // AppendArrayEntry appends a new value to the array at the given path.
363 // The value is inserted before the closing ']'. Comments and formatting
364 // are preserved.
365 func AppendArrayEntry(data []byte, arrayPath []string, valueJSON string) ([]byte, error) {
366 start, end, err := findValueRange(data, arrayPath)
367 if err != nil {
368 return nil, err
369 }
370 if data[start] != '[' {
371 return nil, fmt.Errorf("value at path %v is not an array", arrayPath)
372 }
373
374 // Find the closing ']'.
375 closeBracket := end - 1
376 for closeBracket > start && data[closeBracket] != ']' {
377 closeBracket--
378 }
379
380 indent := detectIndent(data, closeBracket)
381
382 // Check if the array has existing entries.
383 hasEntries := false
384 scan := start + 1
385 scan = skipWhitespaceAndComments(data, scan, len(data))
386 if scan < closeBracket {
387 hasEntries = true
388 }
389
390 var insertion string
391 if hasEntries {
392 // Find the last non-whitespace byte before closeBracket to
393 // append comma after the previous entry.
394 lastContent := closeBracket - 1
395 for lastContent > start && (data[lastContent] == ' ' || data[lastContent] == '\t' || data[lastContent] == '\n' || data[lastContent] == '\r') {
396 lastContent--
397 }
398 comma := ""
399 if lastContent > start && data[lastContent] != ',' {
400 comma = ","
401 }
402 insertion = fmt.Sprintf("%s\n%s %s\n%s", comma, indent, valueJSON, indent)
403 result := make([]byte, 0, len(data)+len(insertion))
404 result = append(result, data[:lastContent+1]...)
405 result = append(result, []byte(insertion)...)
406 result = append(result, data[closeBracket:]...)
407 return result, nil
408 }
409
410 insertion = fmt.Sprintf("\n%s %s\n%s", indent, valueJSON, indent)
411 result := make([]byte, 0, len(data)+len(insertion))
412 result = append(result, data[:closeBracket]...)
413 result = append(result, []byte(insertion)...)
414 result = append(result, data[closeBracket:]...)
415 return result, nil
416 }
417
418 // detectIndent returns the whitespace prefix of the line containing position pos.
419 func detectIndent(data []byte, pos int) string {
420 lineStart := pos
421 for lineStart > 0 && data[lineStart-1] != '\n' {
422 lineStart--
423 }
424 indent := ""
425 for i := lineStart; i < pos; i++ {
426 if data[i] == ' ' || data[i] == '\t' {
427 indent += string(data[i])
428 } else {
429 break
430 }
431 }
432 return indent
433 }
434
435 // skipToChar advances to the first occurrence of ch, skipping strings and comments.
436 func skipToChar(data []byte, i, n int, ch byte) int {
437 for i < n {
438 if data[i] == '"' {
439 i = skipString(data, i, n)
440 continue
441 }
442 if data[i] == '/' && i+1 < n && data[i+1] == '/' {
443 for i < n && data[i] != '\n' {
444 i++
445 }
446 continue
447 }
448 if data[i] == '/' && i+1 < n && data[i+1] == '*' {
449 i += 2
450 for i+1 < n {
451 if data[i] == '*' && data[i+1] == '/' {
452 i += 2
453 break
454 }
455 i++
456 }
457 continue
458 }
459 if data[i] == ch {
460 return i
461 }
462 i++
463 }
464 return i
465 }
466
github.com/docToolchain/Bausteinsicht/internal/model/patterns.go 0.0%
1 package model
2
3 import (
4 "strings"
5 "unicode"
6 )
7
8 // ExpandPattern takes a pattern definition and applies variable substitution
9 // to generate concrete elements and relationships.
10 // baseID is used for {base}, title is used for {Title} and {BASE}.
11 func ExpandPattern(pattern PatternDefinition, baseID, title string) ([]Element, []Relationship, error) {
12 if title == "" {
13 title = baseID
14 }
15
16 vars := map[string]string{
17 "{base}": baseID,
18 "{Title}": toTitleCase(title),
19 "{BASE}": strings.ToUpper(baseID),
20 }
21
22 // Expand elements (including nested children)
23 elements := make([]Element, len(pattern.Elements))
24 for i, tmpl := range pattern.Elements {
25 elements[i] = expandPatternElement(tmpl, vars)
26 }
27
28 // Expand relationships
29 relationships := make([]Relationship, len(pattern.Relationships))
30 for i, tmpl := range pattern.Relationships {
31 relationships[i] = Relationship{
32 From: replaceVars(tmpl.From, vars),
33 To: replaceVars(tmpl.To, vars),
34 Label: replaceVars(tmpl.Label, vars),
35 Kind: tmpl.Kind,
36 Description: replaceVars(tmpl.Description, vars),
37 }
38 }
39
40 return elements, relationships, nil
41 }
42
43 // expandPatternElement recursively expands an element template, including children
44 func expandPatternElement(tmpl PatternElement, vars map[string]string) Element {
45 elem := Element{
46 Kind: tmpl.Kind,
47 Title: replaceVars(tmpl.Title, vars),
48 Description: replaceVars(tmpl.Description, vars),
49 Technology: replaceVars(tmpl.Technology, vars),
50 Tags: tmpl.Tags,
51 }
52
53 // Recursively expand children if present
54 if len(tmpl.Children) > 0 {
55 elem.Children = make(map[string]Element, len(tmpl.Children))
56 for _, childTmpl := range tmpl.Children {
57 childID := replaceVars(childTmpl.ID, vars)
58 elem.Children[childID] = expandPatternElement(childTmpl, vars)
59 }
60 }
61
62 return elem
63 }
64
65 // ExpandPatternIDs applies variable substitution to element and relationship IDs
66 func ExpandPatternIDs(pattern PatternDefinition, baseID string) ([]string, []string, error) {
67 vars := map[string]string{
68 "{base}": baseID,
69 "{BASE}": strings.ToUpper(baseID),
70 }
71
72 var elemIDs []string
73 for _, tmpl := range pattern.Elements {
74 elemIDs = append(elemIDs, expandPatternElementIDs(tmpl, vars)...)
75 }
76
77 relIDs := make([]string, len(pattern.Relationships))
78 for i, tmpl := range pattern.Relationships {
79 relIDs[i] = replaceVars(tmpl.ID, vars)
80 }
81
82 return elemIDs, relIDs, nil
83 }
84
85 // expandPatternElementIDs recursively extracts all element IDs from a pattern element
86 func expandPatternElementIDs(tmpl PatternElement, vars map[string]string) []string {
87 ids := []string{replaceVars(tmpl.ID, vars)}
88 for _, childTmpl := range tmpl.Children {
89 ids = append(ids, expandPatternElementIDs(childTmpl, vars)...)
90 }
91 return ids
92 }
93
94 // replaceVars substitutes template variables in a string
95 func replaceVars(s string, vars map[string]string) string {
96 result := s
97 for k, v := range vars {
98 result = strings.ReplaceAll(result, k, v)
99 }
100 return result
101 }
102
103 // toTitleCase converts "order" to "Order"
104 func toTitleCase(s string) string {
105 if len(s) == 0 {
106 return s
107 }
108 runes := []rune(s)
109 runes[0] = unicode.ToUpper(runes[0])
110 return string(runes)
111 }
112
113 // CheckPatternConflicts checks if any generated IDs already exist in the model
114 func CheckPatternConflicts(m *BausteinsichtModel, pattern PatternDefinition, baseID string) ([]string, error) {
115 elemIDs, _, err := ExpandPatternIDs(pattern, baseID)
116 if err != nil {
117 return nil, err
118 }
119
120 flat, _ := FlattenElements(m)
121 var conflicts []string
122
123 for _, id := range elemIDs {
124 if _, exists := flat[id]; exists {
125 conflicts = append(conflicts, id)
126 }
127 }
128
129 return conflicts, nil
130 }
131
github.com/docToolchain/Bausteinsicht/internal/model/resolve.go 49.5%
1 package model
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7 )
8
9 // MaxElementDepth is the maximum nesting depth for elements.
10 // This prevents stack overflow from deeply nested or circular model definitions.
11 const MaxElementDepth = 50
12
13 // Resolve traverses the model hierarchy using dot notation (e.g., "webshop.api.auth").
14 func Resolve(m *BausteinsichtModel, id string) (*Element, error) {
15 parts := strings.Split(id, ".")
16 root := parts[0]
17
18 elem, ok := m.Model[root]
19 if !ok {
20 return nil, fmt.Errorf("element %q not found", id)
21 }
22
23 for _, part := range parts[1:] {
24 if elem.Children == nil {
25 return nil, fmt.Errorf("element %q not found: no children at this level", id)
26 }
27 child, ok := elem.Children[part]
28 if !ok {
29 return nil, fmt.Errorf("element %q not found", id)
30 }
31 elem = child
32 }
33
34 return &elem, nil
35 }
36
37 // flattenInto recursively adds elements to the map with their full dot-notation path.
38 func flattenInto(children map[string]Element, prefix string, depth int, result map[string]*Element) error {
39 if depth > MaxElementDepth {
40 return fmt.Errorf("element nesting exceeds maximum depth of %d at %q", MaxElementDepth, strings.TrimSuffix(prefix, "."))
41 }
42 for key, elem := range children {
43 fullID := prefix + key
44 e := elem
45 result[fullID] = &e
46 if elem.Children != nil {
47 if err := flattenInto(elem.Children, fullID+".", depth+1, result); err != nil {
48 return err
49 }
50 }
51 }
52 return nil
53 }
54
55 // FlattenElements returns all elements keyed by full dot-notation ID path.
56 // Returns an error if the element hierarchy exceeds MaxElementDepth.
57 func FlattenElements(m *BausteinsichtModel) (map[string]*Element, error) {
58 result := make(map[string]*Element)
59 if err := flattenInto(m.Model, "", 1, result); err != nil {
60 return nil, err
61 }
62 return result, nil
63 }
64
65 // MatchPattern matches elements in the flat map against a pattern.
66 // Supported patterns:
67 // - "id" — exact match
68 // - "prefix.*" — direct children of prefix (one level deep)
69 // - "prefix.**" — all descendants of prefix (recursive)
70 // - "*" — all top-level elements (no dots in ID)
71 // - "**" — all elements
72 func MatchPattern(flatMap map[string]*Element, pattern string) []string {
73 var matches []string
74
75 switch {
76 case pattern == "**":
77 // Match all elements.
78 for id := range flatMap {
79 matches = append(matches, id)
80 }
81
82 case pattern == "*":
83 // Match top-level elements only (no dots in ID).
84 for id := range flatMap {
85 if !strings.Contains(id, ".") {
86 matches = append(matches, id)
87 }
88 }
89
90 case strings.HasSuffix(pattern, ".**"):
91 // Match all descendants of prefix (recursive).
92 prefix := strings.TrimSuffix(pattern, "**")
93 for id := range flatMap {
94 if strings.HasPrefix(id, prefix) {
95 matches = append(matches, id)
96 }
97 }
98
99 case strings.HasSuffix(pattern, ".*"):
100 // Match direct children only (one level deep).
101 prefix := strings.TrimSuffix(pattern, "*")
102 depth := strings.Count(prefix, ".")
103 for id := range flatMap {
104 if !strings.HasPrefix(id, prefix) {
105 continue
106 }
107 rest := id[len(prefix):]
108 if !strings.Contains(rest, ".") && strings.Count(id, ".") == depth {
109 matches = append(matches, id)
110 }
111 }
112
113 default:
114 // Exact match.
115 if _, ok := flatMap[pattern]; ok {
116 matches = append(matches, pattern)
117 }
118 }
119
120 return matches
121 }
122
123 // ResolveView resolves view includes/excludes to a list of element IDs.
124 // Starts with include patterns, then removes exclude patterns.
125 func ResolveView(m *BausteinsichtModel, view *View) ([]string, error) {
126 if len(view.Include) == 0 {
127 return []string{}, nil
128 }
129
130 flatMap, err := FlattenElements(m)
131 if err != nil {
132 return nil, err
133 }
134
135 included := make(map[string]bool)
136 for _, pattern := range view.Include {
137 for _, id := range MatchPattern(flatMap, pattern) {
138 included[id] = true
139 }
140 }
141
142 for _, pattern := range view.Exclude {
143 for _, id := range MatchPattern(flatMap, pattern) {
144 delete(included, id)
145 }
146 }
147
148 result := make([]string, 0, len(included))
149 for id := range included {
150 result = append(result, id)
151 }
152 sort.Strings(result)
153 return result, nil
154 }
155
156 // FilterElementsByTags applies tag-based filtering to a flat element map.
157 // It includes elements that have ALL FilterTags and excludes elements with ANY ExcludeTags.
158 func FilterElementsByTags(elements map[string]*Element, filterTags, excludeTags []string) map[string]*Element {
159 // Quick exit: no filtering
160 if len(filterTags) == 0 && len(excludeTags) == 0 {
161 return elements
162 }
163
164 result := make(map[string]*Element)
165 for id, elem := range elements {
166 // Check exclude first: if ANY exclude-tag matches, skip
167 excluded := false
168 for _, excludeTag := range excludeTags {
169 for _, elemTag := range elem.Tags {
170 if elemTag == excludeTag {
171 excluded = true
172 break
173 }
174 }
175 if excluded {
176 break
177 }
178 }
179 if excluded {
180 continue
181 }
182
183 // Check include: if ANY filter-tags are specified, element must have ALL of them
184 if len(filterTags) > 0 {
185 hasAllFilterTags := true
186 for _, filterTag := range filterTags {
187 found := false
188 for _, elemTag := range elem.Tags {
189 if elemTag == filterTag {
190 found = true
191 break
192 }
193 }
194 if !found {
195 hasAllFilterTags = false
196 break
197 }
198 }
199 if !hasAllFilterTags {
200 continue
201 }
202 }
203
204 result[id] = elem
205 }
206
207 return result
208 }
209
github.com/docToolchain/Bausteinsicht/internal/model/types.go 61.5%
1 package model
2
3 // Element lifecycle status values
4 const (
5 StatusProposed = "proposed"
6 StatusDesign = "design"
7 StatusImplementing = "implementation"
8 StatusDeployed = "deployed"
9 StatusDeprecated = "deprecated"
10 StatusArchived = "archived"
11 )
12
13 var ValidStatuses = []string{
14 StatusProposed,
15 StatusDesign,
16 StatusImplementing,
17 StatusDeployed,
18 StatusDeprecated,
19 StatusArchived,
20 }
21
22 // StatusColor returns the draw.io badge color for a given status
23 func StatusColor(status string) string {
24 switch status {
25 case StatusProposed:
26 return "#fff2cc" // yellow
27 case StatusDesign:
28 return "#dae8fc" // blue
29 case StatusImplementing:
30 return "#ffe6cc" // orange
31 case StatusDeployed:
32 return "#d5e8d4" // green
33 case StatusDeprecated:
34 return "#f8cecc" // red
35 case StatusArchived:
36 return "#f5f5f5" // grey
37 default:
38 return "#ffffff" // white
39 }
40 }
41
42 // DecisionBadgeColor returns the draw.io badge color for a given ADR status
43 func DecisionBadgeColor(status ADRStatus) string {
44 switch status {
45 case ADRActive:
46 return "#0066cc" // blue
47 case ADRSuperseded, ADRDeprecated:
48 return "#999999" // grey
49 case ADRProposed:
50 return "#ffcc00" // yellow
51 default:
52 return "#ffffff" // white
53 }
54 }
55
56 // Relationship cardinality values
57 const (
58 CardinalityOneToOne = "1:1"
59 CardinalityOneToMany = "1:N"
60 CardinalityManyToMany = "N:N"
61 )
62
63 var ValidCardinalities = []string{
64 CardinalityOneToOne,
65 CardinalityOneToMany,
66 CardinalityManyToMany,
67 }
68
69 // Data flow annotation values
70 const (
71 DataFlowSync = "sync"
72 DataFlowAsync = "async"
73 DataFlowRequestResponse = "request/response"
74 DataFlowPublishSubscribe = "publish/subscribe"
75 )
76
77 var ValidDataFlows = []string{
78 DataFlowSync,
79 DataFlowAsync,
80 DataFlowRequestResponse,
81 DataFlowPublishSubscribe,
82 }
83
84 // Config holds top-level configuration for diagram generation.
85 type Config struct {
86 Metadata *bool `json:"metadata,omitempty"`
87 Legend *bool `json:"legend,omitempty"`
88 Author string `json:"author,omitempty"`
89 Repo string `json:"repo,omitempty"`
90 }
91
92 // ModelSnapshot represents a snapshot of architecture (as-is or to-be)
93 type ModelSnapshot struct {
94 Elements map[string]Element `json:"elements"`
95 Relationships []Relationship `json:"relationships"`
96 }
97
98 // BausteinsichtModel is the top-level model file
99 type BausteinsichtModel struct {
100 Schema string `json:"$schema,omitempty"`
101 Config Config `json:"config,omitempty"`
102 Specification Specification `json:"specification"`
103 Model map[string]Element `json:"model"`
104 Relationships []Relationship `json:"relationships"`
105 Views map[string]View `json:"views"`
106 DynamicViews []DynamicView `json:"dynamicViews,omitempty"`
107 Constraints []Constraint `json:"constraints,omitempty"`
108 AsIs *ModelSnapshot `json:"asIs,omitempty"`
109 ToBe *ModelSnapshot `json:"toBe,omitempty"`
110
111 // ElementOrder stores the definition order of element kinds from
112 // specification.elements. Used by the layout engine for layer assignment.
113 ElementOrder []string `json:"-"`
114 }
115
116 // StepType describes how a sequence step arrow is rendered.
117 type StepType string
118
119 const (
120 StepSync StepType = "sync"
121 StepAsync StepType = "async"
122 StepReturn StepType = "return"
123 )
124
125 // SequenceStep is one message/call in a dynamic view.
126 type SequenceStep struct {
127 Index int `json:"index"`
128 From string `json:"from"`
129 To string `json:"to"`
130 Label string `json:"label"`
131 Type StepType `json:"type,omitempty"`
132 }
133
134 // DynamicView describes a sequence of interactions between elements.
135 type DynamicView struct {
136 Key string `json:"key"`
137 Title string `json:"title"`
138 Description string `json:"description,omitempty"`
139 Steps []SequenceStep `json:"steps"`
140 }
141
142 // Constraint defines an architectural rule that can be enforced via `bausteinsicht lint`.
143 type Constraint struct {
144 ID string `json:"id"`
145 Description string `json:"description"`
146 Rule string `json:"rule"`
147
148 // no-relationship / allowed-relationship
149 FromKind string `json:"from-kind,omitempty"`
150 ToKind string `json:"to-kind,omitempty"`
151 FromKinds []string `json:"from-kinds,omitempty"`
152
153 // required-field
154 ElementKind string `json:"element-kind,omitempty"`
155 Field string `json:"field,omitempty"`
156
157 // max-depth
158 Max int `json:"max,omitempty"`
159
160 // technology-allowed
161 Technologies []string `json:"technologies,omitempty"`
162 }
163
164 // TagDefinition describes a tag with optional styling for draw.io rendering.
165 type TagDefinition struct {
166 ID string `json:"id"`
167 Description string `json:"description,omitempty"`
168 Style map[string]interface{} `json:"style,omitempty"`
169 }
170
171 // PatternElement describes an element template in a pattern
172 type PatternElement struct {
173 ID string `json:"id"`
174 Kind string `json:"kind"`
175 Title string `json:"title"`
176 Technology string `json:"technology,omitempty"`
177 Description string `json:"description,omitempty"`
178 Tags []string `json:"tags,omitempty"`
179 Children []PatternElement `json:"children,omitempty"`
180 }
181
182 // PatternRelationship describes a relationship template in a pattern
183 type PatternRelationship struct {
184 ID string `json:"id"`
185 From string `json:"from"`
186 To string `json:"to"`
187 Label string `json:"label,omitempty"`
188 Kind string `json:"kind,omitempty"`
189 Description string `json:"description,omitempty"`
190 }
191
192 // PatternDefinition describes a reusable topology pattern
193 type PatternDefinition struct {
194 Description string `json:"description,omitempty"`
195 Elements []PatternElement `json:"elements"`
196 Relationships []PatternRelationship `json:"relationships,omitempty"`
197 }
198
199 // ADRStatus describes the status of an architecture decision record
200 type ADRStatus string
201
202 const (
203 ADRProposed ADRStatus = "proposed"
204 ADRActive ADRStatus = "active"
205 ADRDeprecated ADRStatus = "deprecated"
206 ADRSuperseded ADRStatus = "superseded"
207 )
208
209 // DecisionRecord represents an architecture decision record (ADR)
210 type DecisionRecord struct {
211 ID string `json:"id"`
212 Title string `json:"title"`
213 Status ADRStatus `json:"status"`
214 Date string `json:"date,omitempty"`
215 FilePath string `json:"file,omitempty"`
216 }
217
218 type Specification struct {
219 Elements map[string]ElementKind `json:"elements"`
220 Relationships map[string]RelationshipKind `json:"relationships,omitempty"`
221 Tags []TagDefinition `json:"tags,omitempty"`
222 Patterns map[string]PatternDefinition `json:"patterns,omitempty"`
223 Decisions []DecisionRecord `json:"decisions,omitempty"`
224 }
225
226 type ElementKind struct {
227 Notation string `json:"notation"`
228 Description string `json:"description,omitempty"`
229 Container bool `json:"container,omitempty"`
230 }
231
232 type RelationshipKind struct {
233 Notation string `json:"notation"`
234 Dashed bool `json:"dashed,omitempty"`
235 }
236
237 type Element struct {
238 Kind string `json:"kind"`
239 Title string `json:"title"`
240 Description string `json:"description,omitempty"`
241 Technology string `json:"technology,omitempty"`
242 Tags []string `json:"tags,omitempty"`
243 Status string `json:"status,omitempty"`
244 Decisions []string `json:"decisions,omitempty"`
245 Children map[string]Element `json:"children,omitempty"`
246 Metadata map[string]string `json:"metadata,omitempty"`
247 }
248
249 type Relationship struct {
250 From string `json:"from"`
251 To string `json:"to"`
252 Label string `json:"label,omitempty"`
253 Kind string `json:"kind,omitempty"`
254 Description string `json:"description,omitempty"`
255 Decisions []string `json:"decisions,omitempty"`
256 Cardinality string `json:"cardinality,omitempty"`
257 DataFlow string `json:"dataFlow,omitempty"`
258 }
259
260 type View struct {
261 Title string `json:"title"`
262 Scope string `json:"scope,omitempty"`
263 Include []string `json:"include,omitempty"`
264 Exclude []string `json:"exclude,omitempty"`
265 FilterTags []string `json:"filter-tags,omitempty"` // Include only elements with ALL of these tags
266 ExcludeTags []string `json:"exclude-tags,omitempty"` // Exclude elements with ANY of these tags
267 Description string `json:"description,omitempty"`
268 Layout string `json:"layout,omitempty"`
269 }
270
github.com/docToolchain/Bausteinsicht/internal/model/validate.go 84.0%
1 package model
2
3 import (
4 "fmt"
5 "strings"
6 )
7
8 // ValidationError describes a single validation problem with its model path.
9 type ValidationError struct {
10 Path string
11 Message string
12 }
13
14 func (e ValidationError) Error() string {
15 return fmt.Sprintf("%s: %s", e.Path, e.Message)
16 }
17
18 // ValidationWarning describes a non-fatal issue with the model.
19 type ValidationWarning struct {
20 Path string
21 Message string
22 }
23
24 // ValidationResult holds both errors and warnings from validation.
25 type ValidationResult struct {
26 Errors []ValidationError
27 Warnings []ValidationWarning
28 }
29
30 // Validate checks the model for consistency and returns all found errors.
31 func Validate(m *BausteinsichtModel) []ValidationError {
32 result := ValidateWithWarnings(m)
33 return result.Errors
34 }
35
36 // ValidateWithWarnings checks the model for consistency and returns errors and warnings.
37 func ValidateWithWarnings(m *BausteinsichtModel) ValidationResult {
38 var result ValidationResult
39 result.Errors = append(result.Errors, validateElements(m)...)
40 result.Errors = append(result.Errors, validateRelationships(m)...)
41 result.Errors = append(result.Errors, validateViews(m)...)
42 result.Errors = append(result.Errors, validateDynamicViews(m)...)
43 result.Errors = append(result.Errors, validatePatterns(m)...)
44 result.Errors = append(result.Errors, validateDecisions(m)...)
45 result.Warnings = append(result.Warnings, validateEmptyModel(m)...)
46 result.Warnings = append(result.Warnings, validateLifecycleStatus(m)...)
47 result.Warnings = append(result.Warnings, validateOrphanDecisions(m)...)
48 result.Warnings = append(result.Warnings, validateSupersededDecisions(m)...)
49 return result
50 }
51
52 // validateEmptyModel checks for models with no specification or no elements.
53 func validateEmptyModel(m *BausteinsichtModel) []ValidationWarning {
54 var warnings []ValidationWarning
55 if len(m.Specification.Elements) == 0 {
56 warnings = append(warnings, ValidationWarning{
57 Path: "specification",
58 Message: "no element kinds defined in specification",
59 })
60 }
61 if len(m.Model) == 0 {
62 warnings = append(warnings, ValidationWarning{
63 Path: "model",
64 Message: "model is empty (no elements defined)",
65 })
66 }
67 return warnings
68 }
69
70 func validateElements(m *BausteinsichtModel) []ValidationError {
71 var errs []ValidationError
72 for id, elem := range m.Model {
73 if err := validateElementID(id); err != nil {
74 errs = append(errs, ValidationError{Path: "model." + id, Message: err.Error()})
75 }
76 errs = append(errs, validateElement(m, "model."+id, elem, 1)...)
77 }
78 return errs
79 }
80
81 func validateElement(m *BausteinsichtModel, path string, elem Element, depth int) []ValidationError {
82 var errs []ValidationError
83
84 if depth > MaxElementDepth {
85 errs = append(errs, ValidationError{
86 Path: path,
87 Message: fmt.Sprintf("element nesting exceeds maximum depth of %d", MaxElementDepth),
88 })
89 return errs
90 }
91
92 if elem.Kind == "" {
93 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"kind\""})
94 } else {
95 kindDef, known := m.Specification.Elements[elem.Kind]
96 if !known {
97 errs = append(errs, ValidationError{
98 Path: path,
99 Message: fmt.Sprintf("unknown kind %q", elem.Kind),
100 })
101 } else if len(elem.Children) > 0 && !kindDef.Container {
102 errs = append(errs, ValidationError{
103 Path: path,
104 Message: fmt.Sprintf("kind %q does not allow children (container: false)", elem.Kind),
105 })
106 }
107 }
108
109 if elem.Title == "" {
110 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"title\""})
111 }
112
113 for childID, child := range elem.Children {
114 if err := validateElementID(childID); err != nil {
115 errs = append(errs, ValidationError{Path: path + "." + childID, Message: err.Error()})
116 }
117 errs = append(errs, validateElement(m, path+"."+childID, child, depth+1)...)
118 }
119
120 return errs
121 }
122
123 func validateRelationships(m *BausteinsichtModel) []ValidationError {
124 var errs []ValidationError
125 // Track seen relationships keyed by "from->to->kind->label" to allow
126 // multiple relationships between the same pair with different kind or label. (#142)
127 type relSig struct {
128 from, to, kind, label string
129 }
130 seen := make(map[relSig]int) // signature → first index
131
132 for i, rel := range m.Relationships {
133 path := fmt.Sprintf("relationships[%d]", i)
134
135 if _, err := lookupElement(m, rel.From); err != nil {
136 errs = append(errs, ValidationError{
137 Path: path,
138 Message: fmt.Sprintf("from %q does not resolve to an existing element", rel.From),
139 })
140 }
141 if _, err := lookupElement(m, rel.To); err != nil {
142 errs = append(errs, ValidationError{
143 Path: path,
144 Message: fmt.Sprintf("to %q does not resolve to an existing element", rel.To),
145 })
146 }
147 if rel.Kind != "" {
148 if _, known := m.Specification.Relationships[rel.Kind]; !known {
149 errs = append(errs, ValidationError{
150 Path: path,
151 Message: fmt.Sprintf("unknown relationship kind %q", rel.Kind),
152 })
153 }
154 }
155
156 // Validate cardinality
157 if rel.Cardinality != "" {
158 valid := false
159 for _, c := range ValidCardinalities {
160 if rel.Cardinality == c {
161 valid = true
162 break
163 }
164 }
165 if !valid {
166 errs = append(errs, ValidationError{
167 Path: path,
168 Message: fmt.Sprintf("invalid cardinality %q (valid: %v)", rel.Cardinality, ValidCardinalities),
169 })
170 }
171 }
172
173 // Validate data flow
174 if rel.DataFlow != "" {
175 valid := false
176 for _, df := range ValidDataFlows {
177 if rel.DataFlow == df {
178 valid = true
179 break
180 }
181 }
182 if !valid {
183 errs = append(errs, ValidationError{
184 Path: path,
185 Message: fmt.Sprintf("invalid dataFlow %q (valid: %v)", rel.DataFlow, ValidDataFlows),
186 })
187 }
188 }
189
190 // Detect fully duplicate relationships (same from, to, kind, and label). (#117, #142)
191 // Multiple relationships between the same pair are allowed if they
192 // differ in kind or label.
193 sig := relSig{from: rel.From, to: rel.To, kind: rel.Kind, label: rel.Label}
194 if firstIdx, exists := seen[sig]; exists {
195 errs = append(errs, ValidationError{
196 Path: path,
197 Message: fmt.Sprintf("duplicate relationship %s → %s (first at relationships[%d])", rel.From, rel.To, firstIdx),
198 })
199 } else {
200 seen[sig] = i
201 }
202 }
203 return errs
204 }
205
206 // validLayouts is the set of allowed values for View.Layout.
207 var validLayouts = map[string]bool{
208 "": true,
209 "layered": true,
210 "grid": true,
211 "none": true,
212 }
213
214 func validateViews(m *BausteinsichtModel) []ValidationError {
215 var errs []ValidationError
216 for id, view := range m.Views {
217 path := "views." + id
218 if view.Title == "" {
219 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"title\""})
220 }
221 if !validLayouts[view.Layout] {
222 errs = append(errs, ValidationError{
223 Path: path,
224 Message: fmt.Sprintf("invalid layout %q (must be \"layered\", \"grid\", \"none\", or empty)", view.Layout),
225 })
226 }
227 if view.Scope != "" {
228 if _, err := lookupElement(m, view.Scope); err != nil {
229 errs = append(errs, ValidationError{
230 Path: path,
231 Message: fmt.Sprintf("scope %q does not resolve to an existing element", view.Scope),
232 })
233 }
234 }
235 for _, entry := range view.Include {
236 if strings.Contains(entry, "*") {
237 continue
238 }
239 if _, err := lookupElement(m, entry); err != nil {
240 errs = append(errs, ValidationError{
241 Path: path + ".include",
242 Message: fmt.Sprintf("element %q does not exist", entry),
243 })
244 }
245 }
246 for _, entry := range view.Exclude {
247 if strings.Contains(entry, "*") {
248 continue
249 }
250 if _, err := lookupElement(m, entry); err != nil {
251 errs = append(errs, ValidationError{
252 Path: path + ".exclude",
253 Message: fmt.Sprintf("element %q does not exist", entry),
254 })
255 }
256 }
257
258 // Validate filter-tags
259 validTagIDs := make(map[string]bool)
260 for _, tag := range m.Specification.Tags {
261 validTagIDs[tag.ID] = true
262 }
263 for _, tag := range view.FilterTags {
264 if !validTagIDs[tag] {
265 errs = append(errs, ValidationError{
266 Path: path + ".filter-tags",
267 Message: fmt.Sprintf("tag %q is not defined in specification.tags", tag),
268 })
269 }
270 }
271
272 // Validate exclude-tags
273 for _, tag := range view.ExcludeTags {
274 if !validTagIDs[tag] {
275 errs = append(errs, ValidationError{
276 Path: path + ".exclude-tags",
277 Message: fmt.Sprintf("tag %q is not defined in specification.tags", tag),
278 })
279 }
280 }
281 }
282 return errs
283 }
284
285 var validStepTypes = map[StepType]bool{
286 StepSync: true,
287 StepAsync: true,
288 StepReturn: true,
289 "": true, // omitted → default sync
290 }
291
292 func validateDynamicViews(m *BausteinsichtModel) []ValidationError {
293 var errs []ValidationError
294 for vi, dv := range m.DynamicViews {
295 path := fmt.Sprintf("dynamicViews[%d]", vi)
296 if dv.Key == "" {
297 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"key\""})
298 }
299 if dv.Title == "" {
300 errs = append(errs, ValidationError{Path: path, Message: "missing required field \"title\""})
301 }
302 if len(dv.Steps) == 0 {
303 errs = append(errs, ValidationError{Path: path, Message: "dynamic view must have at least one step"})
304 continue
305 }
306 seenIndex := make(map[int]bool)
307 for si, step := range dv.Steps {
308 spath := fmt.Sprintf("%s.steps[%d]", path, si)
309 if _, err := lookupElement(m, step.From); err != nil {
310 errs = append(errs, ValidationError{
311 Path: spath,
312 Message: fmt.Sprintf("from %q does not resolve to an existing element", step.From),
313 })
314 }
315 if _, err := lookupElement(m, step.To); err != nil {
316 errs = append(errs, ValidationError{
317 Path: spath,
318 Message: fmt.Sprintf("to %q does not resolve to an existing element", step.To),
319 })
320 }
321 if !validStepTypes[step.Type] {
322 errs = append(errs, ValidationError{
323 Path: spath,
324 Message: fmt.Sprintf("invalid type %q (must be \"sync\", \"async\", or \"return\")", step.Type),
325 })
326 }
327 if seenIndex[step.Index] {
328 errs = append(errs, ValidationError{
329 Path: spath,
330 Message: fmt.Sprintf("duplicate step index %d", step.Index),
331 })
332 }
333 seenIndex[step.Index] = true
334 }
335 }
336 return errs
337 }
338
339 // validateElementID checks that an element ID is valid.
340 func validateElementID(id string) error {
341 if strings.TrimSpace(id) == "" {
342 return fmt.Errorf("invalid element ID %q: must not be empty or whitespace", id)
343 }
344 return nil
345 }
346
347 // lookupElement resolves a dot-notation path to an Element within the model.
348 func lookupElement(m *BausteinsichtModel, path string) (Element, error) {
349 head, rest, hasDot := strings.Cut(path, ".")
350 elem, ok := m.Model[head]
351 if !ok {
352 return Element{}, fmt.Errorf("element %q not found", head)
353 }
354 if !hasDot {
355 return elem, nil
356 }
357 return lookupChild(elem, rest)
358 }
359
360 func lookupChild(parent Element, path string) (Element, error) {
361 head, rest, hasDot := strings.Cut(path, ".")
362 child, ok := parent.Children[head]
363 if !ok {
364 return Element{}, fmt.Errorf("element %q not found", head)
365 }
366 if !hasDot {
367 return child, nil
368 }
369 return lookupChild(child, rest)
370 }
371
372 // validateLifecycleStatus checks element status fields for validity and lifecycle warnings.
373 func validateLifecycleStatus(m *BausteinsichtModel) []ValidationWarning {
374 var warnings []ValidationWarning
375 flatElements, _ := FlattenElements(m)
376
377 // Collect outgoing relationships by element
378 outgoing := make(map[string][]string)
379 for _, rel := range m.Relationships {
380 outgoing[rel.From] = append(outgoing[rel.From], rel.To)
381 }
382
383 for id, elem := range flatElements {
384 if elem.Status == "" {
385 continue // Status is optional
386 }
387
388 // Validate status value
389 validStatus := false
390 for _, valid := range ValidStatuses {
391 if elem.Status == valid {
392 validStatus = true
393 break
394 }
395 }
396 if !validStatus {
397 warnings = append(warnings, ValidationWarning{
398 Path: "model." + id,
399 Message: fmt.Sprintf("unknown status %q; valid values are: %v", elem.Status, ValidStatuses),
400 })
401 continue
402 }
403
404 // Rule: archived elements should not have outgoing relationships
405 if elem.Status == StatusArchived && len(outgoing[id]) > 0 {
406 warnings = append(warnings, ValidationWarning{
407 Path: "model." + id,
408 Message: fmt.Sprintf("archived element has %d outgoing relationships; archived elements should not have active relationships", len(outgoing[id])),
409 })
410 }
411
412 // Rule: deprecated elements should ideally have a successor
413 if elem.Status == StatusDeprecated {
414 hasSuccessor := false
415 for _, target := range outgoing[id] {
416 if targetElem, ok := flatElements[target]; ok && targetElem.Status == StatusDeployed && targetElem.Kind == elem.Kind {
417 hasSuccessor = true
418 break
419 }
420 }
421 if !hasSuccessor {
422 warnings = append(warnings, ValidationWarning{
423 Path: "model." + id,
424 Message: "deprecated element has no deployed successor of the same kind; consider linking to a replacement",
425 })
426 }
427 }
428 }
429
430 return warnings
431 }
432
433 // validatePatterns checks pattern definitions for consistency
434 func validatePatterns(m *BausteinsichtModel) []ValidationError {
435 var errs []ValidationError
436
437 for patternID, pattern := range m.Specification.Patterns {
438 path := "specification.patterns." + patternID
439
440 // Validate all element kinds referenced in the pattern exist
441 for i, elem := range pattern.Elements {
442 elemPath := fmt.Sprintf("%s.elements[%d]", path, i)
443 if elem.Kind == "" {
444 errs = append(errs, ValidationError{
445 Path: elemPath,
446 Message: "missing required field \"kind\"",
447 })
448 } else if _, exists := m.Specification.Elements[elem.Kind]; !exists {
449 errs = append(errs, ValidationError{
450 Path: elemPath,
451 Message: fmt.Sprintf("unknown kind %q", elem.Kind),
452 })
453 }
454 }
455
456 // Validate all relationship kinds referenced in the pattern exist
457 for i, rel := range pattern.Relationships {
458 relPath := fmt.Sprintf("%s.relationships[%d]", path, i)
459 if rel.Kind != "" {
460 if _, exists := m.Specification.Relationships[rel.Kind]; !exists {
461 errs = append(errs, ValidationError{
462 Path: relPath,
463 Message: fmt.Sprintf("unknown relationship kind %q", rel.Kind),
464 })
465 }
466 }
467 }
468 }
469
470 return errs
471 }
472
473 // validateDecisions checks for unknown ADR IDs referenced by elements and relationships.
474 func validateDecisions(m *BausteinsichtModel) []ValidationError {
475 var errs []ValidationError
476
477 // Build a map of known decision IDs
478 knownDecisions := make(map[string]bool)
479 for _, decision := range m.Specification.Decisions {
480 knownDecisions[decision.ID] = true
481 }
482
483 // Check elements
484 flatElements, _ := FlattenElements(m)
485 for id, elem := range flatElements {
486 for _, decisionID := range elem.Decisions {
487 if !knownDecisions[decisionID] {
488 errs = append(errs, ValidationError{
489 Path: "model." + id,
490 Message: fmt.Sprintf("references unknown decision %q", decisionID),
491 })
492 }
493 }
494 }
495
496 // Check relationships
497 for i, rel := range m.Relationships {
498 for _, decisionID := range rel.Decisions {
499 if !knownDecisions[decisionID] {
500 errs = append(errs, ValidationError{
501 Path: fmt.Sprintf("relationships[%d]", i),
502 Message: fmt.Sprintf("references unknown decision %q", decisionID),
503 })
504 }
505 }
506 }
507
508 return errs
509 }
510
511 // validateOrphanDecisions checks for decisions that are not referenced by any element or relationship.
512 func validateOrphanDecisions(m *BausteinsichtModel) []ValidationWarning {
513 var warnings []ValidationWarning
514
515 // Build a set of referenced decisions
516 referencedDecisions := make(map[string]bool)
517
518 flatElements, _ := FlattenElements(m)
519 for _, elem := range flatElements {
520 for _, decisionID := range elem.Decisions {
521 referencedDecisions[decisionID] = true
522 }
523 }
524
525 for _, rel := range m.Relationships {
526 for _, decisionID := range rel.Decisions {
527 referencedDecisions[decisionID] = true
528 }
529 }
530
531 // Check for orphans
532 for _, decision := range m.Specification.Decisions {
533 if !referencedDecisions[decision.ID] {
534 warnings = append(warnings, ValidationWarning{
535 Path: "specification.decisions",
536 Message: fmt.Sprintf("decision %q is not referenced by any element or relationship", decision.ID),
537 })
538 }
539 }
540
541 return warnings
542 }
543
544 // validateSupersededDecisions checks for superseded decisions that are still referenced.
545 func validateSupersededDecisions(m *BausteinsichtModel) []ValidationWarning {
546 var warnings []ValidationWarning
547
548 // Build a map of decision status
549 decisionStatus := make(map[string]ADRStatus)
550 for _, decision := range m.Specification.Decisions {
551 decisionStatus[decision.ID] = decision.Status
552 }
553
554 // Check elements
555 flatElements, _ := FlattenElements(m)
556 for id, elem := range flatElements {
557 for _, decisionID := range elem.Decisions {
558 if status, exists := decisionStatus[decisionID]; exists && status == ADRSuperseded {
559 warnings = append(warnings, ValidationWarning{
560 Path: "model." + id,
561 Message: fmt.Sprintf("references superseded decision %q", decisionID),
562 })
563 }
564 }
565 }
566
567 // Check relationships
568 for i, rel := range m.Relationships {
569 for _, decisionID := range rel.Decisions {
570 if status, exists := decisionStatus[decisionID]; exists && status == ADRSuperseded {
571 warnings = append(warnings, ValidationWarning{
572 Path: fmt.Sprintf("relationships[%d]", i),
573 Message: fmt.Sprintf("references superseded decision %q", decisionID),
574 })
575 }
576 }
577 }
578
579 return warnings
580 }
581
582
github.com/docToolchain/Bausteinsicht/internal/overlay/apply.go 0.0%
1 package overlay
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7
8 "github.com/beevik/etree"
9 )
10
11 const OriginalFillAttr = "data-original-fill"
12
13 func LoadMetricsFile(path string) (*MetricsFile, error) {
14 data, err := os.ReadFile(path)
15 if err != nil {
16 return nil, fmt.Errorf("reading metrics file: %w", err)
17 }
18 var mf MetricsFile
19 if err := json.Unmarshal(data, &mf); err != nil {
20 return nil, fmt.Errorf("parsing metrics file: %w", err)
21 }
22 return &mf, nil
23 }
24
25 func Apply(drawioPath string, metrics *MetricsFile, metricKey string, scheme ColorScheme) error {
26 doc := etree.NewDocument()
27 if err := doc.ReadFromFile(drawioPath); err != nil {
28 return fmt.Errorf("reading draw.io file: %w", err)
29 }
30
31 extracted, err := ExtractMetric(metrics.Metrics, metricKey)
32 if err != nil {
33 return fmt.Errorf("extracting metric %q: %w", metricKey, err)
34 }
35
36 if len(extracted) == 0 {
37 return fmt.Errorf("no elements found for metric %q", metricKey)
38 }
39
40 higherIsBetter := IsMetricBetter(metricKey)
41 normalized := Normalize(extracted, higherIsBetter)
42
43 root := doc.Root()
44 for _, page := range root.FindElements(".//mxGraphModel/root/mxCell") {
45 elementID := page.SelectAttrValue("id", "")
46 if elementID == "" || elementID == "0" || elementID == "1" {
47 continue
48 }
49
50 if normVal, ok := normalized[elementID]; ok {
51 applyColor(page, normVal, scheme)
52 }
53 }
54
55 if err := doc.WriteToFile(drawioPath); err != nil {
56 return fmt.Errorf("writing draw.io file: %w", err)
57 }
58 return nil
59 }
60
61 func Remove(drawioPath string) error {
62 doc := etree.NewDocument()
63 if err := doc.ReadFromFile(drawioPath); err != nil {
64 return fmt.Errorf("reading draw.io file: %w", err)
65 }
66
67 root := doc.Root()
68 for _, cell := range root.FindElements(".//mxGraphModel/root/mxCell") {
69 originalFill := cell.SelectAttrValue(OriginalFillAttr, "")
70 if originalFill != "" {
71 geometry := cell.FindElement("mxGeometry")
72 if geometry != nil {
73 style := geometry.SelectAttrValue("style", "")
74 if style != "" {
75 style = updateStyleFill(style, originalFill)
76 geometry.CreateAttr("style", style)
77 }
78 }
79 cell.RemoveAttr(OriginalFillAttr)
80 }
81 }
82
83 if err := doc.WriteToFile(drawioPath); err != nil {
84 return fmt.Errorf("writing draw.io file: %w", err)
85 }
86 return nil
87 }
88
89 func applyColor(cell *etree.Element, normalized float64, scheme ColorScheme) {
90 color := ColorForValue(normalized, scheme)
91
92 geometry := cell.FindElement("mxGeometry")
93 if geometry == nil {
94 return
95 }
96
97 style := geometry.SelectAttrValue("style", "")
98 originalFill := geometry.SelectAttrValue("fillColor", "")
99
100 if originalFill == "" {
101 originalFill = "#ffffff"
102 }
103 if cell.SelectAttrValue(OriginalFillAttr, "") == "" {
104 cell.CreateAttr(OriginalFillAttr, originalFill)
105 }
106
107 style = updateStyleFill(style, color)
108 geometry.CreateAttr("style", style)
109 }
110
111 func updateStyleFill(style, color string) string {
112 if style == "" {
113 return "fillColor=" + color
114 }
115
116 result := ""
117 hasKey := false
118 for _, part := range parseStyleParts(style) {
119 if len(part) > 0 && startsWithKey(part, "fillColor") {
120 result += "fillColor=" + color + ";"
121 hasKey = true
122 } else {
123 if part != "" {
124 result += part + ";"
125 }
126 }
127 }
128 if !hasKey {
129 result += "fillColor=" + color + ";"
130 }
131 return result
132 }
133
134 func parseStyleParts(style string) []string {
135 var result []string
136 var current string
137 for _, ch := range style {
138 if ch == ';' {
139 if current != "" {
140 result = append(result, current)
141 current = ""
142 }
143 } else {
144 current += string(ch)
145 }
146 }
147 if current != "" {
148 result = append(result, current)
149 }
150 return result
151 }
152
153 func startsWithKey(part, key string) bool {
154 return len(part) >= len(key) && part[:len(key)] == key
155 }
156
github.com/docToolchain/Bausteinsicht/internal/overlay/normalize.go 97.2%
1 package overlay
2
3 import (
4 "cmp"
5 "slices"
6 )
7
8 func IsMetricBetter(metricName string) bool {
9 goodMetrics := map[string]bool{
10 "coverage": true,
11 "uptime": true,
12 "deploy_freq": true,
13 "success_rate": true,
14 "availability": true,
15 }
16 badMetrics := map[string]bool{
17 "error_rate": true,
18 "latency": true,
19 "p99": true,
20 "p99_ms": true,
21 "response_time": true,
22 "cpu_usage": true,
23 "memory_usage": true,
24 "error_count": true,
25 "failures": true,
26 }
27
28 if goodMetrics[metricName] {
29 return true
30 }
31 if badMetrics[metricName] {
32 return false
33 }
34 return false
35 }
36
37 func Normalize(metrics []NormalizedMetric, higherIsBetter bool) map[string]float64 {
38 if len(metrics) == 0 {
39 return make(map[string]float64)
40 }
41
42 values := make([]float64, len(metrics))
43 for i, m := range metrics {
44 values[i] = m.Value
45 }
46
47 min := slices.Min(values)
48 max := slices.Max(values)
49 span := max - min
50
51 result := make(map[string]float64)
52 for _, m := range metrics {
53 normalized := 0.0
54 if span > 0 {
55 normalized = (m.Value - min) / span
56 }
57 if !higherIsBetter {
58 normalized = 1 - normalized
59 }
60 result[m.ElementID] = normalized
61 }
62 return result
63 }
64
65 func ColorForValue(normalized float64, scheme ColorScheme) string {
66 switch {
67 case normalized < 0.25:
68 return scheme.Green
69 case normalized < 0.50:
70 return scheme.Yellow
71 case normalized < 0.75:
72 return scheme.Orange
73 default:
74 return scheme.Red
75 }
76 }
77
78 func ExtractMetric(metrics []ElementMetric, metricKey string) ([]NormalizedMetric, error) {
79 result := make([]NormalizedMetric, 0, len(metrics))
80 for _, m := range metrics {
81 if val, ok := m.Values[metricKey]; ok {
82 result = append(result, NormalizedMetric{
83 ElementID: m.ElementID,
84 Value: val,
85 })
86 }
87 }
88 slices.SortFunc(result, func(a, b NormalizedMetric) int {
89 return cmp.Compare(a.ElementID, b.ElementID)
90 })
91 return result, nil
92 }
93
github.com/docToolchain/Bausteinsicht/internal/overlay/types.go 0.0%
1 package overlay
2
3 import (
4 "encoding/json"
5 )
6
7 type MetricsFile struct {
8 Meta MetaInfo `json:"meta"`
9 Metrics []ElementMetric `json:"metrics"`
10 }
11
12 type MetaInfo struct {
13 Generated string `json:"generated"`
14 Source string `json:"source"`
15 MetricDescriptions map[string]string `json:"metric_descriptions"`
16 }
17
18 type ElementMetric struct {
19 ElementID string `json:"elementId"`
20 Values map[string]float64
21 }
22
23 func (em *ElementMetric) UnmarshalJSON(data []byte) error {
24 var raw map[string]interface{}
25 if err := json.Unmarshal(data, &raw); err != nil {
26 return err
27 }
28
29 em.Values = make(map[string]float64)
30 for key, val := range raw {
31 if key == "elementId" {
32 if str, ok := val.(string); ok {
33 em.ElementID = str
34 }
35 } else if num, ok := val.(float64); ok {
36 em.Values[key] = num
37 }
38 }
39 return nil
40 }
41
42 type NormalizedMetric struct {
43 ElementID string
44 Value float64
45 }
46
47 type ColorScheme struct {
48 Green string
49 Yellow string
50 Orange string
51 Red string
52 }
53
54 var DefaultColorScheme = ColorScheme{
55 Green: "#d5e8d4",
56 Yellow: "#fff2cc",
57 Orange: "#ffe6cc",
58 Red: "#f8cecc",
59 }
60
github.com/docToolchain/Bausteinsicht/internal/schema/generator.go 85.9%
1 package schema
2
3 import (
4 "encoding/json"
5 "reflect"
6 )
7
8 // JSONSchema represents a JSON Schema Draft 7 schema
9 type JSONSchema struct {
10 Schema string `json:"$schema"`
11 Title string `json:"title"`
12 Description string `json:"description,omitempty"`
13 Type string `json:"type"`
14 Properties map[string]interface{} `json:"properties,omitempty"`
15 Required []string `json:"required,omitempty"`
16 Definitions map[string]interface{} `json:"definitions,omitempty"`
17 }
18
19 // Generator generates JSON Schema from Go types
20 type Generator struct {
21 definitions map[string]interface{}
22 }
23
24 // NewGenerator creates a new schema generator
25 func NewGenerator() *Generator {
26 return &Generator{
27 definitions: make(map[string]interface{}),
28 }
29 }
30
31 // Generate generates JSON Schema for a given type
32 func (g *Generator) Generate(v interface{}) *JSONSchema {
33 schema := &JSONSchema{
34 Schema: "http://json-schema.org/draft-07/schema#",
35 Title: "Bausteinsicht Model",
36 Description: "Architecture model in Bausteinsicht format",
37 Type: "object",
38 Properties: make(map[string]interface{}),
39 Definitions: g.definitions,
40 }
41
42 // Generate properties from struct fields
43 t := reflect.TypeOf(v)
44 if t.Kind() == reflect.Ptr { //nolint:govet
45 t = t.Elem()
46 }
47
48 for i := 0; i < t.NumField(); i++ {
49 field := t.Field(i)
50 jsonTag := field.Tag.Get("json")
51 if jsonTag == "" || jsonTag == "-" {
52 continue
53 }
54
55 fieldName := jsonTag
56 if idx := findComma(jsonTag); idx >= 0 {
57 fieldName = jsonTag[:idx]
58 }
59
60 schema.Properties[fieldName] = g.generateFieldSchema(field.Type)
61
62 // Add to required if no omitempty
63 if !hasOmitempty(jsonTag) {
64 schema.Required = append(schema.Required, fieldName)
65 }
66 }
67
68 return schema
69 }
70
71 // generateFieldSchema generates schema for a single field
72 func (g *Generator) generateFieldSchema(t reflect.Type) interface{} {
73 if t.Kind() == reflect.Ptr { //nolint:govet
74 return g.generateFieldSchema(t.Elem())
75 }
76
77 switch t.Kind() {
78 case reflect.String:
79 return map[string]interface{}{"type": "string"}
80 case reflect.Bool:
81 return map[string]interface{}{"type": "boolean"}
82 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
83 return map[string]interface{}{"type": "integer"}
84 case reflect.Float32, reflect.Float64:
85 return map[string]interface{}{"type": "number"}
86 case reflect.Slice, reflect.Array:
87 return map[string]interface{}{
88 "type": "array",
89 "items": g.generateFieldSchema(t.Elem()),
90 }
91 case reflect.Map:
92 return map[string]interface{}{
93 "type": "object",
94 }
95 case reflect.Struct:
96 return g.generateObjectSchema(t)
97 default:
98 return map[string]interface{}{"type": "object"}
99 }
100 }
101
102 // generateObjectSchema generates schema for a struct type
103 func (g *Generator) generateObjectSchema(t reflect.Type) interface{} {
104 typeName := t.Name()
105 if typeName == "" {
106 return map[string]interface{}{"type": "object"}
107 }
108
109 // Check if already defined
110 if _, exists := g.definitions[typeName]; exists {
111 return map[string]interface{}{"$ref": "#/definitions/" + typeName}
112 }
113
114 schema := map[string]interface{}{
115 "type": "object",
116 "properties": make(map[string]interface{}),
117 }
118
119 properties := schema["properties"].(map[string]interface{})
120 required := []string{}
121
122 for i := 0; i < t.NumField(); i++ {
123 field := t.Field(i)
124 jsonTag := field.Tag.Get("json")
125 if jsonTag == "" || jsonTag == "-" {
126 continue
127 }
128
129 fieldName := jsonTag
130 if idx := findComma(jsonTag); idx >= 0 {
131 fieldName = jsonTag[:idx]
132 }
133
134 properties[fieldName] = g.generateFieldSchema(field.Type)
135
136 if !hasOmitempty(jsonTag) {
137 required = append(required, fieldName)
138 }
139 }
140
141 if len(required) > 0 {
142 schema["required"] = required
143 }
144
145 g.definitions[typeName] = schema
146 return map[string]interface{}{"$ref": "#/definitions/" + typeName}
147 }
148
149 // ToJSON returns the schema as formatted JSON
150 func (s *JSONSchema) ToJSON() ([]byte, error) {
151 return json.MarshalIndent(s, "", " ")
152 }
153
154 // helper functions
155
156 func findComma(s string) int {
157 for i, c := range s {
158 if c == ',' {
159 return i
160 }
161 }
162 return -1
163 }
164
165 func hasOmitempty(jsonTag string) bool {
166 idx := findComma(jsonTag)
167 if idx < 0 {
168 return false
169 }
170 return json.Unmarshal([]byte(`"`+jsonTag[idx+1:]+`"`), new(string)) == nil &&
171 contains(jsonTag[idx+1:], "omitempty")
172 }
173
174 func contains(s, substr string) bool {
175 for i := 0; i <= len(s)-len(substr); i++ {
176 if s[i:i+len(substr)] == substr {
177 return true
178 }
179 }
180 return false
181 }
182
github.com/docToolchain/Bausteinsicht/internal/search/scorer.go 95.6%
1 package search
2
3 import (
4 "strings"
5 )
6
7 // fieldMatch checks whether the field value contains all query words (case-insensitive).
8 // Returns the weight if it matches, 0 otherwise. An exact full-string match
9 // (after lowercasing) returns 10× the weight to prioritise ID hits.
10 func fieldMatch(value string, words []string, weight int) (score int, matched bool) {
11 if value == "" || weight == 0 {
12 return 0, false
13 }
14 lower := strings.ToLower(value)
15 for _, w := range words {
16 if !strings.Contains(lower, w) {
17 return 0, false
18 }
19 }
20 // Exact match bonus: single-word query that equals the whole field value.
21 if len(words) == 1 && lower == words[0] {
22 return weight * 10, true
23 }
24 return weight, true
25 }
26
27 // scoreElement computes a relevance score for an element.
28 // Returns the total score and the list of field names that contributed.
29 func scoreElement(id, title, description, technology, kind string, tags []string, words []string) (int, []string) {
30 type field struct {
31 name string
32 value string
33 weight int
34 }
35 fields := []field{
36 {"id", id, 3},
37 {"title", title, 3},
38 {"technology", technology, 2},
39 {"kind", kind, 2},
40 {"description", description, 1},
41 }
42
43 total := 0
44 var matched []string
45 for _, f := range fields {
46 if s, ok := fieldMatch(f.value, words, f.weight); ok {
47 total += s
48 matched = append(matched, f.name)
49 }
50 }
51 for _, tag := range tags {
52 if s, ok := fieldMatch(tag, words, 2); ok {
53 total += s
54 if !contains(matched, "tags") {
55 matched = append(matched, "tags")
56 }
57 }
58 }
59 return total, matched
60 }
61
62 // scoreRelationship computes a relevance score for a relationship.
63 func scoreRelationship(id, label, kind, fromTitle, toTitle string, words []string) (int, []string) {
64 type field struct {
65 name string
66 value string
67 weight int
68 }
69 fields := []field{
70 {"id", id, 3},
71 {"label", label, 3},
72 {"kind", kind, 2},
73 {"from", fromTitle, 2},
74 {"to", toTitle, 2},
75 }
76
77 total := 0
78 var matched []string
79 for _, f := range fields {
80 if s, ok := fieldMatch(f.value, words, f.weight); ok {
81 total += s
82 matched = append(matched, f.name)
83 }
84 }
85 return total, matched
86 }
87
88 // scoreView computes a relevance score for a view.
89 func scoreView(key, title, description string, words []string) (int, []string) {
90 type field struct {
91 name string
92 value string
93 weight int
94 }
95 fields := []field{
96 {"key", key, 3},
97 {"title", title, 3},
98 {"description", description, 1},
99 }
100
101 total := 0
102 var matched []string
103 for _, f := range fields {
104 if s, ok := fieldMatch(f.value, words, f.weight); ok {
105 total += s
106 matched = append(matched, f.name)
107 }
108 }
109 return total, matched
110 }
111
112 func contains(slice []string, s string) bool {
113 for _, v := range slice {
114 if v == s {
115 return true
116 }
117 }
118 return false
119 }
120
github.com/docToolchain/Bausteinsicht/internal/search/search.go 94.9%
1 // Package search implements full-text search over Bausteinsicht model objects
2 // (elements, relationships, views) with field-weighted relevance scoring.
3 package search
4
5 import (
6 "sort"
7 "strings"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // Run searches the model for the given query and returns ranked results.
13 // The query is split into words; all words must appear (AND semantics).
14 // Results are sorted by descending score, then alphabetically by ID.
15 func Run(query string, m *model.BausteinsichtModel, opts Options) Response {
16 words := tokenise(query)
17 if len(words) == 0 {
18 return Response{Query: query, Results: []Result{}, Total: 0}
19 }
20
21 flat, err := model.FlattenElements(m)
22 if err != nil {
23 return Response{Query: query, Results: []Result{}, Total: 0}
24 }
25
26 // Build a title lookup for relationships (from/to display).
27 titleOf := func(id string) string {
28 if e, ok := flat[id]; ok && e.Title != "" {
29 return e.Title
30 }
31 return id
32 }
33
34 var results []Result
35
36 if opts.Type == "" || opts.Type == ResultElement {
37 for id, elem := range flat {
38 score, matched := scoreElement(id, elem.Title, elem.Description, elem.Technology, elem.Kind, elem.Tags, words)
39 if score == 0 {
40 continue
41 }
42 results = append(results, Result{
43 Type: ResultElement,
44 ID: id,
45 Title: elem.Title,
46 Kind: elem.Kind,
47 Technology: elem.Technology,
48 Description: elem.Description,
49 Score: score,
50 MatchedFields: matched,
51 })
52 }
53 }
54
55 if opts.Type == "" || opts.Type == ResultRelationship {
56 for _, rel := range m.Relationships {
57 id := rel.From + "->" + rel.To
58 score, matched := scoreRelationship(id, rel.Label, rel.Kind, titleOf(rel.From), titleOf(rel.To), words)
59 if score == 0 {
60 continue
61 }
62 results = append(results, Result{
63 Type: ResultRelationship,
64 ID: id,
65 Title: rel.Label,
66 Kind: rel.Kind,
67 From: rel.From,
68 To: rel.To,
69 Description: rel.Description,
70 Score: score,
71 MatchedFields: matched,
72 })
73 }
74 }
75
76 if opts.Type == "" || opts.Type == ResultView {
77 for key, view := range m.Views {
78 score, matched := scoreView(key, view.Title, view.Description, words)
79 if score == 0 {
80 continue
81 }
82 results = append(results, Result{
83 Type: ResultView,
84 ID: key,
85 Title: view.Title,
86 Description: view.Description,
87 Score: score,
88 MatchedFields: matched,
89 })
90 }
91 }
92
93 sort.Slice(results, func(i, j int) bool {
94 if results[i].Score != results[j].Score {
95 return results[i].Score > results[j].Score
96 }
97 return results[i].ID < results[j].ID
98 })
99
100 return Response{
101 Query: query,
102 Results: results,
103 Total: len(results),
104 }
105 }
106
107 // tokenise lowercases the query and splits it into words.
108 func tokenise(query string) []string {
109 lower := strings.ToLower(strings.TrimSpace(query))
110 if lower == "" {
111 return nil
112 }
113 return strings.Fields(lower)
114 }
115
github.com/docToolchain/Bausteinsicht/internal/snapshot/storage.go 0.0%
1 package snapshot
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9 "time"
10 )
11
12 const (
13 snapshotDir = ".bausteinsicht-snapshots"
14 indexFile = "index.json"
15 snapshotDir0755 = 0o755
16 fileMode0644 = 0o644
17 )
18
19 // Manager handles snapshot storage and retrieval
20 type Manager struct {
21 baseDir string
22 }
23
24 // NewManager creates a new snapshot manager for a given directory
25 func NewManager(baseDir string) *Manager {
26 return &Manager{baseDir: baseDir}
27 }
28
29 // snapshotPath returns the path to the snapshot directory
30 func (m *Manager) snapshotPath() string {
31 return filepath.Join(m.baseDir, snapshotDir)
32 }
33
34 // indexPath returns the path to the snapshot index file
35 func (m *Manager) indexPath() string {
36 return filepath.Join(m.snapshotPath(), indexFile)
37 }
38
39 // snapshotFilePath returns the path to a specific snapshot file
40 func (m *Manager) snapshotFilePath(id string) string {
41 return filepath.Join(m.snapshotPath(), id+".json")
42 }
43
44 // Save stores a snapshot to disk
45 func (m *Manager) Save(snapshot *Snapshot) error {
46 // Create snapshot directory if it doesn't exist
47 snapPath := m.snapshotPath()
48 if err := os.MkdirAll(snapPath, snapshotDir0755); err != nil {
49 return fmt.Errorf("creating snapshot directory: %w", err)
50 }
51
52 // Write snapshot file
53 snapshotFile := m.snapshotFilePath(snapshot.ID)
54 data, err := json.MarshalIndent(snapshot, "", " ")
55 if err != nil {
56 return fmt.Errorf("marshaling snapshot: %w", err)
57 }
58 if err := os.WriteFile(snapshotFile, data, fileMode0644); err != nil {
59 return fmt.Errorf("writing snapshot file: %w", err)
60 }
61
62 // Update index
63 if err := m.updateIndex(snapshot); err != nil {
64 return fmt.Errorf("updating index: %w", err)
65 }
66
67 return nil
68 }
69
70 // Load retrieves a snapshot from disk
71 func (m *Manager) Load(id string) (*Snapshot, error) {
72 snapshotFile := m.snapshotFilePath(id)
73 data, err := os.ReadFile(snapshotFile)
74 if err != nil {
75 return nil, fmt.Errorf("reading snapshot file: %w", err)
76 }
77
78 var snapshot Snapshot
79 if err := json.Unmarshal(data, &snapshot); err != nil {
80 return nil, fmt.Errorf("parsing snapshot: %w", err)
81 }
82
83 return &snapshot, nil
84 }
85
86 // List returns all saved snapshots sorted by timestamp (newest first)
87 func (m *Manager) List() ([]SnapshotMetadata, error) {
88 indexPath := m.indexPath()
89 data, err := os.ReadFile(indexPath)
90 if err != nil {
91 if os.IsNotExist(err) {
92 return []SnapshotMetadata{}, nil
93 }
94 return nil, fmt.Errorf("reading index: %w", err)
95 }
96
97 var index SnapshotIndex
98 if err := json.Unmarshal(data, &index); err != nil {
99 return nil, fmt.Errorf("parsing index: %w", err)
100 }
101
102 return index.Snapshots, nil
103 }
104
105 // Delete removes a snapshot from disk
106 func (m *Manager) Delete(id string) error {
107 snapshotFile := m.snapshotFilePath(id)
108 if err := os.Remove(snapshotFile); err != nil {
109 return fmt.Errorf("deleting snapshot file: %w", err)
110 }
111
112 // Update index to remove the snapshot
113 if err := m.removeFromIndex(id); err != nil {
114 return fmt.Errorf("updating index: %w", err)
115 }
116
117 return nil
118 }
119
120 // updateIndex adds or updates a snapshot in the index
121 func (m *Manager) updateIndex(snapshot *Snapshot) error {
122 snapPath := m.snapshotPath()
123 if err := os.MkdirAll(snapPath, snapshotDir0755); err != nil {
124 return err
125 }
126
127 indexPath := m.indexPath()
128 var index SnapshotIndex
129
130 // Read existing index if it exists
131 if data, err := os.ReadFile(indexPath); err == nil {
132 if err := json.Unmarshal(data, &index); err != nil {
133 return err
134 }
135 }
136
137 // Remove old entry if it exists
138 index.Snapshots = removeMetadataByID(index.Snapshots, snapshot.ID)
139
140 // Add new entry
141 index.Snapshots = append(index.Snapshots, snapshot.ToMetadata())
142
143 // Sort by timestamp (newest first)
144 sort.Slice(index.Snapshots, func(i, j int) bool {
145 return index.Snapshots[i].Timestamp.After(index.Snapshots[j].Timestamp)
146 })
147
148 index.Version = 1
149 index.UpdatedAt = time.Now().UTC()
150
151 // Write updated index
152 data, err := json.MarshalIndent(index, "", " ")
153 if err != nil {
154 return err
155 }
156 return os.WriteFile(indexPath, data, fileMode0644)
157 }
158
159 // removeFromIndex removes a snapshot from the index
160 func (m *Manager) removeFromIndex(id string) error {
161 indexPath := m.indexPath()
162 data, err := os.ReadFile(indexPath)
163 if err != nil {
164 if os.IsNotExist(err) {
165 return nil
166 }
167 return err
168 }
169
170 var index SnapshotIndex
171 if err := json.Unmarshal(data, &index); err != nil {
172 return err
173 }
174
175 index.Snapshots = removeMetadataByID(index.Snapshots, id)
176 index.UpdatedAt = time.Now().UTC()
177
178 updatedData, err := json.MarshalIndent(index, "", " ")
179 if err != nil {
180 return err
181 }
182
183 return os.WriteFile(indexPath, updatedData, fileMode0644)
184 }
185
186 // removeMetadataByID removes a metadata entry by ID
187 func removeMetadataByID(metadata []SnapshotMetadata, id string) []SnapshotMetadata {
188 var result []SnapshotMetadata
189 for _, m := range metadata {
190 if m.ID != id {
191 result = append(result, m)
192 }
193 }
194 return result
195 }
196
197 // Exists checks if a snapshot exists
198 func (m *Manager) Exists(id string) bool {
199 snapshotFile := m.snapshotFilePath(id)
200 _, err := os.Stat(snapshotFile)
201 return err == nil
202 }
203
204 // ListFiles returns all snapshot files in the directory
205 func (m *Manager) ListFiles() ([]string, error) {
206 snapPath := m.snapshotPath()
207 entries, err := os.ReadDir(snapPath)
208 if err != nil {
209 if os.IsNotExist(err) {
210 return []string{}, nil
211 }
212 return nil, err
213 }
214
215 var files []string
216 for _, entry := range entries {
217 if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" && entry.Name() != indexFile {
218 files = append(files, entry.Name())
219 }
220 }
221
222 return files, nil
223 }
224
github.com/docToolchain/Bausteinsicht/internal/snapshot/types.go 0.0%
1 package snapshot
2
3 import (
4 "fmt"
5 "time"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // Snapshot represents a point-in-time capture of the architecture model
11 type Snapshot struct {
12 ID string `json:"id"`
13 Timestamp time.Time `json:"timestamp"`
14 Message string `json:"message,omitempty"`
15 Model *model.BausteinsichtModel `json:"model"`
16 }
17
18 // SnapshotMetadata is lightweight metadata for snapshots in the index
19 type SnapshotMetadata struct {
20 ID string `json:"id"`
21 Timestamp time.Time `json:"timestamp"`
22 Message string `json:"message,omitempty"`
23 ElementCount int `json:"elementCount"`
24 RelCount int `json:"relationshipCount"`
25 }
26
27 // SnapshotIndex holds the list of all snapshots
28 type SnapshotIndex struct {
29 Version int `json:"version"`
30 Snapshots []SnapshotMetadata `json:"snapshots"`
31 UpdatedAt time.Time `json:"updatedAt"`
32 }
33
34 // NewSnapshot creates a snapshot from a model
35 func NewSnapshot(message string, model *model.BausteinsichtModel) *Snapshot {
36 snapshot := &Snapshot{
37 ID: generateSnapshotID(),
38 Timestamp: time.Now().UTC(),
39 Message: message,
40 Model: model,
41 }
42 return snapshot
43 }
44
45 // generateSnapshotID creates a timestamp-based snapshot ID with nanosecond precision
46 func generateSnapshotID() string {
47 now := time.Now().UTC()
48 return now.Format("snapshot-2006-01-02T15-04-05") + fmt.Sprintf(".%09dZ", now.Nanosecond())
49 }
50
51 // ToMetadata converts a snapshot to its metadata representation
52 func (s *Snapshot) ToMetadata() SnapshotMetadata {
53 elementCount := 0
54 if s.Model != nil {
55 elementCount = len(flattenElements(s.Model.Model))
56 }
57
58 relationCount := 0
59 if s.Model != nil {
60 relationCount = len(s.Model.Relationships)
61 }
62
63 return SnapshotMetadata{
64 ID: s.ID,
65 Timestamp: s.Timestamp,
66 Message: s.Message,
67 ElementCount: elementCount,
68 RelCount: relationCount,
69 }
70 }
71
72 // flattenElements counts total elements including nested ones
73 func flattenElements(elems map[string]model.Element) map[string]model.Element {
74 result := make(map[string]model.Element)
75 for key, elem := range elems {
76 result[key] = elem
77 if len(elem.Children) > 0 {
78 children := flattenElements(elem.Children)
79 for k, v := range children {
80 result[key+"."+k] = v
81 }
82 }
83 }
84 return result
85 }
86
github.com/docToolchain/Bausteinsicht/internal/sync/badge.go 51.7%
1 package sync
2
3 import (
4 "fmt"
5
6 "github.com/beevik/etree"
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // AddStatusBadge adds a status badge as a child cell to an element shape.
11 // The badge is positioned in the top-right corner of the element.
12 func AddStatusBadge(elementCell *etree.Element, status string) {
13 if status == "" {
14 return // No badge for unset status
15 }
16
17 // Get element width (or use default if not set)
18 width := getAttrFloat(elementCell, "width", 100)
19
20 // Badge dimensions and positioning
21 badgeWidth := 60.0
22 badgeHeight := 20.0
23 badgeX := width - badgeWidth - 2
24 badgeY := 2.0
25
26 // Create badge cell
27 badge := etree.NewElement("mxCell")
28 badge.CreateAttr("id", fmt.Sprintf("%s_badge", getAttr(elementCell, "id")))
29 badge.CreateAttr("value", status)
30 badge.CreateAttr("style", fmt.Sprintf(
31 "rounded=1;fillColor=%s;strokeColor=%s;fontSize=11;fontColor=#000000;"+
32 "whiteSpace=wrap;overflow=hidden;connectable=0",
33 model.StatusColor(status), model.StatusColor(status)))
34 badge.CreateAttr("vertex", "1")
35 badge.CreateAttr("parent", getAttr(elementCell, "id"))
36
37 // Geometry for the badge
38 geom := etree.NewElement("mxGeometry")
39 geom.CreateAttr("x", fmt.Sprintf("%.0f", badgeX))
40 geom.CreateAttr("y", fmt.Sprintf("%.0f", badgeY))
41 geom.CreateAttr("width", fmt.Sprintf("%.0f", badgeWidth))
42 geom.CreateAttr("height", fmt.Sprintf("%.0f", badgeHeight))
43 geom.CreateAttr("as", "geometry")
44
45 badge.AddChild(geom)
46 elementCell.AddChild(badge)
47 }
48
49 // getAttr retrieves a string attribute value
50 func getAttr(el *etree.Element, name string) string {
51 attr := el.SelectAttr(name)
52 if attr == nil {
53 return ""
54 }
55 return attr.Value
56 }
57
58 // AddDecisionBadges adds decision badges as child cells to an element shape.
59 // One badge is created per decision, positioned in a row in the top-right corner.
60 func AddDecisionBadges(elementCell *etree.Element, decisions []string, decisionMap map[string]*model.DecisionRecord) {
61 if len(decisions) == 0 {
62 return // No badges if no decisions
63 }
64
65 // Get element width (or use default if not set)
66 width := getAttrFloat(elementCell, "width", 100)
67
68 // Badge dimensions and positioning
69 badgeWidth := 30.0
70 badgeHeight := 20.0
71 badgeStartX := width - (badgeWidth * float64(len(decisions))) - 4
72 badgeY := 2.0
73
74 for i, decisionID := range decisions {
75 badge := etree.NewElement("mxCell")
76 badge.CreateAttr("id", fmt.Sprintf("%s_decision_%d", getAttr(elementCell, "id"), i))
77 badge.CreateAttr("value", "⚖")
78
79 // Determine color based on decision status
80 color := model.DecisionBadgeColor("")
81 if decision, ok := decisionMap[decisionID]; ok {
82 color = model.DecisionBadgeColor(decision.Status)
83 }
84
85 badge.CreateAttr("style", fmt.Sprintf(
86 "rounded=0;fillColor=%s;strokeColor=%s;fontSize=14;fontColor=#ffffff;"+
87 "whiteSpace=wrap;overflow=hidden;connectable=0;align=center;verticalAlign=middle",
88 color, color))
89 badge.CreateAttr("vertex", "1")
90 badge.CreateAttr("parent", getAttr(elementCell, "id"))
91 badge.CreateAttr("bausteinsicht_decision_id", decisionID)
92
93 // Geometry for the badge
94 badgeX := badgeStartX + (badgeWidth * float64(i))
95 geom := etree.NewElement("mxGeometry")
96 geom.CreateAttr("x", fmt.Sprintf("%.0f", badgeX))
97 geom.CreateAttr("y", fmt.Sprintf("%.0f", badgeY))
98 geom.CreateAttr("width", fmt.Sprintf("%.0f", badgeWidth))
99 geom.CreateAttr("height", fmt.Sprintf("%.0f", badgeHeight))
100 geom.CreateAttr("as", "geometry")
101
102 badge.AddChild(geom)
103 elementCell.AddChild(badge)
104 }
105 }
106
107 // getAttrFloat retrieves a float attribute value with default
108 func getAttrFloat(el *etree.Element, name string, defaultVal float64) float64 {
109 attr := el.SelectAttr(name)
110 if attr == nil {
111 return defaultVal
112 }
113 var val float64
114 _, _ = fmt.Sscanf(attr.Value, "%f", &val)
115 return val
116 }
117
github.com/docToolchain/Bausteinsicht/internal/sync/conflict.go 100.0%
1 package sync
2
3 import "fmt"
4
5 // Conflict represents a field that was changed on both sides since the last sync.
6 type Conflict struct {
7 ElementID string
8 Field string // "title", "description", "technology"
9 ModelValue string
10 DrawioValue string
11 LastSyncValue string
12 }
13
14 // ResolvedConflict is a conflict with its resolution decision.
15 type ResolvedConflict struct {
16 Conflict
17 Winner string // "model" or "drawio"
18 Warning string // human-readable warning message
19 }
20
21 // ConflictResolver resolves conflicts between model and draw.io changes.
22 // Designed as an interface for future extension (interactive, merge strategies).
23 type ConflictResolver interface {
24 Resolve(conflicts []Conflict) []ResolvedConflict
25 }
26
27 // ModelWinsResolver always picks the model value (v1 default strategy).
28 type ModelWinsResolver struct{}
29
30 // NewModelWinsResolver creates a new ModelWinsResolver.
31 func NewModelWinsResolver() *ModelWinsResolver {
32 return &ModelWinsResolver{}
33 }
34
35 // Resolve resolves all conflicts by choosing the model value.
36 func (r *ModelWinsResolver) Resolve(conflicts []Conflict) []ResolvedConflict {
37 resolved := make([]ResolvedConflict, 0, len(conflicts))
38 for _, c := range conflicts {
39 warning := fmt.Sprintf(
40 "Conflict detected for element %q:\n"+
41 " Field: %s\n"+
42 " Model value: %q\n"+
43 " draw.io value: %q\n"+
44 " Last sync: %q\n"+
45 " → Keeping model value. Edit draw.io manually if needed.",
46 c.ElementID, c.Field, c.ModelValue, c.DrawioValue, c.LastSyncValue,
47 )
48 resolved = append(resolved, ResolvedConflict{
49 Conflict: c,
50 Winner: "model",
51 Warning: warning,
52 })
53 }
54 return resolved
55 }
56
github.com/docToolchain/Bausteinsicht/internal/sync/diff.go 91.3%
1 package sync
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/drawio"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // ChangeType classifies a change.
13 type ChangeType int
14
15 const (
16 Added ChangeType = iota
17 Modified // nolint:deadcode
18 Deleted
19 )
20
21 // ElementChange represents a change to a single element.
22 type ElementChange struct {
23 ID string
24 Type ChangeType
25 Field string // "title", "description", "technology", "" for add/delete
26 OldValue string
27 NewValue string
28 }
29
30 // RelationshipChange represents a change to a relationship.
31 type RelationshipChange struct {
32 From string
33 To string
34 Index int // relationship array index for disambiguation
35 Type ChangeType
36 Field string // "label", "" for add/delete
37 OldValue string
38 NewValue string
39 }
40
41 // ChangeSet contains all detected changes from both sides.
42 type ChangeSet struct {
43 ModelElementChanges []ElementChange
44 ModelRelationshipChanges []RelationshipChange
45 DrawioElementChanges []ElementChange
46 DrawioRelationshipChanges []RelationshipChange
47 Conflicts []Conflict
48 }
49
50 // drawioElemSnapshot holds extracted data from a draw.io element.
51 type drawioElemSnapshot struct {
52 title string
53 technology string
54 description string
55 kind string
56 }
57
58 // relKey returns a canonical key for a relationship.
59 // The index disambiguates multiple relationships between the same pair.
60 func relKey(from, to string, index int) string {
61 return fmt.Sprintf("%s:%s:%d", from, to, index)
62 }
63
64 // computeVisibleElements returns the set of element IDs that should be visible
65 // across all views. If the model has no views, returns nil (meaning ALL elements
66 // are visible on the single page).
67 func computeVisibleElements(m *model.BausteinsichtModel) map[string]bool {
68 if len(m.Views) == 0 {
69 return nil // all elements visible
70 }
71 visible := make(map[string]bool)
72 for _, view := range m.Views {
73 v := view
74 resolved, err := model.ResolveView(m, &v)
75 if err != nil {
76 continue
77 }
78 for _, id := range resolved {
79 visible[id] = true
80 }
81 // The scope element itself is also visible (rendered as boundary).
82 if view.Scope != "" {
83 visible[view.Scope] = true
84 }
85 }
86 return visible
87 }
88
89 // computeVisibleRelationships returns the set of relationship keys (from relKey)
90 // that should have a connector on at least one view page. A relationship is
91 // visible if both endpoints (or a lifted ancestor of each) are present on the
92 // same view's resolved element set.
93 // If the model has no views, returns nil (meaning ALL relationships are visible).
94 // This prevents reverse sync from treating connectors removed by view filter
95 // changes as user deletions (#167).
96 func computeVisibleRelationships(m *model.BausteinsichtModel) map[string]bool {
97 if len(m.Views) == 0 {
98 return nil // all relationships visible
99 }
100
101 // Resolve each view's element set.
102 type viewSet struct {
103 elems map[string]bool
104 }
105 var views []viewSet
106 for _, view := range m.Views {
107 v := view
108 resolved, err := model.ResolveView(m, &v)
109 if err != nil {
110 continue
111 }
112 elemSet := make(map[string]bool, len(resolved)+1)
113 for _, id := range resolved {
114 elemSet[id] = true
115 }
116 if view.Scope != "" {
117 elemSet[view.Scope] = true
118 }
119 views = append(views, viewSet{elems: elemSet})
120 }
121
122 visible := make(map[string]bool)
123 for i, r := range m.Relationships {
124 for _, vs := range views {
125 from := liftEndpoint(r.From, vs.elems)
126 to := liftEndpoint(r.To, vs.elems)
127 if from != "" && to != "" && from != to {
128 visible[relKey(r.From, r.To, i)] = true
129 break // found on at least one view
130 }
131 }
132 }
133 return visible
134 }
135
136 // computeNewPageOnlyElements returns the set of element IDs that are visible
137 // exclusively on newly created view pages (not on any pre-existing page).
138 // Elements on new pages should not be treated as "deleted from draw.io" because
139 // they simply haven't been forward-synced to those pages yet (#184, #188, #189).
140 func computeNewPageOnlyElements(m *model.BausteinsichtModel, newPageIDs map[string]bool) map[string]bool {
141 if len(newPageIDs) == 0 || len(m.Views) == 0 {
142 return nil
143 }
144
145 // Compute elements visible on existing (non-new) pages.
146 existingPageElems := make(map[string]bool)
147 for viewID, view := range m.Views {
148 pageID := "view-" + viewID
149 if newPageIDs[pageID] {
150 continue // Skip new pages.
151 }
152 v := view
153 resolved, _ := model.ResolveView(m, &v)
154 for _, id := range resolved {
155 existingPageElems[id] = true
156 }
157 if view.Scope != "" {
158 existingPageElems[view.Scope] = true
159 }
160 }
161
162 // Find elements that are visible but ONLY on new pages.
163 allVisible := computeVisibleElements(m)
164 if allVisible == nil {
165 return nil
166 }
167 newOnly := make(map[string]bool)
168 for id := range allVisible {
169 if !existingPageElems[id] {
170 newOnly[id] = true
171 }
172 }
173 return newOnly
174 }
175
176 // DetectChanges performs a three-way diff between the model, draw.io document,
177 // and the last known sync state.
178 // newPageIDs is the set of page IDs that were just created (not yet populated
179 // by forward sync). Elements expected only on new pages are excluded from
180 // draw.io-side deletion detection (#184, #188, #189).
181 func DetectChanges(m *model.BausteinsichtModel, doc *drawio.Document, lastState *SyncState, newPageIDs map[string]bool) *ChangeSet {
182 cs := &ChangeSet{}
183
184 flatModel, _ := model.FlattenElements(m)
185 drawioElems := extractDrawioElements(doc)
186 visibleElems := computeVisibleElements(m)
187 newPageOnly := computeNewPageOnlyElements(m, newPageIDs)
188 detectElementChanges(cs, flatModel, drawioElems, lastState, visibleElems, newPageOnly)
189 detectCrossViewInconsistencies(cs, doc, flatModel, lastState)
190 detectUnmanagedDrawioElements(cs, doc)
191
192 modelRels := buildModelRelMap(m)
193 drawioRels := extractDrawioRelationships(doc)
194 visibleRels := computeVisibleRelationships(m)
195 detectRelationshipChanges(cs, modelRels, drawioRels, lastState, visibleRels)
196
197 return cs
198 }
199
200 // extractDrawioElements gathers element data from all pages in the document.
201 // It reads from child text sub-cells first, falling back to HTML label parsing
202 // for backward compatibility with older draw.io files.
203 func extractDrawioElements(doc *drawio.Document) map[string]drawioElemSnapshot {
204 result := make(map[string]drawioElemSnapshot)
205 for _, page := range doc.Pages() {
206 for _, obj := range page.FindAllElements() {
207 id := obj.SelectAttrValue("bausteinsicht_id", "")
208 if id == "" {
209 continue
210 }
211 // ReadElementFields checks for child text sub-cells first,
212 // then falls back to ParseLabel for backward compat.
213 title, technology, labelDesc := page.ReadElementFields(obj)
214 // Fall back to XML attribute if label doesn't contain technology (#186).
215 if technology == "" {
216 technology = obj.SelectAttrValue("technology", "")
217 }
218 tooltipDesc := obj.SelectAttrValue("tooltip", "")
219 description := tooltipDesc
220 if description == "" {
221 description = labelDesc
222 }
223 // Skip duplicate bausteinsicht_id — keep first occurrence (#213).
224 if _, exists := result[id]; exists {
225 continue
226 }
227 result[id] = drawioElemSnapshot{
228 title: title,
229 technology: technology,
230 description: description,
231 kind: obj.SelectAttrValue("bausteinsicht_kind", ""),
232 }
233 }
234 }
235 return result
236 }
237
238 // detectUnmanagedDrawioElements finds shapes in draw.io that have no
239 // bausteinsicht_id attribute and emits Added element changes for them.
240 // This allows reverse sync to import new elements drawn by the user (#196).
241 func detectUnmanagedDrawioElements(cs *ChangeSet, doc *drawio.Document) {
242 for _, page := range doc.Pages() {
243 root := page.Root()
244 if root == nil {
245 continue
246 }
247 for _, obj := range root.SelectElements("object") {
248 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
249 continue // managed element, already handled
250 }
251 // Skip non-model elements created by forward sync.
252 objID := obj.SelectAttrValue("id", "")
253 if strings.HasPrefix(objID, "nav-back-") ||
254 strings.HasPrefix(objID, metadataPrefix) ||
255 strings.HasPrefix(objID, legendPrefix) {
256 continue
257 }
258 // Check that it wraps a vertex cell (not a connector).
259 cell := obj.SelectElement("mxCell")
260 if cell == nil || cell.SelectAttrValue("vertex", "") != "1" {
261 continue
262 }
263 // Try sub-cell reading first, then HTML label.
264 title, _, _ := page.ReadElementFields(obj)
265 if title == "" {
266 label := obj.SelectAttrValue("label", "")
267 if label == "" {
268 continue
269 }
270 title, _, _ = drawio.ParseLabel(label)
271 }
272 id := sanitizeID(title)
273 if id == "" {
274 continue
275 }
276 cs.DrawioElementChanges = append(cs.DrawioElementChanges, ElementChange{
277 ID: id,
278 Type: Added,
279 NewValue: title,
280 })
281 }
282 }
283 }
284
285 // sanitizeID converts a title to a lowercase, hyphen-separated ID suitable
286 // for use as a model element key. Strips dots, slashes, and backslashes to
287 // prevent IDs that interfere with dot-notation hierarchy (SEC-013).
288 func sanitizeID(title string) string {
289 title = strings.TrimSpace(title)
290 title = strings.ToLower(title)
291 title = strings.ReplaceAll(title, " ", "-")
292 title = strings.NewReplacer(".", "", "/", "", "\\", "").Replace(title)
293 return title
294 }
295
296 // stripScopedPrefix removes the view prefix from a scoped cell ID.
297 // Scoped cell IDs have the format "viewID--elemID" where "--" is the separator.
298 // If the ID does not contain "--", it is returned unchanged (legacy documents).
299 func stripScopedPrefix(cellID string) string {
300 if idx := strings.Index(cellID, "--"); idx >= 0 {
301 return cellID[idx+2:]
302 }
303 return cellID
304 }
305
306 // resolveCellID maps a draw.io cell ID to a canonical element ID using the
307 // cellToElem lookup table. If the cell ID is not in the table (e.g., because
308 // the element was deleted), it falls back to stripping the scoped view prefix.
309 func resolveCellID(cellID string, cellToElem map[string]string) string {
310 if elemID, ok := cellToElem[cellID]; ok {
311 return elemID
312 }
313 return stripScopedPrefix(cellID)
314 }
315
316 // buildCellIDToElemID builds a mapping from draw.io cell IDs to bausteinsicht
317 // element IDs. When views are used, cell IDs are scoped (e.g., "context--customer")
318 // while element IDs are un-scoped (e.g., "customer").
319 func buildCellIDToElemID(doc *drawio.Document) map[string]string {
320 m := make(map[string]string)
321 for _, page := range doc.Pages() {
322 for _, obj := range page.FindAllElements() {
323 elemID := obj.SelectAttrValue("bausteinsicht_id", "")
324 cellID := obj.SelectAttrValue("id", "")
325 if elemID != "" && cellID != "" {
326 m[cellID] = elemID
327 }
328 }
329 // Also map unmanaged elements (no bausteinsicht_id) to their
330 // sanitized label-based IDs so that connectors targeting them
331 // resolve correctly during reverse sync (#211).
332 root := page.Root()
333 if root == nil {
334 continue
335 }
336 for _, obj := range root.SelectElements("object") {
337 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
338 continue
339 }
340 cellID := obj.SelectAttrValue("id", "")
341 if cellID == "" {
342 continue
343 }
344 if strings.HasPrefix(cellID, "nav-back-") {
345 continue
346 }
347 cell := obj.SelectElement("mxCell")
348 if cell == nil || cell.SelectAttrValue("vertex", "") != "1" {
349 continue
350 }
351 label := obj.SelectAttrValue("label", "")
352 if label == "" {
353 continue
354 }
355 title, _, _ := drawio.ParseLabel(label)
356 id := sanitizeID(title)
357 if id != "" {
358 m[cellID] = id
359 }
360 }
361 }
362 return m
363 }
364
365 // extractDrawioRelationships gathers connector data from all pages.
366 // Connector source/target cell IDs are resolved to element IDs using the
367 // bausteinsicht_id attributes of referenced elements.
368 // Lifted connectors (where an endpoint was lifted to a parent because the
369 // original target is not visible on a view) are excluded to avoid phantom
370 // reverse changes.
371 func extractDrawioRelationships(doc *drawio.Document) map[string]RelationshipState {
372 cellToElem := buildCellIDToElemID(doc)
373 result := make(map[string]RelationshipState)
374 for _, page := range doc.Pages() {
375 for _, cell := range page.FindAllConnectors() {
376 fromCell := cell.SelectAttrValue("source", "")
377 toCell := cell.SelectAttrValue("target", "")
378 if fromCell == "" || toCell == "" {
379 continue
380 }
381 // Resolve scoped cell IDs to element IDs.
382 // Fall back to stripping the view prefix from scoped cell IDs
383 // (e.g., "components--onlineshop.db" → "onlineshop.db") when
384 // the element was deleted and is no longer in cellToElem (#166).
385 // For legacy (non-view) documents the raw cell ID is used as-is.
386 from := resolveCellID(fromCell, cellToElem)
387 to := resolveCellID(toCell, cellToElem)
388 // Skip connectors targeting navigation buttons (#205).
389 if strings.HasPrefix(from, "nav-back-") || strings.HasPrefix(to, "nav-back-") {
390 continue
391 }
392 // Extract the relationship index from the connector ID.
393 cellID := cell.SelectAttrValue("id", "")
394 index := parseConnectorIndex(cellID)
395 key := relKey(from, to, index)
396 if _, exists := result[key]; !exists {
397 result[key] = RelationshipState{
398 From: from,
399 To: to,
400 Index: index,
401 Label: cell.SelectAttrValue("value", ""),
402 }
403 }
404 }
405 }
406 return result
407 }
408
409 // parseConnectorIndex extracts the index from a connector ID of the form
410 // "rel-<from>-<to>-<index>". Returns 0 if the ID does not contain an index
411 // (backward compatibility with old connector IDs "rel-<from>-<to>").
412 func parseConnectorIndex(id string) int {
413 if !strings.HasPrefix(id, "rel-") {
414 return 0
415 }
416 // The index is the last segment after the last '-'.
417 lastDash := strings.LastIndex(id, "-")
418 if lastDash < 0 {
419 return 0
420 }
421 indexStr := id[lastDash+1:]
422 var index int
423 if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil {
424 return 0
425 }
426 return index
427 }
428
429 // buildModelRelMap converts model relationships to a map keyed by relKey.
430 func buildModelRelMap(m *model.BausteinsichtModel) map[string]RelationshipState {
431 modelRels := make(map[string]RelationshipState, len(m.Relationships))
432 for i, r := range m.Relationships {
433 modelRels[relKey(r.From, r.To, i)] = RelationshipState{
434 From: r.From,
435 To: r.To,
436 Index: i,
437 Label: r.Label,
438 Kind: r.Kind,
439 }
440 }
441 return modelRels
442 }
443
444 // detectElementChanges performs three-way comparison for elements.
445 // visibleElems is the set of element IDs visible across all views. If nil,
446 // all elements are considered visible (no views defined).
447 // newPageOnly is the set of elements visible ONLY on newly created pages
448 // (not yet populated by forward sync). These are excluded from draw.io-side
449 // deletion detection (#184, #188, #189).
450 func detectElementChanges(
451 cs *ChangeSet,
452 flatModel map[string]*model.Element,
453 drawioElems map[string]drawioElemSnapshot,
454 lastState *SyncState,
455 visibleElems map[string]bool,
456 newPageOnly map[string]bool,
457 ) {
458 allIDsMap := unionElementIDs(flatModel, drawioElems, lastState)
459 allIDs := make([]string, 0, len(allIDsMap))
460 for id := range allIDsMap {
461 allIDs = append(allIDs, id)
462 }
463 sort.Strings(allIDs)
464
465 for _, id := range allIDs {
466 me, inModel := flatModel[id]
467 de, inDrawio := drawioElems[id]
468 lastElem, inLast := lastState.Elements[id]
469
470 // Model side changes
471 switch {
472 case inModel && !inLast:
473 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{ID: id, Type: Added})
474 case !inModel && inLast:
475 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{ID: id, Type: Deleted})
476 case inModel && inLast:
477 appendIfChanged(id, "title", lastElem.Title, me.Title, &cs.ModelElementChanges)
478 appendIfChanged(id, "description", lastElem.Description, me.Description, &cs.ModelElementChanges)
479 appendIfChanged(id, "technology", lastElem.Technology, me.Technology, &cs.ModelElementChanges)
480 appendIfChanged(id, "kind", lastElem.Kind, me.Kind, &cs.ModelElementChanges)
481 }
482
483 // Draw.io side changes
484 switch {
485 case inDrawio && !inLast:
486 cs.DrawioElementChanges = append(cs.DrawioElementChanges, ElementChange{ID: id, Type: Added})
487 case !inDrawio && inLast:
488 // Only treat as deleted if the element should be visible on at least one
489 // view page. Elements not in any view's resolved set are simply filtered
490 // out and their absence from draw.io is expected, not a deletion. (#108, #118)
491 // Also skip elements that are only expected on newly created pages — those
492 // pages haven't been populated by forward sync yet (#184, #188, #189).
493 if visibleElems == nil || visibleElems[id] {
494 if newPageOnly != nil && newPageOnly[id] {
495 continue
496 }
497 // Skip elements that were never rendered to a draw.io page.
498 // When RenderedElements is available (state from v2+), an element
499 // absent from draw.io but not in RenderedElements was never on any
500 // page — it was filtered by views. Forward sync will create it now
501 // that views include it. (#240)
502 // When RenderedElements is nil (old state files), fall back to
503 // treating all elements as rendered (preserving old behavior).
504 if lastState.RenderedElements != nil && !lastState.RenderedElements[id] {
505 continue
506 }
507 cs.DrawioElementChanges = append(cs.DrawioElementChanges, ElementChange{ID: id, Type: Deleted})
508 }
509 case inDrawio && inLast:
510 appendIfChanged(id, "title", lastElem.Title, de.title, &cs.DrawioElementChanges)
511 appendIfChanged(id, "description", lastElem.Description, de.description, &cs.DrawioElementChanges)
512 appendIfChanged(id, "technology", lastElem.Technology, de.technology, &cs.DrawioElementChanges)
513 // Note: kind is not compared on the draw.io side because scope
514 // boundary elements have a derived kind (e.g. "system_boundary")
515 // that legitimately differs from the model kind ("system").
516 }
517
518 // Conflicts: both sides modified the same field
519 if inModel && inDrawio && inLast {
520 checkElemConflict(cs, id, "title", lastElem.Title, me.Title, de.title)
521 checkElemConflict(cs, id, "description", lastElem.Description, me.Description, de.description)
522 checkElemConflict(cs, id, "technology", lastElem.Technology, me.Technology, de.technology)
523 // Note: kind conflicts are not checked because kind is
524 // model-authoritative and draw.io boundary kinds are derived.
525 }
526 }
527 }
528
529 // unionElementIDs returns the union of IDs across all three sources.
530 func unionElementIDs(
531 flatModel map[string]*model.Element,
532 drawioElems map[string]drawioElemSnapshot,
533 lastState *SyncState,
534 ) map[string]struct{} {
535 all := make(map[string]struct{})
536 for id := range flatModel {
537 all[id] = struct{}{}
538 }
539 for id := range lastState.Elements {
540 all[id] = struct{}{}
541 }
542 for id := range drawioElems {
543 all[id] = struct{}{}
544 }
545 return all
546 }
547
548 // detectCrossViewInconsistencies scans ALL pages in the draw.io document
549 // and emits forward changes when an element on any page shows a stale value
550 // that doesn't match the model, even though model and state agree.
551 // This handles the case where reverse sync updated one view but other views
552 // still show the old value (#236).
553 func detectCrossViewInconsistencies(
554 cs *ChangeSet,
555 doc *drawio.Document,
556 flatModel map[string]*model.Element,
557 lastState *SyncState,
558 ) {
559 // Track which element+field combos we've already emitted to avoid duplicates.
560 type fieldKey struct {
561 id, field string
562 }
563 emitted := make(map[fieldKey]bool)
564 // Skip fields that detectElementChanges already emitted as model changes.
565 for _, ch := range cs.ModelElementChanges {
566 if ch.Field != "" {
567 emitted[fieldKey{ch.ID, ch.Field}] = true
568 }
569 }
570 // Skip fields that have a legitimate draw.io-side change — those are user
571 // edits that should be reverse-synced, not overwritten by forward sync.
572 for _, ch := range cs.DrawioElementChanges {
573 if ch.Field != "" {
574 emitted[fieldKey{ch.ID, ch.Field}] = true
575 }
576 }
577
578 for _, page := range doc.Pages() {
579 for _, obj := range page.FindAllElements() {
580 id := obj.SelectAttrValue("bausteinsicht_id", "")
581 if id == "" {
582 continue
583 }
584 me, inModel := flatModel[id]
585 if !inModel {
586 continue
587 }
588 lastElem, inLast := lastState.Elements[id]
589 if !inLast {
590 continue
591 }
592
593 title, technology, labelDesc := page.ReadElementFields(obj)
594 if technology == "" {
595 technology = obj.SelectAttrValue("technology", "")
596 }
597 tooltipDesc := obj.SelectAttrValue("tooltip", "")
598 description := tooltipDesc
599 if description == "" {
600 description = labelDesc
601 }
602
603 // If model and state agree but this page shows a stale value,
604 // emit a forward change so all views are brought up to date.
605 if me.Title == lastElem.Title && title != me.Title {
606 fk := fieldKey{id, "title"}
607 if !emitted[fk] {
608 emitted[fk] = true
609 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{
610 ID: id, Type: Modified, Field: "title",
611 OldValue: title, NewValue: me.Title,
612 })
613 }
614 }
615 if me.Description == lastElem.Description && description != me.Description {
616 fk := fieldKey{id, "description"}
617 if !emitted[fk] {
618 emitted[fk] = true
619 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{
620 ID: id, Type: Modified, Field: "description",
621 OldValue: description, NewValue: me.Description,
622 })
623 }
624 }
625 if me.Technology == lastElem.Technology && technology != me.Technology {
626 fk := fieldKey{id, "technology"}
627 if !emitted[fk] {
628 emitted[fk] = true
629 cs.ModelElementChanges = append(cs.ModelElementChanges, ElementChange{
630 ID: id, Type: Modified, Field: "technology",
631 OldValue: technology, NewValue: me.Technology,
632 })
633 }
634 }
635 }
636 }
637 }
638
639 // appendIfChanged adds a Modified ElementChange if newValue differs from lastValue.
640 func appendIfChanged(id, field, lastValue, newValue string, changes *[]ElementChange) {
641 if newValue != lastValue {
642 *changes = append(*changes, ElementChange{
643 ID: id,
644 Type: Modified,
645 Field: field,
646 OldValue: lastValue,
647 NewValue: newValue,
648 })
649 }
650 }
651
652 // checkElemConflict adds a Conflict when both model and draw.io changed the same field.
653 func checkElemConflict(cs *ChangeSet, id, field, last, modelVal, drawioVal string) {
654 if modelVal != last && drawioVal != last {
655 cs.Conflicts = append(cs.Conflicts, Conflict{
656 ElementID: id,
657 Field: field,
658 ModelValue: modelVal,
659 DrawioValue: drawioVal,
660 LastSyncValue: last,
661 })
662 }
663 }
664
665 // detectRelationshipChanges performs three-way comparison for relationships.
666 // visibleRels is the set of relationship keys that should have a connector on
667 // at least one view page. If nil, all relationships are considered visible
668 // (no views defined). Used to prevent treating filter-removed connectors as
669 // user deletions (#167).
670 func detectRelationshipChanges(
671 cs *ChangeSet,
672 modelRels map[string]RelationshipState,
673 drawioRels map[string]RelationshipState,
674 lastState *SyncState,
675 visibleRels map[string]bool,
676 ) {
677 lastRels := make(map[string]RelationshipState, len(lastState.Relationships))
678 for _, r := range lastState.Relationships {
679 lastRels[relKey(r.From, r.To, r.Index)] = r
680 }
681
682 allKeys := unionRelKeys(modelRels, drawioRels, lastRels)
683
684 for k := range allKeys {
685 mr, inModel := modelRels[k]
686 dr, inDrawio := drawioRels[k]
687 lr, inLast := lastRels[k]
688
689 from, to, index := resolveRelFromTo(mr, lr, dr)
690
691 // Model side
692 switch {
693 case inModel && !inLast:
694 cs.ModelRelationshipChanges = append(cs.ModelRelationshipChanges, RelationshipChange{
695 From: from, To: to, Index: index, Type: Added, NewValue: mr.Label,
696 })
697 case !inModel && inLast:
698 cs.ModelRelationshipChanges = append(cs.ModelRelationshipChanges, RelationshipChange{
699 From: from, To: to, Index: index, Type: Deleted,
700 })
701 case inModel && inLast && mr.Label != lr.Label:
702 cs.ModelRelationshipChanges = append(cs.ModelRelationshipChanges, RelationshipChange{
703 From: from, To: to, Index: index, Type: Modified, Field: "label",
704 OldValue: lr.Label, NewValue: mr.Label,
705 })
706 }
707
708 // Draw.io side
709 switch {
710 case inDrawio && !inLast:
711 // Skip lifted connectors: when a view lifts a relationship
712 // endpoint to a parent (e.g., A→B.child becomes A→B),
713 // the lifted connector should not be treated as a new relationship.
714 if isLiftedRelationship(from, to, modelRels) {
715 continue
716 }
717 cs.DrawioRelationshipChanges = append(cs.DrawioRelationshipChanges, RelationshipChange{
718 From: from, To: to, Index: index, Type: Added, NewValue: dr.Label,
719 })
720 case !inDrawio && inLast:
721 // Only treat as deleted if the relationship should have a connector
722 // on at least one view page. Relationships whose endpoints are not
723 // visible on any view (due to filter changes) are simply absent from
724 // draw.io — not user deletions. (#167)
725 if visibleRels != nil && !visibleRels[k] {
726 continue
727 }
728 // Skip if a lifted version of this relationship exists in draw.io.
729 // When a view lifts endpoints (e.g., cli→model.loader becomes
730 // cli→model), the connector has different keys but still represents
731 // this relationship. Without this check, the original relationship
732 // would be incorrectly deleted (#223).
733 if hasLiftedConnectorInDrawio(from, to, drawioRels) {
734 continue
735 }
736 cs.DrawioRelationshipChanges = append(cs.DrawioRelationshipChanges, RelationshipChange{
737 From: from, To: to, Index: index, Type: Deleted,
738 })
739 case inDrawio && inLast && dr.Label != lr.Label:
740 cs.DrawioRelationshipChanges = append(cs.DrawioRelationshipChanges, RelationshipChange{
741 From: from, To: to, Index: index, Type: Modified, Field: "label",
742 OldValue: lr.Label, NewValue: dr.Label,
743 })
744 }
745 }
746 }
747
748 // unionRelKeys returns the union of relationship keys from all three sources.
749 func unionRelKeys(
750 modelRels, drawioRels, lastRels map[string]RelationshipState,
751 ) map[string]struct{} {
752 all := make(map[string]struct{})
753 for k := range modelRels {
754 all[k] = struct{}{}
755 }
756 for k := range lastRels {
757 all[k] = struct{}{}
758 }
759 for k := range drawioRels {
760 all[k] = struct{}{}
761 }
762 return all
763 }
764
765 // isLiftedRelationship returns true if the relationship from→to is a "lifted"
766 // version of an existing model relationship. A relationship is lifted when a
767 // view shows a connector between parent elements because the original endpoint
768 // is not visible. For example, model has A→B.child but the view only shows A
769 // and B, so the connector is lifted to A→B.
770 func isLiftedRelationship(from, to string, modelRels map[string]RelationshipState) bool {
771 // A self-referencing relationship (from == to) can never be a lifted
772 // version of a child-to-child relationship, because lifting never
773 // collapses two distinct endpoints into the same element (#212).
774 if from == to {
775 return false
776 }
777 for _, mr := range modelRels {
778 // Same from, model to is more specific (to is ancestor of mr.To)
779 if mr.From == from && mr.To != to && strings.HasPrefix(mr.To, to+".") {
780 return true
781 }
782 // Same to, model from is more specific
783 if mr.To == to && mr.From != from && strings.HasPrefix(mr.From, from+".") {
784 return true
785 }
786 // Both endpoints lifted
787 if mr.From != from && mr.To != to &&
788 strings.HasPrefix(mr.From, from+".") && strings.HasPrefix(mr.To, to+".") {
789 return true
790 }
791 }
792 return false
793 }
794
795 // hasLiftedConnectorInDrawio returns true if a connector in drawioRels is a
796 // lifted version of the relationship from→to. This is the inverse of
797 // isLiftedRelationship: here we check if the drawio connector endpoints are
798 // ancestors of the model relationship endpoints.
799 // For example, model has cli→model.loader but drawio has cli→model (lifted).
800 func hasLiftedConnectorInDrawio(from, to string, drawioRels map[string]RelationshipState) bool {
801 for _, dr := range drawioRels {
802 fromMatch := dr.From == from || (dr.From != from && strings.HasPrefix(from, dr.From+"."))
803 toMatch := dr.To == to || (dr.To != to && strings.HasPrefix(to, dr.To+"."))
804 if fromMatch && toMatch && (dr.From != from || dr.To != to) {
805 return true
806 }
807 }
808 return false
809 }
810
811 // resolveRelFromTo returns the from/to/index from the first non-empty source.
812 func resolveRelFromTo(mr, lr, dr RelationshipState) (from, to string, index int) {
813 from, to, index = mr.From, mr.To, mr.Index
814 if from == "" {
815 from, to, index = lr.From, lr.To, lr.Index
816 }
817 if from == "" {
818 from, to, index = dr.From, dr.To, dr.Index
819 }
820 return from, to, index
821 }
822
github.com/docToolchain/Bausteinsicht/internal/sync/engine.go 95.9%
1 package sync
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/drawio"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // SyncResult contains the comprehensive result of a sync cycle.
13 type SyncResult struct {
14 Forward *ForwardResult
15 Reverse *ReverseResult
16 Changes *ChangeSet // The (post-conflict-resolution) changes used for sync.
17 Conflicts []ResolvedConflict
18 Warnings []string
19 }
20
21 // Run executes one full bidirectional sync cycle.
22 // It is a pure function — no file I/O. All data is passed as parameters.
23 //
24 // Sequence (Chapter 6 - Runtime View):
25 // 1. DetectChanges → ChangeSet
26 // 2. Resolve conflicts (model wins)
27 // 3. Remove conflicting fields from DrawioElementChanges
28 // 4. ApplyForward → ForwardResult
29 // 5. ApplyReverse → ReverseResult
30 // 6. Collect all warnings
31 func Run(
32 m *model.BausteinsichtModel,
33 doc *drawio.Document,
34 lastState *SyncState,
35 templates *drawio.TemplateSet,
36 newPageIDs map[string]bool,
37 opts ...ForwardOptions,
38 ) *SyncResult {
39 result := &SyncResult{}
40
41 // Step 1: Detect changes from both sides.
42 // Pass newPageIDs so that elements expected only on newly created pages
43 // are not mistakenly treated as "deleted from draw.io" (#184, #188, #189).
44 changes := DetectChanges(m, doc, lastState, newPageIDs)
45
46 // Step 2: Resolve conflicts.
47 if len(changes.Conflicts) > 0 {
48 resolved := NewModelWinsResolver().Resolve(changes.Conflicts)
49 result.Conflicts = resolved
50
51 // Step 3: For model-wins conflicts, drop the draw.io change so that
52 // ApplyReverse does not overwrite the model value.
53 changes.DrawioElementChanges = filterConflictingDrawioChanges(
54 changes.DrawioElementChanges, resolved,
55 )
56 }
57
58 result.Changes = changes
59
60 // Step 4: Forward sync (model → draw.io).
61 result.Forward = ApplyForward(changes, doc, templates, m, opts...)
62
63 // Step 5: Reverse sync (draw.io → model).
64 result.Reverse = ApplyReverse(changes, m)
65
66 // Step 6: Warn about model elements not visible in any view (#183).
67 if len(m.Views) > 0 {
68 visible := computeVisibleElements(m)
69 flat, _ := model.FlattenElements(m)
70 var invisible []string
71 for id := range flat {
72 if visible != nil && !visible[id] {
73 invisible = append(invisible, id)
74 }
75 }
76 if len(invisible) > 0 {
77 sort.Strings(invisible)
78 for _, id := range invisible {
79 result.Warnings = append(result.Warnings,
80 fmt.Sprintf("Element %q exists in the model but is not visible in any view — add it to a view's include list", id))
81 }
82 }
83 }
84
85 // Step 7: Collect all warnings.
86 result.Warnings = append(result.Warnings, result.Forward.Warnings...)
87 result.Warnings = append(result.Warnings, result.Reverse.Warnings...)
88 for _, rc := range result.Conflicts {
89 result.Warnings = append(result.Warnings, rc.Warning)
90 }
91
92 return result
93 }
94
95 // RemoveOrphanedViewPages removes pages from the draw.io document that were
96 // created for views that no longer exist in the model. Pages are identified as
97 // view-managed if their id starts with the "view-" prefix. Pages whose id does
98 // not start with "view-" are preserved (e.g., default template pages).
99 func RemoveOrphanedViewPages(doc *drawio.Document, m *model.BausteinsichtModel) {
100 // Build the set of expected view page IDs from the model.
101 expectedPages := make(map[string]bool, len(m.Views))
102 for viewID := range m.Views {
103 expectedPages["view-"+viewID] = true
104 }
105
106 // Iterate pages and collect orphaned view page IDs.
107 var orphans []string
108 for _, page := range doc.Pages() {
109 pageID := page.ID()
110 if !strings.HasPrefix(pageID, "view-") {
111 continue // Not a view-managed page; preserve it.
112 }
113 if !expectedPages[pageID] {
114 orphans = append(orphans, pageID)
115 }
116 }
117
118 // Remove orphaned pages.
119 for _, id := range orphans {
120 doc.RemovePage(id)
121 }
122 }
123
124 // filterConflictingDrawioChanges removes draw.io element changes for fields
125 // that were resolved in favour of the model (Winner == "model").
126 func filterConflictingDrawioChanges(
127 drawioChanges []ElementChange,
128 resolved []ResolvedConflict,
129 ) []ElementChange {
130 // Build a set of (elementID, field) pairs that model won.
131 type conflictKey struct {
132 id string
133 field string
134 }
135 modelWins := make(map[conflictKey]struct{}, len(resolved))
136 for _, rc := range resolved {
137 if rc.Winner == "model" {
138 modelWins[conflictKey{rc.ElementID, rc.Field}] = struct{}{}
139 }
140 }
141
142 if len(modelWins) == 0 {
143 return drawioChanges
144 }
145
146 filtered := make([]ElementChange, 0, len(drawioChanges))
147 for _, ch := range drawioChanges {
148 if _, skip := modelWins[conflictKey{ch.ID, ch.Field}]; !skip {
149 filtered = append(filtered, ch)
150 }
151 }
152 return filtered
153 }
154
github.com/docToolchain/Bausteinsicht/internal/sync/forward.go 80.8%
1 package sync
2
3 import (
4 "fmt"
5 "sort"
6 "strconv"
7 "strings"
8
9 "github.com/beevik/etree"
10 "github.com/docToolchain/Bausteinsicht/internal/drawio"
11 "github.com/docToolchain/Bausteinsicht/internal/model"
12 )
13
14 const (
15 newElementMarker = "strokeColor=#FF0000;dashed=1;"
16 elementGap = 40.0
17 defaultWidth = 120.0
18 defaultHeight = 60.0
19 )
20
21 // applyTagStyles applies styles defined in tag definitions to an element's style.
22 // For each tag on the element, looks up the corresponding TagDefinition and merges
23 // any styles defined there into the element's style string.
24 func applyTagStyles(elem *model.Element, spec *model.Specification, baseStyle string) string {
25 if len(elem.Tags) == 0 {
26 return baseStyle
27 }
28
29 // Build a map of tag ID to TagDefinition for quick lookup
30 tagDefMap := make(map[string]model.TagDefinition)
31 for _, tagDef := range spec.Tags {
32 tagDefMap[tagDef.ID] = tagDef
33 }
34
35 // Collect all style properties from tags that apply to this element
36 styleProps := make(map[string]interface{})
37 for _, tagID := range elem.Tags {
38 if tagDef, ok := tagDefMap[tagID]; ok && len(tagDef.Style) > 0 {
39 for k, v := range tagDef.Style {
40 styleProps[k] = v
41 }
42 }
43 }
44
45 // If no tag styles found, return base style
46 if len(styleProps) == 0 {
47 return baseStyle
48 }
49
50 // Convert style properties to a string and merge with baseStyle
51 tagStyleStr := ""
52 for k, v := range styleProps {
53 tagStyleStr += k + "=" + toString(v) + ";"
54 }
55
56 return mergeStyles(baseStyle, tagStyleStr)
57 }
58
59 // toString converts a value to a string representation suitable for draw.io style attributes.
60 func toString(v interface{}) string {
61 switch val := v.(type) {
62 case string:
63 return val
64 case float64:
65 if val == float64(int64(val)) {
66 return strconv.FormatInt(int64(val), 10)
67 }
68 return strconv.FormatFloat(val, 'f', -1, 64)
69 case int:
70 return strconv.Itoa(val)
71 case bool:
72 return strconv.FormatBool(val)
73 default:
74 return fmt.Sprintf("%v", v)
75 }
76 }
77
78 // ForwardOptions holds optional parameters for forward sync.
79 type ForwardOptions struct {
80 ModelPath string // path to the model file, shown in metadata box
81 SyncTime string // timestamp string, shown in metadata box
82 Relayout bool // when true, clear and re-layout all view pages
83 }
84
85 // ForwardResult summarises the changes applied to a draw.io document.
86 type ForwardResult struct {
87 ElementsCreated int
88 ElementsUpdated int
89 ElementsDeleted int
90 ConnectorsCreated int
91 ConnectorsUpdated int
92 ConnectorsDeleted int
93 MetadataUpdated int // metadata and legend boxes created/updated
94 Warnings []string
95 }
96
97 // ApplyForward applies ModelElementChanges and ModelRelationshipChanges from cs
98 // to doc, using templates for styles and m for element data.
99 // When the model defines views, elements and relationships are placed on their
100 // corresponding view pages. Without views, falls back to the first page.
101 // opts is optional — pass nil to skip metadata/legend generation.
102 func ApplyForward(
103 cs *ChangeSet,
104 doc *drawio.Document,
105 templates *drawio.TemplateSet,
106 m *model.BausteinsichtModel,
107 opts ...ForwardOptions,
108 ) *ForwardResult {
109 result := &ForwardResult{}
110 flat, _ := model.FlattenElements(m)
111
112 var fwdOpts ForwardOptions
113 if len(opts) > 0 {
114 fwdOpts = opts[0]
115 }
116
117 if len(m.Views) == 0 {
118 applyForwardToPage(cs, doc, templates, flat, nil, m, result)
119 return result
120 }
121
122 applyForwardPerView(cs, doc, templates, flat, m, &fwdOpts, result)
123 return result
124 }
125
126 // applyForwardToPage applies all changes to a single page (legacy/no-views mode).
127 func applyForwardToPage(
128 cs *ChangeSet,
129 doc *drawio.Document,
130 templates *drawio.TemplateSet,
131 flat map[string]*model.Element,
132 elemFilter map[string]bool,
133 m *model.BausteinsichtModel,
134 result *ForwardResult,
135 ) {
136 page := firstPage(doc)
137 if page == nil {
138 result.Warnings = append(result.Warnings, "no page found in document")
139 return
140 }
141 applyChangesToPage(cs, page, templates, flat, elemFilter, "", "", &m.Specification, result)
142
143 // Reconcile orphaned elements: remove any element on the page whose
144 // bausteinsicht_id is not present in the current model. This handles
145 // cases where the sync state is missing or the model was emptied. (#110)
146 reconcileOrphanedElements(page, flat, result)
147
148 // Synchronize decision badges with the model
149 synchronizeDecisionBadges(page, m)
150 }
151
152 // applyForwardPerView iterates over model views and applies changes per page.
153 func applyForwardPerView(
154 cs *ChangeSet,
155 doc *drawio.Document,
156 templates *drawio.TemplateSet,
157 flat map[string]*model.Element,
158 m *model.BausteinsichtModel,
159 opts *ForwardOptions,
160 result *ForwardResult,
161 ) {
162 // Build drill-down link map: elementID → "data:page/id,view-<viewID>"
163 // An element gets a link when a view's scope matches that element.
164 drillDownLinks := make(map[string]string)
165 for vID, v := range m.Views {
166 if v.Scope != "" {
167 drillDownLinks[v.Scope] = "data:page/id,view-" + vID
168 }
169 }
170
171 for viewID, view := range m.Views {
172 pageID := "view-" + viewID
173 page := doc.GetPage(pageID)
174 if page == nil {
175 result.Warnings = append(result.Warnings,
176 "no page found for view: "+viewID)
177 continue
178 }
179
180 viewCopy := view
181 resolved, err := model.ResolveView(m, &viewCopy)
182 if err != nil {
183 result.Warnings = append(result.Warnings,
184 "resolving view "+viewID+": "+err.Error())
185 continue
186 }
187
188 elemSet := make(map[string]bool, len(resolved))
189 for _, id := range resolved {
190 elemSet[id] = true
191 }
192
193 scopeID := view.Scope
194
195 // --relayout: clear all managed elements from the page so
196 // populateNewPage treats it as a fresh page.
197 if opts != nil && opts.Relayout {
198 clearPageElements(page)
199 }
200
201 if scopeID != "" {
202 createScopeBoundary(scopeID, viewID, page, templates, flat, &m.Specification, result)
203 // Include scope element in the filter so connectors targeting the
204 // boundary element are rendered (#217).
205 elemSet[scopeID] = true
206 }
207
208 // Populate resolved elements that aren't already on the page.
209 // This runs BEFORE applyChangesToPage so the layout engine can
210 // position elements on fresh pages. applyChangesToPage will then
211 // skip Added elements that are already placed. For existing pages,
212 // this handles elements newly included via view changes (#231).
213 populateNewPage(page, viewID, scopeID, templates, flat, elemSet, m, result)
214
215 applyChangesToPage(cs, page, templates, flat, elemSet, viewID, scopeID, &m.Specification, result)
216
217 // Populate connectors for relationships whose endpoints are both
218 // on the page but whose connector doesn't exist yet. This handles
219 // relationships involving newly populated elements (#231).
220 populateConnectors(page, viewID, scopeID, m, elemSet, templates, result)
221
222 // Reconciliation: remove elements on the page that are no longer
223 // in the resolved view (e.g., after exclude list changes). #102
224 reconcileViewPage(page, elemSet, flat, scopeID, viewID, result)
225
226 // Set drill-down links on elements that have a detail view. (#198)
227 applyDrillDownLinks(page, drillDownLinks)
228
229 // Create back-navigation button on detail views (views with scope). (#198)
230 if scopeID != "" {
231 createBackNavButton(page, viewID, scopeID, m)
232 }
233
234 // Create metadata and legend boxes on each view page. (#233)
235 metadataEnabled := m.Config.Metadata == nil || *m.Config.Metadata
236 legendEnabled := m.Config.Legend == nil || *m.Config.Legend
237
238 if metadataEnabled && opts != nil {
239 if createMetadata(page, viewID, view, m.Config, opts.ModelPath, opts.SyncTime) {
240 result.MetadataUpdated++
241 }
242 }
243 if legendEnabled {
244 if createLegend(page, viewID, m.Specification, templates, elemSet, flat) {
245 result.MetadataUpdated++
246 }
247 }
248
249 // Synchronize decision badges with the model
250 synchronizeDecisionBadges(page, m)
251 }
252 }
253
254 // populateNewPage creates elements on a view page for all elements in
255 // the view's resolved set that aren't already present.
256 // For new pages, this populates elements already in sync state (#184, #188).
257 // For existing pages, this adds elements newly included via view changes (#231).
258 func populateNewPage(
259 page *drawio.Page,
260 viewID string,
261 scopeID string,
262 templates *drawio.TemplateSet,
263 flat map[string]*model.Element,
264 elemSet map[string]bool,
265 m *model.BausteinsichtModel,
266 result *ForwardResult,
267 ) {
268 // Collect elements that need placement.
269 var toPlace []string
270 for id := range elemSet {
271 if id == scopeID {
272 continue
273 }
274 if page.FindElement(id) != nil {
275 continue
276 }
277 toPlace = append(toPlace, id)
278 }
279 if len(toPlace) == 0 {
280 return
281 }
282
283 // Determine if this is a fresh page (no existing bausteinsicht elements
284 // besides the scope boundary which is created before this function runs).
285 existingElems := page.FindAllElements()
286 nonBoundaryCount := 0
287 for _, obj := range existingElems {
288 if obj.SelectAttrValue("bausteinsicht_id", "") != scopeID || scopeID == "" {
289 nonBoundaryCount++
290 }
291 }
292 isFreshPage := nonBoundaryCount == 0
293
294 // Look up the view's layout mode.
295 layoutMode := ""
296 for vID, v := range m.Views {
297 if "view-"+vID == page.ID() {
298 layoutMode = v.Layout
299 break
300 }
301 }
302
303 if isFreshPage {
304 // Use layout engine for fresh pages.
305 lr := computeLayout(toPlace, flat, templates, m.ElementOrder, scopeID, layoutMode, m.Relationships)
306
307 // Resize and reposition the scope boundary if the layout engine computed dimensions.
308 if scopeID != "" && lr.BoundaryWidth > 0 && lr.BoundaryHeight > 0 {
309 resizeScopeBoundary(page, scopeID, lr.BoundaryX, lr.BoundaryY, lr.BoundaryWidth, lr.BoundaryHeight)
310 }
311
312 sort.Strings(toPlace)
313 for _, id := range toPlace {
314 pos, ok := lr.Positions[id]
315 if !ok {
316 continue
317 }
318 placeSingleElement(id, viewID, scopeID, page, templates, flat, &m.Specification, pos.X, pos.Y, false, result)
319 }
320 } else {
321 // Incremental: fall back to cursor-based placement.
322 pl := computePlacement(page)
323 for _, id := range toPlace {
324 applyElementAdded(id, viewID, scopeID, page, templates, flat, &m.Specification, &pl, result)
325 }
326 }
327 }
328
329 // populateConnectors creates connectors for all model relationships whose
330 // (possibly lifted) endpoints are both on the page but no connector exists yet.
331 // This ensures relationships involving newly populated elements are rendered (#231).
332 func populateConnectors(
333 page *drawio.Page,
334 viewID string,
335 scopeID string,
336 m *model.BausteinsichtModel,
337 elemSet map[string]bool,
338 templates *drawio.TemplateSet,
339 result *ForwardResult,
340 ) {
341 liftedSeen := make(map[string]bool)
342 for i, rel := range m.Relationships {
343 from := liftEndpoint(rel.From, elemSet)
344 to := liftEndpoint(rel.To, elemSet)
345 if from == "" || to == "" {
346 continue
347 }
348 // Skip self-referencing lifted relationships.
349 if from == to && (from != rel.From || to != rel.To) {
350 continue
351 }
352 // Skip connectors between the scope boundary and external elements.
353 // The boundary is a visual container — scope↔external relationships
354 // belong in the parent view. Child↔scope connections are allowed.
355 if scopeID != "" && isScopeExternalConnector(from, to, scopeID) {
356 continue
357 }
358
359 isLifted := from != rel.From || to != rel.To
360 pairKey := from + "->" + to
361 if isLifted {
362 if liftedSeen[pairKey] {
363 continue
364 }
365 liftedSeen[pairKey] = true
366 } else {
367 liftedSeen[pairKey] = true
368 }
369
370 srcRef := scopedCellID(viewID, from)
371 tgtRef := scopedCellID(viewID, to)
372 if page.FindConnector(srcRef, tgtRef, i) != nil {
373 continue // Already exists.
374 }
375 style := templates.GetConnectorStyle()
376 data := drawio.ConnectorData{
377 From: from,
378 To: to,
379 Label: rel.Label,
380 SourceRef: srcRef,
381 TargetRef: tgtRef,
382 Index: i,
383 }
384 page.CreateConnector(data, style)
385 result.ConnectorsCreated++
386 }
387 }
388
389 // scopedCellID returns a page-scoped cell ID to ensure file-wide uniqueness.
390 // If viewID is empty, returns the raw element ID (legacy mode).
391 func scopedCellID(viewID, elemID string) string {
392 if viewID == "" {
393 return elemID
394 }
395 return viewID + "--" + elemID
396 }
397
398 // isChildOf returns true if id is a direct or nested child of parentID.
399 // Example: isChildOf("shop.api", "shop") → true
400 func isChildOf(id, parentID string) bool {
401 return strings.HasPrefix(id, parentID+".")
402 }
403
404 // isScopeExternalConnector returns true if one endpoint is the scope and
405 // the other is NOT a child of the scope. Child↔scope connections are allowed,
406 // but scope↔external connections should be shown in the parent view.
407 func isScopeExternalConnector(from, to, scopeID string) bool {
408 if from == scopeID && !isChildOf(to, scopeID) {
409 return true
410 }
411 if to == scopeID && !isChildOf(from, scopeID) {
412 return true
413 }
414 return false
415 }
416
417 // liftEndpoint returns id if it is in elemFilter. Otherwise it walks up the
418 // parent chain (by removing the last dot-segment) until a parent is found in
419 // the filter. Returns "" if no ancestor is present on the page.
420 func liftEndpoint(id string, elemFilter map[string]bool) string {
421 if elemFilter[id] {
422 return id
423 }
424 for {
425 dot := strings.LastIndex(id, ".")
426 if dot < 0 {
427 return ""
428 }
429 id = id[:dot]
430 if elemFilter[id] {
431 return id
432 }
433 }
434 }
435
436 // createScopeBoundary creates a boundary/swimlane element for the scope element
437 // of a view (e.g., the parent system in a container view).
438 // spec provides tag definitions for applying tag-based styles.
439 func createScopeBoundary(
440 scopeID string,
441 viewID string,
442 page *drawio.Page,
443 templates *drawio.TemplateSet,
444 flat map[string]*model.Element,
445 spec *model.Specification,
446 result *ForwardResult,
447 ) {
448 // Skip if already present on the page.
449 if page.FindElement(scopeID) != nil {
450 return
451 }
452
453 elem, ok := flat[scopeID]
454 if !ok {
455 result.Warnings = append(result.Warnings, "scope element not found in model: "+scopeID)
456 return
457 }
458
459 boundaryKind := elem.Kind + "_boundary"
460 ts, ok := templates.GetBoundaryStyle(boundaryKind)
461 if !ok {
462 ts = drawio.TemplateStyle{Width: 400, Height: 300}
463 result.Warnings = append(result.Warnings, "no boundary template for kind: "+boundaryKind)
464 }
465
466 baseStyle := ts.Style
467 // Apply tag-based styles if any tags are defined on the element
468 if spec != nil && len(elem.Tags) > 0 {
469 baseStyle = applyTagStyles(elem, spec, baseStyle)
470 }
471
472 style := baseStyle
473 width := ts.Width
474 if width == 0 {
475 width = 400
476 }
477 height := ts.Height
478 if height == 0 {
479 height = 300
480 }
481
482 data := drawio.ElementData{
483 ID: scopeID,
484 CellID: scopedCellID(viewID, scopeID),
485 Kind: boundaryKind,
486 Title: elem.Title,
487 Technology: elem.Technology,
488 Description: elem.Description,
489 X: elementGap,
490 Y: elementGap,
491 Width: width,
492 Height: height,
493 // Boundaries don't use sub-cells — they use the swimlane header for the label.
494 }
495
496 if err := page.CreateElement(data, style); err != nil {
497 result.Warnings = append(result.Warnings, "failed to create scope boundary "+scopeID+": "+err.Error())
498 }
499 }
500
501 // applyChangesToPage applies element and relationship changes to a single page.
502 // If elemFilter is nil, all changes are applied. Otherwise only elements in the
503 // filter set are processed, and relationships are only created when both
504 // endpoints are in the filter set.
505 // viewID is used to scope cell IDs for file-wide uniqueness (empty = legacy).
506 // scopeID identifies the boundary element for parenting children (empty = no scope).
507 // spec provides tag definitions for applying tag-based styles.
508 func applyChangesToPage(
509 cs *ChangeSet,
510 page *drawio.Page,
511 templates *drawio.TemplateSet,
512 flat map[string]*model.Element,
513 elemFilter map[string]bool,
514 viewID string,
515 scopeID string,
516 spec *model.Specification,
517 result *ForwardResult,
518 ) {
519 pl := computePlacement(page)
520
521 for _, ch := range cs.ModelElementChanges {
522 switch ch.Type {
523 case Added:
524 if elemFilter != nil && !elemFilter[ch.ID] {
525 continue
526 }
527 applyElementAdded(ch.ID, viewID, scopeID, page, templates, flat, spec, &pl, result)
528 case Modified:
529 if elemFilter != nil && !elemFilter[ch.ID] && ch.ID != scopeID {
530 continue
531 }
532 applyElementModified(ch, page, templates, flat, result)
533 case Deleted:
534 // Deleted elements are removed from all pages where they exist,
535 // regardless of the current view filter — a deleted element is
536 // no longer in the model, so it can't appear in any view's
537 // resolved set. We just check if it exists on this page.
538 cellID := scopedCellID(viewID, ch.ID)
539 if page.FindElement(ch.ID) != nil {
540 // Delete connectors referencing this element's cell ID before
541 // removing the element itself. (#101)
542 result.ConnectorsDeleted += countConnectorsFor(page, cellID)
543 page.DeleteConnectorsFor(cellID)
544 page.DeleteElement(ch.ID)
545 result.ElementsDeleted++
546 }
547 }
548 }
549
550 liftedSeen := make(map[string]bool)
551
552 // Process relationships in two passes: direct first, then lifted.
553 // This ensures that when a direct relationship (e.g., api→db) and a
554 // lifted relationship (e.g., api.catalog→db lifted to api→db) map to
555 // the same pair, the direct one's label is used for the connector.
556 for pass := 0; pass < 2; pass++ {
557 for _, ch := range cs.ModelRelationshipChanges {
558 switch ch.Type {
559 case Deleted:
560 if pass != 0 {
561 continue
562 }
563 // For deletions, use scoped cell IDs to find and remove the
564 // connector. The original endpoints may no longer be in the
565 // view's element filter, so we bypass lifting entirely.
566 fromRef := scopedCellID(viewID, ch.From)
567 toRef := scopedCellID(viewID, ch.To)
568 if page.FindConnector(fromRef, toRef, ch.Index) != nil {
569 page.DeleteConnector(fromRef, toRef, ch.Index)
570 result.ConnectorsDeleted++
571 }
572 default:
573 from := ch.From
574 to := ch.To
575 if elemFilter != nil {
576 from = liftEndpoint(from, elemFilter)
577 to = liftEndpoint(to, elemFilter)
578 if from == "" || to == "" || (from == to && (from != ch.From || to != ch.To)) {
579 continue
580 }
581 // Skip connectors between scope boundary and externals.
582 if scopeID != "" && isScopeExternalConnector(from, to, scopeID) {
583 continue
584 }
585 }
586 isLifted := from != ch.From || to != ch.To
587 if pass == 0 && isLifted {
588 continue // First pass: only direct relationships
589 }
590 if pass == 1 && !isLifted {
591 continue // Second pass: only lifted relationships
592 }
593 lifted := RelationshipChange{From: from, To: to, Index: ch.Index, Type: ch.Type, NewValue: ch.NewValue}
594 switch ch.Type {
595 case Added:
596 // Only deduplicate lifted relationships. When multiple
597 // child relationships (e.g., a.x→b.z and a.y→b.z) are
598 // lifted to the same parent pair (a→b), only one connector
599 // should be created. Direct (non-lifted) relationships
600 // with the same pair must not be deduplicated. (#142)
601 pairKey := from + "->" + to
602 if isLifted {
603 // Skip lifted relationships when a direct relationship
604 // or another lifted relationship already covers this
605 // pair. (#142, #197)
606 if liftedSeen[pairKey] {
607 continue
608 }
609 liftedSeen[pairKey] = true
610 } else {
611 // Record direct relationships so lifted ones targeting
612 // the same pair are suppressed in pass 1. (#197)
613 liftedSeen[pairKey] = true
614 }
615 applyRelAdded(lifted, viewID, page, templates, result)
616 case Modified:
617 page.UpdateConnectorLabel(from, to, ch.Index, ch.NewValue)
618 result.ConnectorsUpdated++
619 }
620 }
621 }
622 }
623 }
624
625 // firstPage returns the first page in doc, or nil if there are none.
626 func firstPage(doc *drawio.Document) *drawio.Page {
627 pages := doc.Pages()
628 if len(pages) == 0 {
629 return nil
630 }
631 return pages[0]
632 }
633
634 // placement tracks where the next new element should be placed.
635 type placement struct {
636 nextX float64
637 nextY float64
638 }
639
640 // computePlacement scans existing elements on a page and returns a placement
641 // state positioned one row below all existing content.
642 func computePlacement(page *drawio.Page) placement {
643 maxY := 0.0
644 for _, obj := range page.FindAllElements() {
645 cell := obj.FindElement("mxCell")
646 if cell == nil {
647 continue
648 }
649 geo := cell.FindElement("mxGeometry")
650 if geo == nil {
651 continue
652 }
653 y, _ := strconv.ParseFloat(geo.SelectAttrValue("y", "0"), 64)
654 h, _ := strconv.ParseFloat(geo.SelectAttrValue("height", "0"), 64)
655 if bottom := y + h; bottom > maxY {
656 maxY = bottom
657 }
658 }
659
660 startY := maxY
661 if maxY > 0 {
662 startY = maxY + elementGap
663 }
664 return placement{nextX: elementGap, nextY: startY}
665 }
666
667 // applyElementAdded creates a new element on page with a visual new-element marker.
668 // If scopeID is set and the element is a child of the scope, it is parented to the
669 // scope boundary cell.
670 // spec provides tag definitions for applying tag-based styles.
671 func applyElementAdded(
672 id string,
673 viewID string,
674 scopeID string,
675 page *drawio.Page,
676 templates *drawio.TemplateSet,
677 flat map[string]*model.Element,
678 spec *model.Specification,
679 pl *placement,
680 result *ForwardResult,
681 ) {
682 // Skip layout/creation if element already exists on the page (prevents duplicates on sync
683 // state reset, #141). Still update content so model values are applied (e.g. description
684 // truncation kicks in even on existing elements after a state reset).
685 if page.FindElement(id) != nil {
686 if elem, ok := flat[id]; ok {
687 page.UpdateElement(id, drawio.ElementData{
688 Title: elem.Title,
689 Technology: elem.Technology,
690 Description: elem.Description,
691 })
692 }
693 return
694 }
695
696 elem, ok := flat[id]
697 if !ok {
698 result.Warnings = append(result.Warnings, "element not found in model: "+id)
699 return
700 }
701
702 ts, ok := templates.GetStyle(elem.Kind)
703 if !ok {
704 ts = drawio.TemplateStyle{Width: defaultWidth, Height: defaultHeight}
705 result.Warnings = append(result.Warnings, "no template style for kind: "+elem.Kind)
706 }
707
708 // Apply tag-based styles if any tags are defined on the element
709 baseStyle := ts.Style
710 if spec != nil && len(elem.Tags) > 0 {
711 baseStyle = applyTagStyles(elem, spec, baseStyle)
712 }
713
714 style := mergeStyles(baseStyle, newElementMarker)
715
716 width := ts.Width
717 if width == 0 {
718 width = defaultWidth
719 }
720 height := ts.Height
721 if height == 0 {
722 height = defaultHeight
723 }
724
725 data := drawio.ElementData{
726 ID: id,
727 CellID: scopedCellID(viewID, id),
728 Kind: elem.Kind,
729 Title: elem.Title,
730 Technology: elem.Technology,
731 Description: elem.Description,
732 X: pl.nextX,
733 Y: pl.nextY,
734 Width: width,
735 Height: height,
736 SubCells: subCellsFromTemplate(ts),
737 }
738
739 // Parent children of the scope element to the boundary cell.
740 if scopeID != "" && isChildOf(id, scopeID) {
741 data.ParentID = scopedCellID(viewID, scopeID)
742 }
743
744 if err := page.CreateElement(data, style); err != nil {
745 result.Warnings = append(result.Warnings, "failed to create element "+id+": "+err.Error())
746 return
747 }
748
749 pl.nextX += width + elementGap
750 result.ElementsCreated++
751 }
752
753 // placeSingleElement creates a new element at specific coordinates (used by layout engine).
754 func placeSingleElement(
755 id string,
756 viewID string,
757 scopeID string,
758 page *drawio.Page,
759 templates *drawio.TemplateSet,
760 flat map[string]*model.Element,
761 spec *model.Specification,
762 x, y float64,
763 markNew bool,
764 result *ForwardResult,
765 ) {
766 if page.FindElement(id) != nil {
767 return
768 }
769
770 elem, ok := flat[id]
771 if !ok {
772 result.Warnings = append(result.Warnings, "element not found in model: "+id)
773 return
774 }
775
776 ts, ok := templates.GetStyle(elem.Kind)
777 if !ok {
778 ts = drawio.TemplateStyle{Width: defaultWidth, Height: defaultHeight}
779 result.Warnings = append(result.Warnings, "no template style for kind: "+elem.Kind)
780 }
781
782 baseStyle := ts.Style
783 // Apply tag-based styles if any tags are defined on the element
784 if spec != nil && len(elem.Tags) > 0 {
785 baseStyle = applyTagStyles(elem, spec, baseStyle)
786 }
787
788 style := baseStyle
789 if markNew {
790 style = mergeStyles(baseStyle, newElementMarker)
791 }
792
793 width := ts.Width
794 if width == 0 {
795 width = defaultWidth
796 }
797 height := ts.Height
798 if height == 0 {
799 height = defaultHeight
800 }
801
802 data := drawio.ElementData{
803 ID: id,
804 CellID: scopedCellID(viewID, id),
805 Kind: elem.Kind,
806 Title: elem.Title,
807 Technology: elem.Technology,
808 Description: elem.Description,
809 X: x,
810 Y: y,
811 Width: width,
812 Height: height,
813 SubCells: subCellsFromTemplate(ts),
814 }
815
816 if scopeID != "" && isChildOf(id, scopeID) {
817 data.ParentID = scopedCellID(viewID, scopeID)
818 }
819
820 if err := page.CreateElement(data, style); err != nil {
821 result.Warnings = append(result.Warnings, "failed to create element "+id+": "+err.Error())
822 return
823 }
824
825 result.ElementsCreated++
826 }
827
828 // resizeScopeBoundary updates the geometry and position of an existing scope boundary element.
829 func resizeScopeBoundary(page *drawio.Page, scopeID string, x, y, width, height float64) {
830 obj := page.FindElement(scopeID)
831 if obj == nil {
832 return
833 }
834 cell := obj.FindElement("mxCell")
835 if cell == nil {
836 return
837 }
838 geo := cell.FindElement("mxGeometry")
839 if geo == nil {
840 return
841 }
842 geo.CreateAttr("x", strconv.FormatFloat(x, 'f', -1, 64))
843 geo.CreateAttr("y", strconv.FormatFloat(y, 'f', -1, 64))
844 geo.CreateAttr("width", strconv.FormatFloat(width, 'f', -1, 64))
845 geo.CreateAttr("height", strconv.FormatFloat(height, 'f', -1, 64))
846 }
847
848 // clearPageElements removes all managed bausteinsicht elements and connectors
849 // from a page. Used by --relayout to allow the layout engine to reposition
850 // everything from scratch.
851 func clearPageElements(page *drawio.Page) {
852 root := page.Root()
853 if root == nil {
854 return
855 }
856 // Collect elements to remove (cannot modify tree while iterating).
857 var toRemove []*etree.Element
858 for _, obj := range root.SelectElements("object") {
859 if obj.SelectAttrValue("bausteinsicht_id", "") != "" {
860 toRemove = append(toRemove, obj)
861 }
862 }
863 for _, cell := range root.SelectElements("mxCell") {
864 if strings.HasPrefix(cell.SelectAttrValue("id", ""), "rel-") {
865 toRemove = append(toRemove, cell)
866 }
867 }
868 for _, el := range toRemove {
869 root.RemoveChild(el)
870 }
871 }
872
873 // subCellsFromTemplate creates SubCellTemplates from a TemplateStyle.
874 // Returns nil if the template has no sub-cell definitions.
875 func subCellsFromTemplate(ts drawio.TemplateStyle) *drawio.SubCellTemplates {
876 if ts.TitleStyle == nil {
877 return nil
878 }
879 return &drawio.SubCellTemplates{
880 Title: ts.TitleStyle,
881 Tech: ts.TechStyle,
882 Desc: ts.DescStyle,
883 }
884 }
885
886 // applyElementModified updates the changed field of an existing element.
887 func applyElementModified(
888 ch ElementChange,
889 page *drawio.Page,
890 templates *drawio.TemplateSet,
891 flat map[string]*model.Element,
892 result *ForwardResult,
893 ) {
894 elem, ok := flat[ch.ID]
895 if !ok {
896 result.Warnings = append(result.Warnings, "element not found in model for update: "+ch.ID)
897 return
898 }
899
900 // Handle kind changes separately (only updates attribute and style).
901 if ch.Field == "kind" {
902 ts, ok := templates.GetStyle(elem.Kind)
903 if ok {
904 page.UpdateElementKind(ch.ID, elem.Kind, ts.Style)
905 } else {
906 page.UpdateElementKind(ch.ID, elem.Kind, "")
907 }
908 result.ElementsUpdated++
909 return
910 }
911
912 // When a specific field is known, read the current draw.io values and only
913 // override the changed field. This prevents overwriting draw.io-side changes
914 // to other fields during concurrent modification. (#109)
915 title := elem.Title
916 technology := elem.Technology
917 description := elem.Description
918
919 if ch.Field != "" {
920 obj := page.FindElement(ch.ID)
921 if obj != nil {
922 // Use ReadElementFields which handles both sub-cells and HTML labels.
923 curTitle, curTech, curDesc := page.ReadElementFields(obj)
924 curTooltip := obj.SelectAttrValue("tooltip", "")
925 if curDesc == "" {
926 curDesc = curTooltip
927 }
928
929 // Start from current draw.io values, override only the changed field.
930 title = curTitle
931 technology = curTech
932 description = curDesc
933
934 switch ch.Field {
935 case "title":
936 title = elem.Title
937 case "technology":
938 technology = elem.Technology
939 case "description":
940 description = elem.Description
941 }
942 }
943 }
944
945 data := drawio.ElementData{
946 ID: ch.ID,
947 Title: title,
948 Technology: technology,
949 Description: description,
950 }
951 page.UpdateElement(ch.ID, data)
952 result.ElementsUpdated++
953 }
954
955 // countConnectorsFor counts the number of connectors on page where source or
956 // target matches cellID. This is used to increment ConnectorsDeleted before
957 // calling page.DeleteConnectorsFor.
958 func countConnectorsFor(page *drawio.Page, cellID string) int {
959 n := 0
960 for _, c := range page.FindAllConnectors() {
961 src := c.SelectAttrValue("source", "")
962 tgt := c.SelectAttrValue("target", "")
963 if src == cellID || tgt == cellID {
964 n++
965 }
966 }
967 return n
968 }
969
970 // applyRelAdded creates a new connector on page. If the connector already
971 // exists (e.g., sync state was deleted), it is skipped to avoid duplicates. (#119)
972 func applyRelAdded(
973 ch RelationshipChange,
974 viewID string,
975 page *drawio.Page,
976 templates *drawio.TemplateSet,
977 result *ForwardResult,
978 ) {
979 srcRef := scopedCellID(viewID, ch.From)
980 tgtRef := scopedCellID(viewID, ch.To)
981
982 // Skip if connector already exists to prevent duplicates. (#119)
983 if page.FindConnector(srcRef, tgtRef, ch.Index) != nil {
984 return
985 }
986
987 style := templates.GetConnectorStyle()
988 data := drawio.ConnectorData{
989 From: ch.From,
990 To: ch.To,
991 Label: ch.NewValue,
992 SourceRef: srcRef,
993 TargetRef: tgtRef,
994 Index: ch.Index,
995 }
996 page.CreateConnector(data, style)
997 result.ConnectorsCreated++
998 }
999
1000 // reconcileViewPage removes elements from the page that are not in the
1001 // resolved view filter. This handles cases where view include/exclude rules
1002 // change without corresponding model element changes (no ChangeSet entries).
1003 // Elements not present in the flat model map are preserved — they were
1004 // manually added by the user in draw.io and should not be deleted. (#115)
1005 func reconcileViewPage(
1006 page *drawio.Page,
1007 elemFilter map[string]bool,
1008 flat map[string]*model.Element,
1009 scopeID string,
1010 viewID string,
1011 result *ForwardResult,
1012 ) {
1013 if elemFilter == nil {
1014 return
1015 }
1016
1017 for _, obj := range page.FindAllElements() {
1018 id := obj.SelectAttrValue("bausteinsicht_id", "")
1019 if id == "" {
1020 continue
1021 }
1022
1023 // Skip the scope boundary element — it's rendered separately
1024 // and is not subject to the normal element filter.
1025 if id == scopeID {
1026 continue
1027 }
1028
1029 if elemFilter[id] {
1030 continue
1031 }
1032
1033 // Preserve elements not in the model — they were manually added
1034 // by the user in draw.io and should not be deleted. (#115)
1035 if _, inModel := flat[id]; !inModel {
1036 continue
1037 }
1038
1039 // Element is on the page but not in the view's resolved set.
1040 // Remove its connectors first (using scoped cell ID), then the element.
1041 // On view pages, connectors reference scoped cell IDs, not raw element IDs.
1042 cellID := scopedCellID(viewID, id)
1043 result.ConnectorsDeleted += countConnectorsFor(page, cellID)
1044 page.DeleteConnectorsFor(cellID)
1045
1046 page.DeleteElement(id)
1047 result.ElementsDeleted++
1048 }
1049 }
1050
1051 // reconcileOrphanedElements removes elements from the page whose
1052 // bausteinsicht_id does not exist in the current model. This is the
1053 // no-views equivalent of reconcileViewPage and handles cases where
1054 // sync state is missing or the model was emptied. (#110)
1055 func reconcileOrphanedElements(
1056 page *drawio.Page,
1057 flat map[string]*model.Element,
1058 result *ForwardResult,
1059 ) {
1060 for _, obj := range page.FindAllElements() {
1061 id := obj.SelectAttrValue("bausteinsicht_id", "")
1062 if id == "" {
1063 continue
1064 }
1065 if _, inModel := flat[id]; inModel {
1066 continue
1067 }
1068 // Element is on the page but not in the model — remove it.
1069 cellID := id // no view scoping in legacy mode
1070 result.ConnectorsDeleted += countConnectorsFor(page, cellID)
1071 page.DeleteConnectorsFor(cellID)
1072 page.DeleteElement(id)
1073 result.ElementsDeleted++
1074 }
1075 }
1076
1077 // mergeStyles merges overlay style properties into a base style string.
1078 // If both base and overlay define the same key (e.g., strokeColor), the
1079 // overlay value wins. This prevents duplicate keys in the style string (#187).
1080 func mergeStyles(base, overlay string) string {
1081 if overlay == "" {
1082 return base
1083 }
1084 if base == "" {
1085 return overlay
1086 }
1087
1088 // Parse overlay keys.
1089 overlayKeys := make(map[string]string)
1090 for _, part := range strings.Split(overlay, ";") {
1091 part = strings.TrimSpace(part)
1092 if part == "" {
1093 continue
1094 }
1095 if idx := strings.IndexByte(part, '='); idx > 0 {
1096 overlayKeys[part[:idx]] = part
1097 } else {
1098 overlayKeys[part] = part
1099 }
1100 }
1101
1102 // Build result: base properties (skipping those overridden) + overlay.
1103 var sb strings.Builder
1104 for _, part := range strings.Split(base, ";") {
1105 part = strings.TrimSpace(part)
1106 if part == "" {
1107 continue
1108 }
1109 key := part
1110 if idx := strings.IndexByte(part, '='); idx > 0 {
1111 key = part[:idx]
1112 }
1113 if _, overridden := overlayKeys[key]; overridden {
1114 continue
1115 }
1116 sb.WriteString(part)
1117 sb.WriteByte(';')
1118 }
1119 for _, v := range overlayKeys {
1120 sb.WriteString(v)
1121 sb.WriteByte(';')
1122 }
1123 return sb.String()
1124 }
1125
1126 // applyDrillDownLinks sets the link attribute on elements that have a detail
1127 // view (a view whose scope matches the element's bausteinsicht_id). (#198)
1128 func applyDrillDownLinks(page *drawio.Page, links map[string]string) {
1129 for _, obj := range page.FindAllElements() {
1130 bid := obj.SelectAttrValue("bausteinsicht_id", "")
1131 if link, ok := links[bid]; ok {
1132 setAttrOn(obj, "link", link)
1133 }
1134 }
1135 }
1136
1137 // setAttrOn sets or creates an attribute on an etree element.
1138 func setAttrOn(el *etree.Element, key, value string) {
1139 for i, a := range el.Attr {
1140 if a.Key == key {
1141 el.Attr[i].Value = value
1142 return
1143 }
1144 }
1145 el.CreateAttr(key, value)
1146 }
1147
1148 // createBackNavButton adds a small navigation button to a detail view page
1149 // that links back to the parent view. The parent view is the one that
1150 // contains the scope element. (#198)
1151 func createBackNavButton(
1152 page *drawio.Page,
1153 viewID string,
1154 scopeID string,
1155 m *model.BausteinsichtModel,
1156 ) {
1157 navCellID := "nav-back-" + viewID
1158
1159 // Don't create if already exists.
1160 root := page.Root()
1161 if root == nil {
1162 return
1163 }
1164 for _, obj := range root.SelectElements("object") {
1165 if obj.SelectAttrValue("id", "") == navCellID {
1166 return
1167 }
1168 }
1169
1170 // Find the parent view: a view that includes the scope element.
1171 var parentViewID string
1172 var parentTitle string
1173 for vID, v := range m.Views {
1174 if vID == viewID {
1175 continue
1176 }
1177 viewCopy := v
1178 resolved, err := model.ResolveView(m, &viewCopy)
1179 if err != nil {
1180 continue
1181 }
1182 for _, id := range resolved {
1183 if id == scopeID {
1184 parentViewID = vID
1185 parentTitle = v.Title
1186 break
1187 }
1188 }
1189 if parentViewID != "" {
1190 break
1191 }
1192 }
1193
1194 if parentViewID == "" {
1195 return // No parent view found.
1196 }
1197
1198 obj := root.CreateElement("object")
1199 obj.CreateAttr("label", "&larr; "+parentTitle)
1200 obj.CreateAttr("id", navCellID)
1201 obj.CreateAttr("link", "data:page/id,view-"+parentViewID)
1202
1203 cell := obj.CreateElement("mxCell")
1204 cell.CreateAttr("style", "rounded=1;fillColor=#f8cecc;strokeColor=#b85450;html=1;fontSize=10;")
1205 cell.CreateAttr("vertex", "1")
1206 cell.CreateAttr("parent", "1")
1207
1208 geo := cell.CreateElement("mxGeometry")
1209 geo.CreateAttr("x", "20")
1210 geo.CreateAttr("y", "20")
1211 geo.CreateAttr("width", "140")
1212 geo.CreateAttr("height", "30")
1213 geo.CreateAttr("as", "geometry")
1214 }
1215
1216 // synchronizeDecisionBadges updates decision badges on all elements in a page
1217 // to match the model. It removes old badges and creates new ones based on the
1218 // element's decision links in the model.
1219 func synchronizeDecisionBadges(page *drawio.Page, m *model.BausteinsichtModel) {
1220 if m == nil {
1221 return
1222 }
1223
1224 // Build decision map for quick lookup
1225 decisionMap := make(map[string]*model.DecisionRecord)
1226 for i := range m.Specification.Decisions {
1227 decisionMap[m.Specification.Decisions[i].ID] = &m.Specification.Decisions[i]
1228 }
1229
1230 // Find all elements on the page
1231 root := page.Root()
1232 if root == nil {
1233 return
1234 }
1235
1236 for _, obj := range root.SelectElements("object") {
1237 elemID := obj.SelectAttrValue("bausteinsicht_id", "")
1238 if elemID == "" {
1239 continue
1240 }
1241
1242 // Look up element in model
1243 elem, ok := findElementByID(m, elemID)
1244 if !ok || elem == nil {
1245 continue
1246 }
1247
1248 // Find the mxCell for this element
1249 cell := obj.SelectElement("mxCell")
1250 if cell == nil {
1251 continue
1252 }
1253
1254 // Remove old decision badges
1255 children := cell.SelectElements("mxCell")
1256 for i := len(children) - 1; i >= 0; i-- {
1257 badgeID := children[i].SelectAttrValue("bausteinsicht_decision_id", "")
1258 if badgeID != "" {
1259 cell.RemoveChild(children[i])
1260 }
1261 }
1262
1263 // Add new decision badges
1264 if len(elem.Decisions) > 0 {
1265 AddDecisionBadges(cell, elem.Decisions, decisionMap)
1266 }
1267 }
1268 }
1269
1270 // findElementByID finds an element in the model by its dot-path ID (e.g., "system.backend.api")
1271 func findElementByID(m *model.BausteinsichtModel, id string) (*model.Element, bool) {
1272 parts := strings.Split(id, ".")
1273 if len(parts) == 0 {
1274 return nil, false
1275 }
1276
1277 elem, ok := m.Model[parts[0]]
1278 if !ok {
1279 return nil, false
1280 }
1281
1282 // Navigate through child elements
1283 current := &elem
1284 for _, part := range parts[1:] {
1285 child, ok := current.Children[part]
1286 if !ok {
1287 return nil, false
1288 }
1289 current = &child
1290 }
1291
1292 return current, true
1293 }
1294
github.com/docToolchain/Bausteinsicht/internal/sync/layout.go 85.5%
1 package sync
2
3 import (
4 "math"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/drawio"
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 type position struct {
13 X, Y float64
14 }
15
16 type layoutConfig struct {
17 pageWidth float64 // 1169 (A4 landscape)
18 elementGap float64 // 40
19 padding float64 // 60 (boundary inner padding)
20 startX float64 // 40
21 startY float64 // 40
22 }
23
24 var defaultLayoutConfig = layoutConfig{
25 pageWidth: 1169,
26 elementGap: 60,
27 padding: 60,
28 startX: 40,
29 startY: 40,
30 }
31
32 // layoutResult holds computed positions and optional boundary dimensions.
33 type layoutResult struct {
34 Positions map[string]position
35 BoundaryX float64
36 BoundaryY float64
37 BoundaryWidth float64
38 BoundaryHeight float64
39 }
40
41 // computeLayout returns positions for elements to place on a fresh page.
42 // It only computes positions; it does not modify the document.
43 func computeLayout(
44 ids []string,
45 flat map[string]*model.Element,
46 templates *drawio.TemplateSet,
47 elementOrder []string,
48 scopeID string,
49 layout string,
50 relationships []model.Relationship,
51 ) layoutResult {
52 switch layout {
53 case "grid":
54 return computeGridLayout(ids, flat, templates)
55 case "none":
56 return computeNoneLayout(ids, flat, templates)
57 default: // "layered" or ""
58 return computeLayeredLayout(ids, flat, templates, elementOrder, scopeID, relationships)
59 }
60 }
61
62 // computeLayeredLayout arranges elements in horizontal rows grouped by kind.
63 // Kinds are ordered according to elementOrder (from specification.elements).
64 // Elements within each layer are sorted alphabetically.
65 //
66 // For scoped views the layout is:
67 // 1. Actor-like externals (kinds whose notation contains "Actor") → top rows
68 // 2. Scope boundary with children → middle
69 // 3. Non-actor externals → bottom rows
70 func computeLayeredLayout(
71 ids []string,
72 flat map[string]*model.Element,
73 templates *drawio.TemplateSet,
74 elementOrder []string,
75 scopeID string,
76 relationships []model.Relationship,
77 ) layoutResult {
78 cfg := defaultLayoutConfig
79 result := layoutResult{Positions: make(map[string]position)}
80
81 // Build kind → tier mapping from elementOrder.
82 kindTier := make(map[string]int)
83 for i, k := range elementOrder {
84 kindTier[k] = i
85 }
86 maxTier := len(elementOrder) // unknown kinds go here
87
88 // Separate scoped children from external elements.
89 // For externals, further split into actors (top) and non-actors (bottom).
90 var scopeChildren []string
91 var actorExternals []string
92 var otherExternals []string
93 for _, id := range ids {
94 if id == scopeID {
95 continue
96 }
97 if scopeID != "" && isChildOf(id, scopeID) {
98 scopeChildren = append(scopeChildren, id)
99 } else {
100 if isActorKind(id, flat) {
101 actorExternals = append(actorExternals, id)
102 } else {
103 otherExternals = append(otherExternals, id)
104 }
105 }
106 }
107
108 // If no scope, treat everything as one group (actors first, then rest).
109 if scopeID == "" {
110 all := append(actorExternals, otherExternals...)
111 all = append(all, scopeChildren...)
112 placeLayered(all, flat, templates, kindTier, maxTier, cfg, cfg.startX, cfg.startY, result.Positions)
113 return result
114 }
115
116 // Compute boundary dimensions first (needed for centering actors/externals).
117 boundaryX := cfg.startX
118 var boundaryContentW, boundaryContentH float64
119 boundaryStartSize := 30.0
120
121 scopeChildPositions := make(map[string]position)
122 if len(scopeChildren) > 0 {
123 innerX := cfg.padding
124 innerY := boundaryStartSize + cfg.padding
125 minContentWidth := minBoundaryContentWidth(scopeChildren, flat, templates, cfg)
126
127 boundaryContentW, boundaryContentH = placeBFS(scopeChildren, flat, templates, cfg, innerX, innerY, minContentWidth, actorExternals, relationships, scopeChildPositions)
128
129 if boundaryContentW < minContentWidth {
130 boundaryContentW = minContentWidth
131 }
132
133 result.BoundaryWidth = boundaryContentW + 2*cfg.padding
134 result.BoundaryHeight = boundaryContentH + boundaryStartSize + 2*cfg.padding
135
136 if result.BoundaryWidth < 400 {
137 result.BoundaryWidth = 400
138 }
139 if result.BoundaryHeight < 300 {
140 result.BoundaryHeight = 300
141 }
142 }
143
144 // Reference width for centering: the wider of boundary or page content.
145 refWidth := result.BoundaryWidth
146 if refWidth < 400 {
147 refWidth = 400
148 }
149
150 curY := cfg.startY
151
152 // 1. Place actor externals above the boundary, centered to boundary width.
153 // Reserve the first row even when there are no actors so users can add them later.
154 if len(actorExternals) > 0 {
155 actorW, actorH := placeLayered(actorExternals, flat, templates, kindTier, maxTier, cfg, cfg.startX, curY, result.Positions)
156 // Center actor row relative to boundary width.
157 if actorW < refWidth {
158 offset := (refWidth - actorW) / 2
159 for _, id := range actorExternals {
160 if p, ok := result.Positions[id]; ok {
161 p.X += offset
162 result.Positions[id] = p
163 }
164 }
165 }
166 curY += actorH + cfg.elementGap
167 } else {
168 // No actors — still reserve space for a potential actor row.
169 curY += defaultHeight + cfg.elementGap
170 }
171
172 // 2. Place scope boundary and its children.
173 boundaryY := curY
174 if len(scopeChildren) > 0 {
175 result.BoundaryX = boundaryX
176 result.BoundaryY = boundaryY
177
178 // Copy pre-computed child positions into result.
179 for id, pos := range scopeChildPositions {
180 result.Positions[id] = pos
181 }
182
183 curY = boundaryY + result.BoundaryHeight + cfg.elementGap
184 }
185
186 // 3. Place non-actor externals below the boundary, centered.
187 if len(otherExternals) > 0 {
188 extW, _ := placeLayered(otherExternals, flat, templates, kindTier, maxTier, cfg, cfg.startX, curY, result.Positions)
189 if extW < refWidth {
190 offset := (refWidth - extW) / 2
191 for _, id := range otherExternals {
192 if p, ok := result.Positions[id]; ok {
193 p.X += offset
194 result.Positions[id] = p
195 }
196 }
197 }
198 }
199
200 return result
201 }
202
203 // isActorKind returns true if the element's kind contains "actor" (case-insensitive).
204 func isActorKind(id string, flat map[string]*model.Element) bool {
205 elem := flat[id]
206 if elem == nil {
207 return false
208 }
209 k := elem.Kind
210 return k == "actor" || k == "Actor" || k == "user" || k == "User" ||
211 k == "person" || k == "Person"
212 }
213
214 // minBoundaryContentWidth computes the minimum content width to fit at least
215 // 3 average-sized elements side by side.
216 func minBoundaryContentWidth(ids []string, flat map[string]*model.Element, templates *drawio.TemplateSet, cfg layoutConfig) float64 {
217 if len(ids) == 0 {
218 return 0
219 }
220 // Use the most common element width as reference.
221 totalW := 0.0
222 for _, id := range ids {
223 w, _ := elementSize(id, flat, templates)
224 totalW += w
225 }
226 avgW := totalW / float64(len(ids))
227
228 cols := 3
229 if len(ids) < cols {
230 cols = len(ids)
231 }
232 return float64(cols)*avgW + float64(cols-1)*cfg.elementGap
233 }
234
235 // placeLayered places elements in layered rows, centered horizontally,
236 // and returns the content width and height.
237 func placeLayered(
238 ids []string,
239 flat map[string]*model.Element,
240 templates *drawio.TemplateSet,
241 kindTier map[string]int,
242 maxTier int,
243 cfg layoutConfig,
244 originX, originY float64,
245 positions map[string]position,
246 ) (contentWidth, contentHeight float64) {
247 // Group by tier.
248 tiers := make(map[int][]string)
249 for _, id := range ids {
250 elem := flat[id]
251 if elem == nil {
252 continue
253 }
254 tier, ok := kindTier[elem.Kind]
255 if !ok {
256 tier = maxTier
257 }
258 tiers[tier] = append(tiers[tier], id)
259 }
260
261 // Sort tier keys.
262 tierKeys := make([]int, 0, len(tiers))
263 for k := range tiers {
264 tierKeys = append(tierKeys, k)
265 }
266 sort.Ints(tierKeys)
267
268 // First pass: place left-aligned and track row membership for centering.
269 type rowInfo struct {
270 ids []string
271 width float64
272 height float64
273 y float64
274 }
275 var rows []rowInfo
276 curY := originY
277 maxRowWidth := 0.0
278
279 for _, tier := range tierKeys {
280 elems := tiers[tier]
281 sort.Strings(elems)
282
283 curX := originX
284 rowHeight := 0.0
285 var currentRow []string
286
287 for _, id := range elems {
288 w, h := elementSize(id, flat, templates)
289
290 // Row wrapping.
291 if curX > originX && curX+w > cfg.pageWidth-cfg.startX {
292 rowWidth := curX - cfg.elementGap - originX
293 rows = append(rows, rowInfo{ids: currentRow, width: rowWidth, height: rowHeight, y: curY})
294 if rowWidth > maxRowWidth {
295 maxRowWidth = rowWidth
296 }
297 curY += rowHeight + cfg.elementGap
298 curX = originX
299 rowHeight = 0
300 currentRow = nil
301 }
302
303 positions[id] = position{X: curX, Y: curY}
304 currentRow = append(currentRow, id)
305 curX += w + cfg.elementGap
306 if h > rowHeight {
307 rowHeight = h
308 }
309 }
310
311 // Finish last row of this tier.
312 if len(currentRow) > 0 {
313 rowWidth := curX - cfg.elementGap - originX
314 rows = append(rows, rowInfo{ids: currentRow, width: rowWidth, height: rowHeight, y: curY})
315 if rowWidth > maxRowWidth {
316 maxRowWidth = rowWidth
317 }
318 }
319 curY += rowHeight + cfg.elementGap
320 }
321
322 // Second pass: center each row within the max row width.
323 for _, row := range rows {
324 if row.width >= maxRowWidth {
325 continue
326 }
327 offset := (maxRowWidth - row.width) / 2
328 for _, id := range row.ids {
329 p := positions[id]
330 p.X += offset
331 positions[id] = p
332 }
333 }
334
335 contentWidth = maxRowWidth
336 contentHeight = curY - originY - cfg.elementGap // subtract trailing gap
337 if contentHeight < 0 {
338 contentHeight = 0
339 }
340 return contentWidth, contentHeight
341 }
342
343 // placeBFS places scope children using relationship-based BFS ordering:
344 // 1. Row 1: elements connected to actors (external seeds)
345 // 2. Row 2: elements connected to row 1
346 // 3. Row N: elements connected to row N-1
347 // 4. Remaining: any unconnected elements at the end
348 //
349 // Within each row, the next element is chosen by adjacency to the last placed
350 // element (greedy neighbor selection), with alphabetical fallback.
351 func placeBFS(
352 ids []string,
353 flat map[string]*model.Element,
354 templates *drawio.TemplateSet,
355 cfg layoutConfig,
356 originX, originY float64,
357 minRowWidth float64,
358 actorIDs []string,
359 relationships []model.Relationship,
360 positions map[string]position,
361 ) (contentWidth, contentHeight float64) {
362 // Build an adjacency map among the scope children.
363 idSet := make(map[string]bool, len(ids))
364 for _, id := range ids {
365 idSet[id] = true
366 }
367
368 // adj maps each scope child to its neighbors (other scope children
369 // or external elements it is connected to).
370 adj := make(map[string]map[string]bool)
371 for _, id := range ids {
372 adj[id] = make(map[string]bool)
373 }
374
375 actorSet := make(map[string]bool, len(actorIDs))
376 for _, id := range actorIDs {
377 actorSet[id] = true
378 }
379
380 // connectedToActor tracks which scope children have a direct or
381 // indirect (via lifted) relationship to an actor.
382 connectedToActor := make(map[string]bool)
383
384 for _, rel := range relationships {
385 from, to := rel.From, rel.To
386
387 // Check if this relationship connects a scope child to an actor.
388 if idSet[from] && actorSet[to] {
389 connectedToActor[from] = true
390 }
391 if idSet[to] && actorSet[from] {
392 connectedToActor[to] = true
393 }
394
395 // Also check lifted relationships: if an actor connects to a
396 // parent of a scope child (e.g., actor→onlineshop.frontend where
397 // frontend is a scope child via onlineshop.frontend).
398 fromInScope := idSet[from] || hasChildInSet(from, idSet)
399 toInScope := idSet[to] || hasChildInSet(to, idSet)
400 _ = fromInScope
401 _ = toInScope
402
403 // Build adjacency between scope children.
404 fromResolved := resolveToScopeChild(from, idSet)
405 toResolved := resolveToScopeChild(to, idSet)
406 if fromResolved != "" && toResolved != "" && fromResolved != toResolved {
407 if adj[fromResolved] != nil && adj[toResolved] != nil {
408 adj[fromResolved][toResolved] = true
409 adj[toResolved][fromResolved] = true
410 }
411 }
412
413 // Track actor connections via resolved scope children.
414 if fromResolved != "" && actorSet[to] {
415 connectedToActor[fromResolved] = true
416 }
417 if toResolved != "" && actorSet[from] {
418 connectedToActor[toResolved] = true
419 }
420 }
421
422 // BFS: assign elements to rows.
423 placed := make(map[string]bool)
424 var rows [][]string
425
426 // Row 0: elements connected to actors.
427 var row0 []string
428 for _, id := range ids {
429 if connectedToActor[id] {
430 row0 = append(row0, id)
431 placed[id] = true
432 }
433 }
434 sort.Strings(row0)
435 if len(row0) > 0 {
436 rows = append(rows, orderByAdjacency(row0, adj))
437 }
438
439 // Subsequent rows: BFS from previous row.
440 for len(rows) > 0 {
441 if len(placed) >= len(ids) {
442 break
443 }
444 prevRow := rows[len(rows)-1]
445 var nextRow []string
446 for _, prev := range prevRow {
447 for neighbor := range adj[prev] {
448 if !placed[neighbor] {
449 nextRow = append(nextRow, neighbor)
450 placed[neighbor] = true
451 }
452 }
453 }
454 if len(nextRow) == 0 {
455 break
456 }
457 sort.Strings(nextRow)
458 rows = append(rows, orderByAdjacency(nextRow, adj))
459 }
460
461 // Remaining: elements not reached by BFS (no relationships).
462 var remaining []string
463 for _, id := range ids {
464 if !placed[id] {
465 remaining = append(remaining, id)
466 }
467 }
468 if len(remaining) > 0 {
469 sort.Strings(remaining)
470 rows = append(rows, remaining)
471 }
472
473 // Place rows.
474 curY := originY
475 maxWidth := 0.0
476
477 for _, row := range rows {
478 curX := originX
479 rowHeight := 0.0
480
481 for _, id := range row {
482 w, h := elementSize(id, flat, templates)
483
484 // Row wrapping.
485 if curX > originX && curX+w > originX+minRowWidth {
486 curY += rowHeight + cfg.elementGap
487 curX = originX
488 rowHeight = 0
489 }
490
491 positions[id] = position{X: curX, Y: curY}
492 curX += w + cfg.elementGap
493 usedWidth := curX - cfg.elementGap - originX
494 if usedWidth > maxWidth {
495 maxWidth = usedWidth
496 }
497 if h > rowHeight {
498 rowHeight = h
499 }
500 }
501
502 curY += rowHeight + cfg.elementGap
503 }
504
505 contentWidth = maxWidth
506 contentHeight = curY - originY - cfg.elementGap
507 if contentHeight < 0 {
508 contentHeight = 0
509 }
510 return contentWidth, contentHeight
511 }
512
513 // orderByAdjacency reorders elements so that each next element is a neighbor
514 // of the previously placed one (greedy). Falls back to original order.
515 func orderByAdjacency(ids []string, adj map[string]map[string]bool) []string {
516 if len(ids) <= 1 {
517 return ids
518 }
519
520 remaining := make(map[string]bool, len(ids))
521 for _, id := range ids {
522 remaining[id] = true
523 }
524
525 result := make([]string, 0, len(ids))
526 // Start with the first element (alphabetically).
527 current := ids[0]
528 result = append(result, current)
529 delete(remaining, current)
530
531 for len(remaining) > 0 {
532 // Find a neighbor of current that is in remaining.
533 var next string
534 var candidates []string
535 for neighbor := range adj[current] {
536 if remaining[neighbor] {
537 candidates = append(candidates, neighbor)
538 }
539 }
540 if len(candidates) > 0 {
541 sort.Strings(candidates)
542 next = candidates[0]
543 } else {
544 // No neighbor found — pick alphabetically first remaining.
545 var fallback []string
546 for id := range remaining {
547 fallback = append(fallback, id)
548 }
549 if len(fallback) == 0 {
550 break
551 }
552 sort.Strings(fallback)
553 next = fallback[0]
554 }
555 result = append(result, next)
556 delete(remaining, next)
557 current = next
558 }
559
560 return result
561 }
562
563 // resolveToScopeChild resolves an element ID to a scope child ID.
564 // If the ID is directly in the set, returns it. If a parent is in the set,
565 // returns the parent. Returns "" if no match.
566 func resolveToScopeChild(id string, scopeChildren map[string]bool) string {
567 if scopeChildren[id] {
568 return id
569 }
570 // Walk up the hierarchy to find a scope child ancestor.
571 for {
572 dot := strings.LastIndex(id, ".")
573 if dot < 0 {
574 return ""
575 }
576 id = id[:dot]
577 if scopeChildren[id] {
578 return id
579 }
580 }
581 }
582
583 // hasChildInSet returns true if any key in the set is a child of id.
584 func hasChildInSet(id string, set map[string]bool) bool {
585 prefix := id + "."
586 for k := range set {
587 if strings.HasPrefix(k, prefix) {
588 return true
589 }
590 }
591 return false
592 }
593
594 // computeGridLayout arranges all elements in a simple grid.
595 func computeGridLayout(
596 ids []string,
597 flat map[string]*model.Element,
598 templates *drawio.TemplateSet,
599 ) layoutResult {
600 cfg := defaultLayoutConfig
601 result := layoutResult{Positions: make(map[string]position)}
602
603 sorted := make([]string, len(ids))
604 copy(sorted, ids)
605 sort.Strings(sorted)
606
607 // Determine max element width for column calculation.
608 maxW := 0.0
609 for _, id := range sorted {
610 w, _ := elementSize(id, flat, templates)
611 if w > maxW {
612 maxW = w
613 }
614 }
615
616 columns := int(math.Floor((cfg.pageWidth - 2*cfg.startX) / (maxW + cfg.elementGap)))
617 if columns < 1 {
618 columns = 1
619 }
620
621 col, row := 0, 0
622 for _, id := range sorted {
623 _, h := elementSize(id, flat, templates)
624 _ = h
625 x := cfg.startX + float64(col)*(maxW+cfg.elementGap)
626 y := cfg.startY + float64(row)*(defaultHeight+cfg.elementGap)
627 result.Positions[id] = position{X: x, Y: y}
628 col++
629 if col >= columns {
630 col = 0
631 row++
632 }
633 }
634
635 return result
636 }
637
638 // computeNoneLayout uses the legacy horizontal row placement.
639 func computeNoneLayout(
640 ids []string,
641 flat map[string]*model.Element,
642 templates *drawio.TemplateSet,
643 ) layoutResult {
644 cfg := defaultLayoutConfig
645 result := layoutResult{Positions: make(map[string]position)}
646
647 sorted := make([]string, len(ids))
648 copy(sorted, ids)
649 sort.Strings(sorted)
650
651 curX := cfg.startX
652 for _, id := range sorted {
653 w, _ := elementSize(id, flat, templates)
654 result.Positions[id] = position{X: curX, Y: cfg.startY}
655 curX += w + cfg.elementGap
656 }
657
658 return result
659 }
660
661 // elementSize returns the width and height for an element, based on its
662 // template style or default dimensions.
663 func elementSize(id string, flat map[string]*model.Element, templates *drawio.TemplateSet) (float64, float64) {
664 elem := flat[id]
665 if elem == nil {
666 return defaultWidth, defaultHeight
667 }
668 ts, ok := templates.GetStyle(elem.Kind)
669 if !ok {
670 return defaultWidth, defaultHeight
671 }
672 w := ts.Width
673 if w == 0 {
674 w = defaultWidth
675 }
676 h := ts.Height
677 if h == 0 {
678 h = defaultHeight
679 }
680 return w, h
681 }
682
github.com/docToolchain/Bausteinsicht/internal/sync/metadata.go 81.8%
1 package sync
2
3 import (
4 "fmt"
5 "html"
6 "sort"
7 "strconv"
8 "strings"
9
10 "github.com/beevik/etree"
11 "github.com/docToolchain/Bausteinsicht/internal/drawio"
12 "github.com/docToolchain/Bausteinsicht/internal/model"
13 )
14
15 const (
16 metadataPrefix = "metadata-"
17 legendPrefix = "legend-"
18 infoBoxGap = 80.0
19 metadataX = 40.0
20 metadataWidth = 500.0
21 legendWidthRatio = 0.30
22 legendGap = 60.0
23 )
24
25 // createMetadata creates or updates a metadata info box on the view page.
26 // Returns true if the label was created or changed.
27 func createMetadata(
28 page *drawio.Page,
29 viewID string,
30 view model.View,
31 cfg model.Config,
32 modelPath string,
33 timestamp string,
34 ) bool {
35 cellID := metadataPrefix + viewID
36 label := buildMetadataLabel(view, cfg, modelPath, timestamp)
37
38 root := page.Root()
39 if root == nil {
40 return false
41 }
42
43 // Update existing metadata cell — only if label actually changed.
44 for _, obj := range root.SelectElements("object") {
45 if obj.SelectAttrValue("id", "") == cellID {
46 if obj.SelectAttrValue("label", "") == label {
47 return false
48 }
49 obj.CreateAttr("label", label)
50 return true
51 }
52 }
53
54 // Create new metadata cell — 60% of content width.
55 legendCellID := legendPrefix + viewID
56 y := computeMaxY(page, cellID, legendCellID) + infoBoxGap
57 contentWidth := computeContentWidth(page, cellID, legendCellID)
58 if contentWidth < 600 {
59 contentWidth = 600
60 }
61 width := contentWidth * 0.60
62 createInfoBox(root, cellID, label, metadataX, y, width)
63 return true
64 }
65
66 // createLegend creates or updates a legend box on the view page.
67 // Returns true if the label was created or changed.
68 func createLegend(
69 page *drawio.Page,
70 viewID string,
71 spec model.Specification,
72 templates *drawio.TemplateSet,
73 elemSet map[string]bool,
74 flat map[string]*model.Element,
75 ) bool {
76 cellID := legendPrefix + viewID
77 label := buildLegendLabel(spec, templates, elemSet, flat)
78
79 root := page.Root()
80 if root == nil {
81 return false
82 }
83
84 // Update existing legend cell — only if label actually changed.
85 for _, obj := range root.SelectElements("object") {
86 if obj.SelectAttrValue("id", "") == cellID {
87 if obj.SelectAttrValue("label", "") == label {
88 return false
89 }
90 obj.CreateAttr("label", label)
91 return true
92 }
93 }
94
95 // Create new legend cell — positioned right of the metadata box, same Y.
96 metaCellID := metadataPrefix + viewID
97 y := computeMaxY(page, cellID, metaCellID) + infoBoxGap
98
99 // Compute content width for legend positioning.
100 contentWidth := computeContentWidth(page, cellID, metaCellID)
101 if contentWidth < 600 {
102 contentWidth = 600
103 }
104 legendWidth := contentWidth * legendWidthRatio
105 legendX := contentWidth - legendWidth + metadataX
106 createInfoBox(root, cellID, label, legendX, y, legendWidth)
107 return true
108 }
109
110 // buildMetadataLabel returns an HTML label for the metadata box.
111 func buildMetadataLabel(view model.View, cfg model.Config, modelPath string, timestamp string) string {
112 var sb strings.Builder
113 sb.WriteString("<b>")
114 sb.WriteString(html.EscapeString(view.Title))
115 sb.WriteString("</b>")
116 if view.Description != "" {
117 sb.WriteString("<br>")
118 sb.WriteString(html.EscapeString(view.Description))
119 }
120 sb.WriteString("<br><br>")
121 sb.WriteString("<font point-size=\"9\">")
122 if cfg.Repo != "" {
123 sb.WriteString("Repo: ")
124 sb.WriteString(html.EscapeString(cfg.Repo))
125 sb.WriteString("<br>")
126 }
127 sb.WriteString("Source: ")
128 sb.WriteString(html.EscapeString(modelPath))
129 sb.WriteString("<br>Last synced: ")
130 sb.WriteString(html.EscapeString(timestamp))
131 if cfg.Author != "" {
132 sb.WriteString("<br>Author: ")
133 sb.WriteString(html.EscapeString(cfg.Author))
134 }
135 sb.WriteString("<br>Generated by Bausteinsicht</font>")
136 return sb.String()
137 }
138
139 // buildLegendLabel returns an HTML label for the legend box.
140 func buildLegendLabel(
141 spec model.Specification,
142 templates *drawio.TemplateSet,
143 elemSet map[string]bool,
144 flat map[string]*model.Element,
145 ) string {
146 // Collect kinds actually used in this view.
147 usedKinds := make(map[string]bool)
148 for id := range elemSet {
149 if elem, ok := flat[id]; ok {
150 usedKinds[elem.Kind] = true
151 }
152 }
153
154 // Sort kinds for deterministic output.
155 kinds := make([]string, 0, len(usedKinds))
156 for k := range usedKinds {
157 kinds = append(kinds, k)
158 }
159 sort.Strings(kinds)
160
161 allStyles := templates.GetAllStyles()
162
163 var sb strings.Builder
164 sb.WriteString("<b>Legend</b>")
165
166 for _, kind := range kinds {
167 ekind, ok := spec.Elements[kind]
168 if !ok {
169 continue
170 }
171
172 color := extractFillColor(allStyles, kind)
173 sb.WriteString("<br>")
174 fmt.Fprintf(&sb,
175 "<font color=\"%s\">\u25a0</font> %s",
176 html.EscapeString(color),
177 html.EscapeString(ekind.Notation),
178 )
179 }
180
181 return sb.String()
182 }
183
184 // extractFillColor extracts the fillColor from a template style string.
185 // Returns a default gray if not found.
186 func extractFillColor(allStyles map[string]drawio.TemplateStyle, kind string) string {
187 ts, ok := allStyles[kind]
188 if !ok {
189 return "#666666"
190 }
191 for _, part := range strings.Split(ts.Style, ";") {
192 if strings.HasPrefix(part, "fillColor=") {
193 return strings.TrimPrefix(part, "fillColor=")
194 }
195 }
196 return "#666666"
197 }
198
199 // createInfoBox creates a non-model mxCell info box at the given position.
200 func createInfoBox(root *etree.Element, id, label string, x, y, width float64) {
201 obj := root.CreateElement("object")
202 obj.CreateAttr("label", label)
203 obj.CreateAttr("id", id)
204
205 cell := obj.CreateElement("mxCell")
206 cell.CreateAttr("style", "rounded=0;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=none;fontColor=#333333;fontSize=10;align=left;verticalAlign=top;")
207 cell.CreateAttr("vertex", "1")
208 cell.CreateAttr("parent", "1")
209
210 geo := cell.CreateElement("mxGeometry")
211 geo.CreateAttr("x", fmt.Sprintf("%.0f", x))
212 geo.CreateAttr("y", fmt.Sprintf("%.0f", y))
213 geo.CreateAttr("width", fmt.Sprintf("%.0f", width))
214 geo.CreateAttr("height", "120")
215 geo.CreateAttr("as", "geometry")
216 }
217
218 // computeMaxY finds the bottom-most Y coordinate of elements on the page,
219 // excluding the specified cell IDs (used to ignore metadata/legend boxes).
220 func computeMaxY(page *drawio.Page, excludeIDs ...string) float64 {
221 exclude := make(map[string]bool, len(excludeIDs))
222 for _, id := range excludeIDs {
223 exclude[id] = true
224 }
225
226 maxY := 0.0
227 root := page.Root()
228 if root == nil {
229 return maxY
230 }
231 for _, obj := range root.ChildElements() {
232 // Skip excluded cells.
233 if id := obj.SelectAttrValue("id", ""); exclude[id] {
234 continue
235 }
236 cell := obj.FindElement("mxCell")
237 if cell == nil {
238 if obj.Tag == "mxCell" {
239 cell = obj
240 } else {
241 continue
242 }
243 }
244 geo := cell.FindElement("mxGeometry")
245 if geo == nil {
246 continue
247 }
248 y := parseFloat(geo.SelectAttrValue("y", "0"))
249 h := parseFloat(geo.SelectAttrValue("height", "0"))
250 if bottom := y + h; bottom > maxY {
251 maxY = bottom
252 }
253 }
254 return maxY
255 }
256
257 // computeContentWidth finds the rightmost X+Width of elements on the page,
258 // excluding the specified cell IDs.
259 func computeContentWidth(page *drawio.Page, excludeIDs ...string) float64 {
260 exclude := make(map[string]bool, len(excludeIDs))
261 for _, id := range excludeIDs {
262 exclude[id] = true
263 }
264
265 maxRight := 0.0
266 root := page.Root()
267 if root == nil {
268 return maxRight
269 }
270 for _, obj := range root.ChildElements() {
271 if id := obj.SelectAttrValue("id", ""); exclude[id] {
272 continue
273 }
274 cell := obj.FindElement("mxCell")
275 if cell == nil {
276 if obj.Tag == "mxCell" {
277 cell = obj
278 } else {
279 continue
280 }
281 }
282 geo := cell.FindElement("mxGeometry")
283 if geo == nil {
284 continue
285 }
286 x := parseFloat(geo.SelectAttrValue("x", "0"))
287 w := parseFloat(geo.SelectAttrValue("width", "0"))
288 if right := x + w; right > maxRight {
289 maxRight = right
290 }
291 }
292 return maxRight
293 }
294
295 // parseFloat parses a string to float64, returning 0 on failure.
296 func parseFloat(s string) float64 {
297 f, _ := strconv.ParseFloat(s, 64)
298 return f
299 }
300
github.com/docToolchain/Bausteinsicht/internal/sync/patchops.go 81.5%
1 package sync
2
3 import (
4 "strings"
5
6 "github.com/docToolchain/Bausteinsicht/internal/model"
7 )
8
9 // ReversePatchOps converts the reverse (draw.io → model) changes in cs into
10 // PatchOps that can be applied to the JSONC file directly. Returns the ops
11 // and true if all changes are patchable (only element field modifications).
12 // Returns nil, false if any structural change (add/delete) or relationship
13 // change is present — the caller should fall back to full Save.
14 func ReversePatchOps(cs *ChangeSet) ([]model.PatchOp, bool) {
15 // Any relationship change or structural element change means we can't patch.
16 if len(cs.DrawioRelationshipChanges) > 0 {
17 return nil, false
18 }
19
20 var ops []model.PatchOp
21 for _, ch := range cs.DrawioElementChanges {
22 if ch.Type != Modified || ch.Field == "" {
23 return nil, false
24 }
25 path := elementFieldPath(ch.ID, ch.Field)
26 ops = append(ops, model.PatchOp{
27 Path: path,
28 Value: `"` + jsonEscapeString(ch.NewValue) + `"`,
29 })
30 }
31 return ops, true
32 }
33
34 // elementFieldPath converts a dot-separated element ID and field name into
35 // a JSON path. E.g., ("webshop.api", "technology") →
36 // ["model", "webshop", "children", "api", "technology"]
37 func elementFieldPath(id, field string) []string {
38 parts := strings.Split(id, ".")
39 path := []string{"model"}
40 for i, part := range parts {
41 path = append(path, part)
42 if i < len(parts)-1 {
43 path = append(path, "children")
44 }
45 }
46 path = append(path, field)
47 return path
48 }
49
50 // jsonEscapeString escapes special characters for JSON string values.
51 func jsonEscapeString(s string) string {
52 var b strings.Builder
53 for _, r := range s {
54 switch r {
55 case '"':
56 b.WriteString(`\"`)
57 case '\\':
58 b.WriteString(`\\`)
59 case '\n':
60 b.WriteString(`\n`)
61 case '\r':
62 b.WriteString(`\r`)
63 case '\t':
64 b.WriteString(`\t`)
65 default:
66 b.WriteRune(r)
67 }
68 }
69 return b.String()
70 }
71
github.com/docToolchain/Bausteinsicht/internal/sync/reverse.go 83.3%
1 package sync
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // maxReverseDepth limits recursion in modifyInMap/deleteFromMap to prevent stack overflow.
11 const maxReverseDepth = model.MaxElementDepth
12
13 // ReverseResult summarizes the changes applied back to the model.
14 type ReverseResult struct {
15 ElementsCreated int
16 ElementsUpdated int
17 ElementsDeleted int
18 RelationshipsCreated int
19 RelationshipsUpdated int
20 RelationshipsDeleted int
21 Warnings []string
22 }
23
24 // ApplyReverse applies draw.io-side changes back to the model.
25 func ApplyReverse(changes *ChangeSet, m *model.BausteinsichtModel) *ReverseResult {
26 result := &ReverseResult{}
27
28 // Pre-compute the flat model for accurate existence checks.
29 // m.Model is a top-level map, so a key like "parent.child" won't be found
30 // there directly. We need the flattened view to properly detect nested elements.
31 flatModel, _ := model.FlattenElements(m)
32
33 for _, ch := range changes.DrawioElementChanges {
34 applyElementChange(ch, m, flatModel, result)
35 }
36
37 // Detect direction-swap pairs (Deleted a→b + Added b→a) so we can
38 // update the relationship in-place and preserve metadata (#185).
39 swaps := detectRelSwaps(changes.DrawioRelationshipChanges)
40
41 for _, ch := range changes.DrawioRelationshipChanges {
42 if swaps[relSwapKey{ch.From, ch.To, ch.Type}] {
43 continue // handled as part of a swap pair
44 }
45 applyRelationshipChange(ch, m, flatModel, result)
46 }
47
48 // Apply swaps: update direction in-place, preserving kind/label/description.
49 for _, sw := range collectSwapPairs(changes.DrawioRelationshipChanges) {
50 applyRelSwap(sw.from, sw.to, m, result)
51 }
52
53 return result
54 }
55
56 type relSwapKey struct {
57 from, to string
58 typ ChangeType
59 }
60
61 type swapPair struct {
62 from, to string // new direction (from the Added change)
63 }
64
65 // detectRelSwaps returns a set of (from, to, type) triples that are part of
66 // a direction-swap pair. A swap is a Deleted(a→b) paired with Added(b→a).
67 func detectRelSwaps(changes []RelationshipChange) map[relSwapKey]bool {
68 deleted := make(map[[2]string]bool)
69 added := make(map[[2]string]bool)
70 for _, ch := range changes {
71 switch ch.Type {
72 case Deleted:
73 deleted[[2]string{ch.From, ch.To}] = true
74 case Added:
75 added[[2]string{ch.From, ch.To}] = true
76 }
77 }
78 result := make(map[relSwapKey]bool)
79 for pair := range deleted {
80 reverse := [2]string{pair[1], pair[0]}
81 if added[reverse] {
82 result[relSwapKey{pair[0], pair[1], Deleted}] = true
83 result[relSwapKey{pair[1], pair[0], Added}] = true
84 }
85 }
86 return result
87 }
88
89 // collectSwapPairs returns the new-direction pairs for detected swaps.
90 func collectSwapPairs(changes []RelationshipChange) []swapPair {
91 swaps := detectRelSwaps(changes)
92 var pairs []swapPair
93 for key := range swaps {
94 if key.typ == Added {
95 pairs = append(pairs, swapPair{from: key.from, to: key.to})
96 }
97 }
98 return pairs
99 }
100
101 // applyRelSwap updates a relationship's direction in-place, preserving metadata.
102 func applyRelSwap(newFrom, newTo string, m *model.BausteinsichtModel, result *ReverseResult) {
103 for i, r := range m.Relationships {
104 if r.From == newTo && r.To == newFrom {
105 m.Relationships[i].From = newFrom
106 m.Relationships[i].To = newTo
107 result.RelationshipsUpdated++
108 return
109 }
110 }
111 // Fallback: original already deleted somehow; create new.
112 m.Relationships = append(m.Relationships, model.Relationship{
113 From: newFrom,
114 To: newTo,
115 })
116 result.RelationshipsCreated++
117 }
118
119 func applyElementChange(ch ElementChange, m *model.BausteinsichtModel, flatModel map[string]*model.Element, result *ReverseResult) {
120 switch ch.Type {
121 case Modified:
122 // Reject empty title updates from draw.io (#150).
123 if ch.Field == "title" && strings.TrimSpace(ch.NewValue) == "" {
124 result.Warnings = append(result.Warnings,
125 fmt.Sprintf("Element %q: ignoring empty title from draw.io", ch.ID))
126 return
127 }
128 err := modifyElement(m, ch.ID, func(e *model.Element) {
129 switch ch.Field {
130 case "title":
131 e.Title = ch.NewValue
132 case "description":
133 e.Description = ch.NewValue
134 case "technology":
135 e.Technology = ch.NewValue
136 }
137 })
138 if err != nil {
139 result.Warnings = append(result.Warnings,
140 fmt.Sprintf("Element %q not found in model: %v", ch.ID, err))
141 return
142 }
143 result.ElementsUpdated++
144
145 case Deleted:
146 err := deleteElement(m, ch.ID)
147 if err != nil {
148 result.Warnings = append(result.Warnings,
149 fmt.Sprintf("Element %q could not be deleted: %v", ch.ID, err))
150 return
151 }
152 result.ElementsDeleted++
153 // Clean orphaned relationships referencing the deleted element (#266).
154 prefix := ch.ID + "."
155 var kept []model.Relationship
156 for _, r := range m.Relationships {
157 if r.From == ch.ID || r.To == ch.ID ||
158 strings.HasPrefix(r.From, prefix) || strings.HasPrefix(r.To, prefix) {
159 result.RelationshipsDeleted++
160 continue
161 }
162 kept = append(kept, r)
163 }
164 m.Relationships = kept
165 // Clean stale references from view include/exclude lists.
166 for viewID, v := range m.Views {
167 v.Include = removeFromSlice(v.Include, ch.ID)
168 v.Exclude = removeFromSlice(v.Exclude, ch.ID)
169 m.Views[viewID] = v
170 }
171 result.Warnings = append(result.Warnings,
172 fmt.Sprintf("Element %q was deleted in draw.io and removed from model", ch.ID))
173
174 case Added:
175 if m.Model == nil {
176 m.Model = make(map[string]model.Element)
177 }
178 // Use the pre-computed flat model to check existence across the full
179 // hierarchy. A naive m.Model[ch.ID] check misses nested elements whose
180 // IDs are dot-paths (e.g. "parent.child"), causing them to be
181 // re-created as spurious top-level entries with empty titles (#307).
182 if _, exists := flatModel[ch.ID]; exists {
183 result.Warnings = append(result.Warnings,
184 fmt.Sprintf("New element %q from draw.io skipped: ID already exists in model.", ch.ID))
185 return
186 }
187 kind := firstSpecKind(m)
188 m.Model[ch.ID] = model.Element{
189 Kind: kind,
190 Title: ch.NewValue,
191 }
192 result.ElementsCreated++
193 result.Warnings = append(result.Warnings,
194 fmt.Sprintf("New element %q added from draw.io (kind=%q) — review and assign a meaningful ID and kind.", ch.ID, kind))
195 }
196 }
197
198 func applyRelationshipChange(ch RelationshipChange, m *model.BausteinsichtModel, flatModel map[string]*model.Element, result *ReverseResult) {
199 switch ch.Type {
200 case Modified:
201 updated := false
202 if ch.Index >= 0 && ch.Index < len(m.Relationships) {
203 r := m.Relationships[ch.Index]
204 if r.From == ch.From && r.To == ch.To {
205 if ch.Field == "label" {
206 m.Relationships[ch.Index].Label = ch.NewValue
207 }
208 updated = true
209 }
210 }
211 // Fallback: search by from/to if index does not match.
212 if !updated {
213 for i, r := range m.Relationships {
214 if r.From == ch.From && r.To == ch.To {
215 if ch.Field == "label" {
216 m.Relationships[i].Label = ch.NewValue
217 }
218 updated = true
219 break
220 }
221 }
222 }
223 if updated {
224 result.RelationshipsUpdated++
225 } else {
226 result.Warnings = append(result.Warnings,
227 fmt.Sprintf("Relationship %q->%q not found in model", ch.From, ch.To))
228 }
229
230 case Deleted:
231 before := len(m.Relationships)
232 if ch.Index >= 0 && ch.Index < len(m.Relationships) {
233 r := m.Relationships[ch.Index]
234 if r.From == ch.From && r.To == ch.To {
235 m.Relationships = append(m.Relationships[:ch.Index], m.Relationships[ch.Index+1:]...)
236 } else {
237 // Fallback: filter by from/to.
238 m.Relationships = filterRelationships(m.Relationships, ch.From, ch.To)
239 }
240 } else {
241 m.Relationships = filterRelationships(m.Relationships, ch.From, ch.To)
242 }
243 if len(m.Relationships) < before {
244 result.RelationshipsDeleted++
245 }
246
247 case Added:
248 // Validate that both endpoints exist in the model before adding (#329).
249 // Prevents stale relationships from old draw.io files being imported after model replacement.
250 if _, fromExists := flatModel[ch.From]; !fromExists {
251 result.Warnings = append(result.Warnings,
252 fmt.Sprintf("Relationship %q->%q rejected: From element %q does not exist in model", ch.From, ch.To, ch.From))
253 return
254 }
255 if _, toExists := flatModel[ch.To]; !toExists {
256 result.Warnings = append(result.Warnings,
257 fmt.Sprintf("Relationship %q->%q rejected: To element %q does not exist in model", ch.From, ch.To, ch.To))
258 return
259 }
260 m.Relationships = append(m.Relationships, model.Relationship{
261 From: ch.From,
262 To: ch.To,
263 Label: ch.NewValue,
264 })
265 result.RelationshipsCreated++
266 }
267 }
268
269 // filterRelationships returns all relationships except those matching from/to.
270 func filterRelationships(rels []model.Relationship, from, to string) []model.Relationship {
271 result := make([]model.Relationship, 0, len(rels))
272 for _, r := range rels {
273 if r.From != from || r.To != to {
274 result = append(result, r)
275 }
276 }
277 return result
278 }
279
280 // modifyElement finds an element by dot-notation ID and applies fn to it.
281 func modifyElement(m *model.BausteinsichtModel, id string, fn func(*model.Element)) error {
282 parts := strings.Split(id, ".")
283 return modifyInMap(m.Model, parts, id, fn)
284 }
285
286 // modifyInMap recursively walks the map to find and modify the target element.
287 func modifyInMap(elems map[string]model.Element, parts []string, fullID string, fn func(*model.Element)) error {
288 if len(parts) == 0 {
289 return fmt.Errorf("empty path")
290 }
291 if len(parts) > maxReverseDepth {
292 return fmt.Errorf("element path %q exceeds maximum depth of %d", fullID, maxReverseDepth)
293 }
294 key := parts[0]
295 elem, ok := elems[key]
296 if !ok {
297 return fmt.Errorf("element %q not found", fullID)
298 }
299 if len(parts) == 1 {
300 fn(&elem)
301 elems[key] = elem
302 return nil
303 }
304 if elem.Children == nil {
305 return fmt.Errorf("element %q not found: no children at this level", fullID)
306 }
307 if err := modifyInMap(elem.Children, parts[1:], fullID, fn); err != nil {
308 return err
309 }
310 elems[key] = elem
311 return nil
312 }
313
314 // deleteElement removes an element by dot-notation ID from the model hierarchy.
315 func deleteElement(m *model.BausteinsichtModel, id string) error {
316 parts := strings.Split(id, ".")
317 return deleteFromMap(m.Model, parts, id)
318 }
319
320 // deleteFromMap recursively walks to find the parent map and deletes the element.
321 func deleteFromMap(elems map[string]model.Element, parts []string, fullID string) error {
322 if len(parts) == 0 {
323 return fmt.Errorf("empty path")
324 }
325 if len(parts) > maxReverseDepth {
326 return fmt.Errorf("element path %q exceeds maximum depth of %d", fullID, maxReverseDepth)
327 }
328 key := parts[0]
329 if len(parts) == 1 {
330 if _, ok := elems[key]; !ok {
331 return fmt.Errorf("element %q not found", fullID)
332 }
333 delete(elems, key)
334 return nil
335 }
336 elem, ok := elems[key]
337 if !ok {
338 return fmt.Errorf("element %q not found", fullID)
339 }
340 if elem.Children == nil {
341 return fmt.Errorf("element %q not found: no children at this level", fullID)
342 }
343 if err := deleteFromMap(elem.Children, parts[1:], fullID); err != nil {
344 return err
345 }
346 elems[key] = elem
347 return nil
348 }
349
350 // firstSpecKind returns the first element kind defined in the specification,
351 // sorted alphabetically for determinism. Returns "" if no kinds are defined.
352 func firstSpecKind(m *model.BausteinsichtModel) string {
353 if len(m.Specification.Elements) == 0 {
354 return ""
355 }
356 var best string
357 for k := range m.Specification.Elements {
358 if best == "" || k < best {
359 best = k
360 }
361 }
362 return best
363 }
364
365 // removeFromSlice returns a new slice with all occurrences of val removed.
366 func removeFromSlice(s []string, val string) []string {
367 result := make([]string, 0, len(s))
368 for _, v := range s {
369 if v != val {
370 result = append(result, v)
371 }
372 }
373 if len(result) == 0 {
374 return nil
375 }
376 return result
377 }
378
github.com/docToolchain/Bausteinsicht/internal/sync/state.go 71.8%
1 // Package sync handles bidirectional synchronization between the model and draw.io files.
2 package sync
3
4 import (
5 "crypto/sha256"
6 "encoding/json"
7 "fmt"
8 "os"
9 "path/filepath"
10 "time"
11
12 "github.com/docToolchain/Bausteinsicht/internal/drawio"
13 "github.com/docToolchain/Bausteinsicht/internal/model"
14 )
15
16 // SyncState stores the state after each successful sync.
17 type SyncState struct {
18 Checksum string `json:"checksum,omitempty"`
19 Timestamp string `json:"timestamp"`
20 ModelHash string `json:"model_hash"`
21 DrawioHash string `json:"drawio_hash"`
22 Elements map[string]ElementState `json:"elements"`
23 Relationships []RelationshipState `json:"relationships"`
24 RenderedElements map[string]bool `json:"rendered_elements,omitempty"`
25 }
26
27 // ElementState captures an element's synced values.
28 type ElementState struct {
29 Title string `json:"title"`
30 Description string `json:"description,omitempty"`
31 Technology string `json:"technology,omitempty"`
32 Kind string `json:"kind"`
33 }
34
35 // RelationshipState captures a relationship's synced values.
36 type RelationshipState struct {
37 From string `json:"from"`
38 To string `json:"to"`
39 Index int `json:"index"`
40 Label string `json:"label,omitempty"`
41 Kind string `json:"kind,omitempty"`
42 }
43
44 // LoadState reads a SyncState from the given path.
45 // If the file does not exist, an empty SyncState is returned (first-sync scenario).
46 func LoadState(path string) (*SyncState, error) {
47 data, err := os.ReadFile(path) // #nosec G304 -- path derived from model location
48 if err != nil {
49 if os.IsNotExist(err) {
50 return &SyncState{
51 Elements: make(map[string]ElementState),
52 Relationships: []RelationshipState{},
53 }, nil
54 }
55 return nil, fmt.Errorf("LoadState %q: %w", path, err)
56 }
57
58 // Treat a zero-byte file as empty/missing state (e.g. truncated write).
59 if len(data) == 0 {
60 return &SyncState{
61 Elements: make(map[string]ElementState),
62 Relationships: []RelationshipState{},
63 }, nil
64 }
65
66 var state SyncState
67 if err := json.Unmarshal(data, &state); err != nil {
68 return nil, fmt.Errorf("LoadState %q: %w", path, err)
69 }
70
71 // Verify integrity checksum if present (backward compat: old files without checksum skip validation).
72 if state.Checksum != "" {
73 savedChecksum := state.Checksum
74 state.Checksum = ""
75 canonical, err := json.Marshal(&state)
76 if err != nil {
77 return nil, fmt.Errorf("LoadState %q: checksum verification marshal: %w", path, err)
78 }
79 sum := sha256.Sum256(canonical)
80 computed := fmt.Sprintf("sha256:%x", sum)
81 if computed != savedChecksum {
82 return nil, fmt.Errorf("LoadState %q: sync state corrupted (checksum mismatch); delete .bausteinsicht-sync and re-run sync", path)
83 }
84 }
85
86 if state.Elements == nil {
87 state.Elements = make(map[string]ElementState)
88 }
89 if state.Relationships == nil {
90 state.Relationships = []RelationshipState{}
91 }
92 return &state, nil
93 }
94
95 // SaveState atomically writes state to path using a temp file + rename.
96 func SaveState(path string, state *SyncState) error {
97 // Compute integrity checksum: marshal without checksum → hash → set checksum → marshal with checksum.
98 state.Checksum = ""
99 canonical, err := json.Marshal(state)
100 if err != nil {
101 return fmt.Errorf("SaveState checksum marshal: %w", err)
102 }
103 sum := sha256.Sum256(canonical)
104 state.Checksum = fmt.Sprintf("sha256:%x", sum)
105
106 data, err := json.MarshalIndent(state, "", " ")
107 if err != nil {
108 return fmt.Errorf("SaveState marshal: %w", err)
109 }
110
111 dir := filepath.Dir(path)
112 tmp, err := os.CreateTemp(dir, ".bausteinsicht-sync-tmp-*")
113 if err != nil {
114 return fmt.Errorf("SaveState create temp: %w", err)
115 }
116 tmpName := tmp.Name()
117
118 if _, err := tmp.Write(data); err != nil {
119 _ = tmp.Close()
120 _ = os.Remove(tmpName)
121 return fmt.Errorf("SaveState write: %w", err)
122 }
123 if err := tmp.Close(); err != nil {
124 _ = os.Remove(tmpName)
125 return fmt.Errorf("SaveState close: %w", err)
126 }
127 if err := os.Rename(tmpName, path); err != nil {
128 _ = os.Remove(tmpName)
129 return fmt.Errorf("SaveState rename: %w", err)
130 }
131 return nil
132 }
133
134 // ComputeHash reads the file at path and returns a "sha256:<hex>" fingerprint.
135 func ComputeHash(path string) (string, error) {
136 data, err := os.ReadFile(path) // #nosec G304 -- path derived from model location
137 if err != nil {
138 return "", fmt.Errorf("ComputeHash %q: %w", path, err)
139 }
140 sum := sha256.Sum256(data)
141 return fmt.Sprintf("sha256:%x", sum), nil
142 }
143
144 // BuildState creates a SyncState snapshot from the current model and draw.io document.
145 func BuildState(m *model.BausteinsichtModel, doc *drawio.Document, modelPath, drawioPath string) (*SyncState, error) {
146 modelHash, err := ComputeHash(modelPath)
147 if err != nil {
148 return nil, fmt.Errorf("BuildState model hash: %w", err)
149 }
150
151 drawioHash, err := ComputeHash(drawioPath)
152 if err != nil {
153 return nil, fmt.Errorf("BuildState drawio hash: %w", err)
154 }
155
156 flat, err := model.FlattenElements(m)
157 if err != nil {
158 return nil, fmt.Errorf("BuildState flatten: %w", err)
159 }
160 elements := make(map[string]ElementState, len(flat))
161 for id, elem := range flat {
162 elements[id] = ElementState{
163 Title: elem.Title,
164 Description: elem.Description,
165 Technology: elem.Technology,
166 Kind: elem.Kind,
167 }
168 }
169
170 rels := make([]RelationshipState, 0, len(m.Relationships))
171 for i, r := range m.Relationships {
172 rels = append(rels, RelationshipState{
173 From: r.From,
174 To: r.To,
175 Index: i,
176 Label: r.Label,
177 Kind: r.Kind,
178 })
179 }
180
181 // Record which elements are actually present on draw.io pages.
182 // This allows deletion detection to distinguish "user deleted from draw.io"
183 // from "element was never rendered because views didn't include it" (#240).
184 rendered := make(map[string]bool)
185 if doc != nil {
186 for _, page := range doc.Pages() {
187 for _, obj := range page.FindAllElements() {
188 id := obj.SelectAttrValue("bausteinsicht_id", "")
189 if id != "" {
190 rendered[id] = true
191 }
192 }
193 }
194 }
195
196 return &SyncState{
197 Timestamp: time.Now().UTC().Format(time.RFC3339),
198 ModelHash: modelHash,
199 DrawioHash: drawioHash,
200 Elements: elements,
201 Relationships: rels,
202 RenderedElements: rendered,
203 }, nil
204 }
205
github.com/docToolchain/Bausteinsicht/internal/table/table.go 61.2%
1 package table
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/docToolchain/Bausteinsicht/internal/model"
9 )
10
11 // Format represents the output format for table export.
12 type Format int
13
14 const (
15 AsciiDoc Format = iota
16 Markdown
17 )
18
19 // Row represents a single element row in the table export.
20 type Row struct {
21 ID string `json:"id"`
22 Title string `json:"title"`
23 Kind string `json:"kind"`
24 Technology string `json:"technology,omitempty"`
25 Description string `json:"description,omitempty"`
26 }
27
28 // FormatView renders a single view's elements as a table.
29 func FormatView(m *model.BausteinsichtModel, viewKey string, f Format) (string, error) {
30 view, ok := m.Views[viewKey]
31 if !ok {
32 return "", fmt.Errorf("view %q not found", viewKey)
33 }
34
35 rows, err := resolveRows(m, &view)
36 if err != nil {
37 return "", err
38 }
39
40 var b strings.Builder
41 writeTitle(&b, view.Title, f)
42 writeTable(&b, rows, f)
43 return b.String(), nil
44 }
45
46 // FormatAllViews renders all views as tables in a single document.
47 func FormatAllViews(m *model.BausteinsichtModel, f Format) (string, error) {
48 keys := sortedViewKeys(m)
49 var b strings.Builder
50 for i, key := range keys {
51 if i > 0 {
52 b.WriteString("\n")
53 }
54 view, ok := m.Views[key]
55 if !ok {
56 continue
57 }
58 rows, err := resolveRows(m, &view)
59 if err != nil {
60 return "", err
61 }
62 writeTitle(&b, view.Title, f)
63 writeTable(&b, rows, f)
64 }
65 return b.String(), nil
66 }
67
68 // FormatCombined renders all elements across all views (deduplicated) as a single table.
69 func FormatCombined(m *model.BausteinsichtModel, f Format) (string, error) {
70 seen := make(map[string]bool)
71 var rows []Row
72
73 flat, _ := model.FlattenElements(m)
74 keys := sortedViewKeys(m)
75
76 for _, key := range keys {
77 view, ok := m.Views[key]
78 if !ok {
79 continue
80 }
81 v := view
82 resolved, err := model.ResolveView(m, &v)
83 if err != nil {
84 continue
85 }
86 for _, id := range resolved {
87 if seen[id] {
88 continue
89 }
90 seen[id] = true
91 elem := flat[id]
92 if elem == nil {
93 continue
94 }
95 rows = append(rows, Row{
96 ID: id,
97 Title: elem.Title,
98 Kind: elem.Kind,
99 Technology: elem.Technology,
100 Description: elem.Description,
101 })
102 }
103 }
104
105 sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
106
107 var b strings.Builder
108 writeTitle(&b, "All Elements", f)
109 writeTable(&b, rows, f)
110 return b.String(), nil
111 }
112
113 func resolveRows(m *model.BausteinsichtModel, view *model.View) ([]Row, error) {
114 resolved, err := model.ResolveView(m, view)
115 if err != nil {
116 return nil, err
117 }
118
119 flat, _ := model.FlattenElements(m)
120 sort.Strings(resolved)
121
122 var rows []Row
123 for _, id := range resolved {
124 elem := flat[id]
125 if elem == nil {
126 continue
127 }
128 rows = append(rows, Row{
129 ID: id,
130 Title: elem.Title,
131 Kind: elem.Kind,
132 Technology: elem.Technology,
133 Description: elem.Description,
134 })
135 }
136 return rows, nil
137 }
138
139 func writeTitle(b *strings.Builder, title string, f Format) {
140 switch f {
141 case AsciiDoc:
142 fmt.Fprintf(b, "=== %s\n\n", title)
143 case Markdown:
144 fmt.Fprintf(b, "### %s\n\n", title)
145 }
146 }
147
148 func writeTable(b *strings.Builder, rows []Row, f Format) {
149 switch f {
150 case AsciiDoc:
151 writeAsciiDocTable(b, rows)
152 case Markdown:
153 writeMarkdownTable(b, rows)
154 }
155 }
156
157 func writeAsciiDocTable(b *strings.Builder, rows []Row) {
158 b.WriteString("[cols=\"2,1,1,3\"]\n|===\n")
159 b.WriteString("| Element | Kind | Technology | Description\n\n")
160 for _, r := range rows {
161 fmt.Fprintf(b, "| %s\n| %s\n| %s\n| %s\n\n", r.Title, r.Kind, r.Technology, r.Description)
162 }
163 b.WriteString("|===\n")
164 }
165
166 func writeMarkdownTable(b *strings.Builder, rows []Row) {
167 b.WriteString("| Element | Kind | Technology | Description |\n")
168 b.WriteString("|---------|------|------------|-------------|\n")
169 for _, r := range rows {
170 fmt.Fprintf(b, "| %s | %s | %s | %s |\n", r.Title, r.Kind, r.Technology, r.Description)
171 }
172 }
173
174 // CollectRows returns the row data for JSON export.
175 // If viewKey is set, only that view's rows are returned.
176 // If combined is true, all views are merged (deduplicated).
177 // Otherwise, all views' rows are returned.
178 func CollectRows(m *model.BausteinsichtModel, viewKey string, combined bool) ([]Row, error) {
179 switch {
180 case combined:
181 return collectCombinedRows(m)
182 case viewKey != "":
183 view, ok := m.Views[viewKey]
184 if !ok {
185 return nil, fmt.Errorf("view %q not found", viewKey)
186 }
187 return resolveRows(m, &view)
188 default:
189 return collectAllRows(m)
190 }
191 }
192
193 func collectCombinedRows(m *model.BausteinsichtModel) ([]Row, error) {
194 seen := make(map[string]bool)
195 var rows []Row
196 flat, _ := model.FlattenElements(m)
197 for _, key := range sortedViewKeys(m) {
198 view, ok := m.Views[key]
199 if !ok {
200 continue
201 }
202 v := view
203 resolved, err := model.ResolveView(m, &v)
204 if err != nil {
205 continue
206 }
207 for _, id := range resolved {
208 if seen[id] {
209 continue
210 }
211 seen[id] = true
212 elem := flat[id]
213 if elem == nil {
214 continue
215 }
216 rows = append(rows, Row{
217 ID: id, Title: elem.Title, Kind: elem.Kind,
218 Technology: elem.Technology, Description: elem.Description,
219 })
220 }
221 }
222 sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
223 return rows, nil
224 }
225
226 func collectAllRows(m *model.BausteinsichtModel) ([]Row, error) {
227 var rows []Row
228 for _, key := range sortedViewKeys(m) {
229 view, ok := m.Views[key]
230 if !ok {
231 continue
232 }
233 viewRows, err := resolveRows(m, &view)
234 if err != nil {
235 return nil, err
236 }
237 rows = append(rows, viewRows...)
238 }
239 return rows, nil
240 }
241
242 func sortedViewKeys(m *model.BausteinsichtModel) []string {
243 keys := make([]string, 0, len(m.Views))
244 for k := range m.Views {
245 keys = append(keys, k)
246 }
247 sort.Strings(keys)
248 return keys
249 }
250
github.com/docToolchain/Bausteinsicht/internal/template/generator.go 96.1%
1 package template
2
3 import (
4 "bytes"
5 "fmt"
6 "sort"
7 "strings"
8
9 etree "github.com/beevik/etree"
10 "github.com/docToolchain/Bausteinsicht/internal/model"
11 )
12
13 // Generator creates a draw.io template from an element specification.
14 type Generator struct {
15 spec model.Specification
16 style string
17 nextID int
18 }
19
20 // NewGenerator creates a new template generator.
21 func NewGenerator(spec model.Specification, style string) *Generator {
22 if style == "" {
23 style = DefaultStyle
24 }
25 return &Generator{
26 spec: spec,
27 style: style,
28 nextID: 2,
29 }
30 }
31
32 // Generate produces the draw.io template XML as a complete mxfile.
33 func (g *Generator) Generate() string {
34 doc := etree.NewDocument()
35 doc.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
36
37 mxfile := doc.CreateElement("mxfile")
38 mxfile.CreateAttr("host", "app.diagrams.net")
39 mxfile.CreateAttr("modified", "2026-01-01T00:00:00.000Z")
40 mxfile.CreateAttr("agent", "Bausteinsicht")
41 mxfile.CreateAttr("version", "1.0")
42 mxfile.CreateAttr("type", "device")
43
44 diagram := mxfile.CreateElement("diagram")
45 diagram.CreateAttr("id", "1")
46 diagram.CreateAttr("name", "Template")
47
48 root := diagram.CreateElement("mxGraphModel")
49 root.CreateAttr("dx", "0")
50 root.CreateAttr("dy", "0")
51 root.CreateAttr("grid", "1")
52 root.CreateAttr("gridSize", "10")
53 root.CreateAttr("guides", "1")
54 root.CreateAttr("tooltips", "1")
55 root.CreateAttr("connect", "1")
56 root.CreateAttr("arrows", "1")
57 root.CreateAttr("fold", "1")
58 root.CreateAttr("page", "1")
59 root.CreateAttr("pageScale", "1")
60 root.CreateAttr("pageWidth", "827")
61 root.CreateAttr("pageHeight", "1169")
62 root.CreateAttr("background", "#ffffff")
63 root.CreateAttr("math", "0")
64 root.CreateAttr("shadow", "0")
65
66 rootElem := root.CreateElement("root")
67 cell0 := rootElem.CreateElement("mxCell")
68 cell0.CreateAttr("id", "0")
69 cell1 := rootElem.CreateElement("mxCell")
70 cell1.CreateAttr("id", "1")
71 cell1.CreateAttr("parent", "0")
72
73 g.nextID = 2
74
75 // Collect kinds in sorted order
76 var kinds []string
77 for kind := range g.spec.Elements {
78 kinds = append(kinds, kind)
79 }
80
81 // Sort for consistent output
82 sort.Strings(kinds)
83
84 // Layout elements in grid (4 columns)
85 layout := GridLayout(kinds, 4)
86
87 for _, elem := range layout {
88 g.addElement(rootElem, elem.Kind, elem.Position.X, elem.Position.Y)
89 }
90
91 doc.Indent(2)
92 var buf bytes.Buffer
93 if _, err := doc.WriteTo(&buf); err != nil {
94 return ""
95 }
96 return buf.String()
97 }
98
99 func (g *Generator) addElement(parent *etree.Element, kind string, x, y int) {
100 cfg := DefaultShapeConfig(kind)
101 colors := ColorForKind(g.style, kind)
102 elementSpec := g.spec.Elements[kind]
103
104 // Create label with kind name and type
105 kindTitle := strings.ToUpper(kind[:1]) + kind[1:]
106 label := fmt.Sprintf("<b>%s</b><br/>[%s]", kindTitle, kind)
107
108 // Build style string
109 style := g.buildStyle(cfg, colors, elementSpec)
110
111 cell := parent.CreateElement("mxCell")
112 cell.CreateAttr("id", fmt.Sprintf("%d", g.nextID))
113 g.nextID++
114 cell.CreateAttr("value", label) // Don't escape: draw.io uses html=1 and requires raw HTML markup for rich text
115 cell.CreateAttr("style", style+";html=1") // Enable HTML rendering in draw.io
116 cell.CreateAttr("vertex", "1")
117 cell.CreateAttr("parent", "1")
118
119 geometry := cell.CreateElement("mxGeometry")
120 geometry.CreateAttr("x", fmt.Sprintf("%d", x))
121 geometry.CreateAttr("y", fmt.Sprintf("%d", y))
122 geometry.CreateAttr("width", fmt.Sprintf("%d", cfg.Width))
123 geometry.CreateAttr("height", fmt.Sprintf("%d", cfg.Height))
124 geometry.CreateAttr("as", "geometry")
125 }
126
127 func (g *Generator) buildStyle(cfg ShapeConfig, colors ColorStyle, _ model.ElementKind) string {
128 parts := []string{
129 fmt.Sprintf("fillColor=%s", colors.Fill),
130 fmt.Sprintf("strokeColor=%s", colors.Stroke),
131 "fontColor=#000000",
132 "fontSize=12",
133 }
134
135 // Add shape if specified
136 if cfg.Shape != "" {
137 if strings.HasPrefix(cfg.Shape, "shape=") {
138 parts = append(parts, cfg.Shape)
139 } else if !strings.Contains(cfg.Shape, "=") {
140 parts = append(parts, fmt.Sprintf("shape=%s", cfg.Shape))
141 } else {
142 parts = append(parts, cfg.Shape)
143 }
144 }
145
146 return strings.Join(parts, ";")
147 }
148
github.com/docToolchain/Bausteinsicht/internal/template/layout.go 94.7%
1 package template
2
3 // Position represents the X, Y coordinates of an element.
4 type Position struct {
5 X int
6 Y int
7 }
8
9 // Element holds a kind and its position.
10 type Element struct {
11 Kind string
12 Position Position
13 }
14
15 // GridLayout arranges elements in a grid.
16 func GridLayout(kinds []string, cols int) []Element {
17 if cols <= 0 {
18 cols = 4
19 }
20
21 var elements []Element
22 x, y := 40, 40
23 colCount := 0
24 maxHeight := 0
25
26 for _, kind := range kinds {
27 cfg := DefaultShapeConfig(kind)
28 elements = append(elements, Element{
29 Kind: kind,
30 Position: Position{X: x, Y: y},
31 })
32
33 if cfg.Height > maxHeight {
34 maxHeight = cfg.Height
35 }
36
37 colCount++
38 if colCount >= cols {
39 x = 40
40 y += maxHeight + 40
41 colCount = 0
42 maxHeight = 0
43 } else {
44 x += cfg.Width + 40
45 }
46 }
47
48 return elements
49 }
50
github.com/docToolchain/Bausteinsicht/internal/template/shapes.go 100.0%
1 package template
2
3 // ShapeConfig defines the draw.io shape and dimensions for a kind.
4 type ShapeConfig struct {
5 Shape string
6 Width int
7 Height int
8 }
9
10 // KindShapes maps element kinds to their shape configurations.
11 var KindShapes = map[string]ShapeConfig{
12 "person": {Shape: "mxgraph.archimate3.actor", Width: 60, Height: 80},
13 "actor": {Shape: "mxgraph.archimate3.actor", Width: 60, Height: 80},
14 "system": {Shape: "rounded=1", Width: 160, Height: 60},
15 "service": {Shape: "rounded=1", Width: 120, Height: 60},
16 "container": {Shape: "rounded=1;container=1", Width: 200, Height: 120},
17 "database": {Shape: "mxgraph.flowchart.database", Width: 60, Height: 80},
18 "datastore": {Shape: "mxgraph.flowchart.database", Width: 60, Height: 80},
19 "cache": {Shape: "mxgraph.flowchart.stored_data", Width: 80, Height: 60},
20 "queue": {Shape: "mxgraph.flowchart.process", Width: 120, Height: 60},
21 "filestore": {Shape: "mxgraph.flowchart.stored_data", Width: 80, Height: 60},
22 "component": {Shape: "rounded=1", Width: 120, Height: 60},
23 "frontend": {Shape: "rounded=1", Width: 120, Height: 60},
24 "mobile": {Shape: "mxgraph.iphone.phone3", Width: 60, Height: 100},
25 "ui": {Shape: "rounded=1", Width: 120, Height: 60},
26 "external_system": {Shape: "dashed=1;dashPattern=5 5;rounded=1", Width: 160, Height: 60},
27 }
28
29 // ColorStyle defines fill and stroke colors for a style preset.
30 type ColorStyle struct {
31 Fill string
32 Stroke string
33 }
34
35 // StylePresets defines the visual presets for different kinds.
36 var StylePresets = map[string]map[string]ColorStyle{
37 "default": {
38 "person": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
39 "actor": {Fill: "#dae8fc", Stroke: "#6c8ebf"},
40 "system": {Fill: "#d5e8d4", Stroke: "#82b366"},
41 "service": {Fill: "#d5e8d4", Stroke: "#82b366"},
42 "container": {Fill: "#d5e8d4", Stroke: "#82b366"},
43 "database": {Fill: "#fff2cc", Stroke: "#d6b656"},
44 "datastore": {Fill: "#fff2cc", Stroke: "#d6b656"},
45 "cache": {Fill: "#fff2cc", Stroke: "#d6b656"},
46 "queue": {Fill: "#f8cecc", Stroke: "#b85450"},
47 "filestore": {Fill: "#fff2cc", Stroke: "#d6b656"},
48 "component": {Fill: "#d5e8d4", Stroke: "#82b366"},
49 "frontend": {Fill: "#e1d5e7", Stroke: "#9673a6"},
50 "mobile": {Fill: "#e1d5e7", Stroke: "#9673a6"},
51 "ui": {Fill: "#e1d5e7", Stroke: "#9673a6"},
52 "external_system": {Fill: "#f5f5f5", Stroke: "#999999"},
53 },
54 "c4": {
55 "person": {Fill: "#08427b", Stroke: "#08427b"},
56 "actor": {Fill: "#08427b", Stroke: "#08427b"},
57 "system": {Fill: "#1168bd", Stroke: "#0b4884"},
58 "service": {Fill: "#1168bd", Stroke: "#0b4884"},
59 "container": {Fill: "#1168bd", Stroke: "#0b4884"},
60 "database": {Fill: "#438dd5", Stroke: "#3c7fc0"},
61 "datastore": {Fill: "#438dd5", Stroke: "#3c7fc0"},
62 "cache": {Fill: "#438dd5", Stroke: "#3c7fc0"},
63 "queue": {Fill: "#999999", Stroke: "#666666"},
64 "filestore": {Fill: "#438dd5", Stroke: "#3c7fc0"},
65 "component": {Fill: "#438dd5", Stroke: "#3c7fc0"},
66 "frontend": {Fill: "#1168bd", Stroke: "#0b4884"},
67 "mobile": {Fill: "#1168bd", Stroke: "#0b4884"},
68 "ui": {Fill: "#1168bd", Stroke: "#0b4884"},
69 "external_system": {Fill: "#999999", Stroke: "#666666"},
70 },
71 "minimal": {
72 "person": {Fill: "#ffffff", Stroke: "#999999"},
73 "actor": {Fill: "#ffffff", Stroke: "#999999"},
74 "system": {Fill: "#ffffff", Stroke: "#999999"},
75 "service": {Fill: "#ffffff", Stroke: "#999999"},
76 "container": {Fill: "#ffffff", Stroke: "#999999"},
77 "database": {Fill: "#ffffff", Stroke: "#999999"},
78 "datastore": {Fill: "#ffffff", Stroke: "#999999"},
79 "cache": {Fill: "#ffffff", Stroke: "#999999"},
80 "queue": {Fill: "#ffffff", Stroke: "#999999"},
81 "filestore": {Fill: "#ffffff", Stroke: "#999999"},
82 "component": {Fill: "#ffffff", Stroke: "#999999"},
83 "frontend": {Fill: "#ffffff", Stroke: "#999999"},
84 "mobile": {Fill: "#ffffff", Stroke: "#999999"},
85 "ui": {Fill: "#ffffff", Stroke: "#999999"},
86 "external_system": {Fill: "#ffffff", Stroke: "#999999"},
87 },
88 "dark": {
89 "person": {Fill: "#ffb74d", Stroke: "#ff8a00"},
90 "actor": {Fill: "#ffb74d", Stroke: "#ff8a00"},
91 "system": {Fill: "#4dd0e1", Stroke: "#00acc1"},
92 "service": {Fill: "#4dd0e1", Stroke: "#00acc1"},
93 "container": {Fill: "#4dd0e1", Stroke: "#00acc1"},
94 "database": {Fill: "#81c784", Stroke: "#66bb6a"},
95 "datastore": {Fill: "#81c784", Stroke: "#66bb6a"},
96 "cache": {Fill: "#81c784", Stroke: "#66bb6a"},
97 "queue": {Fill: "#e57373", Stroke: "#ef5350"},
98 "filestore": {Fill: "#81c784", Stroke: "#66bb6a"},
99 "component": {Fill: "#4dd0e1", Stroke: "#00acc1"},
100 "frontend": {Fill: "#ba68c8", Stroke: "#ab47bc"},
101 "mobile": {Fill: "#ba68c8", Stroke: "#ab47bc"},
102 "ui": {Fill: "#ba68c8", Stroke: "#ab47bc"},
103 "external_system": {Fill: "#bbdefb", Stroke: "#64b5f6"},
104 },
105 }
106
107 // DefaultStyle is the default visual preset.
108 const DefaultStyle = "default"
109
110 // ColorForKind returns the color style for a kind in a given preset.
111 // Falls back to default preset if not found.
112 func ColorForKind(preset, kind string) ColorStyle {
113 if colors, ok := StylePresets[preset]; ok {
114 if color, ok := colors[kind]; ok {
115 return color
116 }
117 }
118 // Fall back to default preset
119 if colors, ok := StylePresets[DefaultStyle]; ok {
120 if color, ok := colors[kind]; ok {
121 return color
122 }
123 }
124 // Ultimate fallback
125 return ColorStyle{Fill: "#d5e8d4", Stroke: "#82b366"}
126 }
127
128 // DefaultShapeConfig returns the shape config for a kind.
129 // Falls back to rounded rectangle if not found.
130 func DefaultShapeConfig(kind string) ShapeConfig {
131 if cfg, ok := KindShapes[kind]; ok {
132 return cfg
133 }
134 return ShapeConfig{Shape: "rounded=1", Width: 120, Height: 60}
135 }
136
github.com/docToolchain/Bausteinsicht/internal/watcher/watcher.go 89.4%
1 // Package watcher monitors file system changes for watch mode operation.
2 package watcher
3
4 import (
5 "os"
6 "sync"
7 "time"
8
9 "github.com/fsnotify/fsnotify"
10 )
11
12 // DefaultDebounce is the default debounce duration.
13 const DefaultDebounce = 300 * time.Millisecond
14
15 // OnChange is the callback type invoked when a watched file changes.
16 type OnChange func(changedFile string)
17
18 // Watcher monitors specific files for write changes with debounce support.
19 type Watcher struct {
20 fsWatcher *fsnotify.Watcher
21 debounce time.Duration
22 onChange OnChange
23 done chan struct{}
24 syncing bool
25 mu sync.Mutex
26 syncMu sync.Mutex // serializes sync execution to prevent concurrent onChange calls
27 }
28
29 // New creates a Watcher that monitors the given files. The onChange callback
30 // is invoked after the debounce duration elapses following a write event.
31 func New(files []string, debounce time.Duration, onChange OnChange) (*Watcher, error) {
32 fsw, err := fsnotify.NewWatcher()
33 if err != nil {
34 return nil, err
35 }
36
37 for _, f := range files {
38 if err := fsw.Add(f); err != nil {
39 _ = fsw.Close()
40 return nil, err
41 }
42 }
43
44 return &Watcher{
45 fsWatcher: fsw,
46 debounce: debounce,
47 onChange: onChange,
48 done: make(chan struct{}),
49 }, nil
50 }
51
52 // Start begins listening for file change events in a background goroutine.
53 func (w *Watcher) Start() error {
54 go w.loop()
55 return nil
56 }
57
58 // Stop signals the watcher to shut down and closes the underlying fsnotify watcher.
59 func (w *Watcher) Stop() {
60 close(w.done)
61 _ = w.fsWatcher.Close()
62 }
63
64 // SetSyncing sets the syncing flag. While true, file change events are ignored
65 // to prevent re-triggering from the watcher's own writes.
66 func (w *Watcher) SetSyncing(v bool) {
67 w.mu.Lock()
68 defer w.mu.Unlock()
69 w.syncing = v
70 }
71
72 func (w *Watcher) isSyncing() bool {
73 w.mu.Lock()
74 defer w.mu.Unlock()
75 return w.syncing
76 }
77
78 func (w *Watcher) loop() {
79 var timer *time.Timer
80 var lastFile string
81
82 for {
83 select {
84 case <-w.done:
85 if timer != nil {
86 timer.Stop()
87 }
88 return
89
90 case event, ok := <-w.fsWatcher.Events:
91 if !ok {
92 return
93 }
94
95 // When a file is removed or renamed, try to re-add it.
96 // Editors and git often use atomic writes (delete + create).
97 if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
98 go w.rewatch(event.Name)
99 continue
100 }
101
102 if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
103 continue
104 }
105 if w.isSyncing() {
106 continue
107 }
108
109 lastFile = event.Name
110
111 if timer != nil {
112 timer.Stop()
113 }
114 captured := lastFile // capture by value to avoid data race with AfterFunc goroutine
115 timer = time.AfterFunc(w.debounce, func() {
116 if !w.isSyncing() {
117 w.syncMu.Lock()
118 defer w.syncMu.Unlock()
119 if !w.isSyncing() {
120 w.onChange(captured)
121 }
122 }
123 })
124
125 case _, ok := <-w.fsWatcher.Errors:
126 if !ok {
127 return
128 }
129 }
130 }
131 }
132
133 // rewatch polls for a removed file to reappear, then re-adds it to the watcher.
134 // Uses exponential backoff (50ms → 100ms → ... → 2s max) and polls indefinitely
135 // until the file reappears or Stop() is called (#268).
136 func (w *Watcher) rewatch(path string) {
137 backoff := 50 * time.Millisecond
138 const maxBackoff = 2 * time.Second
139
140 for {
141 select {
142 case <-w.done:
143 return
144 default:
145 }
146
147 if _, err := os.Stat(path); err == nil {
148 // File exists again — re-add to watcher.
149 _ = w.fsWatcher.Add(path)
150 // File was replaced via atomic rename — trigger callback
151 // since the content has changed but no Write event will fire.
152 if !w.isSyncing() {
153 time.AfterFunc(w.debounce, func() {
154 if !w.isSyncing() {
155 w.syncMu.Lock()
156 defer w.syncMu.Unlock()
157 if !w.isSyncing() {
158 w.onChange(path)
159 }
160 }
161 })
162 }
163 return
164 }
165 time.Sleep(backoff)
166 if backoff < maxBackoff {
167 backoff *= 2
168 if backoff > maxBackoff {
169 backoff = maxBackoff
170 }
171 }
172 }
173 }
174
github.com/docToolchain/Bausteinsicht/internal/workspace/loader.go 0.0%
1 package workspace
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/docToolchain/Bausteinsicht/internal/model"
10 )
11
12 // LoadConfig reads and parses a workspace configuration file.
13 func LoadConfig(path string) (*Config, error) {
14 data, err := os.ReadFile(path)
15 if err != nil {
16 return nil, fmt.Errorf("reading config: %w", err)
17 }
18
19 var cfg Config
20 if err := json.Unmarshal(data, &cfg); err != nil {
21 return nil, fmt.Errorf("parsing config: %w", err)
22 }
23
24 return &cfg, nil
25 }
26
27 // LoadModels loads all models referenced in the configuration.
28 // The basePath is used to resolve relative model paths.
29 func LoadModels(cfg *Config, basePath string) ([]LoadedModel, error) {
30 var loaded []LoadedModel
31
32 for _, ref := range cfg.Models {
33 modelPath := ref.Path
34 // If path is relative, resolve it relative to basePath
35 if !filepath.IsAbs(modelPath) {
36 modelPath = filepath.Join(basePath, modelPath)
37 }
38
39 m, err := model.Load(modelPath)
40 if err != nil {
41 return nil, fmt.Errorf("loading model %s (%s): %w", ref.ID, ref.Path, err)
42 }
43
44 loaded = append(loaded, LoadedModel{
45 Ref: ref,
46 Model: m,
47 })
48 }
49
50 return loaded, nil
51 }
52
53 // SaveConfig writes a workspace configuration to a file.
54 func SaveConfig(cfg *Config, path string) error {
55 data, err := json.MarshalIndent(cfg, "", " ")
56 if err != nil {
57 return fmt.Errorf("marshaling config: %w", err)
58 }
59
60 if err := os.WriteFile(path, data, 0644); err != nil {
61 return fmt.Errorf("writing config: %w", err)
62 }
63
64 return nil
65 }
66
github.com/docToolchain/Bausteinsicht/internal/workspace/merge.go 53.8%
1 package workspace
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/docToolchain/Bausteinsicht/internal/model"
8 )
9
10 // MergeModels combines multiple models into a single unified model.
11 // Element IDs are prefixed to avoid collisions:
12 // - If ModelRef.Prefix is set, use it as prefix
13 // - Otherwise, use ModelRef.ID as prefix
14 // Cross-model relationships are resolved using prefixed IDs.
15 func MergeModels(loaded []LoadedModel) (*model.BausteinsichtModel, error) {
16 if len(loaded) == 0 {
17 return nil, fmt.Errorf("no models to merge")
18 }
19
20 merged := &model.BausteinsichtModel{
21 Specification: model.Specification{
22 Elements: make(map[string]model.ElementKind),
23 Relationships: make(map[string]model.RelationshipKind),
24 },
25 Model: make(map[string]model.Element),
26 Relationships: []model.Relationship{},
27 Views: make(map[string]model.View),
28 DynamicViews: []model.DynamicView{},
29 Constraints: []model.Constraint{},
30 }
31
32 // Merge specifications (element and relationship kinds)
33 for _, lm := range loaded {
34 for kind, def := range lm.Model.Specification.Elements {
35 if _, exists := merged.Specification.Elements[kind]; !exists {
36 merged.Specification.Elements[kind] = def
37 }
38 }
39 for kind, def := range lm.Model.Specification.Relationships {
40 if _, exists := merged.Specification.Relationships[kind]; !exists {
41 merged.Specification.Relationships[kind] = def
42 }
43 }
44 }
45
46 // Map to track ID transformations for relationship resolution
47 idMap := make(map[string]string) // original ID → prefixed ID
48
49 // Merge elements with prefixing
50 for _, lm := range loaded {
51 prefix := lm.Ref.Prefix
52 if prefix == "" {
53 prefix = lm.Ref.ID
54 }
55
56 flatElems, _ := model.FlattenElements(lm.Model)
57 for id, elemPtr := range flatElems {
58 prefixedID := prefixElementID(id, prefix)
59 idMap[id] = prefixedID
60
61 merged.Model[prefixedID] = *elemPtr
62 }
63 }
64
65 // Merge relationships with ID remapping
66 for _, lm := range loaded {
67 prefix := lm.Ref.Prefix
68 if prefix == "" {
69 prefix = lm.Ref.ID
70 }
71
72 for _, rel := range lm.Model.Relationships {
73 remappedRel := rel
74 remappedRel.From = prefixElementID(rel.From, prefix)
75 remappedRel.To = prefixElementID(rel.To, prefix)
76 merged.Relationships = append(merged.Relationships, remappedRel)
77 }
78 }
79
80 // Merge views (each model's views are prefixed with model ID)
81 for _, lm := range loaded {
82 prefix := lm.Ref.Prefix
83 if prefix == "" {
84 prefix = lm.Ref.ID
85 }
86
87 for viewID, view := range lm.Model.Views {
88 viewKey := prefix + "_" + viewID
89 remappedView := view
90 remappedView.Include = remapElementIDs(view.Include, prefix)
91 remappedView.Exclude = remapElementIDs(view.Exclude, prefix)
92 if view.Scope != "" {
93 remappedView.Scope = prefixElementID(view.Scope, prefix)
94 }
95 merged.Views[viewKey] = remappedView
96 }
97 }
98
99 // Merge dynamic views
100 for _, lm := range loaded {
101 prefix := lm.Ref.Prefix
102 if prefix == "" {
103 prefix = lm.Ref.ID
104 }
105
106 for _, dv := range lm.Model.DynamicViews {
107 remappedDV := dv
108 remappedDV.Key = prefix + "_" + dv.Key
109 for i := range remappedDV.Steps {
110 remappedDV.Steps[i].From = prefixElementID(remappedDV.Steps[i].From, prefix)
111 remappedDV.Steps[i].To = prefixElementID(remappedDV.Steps[i].To, prefix)
112 }
113 merged.DynamicViews = append(merged.DynamicViews, remappedDV)
114 }
115 }
116
117 // Merge constraints
118 for _, lm := range loaded {
119 prefix := lm.Ref.Prefix
120 if prefix == "" {
121 prefix = lm.Ref.ID
122 }
123 _ = prefix // TODO: use prefix for remapping element IDs
124
125 for _, constraint := range lm.Model.Constraints {
126 remappedConstraint := constraint
127 if constraint.FromKind != "" {
128 remappedConstraint.FromKind = constraint.FromKind
129 }
130 merged.Constraints = append(merged.Constraints, remappedConstraint)
131 }
132 }
133
134 return merged, nil
135 }
136
137 // prefixElementID adds a prefix to an element ID.
138 // Dot-notation paths like "a.b.c" become "prefix_a.b.c".
139 func prefixElementID(id, prefix string) string {
140 parts := strings.SplitN(id, ".", 2)
141 return prefix + "_" + parts[0] + func() string {
142 if len(parts) > 1 {
143 return "." + parts[1]
144 }
145 return ""
146 }()
147 }
148
149 // remapElementIDs applies prefixing to a list of element IDs.
150 func remapElementIDs(ids []string, prefix string) []string {
151 var result []string
152 for _, id := range ids {
153 result = append(result, prefixElementID(id, prefix))
154 }
155 return result
156 }
157
158 // ResolveWorkspaceView resolves a workspace view by expanding element filters
159 // across all loaded models and returning a unified element set.
160 func ResolveWorkspaceView(cfg *Config, loaded []LoadedModel, view *WorkspaceView) (map[string]*model.Element, error) {
161 merged, err := MergeModels(loaded)
162 if err != nil {
163 return nil, err
164 }
165
166 flatElems, _ := model.FlattenElements(merged)
167 result := make(map[string]*model.Element)
168
169 // Start with includes
170 if len(view.IncludeFrom) > 0 {
171 // Include from specific models
172 for _, modelID := range view.IncludeFrom {
173 for _, lm := range loaded {
174 if lm.Ref.ID == modelID {
175 prefix := lm.Ref.Prefix
176 if prefix == "" {
177 prefix = lm.Ref.ID
178 }
179 flatLM, _ := model.FlattenElements(lm.Model)
180 for id, elemPtr := range flatLM {
181 prefixedID := prefixElementID(id, prefix)
182 if len(view.IncludeKinds) == 0 || contains(view.IncludeKinds, elemPtr.Kind) {
183 result[prefixedID] = elemPtr
184 }
185 }
186 break
187 }
188 }
189 }
190 } else if len(view.IncludeKinds) > 0 {
191 // Include by kinds across all models
192 for id, elemPtr := range flatElems {
193 if contains(view.IncludeKinds, elemPtr.Kind) && !contains(view.ExcludeKinds, elemPtr.Kind) {
194 result[id] = elemPtr
195 }
196 }
197 } else {
198 // Include all
199 result = flatElems
200 }
201
202 // Apply excludes
203 for _, kind := range view.ExcludeKinds {
204 for id, elemPtr := range result {
205 if elemPtr.Kind == kind {
206 delete(result, id)
207 }
208 }
209 }
210
211 return result, nil
212 }
213
214 func contains(slice []string, s string) bool {
215 for _, item := range slice {
216 if item == s {
217 return true
218 }
219 }
220 return false
221 }
222