Cross-Platform Test Coverage & Performance Analysis
| 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% |
| Metric | Change |
|---|
| 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% |
| 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 |
| 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 |
| 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 |
| 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 | } |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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, "&", "&") |
| 142 | s = strings.ReplaceAll(s, "<", "<") |
| 143 | s = strings.ReplaceAll(s, ">", ">") |
| 144 | s = strings.ReplaceAll(s, "\"", """) |
| 145 | return s |
| 146 | } |
| 147 | |
| 148 | // unescapeHTML reverses HTML entity escaping. |
| 149 | func unescapeHTML(s string) string { |
| 150 | s = strings.ReplaceAll(s, """, "\"") |
| 151 | s = strings.ReplaceAll(s, ">", ">") |
| 152 | s = strings.ReplaceAll(s, "<", "<") |
| 153 | s = strings.ReplaceAll(s, "&", "&") |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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, ¶ms); err != nil { |
| 142 | return nil |
| 143 | } |
| 144 | return s.handleInitialize(msg.ID, ¶ms) |
| 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, ¶ms); err != nil { |
| 157 | return nil |
| 158 | } |
| 159 | s.handleDidOpen(¶ms.TextDocument.URI, ¶ms.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, ¶ms); err != nil { |
| 173 | return nil |
| 174 | } |
| 175 | if len(params.ContentChanges) > 0 { |
| 176 | s.handleDidChange(¶ms.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, ¶ms); err != nil { |
| 187 | return nil |
| 188 | } |
| 189 | s.handleDidSave(¶ms.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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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", "← "+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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| Metric | Change |
|---|
| 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% |
| 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 |
| 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 |
| 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 |
| 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 | } |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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, "&", "&") |
| 142 | s = strings.ReplaceAll(s, "<", "<") |
| 143 | s = strings.ReplaceAll(s, ">", ">") |
| 144 | s = strings.ReplaceAll(s, "\"", """) |
| 145 | return s |
| 146 | } |
| 147 | |
| 148 | // unescapeHTML reverses HTML entity escaping. |
| 149 | func unescapeHTML(s string) string { |
| 150 | s = strings.ReplaceAll(s, """, "\"") |
| 151 | s = strings.ReplaceAll(s, ">", ">") |
| 152 | s = strings.ReplaceAll(s, "<", "<") |
| 153 | s = strings.ReplaceAll(s, "&", "&") |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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, ¶ms); err != nil { |
| 142 | return nil |
| 143 | } |
| 144 | return s.handleInitialize(msg.ID, ¶ms) |
| 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, ¶ms); err != nil { |
| 157 | return nil |
| 158 | } |
| 159 | s.handleDidOpen(¶ms.TextDocument.URI, ¶ms.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, ¶ms); err != nil { |
| 173 | return nil |
| 174 | } |
| 175 | if len(params.ContentChanges) > 0 { |
| 176 | s.handleDidChange(¶ms.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, ¶ms); err != nil { |
| 187 | return nil |
| 188 | } |
| 189 | s.handleDidSave(¶ms.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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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", "← "+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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| Metric | Change |
|---|
| 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% |
| 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 |
| 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 |
| 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 |
| 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 | } |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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, "&", "&") |
| 142 | s = strings.ReplaceAll(s, "<", "<") |
| 143 | s = strings.ReplaceAll(s, ">", ">") |
| 144 | s = strings.ReplaceAll(s, "\"", """) |
| 145 | return s |
| 146 | } |
| 147 | |
| 148 | // unescapeHTML reverses HTML entity escaping. |
| 149 | func unescapeHTML(s string) string { |
| 150 | s = strings.ReplaceAll(s, """, "\"") |
| 151 | s = strings.ReplaceAll(s, ">", ">") |
| 152 | s = strings.ReplaceAll(s, "<", "<") |
| 153 | s = strings.ReplaceAll(s, "&", "&") |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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, ¶ms); err != nil { |
| 142 | return nil |
| 143 | } |
| 144 | return s.handleInitialize(msg.ID, ¶ms) |
| 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, ¶ms); err != nil { |
| 157 | return nil |
| 158 | } |
| 159 | s.handleDidOpen(¶ms.TextDocument.URI, ¶ms.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, ¶ms); err != nil { |
| 173 | return nil |
| 174 | } |
| 175 | if len(params.ContentChanges) > 0 { |
| 176 | s.handleDidChange(¶ms.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, ¶ms); err != nil { |
| 187 | return nil |
| 188 | } |
| 189 | s.handleDidSave(¶ms.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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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", "← "+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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |