Files
so-many-crows/nests.go
T

184 lines
5.1 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
}
// 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()
}