feat: add Mermaid.js format for dependency tree visualization (#191)

Adds `bd dep tree --format mermaid` to export dependency trees as Mermaid.js flowcharts.

Features:
- Status indicators: ☐ open, ◧ in_progress, ⚠ blocked, ☑ closed
- Theme-agnostic design
- Works with --reverse flag
- Comprehensive unit tests following TDD

Co-authored-by: David Laing <david@davidlaing.com>
This commit is contained in:
Steve Yegge
2025-10-31 15:11:29 -07:00
committed by GitHub
parent c070a9e7d4
commit aa567f6b9a
6 changed files with 321 additions and 12 deletions

View File

@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
@@ -25,7 +26,6 @@ var depAddCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
depType, _ := cmd.Flags().GetString("type")
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
@@ -148,7 +148,6 @@ var depRemoveCmd = &cobra.Command{
Short: "Remove a dependency",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
// Resolve partial IDs first
@@ -240,7 +239,6 @@ var depTreeCmd = &cobra.Command{
Short: "Show dependency tree",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
// Resolve partial ID first
@@ -276,6 +274,7 @@ var depTreeCmd = &cobra.Command{
showAllPaths, _ := cmd.Flags().GetBool("show-all-paths")
maxDepth, _ := cmd.Flags().GetInt("max-depth")
reverse, _ := cmd.Flags().GetBool("reverse")
formatStr, _ := cmd.Flags().GetString("format")
if maxDepth < 1 {
fmt.Fprintf(os.Stderr, "Error: --max-depth must be >= 1\n")
@@ -288,6 +287,12 @@ var depTreeCmd = &cobra.Command{
os.Exit(1)
}
// Handle mermaid format
if formatStr == "mermaid" {
outputMermaidTree(tree, args[0])
return
}
if jsonOutput {
// Always output array, even if empty
if tree == nil {
@@ -341,8 +346,6 @@ var depCyclesCmd = &cobra.Command{
Use: "cycles",
Short: "Detect dependency cycles",
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
// If daemon is running but doesn't support this command, use direct storage
if daemonClient != nil && store == nil {
var err error
@@ -388,19 +391,71 @@ var depCyclesCmd = &cobra.Command{
},
}
// outputMermaidTree outputs a dependency tree in Mermaid.js flowchart format
func outputMermaidTree(tree []*types.TreeNode, rootID string) {
if len(tree) == 0 {
fmt.Println("flowchart TD")
fmt.Printf(" %s[\"No dependencies\"]\n", rootID)
return
}
fmt.Println("flowchart TD")
// Output nodes
nodesSeen := make(map[string]bool)
for _, node := range tree {
if !nodesSeen[node.ID] {
emoji := getStatusEmoji(node.Status)
label := fmt.Sprintf("%s %s: %s", emoji, node.ID, node.Title)
// Escape quotes and backslashes in label
label = strings.ReplaceAll(label, "\\", "\\\\")
label = strings.ReplaceAll(label, "\"", "\\\"")
fmt.Printf(" %s[\"%s\"]\n", node.ID, label)
nodesSeen[node.ID] = true
}
}
fmt.Println()
// Output edges - use explicit parent relationships from ParentID
for _, node := range tree {
if node.ParentID != "" && node.ParentID != node.ID {
fmt.Printf(" %s --> %s\n", node.ParentID, node.ID)
}
}
}
// getStatusEmoji returns a symbol indicator for a given status
func getStatusEmoji(status types.Status) string {
switch status {
case types.StatusOpen:
return "☐" // U+2610 Ballot Box
case types.StatusInProgress:
return "◧" // U+25E7 Square Left Half Black
case types.StatusBlocked:
return "⚠" // U+26A0 Warning Sign
case types.StatusClosed:
return "☑" // U+2611 Ballot Box with Check
default:
return "?"
}
}
func init() {
depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)")
depAddCmd.Flags().Bool("json", false, "Output JSON format")
depRemoveCmd.Flags().Bool("json", false, "Output JSON format")
depTreeCmd.Flags().Bool("show-all-paths", false, "Show all paths to nodes (no deduplication for diamond dependencies)")
depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)")
depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (what was discovered from this) instead of dependency tree (what blocks this)")
depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart")
depTreeCmd.Flags().Bool("json", false, "Output JSON format")
depCyclesCmd.Flags().Bool("json", false, "Output JSON format")
depCmd.AddCommand(depAddCmd)
depCmd.AddCommand(depRemoveCmd)
depCmd.AddCommand(depTreeCmd)

View File

@@ -1,9 +1,13 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -266,3 +270,217 @@ func TestDepRemove(t *testing.T) {
t.Errorf("Expected 0 dependencies after removal, got %d", len(deps))
}
}
func TestDepTreeFormatFlag(t *testing.T) {
// Test that the --format flag exists on depTreeCmd
flag := depTreeCmd.Flags().Lookup("format")
if flag == nil {
t.Fatal("depTreeCmd should have --format flag")
}
// Test default value is empty string
if flag.DefValue != "" {
t.Errorf("Expected default format='', got %q", flag.DefValue)
}
// Test usage text mentions mermaid
if !strings.Contains(flag.Usage, "mermaid") {
t.Errorf("Expected flag usage to mention 'mermaid', got %q", flag.Usage)
}
}
func TestGetStatusEmoji(t *testing.T) {
tests := []struct {
status types.Status
want string
}{
{types.StatusOpen, "☐"},
{types.StatusInProgress, "◧"},
{types.StatusBlocked, "⚠"},
{types.StatusClosed, "☑"},
{types.Status("unknown"), "?"},
}
for _, tt := range tests {
t.Run(string(tt.status), func(t *testing.T) {
got := getStatusEmoji(tt.status)
if got != tt.want {
t.Errorf("getStatusEmoji(%q) = %q, want %q", tt.status, got, tt.want)
}
})
}
}
func TestOutputMermaidTree(t *testing.T) {
tests := []struct {
name string
tree []*types.TreeNode
rootID string
want []string // Lines that must appear in output
}{
{
name: "empty tree",
tree: []*types.TreeNode{},
rootID: "test-1",
want: []string{
"flowchart TD",
`test-1["No dependencies"]`,
},
},
{
name: "single dependency",
tree: []*types.TreeNode{
{
Issue: types.Issue{ID: "test-1", Title: "Task 1", Status: types.StatusInProgress},
Depth: 0,
ParentID: "",
},
{
Issue: types.Issue{ID: "test-2", Title: "Task 2", Status: types.StatusClosed},
Depth: 1,
ParentID: "test-1",
},
},
rootID: "test-1",
want: []string{
"flowchart TD",
`test-1["◧ test-1: Task 1"]`,
`test-2["☑ test-2: Task 2"]`,
"test-1 --> test-2",
},
},
{
name: "multiple dependencies",
tree: []*types.TreeNode{
{
Issue: types.Issue{ID: "test-1", Title: "Main", Status: types.StatusOpen},
Depth: 0,
ParentID: "",
},
{
Issue: types.Issue{ID: "test-2", Title: "Sub 1", Status: types.StatusClosed},
Depth: 1,
ParentID: "test-1",
},
{
Issue: types.Issue{ID: "test-3", Title: "Sub 2", Status: types.StatusBlocked},
Depth: 1,
ParentID: "test-1",
},
},
rootID: "test-1",
want: []string{
"flowchart TD",
`test-1["☐ test-1: Main"]`,
`test-2["☑ test-2: Sub 1"]`,
`test-3["⚠ test-3: Sub 2"]`,
"test-1 --> test-2",
"test-1 --> test-3",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Capture stdout
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
outputMermaidTree(tt.tree, tt.rootID)
w.Close()
os.Stdout = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Verify all expected lines appear
for _, line := range tt.want {
if !strings.Contains(output, line) {
t.Errorf("expected output to contain %q, got:\n%s", line, output)
}
}
})
}
}
func TestOutputMermaidTree_Siblings(t *testing.T) {
// Test case: Siblings with children (reproduces issue with wrong parent inference)
// Structure:
// BD-1 (root)
// ├── BD-2 (sibling 1)
// │ └── BD-4 (child of BD-2)
// └── BD-3 (sibling 2)
// └── BD-5 (child of BD-3)
tree := []*types.TreeNode{
{
Issue: types.Issue{ID: "BD-1", Title: "Parent", Status: types.StatusOpen},
Depth: 0,
ParentID: "",
},
{
Issue: types.Issue{ID: "BD-2", Title: "Sibling 1", Status: types.StatusOpen},
Depth: 1,
ParentID: "BD-1",
},
{
Issue: types.Issue{ID: "BD-3", Title: "Sibling 2", Status: types.StatusOpen},
Depth: 1,
ParentID: "BD-1",
},
{
Issue: types.Issue{ID: "BD-4", Title: "Child of Sibling 1", Status: types.StatusOpen},
Depth: 2,
ParentID: "BD-2",
},
{
Issue: types.Issue{ID: "BD-5", Title: "Child of Sibling 2", Status: types.StatusOpen},
Depth: 2,
ParentID: "BD-3",
},
}
// Capture stdout
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
outputMermaidTree(tree, "BD-1")
w.Close()
os.Stdout = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Verify correct edges exist
correctEdges := []string{
"BD-1 --> BD-2",
"BD-1 --> BD-3",
"BD-2 --> BD-4",
"BD-3 --> BD-5",
}
for _, edge := range correctEdges {
if !strings.Contains(output, edge) {
t.Errorf("expected edge %q to be present, got:\n%s", edge, output)
}
}
// Verify incorrect edges do NOT exist (siblings shouldn't be connected)
incorrectEdges := []string{
"BD-2 --> BD-3", // Siblings shouldn't be connected
"BD-3 --> BD-4", // BD-4's parent is BD-2, not BD-3
"BD-4 --> BD-3", // Wrong direction
"BD-4 --> BD-5", // These are cousins, not parent-child
}
for _, edge := range incorrectEdges {
if strings.Contains(output, edge) {
t.Errorf("incorrect edge %q should NOT be present, got:\n%s", edge, output)
}
}
}