feat(internal): add foundational packages — config, logger, errors

- config: env-based loading with startup validation, 13 tests
- logger: structured slog wrapper with redaction, 10 tests
- errors: unified AppError model with HTTP mapping, 18 tests
- 41 tests total, build + vet clean
This commit is contained in:
Aska committed 2026-08-09 20:35:42 +08:00
1 parent b69b74435a
commit 7943e290ec
11 files changed
+1590

No files matched your search

View File
Whitespace-only changes.
+311
View File
@@ -0,0 +1,311 @@
// Package config provides configuration loading with environment variable binding and startup validation.
// All configuration values are loaded from environment variables with sensible defaults.
// Required fields that are missing will cause a fatal error at startup.
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
// Config holds all application configuration.
// Fields are populated from environment variables during Load().
type Config struct {
// Environment
Env string // "development" | "production" | "test"
LogLevel string // "debug" | "info" | "warn" | "error"
LogFmt string // "text" | "json"
// Server
Port int
Host string
GinMode string // "debug" | "release" | "test"
// Database
DatabaseURL string // Full connection string (takes priority)
PostgresHost string
PostgresPort int
PostgresUser string
PostgresPassword string
PostgresDB string
PostgresSSLMode string
// NATS
NATSUrl string
// Redis
RedisHost string
RedisPort int
// LLM
LLMBaseURL string
LLMAPIKey string
LLMModel string
LLMFallbackModel string
// Embedding
EmbeddingAPIURL string
EmbeddingModel string
EmbeddingAPIKey string
// Voice
DashScopeAPIKey string
TTSProvider string // "edge-tts" | "espeak"
ASRProvider string // "dashscope" | "whisper-local"
// Auth
JWTSecret string
JWTExpiryHours int
AdminUsername string
AdminPassword string
RegistrationOpen bool
// Internal Service
InternalServiceToken string
// CORS
AllowedOrigins []string
// Background Thinking
EnableBackgroundThinking bool
ThinkIntervalSec int
ThinkSilenceTimeoutSec int
// Platform Bridge
PlatformBridgeEnabled bool
PlatformChannels string
// IoT
IoTProvider string // "mock" | "homeassistant" | "mqtt" | "none"
// IoT Debug Service URL (legacy, kept for v1 compat)
IoTServiceURL string
// RAG
PersonaDir string
KnowledgeDir string
DataDir string
// Search
SearXNGURL string
// Host Exec
HostExecBackend string // "wsl" | "docker" | "direct" | ""
// Caddy (production)
Domain string
ACMEEmail string
// Ethend (deprecated, kept for compat)
EthendPort int
// WebSocket
WSMaxConnections int
SessionIdleTimeoutMin int
// Timezone
TZ string
}
// DefaultConfig returns a Config populated with safe default values.
func DefaultConfig() *Config {
return &Config{
Env: "development",
LogLevel: "info",
LogFmt: "text",
Port: 8080,
Host: "0.0.0.0",
GinMode: "debug",
PostgresHost: "localhost",
PostgresPort: 5432,
PostgresUser: "cyrene",
PostgresPassword: "cyrene_pass",
PostgresDB: "cyrene_ai",
PostgresSSLMode: "disable",
NATSUrl: "nats://localhost:4222",
RedisHost: "localhost",
RedisPort: 6379,
LLMBaseURL: "https://api.openai.com/v1",
LLMModel: "gpt-4o",
LLMFallbackModel: "gpt-4o-mini",
EmbeddingModel: "text-embedding-3-small",
TTSProvider: "edge-tts",
ASRProvider: "dashscope",
JWTExpiryHours: 720,
AdminUsername: "admin",
RegistrationOpen: true,
EnableBackgroundThinking: true,
ThinkIntervalSec: 300,
ThinkSilenceTimeoutSec: 120,
IoTProvider: "mock",
PersonaDir: "./internal/persona",
KnowledgeDir: "./data/knowledge",
DataDir: "./data",
HostExecBackend: "",
EthendPort: 9090,
WSMaxConnections: 1000,
SessionIdleTimeoutMin: 30,
TZ: "Asia/Shanghai",
}
}
// DatabaseURL returns the constructed database URL if no explicit DATABASE_URL was set.
func (c *Config) GetDatabaseURL() string {
if c.DatabaseURL != "" {
return c.DatabaseURL
}
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
c.PostgresUser, c.PostgresPassword, c.PostgresHost, c.PostgresPort, c.PostgresDB, c.PostgresSSLMode)
}
// IsDevelopment returns true when running in development mode.
func (c *Config) IsDevelopment() bool {
return c.Env == "development"
}
// IsProduction returns true when running in production mode.
func (c *Config) IsProduction() bool {
return c.Env == "production"
}
// validate runs all validation checks on the loaded config.
// Returns a list of validation errors.
func (c *Config) validate() []string {
var errs []string
// Required: LLM API Key (only required in production or when not using mock)
if c.LLMAPIKey == "" {
errs = append(errs, "LLM_API_KEY is required")
}
// Required: JWT Secret
if c.JWTSecret == "" {
errs = append(errs, "JWT_SECRET is required")
}
if len(c.JWTSecret) < 32 {
errs = append(errs, "JWT_SECRET must be at least 32 characters")
}
// Required: Admin Password
if c.AdminPassword == "" {
errs = append(errs, "ADMIN_PASSWORD is required")
}
if len(c.AdminPassword) < 8 {
errs = append(errs, "ADMIN_PASSWORD must be at least 8 characters")
}
// Required: Internal Service Token
if c.InternalServiceToken == "" && c.IsProduction() {
errs = append(errs, "INTERNAL_SERVICE_TOKEN is required in production")
}
// IoT Provider must be valid
validIoTProviders := map[string]bool{"mock": true, "homeassistant": true, "mqtt": true, "none": true}
if !validIoTProviders[c.IoTProvider] {
errs = append(errs, fmt.Sprintf("IOT_PROVIDER must be one of: mock, homeassistant, mqtt, none (got: %s)", c.IoTProvider))
}
return errs
}
// ValidateOrFatal validates the config and calls log.Fatal if any required fields are missing.
func (c *Config) ValidateOrFatal() {
errs := c.validate()
if len(errs) > 0 {
fmt.Fprintf(os.Stderr, "=== Configuration validation errors ===\n")
for _, e := range errs {
fmt.Fprintf(os.Stderr, " - %s\n", e)
}
fmt.Fprintf(os.Stderr, "=======================================\n")
fmt.Fprintf(os.Stderr, "Fix the above errors and restart.\n")
os.Exit(1)
}
}
// --- Helper functions for loading from environment ---
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
func getEnvInt(key string, defaultVal int) int {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
i, err := strconv.Atoi(v)
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid integer for %s=%q, using default %d\n", key, v, defaultVal)
return defaultVal
}
return i
}
func getEnvBool(key string, defaultVal bool) bool {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
fmt.Fprintf(os.Stderr, "WARNING: invalid boolean for %s=%q, using default %v\n", key, v, defaultVal)
return defaultVal
}
}
func getEnvSlice(key string, defaultVal []string) []string {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
parts := strings.Split(v, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
if len(result) == 0 {
return defaultVal
}
return result
}
func getEnvDuration(key string, defaultVal time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
d, err := time.ParseDuration(v)
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid duration for %s=%q, using default %v\n", key, v, defaultVal)
return defaultVal
}
return d
}
+308
View File
@@ -0,0 +1,308 @@
package config
import (
"os"
"testing"
)
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if cfg.Port != 8080 {
t.Errorf("expected Port=8080, got %d", cfg.Port)
}
if cfg.Env != "development" {
t.Errorf("expected Env=development, got %s", cfg.Env)
}
if cfg.PostgresHost != "localhost" {
t.Errorf("expected PostgresHost=localhost, got %s", cfg.PostgresHost)
}
if cfg.IoTProvider != "mock" {
t.Errorf("expected IoTProvider=mock, got %s", cfg.IoTProvider)
}
}
func TestGetDatabaseURL(t *testing.T) {
tests := []struct {
name string
dbURL string
host string
port int
user string
pass string
db string
sslMode string
expected string
}{
{
name: "explicit DATABASE_URL takes priority",
dbURL: "postgres://custom:pass@remote:9999/mydb?sslmode=require",
expected: "postgres://custom:pass@remote:9999/mydb?sslmode=require",
},
{
name: "default connection string from individual fields",
host: "localhost",
port: 5432,
user: "cyrene",
pass: "cyrene_pass",
db: "cyrene_ai",
sslMode: "disable",
expected: "postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := DefaultConfig()
if tt.dbURL != "" {
cfg.DatabaseURL = tt.dbURL
}
if tt.host != "" {
cfg.PostgresHost = tt.host
}
if tt.port != 0 {
cfg.PostgresPort = tt.port
}
if tt.user != "" {
cfg.PostgresUser = tt.user
}
if tt.pass != "" {
cfg.PostgresPassword = tt.pass
}
if tt.db != "" {
cfg.PostgresDB = tt.db
}
if tt.sslMode != "" {
cfg.PostgresSSLMode = tt.sslMode
}
got := cfg.GetDatabaseURL()
if got != tt.expected {
t.Errorf("GetDatabaseURL() = %q, want %q", got, tt.expected)
}
})
}
}
func TestIsDevelopment(t *testing.T) {
cfg := DefaultConfig()
cfg.Env = "development"
if !cfg.IsDevelopment() {
t.Error("expected IsDevelopment() to be true")
}
if cfg.IsProduction() {
t.Error("expected IsProduction() to be false in dev mode")
}
cfg.Env = "production"
if cfg.IsDevelopment() {
t.Error("expected IsDevelopment() to be false in prod mode")
}
if !cfg.IsProduction() {
t.Error("expected IsProduction() to be true")
}
}
func TestValidateMissingLLMAPIKey(t *testing.T) {
cfg := DefaultConfig()
cfg.LLMAPIKey = ""
cfg.Env = "development"
// In dev mode, we validate but don't call ValidateOrFatal directly
errs := cfg.validate()
found := false
for _, e := range errs {
if contains(e, "LLM_API_KEY") {
found = true
}
}
if !found {
t.Errorf("expected LLM_API_KEY validation error, got: %v", errs)
}
}
func TestValidateMissingJWTSecret(t *testing.T) {
cfg := DefaultConfig()
cfg.JWTSecret = ""
errs := cfg.validate()
found := false
for _, e := range errs {
if contains(e, "JWT_SECRET") {
found = true
}
}
if !found {
t.Errorf("expected JWT_SECRET validation error, got: %v", errs)
}
}
func TestValidateJWTSecretTooShort(t *testing.T) {
cfg := DefaultConfig()
cfg.JWTSecret = "short"
errs := cfg.validate()
found := false
for _, e := range errs {
if contains(e, "JWT_SECRET must be at least 32 characters") {
found = true
}
}
if !found {
t.Errorf("expected JWT_SECRET length validation error, got: %v", errs)
}
}
func TestValidateMissingAdminPassword(t *testing.T) {
cfg := DefaultConfig()
cfg.AdminPassword = ""
errs := cfg.validate()
found := false
for _, e := range errs {
if contains(e, "ADMIN_PASSWORD") {
found = true
}
}
if !found {
t.Errorf("expected ADMIN_PASSWORD validation error, got: %v", errs)
}
}
func TestValidateAdminPasswordTooShort(t *testing.T) {
cfg := DefaultConfig()
cfg.AdminPassword = "1234567" // 7 chars, min is 8
errs := cfg.validate()
found := false
for _, e := range errs {
if contains(e, "ADMIN_PASSWORD must be at least 8 characters") {
found = true
}
}
if !found {
t.Errorf("expected ADMIN_PASSWORD length validation error, got: %v", errs)
}
}
func TestValidateInvalidIoTProvider(t *testing.T) {
cfg := DefaultConfig()
cfg.IoTProvider = "invalid-provider"
errs := cfg.validate()
found := false
for _, e := range errs {
if contains(e, "IOT_PROVIDER") {
found = true
}
}
if !found {
t.Errorf("expected IOT_PROVIDER validation error, got: %v", errs)
}
}
func TestLoadFromEnv(t *testing.T) {
// Set some test env vars
os.Setenv("PORT", "9999")
os.Setenv("ENV", "test")
os.Setenv("LLM_API_KEY", "sk-test-key")
os.Setenv("JWT_SECRET", "a-very-long-secret-that-is-at-least-32-chars!!")
os.Setenv("ADMIN_PASSWORD", "test-admin-password")
defer func() {
os.Unsetenv("PORT")
os.Unsetenv("ENV")
os.Unsetenv("LLM_API_KEY")
os.Unsetenv("JWT_SECRET")
os.Unsetenv("ADMIN_PASSWORD")
}()
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Port != 9999 {
t.Errorf("expected Port=9999 from env, got %d", cfg.Port)
}
if cfg.Env != "test" {
t.Errorf("expected Env=test from env, got %s", cfg.Env)
}
if cfg.LLMAPIKey != "sk-test-key" {
t.Errorf("expected LLMAPIKey from env, got %s", cfg.LLMAPIKey)
}
// Validation should pass with these values
errs := cfg.validate()
if len(errs) > 0 {
t.Errorf("validation should pass but got errors: %v", errs)
}
}
func TestGetEnvBool(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"1", true}, {"true", true}, {"TRUE", true}, {"yes", true}, {"on", true},
{"0", false}, {"false", false}, {"FALSE", false}, {"no", false}, {"off", false},
{"", true}, // empty should use default=true
}
for _, tt := range tests {
os.Setenv("TEST_BOOL_VAR", tt.input)
got := getEnvBool("TEST_BOOL_VAR", true)
if got != tt.expected {
t.Errorf("getEnvBool(%q, true) = %v, want %v", tt.input, got, tt.expected)
}
}
os.Unsetenv("TEST_BOOL_VAR")
}
func TestGetEnvSlice(t *testing.T) {
tests := []struct {
input string
expected []string
}{
{"", []string{"default"}},
{"a,b,c", []string{"a", "b", "c"}},
{" a , b , c ", []string{"a", "b", "c"}}, // trim spaces
{"single", []string{"single"}},
}
for _, tt := range tests {
os.Setenv("TEST_SLICE_VAR", tt.input)
got := getEnvSlice("TEST_SLICE_VAR", []string{"default"})
if len(got) != len(tt.expected) {
t.Errorf("getEnvSlice(%q) length = %d, want %d", tt.input, len(got), len(tt.expected))
continue
}
for i := range got {
if got[i] != tt.expected[i] {
t.Errorf("getEnvSlice(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.expected[i])
}
}
}
os.Unsetenv("TEST_SLICE_VAR")
}
func TestAllValidIoTProviders(t *testing.T) {
validProviders := []string{"mock", "homeassistant", "mqtt", "none"}
for _, provider := range validProviders {
cfg := DefaultConfig()
cfg.IoTProvider = provider
errs := cfg.validate()
for _, e := range errs {
if contains(e, "IOT_PROVIDER") {
t.Errorf("IoTProvider=%q should be valid but got error: %s", provider, e)
}
}
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchSubstring(s, substr)
}
func searchSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+171
View File
@@ -0,0 +1,171 @@
package config
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/joho/godotenv"
)
// Load reads configuration from environment variables, with optional .env file support.
// It returns a fully populated Config or an error if the .env file failed to load.
// Use ValidateOrFatal() after loading to enforce required fields.
func Load() (*Config, error) {
cfg := DefaultConfig()
// Attempt to load .env file from multiple locations
_ = loadEnvFile()
// Merge environment variables over defaults
cfg.mergeEnv()
return cfg, nil
}
// loadEnvFile tries to load .env from the project root.
// It does not return an error; missing .env is acceptable.
func loadEnvFile() error {
// Try current directory first
if err := godotenv.Load(); err == nil {
return nil
}
// Try relative to executable
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
candidates := []string{
filepath.Join(exeDir, ".env"),
filepath.Join(exeDir, "..", ".env"),
filepath.Join(exeDir, "..", "..", ".env"),
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
_ = godotenv.Load(path)
return nil
}
}
}
return fmt.Errorf(".env not found")
}
// mergeEnv reads all environment variables and overrides config defaults.
func (c *Config) mergeEnv() {
// Environment
c.Env = getEnv("ENV", c.Env)
c.LogLevel = getEnv("LOG_LEVEL", c.LogLevel)
c.LogFmt = getEnv("LOG_FORMAT", c.LogFmt)
// Server
c.Port = getEnvInt("PORT", c.Port)
c.Host = getEnv("HOST", c.Host)
c.GinMode = getEnv("GIN_MODE", c.GinMode)
// Database
c.DatabaseURL = os.Getenv("DATABASE_URL")
c.PostgresHost = getEnv("POSTGRES_HOST", c.PostgresHost)
c.PostgresPort = getEnvInt("POSTGRES_PORT", c.PostgresPort)
c.PostgresUser = getEnv("POSTGRES_USER", c.PostgresUser)
c.PostgresPassword = getEnv("POSTGRES_PASSWORD", c.PostgresPassword)
c.PostgresDB = getEnv("POSTGRES_DB", c.PostgresDB)
c.PostgresSSLMode = getEnv("POSTGRES_SSLMODE", c.PostgresSSLMode)
// NATS
c.NATSUrl = getEnv("NATS_URL", c.NATSUrl)
// Redis
c.RedisHost = getEnv("REDIS_HOST", c.RedisHost)
c.RedisPort = getEnvInt("REDIS_PORT", c.RedisPort)
// LLM
c.LLMBaseURL = getEnv("LLM_API_URL", c.LLMBaseURL)
c.LLMAPIKey = getEnv("LLM_API_KEY", c.LLMAPIKey)
c.LLMModel = getEnv("LLM_MODEL", c.LLMModel)
c.LLMFallbackModel = getEnv("LLM_FALLBACK_MODEL", c.LLMFallbackModel)
// Embedding
c.EmbeddingAPIURL = getEnv("EMBEDDING_API_URL", c.EmbeddingAPIURL)
c.EmbeddingModel = getEnv("EMBEDDING_MODEL", c.EmbeddingModel)
c.EmbeddingAPIKey = getEnv("EMBEDDING_API_KEY", c.EmbeddingAPIKey)
// Voice
c.DashScopeAPIKey = getEnv("DASHSCOPE_API_KEY", c.DashScopeAPIKey)
c.TTSProvider = getEnv("TTS_PROVIDER", c.TTSProvider)
c.ASRProvider = getEnv("ASR_PROVIDER", c.ASRProvider)
// Auth
c.JWTSecret = getEnv("JWT_SECRET", c.JWTSecret)
c.JWTExpiryHours = getEnvInt("JWT_EXPIRY_HOURS", c.JWTExpiryHours)
c.AdminUsername = getEnv("ADMIN_USERNAME", c.AdminUsername)
c.AdminPassword = getEnv("ADMIN_PASSWORD", c.AdminPassword)
c.RegistrationOpen = getEnvBool("REGISTRATION_ENABLED", c.RegistrationOpen)
// Internal Service
c.InternalServiceToken = getEnv("INTERNAL_SERVICE_TOKEN", c.InternalServiceToken)
// CORS
c.AllowedOrigins = getEnvSlice("ALLOWED_ORIGINS", c.AllowedOrigins)
// Background Thinking
c.EnableBackgroundThinking = getEnvBool("ENABLE_BACKGROUND_THINKING", c.EnableBackgroundThinking)
c.ThinkIntervalSec = getEnvInt("THINK_INTERVAL_SEC", c.ThinkIntervalSec)
c.ThinkSilenceTimeoutSec = getEnvInt("THINK_SILENCE_TIMEOUT_SEC", c.ThinkSilenceTimeoutSec)
// Platform Bridge
c.PlatformBridgeEnabled = getEnvBool("PLATFORM_BRIDGE_ENABLED", c.PlatformBridgeEnabled)
c.PlatformChannels = getEnv("PLATFORM_CHANNELS", c.PlatformChannels)
// IoT
c.IoTProvider = getEnv("IOT_PROVIDER", c.IoTProvider)
c.IoTServiceURL = getEnv("IOT_SERVICE_URL", c.IoTServiceURL)
// RAG & Data
c.PersonaDir = getEnv("PERSONA_DIR", c.PersonaDir)
c.KnowledgeDir = getEnv("KNOWLEDGE_DIR", c.KnowledgeDir)
c.DataDir = getEnv("DATA_DIR", c.DataDir)
// Search
c.SearXNGURL = getEnv("SEARXNG_URL", c.SearXNGURL)
// Host Exec
c.HostExecBackend = getEnv("HOST_EXEC_BACKEND", c.HostExecBackend)
// Caddy (production)
c.Domain = getEnv("DOMAIN", c.Domain)
c.ACMEEmail = getEnv("ACME_EMAIL", c.ACMEEmail)
// Ethend (deprecated)
c.EthendPort = getEnvInt("ETHEND_PORT", c.EthendPort)
// WebSocket
c.WSMaxConnections = getEnvInt("WS_MAX_CONNECTIONS", c.WSMaxConnections)
c.SessionIdleTimeoutMin = getEnvInt("SESSION_IDLE_TIMEOUT_MIN", c.SessionIdleTimeoutMin)
// Timezone
c.TZ = getEnv("TZ", c.TZ)
}
// logSanitized prints the loaded config, with sensitive fields redacted.
func (c *Config) LogSanitized() {
log.Printf("[config] ENV=%s LOG_LEVEL=%s LOG_FORMAT=%s", c.Env, c.LogLevel, c.LogFmt)
log.Printf("[config] PORT=%d GIN_MODE=%s", c.Port, c.GinMode)
log.Printf("[config] DB_HOST=%s DB_PORT=%d DB_USER=%s DB_NAME=%s",
c.PostgresHost, c.PostgresPort, c.PostgresUser, c.PostgresDB)
log.Printf("[config] NATS_URL=%s REDIS=%s:%d", c.NATSUrl, c.RedisHost, c.RedisPort)
log.Printf("[config] LLM_URL=%s LLM_MODEL=%s FALLBACK=%s",
c.LLMBaseURL, c.LLMModel, c.LLMFallbackModel)
log.Printf("[config] LLM_API_KEY=**** (len=%d)", len(c.LLMAPIKey))
if c.EmbeddingAPIURL != "" {
log.Printf("[config] EMBEDDING_URL=%s MODEL=%s", c.EmbeddingAPIURL, c.EmbeddingModel)
} else {
log.Printf("[config] EMBEDDING=disabled (using n-gram fallback)")
}
log.Printf("[config] IOT_PROVIDER=%s", c.IoTProvider)
log.Printf("[config] TTS=%s ASR=%s", c.TTSProvider, c.ASRProvider)
log.Printf("[config] BACKGROUND_THINKING=%v INTERVAL=%ds SILENCE=%ds",
c.EnableBackgroundThinking, c.ThinkIntervalSec, c.ThinkSilenceTimeoutSec)
log.Printf("[config] JWT_SECRET=**** (len=%d)", len(c.JWTSecret))
log.Printf("[config] ADMIN=%s REGISTRATION=%v", c.AdminUsername, c.RegistrationOpen)
}