From 5af9750d36b61f4c6c4fa6cb5745218f593349bb Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 25 Jun 2026 14:27:22 -0400 Subject: [PATCH] feat(visualize): add theme layer with cascade and bland built-in themes Signed-off-by: Joshua Temple --- internal/graph/command.go | 2 +- internal/graph/graph.go | 33 +++- internal/graph/graph_test.go | 99 ++++++++++ internal/visualize/emitter.go | 13 -- internal/visualize/mermaid.go | 83 +++++++- internal/visualize/testdata/env.mmd | 5 + .../visualize/testdata/representative.mmd | 7 + internal/visualize/testdata/stages.mmd | 3 + internal/visualize/theme.go | 155 +++++++++++++++ internal/visualize/theme_test.go | 178 ++++++++++++++++++ 10 files changed, 545 insertions(+), 33 deletions(-) create mode 100644 internal/visualize/theme.go create mode 100644 internal/visualize/theme_test.go diff --git a/internal/graph/command.go b/internal/graph/command.go index 39c252b1..24e092cb 100644 --- a/internal/graph/command.go +++ b/internal/graph/command.go @@ -42,7 +42,7 @@ missing or invalid manifest is reported as an error.`, cmd.Flags().StringVar(&o.ManifestKey, "manifest-key", config.DefaultManifestKey, "Key in manifest file containing CI config") cmd.Flags().StringVar(&o.Granularity, "granularity", string(GranularityJobs), "Pipeline projection to render; supported values: jobs, stages, env") cmd.Flags().StringVar(&o.Format, "format", formatMermaid, "Diagram output format; supported value: mermaid") - cmd.Flags().StringVar(&o.Theme, "theme", defaultThemeName, "Diagram theme; supported value: default") + cmd.Flags().StringVar(&o.Theme, "theme", defaultThemeName, "Diagram theme: cascade, bland, or a path to a JSON theme file") return cmd } diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 78b32f88..d39fcb01 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -34,8 +34,9 @@ const ( // behavior change. const formatMermaid = "mermaid" -// defaultThemeName is the only theme available today. It mirrors the visualize -// package default so a manifest renders without a theme flag. +// defaultThemeName is the theme applied when no theme flag is set. It mirrors +// the visualize package default (the branded cascade palette) so a manifest +// renders styled without a theme flag. var defaultThemeName = visualize.DefaultTheme.Name // Options carries the inputs to a graph render. ConfigPath and ManifestKey @@ -86,12 +87,9 @@ func Run(o Options, stdout io.Writer) error { granularity, GranularityJobs, GranularityStages, GranularityEnv) } - theme := o.Theme - if theme == "" { - theme = defaultThemeName - } - if theme != defaultThemeName { - return fmt.Errorf("unknown theme %q: only %q is available", theme, defaultThemeName) + theme, err := resolveTheme(o.Theme) + if err != nil { + return err } configPath := o.ConfigPath @@ -108,13 +106,13 @@ func Run(o Options, stdout io.Writer) error { return err } - diagram, err := visualize.NewMermaidEmitter().Emit(vm, visualize.DefaultTheme) + diagram, err := visualize.NewMermaidEmitter().Emit(vm, theme) if err != nil { return fmt.Errorf("rendering %s: %w", format, err) } if o.JSON { - return writeJSON(stdout, format, granularity, theme, diagram) + return writeJSON(stdout, format, granularity, theme.Name, diagram) } // The emitter terminates the diagram with a newline, so Fprint avoids an @@ -125,6 +123,21 @@ func Run(o Options, stdout io.Writer) error { return nil } +// resolveTheme turns the --theme value into a concrete theme. An empty value or +// a built-in name (default, cascade, bland) selects a built-in palette; any +// other value is treated as a path to a user-supplied JSON theme file, loaded +// and validated so a malformed file fails fast with a clear error. +func resolveTheme(name string) (visualize.Theme, error) { + if theme, ok := visualize.LookupTheme(name); ok { + return theme, nil + } + theme, err := visualize.LoadTheme(name) + if err != nil { + return visualize.Theme{}, fmt.Errorf("loading theme: %w", err) + } + return theme, nil +} + // buildView loads the manifest and projects it into the view model the chosen // granularity calls for. The jobs and stages projections need only the pipeline // config; the env projection also needs the per-environment state so it can draw diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go index d16004b8..cc68fad0 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -169,6 +169,105 @@ func TestRun_UnknownTheme_Errors(t *testing.T) { require.Contains(t, err.Error(), "midnight") } +func TestRun_BlandTheme_StylesOutput(t *testing.T) { + path := writeManifest(t) + o := baseOptions(path) + o.Theme = "bland" + + var out bytes.Buffer + require.NoError(t, Run(o, &out)) + + got := out.String() + require.Contains(t, got, "flowchart TD") + // The bland theme sets a neutral Mermaid base and its own line color. + require.Contains(t, got, `"theme": "neutral"`) + require.Contains(t, got, "classDef node_validate") +} + +func TestRun_CascadeAndBland_Differ(t *testing.T) { + path := writeManifest(t) + + var cascade, bland bytes.Buffer + co := baseOptions(path) + co.Theme = "cascade" + require.NoError(t, Run(co, &cascade)) + + bo := baseOptions(path) + bo.Theme = "bland" + require.NoError(t, Run(bo, &bland)) + + require.NotEqual(t, cascade.String(), bland.String()) +} + +func TestRun_DefaultAliasesCascade(t *testing.T) { + path := writeManifest(t) + + var def, cascade bytes.Buffer + do := baseOptions(path) + do.Theme = "default" + require.NoError(t, Run(do, &def)) + + co := baseOptions(path) + co.Theme = "cascade" + require.NoError(t, Run(co, &cascade)) + + require.Equal(t, cascade.String(), def.String()) +} + +func TestRun_FileTheme_Applied(t *testing.T) { + path := writeManifest(t) + + dir := t.TempDir() + themePath := filepath.Join(dir, "custom.json") + body := `{"name":"custom","base":"base","lineColor":"#abcdef","nodeStyles":{"validate":{"fill":"#123456"}}}` + require.NoError(t, os.WriteFile(themePath, []byte(body), 0o600)) + + o := baseOptions(path) + o.Theme = themePath + + var out bytes.Buffer + require.NoError(t, Run(o, &out)) + require.Contains(t, out.String(), "fill:#123456") +} + +func TestRun_FileTheme_JSONReportsName(t *testing.T) { + path := writeManifest(t) + + dir := t.TempDir() + themePath := filepath.Join(dir, "custom.json") + require.NoError(t, os.WriteFile(themePath, []byte(`{"name":"custom","base":"base"}`), 0o600)) + + o := baseOptions(path) + o.Theme = themePath + o.JSON = true + + var out bytes.Buffer + require.NoError(t, Run(o, &out)) + + var payload struct { + Theme string `json:"theme"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &payload)) + require.Equal(t, "custom", payload.Theme) +} + +func TestRun_MalformedThemeFile_Errors(t *testing.T) { + path := writeManifest(t) + + dir := t.TempDir() + themePath := filepath.Join(dir, "bad.json") + require.NoError(t, os.WriteFile(themePath, []byte("{not json"), 0o600)) + + o := baseOptions(path) + o.Theme = themePath + + var out bytes.Buffer + err := Run(o, &out) + require.Error(t, err) + require.Contains(t, err.Error(), "theme") + require.Empty(t, out.String()) +} + func TestRun_MissingManifest_Errors(t *testing.T) { o := baseOptions(filepath.Join(t.TempDir(), "absent.yaml")) diff --git a/internal/visualize/emitter.go b/internal/visualize/emitter.go index 8ccf2a73..13284730 100644 --- a/internal/visualize/emitter.go +++ b/internal/visualize/emitter.go @@ -1,18 +1,5 @@ package visualize -// Theme carries presentation choices an emitter may honor. It is a stub in this -// iteration (a default value is threaded through every emit path); later work -// fills it with concrete styling slots. Keeping it in the signature now means -// adding fields later is additive, never a breaking change. -type Theme struct { - // Name identifies the theme. The zero value selects the emitter's built-in - // default, so callers that do not care about theming pass DefaultTheme. - Name string -} - -// DefaultTheme is the neutral theme used when a caller does not supply one. -var DefaultTheme = Theme{Name: "default"} - // Options holds optional emit behavior. It is populated by the functional // Option tail rather than constructed directly, so new knobs are additive. The // zero value is valid and selects each emitter's defaults. diff --git a/internal/visualize/mermaid.go b/internal/visualize/mermaid.go index f9e868e8..a60b616f 100644 --- a/internal/visualize/mermaid.go +++ b/internal/visualize/mermaid.go @@ -20,32 +20,39 @@ var _ Emitter = (*MermaidEmitter)(nil) // Emit renders vm as Mermaid source. The diagram family follows the model's // Kind: a state machine (env projection) becomes a stateDiagram-v2, every other -// model becomes a top-down flowchart. theme is accepted for interface -// conformance and future styling; the current output does not vary by theme. A +// model becomes a top-down flowchart. theme styles the output: it is rendered +// into a leading init directive and trailing classDef and class lines, so a +// theme swap restyles the header block while leaving the structure unchanged. A // title option, if set, is emitted as a Mermaid title in the frontmatter block. -func (MermaidEmitter) Emit(vm ViewModel, _ Theme, opts ...Option) (string, error) { +func (MermaidEmitter) Emit(vm ViewModel, theme Theme, opts ...Option) (string, error) { o := applyOptions(opts) var b strings.Builder if o.Title != "" { // Mermaid reads a title from a YAML frontmatter block. Quote it so - // punctuation in the title cannot break the parse. + // punctuation in the title cannot break the parse. The block must be the + // first content, ahead of any init directive. b.WriteString("---\n") fmt.Fprintf(&b, "title: %q\n", o.Title) b.WriteString("---\n") } + // The init directive carries the theme's base and edge color. It sits after + // the frontmatter and before the diagram keyword, where Mermaid reads it. + writeThemeInit(&b, theme) + if vm.Kind == DiagramState { - return emitState(&b, vm) + return emitState(&b, vm, theme) } - return emitFlowchart(&b, vm) + return emitFlowchart(&b, vm, theme) } // emitFlowchart renders a directed-graph model (jobs and stages) as a top-down // flowchart. Nodes are declared first in model order with a shape per kind, then -// edges in model order, so dependency or stage flow reads in a stable order. -func emitFlowchart(b *strings.Builder, vm ViewModel) (string, error) { +// edges in model order, then the theme's class styling, so dependency or stage +// flow reads in a stable order and a reader can tell the node kinds apart. +func emitFlowchart(b *strings.Builder, vm ViewModel, theme Theme) (string, error) { b.WriteString("flowchart TD\n") for _, n := range vm.Nodes { @@ -68,6 +75,8 @@ func emitFlowchart(b *strings.Builder, vm ViewModel) (string, error) { } } + writeThemeClasses(b, vm, theme) + return b.String(), nil } @@ -76,7 +85,7 @@ func emitFlowchart(b *strings.Builder, vm ViewModel) (string, error) { // rather than declared states; every other node is declared with a renamed // label when its label differs from its id. Transitions follow model order and // carry their optional caption, so promote, diverge, and rejoin read distinctly. -func emitState(b *strings.Builder, vm ViewModel) (string, error) { +func emitState(b *strings.Builder, vm ViewModel, theme Theme) (string, error) { b.WriteString("stateDiagram-v2\n") // Map each node id to the token it renders as, so edges can resolve a start @@ -118,9 +127,65 @@ func emitState(b *strings.Builder, vm ViewModel) (string, error) { } } + writeThemeClasses(b, vm, theme) + return b.String(), nil } +// writeThemeInit emits the Mermaid init directive carrying the theme's base and +// edge color. It writes nothing when the theme sets neither, so an unstyled +// theme produces no directive. The directive must follow any frontmatter block +// and precede the diagram keyword. +func writeThemeInit(b *strings.Builder, theme Theme) { + if theme.Base == "" && theme.LineColor == "" { + return + } + base := theme.Base + if base == "" { + base = "base" + } + fmt.Fprintf(b, "%%%%{init: {%q: %q", "theme", base) + if theme.LineColor != "" { + fmt.Fprintf(b, ", %q: {%q: %q}", "themeVariables", "lineColor", theme.LineColor) + } + b.WriteString("}}%%\n") +} + +// writeThemeClasses emits the theme's per-kind classDef lines followed by the +// class assignments that bind each node to its kind's class. Kinds appear in +// first-encounter model order so the output is deterministic, pseudo-states +// (start and end) are skipped because they render as [*] rather than declared +// nodes, and a kind with no style is left unclassed so the renderer default +// applies. It writes nothing when the theme defines no node styles. +func writeThemeClasses(b *strings.Builder, vm ViewModel, theme Theme) { + if len(theme.NodeStyles) == 0 { + return + } + + var order []NodeKind + members := make(map[NodeKind][]string) + for _, n := range vm.Nodes { + if n.Kind == NodeStart || n.Kind == NodeEnd { + continue + } + style, ok := theme.NodeStyles[n.Kind] + if !ok || style.isZero() { + continue + } + if _, seen := members[n.Kind]; !seen { + order = append(order, n.Kind) + } + members[n.Kind] = append(members[n.Kind], mermaidID(n.ID)) + } + + for _, k := range order { + fmt.Fprintf(b, " classDef %s %s\n", className(k), classDefBody(theme.NodeStyles[k])) + } + for _, k := range order { + fmt.Fprintf(b, " class %s %s\n", strings.Join(members[k], ","), className(k)) + } +} + // nodeBrackets returns the opening and closing Mermaid shape delimiters for a // node kind. Distinct shapes let a reader tell validate, build, and deploy jobs // apart at a glance. diff --git a/internal/visualize/testdata/env.mmd b/internal/visualize/testdata/env.mmd index c9b834fb..930382cf 100644 --- a/internal/visualize/testdata/env.mmd +++ b/internal/visualize/testdata/env.mmd @@ -1,6 +1,7 @@ --- title: "environments" --- +%%{init: {"theme": "base", "themeVariables": {"lineColor": "#57606a"}}}%% stateDiagram-v2 state "hotfix/login" as staging_hotfix [*] --> dev @@ -9,3 +10,7 @@ stateDiagram-v2 prod --> [*] staging --> staging_hotfix : diverge staging_hotfix --> prod : rejoin + classDef node_env fill:#1f6feb,stroke:#0b3d91,color:#ffffff + classDef node_hotfix fill:#cf222e,stroke:#82071e,color:#ffffff + class dev,staging,prod node_env + class staging_hotfix node_hotfix diff --git a/internal/visualize/testdata/representative.mmd b/internal/visualize/testdata/representative.mmd index ef72c706..337fff82 100644 --- a/internal/visualize/testdata/representative.mmd +++ b/internal/visualize/testdata/representative.mmd @@ -1,6 +1,7 @@ --- title: "pipeline" --- +%%{init: {"theme": "base", "themeVariables": {"lineColor": "#57606a"}}}%% flowchart TD validate([Validate (validate)]) build_api[Build (api)] @@ -15,3 +16,9 @@ flowchart TD deploy_staging --> build_web deploy_prod --> validate deploy_prod --> deploy_staging + classDef node_validate fill:#1f6feb,stroke:#0b3d91,color:#ffffff + classDef node_build fill:#2da44e,stroke:#116329,color:#ffffff + classDef node_deploy fill:#8250df,stroke:#512a97,color:#ffffff + class validate node_validate + class build_api,build_web node_build + class deploy_staging,deploy_prod node_deploy diff --git a/internal/visualize/testdata/stages.mmd b/internal/visualize/testdata/stages.mmd index 5d125bc3..0ec79639 100644 --- a/internal/visualize/testdata/stages.mmd +++ b/internal/visualize/testdata/stages.mmd @@ -1,6 +1,7 @@ --- title: "stages" --- +%%{init: {"theme": "base", "themeVariables": {"lineColor": "#57606a"}}}%% flowchart TD trunk(Trunk) build(Build) @@ -11,3 +12,5 @@ flowchart TD build --> deploy deploy --> promote promote --> release + classDef node_stage fill:#bf8700,stroke:#7d4e00,color:#ffffff + class trunk,build,deploy,promote,release node_stage diff --git a/internal/visualize/theme.go b/internal/visualize/theme.go new file mode 100644 index 00000000..db2c6c7b --- /dev/null +++ b/internal/visualize/theme.go @@ -0,0 +1,155 @@ +package visualize + +import ( + "encoding/json" + "fmt" + "os" +) + +// NodeStyle holds the presentation attributes an emitter renders for one node +// kind. Each field maps to a Mermaid classDef attribute (fill, stroke, color); +// an empty field is omitted so a theme can style only the slots it cares about. +type NodeStyle struct { + // Fill is the node background color, for example "#1f6feb". + Fill string `json:"fill,omitempty"` + // Stroke is the node border color. + Stroke string `json:"stroke,omitempty"` + // Text is the node label color. + Text string `json:"text,omitempty"` +} + +// isZero reports whether the style carries no attribute worth emitting, so the +// emitter can skip an empty classDef rather than write a no-op line. +func (s NodeStyle) isZero() bool { + return s.Fill == "" && s.Stroke == "" && s.Text == "" +} + +// Theme carries the styling the Mermaid emitter renders into a diagram's header. +// It is render data only: the emitter turns it into an init directive and +// classDef lines, and the view model never sees it, so a theme swap restyles a +// diagram without reshaping the model. The zero value emits an unstyled diagram, +// so the field is always safe to thread through an emit path. +type Theme struct { + // Name identifies the theme. The built-in values use "cascade" and "bland"; + // a user-supplied theme file sets its own. + Name string `json:"name"` + // Base selects the Mermaid base theme rendered in the init directive (for + // example "base" or "neutral"). Empty omits the directive unless LineColor is + // set, in which case it falls back to "base". + Base string `json:"base,omitempty"` + // LineColor styles edges and transitions through the init directive's + // themeVariables. Empty leaves edge color to the renderer default. + LineColor string `json:"lineColor,omitempty"` + // NodeStyles maps a NodeKind to its classDef style. A kind with no entry, or + // an empty entry, renders without a class so the renderer default applies. + NodeStyles map[NodeKind]NodeStyle `json:"nodeStyles,omitempty"` +} + +// CascadeTheme is the branded default palette: a distinct accent per node kind +// over a mid-gray edge color. It is the theme cascade graph renders when no +// theme is requested. +var CascadeTheme = Theme{ + Name: "cascade", + Base: "base", + LineColor: "#57606a", + NodeStyles: map[NodeKind]NodeStyle{ + NodeValidate: {Fill: "#1f6feb", Stroke: "#0b3d91", Text: "#ffffff"}, + NodeBuild: {Fill: "#2da44e", Stroke: "#116329", Text: "#ffffff"}, + NodeDeploy: {Fill: "#8250df", Stroke: "#512a97", Text: "#ffffff"}, + NodeStage: {Fill: "#bf8700", Stroke: "#7d4e00", Text: "#ffffff"}, + NodeEnv: {Fill: "#1f6feb", Stroke: "#0b3d91", Text: "#ffffff"}, + NodeHotfix: {Fill: "#cf222e", Stroke: "#82071e", Text: "#ffffff"}, + }, +} + +// BlandTheme is a monotone grayscale palette for low-distraction or print +// contexts: every node kind sits on a light-gray fill with a gray border, and +// edges are a muted gray. It is intentionally distinct from CascadeTheme. +var BlandTheme = Theme{ + Name: "bland", + Base: "neutral", + LineColor: "#8c959f", + NodeStyles: map[NodeKind]NodeStyle{ + NodeValidate: {Fill: "#f6f8fa", Stroke: "#6e7781", Text: "#24292f"}, + NodeBuild: {Fill: "#eaeef2", Stroke: "#6e7781", Text: "#24292f"}, + NodeDeploy: {Fill: "#d0d7de", Stroke: "#57606a", Text: "#24292f"}, + NodeStage: {Fill: "#eaeef2", Stroke: "#6e7781", Text: "#24292f"}, + NodeEnv: {Fill: "#f6f8fa", Stroke: "#6e7781", Text: "#24292f"}, + NodeHotfix: {Fill: "#d0d7de", Stroke: "#57606a", Text: "#24292f"}, + }, +} + +// DefaultTheme is the theme used when a caller does not request one. It is the +// branded cascade palette, so an unparameterized emit is styled by default. +var DefaultTheme = CascadeTheme + +// LookupTheme returns the built-in theme registered under name. "default" is an +// alias for the cascade theme so existing callers keep working. The bool is +// false for any name that is not a built-in, signalling the caller to treat the +// value as a theme-file path instead. +func LookupTheme(name string) (Theme, bool) { + switch name { + case "", "default", CascadeTheme.Name: + return CascadeTheme, true + case BlandTheme.Name: + return BlandTheme, true + default: + return Theme{}, false + } +} + +// LoadTheme reads a user-supplied theme definition from a JSON file and returns +// it. It surfaces a clear error when the file cannot be read, does not parse as +// JSON, or omits the required name, so a malformed theme fails fast rather than +// rendering a silently broken diagram. +func LoadTheme(path string) (Theme, error) { + data, err := os.ReadFile(path) + if err != nil { + return Theme{}, fmt.Errorf("reading theme file %q: %w", path, err) + } + + var theme Theme + if err := json.Unmarshal(data, &theme); err != nil { + return Theme{}, fmt.Errorf("parsing theme file %q: %w", path, err) + } + if theme.Name == "" { + return Theme{}, fmt.Errorf("theme file %q: missing required field %q", path, "name") + } + return theme, nil +} + +// className renders a node kind as its Mermaid classDef name. The "node_" prefix +// keeps the class name from colliding with a node id of the same text (a node +// named "validate" styled by class "node_validate"). +func className(kind NodeKind) string { + return "node_" + string(kind) +} + +// classDefBody renders a NodeStyle as the attribute list of a Mermaid classDef, +// emitting only the set fields in a fixed order so the output is deterministic. +func classDefBody(s NodeStyle) string { + parts := make([]string, 0, 3) + if s.Fill != "" { + parts = append(parts, "fill:"+s.Fill) + } + if s.Stroke != "" { + parts = append(parts, "stroke:"+s.Stroke) + } + if s.Text != "" { + parts = append(parts, "color:"+s.Text) + } + return joinComma(parts) +} + +// joinComma joins parts with a comma. It exists so classDefBody stays free of an +// import for a single call and keeps the attribute separator in one place. +func joinComma(parts []string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += "," + } + out += p + } + return out +} diff --git a/internal/visualize/theme_test.go b/internal/visualize/theme_test.go new file mode 100644 index 00000000..2568ab34 --- /dev/null +++ b/internal/visualize/theme_test.go @@ -0,0 +1,178 @@ +package visualize + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestLookupTheme_BuiltinsAndAliases checks that the built-in names resolve and +// that "default" aliases the cascade theme, while an unknown name is reported as +// not built in so the caller can fall back to loading a file. +func TestLookupTheme_BuiltinsAndAliases(t *testing.T) { + cases := []struct { + name string + wantName string + wantOK bool + }{ + {"cascade", "cascade", true}, + {"bland", "bland", true}, + {"default", "cascade", true}, + {"", "cascade", true}, + {"midnight", "", false}, + {"./themes/custom.json", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := LookupTheme(tc.name) + if ok != tc.wantOK { + t.Fatalf("LookupTheme(%q) ok = %v, want %v", tc.name, ok, tc.wantOK) + } + if ok && got.Name != tc.wantName { + t.Errorf("LookupTheme(%q) name = %q, want %q", tc.name, got.Name, tc.wantName) + } + }) + } +} + +// TestBuiltinThemes_EmitValidMermaid checks that each built-in theme renders an +// init directive and at least one classDef and class assignment, so the styling +// reaches the diagram rather than being silently dropped. +func TestBuiltinThemes_EmitValidMermaid(t *testing.T) { + for _, theme := range []Theme{CascadeTheme, BlandTheme} { + t.Run(theme.Name, func(t *testing.T) { + out, err := NewMermaidEmitter().Emit(buildVM(t, representativeConfig()), theme) + if err != nil { + t.Fatalf("Emit: %v", err) + } + if !strings.Contains(out, "flowchart TD") { + t.Errorf("missing flowchart header in:\n%s", out) + } + if !strings.Contains(out, "%%{init:") { + t.Errorf("missing init directive in:\n%s", out) + } + if !strings.Contains(out, "classDef ") { + t.Errorf("missing classDef in:\n%s", out) + } + if !strings.Contains(out, "class ") { + t.Errorf("missing class assignment in:\n%s", out) + } + }) + } +} + +// TestCascadeAndBland_Differ asserts the two built-in themes produce distinct +// output for the same manifest, so a theme swap is visually meaningful. +func TestCascadeAndBland_Differ(t *testing.T) { + vm := buildVM(t, representativeConfig()) + + cascade, err := NewMermaidEmitter().Emit(vm, CascadeTheme) + if err != nil { + t.Fatalf("cascade emit: %v", err) + } + bland, err := NewMermaidEmitter().Emit(vm, BlandTheme) + if err != nil { + t.Fatalf("bland emit: %v", err) + } + if cascade == bland { + t.Fatalf("cascade and bland themes produced identical output:\n%s", cascade) + } +} + +// TestEmit_PerThemeDeterministic checks that emitting a theme repeatedly yields +// byte-identical output, so a styled diagram is stable run to run. +func TestEmit_PerThemeDeterministic(t *testing.T) { + for _, theme := range []Theme{CascadeTheme, BlandTheme} { + t.Run(theme.Name, func(t *testing.T) { + first, err := NewMermaidEmitter().Emit(buildVM(t, representativeConfig()), theme) + if err != nil { + t.Fatalf("first emit: %v", err) + } + for i := 0; i < 10; i++ { + next, err := NewMermaidEmitter().Emit(buildVM(t, representativeConfig()), theme) + if err != nil { + t.Fatalf("emit %d: %v", i, err) + } + if next != first { + t.Fatalf("emit %d differs:\n%s\nvs\n%s", i, next, first) + } + } + }) + } +} + +// TestLoadTheme_AppliesStyling writes a small theme file, loads it, and asserts +// its custom fill reaches the emitted classDef. +func TestLoadTheme_AppliesStyling(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "custom.json") + body := `{ + "name": "custom", + "base": "base", + "lineColor": "#abcdef", + "nodeStyles": { + "validate": {"fill": "#123456", "stroke": "#654321", "text": "#fefefe"} + } +}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write theme: %v", err) + } + + theme, err := LoadTheme(path) + if err != nil { + t.Fatalf("LoadTheme: %v", err) + } + if theme.Name != "custom" { + t.Errorf("theme name = %q, want custom", theme.Name) + } + + out, err := NewMermaidEmitter().Emit(buildVM(t, representativeConfig()), theme) + if err != nil { + t.Fatalf("Emit: %v", err) + } + if !strings.Contains(out, "fill:#123456") { + t.Errorf("custom fill not applied in:\n%s", out) + } + if !strings.Contains(out, `"lineColor": "#abcdef"`) { + t.Errorf("custom line color not applied in:\n%s", out) + } +} + +// TestLoadTheme_MissingFile_Errors checks that an absent theme path errors +// clearly rather than rendering an unstyled diagram. +func TestLoadTheme_MissingFile_Errors(t *testing.T) { + _, err := LoadTheme(filepath.Join(t.TempDir(), "absent.json")) + if err == nil { + t.Fatal("expected error for missing theme file, got nil") + } + if !strings.Contains(err.Error(), "absent.json") { + t.Errorf("error should name the file, got: %v", err) + } +} + +// TestLoadTheme_Malformed_Errors checks that invalid JSON and a missing name +// both produce a clear error. +func TestLoadTheme_Malformed_Errors(t *testing.T) { + dir := t.TempDir() + + bad := filepath.Join(dir, "bad.json") + if err := os.WriteFile(bad, []byte("{not json"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := LoadTheme(bad); err == nil { + t.Error("expected error for malformed JSON, got nil") + } + + noName := filepath.Join(dir, "noname.json") + if err := os.WriteFile(noName, []byte(`{"base": "base"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + _, err := LoadTheme(noName) + if err == nil { + t.Fatal("expected error for theme missing name, got nil") + } + if !strings.Contains(err.Error(), "name") { + t.Errorf("error should mention the missing name field, got: %v", err) + } +}