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
58 changes: 58 additions & 0 deletions internal/visualize/emitter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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.
type Options struct {
// Title, when set, is rendered as a diagram heading by emitters that
// support one. An empty title emits no heading.
Title string
}

// Option configures emit behavior. Following the repo convention, required
// inputs are positional on Emit and optional behavior arrives as a variadic
// Option tail, so capabilities can be added without changing the signature.
type Option func(*Options)

// WithTitle sets a diagram heading. Emitters that have no notion of a heading
// ignore it.
func WithTitle(title string) Option {
return func(o *Options) { o.Title = title }
}

// applyOptions folds a variadic Option tail onto a zero Options value and
// returns the result, so each Emit call starts from a clean, default state.
func applyOptions(opts []Option) Options {
var o Options
for _, fn := range opts {
if fn != nil {
fn(&o)
}
}
return o
}

// Emitter turns a render-agnostic ViewModel into diagram source. It is the
// pluggable seam of the package: Mermaid is the first implementation, and a
// future renderer is added by writing another Emitter without touching the view
// model. Required inputs (the model and a theme) are positional; optional
// behavior arrives as a variadic Option tail.
type Emitter interface {
// Emit renders vm using theme and any options, returning the diagram source.
// It returns an error only when the model cannot be expressed as valid
// source; a well-formed model always emits successfully.
Emit(vm ViewModel, theme Theme, opts ...Option) (string, error)
}
100 changes: 100 additions & 0 deletions internal/visualize/mermaid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package visualize

import (
"fmt"
"strings"
)

// MermaidEmitter renders a ViewModel as Mermaid flowchart source that GitHub
// renders natively in Markdown. It is the first concrete Emitter. Output is
// deterministic: it ranges the model's ordered slices and never iterates a map,
// so the same model always yields byte-identical source.
type MermaidEmitter struct{}

// NewMermaidEmitter returns a ready MermaidEmitter. The type is stateless, so
// the constructor exists only to give callers a stable construction point.
func NewMermaidEmitter() *MermaidEmitter { return &MermaidEmitter{} }

// compile-time assertion that MermaidEmitter satisfies the Emitter seam.
var _ Emitter = (*MermaidEmitter)(nil)

// Emit renders vm as a top-down Mermaid flowchart. Nodes are declared first in
// model order with class-tagged shapes per kind, then hard edges (solid arrows)
// and optional edges (dotted arrows) in model order, so the two dependency
// kinds are visually distinct. theme is accepted for interface conformance and
// future styling; the current output does not vary by theme. 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) {
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.
b.WriteString("---\n")
fmt.Fprintf(&b, "title: %q\n", o.Title)
b.WriteString("---\n")
}

b.WriteString("flowchart TD\n")

for _, n := range vm.Nodes {
// Node shape per kind: stadium for validate, rectangle for build,
// subroutine for deploy. The label is bracket-escaped so a display name
// with brackets cannot terminate the node early.
open, close := nodeBrackets(n.Kind)
fmt.Fprintf(&b, " %s%s%s%s\n", mermaidID(n.ID), open, mermaidLabel(n.Label), close)
}

for _, e := range vm.Edges {
switch e.Kind {
case EdgeOptional:
// Dotted arrow marks an ordering-only optional dependency.
fmt.Fprintf(&b, " %s -.-> %s\n", mermaidID(e.From), mermaidID(e.To))
case EdgeHard:
fmt.Fprintf(&b, " %s --> %s\n", mermaidID(e.From), mermaidID(e.To))
default:
return "", fmt.Errorf("visualize: mermaid: unknown edge kind %q", e.Kind)
}
}

return b.String(), nil
}

// 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.
func nodeBrackets(kind NodeKind) (string, string) {
switch kind {
case NodeValidate:
return "([", "])" // stadium
case NodeDeploy:
return "[[", "]]" // subroutine
case NodeBuild:
return "[", "]" // rectangle
default:
return "[", "]"
}
}

// mermaidID sanitizes a job ID into a Mermaid node identifier. Cascade job IDs
// are already prefixed slugs (validate, build-app), so only the hyphen needs
// folding to an underscore to stay inside Mermaid's identifier rules.
func mermaidID(id string) string {
return strings.ReplaceAll(id, "-", "_")
}

