feat(module): add four-phase Module interface and registry
This commit is contained in:
61
internal/module/module.go
Normal file
61
internal/module/module.go
Normal 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)
|
||||||
|
}
|
||||||
56
internal/module/registry.go
Normal file
56
internal/module/registry.go
Normal 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
|
||||||
|
}
|
||||||
57
internal/module/registry_test.go
Normal file
57
internal/module/registry_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user