Skip to content
Merged
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
2 changes: 1 addition & 1 deletion internal/graph/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
33 changes: 23 additions & 10 deletions internal/graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
99 changes: 99 additions & 0 deletions internal/graph/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down
13 changes: 0 additions & 13 deletions internal/visualize/emitter.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
83 changes: 74 additions & 9 deletions internal/visualize/mermaid.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -68,6 +75,8 @@ func emitFlowchart(b *strings.Builder, vm ViewModel) (string, error) {
}
}

writeThemeClasses(b, vm, theme)

return b.String(), nil
}

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions internal/visualize/testdata/env.mmd
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: "environments"
---
%%{init: {"theme": "base", "themeVariables": {"lineColor": "#57606a"}}}%%
stateDiagram-v2
state "hotfix/login" as staging_hotfix
[*] --> dev
Expand All @@ -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
7 changes: 7 additions & 0 deletions internal/visualize/testdata/representative.mmd
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: "pipeline"
---
%%{init: {"theme": "base", "themeVariables": {"lineColor": "#57606a"}}}%%
flowchart TD
validate([Validate (validate)])
build_api[Build (api)]
Expand All @@ -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
3 changes: 3 additions & 0 deletions internal/visualize/testdata/stages.mmd
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: "stages"
---
%%{init: {"theme": "base", "themeVariables": {"lineColor": "#57606a"}}}%%
flowchart TD
trunk(Trunk)
build(Build)
Expand All @@ -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
Loading
Loading