210 lines
5.8 KiB
Go
210 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// A nest is a room where a team of crows works together. Its core crows are
|
|
// picked first; when a job needs more, the Sentry borrows from the rest of the
|
|
// flock. The Sentry and Elder serve every nest and are never core members.
|
|
type Nest struct {
|
|
ID string `json:"nest_id"`
|
|
Name string `json:"name"`
|
|
Crows []string `json:"crows"`
|
|
}
|
|
|
|
const (
|
|
defaultNestID = "todo"
|
|
maxNests = 20 // includes the default nest
|
|
maxNestName = 40
|
|
)
|
|
|
|
// defaultNest has an empty core: its crows come from the whole flock.
|
|
var defaultNest = Nest{ID: defaultNestID, Name: "To do", Crows: []string{}}
|
|
|
|
// NestError is a rejected nest. Clash means the nest_id is already taken (409);
|
|
// anything else is bad input or too many nests (400).
|
|
type NestError struct {
|
|
Clash bool
|
|
Msg string
|
|
}
|
|
|
|
func (e *NestError) Error() string { return e.Msg }
|
|
|
|
func invalid(format string, a ...any) error {
|
|
return &NestError{Msg: fmt.Sprintf(format, a...)}
|
|
}
|
|
|
|
// NestStore holds the nests and saves the ones people create to disk.
|
|
type NestStore struct {
|
|
path string
|
|
mu sync.Mutex
|
|
made []Nest // created nests, in creation order; the default nest is not stored
|
|
allowCrow func(name string) bool // extra member names beyond the roster (spun crows)
|
|
}
|
|
|
|
// AllowCrows extends the names that may be nest members beyond the persona
|
|
// roster: the server wires it to the spun-crow registry so the Boss can
|
|
// staff a nest with registry crows (#43). It is set once at wiring time,
|
|
// before anything is served; Create stays the only mutation.
|
|
func (s *NestStore) AllowCrows(ok func(name string) bool) { s.allowCrow = ok }
|
|
|
|
// memberKnown reports whether a name may join a nest: a working persona crow
|
|
// (the roster excludes the service roles) or an allowed extra name.
|
|
func (s *NestStore) memberKnown(name string) bool {
|
|
return slices.Contains(roster, name) || (s.allowCrow != nil && s.allowCrow(name))
|
|
}
|
|
|
|
func LoadNests(path string) (*NestStore, error) {
|
|
s := &NestStore{path: path}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
if err == nil {
|
|
if err := json.Unmarshal(data, &s.made); err != nil {
|
|
return nil, fmt.Errorf("nests file is unreadable: %w", err)
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// All returns the default nest first, then the others in creation order.
|
|
func (s *NestStore) All() []Nest {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []Nest{copyNest(defaultNest)}
|
|
for _, n := range s.made {
|
|
out = append(out, copyNest(n))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Get finds a nest by ID.
|
|
func (s *NestStore) Get(id string) (Nest, bool) {
|
|
if id == defaultNestID {
|
|
return copyNest(defaultNest), true
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, n := range s.made {
|
|
if n.ID == id {
|
|
return copyNest(n), true
|
|
}
|
|
}
|
|
return Nest{}, false
|
|
}
|
|
|
|
// Create checks the guardrails, adds the nest, and saves. A rejection is a
|
|
// *NestError; any other error means the save failed.
|
|
func (s *NestStore) Create(name string, crows []string) (Nest, error) {
|
|
name = strings.TrimSpace(name)
|
|
if n := utf8.RuneCountInString(name); n < 1 || n > maxNestName {
|
|
return Nest{}, invalid("A nest name is 1 to %d characters.", maxNestName)
|
|
}
|
|
id := slugify(name)
|
|
if id == "" {
|
|
return Nest{}, invalid("A nest name needs at least one letter or digit (a-z, 0-9).")
|
|
}
|
|
if len(crows) < 1 || len(crows) > len(roster) {
|
|
return Nest{}, invalid("A nest has 1 to %d crows.", len(roster))
|
|
}
|
|
for i, c := range crows {
|
|
if !s.memberKnown(c) {
|
|
return Nest{}, invalid("%q is not in the flock. Choose from %s.", c, strings.Join(roster, ", "))
|
|
}
|
|
if slices.Contains(crows[:i], c) {
|
|
return Nest{}, invalid("%s is listed twice.", c)
|
|
}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if id == defaultNestID || slices.ContainsFunc(s.made, func(n Nest) bool { return n.ID == id }) {
|
|
return Nest{}, &NestError{Clash: true, Msg: fmt.Sprintf("A nest called %q already exists.", id)}
|
|
}
|
|
if 1+len(s.made) >= maxNests {
|
|
return Nest{}, invalid("The flock keeps at most %d nests.", maxNests)
|
|
}
|
|
n := Nest{ID: id, Name: name, Crows: slices.Clone(crows)}
|
|
s.made = append(s.made, n)
|
|
if err := s.save(); err != nil {
|
|
s.made = s.made[:len(s.made)-1]
|
|
return Nest{}, err
|
|
}
|
|
return copyNest(n), nil
|
|
}
|
|
|
|
// RemoveCrow takes one crow out of a nest's membership (the owner's retire
|
|
// call, #45). An unknown nest or a crow that is not a member is a no-op: the
|
|
// removal is idempotent.
|
|
func (s *NestStore) RemoveCrow(nestID, crow string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.made {
|
|
if s.made[i].ID != nestID {
|
|
continue
|
|
}
|
|
j := slices.Index(s.made[i].Crows, crow)
|
|
if j < 0 {
|
|
return nil
|
|
}
|
|
old := s.made[i].Crows
|
|
rest := slices.Clone(old)
|
|
s.made[i].Crows = slices.Delete(rest, j, j+1)
|
|
if err := s.save(); err != nil {
|
|
s.made[i].Crows = old
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// save writes atomically, like the ledger. The caller holds s.mu.
|
|
func (s *NestStore) save() error {
|
|
data, err := json.MarshalIndent(s.made, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path)
|
|
}
|
|
|
|
func copyNest(n Nest) Nest {
|
|
n.Crows = append([]string{}, n.Crows...)
|
|
return n
|
|
}
|
|
|
|
// slugify lowercases and keeps a-z and 0-9; every other run of characters
|
|
// becomes one dash, and dashes at the ends are dropped.
|
|
func slugify(name string) string {
|
|
var sb strings.Builder
|
|
dash := false
|
|
for _, r := range strings.ToLower(name) {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
if dash && sb.Len() > 0 {
|
|
sb.WriteByte('-')
|
|
}
|
|
sb.WriteRune(r)
|
|
dash = false
|
|
} else {
|
|
dash = true
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|