diff --git a/internal/config/config.go b/internal/config/config.go index fddce15..6c4fced 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ type Config struct { WeatherAPI WeatherAPIConfig `yaml:"weather_api"` Location LocationConfig `yaml:"location"` Secrets SecretsConfig `yaml:"secrets"` + Output OutputConfig `yaml:"output"` Notify NotifyConfig `yaml:"notify"` MissingSource MissingSourceConfig `yaml:"missing_source"` Promptkit PromptkitConfig `yaml:"promptkit"` @@ -51,6 +52,10 @@ type SecretsConfig struct { Directory string `yaml:"directory"` } +type OutputConfig struct { + Directory string `yaml:"directory"` +} + type NotifyConfig struct { Distributor DistributorNotifyConfig `yaml:"distributor"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 32232c0..c6f953b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "reflect" + "strconv" "strings" "testing" "time" @@ -37,6 +38,9 @@ func TestDefaults(t *testing.T) { if cfg.Secrets.Directory != "" { t.Fatalf("Secrets.Directory = %q, want empty", cfg.Secrets.Directory) } + if cfg.Output.Directory != "" { + t.Fatalf("Output.Directory = %q, want empty", cfg.Output.Directory) + } if cfg.Notify.Distributor.Enabled { t.Fatalf("Notify.Distributor.Enabled = true, want false") } @@ -78,6 +82,120 @@ func TestDefaults(t *testing.T) { } } +func TestOutputDirectoryLoading(t *testing.T) { + tests := []struct { + name string + yaml string + wantValue string + wantErr string + }{ + { + name: "omitted", + yaml: "{}\n", + wantValue: "", + }, + { + name: "explicit empty", + yaml: ` +output: + directory: "" +`, + wantValue: "", + }, + { + name: "absolute path", + yaml: ` +output: + directory: /var/lib/weatherreporter/reports +`, + wantValue: "/var/lib/weatherreporter/reports", + }, + { + name: "relative path", + yaml: ` +output: + directory: reports/../published +`, + wantValue: "reports/../published", + }, + { + name: "whitespace only", + yaml: ` +output: + directory: " \t " +`, + wantErr: "output.directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := LoadFile(writeConfig(t, tt.yaml)) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("LoadFile() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + if cfg.Output.Directory != tt.wantValue { + t.Fatalf("Output.Directory = %q, want %q", cfg.Output.Directory, tt.wantValue) + } + }) + } +} + +func TestOutputDirectoryValidationIsConsistentForLoadedAndConstructedConfigs(t *testing.T) { + tests := []struct { + name string + directory string + wantErr string + }{ + {name: "empty"}, + {name: "absolute path", directory: "/var/lib/weatherreporter/reports"}, + {name: "relative path", directory: "reports/../published"}, + {name: "whitespace only", directory: " \t ", wantErr: "output.directory"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + yaml := "output:\n directory: " + strconv.Quote(tt.directory) + "\n" + _, loadErr := LoadFile(writeConfig(t, yaml)) + + cfg := Defaults() + cfg.Output.Directory = tt.directory + validateErr := Validate(cfg) + + if (loadErr == nil) != (validateErr == nil) { + t.Fatalf("LoadFile() error = %v, Validate() error = %v", loadErr, validateErr) + } + if tt.wantErr != "" { + if loadErr == nil || !strings.Contains(loadErr.Error(), tt.wantErr) { + t.Fatalf("LoadFile() error = %v, want %q", loadErr, tt.wantErr) + } + if validateErr == nil || !strings.Contains(validateErr.Error(), tt.wantErr) { + t.Fatalf("Validate() error = %v, want %q", validateErr, tt.wantErr) + } + } + }) + } +} + +func TestOutputDirectoryRejectsUnknownFields(t *testing.T) { + _, err := LoadFile(writeConfig(t, ` +output: + location: reports +`)) + if err == nil { + t.Fatal("LoadFile() error = nil, want strict decoding error") + } + if !strings.Contains(err.Error(), "field location not found") { + t.Fatalf("LoadFile() error = %q, want output field rejection", err.Error()) + } +} + func TestLoadExampleConfig(t *testing.T) { cfg, err := LoadFile(filepath.Join("..", "..", "examples", "config.yml")) if err != nil { diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 6ae2c1a..7156e4d 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -21,6 +21,9 @@ func Defaults() Config { Secrets: SecretsConfig{ Directory: "", }, + Output: OutputConfig{ + Directory: "", + }, Notify: NotifyConfig{ Distributor: DistributorNotifyConfig{ Enabled: false, diff --git a/internal/config/validate.go b/internal/config/validate.go index fd6035a..2364ffb 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -15,6 +15,9 @@ func Validate(cfg Config) error { if err := validateReportDistributorPathOverrides(cfg); err != nil { return err } + if cfg.Output.Directory != "" && strings.TrimSpace(cfg.Output.Directory) == "" { + return fmt.Errorf("output.directory must not be blank when configured") + } if cfg.WeatherAPI.BaseURL != "" { parsed, err := url.Parse(cfg.WeatherAPI.BaseURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" {