diff --git a/internal/module/module.go b/internal/module/module.go new file mode 100644 index 0000000..a286066 --- /dev/null +++ b/internal/module/module.go @@ -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) +} diff --git a/internal/module/registry.go b/internal/module/registry.go new file mode 100644 index 0000000..303f725 --- /dev/null +++ b/internal/module/registry.go @@ -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 +} diff --git a/internal/module/registry_test.go b/internal/module/registry_test.go new file mode 100644 index 0000000..3536051 --- /dev/null +++ b/internal/module/registry_test.go @@ -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) + } + } + }) +}