feat(engine): add topological sort and descendants for module DAG
This commit is contained in:
91
internal/engine/dag.go
Normal file
91
internal/engine/dag.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TopoSort returns module IDs in a valid dependency order.
|
||||||
|
// Ties are broken alphabetically for deterministic output.
|
||||||
|
// Returns an error on unknown deps or cycles.
|
||||||
|
func TopoSort(metas []module.Meta) ([]string, error) {
|
||||||
|
known := make(map[string]bool, len(metas))
|
||||||
|
for _, m := range metas {
|
||||||
|
known[m.ID] = true
|
||||||
|
}
|
||||||
|
for _, m := range metas {
|
||||||
|
for _, d := range m.Deps {
|
||||||
|
if !known[d] {
|
||||||
|
return nil, fmt.Errorf("module %q depends on unknown %q", m.ID, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn's algorithm with alphabetical tie-breaking.
|
||||||
|
indeg := make(map[string]int, len(metas))
|
||||||
|
children := make(map[string][]string, len(metas))
|
||||||
|
for _, m := range metas {
|
||||||
|
if _, ok := indeg[m.ID]; !ok {
|
||||||
|
indeg[m.ID] = 0
|
||||||
|
}
|
||||||
|
for _, d := range m.Deps {
|
||||||
|
indeg[m.ID]++
|
||||||
|
children[d] = append(children[d], m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ready []string
|
||||||
|
for id, d := range indeg {
|
||||||
|
if d == 0 {
|
||||||
|
ready = append(ready, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(ready)
|
||||||
|
|
||||||
|
var order []string
|
||||||
|
for len(ready) > 0 {
|
||||||
|
next := ready[0]
|
||||||
|
ready = ready[1:]
|
||||||
|
order = append(order, next)
|
||||||
|
kids := append([]string(nil), children[next]...)
|
||||||
|
sort.Strings(kids)
|
||||||
|
for _, c := range kids {
|
||||||
|
indeg[c]--
|
||||||
|
if indeg[c] == 0 {
|
||||||
|
ready = append(ready, c)
|
||||||
|
sort.Strings(ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(order) != len(metas) {
|
||||||
|
return nil, fmt.Errorf("dependency cycle detected among modules")
|
||||||
|
}
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descendants returns the set of module IDs transitively downstream of root.
|
||||||
|
// Root itself is NOT included.
|
||||||
|
func Descendants(metas []module.Meta, root string) map[string]bool {
|
||||||
|
children := make(map[string][]string)
|
||||||
|
for _, m := range metas {
|
||||||
|
for _, d := range m.Deps {
|
||||||
|
children[d] = append(children[d], m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make(map[string]bool)
|
||||||
|
var walk func(string)
|
||||||
|
walk = func(id string) {
|
||||||
|
for _, c := range children[id] {
|
||||||
|
if out[c] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[c] = true
|
||||||
|
walk(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(root)
|
||||||
|
return out
|
||||||
|
}
|
||||||
79
internal/engine/dag_test.go
Normal file
79
internal/engine/dag_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTopoSort(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
metas []module.Meta
|
||||||
|
want []string
|
||||||
|
err bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "linear chain",
|
||||||
|
metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"b"}}},
|
||||||
|
want: []string{"a", "b", "c"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "independent modules sorted alphabetically",
|
||||||
|
metas: []module.Meta{{ID: "c"}, {ID: "a"}, {ID: "b"}},
|
||||||
|
want: []string{"a", "b", "c"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "diamond",
|
||||||
|
metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"a"}}, {ID: "d", Deps: []string{"b", "c"}}},
|
||||||
|
want: []string{"a", "b", "c", "d"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown dep",
|
||||||
|
metas: []module.Meta{{ID: "a", Deps: []string{"missing"}}},
|
||||||
|
err: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cycle",
|
||||||
|
metas: []module.Meta{{ID: "a", Deps: []string{"b"}}, {ID: "b", Deps: []string{"a"}}},
|
||||||
|
err: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := TopoSort(tc.metas)
|
||||||
|
if tc.err {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("want error, got nil (order=%v)", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Fatalf("order = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDescendants(t *testing.T) {
|
||||||
|
metas := []module.Meta{
|
||||||
|
{ID: "a"},
|
||||||
|
{ID: "b", Deps: []string{"a"}},
|
||||||
|
{ID: "c", Deps: []string{"a"}},
|
||||||
|
{ID: "d", Deps: []string{"b"}},
|
||||||
|
{ID: "e"}, // independent
|
||||||
|
}
|
||||||
|
got := Descendants(metas, "a")
|
||||||
|
want := map[string]bool{"b": true, "c": true, "d": true}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("descendants(a) = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(Descendants(metas, "e")) != 0 {
|
||||||
|
t.Fatalf("descendants(e) should be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user