feat: add configuration management with YAML parsing and validation
Add internal/config package with Config struct (Server, Navidrome, MusicBrainz, Telegram, Scanner sections), LoadConfig function for YAML parsing, config validation (required fields, port range, threshold range), and defaults. Include config.yaml.example matching spec. Update main.go to use real config package instead of placeholders. Add comprehensive tests covering valid configs, defaults, missing files, malformed YAML, and validation boundaries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,13 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"naviwatcher/internal/config"
|
||||
)
|
||||
|
||||
const defaultConfigPath = "config.yaml"
|
||||
@@ -19,7 +20,7 @@ func main() {
|
||||
|
||||
log.Println("NaviWatcher starting...")
|
||||
|
||||
cfg, err := loadConfig(*configPath)
|
||||
cfg, err := config.LoadConfig(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
@@ -45,44 +46,7 @@ func main() {
|
||||
log.Println("NaviWatcher stopped.")
|
||||
}
|
||||
|
||||
// placeholderConfig mirrors the expected config structure for the skeleton phase.
|
||||
// Will be replaced by internal/config.Config in Task 2.
|
||||
type placeholderConfig struct {
|
||||
Server struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(path string) (*placeholderConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
var cfg placeholderConfig
|
||||
if err := parseYAML(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Server.Host == "" {
|
||||
cfg.Server.Host = "0.0.0.0"
|
||||
}
|
||||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8080
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// parseYAML is a temporary placeholder. Will be replaced by gopkg.in/yaml.v3 in Task 2.
|
||||
func parseYAML(data []byte, out *placeholderConfig) error {
|
||||
// Minimal YAML parsing for skeleton: just handle "server:\n host: ...\n port: ..."
|
||||
// This is intentionally simple and will be replaced by proper YAML parsing.
|
||||
return nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfg *placeholderConfig) error {
|
||||
func run(ctx context.Context, cfg *config.Config) error {
|
||||
// Main application loop — blocks until context is cancelled.
|
||||
// Business logic will be added in future tasks.
|
||||
<-ctx.Done()
|
||||
|
||||
@@ -1,43 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"naviwatcher/internal/config"
|
||||
)
|
||||
|
||||
func TestLoadConfig_Defaults(t *testing.T) {
|
||||
// The skeleton loadConfig applies defaults when host/port are zero-value.
|
||||
func TestDefaultConfigPath(t *testing.T) {
|
||||
if defaultConfigPath != "config.yaml" {
|
||||
t.Errorf("expected default config path 'config.yaml', got %q", defaultConfigPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_GracefulShutdown(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
cfg := &config.Config{}
|
||||
if err := run(ctx, cfg); err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigIntegration(t *testing.T) {
|
||||
// Integration test: write a minimal valid config and load it via config.LoadConfig,
|
||||
// verifying the full path that main() uses.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("server:\n host: \"\"\n port: 0\n"), 0644); err != nil {
|
||||
|
||||
yaml := `server:
|
||||
host: "127.0.0.1"
|
||||
port: 9090
|
||||
navidrome:
|
||||
url: "http://localhost:4533"
|
||||
user: "test"
|
||||
password: "test"
|
||||
musicbrainz:
|
||||
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(path)
|
||||
cfg, err := config.LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig returned error: %v", err)
|
||||
t.Fatalf("LoadConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.Host != "0.0.0.0" {
|
||||
t.Errorf("expected default host 0.0.0.0, got %q", cfg.Server.Host)
|
||||
if cfg.Server.Host != "127.0.0.1" {
|
||||
t.Errorf("expected host 127.0.0.1, got %q", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("expected default port 8080, got %d", cfg.Server.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_FileNotFound(t *testing.T) {
|
||||
_, err := loadConfig("/nonexistent/path/config.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYAML_Empty(t *testing.T) {
|
||||
var cfg placeholderConfig
|
||||
err := parseYAML([]byte(""), &cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("parseYAML returned error for empty input: %v", err)
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Errorf("expected port 9090, got %d", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Navidrome.URL != "http://localhost:4533" {
|
||||
t.Errorf("expected navidrome url http://localhost:4533, got %q", cfg.Navidrome.URL)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user