feat(module): add four-phase Module interface and registry

This commit is contained in:
MDietermann
2026-04-13 19:29:18 +02:00
parent 5b5e677749
commit eb54225ad3
3 changed files with 174 additions and 0 deletions

61
internal/module/module.go Normal file
View File

@@ -0,0 +1,61 @@
// Package module defines the four-phase contract every installable unit
// implements, along with the value types exchanged with the engine.
package module
import "context"
// Meta is static, declarative information about a module.
type Meta struct {
ID string // stable machine identifier, e.g. "packages"
Name string // human label, e.g. "Base packages"
Description string // one-line summary for TUI
Deps []string // IDs of modules that must complete before this one
}
// State is an opaque, module-defined snapshot of current system state.
// The engine never inspects it; it is threaded from Detect → Plan.
type State any
// ActionKind categorizes what an Action does, for UI grouping only.
type ActionKind int
const (
ActionUnknown ActionKind = iota
ActionPackageInstall
ActionFileWrite
ActionSymlink
ActionCommand
ActionGitClone
ActionGroupAdd
)
// Action is a single planned change with a human-readable description.
type Action struct {
Description string
Kind ActionKind
Exec func(ctx context.Context) error
}
// Plan is an ordered list of actions returned by Module.Plan.
type Plan struct {
Actions []Action
}
// IsEmpty reports whether the plan would make no changes.
func (p Plan) IsEmpty() bool { return len(p.Actions) == 0 }
// Module is the four-phase contract.
type Module interface {
Meta() Meta
Detect(ctx context.Context) (State, error)
Plan(ctx context.Context, current State) (Plan, error)
Apply(ctx context.Context, plan Plan, r Reporter) error
Verify(ctx context.Context) error
}
// Reporter is how modules stream progress events. Defined here as an
// interface to avoid an import cycle with the engine package.
type Reporter interface {
Progress(moduleID string, current, total int, message string)
Log(moduleID, line string)
}

View File

@@ -0,0 +1,56 @@
package module
import (
"fmt"
"sort"
"sync"
)
// Registry holds all known modules keyed by Meta.ID.
type Registry struct {
mu sync.RWMutex
modules map[string]Module
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry {
return &Registry{modules: make(map[string]Module)}
}
// Register adds a module. Returns an error if the ID is already present
// or empty. Safe for concurrent use.
func (r *Registry) Register(m Module) error {
id := m.Meta().ID
if id == "" {
return fmt.Errorf("module has empty ID")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, dup := r.modules[id]; dup {
return fmt.Errorf("module %q already registered", id)
}
r.modules[id] = m
return nil
}
// Get returns the module with the given ID, or (nil, false) if absent.
func (r *Registry) Get(id string) (Module, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.modules[id]
return m, ok
}
// All returns all registered modules sorted by ID for determinism.
func (r *Registry) All() []Module {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Module, 0, len(r.modules))
for _, m := range r.modules {
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Meta().ID < out[j].Meta().ID
})
return out
}

View File

@@ -0,0 +1,57 @@
package module
import (
"context"
"testing"
)
type fakeModule struct{ id string; deps []string }
func (f fakeModule) Meta() Meta { return Meta{ID: f.id, Name: f.id, Deps: f.deps} }
func (f fakeModule) Detect(context.Context) (State, error) { return nil, nil }
func (f fakeModule) Plan(context.Context, State) (Plan, error) { return Plan{}, nil }
func (f fakeModule) Apply(context.Context, Plan, Reporter) error { return nil }
func (f fakeModule) Verify(context.Context) error { return nil }
func TestRegistry(t *testing.T) {
t.Run("register then get", func(t *testing.T) {
r := NewRegistry()
if err := r.Register(fakeModule{id: "a"}); err != nil {
t.Fatal(err)
}
m, ok := r.Get("a")
if !ok || m.Meta().ID != "a" {
t.Fatalf("want module a, got %v ok=%v", m, ok)
}
})
t.Run("duplicate register errors", func(t *testing.T) {
r := NewRegistry()
_ = r.Register(fakeModule{id: "a"})
if err := r.Register(fakeModule{id: "a"}); err == nil {
t.Fatal("expected duplicate error")
}
})
t.Run("empty id errors", func(t *testing.T) {
r := NewRegistry()
if err := r.Register(fakeModule{id: ""}); err == nil {
t.Fatal("expected empty-id error")
}
})
t.Run("all is sorted by id", func(t *testing.T) {
r := NewRegistry()
_ = r.Register(fakeModule{id: "b"})
_ = r.Register(fakeModule{id: "a"})
_ = r.Register(fakeModule{id: "c"})
all := r.All()
got := []string{all[0].Meta().ID, all[1].Meta().ID, all[2].Meta().ID}
want := []string{"a", "b", "c"}
for i := range got {
if got[i] != want[i] {
t.Fatalf("All() = %v, want %v", got, want)
}
}
})
}