rails-flow-map 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,589 @@
1
+ require 'cgi'
2
+
3
+ module RailsFlowMap
4
+ # Compares two graph states and visualizes the differences
5
+ #
6
+ # This formatter analyzes changes between two versions of an application's
7
+ # architecture, highlighting additions, removals, and modifications. It can
8
+ # output in multiple formats including Mermaid, HTML, and plain text.
9
+ #
10
+ # @example Basic usage
11
+ # before = RailsFlowMap.analyze_at('main')
12
+ # after = RailsFlowMap.analyze
13
+ # formatter = GitDiffFormatter.new(before, after)
14
+ # diff = formatter.format
15
+ #
16
+ # @example HTML output with custom options
17
+ # formatter = GitDiffFormatter.new(before, after, {
18
+ # format: :html,
19
+ # include_metrics: true,
20
+ # highlight_breaking_changes: true
21
+ # })
22
+ #
23
+ class GitDiffFormatter
24
+ # Creates a new diff formatter
25
+ #
26
+ # @param before_graph [FlowGraph] The graph representing the "before" state
27
+ # @param after_graph [FlowGraph] The graph representing the "after" state
28
+ # @param options [Hash] Configuration options
29
+ # @option options [Symbol] :format Output format (:mermaid, :html, :text) (default: :mermaid)
30
+ # @option options [Boolean] :include_metrics Include complexity metrics (default: true)
31
+ # @option options [Boolean] :highlight_breaking_changes Highlight breaking changes (default: true)
32
+ def initialize(before_graph, after_graph, options = {})
33
+ @before_graph = before_graph
34
+ @after_graph = after_graph
35
+ @options = options
36
+ @format = options[:format] || :mermaid
37
+ end
38
+
39
+ # Generates the diff visualization
40
+ #
41
+ # @return [String] The formatted diff in the requested format
42
+ # @raise [ArgumentError] If an unknown format is specified
43
+ def format
44
+ diff_result = analyze_differences
45
+
46
+ case @format
47
+ when :mermaid
48
+ format_as_mermaid(diff_result)
49
+ when :html
50
+ format_as_html(diff_result)
51
+ else
52
+ format_as_text(diff_result)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def analyze_differences
59
+ diff = {
60
+ added_nodes: [],
61
+ removed_nodes: [],
62
+ modified_nodes: [],
63
+ added_edges: [],
64
+ removed_edges: [],
65
+ modified_edges: [],
66
+ metrics_change: calculate_metrics_change,
67
+ summary: {}
68
+ }
69
+
70
+ before_nodes = @before_graph.nodes
71
+ after_nodes = @after_graph.nodes
72
+
73
+ # ノードの追加を検出
74
+ after_nodes.each do |id, node|
75
+ unless before_nodes[id]
76
+ diff[:added_nodes] << node
77
+ end
78
+ end
79
+
80
+ # ノードの削除と変更を検出
81
+ before_nodes.each do |id, node|
82
+ if after_nodes[id]
83
+ # ノードが存在する場合、変更をチェック
84
+ if node_changed?(node, after_nodes[id])
85
+ diff[:modified_nodes] << {
86
+ before: node,
87
+ after: after_nodes[id],
88
+ changes: detect_node_changes(node, after_nodes[id])
89
+ }
90
+ end
91
+ else
92
+ diff[:removed_nodes] << node
93
+ end
94
+ end
95
+
96
+ # エッジの変更を検出
97
+ before_edges = @before_graph.edges
98
+ after_edges = @after_graph.edges
99
+
100
+ # エッジの追加
101
+ after_edges.each do |edge|
102
+ unless edge_exists?(edge, before_edges)
103
+ diff[:added_edges] << edge
104
+ end
105
+ end
106
+
107
+ # エッジの削除
108
+ before_edges.each do |edge|
109
+ unless edge_exists?(edge, after_edges)
110
+ diff[:removed_edges] << edge
111
+ end
112
+ end
113
+
114
+ # サマリーの生成
115
+ diff[:summary] = generate_summary(diff)
116
+
117
+ diff
118
+ end
119
+
120
+ def node_changed?(before_node, after_node)
121
+ before_node.name != after_node.name ||
122
+ before_node.type != after_node.type ||
123
+ before_node.attributes != after_node.attributes
124
+ end
125
+
126
+ def detect_node_changes(before_node, after_node)
127
+ changes = []
128
+
129
+ if before_node.name != after_node.name
130
+ changes << { type: :name, before: before_node.name, after: after_node.name }
131
+ end
132
+
133
+ if before_node.attributes != after_node.attributes
134
+ # 関連の変更を検出
135
+ before_assoc = before_node.attributes[:associations] || []
136
+ after_assoc = after_node.attributes[:associations] || []
137
+
138
+ added_assoc = after_assoc - before_assoc
139
+ removed_assoc = before_assoc - after_assoc
140
+
141
+ if added_assoc.any?
142
+ changes << { type: :associations_added, items: added_assoc }
143
+ end
144
+
145
+ if removed_assoc.any?
146
+ changes << { type: :associations_removed, items: removed_assoc }
147
+ end
148
+ end
149
+
150
+ changes
151
+ end
152
+
153
+ def edge_exists?(target_edge, edge_list)
154
+ edge_list.any? do |edge|
155
+ edge.from == target_edge.from &&
156
+ edge.to == target_edge.to &&
157
+ edge.type == target_edge.type
158
+ end
159
+ end
160
+
161
+ def calculate_metrics_change
162
+ {
163
+ nodes: {
164
+ before: @before_graph.node_count,
165
+ after: @after_graph.node_count,
166
+ change: @after_graph.node_count - @before_graph.node_count
167
+ },
168
+ edges: {
169
+ before: @before_graph.edge_count,
170
+ after: @after_graph.edge_count,
171
+ change: @after_graph.edge_count - @before_graph.edge_count
172
+ },
173
+ complexity: calculate_complexity_change
174
+ }
175
+ end
176
+
177
+ def calculate_complexity_change
178
+ before_complexity = calculate_graph_complexity(@before_graph)
179
+ after_complexity = calculate_graph_complexity(@after_graph)
180
+
181
+ {
182
+ before: before_complexity,
183
+ after: after_complexity,
184
+ change: after_complexity - before_complexity,
185
+ percentage: before_complexity > 0 ? ((after_complexity - before_complexity) * 100.0 / before_complexity).round(2) : 0
186
+ }
187
+ end
188
+
189
+ def calculate_graph_complexity(graph)
190
+ # 簡易的な複雑度計算(ノード数 + エッジ数 * 2)
191
+ graph.node_count + (graph.edge_count * 2)
192
+ end
193
+
194
+ def generate_summary(diff)
195
+ {
196
+ total_changes: diff[:added_nodes].count + diff[:removed_nodes].count +
197
+ diff[:modified_nodes].count + diff[:added_edges].count +
198
+ diff[:removed_edges].count,
199
+ breaking_changes: detect_breaking_changes(diff),
200
+ recommendations: generate_recommendations(diff)
201
+ }
202
+ end
203
+
204
+ def detect_breaking_changes(diff)
205
+ breaking = []
206
+
207
+ # 削除されたコントローラーやアクション
208
+ diff[:removed_nodes].each do |node|
209
+ if [:controller, :action, :route].include?(node.type)
210
+ breaking << "#{node.type.to_s.capitalize} '#{node.name}' was removed"
211
+ end
212
+ end
213
+
214
+ # 削除されたモデルの関連
215
+ diff[:removed_edges].each do |edge|
216
+ if edge.type == :belongs_to
217
+ breaking << "Association removed: #{edge.from} belongs_to #{edge.to}"
218
+ end
219
+ end
220
+
221
+ breaking
222
+ end
223
+
224
+ def generate_recommendations(diff)
225
+ recommendations = []
226
+
227
+ # 大量の追加があった場合
228
+ if diff[:added_nodes].count > 10
229
+ recommendations << "Consider breaking down the changes into smaller commits"
230
+ end
231
+
232
+ # 複雑度が大幅に増加した場合
233
+ complexity_change = diff[:metrics_change][:complexity][:percentage]
234
+ if complexity_change > 20
235
+ recommendations << "Complexity increased by #{complexity_change}%. Consider refactoring"
236
+ end
237
+
238
+ # 循環依存が追加された可能性
239
+ if diff[:added_edges].count > diff[:added_nodes].count * 2
240
+ recommendations << "Many new dependencies added. Check for circular dependencies"
241
+ end
242
+
243
+ recommendations
244
+ end
245
+
246
+ def format_as_mermaid(diff)
247
+ output = []
248
+ output << "```mermaid"
249
+ output << "graph TD"
250
+ output << " subgraph Legend"
251
+ output << " Added[Added - Green]:::added"
252
+ output << " Removed[Removed - Red]:::removed"
253
+ output << " Modified[Modified - Yellow]:::modified"
254
+ output << " end"
255
+ output << ""
256
+
257
+ # 全ノードを表示(状態付き)
258
+ all_nodes = {}
259
+
260
+ # 既存のノード
261
+ @after_graph.nodes.each do |id, node|
262
+ if diff[:added_nodes].any? { |n| n.id == id }
263
+ all_nodes[id] = { node: node, status: :added }
264
+ elsif diff[:modified_nodes].any? { |m| m[:after].id == id }
265
+ all_nodes[id] = { node: node, status: :modified }
266
+ else
267
+ all_nodes[id] = { node: node, status: :unchanged }
268
+ end
269
+ end
270
+
271
+ # 削除されたノード
272
+ diff[:removed_nodes].each do |node|
273
+ all_nodes[node.id] = { node: node, status: :removed }
274
+ end
275
+
276
+ # ノードの描画
277
+ all_nodes.each do |id, data|
278
+ node = data[:node]
279
+ status = data[:status]
280
+
281
+ node_text = case node.type
282
+ when :controller
283
+ "#{node.name}[[#{node.name}]]"
284
+ when :action
285
+ "#{node.name}(#{node.name})"
286
+ else
287
+ "#{node.name}[#{node.name}]"
288
+ end
289
+
290
+ class_suffix = status == :unchanged ? "" : ":::#{status}"
291
+ output << " #{node_text}#{class_suffix}"
292
+ end
293
+
294
+ output << ""
295
+
296
+ # エッジの描画
297
+ all_edges = []
298
+
299
+ # 既存のエッジ
300
+ @after_graph.edges.each do |edge|
301
+ if diff[:added_edges].any? { |e| edge_equal?(e, edge) }
302
+ all_edges << { edge: edge, status: :added }
303
+ else
304
+ all_edges << { edge: edge, status: :unchanged }
305
+ end
306
+ end
307
+
308
+ # 削除されたエッジ
309
+ diff[:removed_edges].each do |edge|
310
+ all_edges << { edge: edge, status: :removed }
311
+ end
312
+
313
+ # エッジの描画
314
+ all_edges.each do |data|
315
+ edge = data[:edge]
316
+ status = data[:status]
317
+
318
+ style = case status
319
+ when :added
320
+ "==>"
321
+ when :removed
322
+ "-.->"
323
+ else
324
+ "-->"
325
+ end
326
+
327
+ label = edge.label ? "|#{edge.label}|" : ""
328
+ class_suffix = status == :unchanged ? "" : ":::#{status}"
329
+
330
+ from_exists = all_nodes[edge.from]
331
+ to_exists = all_nodes[edge.to]
332
+
333
+ if from_exists && to_exists
334
+ output << " #{edge.from} #{style}#{label} #{edge.to}#{class_suffix}"
335
+ end
336
+ end
337
+
338
+ output << ""
339
+ output << " classDef added fill:#90EE90,stroke:#006400,stroke-width:3px;"
340
+ output << " classDef removed fill:#FFB6C1,stroke:#8B0000,stroke-width:3px,stroke-dasharray: 5 5;"
341
+ output << " classDef modified fill:#FFFFE0,stroke:#FFD700,stroke-width:3px;"
342
+ output << "```"
343
+
344
+ # 変更サマリー
345
+ output << ""
346
+ output << "## 変更サマリー"
347
+ output << ""
348
+ output << "### 📊 メトリクス変化"
349
+ output << "- ノード数: #{diff[:metrics_change][:nodes][:before]} → #{diff[:metrics_change][:nodes][:after]} (#{format_change(diff[:metrics_change][:nodes][:change])})"
350
+ output << "- エッジ数: #{diff[:metrics_change][:edges][:before]} → #{diff[:metrics_change][:edges][:after]} (#{format_change(diff[:metrics_change][:edges][:change])})"
351
+ output << "- 複雑度: #{diff[:metrics_change][:complexity][:before]} → #{diff[:metrics_change][:complexity][:after]} (#{format_change(diff[:metrics_change][:complexity][:change])} / #{diff[:metrics_change][:complexity][:percentage]}%)"
352
+
353
+ if diff[:summary][:breaking_changes].any?
354
+ output << ""
355
+ output << "### ⚠️ 破壊的変更"
356
+ diff[:summary][:breaking_changes].each do |change|
357
+ output << "- #{change}"
358
+ end
359
+ end
360
+
361
+ if diff[:summary][:recommendations].any?
362
+ output << ""
363
+ output << "### 💡 推奨事項"
364
+ diff[:summary][:recommendations].each do |rec|
365
+ output << "- #{rec}"
366
+ end
367
+ end
368
+
369
+ output.join("\n")
370
+ end
371
+
372
+ def format_as_html(diff)
373
+ # HTML形式での差分表示(D3.jsを使用)
374
+ html_content = <<~HTML
375
+ <!DOCTYPE html>
376
+ <html>
377
+ <head>
378
+ <title>Rails Flow Map - Git Diff Visualization</title>
379
+ <script src="https://d3js.org/d3.v7.min.js"></script>
380
+ <style>
381
+ body { font-family: Arial, sans-serif; margin: 20px; }
382
+ .summary { background: #f0f0f0; padding: 20px; border-radius: 5px; margin-bottom: 20px; }
383
+ .metrics { display: flex; gap: 20px; margin-bottom: 20px; }
384
+ .metric-card { background: white; padding: 15px; border-radius: 5px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
385
+ .added { color: #28a745; }
386
+ .removed { color: #dc3545; }
387
+ .modified { color: #ffc107; }
388
+ .breaking { background: #fff3cd; border: 1px solid #ffeaa7; padding: 10px; border-radius: 5px; margin: 10px 0; }
389
+ #diff-graph { width: 100%; height: 600px; border: 1px solid #ddd; }
390
+ </style>
391
+ </head>
392
+ <body>
393
+ <h1>Rails Flow Map - Architecture Diff</h1>
394
+
395
+ <div class="summary">
396
+ <h2>変更サマリー</h2>
397
+ <div class="metrics">
398
+ <div class="metric-card">
399
+ <h3>ノード</h3>
400
+ <p>#{diff[:metrics_change][:nodes][:before]} → #{diff[:metrics_change][:nodes][:after]}</p>
401
+ <p class="#{diff[:metrics_change][:nodes][:change] >= 0 ? 'added' : 'removed'}">
402
+ #{format_change(diff[:metrics_change][:nodes][:change])}
403
+ </p>
404
+ </div>
405
+ <div class="metric-card">
406
+ <h3>エッジ</h3>
407
+ <p>#{diff[:metrics_change][:edges][:before]} → #{diff[:metrics_change][:edges][:after]}</p>
408
+ <p class="#{diff[:metrics_change][:edges][:change] >= 0 ? 'added' : 'removed'}">
409
+ #{format_change(diff[:metrics_change][:edges][:change])}
410
+ </p>
411
+ </div>
412
+ <div class="metric-card">
413
+ <h3>複雑度</h3>
414
+ <p>#{diff[:metrics_change][:complexity][:before]} → #{diff[:metrics_change][:complexity][:after]}</p>
415
+ <p class="#{diff[:metrics_change][:complexity][:change] >= 0 ? 'added' : 'removed'}">
416
+ #{format_change(diff[:metrics_change][:complexity][:change])} (#{diff[:metrics_change][:complexity][:percentage]}%)
417
+ </p>
418
+ </div>
419
+ </div>
420
+
421
+ #{generate_breaking_changes_html(diff)}
422
+ </div>
423
+
424
+ <div id="diff-graph"></div>
425
+
426
+ <script>
427
+ const diffData = #{generate_diff_data_json(diff)};
428
+ // D3.js visualization code here
429
+ </script>
430
+ </body>
431
+ </html>
432
+ HTML
433
+
434
+ html_content
435
+ end
436
+
437
+ def format_as_text(diff)
438
+ output = []
439
+ output << "Rails Flow Map - Architecture Diff Report"
440
+ output << "=" * 50
441
+ output << ""
442
+
443
+ # 追加されたノード
444
+ if diff[:added_nodes].any?
445
+ output << "## Added Nodes (#{diff[:added_nodes].count})"
446
+ diff[:added_nodes].each do |node|
447
+ output << " + #{node.type}: #{node.name}"
448
+ end
449
+ output << ""
450
+ end
451
+
452
+ # 削除されたノード
453
+ if diff[:removed_nodes].any?
454
+ output << "## Removed Nodes (#{diff[:removed_nodes].count})"
455
+ diff[:removed_nodes].each do |node|
456
+ output << " - #{node.type}: #{node.name}"
457
+ end
458
+ output << ""
459
+ end
460
+
461
+ # 変更されたノード
462
+ if diff[:modified_nodes].any?
463
+ output << "## Modified Nodes (#{diff[:modified_nodes].count})"
464
+ diff[:modified_nodes].each do |mod|
465
+ output << " ~ #{mod[:before].type}: #{mod[:before].name}"
466
+ mod[:changes].each do |change|
467
+ output << " - #{format_change_description(change)}"
468
+ end
469
+ end
470
+ output << ""
471
+ end
472
+
473
+ # メトリクス
474
+ output << "## Metrics"
475
+ output << " Nodes: #{diff[:metrics_change][:nodes][:before]} → #{diff[:metrics_change][:nodes][:after]} (#{format_change(diff[:metrics_change][:nodes][:change])})"
476
+ output << " Edges: #{diff[:metrics_change][:edges][:before]} → #{diff[:metrics_change][:edges][:after]} (#{format_change(diff[:metrics_change][:edges][:change])})"
477
+ output << " Complexity: #{diff[:metrics_change][:complexity][:before]} → #{diff[:metrics_change][:complexity][:after]} (#{format_change(diff[:metrics_change][:complexity][:change])})"
478
+
479
+ output.join("\n")
480
+ end
481
+
482
+ def edge_equal?(edge1, edge2)
483
+ edge1.from == edge2.from && edge1.to == edge2.to && edge1.type == edge2.type
484
+ end
485
+
486
+ def format_change(num)
487
+ num >= 0 ? "+#{num}" : num.to_s
488
+ end
489
+
490
+ def format_change_description(change)
491
+ case change[:type]
492
+ when :name
493
+ "Name changed: #{change[:before]} → #{change[:after]}"
494
+ when :associations_added
495
+ "Associations added: #{change[:items].join(', ')}"
496
+ when :associations_removed
497
+ "Associations removed: #{change[:items].join(', ')}"
498
+ else
499
+ "Changed: #{change[:type]}"
500
+ end
501
+ end
502
+
503
+ def generate_breaking_changes_html(diff)
504
+ return "" unless diff[:summary][:breaking_changes].any?
505
+
506
+ html = '<div class="breaking">'
507
+ html += '<h3>⚠️ 破壊的変更</h3>'
508
+ html += '<ul>'
509
+ diff[:summary][:breaking_changes].each do |change|
510
+ html += "<li>#{CGI.escapeHTML(change)}</li>"
511
+ end
512
+ html += '</ul>'
513
+ html += '</div>'
514
+
515
+ html
516
+ end
517
+
518
+ def generate_diff_data_json(diff)
519
+ {
520
+ nodes: generate_diff_nodes(diff),
521
+ edges: generate_diff_edges(diff)
522
+ }.to_json
523
+ end
524
+
525
+ def generate_diff_nodes(diff)
526
+ nodes = []
527
+
528
+ # 全ノードを収集
529
+ @after_graph.nodes.each do |id, node|
530
+ status = if diff[:added_nodes].any? { |n| n.id == id }
531
+ 'added'
532
+ elsif diff[:modified_nodes].any? { |m| m[:after].id == id }
533
+ 'modified'
534
+ else
535
+ 'unchanged'
536
+ end
537
+
538
+ nodes << {
539
+ id: id,
540
+ name: node.name,
541
+ type: node.type,
542
+ status: status
543
+ }
544
+ end
545
+
546
+ # 削除されたノード
547
+ diff[:removed_nodes].each do |node|
548
+ nodes << {
549
+ id: CGI.escapeHTML(node.id.to_s),
550
+ name: CGI.escapeHTML(node.name.to_s),
551
+ type: node.type.to_s,
552
+ status: 'removed'
553
+ }
554
+ end
555
+
556
+ nodes
557
+ end
558
+
559
+ def generate_diff_edges(diff)
560
+ edges = []
561
+
562
+ # 全エッジを収集
563
+ @after_graph.edges.each do |edge|
564
+ status = diff[:added_edges].any? { |e| edge_equal?(e, edge) } ? 'added' : 'unchanged'
565
+
566
+ edges << {
567
+ source: edge.from,
568
+ target: edge.to,
569
+ type: edge.type,
570
+ label: edge.label,
571
+ status: status
572
+ }
573
+ end
574
+
575
+ # 削除されたエッジ
576
+ diff[:removed_edges].each do |edge|
577
+ edges << {
578
+ source: edge.from,
579
+ target: edge.to,
580
+ type: edge.type,
581
+ label: edge.label,
582
+ status: 'removed'
583
+ }
584
+ end
585
+
586
+ edges
587
+ end
588
+ end
589
+ end
@@ -0,0 +1,111 @@
1
+ module RailsFlowMap
2
+ class GraphVizFormatter
3
+ def format(graph)
4
+ lines = ["digraph RailsFlowMap {"]
5
+ lines << " rankdir=TB;"
6
+ lines << " node [shape=box];"
7
+ lines << ""
8
+
9
+ # Group nodes by type
10
+ models = graph.nodes_by_type(:model)
11
+ controllers = graph.nodes_by_type(:controller)
12
+ actions = graph.nodes_by_type(:action)
13
+
14
+ # Add models subgraph
15
+ unless models.empty?
16
+ lines << " subgraph cluster_models {"
17
+ lines << " label=\"Models\";"
18
+ lines << " style=filled;"
19
+ lines << " color=lightgrey;"
20
+ lines << " node [style=filled,color=pink];"
21
+ models.each do |node|
22
+ lines << " #{node.id} [label=\"#{escape_label(node.name)}\"];"
23
+ end
24
+ lines << " }"
25
+ lines << ""
26
+ end
27
+
28
+ # Add controllers subgraph
29
+ unless controllers.empty?
30
+ lines << " subgraph cluster_controllers {"
31
+ lines << " label=\"Controllers\";"
32
+ lines << " style=filled;"
33
+ lines << " color=lightblue;"
34
+ lines << " node [style=filled,color=lightblue];"
35
+ controllers.each do |node|
36
+ lines << " #{node.id} [label=\"#{escape_label(node.name)}\",shape=component];"
37
+ end
38
+ lines << " }"
39
+ lines << ""
40
+ end
41
+
42
+ # Add actions subgraph
43
+ unless actions.empty?
44
+ lines << " subgraph cluster_actions {"
45
+ lines << " label=\"Actions\";"
46
+ lines << " style=filled;"
47
+ lines << " color=lightgreen;"
48
+ lines << " node [style=filled,color=lightgreen,shape=ellipse];"
49
+ actions.each do |node|
50
+ controller_name = node.attributes[:controller] || "Unknown"
51
+ lines << " #{node.id} [label=\"#{escape_label(controller_name)}##{escape_label(node.name)}\"];"
52
+ end
53
+ lines << " }"
54
+ lines << ""
55
+ end
56
+
57
+ # Add edges
58
+ graph.edges.each do |edge|
59
+ from_node = graph.find_node(edge.from)
60
+ to_node = graph.find_node(edge.to)
61
+
62
+ next unless from_node && to_node
63
+
64
+ edge_attrs = format_edge_attributes(edge)
65
+ lines << " #{edge.from} -> #{edge.to} [#{edge_attrs}];"
66
+ end
67
+
68
+ lines << "}"
69
+
70
+ lines.join("\n")
71
+ end
72
+
73
+ private
74
+
75
+ def escape_label(text)
76
+ text.to_s.gsub('"', '\\"')
77
+ end
78
+
79
+ def format_edge_attributes(edge)
80
+ attrs = []
81
+
82
+ # Add label if present
83
+ if edge.label
84
+ attrs << "label=\"#{escape_label(edge.label)}\""
85
+ end
86
+
87
+ # Add style based on edge type
88
+ case edge.type
89
+ when :belongs_to
90
+ attrs << "style=solid"
91
+ attrs << "arrowhead=normal"
92
+ when :has_one
93
+ attrs << "style=solid"
94
+ attrs << "arrowhead=normal"
95
+ when :has_many
96
+ attrs << "style=solid"
97
+ attrs << "arrowhead=crow"
98
+ when :has_and_belongs_to_many
99
+ attrs << "style=solid"
100
+ attrs << "arrowhead=crow"
101
+ attrs << "arrowtail=crow"
102
+ attrs << "dir=both"
103
+ when :has_action
104
+ attrs << "style=dashed"
105
+ attrs << "color=gray"
106
+ end
107
+
108
+ attrs.join(", ")
109
+ end
110
+ end
111
+ end