feat: add config package with types and JSON serialization
Types: - TownConfig: main town configuration (config/town.json) - RigsConfig: rigs registry with BeadsConfig per rig - AgentState: agent state with role, session, extras Features: - Load/Save functions for all types - Version compatibility checks - Required field validation - Creates parent directories on save Closes gt-f9x.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
188
internal/config/loader.go
Normal file
188
internal/config/loader.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound indicates the config file does not exist.
|
||||
ErrNotFound = errors.New("config file not found")
|
||||
|
||||
// ErrInvalidVersion indicates an unsupported schema version.
|
||||
ErrInvalidVersion = errors.New("unsupported config version")
|
||||
|
||||
// ErrInvalidType indicates an unexpected config type.
|
||||
ErrInvalidType = errors.New("invalid config type")
|
||||
|
||||
// ErrMissingField indicates a required field is missing.
|
||||
ErrMissingField = errors.New("missing required field")
|
||||
)
|
||||
|
||||
// LoadTownConfig loads and validates a town configuration file.
|
||||
func LoadTownConfig(path string) (*TownConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
|
||||
}
|
||||
return nil, fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
|
||||
var config TownConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
|
||||
if err := validateTownConfig(&config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// SaveTownConfig saves a town configuration to a file.
|
||||
func SaveTownConfig(path string, config *TownConfig) error {
|
||||
if err := validateTownConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadRigsConfig loads and validates a rigs registry file.
|
||||
func LoadRigsConfig(path string) (*RigsConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
|
||||
}
|
||||
return nil, fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
|
||||
var config RigsConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
|
||||
if err := validateRigsConfig(&config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// SaveRigsConfig saves a rigs registry to a file.
|
||||
func SaveRigsConfig(path string, config *RigsConfig) error {
|
||||
if err := validateRigsConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAgentState loads an agent state file.
|
||||
func LoadAgentState(path string) (*AgentState, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
|
||||
}
|
||||
return nil, fmt.Errorf("reading state: %w", err)
|
||||
}
|
||||
|
||||
var state AgentState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("parsing state: %w", err)
|
||||
}
|
||||
|
||||
if err := validateAgentState(&state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// SaveAgentState saves an agent state to a file.
|
||||
func SaveAgentState(path string, state *AgentState) error {
|
||||
if err := validateAgentState(state); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding state: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("writing state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTownConfig validates a TownConfig.
|
||||
func validateTownConfig(c *TownConfig) error {
|
||||
if c.Type != "town" && c.Type != "" {
|
||||
return fmt.Errorf("%w: expected type 'town', got '%s'", ErrInvalidType, c.Type)
|
||||
}
|
||||
if c.Version > CurrentTownVersion {
|
||||
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentTownVersion)
|
||||
}
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("%w: name", ErrMissingField)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRigsConfig validates a RigsConfig.
|
||||
func validateRigsConfig(c *RigsConfig) error {
|
||||
if c.Version > CurrentRigsVersion {
|
||||
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentRigsVersion)
|
||||
}
|
||||
if c.Rigs == nil {
|
||||
c.Rigs = make(map[string]RigEntry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAgentState validates an AgentState.
|
||||
func validateAgentState(s *AgentState) error {
|
||||
if s.Role == "" {
|
||||
return fmt.Errorf("%w: role", ErrMissingField)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
132
internal/config/loader_test.go
Normal file
132
internal/config/loader_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTownConfigRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config", "town.json")
|
||||
|
||||
original := &TownConfig{
|
||||
Type: "town",
|
||||
Version: 1,
|
||||
Name: "test-town",
|
||||
CreatedAt: time.Now().Truncate(time.Second),
|
||||
}
|
||||
|
||||
if err := SaveTownConfig(path, original); err != nil {
|
||||
t.Fatalf("SaveTownConfig: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadTownConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTownConfig: %v", err)
|
||||
}
|
||||
|
||||
if loaded.Name != original.Name {
|
||||
t.Errorf("Name = %q, want %q", loaded.Name, original.Name)
|
||||
}
|
||||
if loaded.Type != original.Type {
|
||||
t.Errorf("Type = %q, want %q", loaded.Type, original.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRigsConfigRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config", "rigs.json")
|
||||
|
||||
original := &RigsConfig{
|
||||
Version: 1,
|
||||
Rigs: map[string]RigEntry{
|
||||
"gastown": {
|
||||
GitURL: "git@github.com:steveyegge/gastown.git",
|
||||
AddedAt: time.Now().Truncate(time.Second),
|
||||
BeadsConfig: &BeadsConfig{
|
||||
Repo: "local",
|
||||
Prefix: "gt-",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := SaveRigsConfig(path, original); err != nil {
|
||||
t.Fatalf("SaveRigsConfig: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadRigsConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRigsConfig: %v", err)
|
||||
}
|
||||
|
||||
if len(loaded.Rigs) != 1 {
|
||||
t.Errorf("Rigs count = %d, want 1", len(loaded.Rigs))
|
||||
}
|
||||
|
||||
rig, ok := loaded.Rigs["gastown"]
|
||||
if !ok {
|
||||
t.Fatal("missing 'gastown' rig")
|
||||
}
|
||||
if rig.BeadsConfig == nil || rig.BeadsConfig.Prefix != "gt-" {
|
||||
t.Errorf("BeadsConfig.Prefix = %v, want 'gt-'", rig.BeadsConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStateRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.json")
|
||||
|
||||
original := &AgentState{
|
||||
Role: "mayor",
|
||||
LastActive: time.Now().Truncate(time.Second),
|
||||
Session: "abc123",
|
||||
Extra: map[string]any{
|
||||
"custom": "value",
|
||||
},
|
||||
}
|
||||
|
||||
if err := SaveAgentState(path, original); err != nil {
|
||||
t.Fatalf("SaveAgentState: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadAgentState(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentState: %v", err)
|
||||
}
|
||||
|
||||
if loaded.Role != original.Role {
|
||||
t.Errorf("Role = %q, want %q", loaded.Role, original.Role)
|
||||
}
|
||||
if loaded.Session != original.Session {
|
||||
t.Errorf("Session = %q, want %q", loaded.Session, original.Session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTownConfigNotFound(t *testing.T) {
|
||||
_, err := LoadTownConfig("/nonexistent/path.json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrors(t *testing.T) {
|
||||
// Missing name
|
||||
tc := &TownConfig{Type: "town", Version: 1}
|
||||
if err := validateTownConfig(tc); err == nil {
|
||||
t.Error("expected error for missing name")
|
||||
}
|
||||
|
||||
// Wrong type
|
||||
tc = &TownConfig{Type: "wrong", Version: 1, Name: "test"}
|
||||
if err := validateTownConfig(tc); err == nil {
|
||||
t.Error("expected error for wrong type")
|
||||
}
|
||||
|
||||
// Missing role
|
||||
as := &AgentState{}
|
||||
if err := validateAgentState(as); err == nil {
|
||||
t.Error("expected error for missing role")
|
||||
}
|
||||
}
|
||||
45
internal/config/types.go
Normal file
45
internal/config/types.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Package config provides configuration types and serialization for Gas Town.
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// TownConfig represents the main town configuration (config/town.json).
|
||||
type TownConfig struct {
|
||||
Type string `json:"type"` // "town"
|
||||
Version int `json:"version"` // schema version
|
||||
Name string `json:"name"` // town identifier
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// RigsConfig represents the rigs registry (config/rigs.json).
|
||||
type RigsConfig struct {
|
||||
Version int `json:"version"`
|
||||
Rigs map[string]RigEntry `json:"rigs"`
|
||||
}
|
||||
|
||||
// RigEntry represents a single rig in the registry.
|
||||
type RigEntry struct {
|
||||
GitURL string `json:"git_url"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
BeadsConfig *BeadsConfig `json:"beads,omitempty"`
|
||||
}
|
||||
|
||||
// BeadsConfig represents beads configuration for a rig.
|
||||
type BeadsConfig struct {
|
||||
Repo string `json:"repo"` // "local" | path | git-url
|
||||
Prefix string `json:"prefix"` // issue prefix
|
||||
}
|
||||
|
||||
// AgentState represents an agent's current state (*/state.json).
|
||||
type AgentState struct {
|
||||
Role string `json:"role"` // "mayor", "witness", etc.
|
||||
LastActive time.Time `json:"last_active"`
|
||||
Session string `json:"session,omitempty"`
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// CurrentTownVersion is the current schema version for TownConfig.
|
||||
const CurrentTownVersion = 1
|
||||
|
||||
// CurrentRigsVersion is the current schema version for RigsConfig.
|
||||
const CurrentRigsVersion = 1
|
||||
Reference in New Issue
Block a user