feat(config): add GetSyncMode, GetConflictStrategy, GetSovereignty with warnings

Add config accessor functions for sync mode, conflict strategy, and
federation sovereignty tier settings. These functions:

- Read from config.yaml via viper
- Validate against known valid values
- Return sensible defaults when not set
- Log warnings to stderr when invalid values are configured (instead
  of silently falling back to defaults)

Closes: hq-ew1mbr.26

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
collins
2026-01-17 13:57:42 -08:00
committed by Steve Yegge
parent 16f8c3d3ae
commit e82e15a8c2
2 changed files with 469 additions and 0 deletions

140
internal/config/sync.go Normal file
View File

@@ -0,0 +1,140 @@
package config
import (
"fmt"
"os"
"strings"
)
// Sync mode configuration values (from hq-ew1mbr.3)
// These control how Dolt syncs with JSONL/remotes.
// SyncMode represents the sync mode configuration
type SyncMode string
const (
// SyncModeGitPortable exports JSONL on push, imports on pull (default)
SyncModeGitPortable SyncMode = "git-portable"
// SyncModeRealtime exports JSONL on every change (legacy behavior)
SyncModeRealtime SyncMode = "realtime"
// SyncModeDoltNative uses Dolt remote directly (dolthub://, gs://, s3://)
SyncModeDoltNative SyncMode = "dolt-native"
// SyncModeBeltAndSuspenders uses Dolt remote + JSONL backup
SyncModeBeltAndSuspenders SyncMode = "belt-and-suspenders"
)
// validSyncModes is the set of allowed sync mode values
var validSyncModes = map[SyncMode]bool{
SyncModeGitPortable: true,
SyncModeRealtime: true,
SyncModeDoltNative: true,
SyncModeBeltAndSuspenders: true,
}
// ConflictStrategy represents the conflict resolution strategy
type ConflictStrategy string
const (
// ConflictStrategyNewest uses last-write-wins (default)
ConflictStrategyNewest ConflictStrategy = "newest"
// ConflictStrategyOurs prefers local changes
ConflictStrategyOurs ConflictStrategy = "ours"
// ConflictStrategyTheirs prefers remote changes
ConflictStrategyTheirs ConflictStrategy = "theirs"
// ConflictStrategyManual requires manual resolution
ConflictStrategyManual ConflictStrategy = "manual"
)
// validConflictStrategies is the set of allowed conflict strategy values
var validConflictStrategies = map[ConflictStrategy]bool{
ConflictStrategyNewest: true,
ConflictStrategyOurs: true,
ConflictStrategyTheirs: true,
ConflictStrategyManual: true,
}
// Sovereignty represents the federation sovereignty tier
type Sovereignty string
const (
// SovereigntyT1 is the most open tier (public repos)
SovereigntyT1 Sovereignty = "T1"
// SovereigntyT2 is organization-level
SovereigntyT2 Sovereignty = "T2"
// SovereigntyT3 is pseudonymous
SovereigntyT3 Sovereignty = "T3"
// SovereigntyT4 is anonymous
SovereigntyT4 Sovereignty = "T4"
)
// validSovereigntyTiers is the set of allowed sovereignty values
var validSovereigntyTiers = map[Sovereignty]bool{
SovereigntyT1: true,
SovereigntyT2: true,
SovereigntyT3: true,
SovereigntyT4: true,
}
// GetSyncMode retrieves the sync mode configuration.
// Returns the configured mode, or SyncModeGitPortable (default) if not set or invalid.
// Logs a warning to stderr if an invalid value is configured.
//
// Config key: sync.mode
// Valid values: git-portable, realtime, dolt-native, belt-and-suspenders
func GetSyncMode() SyncMode {
value := GetString("sync.mode")
if value == "" {
return SyncModeGitPortable // Default
}
mode := SyncMode(strings.ToLower(strings.TrimSpace(value)))
if !validSyncModes[mode] {
fmt.Fprintf(os.Stderr, "Warning: invalid sync.mode %q in config (valid: git-portable, realtime, dolt-native, belt-and-suspenders), using default 'git-portable'\n", value)
return SyncModeGitPortable
}
return mode
}
// GetConflictStrategy retrieves the conflict resolution strategy configuration.
// Returns the configured strategy, or ConflictStrategyNewest (default) if not set or invalid.
// Logs a warning to stderr if an invalid value is configured.
//
// Config key: conflict.strategy
// Valid values: newest, ours, theirs, manual
func GetConflictStrategy() ConflictStrategy {
value := GetString("conflict.strategy")
if value == "" {
return ConflictStrategyNewest // Default
}
strategy := ConflictStrategy(strings.ToLower(strings.TrimSpace(value)))
if !validConflictStrategies[strategy] {
fmt.Fprintf(os.Stderr, "Warning: invalid conflict.strategy %q in config (valid: newest, ours, theirs, manual), using default 'newest'\n", value)
return ConflictStrategyNewest
}
return strategy
}
// GetSovereignty retrieves the federation sovereignty tier configuration.
// Returns the configured tier, or SovereigntyT1 (default) if not set or invalid.
// Logs a warning to stderr if an invalid value is configured.
//
// Config key: federation.sovereignty
// Valid values: T1, T2, T3, T4
func GetSovereignty() Sovereignty {
value := GetString("federation.sovereignty")
if value == "" {
return SovereigntyT1 // Default
}
// Normalize to uppercase for comparison (T1, T2, etc.)
tier := Sovereignty(strings.ToUpper(strings.TrimSpace(value)))
if !validSovereigntyTiers[tier] {
fmt.Fprintf(os.Stderr, "Warning: invalid federation.sovereignty %q in config (valid: T1, T2, T3, T4), using default 'T1'\n", value)
return SovereigntyT1
}
return tier
}