// mermaidLabel escapes a display label for use inside a Mermaid node shape.
// Brackets would otherwise close the shape early and quotes would confuse the
// parser, so both are replaced with HTML entities Mermaid renders verbatim.
func mermaidLabel(label string) string {
r := strings.NewReplacer(
"[", "[",
"]", "]",
"(", "(",
")", ")",
`"`, """,
)
return r.Replace(label)
}
17 changes: 17 additions & 0 deletions internal/visualize/testdata/representative.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
title: "pipeline"
---
flowchart TD
validate([Validate (validate)])
build_api[Build (api)]
build_web[Build (web)]
deploy_staging[[Deploy (staging)]]
deploy_prod[[Deploy (prod)]]
build_api --> validate
build_web --> validate
build_web -.-> build_api
deploy_staging --> validate
deploy_staging --> build_api
deploy_staging --> build_web
deploy_prod --> validate
deploy_prod --> deploy_staging
131 changes: 131 additions & 0 deletions internal/visualize/viewmodel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Package visualize builds a render-agnostic view of a cascade pipeline and
// emits it as a diagram. The view model carries no diagram syntax; concrete
// Emitter implementations (Mermaid is the first) turn it into renderer-specific
// source. The separation keeps the projection independently testable and lets a
// richer renderer be added later without reshaping the model.
package visualize

import (
"fmt"

"github.com/stablekernel/cascade/internal/generate"
)

// NodeKind classifies a pipeline node for rendering. The set mirrors the
// callback types cascade already models (validate, build, deploy). It is a
// rendering hint only; an emitter may map several kinds to one visual style.
type NodeKind string

// Node kinds, one per cascade callback type.
const (
NodeValidate NodeKind = "validate"
NodeBuild NodeKind = "build"
NodeDeploy NodeKind = "deploy"
)

// EdgeKind classifies a dependency edge. Hard edges come from Edges (they both
// order a job and skip-gate it); optional edges come from OptionalEdges (they
// order only). Emitters render the two with visually distinct styling so a
// reader can tell a blocking dependency from an ordering-only one.
type EdgeKind string

// Edge kinds.
const (
EdgeHard EdgeKind = "hard"
EdgeOptional EdgeKind = "optional"
)

// Node is one pipeline job in the view. ID is the stable, prefixed job ID
// (validate, build-app, deploy-app) used as the diagram node identity. Label is
// the human-facing display name. Kind drives styling.
type Node struct {
ID string
Label string
Kind NodeKind
}

// Edge is one dependency from a job to one of its dependencies. From is the
// dependent job ID, To is the job it depends on, and Kind separates hard from
// optional dependencies.
type Edge struct {
From string
To string
Kind EdgeKind
}

// ViewModel is the deterministic, render-agnostic description of a pipeline's
// job DAG. Nodes follow manifest declaration order (the graph's Order seed) and
// edges follow node order then dependency-list order, so two builds of the same
// manifest produce byte-identical emitter output. The model holds no diagram
// syntax.
type ViewModel struct {
Nodes []Node
Edges []Edge
}

// BuildViewModel projects a generated DependencyGraph into a render-agnostic
// ViewModel. It walks the graph in Order (the same deterministic seed
// TopologicalSort uses) so node and edge slices are stable run to run, and it
// rejects a cyclic graph by surfacing the cycle TopologicalSort detects rather
// than emitting a malformed diagram. The returned model depends only on the
// cascade model and carries no Mermaid (or other) syntax.
func BuildViewModel(g *generate.DependencyGraph) (ViewModel, error) {
if g == nil {
return ViewModel{}, fmt.Errorf("visualize: nil dependency graph")
}

// A cyclic DAG has no valid diagram; reuse the generator's cycle detection
// so the failure surfaces here at projection time, never as a panic or a
// misleading partial diagram downstream.
if _, err := g.TopologicalSort(); err != nil {
return ViewModel{}, fmt.Errorf("visualize: %w", err)
}

vm := ViewModel{
Nodes: make([]Node, 0, len(g.Order)),
}

for _, id := range g.Order {
info, ok := g.Nodes[id]
if !ok {
// Order is built alongside Nodes, so a missing entry signals a
// corrupt graph rather than a recoverable state.
return ViewModel{}, fmt.Errorf("visualize: order references unknown node %q", id)
}
vm.Nodes = append(vm.Nodes, Node{
ID: info.JobID,
Label: info.DisplayName,
Kind: nodeKind(info.Type),
})
}

// Emit edges grouped by dependent (in Order), then in dependency-list order
// within each group, so the slice is deterministic. Hard edges precede
// optional edges for the same node to keep the visual reading order stable.
for _, id := range g.Order {
for _, dep := range g.Edges[id] {
vm.Edges = append(vm.Edges, Edge{From: id, To: dep, Kind: EdgeHard})
}
for _, dep := range g.OptionalEdges[id] {
vm.Edges = append(vm.Edges, Edge{From: id, To: dep, Kind: EdgeOptional})
}
}

return vm, nil
}

// nodeKind maps a cascade callback type string to a view NodeKind. An unknown
// type falls back to NodeBuild so rendering degrades gracefully rather than
// dropping the node.
func nodeKind(callbackType string) NodeKind {
switch callbackType {
case "validate":
return NodeValidate
case "deploy":
return NodeDeploy
case "build":
return NodeBuild
default:
return NodeBuild
}
}
Loading
Loading