diff --git a/cmd/render.go b/cmd/render.go index 1b7171c..a58bfdf 100644 --- a/cmd/render.go +++ b/cmd/render.go @@ -1,15 +1,40 @@ package cmd import ( + "context" "fmt" "github.com/spf13/cobra" + "dep-tree/internal/dep_tree" "dep-tree/internal/js" "dep-tree/internal/tui" ) +func printStructured[T any]( + ctx context.Context, + entrypoint string, + parserBuilder func(string) (dep_tree.NodeParser[T], error), +) error { + parser, err := parserBuilder(entrypoint) + if err != nil { + return err + } + _, dt, err := dep_tree.NewDepTree(ctx, parser) + if err != nil { + return err + } + output, err := dt.RenderStructured(parser.Display) + if err != nil { + return err + } + fmt.Println(string(output)) + return nil +} + func RenderCmd() *cobra.Command { + var jsonFormat bool + cmd := &cobra.Command{ Use: "render ", Short: "Render the dependency tree starting from the provided entrypoint", @@ -19,6 +44,10 @@ func RenderCmd() *cobra.Command { entrypoint := args[0] if endsWith(entrypoint, js.Extensions) { + if jsonFormat { + return printStructured(ctx, entrypoint, js.MakeJsParser) + } + return tui.Loop( ctx, entrypoint, @@ -31,5 +60,7 @@ func RenderCmd() *cobra.Command { }, } + cmd.Flags().BoolVar(&jsonFormat, "json", false, "render the dependency try in a machine readable json format") + return cmd } diff --git a/internal/dep_tree/.render_test/Children and Parents should be consistent.json b/internal/dep_tree/.render_test/Children and Parents should be consistent.json new file mode 100755 index 0000000..b719d16 --- /dev/null +++ b/internal/dep_tree/.render_test/Children and Parents should be consistent.json @@ -0,0 +1,12 @@ +{ + "tree": { + "0": { + "1": null, + "2": { + "1": null + } + } + }, + "circularDependencies": [], + "errors": {} +} \ No newline at end of file diff --git a/internal/dep_tree/.render_test/Cyclic deps.json b/internal/dep_tree/.render_test/Cyclic deps.json new file mode 100755 index 0000000..3ea133c --- /dev/null +++ b/internal/dep_tree/.render_test/Cyclic deps.json @@ -0,0 +1,15 @@ +{ + "tree": { + "0": { + "1": null + } + }, + "circularDependencies": [ + [ + "1", + "2", + "1" + ] + ], + "errors": {} +} \ No newline at end of file diff --git a/internal/dep_tree/.render_test/Simple.json b/internal/dep_tree/.render_test/Simple.json new file mode 100755 index 0000000..421c8bb --- /dev/null +++ b/internal/dep_tree/.render_test/Simple.json @@ -0,0 +1,32 @@ +{ + "tree": { + "0": { + "1": { + "2": { + "3": null, + "4": { + "3": null + } + }, + "4": { + "3": null + } + }, + "2": { + "3": null, + "4": { + "3": null + } + }, + "3": null + } + }, + "circularDependencies": [ + [ + "3", + "4", + "3" + ] + ], + "errors": {} +} \ No newline at end of file diff --git a/internal/dep_tree/.render_test/Some nodes have errors.json b/internal/dep_tree/.render_test/Some nodes have errors.json new file mode 100755 index 0000000..b1b78be --- /dev/null +++ b/internal/dep_tree/.render_test/Some nodes have errors.json @@ -0,0 +1,27 @@ +{ + "tree": { + "0": { + "1": { + "2": { + "3": null, + "4": null + }, + "4": null + }, + "2": { + "3": null, + "4": null + }, + "3": null + } + }, + "circularDependencies": [], + "errors": { + "1": [ + "4275 not present in spec" + ], + "3": [ + "1423 not present in spec" + ] + } +} \ No newline at end of file diff --git a/internal/dep_tree/.render_test/Some nodes have errors.txt b/internal/dep_tree/.render_test/Some nodes have errors.txt new file mode 100755 index 0000000..db4919a --- /dev/null +++ b/internal/dep_tree/.render_test/Some nodes have errors.txt @@ -0,0 +1,8 @@ +0 +│ +├▷1 +│ │ +├─┼▷2 +│ │ │ +└─│─┼▷3 + └─┴▷4 diff --git a/internal/dep_tree/.render_test/Two in the same level.json b/internal/dep_tree/.render_test/Two in the same level.json new file mode 100755 index 0000000..245238e --- /dev/null +++ b/internal/dep_tree/.render_test/Two in the same level.json @@ -0,0 +1,15 @@ +{ + "tree": { + "0": { + "1": { + "3": null + }, + "2": { + "3": null + }, + "3": null + } + }, + "circularDependencies": [], + "errors": {} +} \ No newline at end of file diff --git a/internal/dep_tree/.render_test/Weird cycle combination.json b/internal/dep_tree/.render_test/Weird cycle combination.json new file mode 100755 index 0000000..58d654d --- /dev/null +++ b/internal/dep_tree/.render_test/Weird cycle combination.json @@ -0,0 +1,18 @@ +{ + "tree": { + "0": { + "1": { + "2": null + } + } + }, + "circularDependencies": [ + [ + "2", + "3", + "4", + "2" + ] + ], + "errors": {} +} \ No newline at end of file diff --git a/internal/dep_tree/.render_test/Weird cycle combination.txt b/internal/dep_tree/.render_test/Weird cycle combination.txt new file mode 100755 index 0000000..3ad2f9d --- /dev/null +++ b/internal/dep_tree/.render_test/Weird cycle combination.txt @@ -0,0 +1,8 @@ +0 +│ +└▷1 + │4◁───┐ + ││ │ + └┴▷2 │ + │ │ + └▷3┘ diff --git a/internal/dep_tree/level.go b/internal/dep_tree/level.go index 748d248..901ad8c 100644 --- a/internal/dep_tree/level.go +++ b/internal/dep_tree/level.go @@ -66,9 +66,17 @@ func (lc *LevelCalculator[T]) calculateLevel( var level int ctx, level = lc.calculateLevel(ctx, parent.Id, stack) if level == cyclic { + cycleStack := []string{parent.Id} + for _, stackElement := range stack { + cycleStack = append(cycleStack, stackElement) + if stackElement == parent.Id { + break + } + } + lc.Cycles.Set(dep, DepCycle{ Cause: dep, - Stack: append([]string{parent.Id}, stack...), + Stack: cycleStack, }) } else if level > maxLevel { maxLevel = level diff --git a/internal/dep_tree/level_test.go b/internal/dep_tree/level_test.go index 85bb198..51954fd 100644 --- a/internal/dep_tree/level_test.go +++ b/internal/dep_tree/level_test.go @@ -82,6 +82,18 @@ func TestNode_Level(t *testing.T) { ExpectedLevels: []int{0, 3, 4, 1, 2}, ExpectedCycles: [][]string{{"1", "2", "3", "4", "1"}}, }, + { + Name: "Cycle 6", + Children: map[int][]int{ + 0: {1}, + 1: {2}, + 2: {3}, + 3: {4}, + 4: {2}, + }, + ExpectedLevels: []int{0, 1, 2, 3, 1}, + ExpectedCycles: [][]string{{"2", "3", "4", "2"}}, + }, { Name: "Avoid same level", Children: map[int][]int{ diff --git a/internal/dep_tree/render.go b/internal/dep_tree/render.go index 9919466..e32eb79 100644 --- a/internal/dep_tree/render.go +++ b/internal/dep_tree/render.go @@ -1,7 +1,8 @@ package dep_tree import ( - "context" + "encoding/json" + "fmt" "strconv" "dep-tree/internal/board" @@ -16,10 +17,7 @@ const ConnectorOriginNodeIdTag = "connectorOrigin" const ConnectorDestinationNodeIdTag = "connectorDestination" const NodeParentsTag = "nodeParents" -func (dt *DepTree[T]) Render( - ctx context.Context, - display func(node *graph.Node[T]) string, -) (context.Context, *board.Board, error) { +func (dt *DepTree[T]) Render(display func(node *graph.Node[T]) string) (*board.Board, error) { b := board.MakeBoard() lastLevel := -1 @@ -64,7 +62,7 @@ func (dt *DepTree[T]) Render( }, ) if err != nil { - return ctx, nil, err + return nil, err } } @@ -77,9 +75,69 @@ func (dt *DepTree[T]) Render( err := b.AddConnector(n.Node.Id, child.Id, tags) if err != nil { - return ctx, nil, err + return nil, err + } + } + } + return b, nil +} + +type StructuredTree struct { + Tree map[string]interface{} `json:"tree" yaml:"tree"` + CircularDependencies [][]string `json:"circularDependencies" yaml:"circularDependencies"` + Errors map[string][]string `json:"errors" yaml:"errors"` +} + +func (dt *DepTree[T]) makeStructuredTree( + node string, + display func(node *graph.Node[T]) string, +) map[string]interface{} { + var result map[string]interface{} + for _, child := range dt.Graph.Children(node) { + if _, ok := dt.Cycles.Get([2]string{node, child.Id}); ok { + continue + } + if result == nil { + result = make(map[string]interface{}) + } + result[display(child)] = dt.makeStructuredTree(child.Id, display) + } + return result +} + +func (dt *DepTree[T]) RenderStructured(display func(node *graph.Node[T]) string) ([]byte, error) { + root := dt.Graph.Get(dt.RootId) + if root == nil { + return nil, fmt.Errorf("could not retrieve root node from rootId %s", dt.RootId) + } + + structuredTree := StructuredTree{ + Tree: map[string]interface{}{ + display(root): dt.makeStructuredTree(dt.RootId, display), + }, + CircularDependencies: make([][]string, 0), + Errors: make(map[string][]string), + } + + for _, cycle := range dt.Cycles.Keys() { + cycleDep, _ := dt.Cycles.Get(cycle) + renderedCycle := make([]string, len(cycleDep.Stack)) + for i, cycleDepEntry := range cycleDep.Stack { + renderedCycle[i] = display(dt.Graph.Get(cycleDepEntry)) + } + structuredTree.CircularDependencies = append(structuredTree.CircularDependencies, renderedCycle) + } + + for _, node := range dt.Nodes { + if node.Node.Errors != nil && len(node.Node.Errors) > 0 { + erroredNode := display(dt.Graph.Get(node.Node.Id)) + errors := make([]string, len(node.Node.Errors)) + for i, err := range node.Node.Errors { + errors[i] = err.Error() } + structuredTree.Errors[erroredNode] = errors } } - return ctx, b, nil + + return json.MarshalIndent(structuredTree, "", " ") } diff --git a/internal/dep_tree/render_test.go b/internal/dep_tree/render_test.go index 063ffe2..21f42e1 100644 --- a/internal/dep_tree/render_test.go +++ b/internal/dep_tree/render_test.go @@ -11,7 +11,10 @@ import ( const RebuildTestsEnv = "REBUILD_TESTS" -const testDir = ".render_test" +const ( + testDir = ".render_test" + RebuiltTestEnvTrue = "true" +) func fileExists(path string) bool { _, err := os.Stat(path) @@ -58,6 +61,26 @@ func TestRenderGraph(t *testing.T) { {1}, }, }, + { + Name: "Weird cycle combination", + Spec: [][]int{ + 0: {1}, + 1: {2}, + 2: {3}, + 3: {4}, + 4: {2}, + }, + }, + { + Name: "Some nodes have errors", + Spec: [][]int{ + {1, 2, 3}, + {2, 4, 4275}, + {3, 4}, + {1423}, + {}, + }, + }, } for _, tt := range tests { @@ -68,19 +91,16 @@ func TestRenderGraph(t *testing.T) { Spec: tt.Spec, } - ctx := context.Background() - - ctx, dt, err := NewDepTree[[]int](ctx, &testParser) + _, dt, err := NewDepTree[[]int](context.Background(), &testParser) a.NoError(err) - _, board, err := dt.Render(ctx, testParser.Display) + board, err := dt.Render(testParser.Display) a.NoError(err) result, err := board.Render() a.NoError(err) - print(result) outFile := path.Join(testDir, path.Base(tt.Name+".txt")) - if fileExists(outFile) && os.Getenv(RebuildTestsEnv) != "true" { + if fileExists(outFile) && os.Getenv(RebuildTestsEnv) != RebuiltTestEnvTrue { expected, err := os.ReadFile(outFile) a.NoError(err) a.Equal(string(expected), result) @@ -89,6 +109,20 @@ func TestRenderGraph(t *testing.T) { err := os.WriteFile(outFile, []byte(result), os.ModePerm) a.NoError(err) } + + rendered, err := dt.RenderStructured(testParser.Display) + a.NoError(err) + + renderOutFile := path.Join(testDir, path.Base(tt.Name+".json")) + if fileExists(renderOutFile) && os.Getenv(RebuildTestsEnv) != RebuiltTestEnvTrue { + expected, err := os.ReadFile(renderOutFile) + a.NoError(err) + a.Equal(string(expected), string(rendered)) + } else { + _ = os.Mkdir(testDir, os.ModePerm) + err := os.WriteFile(renderOutFile, rendered, os.ModePerm) + a.NoError(err) + } }) } } diff --git a/internal/dep_tree/test_parser.go b/internal/dep_tree/test_parser.go index 4d23b46..fc4b341 100644 --- a/internal/dep_tree/test_parser.go +++ b/internal/dep_tree/test_parser.go @@ -22,7 +22,7 @@ func (t *TestParser) getNode(id string) (*graph.Node[[]int], error) { } var children []int if idInt >= len(t.Spec) { - return nil, fmt.Errorf("%s not present in spec", t.Start) + return nil, fmt.Errorf("%d not present in spec", idInt) } else { children = t.Spec[idInt] } @@ -36,8 +36,12 @@ func (t *TestParser) Entrypoint() (*graph.Node[[]int], error) { func (t *TestParser) Deps(ctx context.Context, n *graph.Node[[]int]) (context.Context, []*graph.Node[[]int], error) { result := make([]*graph.Node[[]int], 0) for _, child := range n.Data { - c, _ := t.getNode(strconv.Itoa(child)) - result = append(result, c) + c, err := t.getNode(strconv.Itoa(child)) + if err != nil { + n.Errors = append(n.Errors, err) + } else { + result = append(result, c) + } } return ctx, result, nil } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a41fa2e..d390dae 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -27,7 +27,7 @@ func Loop[T any]( if err != nil { return err } - _, board, err := dt.Render(ctx, parser.Display) + board, err := dt.Render(parser.Display) if err != nil { return err }