diff --git a/common/labeled_entity.go b/common/labeled_entity.go index 1297ed9..d8e86a3 100644 --- a/common/labeled_entity.go +++ b/common/labeled_entity.go @@ -1,6 +1,9 @@ package common -import "errors" +import ( + "errors" + "fmt" +) type LabelsCollection map[string]string @@ -26,7 +29,7 @@ func (e *LabeledEntity) Label(label string) (string, error) { value, ok := e.labels[label] if !ok { - return "", errLabelNotFound + return "", fmt.Errorf("%w , label: %s", errLabelNotFound, label) } return value, nil diff --git a/export/dot.go b/export/dot.go new file mode 100644 index 0000000..ce55f42 --- /dev/null +++ b/export/dot.go @@ -0,0 +1,175 @@ +package export + +import ( + "bytes" + "fmt" + "github.com/hovsep/fmesh" + "github.com/hovsep/fmesh/component" + "github.com/hovsep/fmesh/port" + "github.com/lucasepe/dot" +) + +type dotExporter struct { +} + +const nodeIDLabel = "export/dot/id" + +func NewDotExporter() Exporter { + return &dotExporter{} +} + +// Export returns the f-mesh represented as digraph in DOT language +func (d *dotExporter) Export(fm *fmesh.FMesh) ([]byte, error) { + if len(fm.Components()) == 0 { + return nil, nil + } + + graph, err := buildGraph(fm) + + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + graph.Write(buf) + + return buf.Bytes(), nil +} + +// buildGraph returns a graph representing the given f-mesh +func buildGraph(fm *fmesh.FMesh) (*dot.Graph, error) { + mainGraph := getMainGraph(fm) + + addComponents(mainGraph, fm.Components()) + + err := addPipes(mainGraph, fm.Components()) + if err != nil { + return nil, err + } + return mainGraph, nil +} + +// addPipes adds pipes representation to the graph +func addPipes(graph *dot.Graph, components component.Collection) error { + for _, c := range components { + for _, srcPort := range c.Outputs() { + for _, destPort := range srcPort.Pipes() { + // Any destination port in any pipe is input port, but we do not know in which component + // so we use the label we added earlier + destPortID, err := destPort.Label(nodeIDLabel) + if err != nil { + return fmt.Errorf("failed to add pipe: %w", err) + } + // Clean up and leave the f-mesh as it was before export + destPort.DeleteLabel(nodeIDLabel) + + // Any source port in any pipe is always output port, so we can build its node ID + srcPortNode := graph.FindNodeByID(getPortID(c.Name(), "output", srcPort.Name())) + destPortNode := graph.FindNodeByID(destPortID) + graph.Edge(srcPortNode, destPortNode).Attr("minlen", 3) + } + } + } + return nil +} + +// addComponents adds components representation to the graph +func addComponents(graph *dot.Graph, components component.Collection) { + for _, c := range components { + // Component + componentSubgraph := getComponentSubgraph(graph, c) + componentNode := getComponentNode(componentSubgraph, c) + + // Input ports + for _, p := range c.Inputs() { + portNode := getPortNode(c, p, "input", componentSubgraph) + componentSubgraph.Edge(portNode, componentNode) + } + + // Output ports + for _, p := range c.Outputs() { + portNode := getPortNode(c, p, "output", componentSubgraph) + componentSubgraph.Edge(componentNode, portNode) + } + } +} + +// getPortNode creates and returns a node representing one port +func getPortNode(c *component.Component, port *port.Port, portKind string, componentSubgraph *dot.Graph) *dot.Node { + portID := getPortID(c.Name(), portKind, port.Name()) + + //Mark ports to be able to find their respective nodes later when adding pipes + port.AddLabel(nodeIDLabel, portID) + + portNode := componentSubgraph.NodeWithID(portID) + portNode. + Attr("label", port.Name()). + Attr("shape", "circle"). + Attr("group", c.Name()) + return portNode +} + +// getComponentSubgraph creates component subgraph and returns it +func getComponentSubgraph(graph *dot.Graph, component *component.Component) *dot.Graph { + componentSubgraph := graph.NewSubgraph() + componentSubgraph. + NodeBaseAttrs(). + Attr("width", "1.0").Attr("height", "1.0") + componentSubgraph. + Attr("label", component.Name()). + Attr("cluster", "true"). + Attr("style", "rounded"). + Attr("color", "black"). + Attr("bgcolor", "lightgrey"). + Attr("margin", "20") + + return componentSubgraph +} + +// getComponentNodeCreate creates component node and returns it +func getComponentNode(componentSubgraph *dot.Graph, component *component.Component) *dot.Node { + componentNode := componentSubgraph.Node() + componentNode.Attr("label", "𝑓") + if component.Description() != "" { + componentNode.Attr("label", component.Description()) + } + componentNode. + Attr("color", "blue"). + Attr("shape", "rect"). + Attr("group", component.Name()) + return componentNode +} + +// getMainGraph creates and returns the main (root) graph +func getMainGraph(fm *fmesh.FMesh) *dot.Graph { + graph := dot.NewGraph(dot.Directed) + graph. + Attr("layout", "dot"). + Attr("splines", "ortho") + + if fm.Description() != "" { + addDescription(graph, fm.Description()) + } + + return graph +} + +func addDescription(graph *dot.Graph, description string) { + descriptionSubgraph := graph.NewSubgraph() + descriptionSubgraph. + Attr("label", "Description:"). + Attr("color", "green"). + Attr("fontcolor", "green"). + Attr("style", "dashed") + descriptionNode := descriptionSubgraph.Node() + descriptionNode. + Attr("shape", "plaintext"). + Attr("color", "green"). + Attr("fontcolor", "green"). + Attr("label", description) +} + +// getPortID returns unique ID used to locate ports while building pipe edges +func getPortID(componentName string, portKind string, portName string) string { + return fmt.Sprintf("component/%s/%s/%s", componentName, portKind, portName) +} diff --git a/export/dot_test.go b/export/dot_test.go new file mode 100644 index 0000000..2f1a682 --- /dev/null +++ b/export/dot_test.go @@ -0,0 +1,76 @@ +package export + +import ( + "github.com/hovsep/fmesh" + "github.com/hovsep/fmesh/component" + "github.com/hovsep/fmesh/port" + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_dotExporter_Export(t *testing.T) { + type args struct { + fm *fmesh.FMesh + } + tests := []struct { + name string + args args + assertions func(t *testing.T, data []byte, err error) + }{ + { + name: "empty f-mesh", + args: args{ + fm: fmesh.New("fm"), + }, + assertions: func(t *testing.T, data []byte, err error) { + assert.NoError(t, err) + assert.Empty(t, data) + }, + }, + { + name: "happy path", + args: args{ + fm: func() *fmesh.FMesh { + adder := component.New("adder"). + WithDescription("This component adds 2 numbers"). + WithInputs("num1", "num2"). + WithOutputs("result"). + WithActivationFunc(func(inputs port.Collection, outputs port.Collection) error { + //The activation func can be even empty, does not affect export + return nil + }) + + multiplier := component.New("multiplier"). + WithDescription("This component multiplies number by 3"). + WithInputs("num"). + WithOutputs("result"). + WithActivationFunc(func(inputs port.Collection, outputs port.Collection) error { + //The activation func can be even empty, does not affect export + return nil + }) + + adder.Outputs().ByName("result").PipeTo(multiplier.Inputs().ByName("num")) + + fm := fmesh.New("fm"). + WithDescription("This f-mesh has just one component"). + WithComponents(adder, multiplier) + return fm + }(), + }, + assertions: func(t *testing.T, data []byte, err error) { + assert.NoError(t, err) + assert.NotEmpty(t, data) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exporter := NewDotExporter() + + got, err := exporter.Export(tt.args.fm) + if tt.assertions != nil { + tt.assertions(t, got, err) + } + }) + } +} diff --git a/export/exporter.go b/export/exporter.go new file mode 100644 index 0000000..8888403 --- /dev/null +++ b/export/exporter.go @@ -0,0 +1,8 @@ +package export + +import "github.com/hovsep/fmesh" + +// Exporter is the common interface for all formats +type Exporter interface { + Export(fm *fmesh.FMesh) ([]byte, error) +} diff --git a/go.mod b/go.mod index b38dabe..ec2fd61 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require github.com/stretchr/testify v1.9.0 require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/lucasepe/dot v0.4.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 60ce688..c22838b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/lucasepe/dot v0.4.3 h1:gqQaH00pPlyK21GgCtoYI70pwOsrjsjJFBgegYm2+/E= +github.com/lucasepe/dot v0.4.3/go.mod h1:5gEWjskJdc7e0jMJJLg/PVNv5ynTLvdTYq9OTgphX8Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= diff --git a/port/port.go b/port/port.go index d988ef3..2b8f6cb 100644 --- a/port/port.go +++ b/port/port.go @@ -28,6 +28,11 @@ func (p *Port) Signals() signal.Group { return p.signals } +// Pipes getter +func (p *Port) Pipes() Group { + return p.pipes +} + // setSignals sets signals field func (p *Port) setSignals(signals signal.Group) { p.signals = signals