Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions cli/src/cmd/app/commands/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const (
graphOutputMarkdown = "markdown"
graphOutputMermaid = "mermaid"
graphOutputDOT = "dot"
graphOutputD2 = "d2"
)

type graphOptions struct {
Expand Down Expand Up @@ -59,7 +60,7 @@ func NewGraphCommand() *cobra.Command {
Long: `Show services, resources, dependency edges, and startup levels from azure.yaml.

Use --output to change the output. text, json, and markdown print to stdout.
mermaid and dot emit a diagram you can drop into a README or an architecture doc.
mermaid, dot, and d2 emit a diagram you can drop into a README or an architecture doc.
Combine with --output-file to write the result to a file instead of stdout.

Pass --focus <service> to narrow the graph to one service, everything it depends
Expand All @@ -75,6 +76,9 @@ Examples:
# Graphviz DOT to stdout
azd app graph --output dot

# D2 diagram written to a file
azd app graph --output d2 --output-file docs/services.d2

# Markdown tables for docs or issue comments
azd app graph --output markdown

Expand All @@ -85,7 +89,7 @@ Examples:
return runGraph(opts)
},
}
cmd.Flags().StringVarP(&opts.output, "output", "o", graphOutputText, "Output format: text, json, markdown, mermaid, or dot")
cmd.Flags().StringVarP(&opts.output, "output", "o", graphOutputText, "Output format: text, json, markdown, mermaid, dot, or d2")
cmd.Flags().StringVar(&opts.outputFile, "output-file", "", "Write output to this file instead of stdout")
cmd.Flags().StringVar(&opts.focus, "focus", "", "Limit the graph to a service, its dependencies, and its dependents")
return cmd
Expand All @@ -102,9 +106,9 @@ func runGraph(opts *graphOptions) error {
opts.output = graphOutputText
}
switch opts.output {
case graphOutputText, graphOutputJSON, graphOutputMarkdown, graphOutputMermaid, graphOutputDOT:
case graphOutputText, graphOutputJSON, graphOutputMarkdown, graphOutputMermaid, graphOutputDOT, graphOutputD2:
default:
return fmt.Errorf("invalid output format: %s (must be text, json, markdown, mermaid, or dot)", opts.output)
return fmt.Errorf("invalid output format: %s (must be text, json, markdown, mermaid, dot, or d2)", opts.output)
}

azureYamlPath, err := findAzureYaml()
Expand Down Expand Up @@ -166,6 +170,8 @@ func renderGraph(w io.Writer, format string, result graphResult) error {
renderGraphMermaid(w, result)
case graphOutputDOT:
renderGraphDOT(w, result)
case graphOutputD2:
renderGraphD2(w, result)
default:
printGraphText(w, result)
}
Expand Down Expand Up @@ -499,3 +505,42 @@ func renderGraphDOT(w io.Writer, result graphResult) {
}
_, _ = fmt.Fprintln(w, "}")
}

// escapeD2Label makes a string safe to use inside a D2 double-quoted label.
// D2 double-quoted strings cannot contain a literal double quote, so quotes are
// swapped for single quotes and newlines are collapsed to spaces.
func escapeD2Label(s string) string {
replacer := strings.NewReplacer(
"\"", "'",
"\n", " ",
)
return replacer.Replace(s)
}

func renderGraphD2(w io.Writer, result graphResult) {
ids := mermaidNodeIDs(result.Nodes)

_, _ = fmt.Fprintln(w, "direction: right")
for _, node := range result.Nodes {
label := node.Name
if node.Type != "" {
label += " (" + node.Type + ")"
}
id := ids[node.Name]
// Resources use a cylinder to read like a datastore, matching how the
// other diagram formats distinguish resources from services.
if node.Type == "resource" {
_, _ = fmt.Fprintf(w, "%s: \"%s\" {\n shape: cylinder\n}\n", id, escapeD2Label(label))
} else {
_, _ = fmt.Fprintf(w, "%s: \"%s\"\n", id, escapeD2Label(label))
}
}
for _, edge := range result.Edges {
from, okFrom := ids[edge.From]
to, okTo := ids[edge.To]
if !okFrom || !okTo {
continue
}
_, _ = fmt.Fprintf(w, "%s -> %s\n", from, to)
}
}
50 changes: 50 additions & 0 deletions cli/src/cmd/app/commands/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,56 @@ func TestEscapeDOTString(t *testing.T) {
}
}

func TestRenderGraphD2(t *testing.T) {
var buf bytes.Buffer
renderGraphD2(&buf, sampleGraphResult())
out := buf.String()

if !strings.HasPrefix(out, "direction: right") {
t.Fatalf("d2 output should start with direction: right:\n%s", out)
}
for _, want := range []string{
"napi_0: \"api (service)\"",
"ndb_1: \"db (resource)\" {",
"shape: cylinder",
"napi_0 -> ndb_1",
} {
if !strings.Contains(out, want) {
t.Fatalf("d2 output missing %q:\n%s", want, out)
}
}
// Service nodes must not carry a shape block.
if strings.Contains(out, "napi_0: \"api (service)\" {") {
t.Fatalf("service node should not have a shape block:\n%s", out)
}
}

func TestRenderGraphD2Dispatch(t *testing.T) {
var buf bytes.Buffer
if err := renderGraph(&buf, graphOutputD2, sampleGraphResult()); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(buf.String(), "napi_0 -> ndb_1") {
t.Fatalf("renderGraph did not dispatch to d2:\n%s", buf.String())
}
}

func TestEscapeD2Label(t *testing.T) {
tests := []struct {
in string
want string
}{
{"plain", "plain"},
{"say \"hi\"", "say 'hi'"},
{"line\nbreak", "line break"},
}
for _, tt := range tests {
if got := escapeD2Label(tt.in); got != tt.want {
t.Errorf("escapeD2Label(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}

func TestRunGraphInvalidFormat(t *testing.T) {
var buf bytes.Buffer
err := runGraph(&graphOptions{output: "svg", writer: &buf})
Expand Down
Loading