diff --git a/internal/logging/enricher.go b/internal/logging/enricher.go index 2d53335..008f32b 100644 --- a/internal/logging/enricher.go +++ b/internal/logging/enricher.go @@ -47,7 +47,7 @@ func (e *ReferenceUpdater[T]) UpdateArgs(args ...any) []any { // Where an argument is of type resource.ID, try and retrieve the // resource corresponding to the ID and replace argument with the // resource. - if id, ok := arg.(resource.ID); ok { + if id, ok := arg.(resource.MonotonicID); ok { t, err := e.Get(id) if err != nil { continue @@ -68,7 +68,7 @@ func (e *ReferenceUpdater[T]) UpdateArgs(args ...any) []any { if !f.IsValid() { continue } - id, ok := f.Interface().(resource.ID) + id, ok := f.Interface().(resource.MonotonicID) if !ok { continue } diff --git a/internal/logging/enricher_test.go b/internal/logging/enricher_test.go index 5881d5e..f59bc19 100644 --- a/internal/logging/enricher_test.go +++ b/internal/logging/enricher_test.go @@ -8,7 +8,7 @@ import ( ) func TestReferenceUpdater(t *testing.T) { - res := &fakeResource{ID: resource.NewID(resource.Module)} + res := &fakeResource{MonotonicID: resource.NewMonotonicID(resource.Module)} updater := &ReferenceUpdater[*fakeResource]{ Getter: &fakeResourceGetter{res: res}, Name: "fake", @@ -16,7 +16,7 @@ func TestReferenceUpdater(t *testing.T) { } t.Run("replace resource id with resource", func(t *testing.T) { - args := []any{"fake", res.ID} + args := []any{"fake", res.MonotonicID} got := updater.UpdateArgs(args...) want := []any{"fake", res} @@ -25,10 +25,10 @@ func TestReferenceUpdater(t *testing.T) { t.Run("add resource when referenced from struct with pointer field", func(t *testing.T) { type logMsgArg struct { - FakeResourceID *resource.ID + FakeResourceID *resource.MonotonicID } - args := []any{"arg1", logMsgArg{FakeResourceID: &res.ID}} + args := []any{"arg1", logMsgArg{FakeResourceID: &res.MonotonicID}} got := updater.UpdateArgs(args...) want := append(args, "fake", res) @@ -37,10 +37,10 @@ func TestReferenceUpdater(t *testing.T) { t.Run("add resource when referenced from struct with non-pointer field", func(t *testing.T) { type logMsgArg struct { - FakeResourceID resource.ID + FakeResourceID resource.MonotonicID } - args := []any{"arg1", logMsgArg{FakeResourceID: res.ID}} + args := []any{"arg1", logMsgArg{FakeResourceID: res.MonotonicID}} got := updater.UpdateArgs(args...) want := append(args, "fake", res) @@ -49,7 +49,7 @@ func TestReferenceUpdater(t *testing.T) { t.Run("handle nil pointer from struct", func(t *testing.T) { type logMsgArg struct { - FakeResourceID *resource.ID + FakeResourceID *resource.MonotonicID } args := []any{"arg1", logMsgArg{FakeResourceID: nil}} @@ -60,7 +60,7 @@ func TestReferenceUpdater(t *testing.T) { } type fakeResource struct { - resource.ID + resource.MonotonicID } type fakeResourceGetter struct { diff --git a/internal/logging/message.go b/internal/logging/message.go index 374fffc..ae6c5cd 100644 --- a/internal/logging/message.go +++ b/internal/logging/message.go @@ -8,23 +8,19 @@ import ( // Message is the event payload for a log message type Message struct { - // A message is a pug resource, but only insofar as it makes it easier to - // handle consistently alongside all other resources (modules, workspaces, - // etc) in the TUI. - resource.ID - + ID resource.MonotonicID Time time.Time Level string Message string `json:"msg"` Attributes []Attr } +func (m Message) GetID() resource.ID { return m.ID } + type Attr struct { + ID resource.MonotonicID Key string Value string - - // An attribute is a pug resource, but only insofar as it makes it easier to - // handle consistently alongside all other resources (modules, workspaces, - // etc) in the TUI. - resource.ID } + +func (a Attr) GetID() resource.ID { return a.ID } diff --git a/internal/logging/sort.go b/internal/logging/sort.go index 6a61009..51ce89f 100644 --- a/internal/logging/sort.go +++ b/internal/logging/sort.go @@ -2,7 +2,7 @@ package logging // BySerialDesc sorts log messages by their serial. func BySerialDesc(i, j Message) int { - if i.Serial < j.Serial { + if i.ID.Serial < j.ID.Serial { return 1 } return -1 diff --git a/internal/logging/writer.go b/internal/logging/writer.go index 4f33537..4bcd113 100644 --- a/internal/logging/writer.go +++ b/internal/logging/writer.go @@ -19,7 +19,7 @@ func (w *writer) Write(p []byte) (int, error) { d := logfmt.NewDecoder(bytes.NewReader(p)) for d.ScanRecord() { msg := Message{ - ID: resource.NewID(resource.Log), + ID: resource.NewMonotonicID(resource.Log), } for d.ScanKeyval() { switch string(d.Key()) { @@ -37,7 +37,7 @@ func (w *writer) Write(p []byte) (int, error) { msg.Attributes = append(msg.Attributes, Attr{ Key: string(d.Key()), Value: string(d.Value()), - ID: resource.NewID(resource.LogAttr), + ID: resource.NewMonotonicID(resource.LogAttr), }) } } diff --git a/internal/module/module.go b/internal/module/module.go index 70c5825..957cca7 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -16,12 +16,12 @@ import ( // Module is a terraform root module. type Module struct { - resource.ID + ID resource.MonotonicID // Path relative to pug working directory Path string // The module's current workspace. - CurrentWorkspaceID *resource.ID + CurrentWorkspaceID resource.ID // The module's backend type Backend string @@ -41,7 +41,7 @@ type Options struct { // New constructs a module. func New(opts Options) *Module { return &Module{ - ID: resource.NewID(resource.Module), + ID: resource.NewMonotonicID(resource.Module), Path: opts.Path, Backend: opts.Backend, } diff --git a/internal/module/service.go b/internal/module/service.go index 01d4090..bc48906 100644 --- a/internal/module/service.go +++ b/internal/module/service.go @@ -215,7 +215,7 @@ func (s *Service) Init(moduleID resource.ID, upgrade bool) (task.Spec, error) { args = append(args, "-upgrade") } spec := task.Spec{ - ModuleID: &mod.ID, + ModuleID: mod.ID, Path: mod.Path, Identifier: InitTask, Execution: task.Execution{ @@ -236,7 +236,7 @@ func (s *Service) Format(moduleID resource.ID) (task.Spec, error) { return task.Spec{}, err } spec := task.Spec{ - ModuleID: &mod.ID, + ModuleID: mod.ID, Path: mod.Path, Execution: task.Execution{ TerraformCommand: []string{"fmt"}, @@ -253,7 +253,7 @@ func (s *Service) Validate(moduleID resource.ID) (task.Spec, error) { return task.Spec{}, err } spec := task.Spec{ - ModuleID: &mod.ID, + ModuleID: mod.ID, Path: mod.Path, Execution: task.Execution{ TerraformCommand: []string{"validate"}, @@ -284,7 +284,7 @@ func (s *Service) GetByPath(path string) (*Module, error) { // SetCurrent sets the current workspace for the module. func (s *Service) SetCurrent(moduleID, workspaceID resource.ID) error { _, err := s.table.Update(moduleID, func(existing *Module) error { - existing.CurrentWorkspaceID = &workspaceID + existing.CurrentWorkspaceID = workspaceID return nil }) return err @@ -297,7 +297,7 @@ func (s *Service) Execute(moduleID resource.ID, program string, args ...string) return task.Spec{}, err } spec := task.Spec{ - ModuleID: &mod.ID, + ModuleID: mod.ID, Path: mod.Path, Execution: task.Execution{ Program: program, diff --git a/internal/module/service_test.go b/internal/module/service_test.go index 5902300..61f83ab 100644 --- a/internal/module/service_test.go +++ b/internal/module/service_test.go @@ -71,11 +71,11 @@ digraph { assert.Len(t, vpc.Dependencies(), 0) // redis if assert.Len(t, redis.Dependencies(), 1) { - assert.Equal(t, vpc.ID, redis.Dependencies()[0].GetID()) + assert.Equal(t, vpc.ID, redis.Dependencies()[0]) } // mysql if assert.Len(t, mysql.Dependencies(), 1) { - assert.Equal(t, vpc.ID, mysql.Dependencies()[0].GetID()) + assert.Equal(t, vpc.ID, mysql.Dependencies()[0]) } // backend if assert.Len(t, backend.Dependencies(), 3) { diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 29feff6..44eb155 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -15,8 +15,7 @@ import ( ) type plan struct { - resource.ID - + ID resource.MonotonicID ModuleID resource.ID WorkspaceID resource.ID ModulePath string @@ -34,7 +33,7 @@ type plan struct { // taskID is the ID of the plan task, and is only set once the task is // created. - taskID *resource.ID + taskID resource.ID } type CreateOptions struct { @@ -66,7 +65,7 @@ func (f *factory) newPlan(workspaceID resource.ID, opts CreateOptions) (*plan, e return nil, fmt.Errorf("retrieving module: %w", err) } plan := &plan{ - ID: resource.NewID(resource.Plan), + ID: resource.NewMonotonicID(resource.Plan), ModuleID: mod.ID, WorkspaceID: ws.ID, ModulePath: mod.Path, @@ -78,7 +77,7 @@ func (f *factory) newPlan(workspaceID resource.ID, opts CreateOptions) (*plan, e moduleDependencies: mod.Dependencies(), } if opts.planFile { - plan.ArtefactsPath = filepath.Join(f.dataDir, fmt.Sprintf("%d", plan.Serial)) + plan.ArtefactsPath = filepath.Join(f.dataDir, fmt.Sprintf("%d", plan.ID.Serial)) if err := os.MkdirAll(plan.ArtefactsPath, 0o755); err != nil { return nil, fmt.Errorf("creating run artefacts directory: %w", err) } @@ -105,8 +104,8 @@ func (r *plan) planTaskSpec() task.Spec { // TODO: assert planFile is true first spec := task.Spec{ Identifier: PlanTask, - ModuleID: &r.ModuleID, - WorkspaceID: &r.WorkspaceID, + ModuleID: r.ModuleID, + WorkspaceID: r.WorkspaceID, Path: r.ModulePath, Env: r.envs, Execution: task.Execution{ @@ -117,7 +116,7 @@ func (r *plan) planTaskSpec() task.Spec { Blocking: true, Description: "plan", AfterCreate: func(t *task.Task) { - r.taskID = &t.ID + r.taskID = t.ID }, BeforeExited: func(t *task.Task) (task.Summary, error) { out, err := io.ReadAll(t.NewReader(false)) @@ -153,8 +152,8 @@ func (r *plan) applyTaskSpec() (task.Spec, error) { } spec := task.Spec{ Identifier: ApplyTask, - ModuleID: &r.ModuleID, - WorkspaceID: &r.WorkspaceID, + ModuleID: r.ModuleID, + WorkspaceID: r.WorkspaceID, Path: r.ModulePath, Execution: task.Execution{ TerraformCommand: []string{"apply"}, diff --git a/internal/plan/service.go b/internal/plan/service.go index 518914d..33aca04 100644 --- a/internal/plan/service.go +++ b/internal/plan/service.go @@ -83,11 +83,11 @@ func (s *Service) ReloadAfterApply(sub <-chan resource.Event[*task.Task]) { if workspaceID == nil { continue } - if _, err := s.states.CreateReloadTask(*workspaceID); err != nil { - s.logger.Error("reloading state after apply", "error", err, "workspace", *workspaceID) + if _, err := s.states.CreateReloadTask(workspaceID); err != nil { + s.logger.Error("reloading state after apply", "error", err, "workspace", workspaceID) continue } - s.logger.Debug("reloading state after apply", "workspace", *workspaceID) + s.logger.Debug("reloading state after apply", "workspace", workspaceID) } } } @@ -150,7 +150,7 @@ func (s *Service) Get(runID resource.ID) (*plan, error) { func (s *Service) getByTaskID(taskID resource.ID) (*plan, error) { for _, plan := range s.List() { - if plan.taskID != nil && *plan.taskID == taskID { + if plan.taskID != nil && plan.taskID == taskID { return plan, nil } } diff --git a/internal/pubsub/broker.go b/internal/pubsub/broker.go index 5c07f9d..b6585be 100644 --- a/internal/pubsub/broker.go +++ b/internal/pubsub/broker.go @@ -17,7 +17,7 @@ type Logger interface { } // Broker allows clients to publish events and subscribe to events -type Broker[T resource.Resource] struct { +type Broker[T any] struct { subs map[chan resource.Event[T]]struct{} // subscriptions mu sync.Mutex // sync access to map done chan struct{} // close when broker is shutting down @@ -25,7 +25,7 @@ type Broker[T resource.Resource] struct { } // NewBroker constructs a pub/sub broker. -func NewBroker[T resource.Resource](logger Logger) *Broker[T] { +func NewBroker[T any](logger Logger) *Broker[T] { b := &Broker[T]{ subs: make(map[chan resource.Event[T]]struct{}), done: make(chan struct{}), diff --git a/internal/resource/event.go b/internal/resource/event.go index 2397037..d145a32 100644 --- a/internal/resource/event.go +++ b/internal/resource/event.go @@ -11,7 +11,7 @@ type ( EventType string // Event represents an event in the lifecycle of a resource - Event[T Resource] struct { + Event[T any] struct { Type EventType Payload T } diff --git a/internal/resource/id.go b/internal/resource/id.go index b3b4b58..183ac97 100644 --- a/internal/resource/id.go +++ b/internal/resource/id.go @@ -1,45 +1,9 @@ package resource -import ( - "fmt" - "sync" -) +// ID uniquely identifies a Pug resource. +type ID any -var ( - // nextID provides the next ID for each kind - nextID map[Kind]uint = make(map[Kind]uint) - mu sync.Mutex -) - -// ID is a unique identifier for a pug resource. -type ID struct { - Serial uint - Kind Kind -} - -func NewID(kind Kind) ID { - mu.Lock() - defer mu.Unlock() - - id := nextID[kind] - nextID[kind]++ - - return ID{ - Serial: id, - Kind: kind, - } -} - -func (id ID) String() string { - return fmt.Sprintf("#%d", id.Serial) -} - -// GetID allows ID to be accessed via an interface value. -func (id ID) GetID() ID { - return id -} - -// GetKind allows Kind to be accessed via an interface value. -func (id ID) GetKind() Kind { - return id.Kind +// Identifiable is a Pug resource with an identity. +type Identifiable interface { + GetID() ID } diff --git a/internal/resource/id_monotonic.go b/internal/resource/id_monotonic.go new file mode 100644 index 0000000..d84d896 --- /dev/null +++ b/internal/resource/id_monotonic.go @@ -0,0 +1,37 @@ +package resource + +import ( + "fmt" + "sync" +) + +var ( + // nextMonotonicID provides the next monotonic ID for each kind + nextMonotonicID map[Kind]uint = make(map[Kind]uint) + mu sync.Mutex +) + +// MonotonicID is an identifier based on an ever-increasing serial number, and a +// kind to differentiate it from other kinds of identifiers. +type MonotonicID struct { + Serial uint + Kind Kind +} + +func NewMonotonicID(kind Kind) MonotonicID { + mu.Lock() + defer mu.Unlock() + + id := nextMonotonicID[kind] + nextMonotonicID[kind]++ + + return MonotonicID{ + Serial: id, + Kind: kind, + } +} + +// String provides a human readable representation of the identifier. +func (id MonotonicID) String() string { + return fmt.Sprintf("#%d", id.Serial) +} diff --git a/internal/resource/id_test.go b/internal/resource/id_test.go index 5e9d15b..6bd3611 100644 --- a/internal/resource/id_test.go +++ b/internal/resource/id_test.go @@ -8,10 +8,10 @@ import ( ) func TestID_String(t *testing.T) { - mod := NewID(Module) - ws := NewID(Workspace) - run := NewID(Plan) - task := NewID(Task) + mod := NewMonotonicID(Module) + ws := NewMonotonicID(Workspace) + run := NewMonotonicID(Plan) + task := NewMonotonicID(Task) t.Run("string", func(t *testing.T) { assert.True(t, strings.HasPrefix(mod.String(), "#")) diff --git a/internal/resource/resource.go b/internal/resource/resource.go index dd11501..7410be0 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -3,9 +3,7 @@ package resource // Resource is a unique pug entity type Resource interface { // GetID retrieves the unique identifier for the resource. - GetID() ID - // GetKind retrieves the kind of resource. - GetKind() Kind + GetID() MonotonicID // String is a human-readable identifier for the resource. Not necessarily // unique across pug. String() string diff --git a/internal/state/resource.go b/internal/state/resource.go index e434381..6a7d62f 100644 --- a/internal/state/resource.go +++ b/internal/state/resource.go @@ -8,21 +8,16 @@ import ( // Resource is a pug state resource. type Resource struct { - resource.ID - + ID resource.MonotonicID WorkspaceID resource.ID Address ResourceAddress Attributes map[string]any Tainted bool } -func (r *Resource) String() string { - return string(r.Address) -} - func newResource(workspaceID resource.ID, addr ResourceAddress, attrs json.RawMessage) (*Resource, error) { res := &Resource{ - ID: resource.NewID(resource.StateResource), + ID: resource.NewMonotonicID(resource.StateResource), WorkspaceID: workspaceID, Address: addr, } @@ -32,4 +27,10 @@ func newResource(workspaceID resource.ID, addr ResourceAddress, attrs json.RawMe return res, nil } +func (r *Resource) String() string { + return string(r.Address) +} + +func (r *Resource) GetID() resource.ID { return r.ID } + type ResourceAddress string diff --git a/internal/state/service.go b/internal/state/service.go index 27b5457..1acc59d 100644 --- a/internal/state/service.go +++ b/internal/state/service.go @@ -144,8 +144,8 @@ func (s *Service) createTaskSpec(workspaceID resource.ID, opts task.Spec) (task. if err != nil { return task.Spec{}, err } - opts.ModuleID = &mod.ID - opts.WorkspaceID = &ws.ID + opts.ModuleID = mod.ID + opts.WorkspaceID = ws.ID opts.Env = []string{ws.TerraformEnv()} opts.Path = mod.Path diff --git a/internal/state/state.go b/internal/state/state.go index 55a2fe5..fc19592 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -12,8 +12,7 @@ import ( ) type State struct { - resource.ID - + ID resource.MonotonicID WorkspaceID resource.ID Resources map[ResourceAddress]*Resource Serial int64 @@ -24,7 +23,7 @@ type State struct { func newState(workspaceID resource.ID, r io.Reader) (*State, error) { // Default to a serial of -1 to indicate that there is no state yet. state := &State{ - ID: resource.NewID(resource.State), + ID: resource.NewMonotonicID(resource.State), WorkspaceID: workspaceID, Serial: -1, } @@ -88,6 +87,8 @@ func newState(workspaceID resource.ID, r io.Reader) (*State, error) { return state, nil } +func (s *State) GetID() resource.ID { return s.ID } + func (s *State) LogValue() slog.Value { return slog.GroupValue( slog.Int("resources", len(s.Resources)), diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 89d22cf..eb7e399 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -82,5 +82,4 @@ func TestState(t *testing.T) { } assert.Equal(t, wantAttrs, got.Resources[`time_sleep.wait_three_seconds["duration"]`].Attributes) }) - } diff --git a/internal/task/dependency_graph_builder.go b/internal/task/dependency_graph_builder.go index 14d7516..5bc48a1 100644 --- a/internal/task/dependency_graph_builder.go +++ b/internal/task/dependency_graph_builder.go @@ -21,12 +21,12 @@ func createDependentTasks(svc taskCreator, reverse bool, specs ...Spec) ([]*Task // the specs that belong to that module. Once the graph is built and // dependencies are established, only then are tasks created from the specs. for _, spec := range specs { - node, ok := b.nodes[*spec.ModuleID] + node, ok := b.nodes[spec.ModuleID] if !ok { node = &dependencyGraphNode{dependencies: spec.Dependencies.ModuleIDs} } node.specs = append(node.specs, spec) - b.nodes[*spec.ModuleID] = node + b.nodes[spec.ModuleID] = node } for id, v := range b.nodes { if !v.visited { diff --git a/internal/task/dependency_graph_builder_test.go b/internal/task/dependency_graph_builder_test.go index 372b400..ea0341f 100644 --- a/internal/task/dependency_graph_builder_test.go +++ b/internal/task/dependency_graph_builder_test.go @@ -15,19 +15,19 @@ func (f *fakeTaskCreator) Create(spec Spec) (*Task, error) { } func TestNewGroupWithDependencies(t *testing.T) { - vpcID := resource.NewID(resource.Module) - mysqlID := resource.NewID(resource.Module) - redisID := resource.NewID(resource.Module) - backendID := resource.NewID(resource.Module) - frontendID := resource.NewID(resource.Module) - mqID := resource.NewID(resource.Module) + vpcID := resource.NewMonotonicID(resource.Module) + mysqlID := resource.NewMonotonicID(resource.Module) + redisID := resource.NewMonotonicID(resource.Module) + backendID := resource.NewMonotonicID(resource.Module) + frontendID := resource.NewMonotonicID(resource.Module) + mqID := resource.NewMonotonicID(resource.Module) - vpcSpec := Spec{ModuleID: &vpcID, Dependencies: &Dependencies{}} - mysqlSpec := Spec{ModuleID: &mysqlID, Dependencies: &Dependencies{ModuleIDs: []resource.ID{vpcID}}} - redisSpec := Spec{ModuleID: &redisID, Dependencies: &Dependencies{ModuleIDs: []resource.ID{vpcID}}} - backendSpec := Spec{ModuleID: &backendID, Dependencies: &Dependencies{ModuleIDs: []resource.ID{vpcID, mysqlID, redisID}}} - frontendSpec := Spec{ModuleID: &frontendID, Dependencies: &Dependencies{ModuleIDs: []resource.ID{vpcID, backendID}}} - mqSpec := Spec{ModuleID: &mqID, Dependencies: &Dependencies{}} + vpcSpec := Spec{ModuleID: vpcID, Dependencies: &Dependencies{}} + mysqlSpec := Spec{ModuleID: mysqlID, Dependencies: &Dependencies{ModuleIDs: []resource.ID{vpcID}}} + redisSpec := Spec{ModuleID: redisID, Dependencies: &Dependencies{ModuleIDs: []resource.ID{vpcID}}} + backendSpec := Spec{ModuleID: backendID, Dependencies: &Dependencies{ModuleIDs: []resource.ID{vpcID, mysqlID, redisID}}} + frontendSpec := Spec{ModuleID: frontendID, Dependencies: &Dependencies{ModuleIDs: []resource.ID{vpcID, backendID}}} + mqSpec := Spec{ModuleID: mqID, Dependencies: &Dependencies{}} t.Run("normal order", func(t *testing.T) { got, err := createDependentTasks(&fakeTaskCreator{}, false, @@ -72,9 +72,11 @@ func TestNewGroupWithDependencies(t *testing.T) { }) } -func hasDependencies(t *testing.T, got []*Task, want resource.ID, deps ...resource.ID) resource.ID { +func hasDependencies(t *testing.T, got []*Task, wantModuleID resource.ID, deps ...resource.MonotonicID) resource.MonotonicID { + t.Helper() + for _, task := range got { - if task.ModuleID != nil && *task.ModuleID == want { + if task.ModuleID != nil && task.ModuleID == wantModuleID { // Module matches, so now check dependencies if assert.Len(t, task.DependsOn, len(deps)) { for _, dep := range deps { @@ -84,6 +86,6 @@ func hasDependencies(t *testing.T, got []*Task, want resource.ID, deps ...resour } } } - t.Fatalf("%s not found in %v", want, got) - return resource.ID{} + t.Fatalf("%s not found in %v", wantModuleID, got) + return resource.MonotonicID{} } diff --git a/internal/task/enqueuer.go b/internal/task/enqueuer.go index 735c980..6fe24f1 100644 --- a/internal/task/enqueuer.go +++ b/internal/task/enqueuer.go @@ -58,10 +58,10 @@ func (e *enqueuer) enqueuable() []*Task { for _, t := range active { if t.Blocking { if t.ModuleID != nil { - blockedModules[*t.ModuleID] = struct{}{} + blockedModules[t.ModuleID] = struct{}{} } if t.WorkspaceID != nil { - blockedWorkspaces[*t.WorkspaceID] = struct{}{} + blockedWorkspaces[t.WorkspaceID] = struct{}{} } } } @@ -79,13 +79,13 @@ func (e *enqueuer) enqueuable() []*Task { continue } if t.WorkspaceID != nil { - if _, ok := blockedWorkspaces[*t.WorkspaceID]; ok { + if _, ok := blockedWorkspaces[t.WorkspaceID]; ok { // Don't enqueue task belonging to workspace blocked by another task continue } } if t.ModuleID != nil { - if _, ok := blockedModules[*t.ModuleID]; ok { + if _, ok := blockedModules[t.ModuleID]; ok { // Don't enqueue task belonging to module blocked by another task continue } @@ -102,12 +102,12 @@ func (e *enqueuer) enqueuable() []*Task { if t.WorkspaceID != nil { // Task blocks workspace; no further tasks belonging to workspace // shall be enqueued. - blockedWorkspaces[*t.WorkspaceID] = struct{}{} + blockedWorkspaces[t.WorkspaceID] = struct{}{} } if t.ModuleID != nil { // Task blocks module; no further tasks belonging to module // shall be enqueued. - blockedModules[*t.ModuleID] = struct{}{} + blockedModules[t.ModuleID] = struct{}{} } } } diff --git a/internal/task/enqueuer_test.go b/internal/task/enqueuer_test.go index d3bacd5..5deda21 100644 --- a/internal/task/enqueuer_test.go +++ b/internal/task/enqueuer_test.go @@ -13,25 +13,25 @@ import ( func TestEnqueuer(t *testing.T) { t.Parallel() - mod1ID := resource.NewID(resource.Module) - ws1ID := resource.NewID(resource.Workspace) + mod1ID := resource.NewMonotonicID(resource.Module) + ws1ID := resource.NewMonotonicID(resource.Workspace) - mod1Task1 := newTestTask(t, Spec{ModuleID: &mod1ID}) - mod1TaskBlocking1 := newTestTask(t, Spec{ModuleID: &mod1ID, Blocking: true}) + mod1Task1 := newTestTask(t, Spec{ModuleID: mod1ID}) + mod1TaskBlocking1 := newTestTask(t, Spec{ModuleID: mod1ID, Blocking: true}) - ws1Task1 := newTestTask(t, Spec{ModuleID: &mod1ID, WorkspaceID: &ws1ID}) - ws1Task2 := newTestTask(t, Spec{ModuleID: &mod1ID, WorkspaceID: &ws1ID}) + ws1Task1 := newTestTask(t, Spec{ModuleID: mod1ID, WorkspaceID: ws1ID}) + ws1Task2 := newTestTask(t, Spec{ModuleID: mod1ID, WorkspaceID: ws1ID}) - ws1TaskBlocking1 := newTestTask(t, Spec{ModuleID: &mod1ID, WorkspaceID: &ws1ID, Blocking: true}) - ws1TaskBlocking2 := newTestTask(t, Spec{ModuleID: &mod1ID, WorkspaceID: &ws1ID, Blocking: true}) - ws1TaskBlocking3 := newTestTask(t, Spec{ModuleID: &mod1ID, WorkspaceID: &ws1ID, Blocking: true}) - ws1TaskImmediate := newTestTask(t, Spec{ModuleID: &mod1ID, WorkspaceID: &ws1ID, Immediate: true}) - ws1TaskDependOnTask1 := newTestTask(t, Spec{ModuleID: &mod1ID, WorkspaceID: &ws1ID, dependsOn: []resource.ID{ws1Task1.ID}}) + ws1TaskBlocking1 := newTestTask(t, Spec{ModuleID: mod1ID, WorkspaceID: ws1ID, Blocking: true}) + ws1TaskBlocking2 := newTestTask(t, Spec{ModuleID: mod1ID, WorkspaceID: ws1ID, Blocking: true}) + ws1TaskBlocking3 := newTestTask(t, Spec{ModuleID: mod1ID, WorkspaceID: ws1ID, Blocking: true}) + ws1TaskImmediate := newTestTask(t, Spec{ModuleID: mod1ID, WorkspaceID: ws1ID, Immediate: true}) + ws1TaskDependOnTask1 := newTestTask(t, Spec{ModuleID: mod1ID, WorkspaceID: ws1ID, dependsOn: []resource.ID{ws1Task1.ID}}) - ws1TaskCompleted := newTestTask(t, Spec{ModuleID: &mod1ID, WorkspaceID: &ws1ID}) + ws1TaskCompleted := newTestTask(t, Spec{ModuleID: mod1ID, WorkspaceID: ws1ID}) ws1TaskCompleted.updateState(Exited) - ws1TaskDependOnCompletedTask := newTestTask(t, Spec{ModuleID: &mod1ID, WorkspaceID: &ws1ID, dependsOn: []resource.ID{ws1TaskCompleted.ID}}) + ws1TaskDependOnCompletedTask := newTestTask(t, Spec{ModuleID: mod1ID, WorkspaceID: ws1ID, dependsOn: []resource.ID{ws1TaskCompleted.ID}}) tests := []struct { name string diff --git a/internal/task/group.go b/internal/task/group.go index 1da5002..d06a83c 100644 --- a/internal/task/group.go +++ b/internal/task/group.go @@ -10,8 +10,7 @@ import ( ) type Group struct { - resource.ID - + ID resource.MonotonicID Created time.Time Command string Tasks []*Task @@ -23,7 +22,7 @@ func newGroup(service *Service, specs ...Spec) (*Group, error) { return nil, errors.New("no specs provided") } g := &Group{ - ID: resource.NewID(resource.TaskGroup), + ID: resource.NewMonotonicID(resource.TaskGroup), Created: time.Now(), } // Validate specifications. There are some settings that are incompatible @@ -84,9 +83,10 @@ func newGroup(service *Service, specs ...Spec) (*Group, error) { return g, nil } -func (g *Group) String() string { return g.Command } +func (g *Group) String() string { return g.Command } +func (g *Group) GetID() resource.ID { return g.ID } -func (g *Group) IncludesTask(taskID resource.ID) bool { +func (g *Group) IncludesTask(taskID resource.MonotonicID) bool { return slices.ContainsFunc(g.Tasks, func(tgt *Task) bool { return tgt.ID == taskID }) diff --git a/internal/task/service_test.go b/internal/task/service_test.go index 7070054..c19309f 100644 --- a/internal/task/service_test.go +++ b/internal/task/service_test.go @@ -10,11 +10,11 @@ import ( func TestService_List(t *testing.T) { t.Parallel() - pending := &Task{ID: resource.NewID(resource.Task), State: Pending} - queued := &Task{ID: resource.NewID(resource.Task), State: Queued} - running := &Task{ID: resource.NewID(resource.Task), State: Running} - exited := &Task{ID: resource.NewID(resource.Task), State: Exited} - errored := &Task{ID: resource.NewID(resource.Task), State: Errored} + pending := &Task{ID: resource.NewMonotonicID(resource.Task), State: Pending} + queued := &Task{ID: resource.NewMonotonicID(resource.Task), State: Queued} + running := &Task{ID: resource.NewMonotonicID(resource.Task), State: Running} + exited := &Task{ID: resource.NewMonotonicID(resource.Task), State: Exited} + errored := &Task{ID: resource.NewMonotonicID(resource.Task), State: Errored} tests := []struct { name string diff --git a/internal/task/spec.go b/internal/task/spec.go index 8e5bd69..e1171f1 100644 --- a/internal/task/spec.go +++ b/internal/task/spec.go @@ -6,13 +6,13 @@ import "github.com/leg100/pug/internal/resource" type Spec struct { // ModuleID is the ID of the module the task belongs to. If nil, the task // does not belong to a module - ModuleID *resource.ID + ModuleID resource.ID // WorkspaceID is the ID of the workspace the task belongs to. If nil, the // task does not belong to a workspace. - WorkspaceID *resource.ID + WorkspaceID resource.ID // TaskGroupID specifies the ID of the task group this task is to belong to. // Nil means the task does not belong to a group. - TaskGroupID *resource.ID + TaskGroupID resource.ID // Execution specifies the execution of a program. Execution Execution // AdditionalExecution specifies the execution of another program. The diff --git a/internal/task/task.go b/internal/task/task.go index 82b9e66..873e876 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -23,11 +23,10 @@ type Identifier string // Task is an execution of a CLI program. type Task struct { - resource.ID - - ModuleID *resource.ID - WorkspaceID *resource.ID - TaskGroupID *resource.ID + ID resource.MonotonicID + ModuleID resource.ID + WorkspaceID resource.ID + TaskGroupID resource.ID Identifier Identifier Program string Args []string @@ -116,7 +115,7 @@ func (f *factory) newTask(spec Spec) (*Task, error) { return nil, errors.New("a task cannot both be blocking and immediately") } task := &Task{ - ID: resource.NewID(resource.Task), + ID: resource.NewMonotonicID(resource.Task), ModuleID: spec.ModuleID, WorkspaceID: spec.WorkspaceID, TaskGroupID: spec.TaskGroupID, @@ -199,9 +198,8 @@ func (f *factory) newTask(spec Spec) (*Task, error) { return task, nil } -func (t *Task) String() string { - return t.Description -} +func (t *Task) GetID() resource.ID { return t.ID } +func (t *Task) String() string { return t.Description } // NewReader returns a reader which contains what has been written thus far to // the task buffer. diff --git a/internal/tui/explorer/model.go b/internal/tui/explorer/model.go index 369e06d..51eedbe 100644 --- a/internal/tui/explorer/model.go +++ b/internal/tui/explorer/model.go @@ -322,7 +322,7 @@ func (m model) GetWorkspaceIDs() ([]resource.ID, error) { if mod.CurrentWorkspaceID == nil { return nil, errors.New("modules must have a current workspace") } - ids[i] = *mod.CurrentWorkspaceID + ids[i] = mod.CurrentWorkspaceID } return ids, nil default: diff --git a/internal/tui/explorer/nodes.go b/internal/tui/explorer/nodes.go index b6b6f73..655404e 100644 --- a/internal/tui/explorer/nodes.go +++ b/internal/tui/explorer/nodes.go @@ -39,7 +39,7 @@ func (d dirNode) String() string { } type moduleNode struct { - id resource.ID + id resource.MonotonicID path string } @@ -56,7 +56,7 @@ func (m moduleNode) String() string { } type workspaceNode struct { - id resource.ID + id resource.MonotonicID name string current bool resourceCount string diff --git a/internal/tui/explorer/selector.go b/internal/tui/explorer/selector.go index 3c5a850..80209aa 100644 --- a/internal/tui/explorer/selector.go +++ b/internal/tui/explorer/selector.go @@ -20,7 +20,7 @@ type selector struct { } func (s *selector) add(n node) error { - id, ok := n.ID().(resource.ID) + id, ok := n.ID().(resource.MonotonicID) if !ok { return ErrUnselectableNode } @@ -42,7 +42,7 @@ func (s *selector) reindex(nodes []node) { } selections := make(map[resource.ID]struct{}, len(s.selections)) for _, n := range nodes { - id, ok := n.ID().(resource.ID) + id, ok := n.ID().(resource.MonotonicID) if !ok { continue } @@ -121,7 +121,7 @@ func (s *selector) addRange(cursor node, cursorIndex int, nodes ...node) error { } func (s *selector) remove(n node) { - id, ok := n.ID().(resource.ID) + id, ok := n.ID().(resource.MonotonicID) if !ok { // silently ignore request to remove non-resource node return @@ -146,7 +146,7 @@ func (s *selector) toggle(n node) error { } func (s *selector) isSelected(n node) bool { - id, ok := n.ID().(resource.ID) + id, ok := n.ID().(resource.MonotonicID) if !ok { // non-resource nodes cannot be selected return false diff --git a/internal/tui/explorer/selector_test.go b/internal/tui/explorer/selector_test.go index fe3da37..325c488 100644 --- a/internal/tui/explorer/selector_test.go +++ b/internal/tui/explorer/selector_test.go @@ -8,8 +8,8 @@ import ( ) func TestSelector_isSelected(t *testing.T) { - mod1 := moduleNode{id: resource.NewID(resource.Module)} - mod2 := moduleNode{id: resource.NewID(resource.Module)} + mod1 := moduleNode{id: resource.NewMonotonicID(resource.Module)} + mod2 := moduleNode{id: resource.NewMonotonicID(resource.Module)} s := selector{selections: make(map[resource.ID]struct{})} s.add(mod1) @@ -19,8 +19,8 @@ func TestSelector_isSelected(t *testing.T) { } func TestSelector_reindex(t *testing.T) { - mod1 := moduleNode{id: resource.NewID(resource.Module)} - mod2 := moduleNode{id: resource.NewID(resource.Module)} + mod1 := moduleNode{id: resource.NewMonotonicID(resource.Module)} + mod2 := moduleNode{id: resource.NewMonotonicID(resource.Module)} s := selector{selections: make(map[resource.ID]struct{})} s.add(mod1) diff --git a/internal/tui/explorer/tracker.go b/internal/tui/explorer/tracker.go index 51030af..4825bda 100644 --- a/internal/tui/explorer/tracker.go +++ b/internal/tui/explorer/tracker.go @@ -125,7 +125,7 @@ func (t *tracker) selectRange() error { func (t *tracker) getSelectedOrCurrentIDs() (resource.Kind, []resource.ID) { if len(t.selections) == 0 { - id, ok := t.cursorNode.ID().(resource.ID) + id, ok := t.cursorNode.ID().(resource.MonotonicID) if !ok { // TODO: consider returning error return -1, nil diff --git a/internal/tui/explorer/tree.go b/internal/tui/explorer/tree.go index e3ece27..da3e657 100644 --- a/internal/tui/explorer/tree.go +++ b/internal/tui/explorer/tree.go @@ -47,7 +47,7 @@ func (b *treeBuilder) newTree(filter string) (*tree, string) { currentWorkspaces := make(map[resource.ID]bool) for _, mod := range modules { if mod.CurrentWorkspaceID != nil { - currentWorkspaces[*mod.CurrentWorkspaceID] = true + currentWorkspaces[mod.CurrentWorkspaceID] = true } } // Arrange workspaces by module, for attachment to modules in tree below. diff --git a/internal/tui/explorer/tree_test.go b/internal/tui/explorer/tree_test.go index fbcf1fe..c842770 100644 --- a/internal/tui/explorer/tree_test.go +++ b/internal/tui/explorer/tree_test.go @@ -12,29 +12,29 @@ import ( var ( mod1 = &module.Module{ - ID: resource.NewID(resource.Module), + ID: resource.NewMonotonicID(resource.Module), Path: "a", } mod2 = &module.Module{ - ID: resource.NewID(resource.Module), + ID: resource.NewMonotonicID(resource.Module), Path: "a/b", } mod3 = &module.Module{ - ID: resource.NewID(resource.Module), + ID: resource.NewMonotonicID(resource.Module), Path: "a/b/c", } ws1 = &workspace.Workspace{ - ID: resource.NewID(resource.Workspace), + ID: resource.NewMonotonicID(resource.Workspace), ModuleID: mod1.ID, Name: "ws1", } ws2 = &workspace.Workspace{ - ID: resource.NewID(resource.Workspace), + ID: resource.NewMonotonicID(resource.Workspace), ModuleID: mod2.ID, Name: "ws2", } ws3 = &workspace.Workspace{ - ID: resource.NewID(resource.Workspace), + ID: resource.NewMonotonicID(resource.Workspace), ModuleID: mod3.ID, Name: "ws3", } diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go index 015a035..bb56d3a 100644 --- a/internal/tui/helpers.go +++ b/internal/tui/helpers.go @@ -38,7 +38,7 @@ func (h *Helpers) ModuleCurrentWorkspace(mod *module.Module) *workspace.Workspac if mod.CurrentWorkspaceID == nil { return nil } - ws, err := h.Workspaces.Get(*mod.CurrentWorkspaceID) + ws, err := h.Workspaces.Get(mod.CurrentWorkspaceID) if err != nil { h.Logger.Error("retrieving current workspace for module", "error", err, "module", mod) return nil @@ -46,11 +46,11 @@ func (h *Helpers) ModuleCurrentWorkspace(mod *module.Module) *workspace.Workspac return ws } -func (h *Helpers) CurrentWorkspaceName(workspaceID *resource.ID) string { +func (h *Helpers) CurrentWorkspaceName(workspaceID resource.ID) string { if workspaceID == nil { return "-" } - ws, err := h.Workspaces.Get(*workspaceID) + ws, err := h.Workspaces.Get(workspaceID) if err != nil { h.Logger.Error("rendering current workspace name", "error", err) return "" @@ -62,7 +62,7 @@ func (h *Helpers) ModuleCurrentResourceCount(mod *module.Module) string { if mod.CurrentWorkspaceID == nil { return "" } - ws, err := h.Workspaces.Get(*mod.CurrentWorkspaceID) + ws, err := h.Workspaces.Get(mod.CurrentWorkspaceID) if err != nil { h.Logger.Error("rendering module current workspace resource count", "error", err) return "" @@ -78,7 +78,7 @@ func (h *Helpers) WorkspaceCurrentCheckmark(ws *workspace.Workspace) string { h.Logger.Error("rendering current workspace checkmark", "error", err) return "" } - if mod.CurrentWorkspaceID != nil && *mod.CurrentWorkspaceID == ws.ID { + if mod.CurrentWorkspaceID != nil && mod.CurrentWorkspaceID == ws.ID { return "✓" } return "" @@ -117,7 +117,7 @@ func (h *Helpers) TaskModule(t *task.Task) *module.Module { if moduleID == nil { return nil } - mod, err := h.Modules.Get(*moduleID) + mod, err := h.Modules.Get(moduleID) if err != nil { return nil } @@ -144,7 +144,7 @@ func (h *Helpers) TaskWorkspace(t *task.Task) *workspace.Workspace { if workspaceID == nil { return nil } - ws, err := h.Workspaces.Get(*workspaceID) + ws, err := h.Workspaces.Get(workspaceID) if err != nil { return nil } diff --git a/internal/tui/logs/model.go b/internal/tui/logs/model.go index b1f13e1..58ec2ef 100644 --- a/internal/tui/logs/model.go +++ b/internal/tui/logs/model.go @@ -53,17 +53,17 @@ func (mm *Maker) Make(id resource.ID, width, height int) (tui.ChildModel, error) { Key: timeAttrKey, Value: msg.Time.Format(timeFormat), - ID: resource.NewID(resource.LogAttr), + ID: resource.NewMonotonicID(resource.LogAttr), }, { Key: messageAttrKey, Value: msg.Message, - ID: resource.NewID(resource.LogAttr), + ID: resource.NewMonotonicID(resource.LogAttr), }, { Key: levelAttrKey, Value: coloredLogLevel(msg.Level), - ID: resource.NewID(resource.LogAttr), + ID: resource.NewMonotonicID(resource.LogAttr), }, } items = append(items, msg.Attributes...) diff --git a/internal/tui/model.go b/internal/tui/model.go index 8481694..77afe96 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -21,8 +21,8 @@ type Maker interface { type Page struct { // The model kind. Identifies the model maker to construct the page. Kind Kind - // The ID of the resource for a model. In the case of global listings of - // modules, workspaces, etc, this is the global resource. + // ID of resource for a model. If the model does not have a single resource + // but is say a listing of resources, then this is nil. ID resource.ID } diff --git a/internal/tui/table/table.go b/internal/tui/table/table.go index eba2de2..ddacb1c 100644 --- a/internal/tui/table/table.go +++ b/internal/tui/table/table.go @@ -28,9 +28,9 @@ const ( ) // Model defines a state for the table widget. -type Model[V resource.Resource] struct { +type Model[V resource.Identifiable] struct { cols []Column - rows []Row[V] + rows []V rowRenderer RowRenderer[V] rendered map[resource.ID]RenderedRow @@ -75,11 +75,6 @@ type Column struct { type ColumnKey string -type Row[V any] struct { - ID resource.ID - Value V -} - type RowRenderer[V any] func(V) RenderedRow // RenderedRow provides the rendered string for each column in a row. @@ -88,7 +83,7 @@ type RenderedRow map[ColumnKey]string type SortFunc[V any] func(V, V) int // New creates a new model for the table widget. -func New[V resource.Resource](cols []Column, fn RowRenderer[V], width, height int, opts ...Option[V]) Model[V] { +func New[V resource.Identifiable](cols []Column, fn RowRenderer[V], width, height int, opts ...Option[V]) Model[V] { filter := textinput.New() filter.Prompt = "Filter: " @@ -121,17 +116,17 @@ func New[V resource.Resource](cols []Column, fn RowRenderer[V], width, height in return m } -type Option[V resource.Resource] func(m *Model[V]) +type Option[V resource.Identifiable] func(m *Model[V]) // WithSortFunc configures the table to sort rows using the given func. -func WithSortFunc[V resource.Resource](sortFunc func(V, V) int) Option[V] { +func WithSortFunc[V resource.Identifiable](sortFunc func(V, V) int) Option[V] { return func(m *Model[V]) { m.sortFunc = sortFunc } } // WithSelectable sets whether rows are selectable. -func WithSelectable[V resource.Resource](s bool) Option[V] { +func WithSelectable[V resource.Identifiable](s bool) Option[V] { return func(m *Model[V]) { m.selectable = s } @@ -139,7 +134,7 @@ func WithSelectable[V resource.Resource](s bool) Option[V] { // WithPreview configures the table to automatically populate the bottom right // pane with a model corresponding to the current row. -func WithPreview[V resource.Resource](maker tui.Kind) Option[V] { +func WithPreview[V resource.Identifiable](maker tui.Kind) Option[V] { return func(m *Model[V]) { m.previewKind = &maker } @@ -252,12 +247,13 @@ func (m Model[V]) Update(msg tea.Msg) (Model[V], tea.Cmd) { return m, nil } +// PreviewCurrentRow func (m *Model[V]) PreviewCurrentRow() (tui.Kind, resource.ID, bool) { if _, ok := m.CurrentRow(); !ok { - return 0, resource.ID{}, false + return 0, resource.MonotonicID{}, false } if m.previewKind == nil { - return 0, resource.ID{}, false + return 0, resource.MonotonicID{}, false } return *m.previewKind, m.currentRowID, true } @@ -321,37 +317,27 @@ func (m *Model[V]) SetBorderStyle(border lipgloss.Border, color lipgloss.Termina // CurrentRow returns the current row the user has highlighted. If the table is // empty then false is returned. -func (m Model[V]) CurrentRow() (Row[V], bool) { +func (m Model[V]) CurrentRow() (V, bool) { if m.currentRowIndex < 0 || m.currentRowIndex >= len(m.rows) { - return *new(Row[V]), false + return *new(V), false } return m.rows[m.currentRowIndex], true } // SelectedOrCurrent returns either the selected rows, or if there are no // selections, the current row -func (m Model[V]) SelectedOrCurrent() []Row[V] { +func (m Model[V]) SelectedOrCurrent() []V { if len(m.selected) > 0 { - rows := make([]Row[V], len(m.selected)) + rows := make([]V, len(m.selected)) var i int - for k, v := range m.selected { - rows[i] = Row[V]{ID: k, Value: v} + for _, v := range m.selected { + rows[i] = v i++ } return rows } if row, ok := m.CurrentRow(); ok { - return []Row[V]{row} - } - return nil -} - -func (m Model[V]) SelectedOrCurrentIDs() []resource.ID { - if len(m.selected) > 0 { - return maps.Keys(m.selected) - } - if row, ok := m.CurrentRow(); ok { - return []resource.ID{row.ID} + return []V{row} } return nil } @@ -365,15 +351,15 @@ func (m *Model[V]) ToggleSelection() { if !ok { return } - if _, isSelected := m.selected[current.ID]; isSelected { - delete(m.selected, current.ID) + if _, isSelected := m.selected[current]; isSelected { + delete(m.selected, current) } else { - m.selected[current.ID] = current.Value + m.selected[current.GetID()] = current } } -// ToggleSelectionByID toggles the selection of the row with the given id. If -// the id does not exist no action is taken. +// ToggleSelectionByID toggles the selection of the row with the given ID. If +// the ID does not exist no action is taken. func (m *Model[V]) ToggleSelectionByID(id resource.ID) { if !m.selectable { return @@ -394,9 +380,8 @@ func (m *Model[V]) SelectAll() { if !m.selectable { return } - for _, row := range m.rows { - m.selected[row.ID] = row.Value + m.selected[row] = row } } @@ -405,7 +390,6 @@ func (m *Model[V]) DeselectAll() { if !m.selectable { return } - m.selected = make(map[resource.ID]V) } @@ -430,7 +414,7 @@ func (m *Model[V]) SelectRange() { n = m.currentRowIndex - first + 1 break } - if _, ok := m.selected[row.ID]; !ok { + if _, ok := m.selected[row.GetID()]; !ok { // Ignore unselected rows continue } @@ -445,7 +429,7 @@ func (m *Model[V]) SelectRange() { first = i + 1 } for _, row := range m.rows[first : first+n] { - m.selected[row.ID] = row.Value + m.selected[row.GetID()] = row } } @@ -469,11 +453,11 @@ func (m *Model[V]) AddItems(items ...V) { } func (m *Model[V]) removeItem(item V) { - delete(m.rendered, item.GetID()) - delete(m.items, item.GetID()) - delete(m.selected, item.GetID()) + delete(m.rendered, item) + delete(m.items, item) + delete(m.selected, item) for i, row := range m.rows { - if row.ID == item.GetID() { + if row.GetID() == item.GetID() { // TODO: this might well produce a memory leak. See note: // https://go.dev/wiki/SliceTricks#delete-without-preserving-order m.rows = append(m.rows[:i], m.rows[i+1:]...) @@ -493,30 +477,30 @@ func (m *Model[V]) removeItem(item V) { func (m *Model[V]) setRows(items ...V) { selected := make(map[resource.ID]V) - m.rows = make([]Row[V], 0, len(items)) + m.rows = make([]V, 0, len(items)) for _, item := range items { - if m.filterVisible() && !m.matchFilter(item.GetID()) { + if m.filterVisible() && !m.matchFilter(item) { // Skip item that doesn't match filter continue } - m.rows = append(m.rows, Row[V]{ID: item.GetID(), Value: item}) + m.rows = append(m.rows, item) if m.selectable { - if _, ok := m.selected[item.GetID()]; ok { - selected[item.GetID()] = item + if _, ok := m.selected[item]; ok { + selected[item] = item } } } m.selected = selected // Sort rows in-place if m.sortFunc != nil { - slices.SortFunc(m.rows, func(i, j Row[V]) int { - return m.sortFunc(i.Value, j.Value) + slices.SortFunc(m.rows, func(i, j V) int { + return m.sortFunc(i, j) }) } // Track current row index m.currentRowIndex = -1 for i, row := range m.rows { - if row.ID == m.currentRowID { + if row.GetID() == m.currentRowID { m.currentRowIndex = i break } @@ -526,15 +510,15 @@ func (m *Model[V]) setRows(items ...V) { // first row. if len(m.rows) > 0 && m.currentRowIndex == -1 { m.currentRowIndex = 0 - m.currentRowID = m.rows[m.currentRowIndex].ID + m.currentRowID = m.rows[m.currentRowIndex].GetID() } m.setStart() } // matchFilter returns true if the item with the given ID matches the filter // value. -func (m *Model[V]) matchFilter(id resource.ID) bool { - for _, col := range m.rendered[id] { +func (m *Model[V]) matchFilter(item V) bool { + for _, col := range m.rendered[item.GetID()] { // Remove ANSI escapes code before filtering stripped := internal.StripAnsi(col) if strings.Contains(stripped, m.filter.Value()) { @@ -559,7 +543,7 @@ func (m *Model[V]) MoveDown(n int) { func (m *Model[V]) moveCurrentRow(n int) { if len(m.rows) > 0 { m.currentRowIndex = clamp(m.currentRowIndex+n, 0, len(m.rows)-1) - m.currentRowID = m.rows[m.currentRowIndex].ID + m.currentRowID = m.rows[m.currentRowIndex].GetID() m.setStart() } } @@ -611,7 +595,7 @@ func (m *Model[V]) renderRow(rowIdx int) string { current bool selected bool ) - if _, ok := m.selected[row.ID]; ok { + if _, ok := m.selected[row.GetID()]; ok { selected = true } current = rowIdx == m.currentRowIndex @@ -626,7 +610,7 @@ func (m *Model[V]) renderRow(rowIdx int) string { foreground = tui.SelectedForeground } - cells := m.rendered[row.ID] + cells := m.rendered[row.GetID()] styledCells := make([]string, len(m.cols)) for i, col := range m.cols { content := cells[col.Key] diff --git a/internal/tui/table/table_test.go b/internal/tui/table/table_test.go index f58dc8f..7528366 100644 --- a/internal/tui/table/table_test.go +++ b/internal/tui/table/table_test.go @@ -11,27 +11,28 @@ import ( ) var ( - resource0 = testResource{n: 0, ID: resource.NewID(resource.Workspace)} - resource1 = testResource{n: 1, ID: resource.NewID(resource.Workspace)} - resource2 = testResource{n: 2, ID: resource.NewID(resource.Workspace)} - resource3 = testResource{n: 3, ID: resource.NewID(resource.Workspace)} - resource4 = testResource{n: 4, ID: resource.NewID(resource.Workspace)} - resource5 = testResource{n: 5, ID: resource.NewID(resource.Workspace)} + resource0 = testResource{n: 0, ID: resource.NewMonotonicID(resource.Workspace)} + resource1 = testResource{n: 1, ID: resource.NewMonotonicID(resource.Workspace)} + resource2 = testResource{n: 2, ID: resource.NewMonotonicID(resource.Workspace)} + resource3 = testResource{n: 3, ID: resource.NewMonotonicID(resource.Workspace)} + resource4 = testResource{n: 4, ID: resource.NewMonotonicID(resource.Workspace)} + resource5 = testResource{n: 5, ID: resource.NewMonotonicID(resource.Workspace)} ) type testResource struct { - resource.ID - - n int + ID resource.MonotonicID + n int } +func (r *testResource) GetID() resource.ID { return r.ID } + // setupTest sets up a table test with several rows. Each row is keyed with an // int, and the row item is an int corresponding to the key, for ease of // testing. The rows are sorted from lowest int to highest int. -func setupTest() Model[testResource] { - renderer := func(v testResource) RenderedRow { return nil } +func setupTest() Model[*testResource] { + renderer := func(v *testResource) RenderedRow { return nil } tbl := New(nil, renderer, 0, 0, - WithSortFunc(func(i, j testResource) int { + WithSortFunc(func(i, j *testResource) int { if i.n < j.n { return -1 } @@ -39,12 +40,12 @@ func setupTest() Model[testResource] { }), ) tbl.SetItems( - resource0, - resource1, - resource2, - resource3, - resource4, - resource5, + &resource0, + &resource1, + &resource2, + &resource3, + &resource4, + &resource5, ) return tbl } @@ -55,7 +56,7 @@ func TestTable_CurrentRow(t *testing.T) { got, ok := tbl.CurrentRow() require.True(t, ok) - assert.Equal(t, resource0, got.Value) + assert.Equal(t, &resource0, got) } func TestTable_ToggleSelection(t *testing.T) { @@ -64,7 +65,7 @@ func TestTable_ToggleSelection(t *testing.T) { tbl.ToggleSelection() assert.Len(t, tbl.selected, 1) - assert.Equal(t, resource0, tbl.selected[resource0.ID]) + assert.Equal(t, &resource0, tbl.selected[resource0.ID]) } func TestTable_SelectRange(t *testing.T) { @@ -117,8 +118,8 @@ func TestTable_SelectRange(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tbl := setupTest() - for _, key := range tt.selected { - tbl.ToggleSelectionByID(key) + for _, id := range tt.selected { + tbl.ToggleSelectionByID(id) } tbl.currentRowIndex = tt.cursor @@ -133,7 +134,7 @@ func TestTable_SelectRange(t *testing.T) { } func sortStrings(i, j resource.ID) int { - if i.String() < j.String() { + if i.(resource.MonotonicID).String() < j.(resource.MonotonicID).String() { return -1 } return 1 diff --git a/internal/tui/task/list.go b/internal/tui/task/list.go index b5b9c79..577e8cd 100644 --- a/internal/tui/task/list.go +++ b/internal/tui/task/list.go @@ -123,7 +123,11 @@ func (m *List) Update(msg tea.Msg) tea.Cmd { case tea.KeyMsg: switch { case key.Matches(msg, keys.Common.Cancel): - taskIDs := m.SelectedOrCurrentIDs() + rows := m.SelectedOrCurrent() + taskIDs := make([]resource.ID, len(rows)) + for i, row := range rows { + taskIDs[i] = row.ID + } return cancel(m.tasks, taskIDs...) case key.Matches(msg, keys.Common.AutoApply): ids, err := m.allPlans() @@ -138,7 +142,7 @@ func (m *List) Update(msg tea.Msg) tea.Cmd { rows := m.SelectedOrCurrent() specs := make([]task.Spec, len(rows)) for i, row := range rows { - specs[i] = row.Value.Spec + specs[i] = row.Spec } return tui.YesNoPrompt( fmt.Sprintf("Retry %d tasks?", len(rows)), @@ -179,7 +183,7 @@ func (m List) allPlans() ([]resource.ID, error) { rows := m.SelectedOrCurrent() ids := make([]resource.ID, len(rows)) for i, row := range rows { - if err := plan.IsApplyable(row.Value); err != nil { + if err := plan.IsApplyable(row); err != nil { return nil, fmt.Errorf("at least one task is not applyable: %w", err) } ids[i] = row.ID @@ -191,10 +195,10 @@ func (m List) GetModuleIDs() ([]resource.ID, error) { rows := m.SelectedOrCurrent() ids := make([]resource.ID, len(rows)) for i, row := range rows { - if row.Value.ModuleID == nil { + if row.ModuleID == nil { return nil, errors.New("valid only on modules") } - ids[i] = *row.Value.ModuleID + ids[i] = row.ModuleID } return ids, nil } @@ -203,22 +207,22 @@ func (m List) GetWorkspaceIDs() ([]resource.ID, error) { rows := m.SelectedOrCurrent() ids := make([]resource.ID, len(rows)) for i, row := range rows { - if row.Value.WorkspaceID != nil { - ids[i] = *row.Value.WorkspaceID - } else if row.Value.ModuleID == nil { + if row.WorkspaceID != nil { + ids[i] = row.WorkspaceID + } else if row.ModuleID == nil { return nil, errors.New("valid only on tasks associated with a module or a workspace") } else { // task has a module ID but no workspace ID, so find out if its // module has a current workspace, and if so, use that. Otherwise // return error - mod, err := m.Modules.Get(*row.Value.ModuleID) + mod, err := m.Modules.Get(row.ModuleID) if err != nil { return nil, err } if mod.CurrentWorkspaceID == nil { return nil, errors.New("valid only on tasks associated with a module with a current workspace, or a workspace") } - ids[i] = *mod.CurrentWorkspaceID + ids[i] = mod.CurrentWorkspaceID } } return ids, nil diff --git a/internal/tui/task/model.go b/internal/tui/task/model.go index 0dded54..981be68 100644 --- a/internal/tui/task/model.go +++ b/internal/tui/task/model.go @@ -334,25 +334,25 @@ func (m Model) GetModuleIDs() ([]resource.ID, error) { if m.task.ModuleID == nil { return nil, errors.New("valid only on modules") } - return []resource.ID{*m.task.ModuleID}, nil + return []resource.ID{m.task.ModuleID}, nil } func (m Model) GetWorkspaceIDs() ([]resource.ID, error) { if m.task.WorkspaceID != nil { - return []resource.ID{*m.task.WorkspaceID}, nil + return []resource.ID{m.task.WorkspaceID}, nil } else if m.task.ModuleID == nil { return nil, errors.New("valid only on tasks associated with a module or a workspace") } else { // task has a module ID but no workspace ID, so find out if if // module has a current workspace, and if so, use that. Otherwise // return error - mod, err := m.Modules.Get(*m.task.ModuleID) + mod, err := m.Modules.Get(m.task.ModuleID) if err != nil { return nil, err } if mod.CurrentWorkspaceID == nil { return nil, errors.New("valid only on tasks associated with a module with a current workspace, or a workspace") } - return []resource.ID{*mod.CurrentWorkspaceID}, nil + return []resource.ID{mod.CurrentWorkspaceID}, nil } } diff --git a/internal/tui/top/model.go b/internal/tui/top/model.go index 0292f5b..50ad11e 100644 --- a/internal/tui/top/model.go +++ b/internal/tui/top/model.go @@ -46,7 +46,7 @@ type model struct { tasks *task.Service spinner *spinner.Model spinning bool - lastTaskID *resource.ID + lastTaskID *resource.MonotonicID err error info string } diff --git a/internal/tui/workspace/resource_list.go b/internal/tui/workspace/resource_list.go index b874622..312ea08 100644 --- a/internal/tui/workspace/resource_list.go +++ b/internal/tui/workspace/resource_list.go @@ -101,7 +101,7 @@ func (m *resourceList) Init() tea.Cmd { // reloadedMsg is sent when a state reload has finished. type reloadedMsg struct { - workspaceID resource.ID + workspaceID resource.MonotonicID err error } @@ -166,7 +166,7 @@ func (m *resourceList) Update(msg tea.Msg) tea.Cmd { return m.createStateCommand(m.states.Untaint, addrs...) case key.Matches(msg, resourcesKeys.Move): if row, ok := m.CurrentRow(); ok { - from := row.Value.Address + from := row.Address return m.Move(m.workspace.ID, from) } case key.Matches(msg, keys.Common.PlanDestroy): @@ -189,7 +189,7 @@ func (m *resourceList) Update(msg tea.Msg) tea.Cmd { case key.Matches(msg, keys.Common.AutoApply): // Create a targeted apply. createRunOptions.TargetAddrs = m.selectedOrCurrentAddresses() - resourceIDs := m.SelectedOrCurrentIDs() + resourceIDs := m.SelectedOrCurrent() fn := func(workspaceID resource.ID) (task.Spec, error) { return m.plans.Apply(workspaceID, createRunOptions) } @@ -258,7 +258,7 @@ func (m resourceList) selectedOrCurrentAddresses() []state.ResourceAddress { addrs := make([]state.ResourceAddress, len(rows)) var i int for _, v := range rows { - addrs[i] = v.Value.Address + addrs[i] = v.Address i++ } return addrs diff --git a/internal/tui/workspace/state_func.go b/internal/tui/workspace/state_func.go index a093d6a..e7cf005 100644 --- a/internal/tui/workspace/state_func.go +++ b/internal/tui/workspace/state_func.go @@ -13,7 +13,7 @@ func (m resourceList) createStateCommand(fn stateFunc, addrs ...state.ResourceAd // Make N copies of the workspace ID where N is the number of addresses workspaceIDs := make([]resource.ID, len(addrs)) for i := range workspaceIDs { - workspaceIDs[i] = m.workspace.GetID() + workspaceIDs[i] = m.workspace.ID } f := newStateTaskFunc(fn, addrs...) return m.CreateTasks(f.createTask, workspaceIDs...) diff --git a/internal/workspace/reloader.go b/internal/workspace/reloader.go index 2cfc342..8d13cc2 100644 --- a/internal/workspace/reloader.go +++ b/internal/workspace/reloader.go @@ -53,7 +53,7 @@ func (r *reloader) Reload(moduleID resource.ID) (task.Spec, error) { return task.Spec{}, err } return task.Spec{ - ModuleID: &mod.ID, + ModuleID: mod.ID, Path: mod.Path, Execution: task.Execution{ TerraformCommand: []string{"workspace", "list"}, @@ -109,8 +109,7 @@ func (r *reloader) resetWorkspaces(mod *module.Module, discovered []string, curr if err != nil { return nil, nil, fmt.Errorf("cannot find current workspace: %s: %w", current, err) } - err = r.modules.SetCurrent(mod.ID, currentWorkspace.ID) - if err != nil { + if err := r.modules.SetCurrent(mod.ID, currentWorkspace.ID); err != nil { return nil, nil, err } return diff --git a/internal/workspace/reloader_test.go b/internal/workspace/reloader_test.go index 2a132ce..d57da3b 100644 --- a/internal/workspace/reloader_test.go +++ b/internal/workspace/reloader_test.go @@ -30,18 +30,20 @@ func TestWorkspace_parseList(t *testing.T) { func TestWorkspace_resetWorkspaces(t *testing.T) { mod := &module.Module{Path: "a/b/c"} + dev, err := New(mod, "dev") require.NoError(t, err) + staging, err := New(mod, "staging") require.NoError(t, err) - var gotCurrent resource.ID table := &fakeWorkspaceTable{ existing: []*Workspace{dev, staging}, } + moduleService := &fakeModuleService{} reloader := &reloader{ &Service{ - modules: &fakeModuleService{current: &gotCurrent}, + modules: moduleService, table: table, }, } @@ -56,17 +58,17 @@ func TestWorkspace_resetWorkspaces(t *testing.T) { assert.Equal(t, "prod", table.added[0].Name) // expect dev to have been made the current workspace - assert.Equal(t, dev.ID, gotCurrent) + assert.Equal(t, dev.ID, moduleService.current) } type fakeModuleService struct { - current *resource.ID + current resource.ID modules } func (f *fakeModuleService) SetCurrent(moduleID, workspaceID resource.ID) error { - *f.current = workspaceID + f.current = workspaceID return nil } diff --git a/internal/workspace/service.go b/internal/workspace/service.go index 3c882e7..ec2850c 100644 --- a/internal/workspace/service.go +++ b/internal/workspace/service.go @@ -101,7 +101,7 @@ func (s *Service) LoadWorkspacesUponInit(sub <-chan resource.Event[*task.Task]) if moduleID == nil { continue } - mod, err := s.modules.Get(*moduleID) + mod, err := s.modules.Get(moduleID) if err != nil { continue } @@ -125,7 +125,7 @@ func (s *Service) Create(path, name string) (task.Spec, error) { return task.Spec{}, err } return task.Spec{ - ModuleID: &mod.ID, + ModuleID: mod.ID, Path: mod.Path, Execution: task.Execution{ TerraformCommand: []string{"workspace", "new"}, @@ -157,13 +157,13 @@ func (s *Service) GetByName(modulePath, name string) (*Workspace, error) { type ListOptions struct { // Filter by ID of workspace's module. - ModuleID *resource.ID + ModuleID resource.ID } func (s *Service) List(opts ListOptions) []*Workspace { var existing []*Workspace for _, ws := range s.table.List() { - if opts.ModuleID != nil && *opts.ModuleID != ws.ModuleID { + if opts.ModuleID != nil && opts.ModuleID != ws.ModuleID { continue } existing = append(existing, ws) @@ -185,7 +185,7 @@ func (s *Service) SelectWorkspace(workspaceID resource.ID) error { } // Create task to immediately set workspace as current workspace for module. _, err = s.tasks.Create(task.Spec{ - ModuleID: &mod.ID, + ModuleID: mod.ID, Path: mod.Path, Execution: task.Execution{ TerraformCommand: []string{"workspace", "select"}, @@ -217,7 +217,7 @@ func (s *Service) Delete(workspaceID resource.ID) (task.Spec, error) { return task.Spec{}, err } return task.Spec{ - ModuleID: &mod.ID, + ModuleID: mod.ID, Path: mod.Path, Execution: task.Execution{ TerraformCommand: []string{"workspace", "delete"}, diff --git a/internal/workspace/sort.go b/internal/workspace/sort.go index 3d2fee5..b774992 100644 --- a/internal/workspace/sort.go +++ b/internal/workspace/sort.go @@ -6,7 +6,7 @@ import ( ) type moduleGetter interface { - Get(id resource.ID) (*module.Module, error) + Get(id resource.MonotonicID) (*module.Module, error) } // Sort sorts workspaces accordingly: diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index e5574a7..a4813ca 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -13,10 +13,9 @@ import ( ) type Workspace struct { - resource.ID - + ID resource.MonotonicID Name string - ModuleID resource.ID + ModuleID resource.MonotonicID ModulePath string Cost *float64 } @@ -26,7 +25,7 @@ func New(mod *module.Module, name string) (*Workspace, error) { return nil, fmt.Errorf("invalid workspace name: %s", name) } return &Workspace{ - ID: resource.NewID(resource.Workspace), + ID: resource.NewMonotonicID(resource.Workspace), Name: name, ModuleID: mod.ID, ModulePath: mod.Path,