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 } 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 !slices.Contains(roster, 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() }