diff --git a/cli/src/cmd/app/commands/graph.go b/cli/src/cmd/app/commands/graph.go index 91f48ffef..b37d09d5a 100644 --- a/cli/src/cmd/app/commands/graph.go +++ b/cli/src/cmd/app/commands/graph.go @@ -20,6 +20,7 @@ const ( graphOutputMarkdown = "markdown" graphOutputMermaid = "mermaid" graphOutputDOT = "dot" + graphOutputD2 = "d2" ) type graphOptions struct { @@ -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 to narrow the graph to one service, everything it depends @@ -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 @@ -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 @@ -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() @@ -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) } @@ -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) + } +} diff --git a/cli/src/cmd/app/commands/graph_test.go b/cli/src/cmd/app/commands/graph_test.go index 717ee7813..9c4b5e880 100644 --- a/cli/src/cmd/app/commands/graph_test.go +++ b/cli/src/cmd/app/commands/graph_test.go @@ -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})