From 37761e017ebd933b710021dbd9cdba9742dcb8f0 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Mon, 13 Apr 2026 19:39:35 +0200 Subject: [PATCH] feat(engine): add topological sort and descendants for module DAG --- internal/engine/dag.go | 91 +++++++++++++++++++++++++++++++++++++ internal/engine/dag_test.go | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 internal/engine/dag.go create mode 100644 internal/engine/dag_test.go diff --git a/internal/engine/dag.go b/internal/engine/dag.go new file mode 100644 index 0000000..a11ace4 --- /dev/null +++ b/internal/engine/dag.go @@ -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 +} diff --git a/internal/engine/dag_test.go b/internal/engine/dag_test.go new file mode 100644 index 0000000..93961c6 --- /dev/null +++ b/internal/engine/dag_test.go @@ -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") + } +}