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