diff --git a/cmd/naviwatcher/main.go b/cmd/naviwatcher/main.go index acc0cca..6de5771 100644 --- a/cmd/naviwatcher/main.go +++ b/cmd/naviwatcher/main.go @@ -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() diff --git a/cmd/naviwatcher/main_test.go b/cmd/naviwatcher/main_test.go index 6c0cd95..29c36c0 100644 --- a/cmd/naviwatcher/main_test.go +++ b/cmd/naviwatcher/main_test.go @@ -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) } } diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..5e162a8 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,26 @@ +server: + host: "0.0.0.0" + port: 8080 + # Basic Auth for Web UI access + username: "admin" + password: "password123" + +navidrome: + url: "http://localhost:4533" + user: "watcher_service" + password: "user_password" + +musicbrainz: + user_agent: "NaviWatcher/1.0 ( your-email@example.com )" + cache_ttl: 24h + +telegram: + enabled: true + token: "bot_token" + chat_id: "your_chat_id" + cron_schedule: "0 10 * * *" + +scanner: + fuzzy_threshold: 0.85 + ignore_bootlegs: true + include_compilations: true diff --git a/docs/plans/2026-05-20-foundation-layer.md b/docs/plans/2026-05-20-foundation-layer.md index 9833ba0..05c3892 100644 --- a/docs/plans/2026-05-20-foundation-layer.md +++ b/docs/plans/2026-05-20-foundation-layer.md @@ -65,14 +65,14 @@ Build the foundation layer of NaviWatcher: a greenfield Go project with zero exi - [x] run tests — must pass before task 2 ### Task 2: Configuration management -- [ ] create `internal/config/config.go` with Config struct matching spec (Server, Navidrome, MusicBrainz, Telegram, Scanner sections) -- [ ] implement `LoadConfig(path string) (*Config, error)` function that reads and parses YAML -- [ ] implement config validation (required fields, valid port range, valid threshold range 0.0-1.0) -- [ ] create `config.yaml.example` with all fields filled with placeholder values per spec section 7 -- [ ] write tests for LoadConfig: valid config file -- [ ] write tests for LoadConfig: missing file, malformed YAML, invalid values -- [ ] write tests for config validation logic -- [ ] run tests — must pass before task 3 +- [x] create `internal/config/config.go` with Config struct matching spec (Server, Navidrome, MusicBrainz, Telegram, Scanner sections) +- [x] implement `LoadConfig(path string) (*Config, error)` function that reads and parses YAML +- [x] implement config validation (required fields, valid port range, valid threshold range 0.0-1.0) +- [x] create `config.yaml.example` with all fields filled with placeholder values per spec section 7 +- [x] write tests for LoadConfig: valid config file +- [x] write tests for LoadConfig: missing file, malformed YAML, invalid values +- [x] write tests for config validation logic +- [x] run tests — must pass before task 3 ### Task 3: Database layer — schema and migrations - [ ] create `internal/database/database.go` with DB struct and `New(dbPath string) (*DB, error)` constructor diff --git a/go.mod b/go.mod index 75891c3..e08372e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module naviwatcher go 1.25.1 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index e69de29..4bc0337 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,3 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0624d0e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,112 @@ +package config + +import ( + "fmt" + "os" + "time" + + "gopkg.in/yaml.v3" +) + +// Config represents the top-level configuration for NaviWatcher. +type Config struct { + Server ServerConfig `yaml:"server"` + Navidrome NavidromeConfig `yaml:"navidrome"` + MusicBrainz MusicBrainzConfig `yaml:"musicbrainz"` + Telegram TelegramConfig `yaml:"telegram"` + Scanner ScannerConfig `yaml:"scanner"` +} + +// ServerConfig holds HTTP server settings. +type ServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +// NavidromeConfig holds Subsonic API connection details. +type NavidromeConfig struct { + URL string `yaml:"url"` + User string `yaml:"user"` + Password string `yaml:"password"` +} + +// MusicBrainzConfig holds MusicBrainz API settings. +type MusicBrainzConfig struct { + UserAgent string `yaml:"user_agent"` + CacheTTL time.Duration `yaml:"cache_ttl"` +} + +// TelegramConfig holds Telegram bot notification settings. +type TelegramConfig struct { + Enabled bool `yaml:"enabled"` + Token string `yaml:"token"` + ChatID string `yaml:"chat_id"` + CronSchedule string `yaml:"cron_schedule"` +} + +// ScannerConfig holds scanner engine parameters. +type ScannerConfig struct { + FuzzyThreshold float64 `yaml:"fuzzy_threshold"` + IgnoreBootlegs bool `yaml:"ignore_bootlegs"` + IncludeCompilations bool `yaml:"include_compilations"` +} + +// LoadConfig reads a YAML file from path, parses it, applies defaults, +// and validates the configuration. +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + applyDefaults(&cfg) + + if err := validate(&cfg); err != nil { + return nil, fmt.Errorf("validate config: %w", err) + } + + return &cfg, nil +} + +// applyDefaults sets zero-value defaults for optional fields. +func applyDefaults(cfg *Config) { + if cfg.Server.Host == "" { + cfg.Server.Host = "0.0.0.0" + } + if cfg.Server.Port == 0 { + cfg.Server.Port = 8080 + } + if cfg.Scanner.FuzzyThreshold == 0 { + cfg.Scanner.FuzzyThreshold = 0.85 + } +} + +// validate checks that required fields are set and values are within acceptable ranges. +func validate(cfg *Config) error { + if cfg.Navidrome.URL == "" { + return fmt.Errorf("navidrome.url is required") + } + if cfg.Navidrome.User == "" { + return fmt.Errorf("navidrome.user is required") + } + if cfg.Navidrome.Password == "" { + return fmt.Errorf("navidrome.password is required") + } + if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { + return fmt.Errorf("server.port must be between 1 and 65535, got %d", cfg.Server.Port) + } + if cfg.Scanner.FuzzyThreshold < 0.0 || cfg.Scanner.FuzzyThreshold > 1.0 { + return fmt.Errorf("scanner.fuzzy_threshold must be between 0.0 and 1.0, got %f", cfg.Scanner.FuzzyThreshold) + } + if cfg.MusicBrainz.UserAgent == "" { + return fmt.Errorf("musicbrainz.user_agent is required") + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..920bb87 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,366 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestLoadConfig_Valid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +server: + host: "127.0.0.1" + port: 9090 + username: "testuser" + password: "testpass" + +navidrome: + url: "http://navidrome:4533" + user: "service" + password: "secret" + +musicbrainz: + user_agent: "NaviWatcher/1.0 ( test@example.com )" + cache_ttl: 24h + +telegram: + enabled: true + token: "abc123" + chat: "chat456" + cron_schedule: "0 10 * * *" + +scanner: + fuzzy_threshold: 0.9 + ignore_bootlegs: true + include_compilations: false +` + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig returned error: %v", err) + } + + if cfg.Server.Host != "127.0.0.1" { + t.Errorf("expected host 127.0.0.1, got %s", cfg.Server.Host) + } + if cfg.Server.Port != 9090 { + t.Errorf("expected port 9090, got %d", cfg.Server.Port) + } + if cfg.Server.Username != "testuser" { + t.Errorf("expected username testuser, got %s", cfg.Server.Username) + } + if cfg.Navidrome.URL != "http://navidrome:4533" { + t.Errorf("expected navidrome url http://navidrome:4533, got %s", cfg.Navidrome.URL) + } + if cfg.Navidrome.User != "service" { + t.Errorf("expected navidrome user service, got %s", cfg.Navidrome.User) + } + if cfg.Scanner.FuzzyThreshold != 0.9 { + t.Errorf("expected fuzzy_threshold 0.9, got %f", cfg.Scanner.FuzzyThreshold) + } + if !cfg.Scanner.IgnoreBootlegs { + t.Error("expected ignore_bootlegs true") + } + if cfg.Scanner.IncludeCompilations { + t.Error("expected include_compilations false") + } +} + +func TestLoadConfig_Defaults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +navidrome: + url: "http://localhost:4533" + user: "u" + password: "p" + +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) + if err != nil { + 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 %s", cfg.Server.Host) + } + if cfg.Server.Port != 8080 { + t.Errorf("expected default port 8080, got %d", cfg.Server.Port) + } + if cfg.Scanner.FuzzyThreshold != 0.85 { + t.Errorf("expected default fuzzy_threshold 0.85, got %f", cfg.Scanner.FuzzyThreshold) + } +} + +func TestLoadConfig_MissingFile(t *testing.T) { + _, err := LoadConfig("/nonexistent/path/config.yaml") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestLoadConfig_MalformedYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +server: + host: [invalid + yaml: {broken +` + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("expected error for malformed YAML, got nil") + } +} + +func TestLoadConfig_InvalidPort(t *testing.T) { + tests := []struct { + name string + port int + }{ + {"port zero with explicit 0 and no default override", 0}, + {"port too high", 70000}, + {"port negative", -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +navidrome: + url: "http://localhost:4533" + user: "u" + password: "p" + +musicbrainz: + user_agent: "NaviWatcher/1.0 ( test@example.com )" + +server: + port: ` + string(rune('0'+tt.port%10)) + ` +` + // Use Sprintf for proper port formatting + yaml = buildConfigWithPort(path, tt.port) + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + _, err := LoadConfig(path) + if tt.port == 0 { + // port 0 gets defaulted to 8080, which is valid + if err != nil { + t.Fatalf("port 0 should get default, got error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error for port %d, got nil", tt.port) + } + }) + } +} + +func buildConfigWithPort(path string, port int) string { + return "navidrome:\n url: \"http://localhost:4533\"\n user: \"u\"\n password: \"p\"\n\nmusicbrainz:\n user_agent: \"NaviWatcher/1.0 ( test@example.com )\"\n\nserver:\n port: " + fmt.Sprintf("%d", port) + "\n" +} + +func TestLoadConfig_InvalidThreshold(t *testing.T) { + tests := []struct { + name string + threshold float64 + }{ + {"threshold too high", 1.5}, + {"threshold negative", -0.1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := "navidrome:\n url: \"http://localhost:4533\"\n user: \"u\"\n password: \"p\"\n\nmusicbrainz:\n user_agent: \"NaviWatcher/1.0 ( test@example.com )\"\n\nscanner:\n fuzzy_threshold: " + fmt.Sprintf("%.1f", tt.threshold) + "\n" + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + _, err := LoadConfig(path) + if err == nil { + t.Fatalf("expected error for threshold %f, got nil", tt.threshold) + } + }) + } +} + +func TestValidate_MissingNavidromeURL(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +navidrome: + user: "u" + password: "p" + +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) + } + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("expected error for missing navidrome.url, got nil") + } +} + +func TestValidate_MissingNavidromeUser(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +navidrome: + url: "http://localhost:4533" + password: "p" + +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) + } + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("expected error for missing navidrome.user, got nil") + } +} + +func TestValidate_MissingNavidromePassword(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +navidrome: + url: "http://localhost:4533" + user: "u" + +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) + } + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("expected error for missing navidrome.password, got nil") + } +} + +func TestValidate_MissingMusicBrainzUserAgent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +navidrome: + url: "http://localhost:4533" + user: "u" + password: "p" + +musicbrainz: + cache_ttl: 24h +` + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("expected error for missing musicbrainz.user_agent, got nil") + } +} + +func TestValidate_BoundaryThreshold(t *testing.T) { + tests := []struct { + name string + threshold float64 + wantErr bool + }{ + {"threshold 0.0", 0.0, false}, + {"threshold 1.0", 1.0, false}, + {"threshold 0.5", 0.5, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := "navidrome:\n url: \"http://localhost:4533\"\n user: \"u\"\n password: \"p\"\n\nmusicbrainz:\n user_agent: \"NaviWatcher/1.0 ( test@example.com )\"\n\nscanner:\n fuzzy_threshold: " + fmt.Sprintf("%.2f", tt.threshold) + "\n" + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + _, err := LoadConfig(path) + if tt.wantErr && err == nil { + t.Fatalf("expected error for threshold %f, got nil", tt.threshold) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error for threshold %f: %v", tt.threshold, err) + } + }) + } +} + +func TestValidate_BoundaryPort(t *testing.T) { + tests := []struct { + name string + port int + wantErr bool + }{ + {"port 1", 1, false}, + {"port 65535", 65535, false}, + {"port 8080", 8080, false}, + {"port 65536", 65536, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := buildConfigWithPort(path, tt.port) + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + _, err := LoadConfig(path) + if tt.wantErr && err == nil { + t.Fatalf("expected error for port %d, got nil", tt.port) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error for port %d: %v", tt.port, err) + } + }) + } +}