View File

@@ -0,0 +1,329 @@
package config
import (
"bytes"
"os"
"strings"
"testing"
)
func TestGetSyncMode(t *testing.T) {
tests := []struct {
name string
configValue string
expectedMode SyncMode
expectsWarning bool
}{
{
name: "empty returns default",
configValue: "",
expectedMode: SyncModeGitPortable,
expectsWarning: false,
},
{
name: "git-portable is valid",
configValue: "git-portable",
expectedMode: SyncModeGitPortable,
expectsWarning: false,
},
{
name: "realtime is valid",
configValue: "realtime",
expectedMode: SyncModeRealtime,
expectsWarning: false,
},
{
name: "dolt-native is valid",
configValue: "dolt-native",
expectedMode: SyncModeDoltNative,
expectsWarning: false,
},
{
name: "belt-and-suspenders is valid",
configValue: "belt-and-suspenders",
expectedMode: SyncModeBeltAndSuspenders,
expectsWarning: false,
},
{
name: "mixed case is normalized",
configValue: "Git-Portable",
expectedMode: SyncModeGitPortable,
expectsWarning: false,
},
{
name: "whitespace is trimmed",
configValue: " realtime ",
expectedMode: SyncModeRealtime,
expectsWarning: false,
},
{
name: "invalid value returns default with warning",
configValue: "invalid-mode",
expectedMode: SyncModeGitPortable,
expectsWarning: true,
},
{
name: "typo returns default with warning",
configValue: "git-portabel",
expectedMode: SyncModeGitPortable,
expectsWarning: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset viper for test
ResetForTesting()
if err := Initialize(); err != nil {
t.Fatalf("Initialize failed: %v", err)
}
// Set the config value
if tt.configValue != "" {
Set("sync.mode", tt.configValue)
}
// Capture stderr
oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
result := GetSyncMode()
// Restore stderr and get output
w.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
buf.ReadFrom(r)
stderrOutput := buf.String()
if result != tt.expectedMode {
t.Errorf("GetSyncMode() = %q, want %q", result, tt.expectedMode)
}
hasWarning := strings.Contains(stderrOutput, "Warning:")
if tt.expectsWarning && !hasWarning {
t.Errorf("Expected warning in stderr, got none. stderr=%q", stderrOutput)
}
if !tt.expectsWarning && hasWarning {
t.Errorf("Unexpected warning in stderr: %q", stderrOutput)
}
})
}
}
func TestGetConflictStrategy(t *testing.T) {
tests := []struct {
name string
configValue string
expectedStrategy ConflictStrategy
expectsWarning bool
}{
{
name: "empty returns default",
configValue: "",
expectedStrategy: ConflictStrategyNewest,
expectsWarning: false,
},
{
name: "newest is valid",
configValue: "newest",
expectedStrategy: ConflictStrategyNewest,
expectsWarning: false,
},
{
name: "ours is valid",
configValue: "ours",
expectedStrategy: ConflictStrategyOurs,
expectsWarning: false,
},
{
name: "theirs is valid",
configValue: "theirs",
expectedStrategy: ConflictStrategyTheirs,
expectsWarning: false,
},
{
name: "manual is valid",
configValue: "manual",
expectedStrategy: ConflictStrategyManual,
expectsWarning: false,
},
{
name: "mixed case is normalized",
configValue: "NEWEST",
expectedStrategy: ConflictStrategyNewest,
expectsWarning: false,
},
{
name: "whitespace is trimmed",
configValue: " ours ",
expectedStrategy: ConflictStrategyOurs,
expectsWarning: false,
},
{
name: "invalid value returns default with warning",
configValue: "invalid-strategy",
expectedStrategy: ConflictStrategyNewest,
expectsWarning: true,
},
{
name: "last-write-wins typo returns default with warning",
configValue: "last-write-wins",
expectedStrategy: ConflictStrategyNewest,
expectsWarning: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset viper for test
ResetForTesting()
if err := Initialize(); err != nil {
t.Fatalf("Initialize failed: %v", err)
}
// Set the config value
if tt.configValue != "" {
Set("conflict.strategy", tt.configValue)
}
// Capture stderr
oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
result := GetConflictStrategy()
// Restore stderr and get output
w.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
buf.ReadFrom(r)
stderrOutput := buf.String()
if result != tt.expectedStrategy {
t.Errorf("GetConflictStrategy() = %q, want %q", result, tt.expectedStrategy)
}
hasWarning := strings.Contains(stderrOutput, "Warning:")
if tt.expectsWarning && !hasWarning {
t.Errorf("Expected warning in stderr, got none. stderr=%q", stderrOutput)
}
if !tt.expectsWarning && hasWarning {
t.Errorf("Unexpected warning in stderr: %q", stderrOutput)
}
})
}
}
func TestGetSovereignty(t *testing.T) {
tests := []struct {
name string
configValue string
expectedTier Sovereignty
expectsWarning bool
}{
{
name: "empty returns default",
configValue: "",
expectedTier: SovereigntyT1,
expectsWarning: false,
},
{
name: "T1 is valid",
configValue: "T1",
expectedTier: SovereigntyT1,
expectsWarning: false,
},
{
name: "T2 is valid",
configValue: "T2",
expectedTier: SovereigntyT2,
expectsWarning: false,
},
{
name: "T3 is valid",
configValue: "T3",
expectedTier: SovereigntyT3,
expectsWarning: false,
},
{
name: "T4 is valid",
configValue: "T4",
expectedTier: SovereigntyT4,
expectsWarning: false,
},
{
name: "lowercase is normalized",
configValue: "t1",
expectedTier: SovereigntyT1,
expectsWarning: false,
},
{
name: "whitespace is trimmed",
configValue: " T2 ",
expectedTier: SovereigntyT2,
expectsWarning: false,
},
{
name: "invalid value returns default with warning",
configValue: "T5",
expectedTier: SovereigntyT1,
expectsWarning: true,
},
{
name: "invalid tier 0 returns default with warning",
configValue: "T0",
expectedTier: SovereigntyT1,
expectsWarning: true,
},
{
name: "word tier returns default with warning",
configValue: "public",
expectedTier: SovereigntyT1,
expectsWarning: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset viper for test
ResetForTesting()
if err := Initialize(); err != nil {
t.Fatalf("Initialize failed: %v", err)
}
// Set the config value
if tt.configValue != "" {
Set("federation.sovereignty", tt.configValue)
}
// Capture stderr
oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
result := GetSovereignty()
// Restore stderr and get output
w.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
buf.ReadFrom(r)
stderrOutput := buf.String()
if result != tt.expectedTier {
t.Errorf("GetSovereignty() = %q, want %q", result, tt.expectedTier)
}
hasWarning := strings.Contains(stderrOutput, "Warning:")
if tt.expectsWarning && !hasWarning {
t.Errorf("Expected warning in stderr, got none. stderr=%q", stderrOutput)
}
if !tt.expectsWarning && hasWarning {
t.Errorf("Unexpected warning in stderr: %q", stderrOutput)
}
})
}
}